Profiles

Validate and resolve profiles

Check a profile, merge it with packs and other layers, and produce the resolved form consumers read.

js
import { readFileSync } from "node:fs";
import { createSchemaValidator, loadPacks, resolveService } from "@meantjs/profile";

const validateSchema = await createSchemaValidator();             // the schema the package ships
const packs = await loadPacks();                                  // the standard packs it ships
const author = JSON.parse(readFileSync("local-ledger.profile.json", "utf8"));

const r = await resolveService({
  did: readFileSync("ledger.did", "utf8"),
  didLabel: "ledger.did",
  validateSchema,
  profiles: [{ doc: author, label: "local-ledger.profile.json" }], // applied as given
  packs: packs.map(({ doc, file }) => ({ doc, label: file })),      // applied only if they match
});

console.log("ok", r.ok);
for (const p of r.profiles) console.log(p.status.padEnd(8), p.name.padEnd(13), p.origin);
for (const d of r.diagnostics) console.log(d.severity.padEnd(8), d.code.padEnd(20), d.profile ?? "-", d.at);
text
ok true
applied  local-ledger  author
applied  icrc1         pack
applied  icrc2         pack
info     I_PACK_MATCHED       icrc1 icrc1
info     I_PACK_MATCHED       icrc2 icrc2
warning  W_OVERRIDDEN         icrc1 icrc1_transfer(0).memo
info     I_UNTAGGED_NUMERIC   - icrc21_canister_call_consent_message(0).user_preferences.metadata.utc_offset_minutes

ledger.did is the ICRC ledger interface of the profiles quickstart, and local-ledger.profile.json is a small author profile for it, shown under Layers. The packages are installed with npm install --save-exact @meantjs/profile@0.5.0; Node 22 or later.

resolveService#

resolveService(opts)@meantjs/profile
tsprofile/packages/resolver/src/browser.d.mts
export interface ResolveOptions {
  /** Candid source of the service (exactly one of `did`, `envelope` and `abi`). */
  did?: string;
  /** The service's candid-core ContractEnvelope. */
  envelope?: unknown;
  /** An EVM contract's ABI: human-readable fragments or JSON ABI entries (section 15). */
  abi?: readonly (string | Record<string, unknown>)[];
  didLabel?: string;
  /** Applied as given; a pack here must match. */
  profiles?: { doc: unknown; label?: string }[];
  /** Tried by Candid subtyping (an EVM pack by selector); applied only if they match. */
  packs?: { doc: unknown; label?: string }[];
  validateSchema: SchemaValidator;
}

The interface comes in one of three ways: did, Candid source compiled here with the pinned @candid-core/cli; envelope, a Contract another tool already wrote; or abi, an EVM contract's ABI (experimental). Passing none or more than one throws a TypeError; everything else is reported as a diagnostic. didLabel is how diagnostics about the input name it.

profiles are applied as given. A canister profile there is checked against its binding; a pack there has to match the service, or it is EE_PACK_NOT_MATCHED. packs are candidates: each is tried by Candid subtyping against its binds.requires and applied only if it matches, with II_PACK_MATCHED or II_PACK_NOT_MATCHED. A pack for the other kind of interface (the ERC-20 pack, on a Candid service) is skipped without a diagnostic, which is why loadPacks() can return all four standard packs. The same document given in both lists is applied once.

ResolveResult@meantjs/profile
tsprofile/packages/resolver/src/browser.d.mts
export interface ResolveResult {
  ok: boolean;
  /** null when the service does not compile (or the envelope cannot be read). */
  resolved: ResolvedForm | null;
  diagnostics: Diagnostic[];
  profiles: ProfileStatus[];
}

ok is false when any diagnostic is an error. resolved is still there when a profile has errors; it is null only when the interface itself cannot be read (EE_CANDID, EE_ENVELOPE, EE_ABI). profiles lists every profile it was given, applied ones first in precedence order, each with a status: applied, rebound (bound to another interface_id, but every address and tag still holds), stale (something no longer holds: its value-conversion tags are left out), not_matched (a pack that does not apply) or invalid (it fails the schema, or is for the other kind of interface, and is left out).

The Node entry and the browser entry#

ImportRuns onWhat it adds
@meantjs/profileNode 22 or laterEverything below, plus what reads files: loadPacks(dir?), createSchemaValidator taking a path or a document, DEFAULT_PACKS_DIR, DEFAULT_SCHEMA_PATH, and the CLI as runCli(argv, io) and USAGE.
@meantjs/profile/browserBrowser bundlesThe same resolver with no Node built-in: the shipped schema as PROFILE_SCHEMA, the packs as STANDARD_PACKS, initCompiler for the cli's wasm. createSchemaValidator takes a document, never a path.
@meantjs/profile/addressAnywhere, no dependencyThe address grammar and the field-name hash: parseAddress, parseRelPath, parsePath, idlHash, U32_MAX.
@meantjs/profile/evmAnywhere, no dependencyThe EVM toolkit under the abi input (experimental).
js
import { createSchemaValidator, initCompiler, resolveService, STANDARD_PACKS } from "@meantjs/profile/browser";

// Only when your bundler moves the cli's candid_core_wasm_bg.wasm: say where it put it.
await initCompiler(new URL("./candid_core_wasm_bg.wasm", import.meta.url));

const did = `service : {
  icrc1_decimals : () -> (nat8) query;
  icrc1_symbol : () -> (text) query;
}`;
const validateSchema = await createSchemaValidator(); // the shipped schema, compiled ahead of time
const r = await resolveService({ did, validateSchema, packs: STANDARD_PACKS.map(({ doc }) => ({ doc })) });

Without initCompiler, the wasm compiler initializes itself on first use, from the file next to the module. fact The shipped schema is compiled ahead of time, so a page needs no 'unsafe-eval' in its Content-Security-Policy; the cli's wasm needs 'wasm-unsafe-eval' in script-src. Only a schema document other than the shipped one is compiled at run time, with new Function, and needs 'unsafe-eval'. The package's tests check both, in Node with code generation from strings disabled and in Chromium under script-src 'self' 'wasm-unsafe-eval' (test/csp.test.mjs, which skips the Chromium half where no Chromium is installed; see the README).

Layers#

jsonlocal-ledger.profile.json
{
  "format": "candid-profile",
  "format_version": 0,
  "kind": "canister",
  "name": "local-ledger",
  "origin": "author",
  "binds": { "interface_id": "candid-core:interface:v1:sha256:22eb4787e1a755acb9aabd0ed2121d754098bcd1cedb5d0ed8b5c4effb921846" },
  "annotations": [
    { "at": "icrc1_transfer(0).memo", "tag": "bytes", "args": { "encoding": "hex" } },
    { "at": "icrc1_transfer", "tag": "describe", "args": { "label": "Send ICP" } }
  ]
}
bash
npx candid-profile resolve ledger.did --profile local-ledger.profile.json --packs > merged.json
text
candid-profile: ledger.did  candid-core:interface:v1:sha256:22eb4787e1a755acb9aabd0ed2121d754098bcd1cedb5d0ed8b5c4effb921846  [26 methods]
  applied     local-ledger     author       candid-profile:v0:sha256:3ab093c4e8583fc5e2efb4e7c8ce595457450bd38c7be6959998c0d17eab3b84
  applied     icrc1            pack         candid-profile:v0:sha256:4eaa9312d60661248ff5627dba9906aa91240abe84cb1b1da8ca20a6fba2e3e6
  applied     icrc2            pack         candid-profile:v0:sha256:f9d75e8bf5ff2edb0b167988feee494eeb54c386e6f62252c4bd96ac56346567
  0 error, 1 warning, 3 info
  warning W_OVERRIDDEN               [icrc1] icrc1_transfer(0).memo  bytes from profile "icrc1" (pack) is overridden by profile "local-ledger" (author) at "icrc1_transfer(0).memo" [icrc1_transfer(0).memo]
bash
node -p 'JSON.stringify(require("./merged.json").methods.icrc1_transfer.args[0].fields.memo.tags, null, 2)'
jsonmerged.json
{
  "bytes": {
    "args": {
      "encoding": "hex"
    },
    "origin": "author",
    "profile": "local-ledger"
  },
  "default": {
    "args": {
      "value": "null",
      "mode": "suggest"
    },
    "origin": "pack",
    "profile": "icrc1"
  },
  "describe": {
    "args": {
      "label": "Memo",
      "help": "Transfers with different memos are never deduplicated against each other. …"
    },
    "origin": "pack",
    "profile": "icrc1"
  }
}

Every profile belongs to a layer by its origin, highest first: author, third_party, pack, inferred (the LAYERS export). Merging is per tag and per position. The highest layer that has a tag at a position supplies it; the same tag lower down is dropped and reported with WW_OVERRIDDEN, on the profile that lost. Other tags at that position stay, so the memo above keeps the pack's default and describe under the author's bytes. The order you pass the profiles in does not change which layer wins, and the layer decides before specificity: an author tag through a type: address beats a pack tag through a method address.

Within one layer, two profiles that disagree are an error:

bash
npx candid-profile resolve ledger.did --profile local-ledger.profile.json --profile second-author.profile.json --packs > conflict.json
text
candid-profile: ledger.did  candid-core:interface:v1:sha256:22eb4787e1a755acb9aabd0ed2121d754098bcd1cedb5d0ed8b5c4effb921846  [26 methods]
  applied     local-ledger     author       candid-profile:v0:sha256:3ab093c4e8583fc5e2efb4e7c8ce595457450bd38c7be6959998c0d17eab3b84
  applied     second-author    author       candid-profile:v0:sha256:2e7ecfa9cf34d4d93438f1fa78140134bc7a9c8c2b0fae1eebcb7659c93b4baa
  applied     icrc1            pack         candid-profile:v0:sha256:4eaa9312d60661248ff5627dba9906aa91240abe84cb1b1da8ca20a6fba2e3e6
  applied     icrc2            pack         candid-profile:v0:sha256:f9d75e8bf5ff2edb0b167988feee494eeb54c386e6f62252c4bd96ac56346567
  1 error, 1 warning, 3 info
  error   E_CONFLICT                 [second-author] icrc1_transfer(0).memo  two different bytes annotations at the same position (profile "local-ledger" "icrc1_transfer(0).memo" and profile "second-author" "icrc1_transfer(0).memo")
  warning W_OVERRIDDEN               [icrc1] icrc1_transfer(0).memo  bytes from profile "icrc1" (pack) is overridden by profile "local-ledger" (author) at "icrc1_transfer(0).memo" [icrc1_transfer(0).memo]

second-author.profile.json is the same kind of author profile, with "encoding": "base64" for the memo. Two different values for one tag at one position, at the same specificity in one layer, are EE_CONFLICT, and the first one given stays: the resolved form keeps hex and the command exits with status 1. Equal values are only WW_DUPLICATE. Inside a layer, a method address beats a type: address at the same position, silently, so a profile can tag a whole declaration and then make one exception. A conflict is found whatever the spelling: icrc1_transfer(0).amount and icrc1_transfer(0).#3573748184 are one position.

The doc comments of a .did make an author profile too (Annotate a .did file); pass it in profiles beside a profile file and the two merge by these rules. Why the layers are ordered this way, and what an inferred profile needs before it applies, is on Profiles, layers and binding.

The resolved form#

r.resolved is the document every consumer reads: format ("candid-profile-resolved"), format_version, interface_id, profiles (each applied profile's name, origin and profile_id, highest layer first), methods (one tree per method, with each position's tags) and diagnostics. Each tag says where it came from: { "args": …, "origin": "pack", "profile": "icrc1" }. A method that no profile gives an effect still has one, the default for its mode, with "origin": "default" and no profile. Dynamic values such as { "from": "query:icrc1_decimals" } stay unresolved; fetching them is the consumer's job. Every field is on Profile and resolved form.

Diagnostics#

tsprofile/packages/resolver/src/browser.d.mts
export interface Diagnostic {
  severity: "error" | "warning" | "info";
  code: string;
  /** A JSON pointer for E_SCHEMA, an address (`send(0).to`, `type:Tokens.e8s`) or a document path (`binds.interface_id`). */
  at: string;
  message: string;
  /** The profile's name, when the diagnostic comes from one. */
  profile?: string;
}

The codes are stable; the messages are for people. A profile that fails the JSON Schema gets EE_SCHEMA and is left out with status invalid, and the rest of the service resolves without it. A canister profile bound to another interface gets WW_REBOUND, then either nothing more or EE_STALE with the specific errors. Every code, with its severity and meaning, is on Profile diagnostics.

Resolve from a ContractEnvelope#

bash
npx candid-core-cli gen tokens.did -o gen
npx candid-profile resolve --envelope gen/tokens.envelope.json --profile tokens.profile.json > tokens.resolved.json
text
contract:  candid-core:contract:v1:sha256:9f5c81035f14d348d05279bf3683ef288679897e69dcb752b74250afc558ae67
interface: candid-core:interface:v1:sha256:f8358625dd47a9b6a237a3a268ba83291fcd0dc27093e80ac27cc48a49dc1d6c
wrote gen/tokens.ts
wrote gen/tokens.envelope.json
candid-profile: gen/tokens.envelope.json  candid-core:interface:v1:sha256:f8358625dd47a9b6a237a3a268ba83291fcd0dc27093e80ac27cc48a49dc1d6c  [2 methods]
  applied     tokens-example   author       candid-profile:v0:sha256:70abc8cbce701650b6adafeabbf636a10b9c80e620bfcb6cc2d8793ee027745c
  0 error, 0 warning, 0 info
js
import { readFileSync } from "node:fs";
import { createSchemaValidator, resolveService } from "@meantjs/profile";

const validateSchema = await createSchemaValidator();
const envelope = JSON.parse(readFileSync("gen/tokens.envelope.json", "utf8")); // written by candid-core-cli gen
const doc = JSON.parse(readFileSync("tokens.profile.json", "utf8"));

const r = await resolveService({ envelope, didLabel: "gen/tokens.envelope.json", validateSchema, profiles: [{ doc }] });

An app that generates its TypeScript with candid-core-cli gen already has the service's Contract, in the envelope written beside the module. Pass it as envelope (--envelope on the command line) and the service is not compiled again (decision D-039). The resolved form is the same as from the .did: the run above writes a file byte for byte equal to the one resolve tokens.did writes, and its interface_id is the envelope's contract.identities.interface, the id the app's generator wrote.

The envelope's identities are taken as written: nothing here recomputes them, so a hand-edited envelope keeps an id that no longer describes it. Its graph is checked before anything walks it. One that cannot be read is EE_ENVELOPE, with resolved: null:

text
  1 error, 0 warning, 0 info
  error   E_ENVELOPE                 gen/broken.envelope.json  invalid_envelope: contract.format_version is 2, this resolver reads version 1

Packs still compile their own binds.requires, so @candid-core/cli stays a dependency either way.

Validate one profile#

js
import { readFileSync } from "node:fs";
import { createSchemaValidator, validateProfile } from "@meantjs/profile";

const validateSchema = await createSchemaValidator();
const doc = JSON.parse(readFileSync("tokens.profile.json", "utf8"));
const v = await validateProfile(doc, { validateSchema, did: readFileSync("tokens.did", "utf8"), didLabel: "tokens.did" });
console.log(v.ok, v.profile_id, v.interface_id, v.methods, v.annotations, v.diagnostics);
text
true candid-profile:v0:sha256:70abc8cbce701650b6adafeabbf636a10b9c80e620bfcb6cc2d8793ee027745c candid-core:interface:v1:sha256:f8358625dd47a9b6a237a3a268ba83291fcd0dc27093e80ac27cc48a49dc1d6c 2 10 []

validateProfile(doc, { validateSchema, did, didLabel, resolved }) checks a single profile, with no layers and no pack search: a pack against its own binds.requires, a canister profile against the did you give. Without did, a canister profile gets only the schema check and WW_NO_INTERFACE. With resolved: true the result also carries that profile's resolved form. It is what the repository's scripts/validate.mjs wraps.

Other exports#

  • profileId(doc) and jcs(value): a profile's identity, and the RFC 8785 canonical JSON it hashes.
  • compileInterface(source) and interfaceFromEnvelope(envelope): the interface as the resolver sees it.
  • matchPack(iface, pack) and matchService(canister, fragment): the pack match on its own, with the reasons and the relaxed result variants.
  • profileFromDocAttributes(response, opts): an author profile from a .did's doc comments (Annotate a .did file).
  • readServedProfile(bytes, { canisterId }): a profile a canister serves (Serve a profile from a canister).

Every export with its signature is on JavaScript API, and every flag of the command is on The candid-profile command.