Profiles
Validate and resolve profiles
Check a profile, merge it with packs and other layers, and produce the resolved form consumers read.
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);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#
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.
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#
| Import | Runs on | What it adds |
|---|---|---|
@meantjs/profile | Node 22 or later | Everything 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/browser | Browser bundles | The 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/address | Anywhere, no dependency | The address grammar and the field-name hash: parseAddress, parseRelPath, parsePath, idlHash, U32_MAX. |
@meantjs/profile/evm | Anywhere, no dependency | The EVM toolkit under the abi input (experimental). |
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#
{
"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" } }
]
}npx candid-profile resolve ledger.did --profile local-ledger.profile.json --packs > merged.jsoncandid-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]node -p 'JSON.stringify(require("./merged.json").methods.icrc1_transfer.args[0].fields.memo.tags, null, 2)'{
"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:
npx candid-profile resolve ledger.did --profile local-ledger.profile.json --profile second-author.profile.json --packs > conflict.jsoncandid-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#
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#
npx candid-core-cli gen tokens.did -o gen
npx candid-profile resolve --envelope gen/tokens.envelope.json --profile tokens.profile.json > tokens.resolved.jsoncontract: 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 infoimport { 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:
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#
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);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)andjcs(value): a profile's identity, and the RFC 8785 canonical JSON it hashes.compileInterface(source)andinterfaceFromEnvelope(envelope): the interface as the resolver sees it.matchPack(iface, pack)andmatchService(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.