@meantjs/profile 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meantjs/profile",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Candid Profile resolver: validates profiles, matches packs by Candid subtyping, merges layers (spec section 8) and emits the resolved form (spec section 10).",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
package/src/analyze.mjs CHANGED
@@ -9,7 +9,7 @@ import { TagIndex, LayeredTags, positions } from "./tags.mjs";
9
9
  import {
10
10
  NUMERIC, METHOD_TAGS, CASE_TAGS, CONFIRM_KINDS, STALE_CODES, VALUE_CONVERSION_TAGS, EVM_LATER_TAGS,
11
11
  checkApplicability, checkDynamic, parseTemplate, outcomeCoverage, caseTargetError, otherCase, overlap, defaultEffect,
12
- tokenPathError,
12
+ tokenPathError, grantError, pinError,
13
13
  } from "./checks.mjs";
14
14
 
15
15
  /** Spec section 8, highest precedence first. */
@@ -29,7 +29,9 @@ function buildLayers(entries, keep = () => true) {
29
29
 
30
30
  /**
31
31
  * @param iface the interface every address resolves against
32
- * @param items [{ doc, pid, rebinding, apply }]; `apply` marks a pack that matched a service and is applied to it
32
+ * @param items [{ doc, pid, rebinding, bound, apply }]; `apply` marks a pack that matched a service and is applied to it;
33
+ * `bound` is { at, id, given }, what a profile binds to against what was given, when it is not `binds.interface_id`
34
+ * against the interface's id (a contract profile binds by its own ABI, spec section 15.3)
33
35
  * @param opts.attribute add `profile` to each diagnostic (multi-profile output)
34
36
  * @returns { diagnostics, layered, ordered, stale } `ordered` is items by precedence; `stale` the stale items
35
37
  */
@@ -44,11 +46,24 @@ export function analyzeProfiles(iface, items, { attribute = true } = {}) {
44
46
  item.diags.push(d);
45
47
  };
46
48
  }
47
- const interfaceId = iface.interfaceId;
49
+ const boundOf = (item) => item.bound ?? { at: "binds.interface_id", id: item.doc.binds.interface_id, given: iface.interfaceId };
50
+
51
+ // A pin (`fixed`, spec 15.4, D-058) holds the value the profile's words were written for. A profile with a pin that
52
+ // does not hold keeps only its effects, without grants, and its pins that hold: never its words without the pin. A
53
+ // pin's problem is always an error, never a pack annotation skipped.
54
+ const unpinned = new Set();
55
+ const pinFailed = (item, code, at, message) => {
56
+ unpinned.add(item);
57
+ item.diag("error", code, at, `${message}; this profile keeps only its effects (without grants) and its pins that hold, since its words were written for the pinned value (spec section 15.4)`);
58
+ };
59
+ const pins = new Map(); // position key -> { value, item, at }: every profile pins a position to one value (spec 15.4)
60
+ const badPins = new Set(); // pin entries that do not hold, left out of the resolved form
48
61
 
49
62
  for (const item of ordered) {
50
63
  if (item.rebinding) {
51
- item.diag("warning", "W_REBOUND", "binds.interface_id", `profile is bound to ${item.doc.binds.interface_id} but the given interface is ${interfaceId}; checking whether every address still resolves`);
64
+ const { at, id, given } = boundOf(item);
65
+ const what = at === "binds.abi" ? "ABI" : "interface";
66
+ item.diag("warning", "W_REBOUND", at, `profile is bound to ${id} but the given ${what} is ${given}; checking whether every address still resolves`);
52
67
  }
53
68
  }
54
69
 
@@ -64,16 +79,28 @@ export function analyzeProfiles(iface, items, { attribute = true } = {}) {
64
79
  let a = original;
65
80
  const at = a.at;
66
81
  const addr = parseAddress(at);
67
- if (!addr) { diag("error", "E_ADDR_SYNTAX", at, "address does not parse"); continue; }
68
- if (doc.kind === "pack" && addr.kind === "type") { diag("error", "E_PACK_TYPE_REF", at, "packs must use method addresses (declaration names are not standardized)"); continue; }
82
+ const pinned = a.tag === "fixed";
83
+ const fail = pinned ? (code, message) => pinFailed(item, code, at, message) : (code, message) => diag("error", code, at, message);
84
+ if (!addr) { fail("E_ADDR_SYNTAX", "address does not parse"); continue; }
85
+ if (doc.kind === "pack" && addr.kind === "type") { fail("E_PACK_TYPE_REF", "packs must use method addresses (declaration names are not standardized)"); continue; }
69
86
  const res = iface.resolve(addr);
70
87
  if (res.error) {
71
88
  // A pack applied to a service that is a narrower subtype (fewer result cases, fewer optional fields) cannot
72
89
  // address what is not there. That is expected, not an error in the pack.
73
- if (item.apply) diag("info", "I_PACK_ANNOTATION_SKIPPED", at, `${a.tag}: ${res.error}`);
74
- else diag("error", res.code ?? "E_ADDR_UNRESOLVED", at, res.error);
90
+ if (item.apply && !pinned) diag("info", "I_PACK_ANNOTATION_SKIPPED", at, `${a.tag}: ${res.error}`);
91
+ else fail(res.code ?? "E_ADDR_UNRESOLVED", res.error);
75
92
  continue;
76
93
  }
94
+ if (pinned) {
95
+ const prev = pins.get(res.key);
96
+ const value = String(a.args.value).toLowerCase();
97
+ if (prev && prev.value !== value) {
98
+ const where = prev.item === item ? `"${prev.at}"` : `profile "${prev.item.doc.name}" "${prev.at}"`;
99
+ pinFailed(item, "E_CONFLICT", at, `fixed pins this position to ${a.args.value}, where ${where} pins it to another value`);
100
+ continue;
101
+ }
102
+ if (!prev) pins.set(res.key, { value, item, at });
103
+ }
77
104
  if (addr.kind === "type") {
78
105
  const others = (iface.aliases.get(iface.decls.get(addr.decl)) ?? []).filter((n) => n !== addr.decl);
79
106
  if (others.length) diag("warning", "W_STRUCTURAL_ALIAS", at, `declaration "${addr.decl}" is structurally identical to ${others.map((n) => `"${n}"`).join(", ")}; the tag reaches every one of them`);
@@ -147,6 +174,18 @@ export function analyzeProfiles(iface, items, { attribute = true } = {}) {
147
174
  }
148
175
  };
149
176
 
177
+ /**
178
+ * A grant that does not hold (spec 5.3, D-056). A pack applied to a service loses only its grant, never the effect:
179
+ * dropping the effect would leave a transfer or approve with the default effect, which needs no confirmation.
180
+ */
181
+ const grants = []; // effects whose grant holds, checked again once dropped tags are known
182
+ const badGrant = (e, diag, why) => {
183
+ if (!e.item.apply) return diag("error", "E_GRANT", e.ann.at, why);
184
+ const { grant, ...args } = e.entry.args;
185
+ e.entry.args = args;
186
+ e.item.diag("info", "I_PACK_ANNOTATION_SKIPPED", e.ann.at, `effect grant dropped, the effect kept: E_GRANT: ${why}`);
187
+ };
188
+
150
189
  const sets = new Map(); // names set -> { at, method, fields: { name, value, show?, kind? } } (spec 5.3, D-045)
151
190
  const named = []; // named positions, checked once every set is known
152
191
  for (const e of entries) {
@@ -167,8 +206,10 @@ export function analyzeProfiles(iface, items, { attribute = true } = {}) {
167
206
  const why = caseTargetError(iface, addr, res, a.tag);
168
207
  if (why) { diag("error", "E_TAG_TARGET", at, why); continue; }
169
208
  const m = iface.methods.get(addr.method);
170
- if (a.tag === "effect") checkEffectMode(m, a.args, diag, at);
171
- else checkTemplate(item, addr.method, a.args.template, diag, at, addr);
209
+ if (a.tag === "effect") {
210
+ checkEffectMode(m, a.args, diag, at);
211
+ if (a.args.grant) badGrant(e, diag, "a grant sits on the effect of a method, not of a case, in this version (D-056)");
212
+ } else checkTemplate(item, addr.method, a.args.template, diag, at, addr);
172
213
  caseEntries.push({ e, diag });
173
214
  continue;
174
215
  }
@@ -176,6 +217,11 @@ export function analyzeProfiles(iface, items, { attribute = true } = {}) {
176
217
  const m = res.method;
177
218
  const methodName = addr.method;
178
219
  if (a.tag === "effect") checkEffectMode(m, a.args, diag, at);
220
+ if (a.tag === "effect" && a.args.grant) {
221
+ const why = grantError(iface, tags, methodName, a.args);
222
+ if (why) badGrant(e, diag, why);
223
+ else grants.push({ e, diag });
224
+ }
179
225
  if (a.tag === "idempotency") {
180
226
  if (m.mode !== "update") diag("error", "E_IDEMPOTENCY_MODE", at, "idempotency applies to update methods");
181
227
  const r = iface.resolveRel(methodName, "args", a.args.stamp);
@@ -233,6 +279,14 @@ export function analyzeProfiles(iface, items, { attribute = true } = {}) {
233
279
  }
234
280
  continue;
235
281
  }
282
+ if (a.tag === "fixed") {
283
+ const why = pinError(iface, addr, res, a.args.value);
284
+ if (why) {
285
+ badPins.add(e);
286
+ pinFailed(item, why.code, at, why.message);
287
+ }
288
+ continue;
289
+ }
236
290
  if (res.method) {
237
291
  if (a.tag !== "describe") diag("error", "E_TAG_TARGET", at, `${a.tag} is a value tag; address a value, not a method`);
238
292
  continue;
@@ -289,9 +343,14 @@ export function analyzeProfiles(iface, items, { attribute = true } = {}) {
289
343
  }
290
344
  }
291
345
 
292
- // Dropping a value tag can leave an idempotency stamp without its timestamp tag: drop that idempotency too.
346
+ // Dropping a value tag can leave an idempotency stamp without its timestamp tag: drop that idempotency too. It can
347
+ // also leave a grant without the amount, timestamp or account tag it needs.
293
348
  if (dropped.size) {
294
349
  const without = buildLayers(entries, (e) => !dropped.has(e));
350
+ for (const { e, diag } of grants) {
351
+ const why = !dropped.has(e) && grantError(iface, without, e.addr.method, e.entry.args);
352
+ if (why) badGrant(e, diag, `${why} (a tag it needs was dropped in this service)`);
353
+ }
295
354
  for (const e of entries) {
296
355
  if (!e.item.apply || e.ann.tag !== "idempotency" || dropped.has(e)) continue;
297
356
  const r = iface.resolveRel(e.addr.method, "args", e.ann.args.stamp);
@@ -302,12 +361,19 @@ export function analyzeProfiles(iface, items, { attribute = true } = {}) {
302
361
  }
303
362
  }
304
363
 
305
- // Binding (spec section 7): a canister profile whose interface changed is stale when anything no longer holds.
364
+ // Binding (spec sections 7 and 15.3): a canister or contract profile whose interface changed is stale when anything
365
+ // no longer holds.
306
366
  const stale = new Set(ordered.filter((item) => item.rebinding && item.diags.some((d) => STALE_CODES.has(d.code))));
307
367
  // A stale profile must not supply value-conversion tags, and a dropped pack annotation is not applied: keep both
308
- // out of the merged result.
309
- const keep = (e) => !dropped.has(e) && !(stale.has(e.item) && VALUE_CONVERSION_TAGS.has(e.ann.tag));
310
- const final = stale.size || dropped.size ? buildLayers(entries, keep) : tags;
368
+ // out of the merged result. A profile with a pin that does not hold supplies its effects, without grants, and its
369
+ // pins that hold (spec 15.4, D-058).
370
+ for (const e of entries) {
371
+ if (!unpinned.has(e.item) || e.ann.tag !== "effect" || e.entry.args.grant === undefined) continue;
372
+ const { grant, ...args } = e.entry.args;
373
+ e.entry.args = args;
374
+ }
375
+ const keep = (e) => !dropped.has(e) && !badPins.has(e) && !(stale.has(e.item) && VALUE_CONVERSION_TAGS.has(e.ann.tag)) && !(unpinned.has(e.item) && e.ann.tag !== "effect" && e.ann.tag !== "fixed");
376
+ const final = stale.size || dropped.size || unpinned.size ? buildLayers(entries, keep) : tags;
311
377
 
312
378
  // Overrides: the same tag at the same position in a lower layer (W_OVERRIDDEN, spec section 8).
313
379
  if (final.layers.length > 1) {
@@ -348,7 +414,7 @@ export function analyzeProfiles(iface, items, { attribute = true } = {}) {
348
414
  }
349
415
 
350
416
  for (const item of stale) {
351
- item.diag("error", "E_STALE", "binds.interface_id", "the interface changed and some annotations no longer hold; value-conversion tags must not be used (the resolved form omits this profile's value-conversion tags)");
417
+ item.diag("error", "E_STALE", boundOf(item).at, "the interface changed and some annotations no longer hold; value-conversion tags must not be used (the resolved form omits this profile's value-conversion tags)");
352
418
  }
353
419
  return { diagnostics, layered: final, ordered, stale };
354
420
  }
package/src/browser.mjs CHANGED
@@ -35,7 +35,8 @@ export const PROFILE_SCHEMA = schema;
35
35
 
36
36
  /**
37
37
  * The standard packs (copies of packs/*.profile.json whose kind is "pack"), sorted by file name, in the shape
38
- * `loadPacks` returns. The standard contract profiles there (Permit2, DAI's permit) are under `./packs/*` too.
38
+ * `loadPacks` returns. The standard contract profiles there (Permit2, DAI's permit, USDC, x402's Permit2 payment) are
39
+ * under `./packs/*` too.
39
40
  */
40
41
  export const STANDARD_PACKS = Object.freeze([
41
42
  { file: "erc20.profile.json", doc: erc20 },
package/src/checks.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Per-annotation semantic checks (spec sections 3 to 6). Moved verbatim from scripts/validate.mjs.
3
3
  */
4
+ import { AbiError, readAddress } from "./abi.mjs";
4
5
  import { parseRelPath } from "./address.mjs";
5
6
 
6
7
  /** EVM integer types (spec section 15): uint8 to uint256 and int8 to int256. int8 to int64 are also Candid's, with the same bounds. */
@@ -18,7 +19,25 @@ export const CASE_TAGS = new Set(["intent", "effect"]);
18
19
  export const CONFIRM_KINDS = new Set(["transfer", "approve", "admin", "destroy"]);
19
20
  /** Tags that convert a value between its wire form and a human form; a stale profile must not supply them (spec section 7). */
20
21
  export const VALUE_CONVERSION_TAGS = new Set(["amount", "timestamp", "duration", "account", "subaccount", "bytes", "named"]);
21
- export const STALE_CODES = new Set(["E_ADDR_UNRESOLVED", "E_TYPE_NODE", "E_TAG_NOT_APPLICABLE", "E_REF_UNRESOLVED", "E_REF_NOT_QUERY", "E_REF_SHAPE", "E_REF_TYPE", "E_STAMP_UNTAGGED", "E_OUTCOME_NOT_CASE", "E_TEMPLATE_PATH", "E_TAG_TARGET", "E_NAMES_MODE", "E_NAMES_SHAPE", "E_NAMES_FIELD", "E_NAMED_SET", "E_NAMED_TYPE", "E_CASE_OVERLAP", "E_CASE_EFFECT", "E_TOKEN_PATH"]);
22
+ export const STALE_CODES = new Set(["E_ADDR_UNRESOLVED", "E_TYPE_NODE", "E_TAG_NOT_APPLICABLE", "E_REF_UNRESOLVED", "E_REF_NOT_QUERY", "E_REF_SHAPE", "E_REF_TYPE", "E_STAMP_UNTAGGED", "E_OUTCOME_NOT_CASE", "E_TEMPLATE_PATH", "E_TAG_TARGET", "E_NAMES_MODE", "E_NAMES_SHAPE", "E_NAMES_FIELD", "E_NAMED_SET", "E_NAMED_TYPE", "E_CASE_OVERLAP", "E_CASE_EFFECT", "E_TOKEN_PATH", "E_GRANT"]);
23
+
24
+ /**
25
+ * Why a pin (`fixed`, spec 15.4, D-058) does not hold, as `{ code, message }`, or null. A pin sits on an `address` in the
26
+ * arguments of an EVM function or message, where a consumer reads it from what is sent or signed before any text, and
27
+ * its value is an address (§15.2: in mixed case, with its EIP-55 checksum).
28
+ */
29
+ export function pinError(iface, addr, res, value) {
30
+ if (res.method || addr.kind !== "arg") return { code: "E_TAG_TARGET", message: "fixed pins a value in the arguments of a function or a message, where a consumer reads it before it shows text" };
31
+ const wrong = checkApplicability(iface, "fixed", res.ref);
32
+ if (wrong) return { code: "E_TAG_NOT_APPLICABLE", message: wrong };
33
+ try {
34
+ readAddress(String(value));
35
+ } catch (e) {
36
+ if (!(e instanceof AbiError)) throw e;
37
+ return { code: "E_ADDRESS_CHECKSUM", message: `fixed: ${e.message}` };
38
+ }
39
+ return null;
40
+ }
22
41
 
23
42
  export function checkApplicability(iface, tag, ref) {
24
43
  const { ref: r } = iface.unwrapOpt(ref);
@@ -33,6 +52,9 @@ export function checkApplicability(iface, tag, ref) {
33
52
  case "bytes": return iface.isBlob(r) ? null : `${tag} needs ${iface.chain === "evm" ? "bytes or bytesN" : "blob"}, found ${iface.describeType(ref)}`;
34
53
  case "text": return TEXT.has(p) ? null : `text needs ${iface.chain === "evm" ? "string" : "text"}, found ${iface.describeType(ref)}`;
35
54
  case "range": return NUMERIC.has(p) || TEXT.has(p) || iface.node(r).kind === "vec" ? null : `range needs numeric, text or vec, found ${iface.describeType(ref)}`;
55
+ case "fixed":
56
+ if (iface.chain !== "evm") return "fixed pins an EVM address; a Candid position cannot carry it yet (spec section 15.4)";
57
+ return p === "address" ? null : `fixed needs an address, found ${iface.describeType(ref)}`;
36
58
  default: return null;
37
59
  }
38
60
  }
@@ -57,6 +79,55 @@ export function tokenPathError(iface, addr, token) {
57
79
  return null;
58
80
  }
59
81
 
82
+ /**
83
+ * An effect's grant (spec 5.3, D-056): who may take the tokens, how many and until when, as positions in the arguments
84
+ * of the method the effect is on. `tags` is the merged view: the amount, timestamp and account tags a grant needs may
85
+ * come from another profile. The reason it is wrong, or null.
86
+ */
87
+ export function grantError(iface, tags, methodName, { kind, grant }) {
88
+ if (kind !== "transfer" && kind !== "approve") return `a grant goes with effect kind transfer or approve, not ${kind}`;
89
+ if ((grant.spender === undefined) === (grant.to === undefined)) return "a grant names exactly one of spender and to";
90
+ if (kind === "approve" && grant.to !== undefined) return "an approve's grant names a spender: an allowance is taken by its spender, not paid to it";
91
+ const at = (name, rel, mayBeAbsent) => {
92
+ const p = parseRelPath(rel);
93
+ const r = iface.resolveRel(methodName, "args", rel);
94
+ const what = `grant ${name} ${JSON.stringify(rel)}`;
95
+ if (!p || r.error) return { error: `${what}: ${r.error ?? "not a relative path"}` };
96
+ if (p.segs.some((s) => s.kind === "elem")) return { error: `${what} steps into []: a grant covers one token, so a request that grants several has none` };
97
+ // The value must be in every call: no case of a variant on the way, and no opt unless it may be left out.
98
+ let cur = iface.methods.get(methodName).args[p.index ?? 0];
99
+ let optional = false;
100
+ for (const s of p.segs) {
101
+ const u = iface.unwrapOpt(cur);
102
+ optional ||= u.optional;
103
+ const n = iface.node(u.ref);
104
+ if (n.kind === "variant") return { error: `${what} passes through a case of a variant, so a call need not hold it` };
105
+ cur = n.fields.find((f) => f.id === s.id).type;
106
+ }
107
+ if ((optional || iface.unwrapOpt(cur).optional) && !mayBeAbsent) return { error: `${what} is optional, but a grant needs its ${name} in every call` };
108
+ return { r, t: tags.at(r, iface) ?? {} };
109
+ };
110
+ const who = grant.spender !== undefined ? "spender" : "to";
111
+ const party = at(who, grant[who], false);
112
+ if (party.error) return party.error;
113
+ if (iface.prim(party.r.ref) !== "address" && !party.t.account) {
114
+ return `grant ${who} ${JSON.stringify(grant[who])} is ${iface.describeType(party.r.ref)}; it must be an address or carry an account tag`;
115
+ }
116
+ const amount = at("amount", grant.amount, false);
117
+ if (amount.error) return amount.error;
118
+ const p = iface.prim(amount.r.ref);
119
+ if (p !== "bool") {
120
+ if (!amount.t.amount) return `grant amount ${JSON.stringify(grant.amount)} is ${iface.describeType(amount.r.ref)} without an amount tag; it must carry one, or be a bool`;
121
+ if (!/^(nat|uint)/.test(p)) return `grant amount ${JSON.stringify(grant.amount)} is ${p}; an amount granted is never negative, so its type is unsigned`;
122
+ }
123
+ for (const rel of grant.ends ?? []) {
124
+ const end = at("ends", rel, true);
125
+ if (end.error) return end.error;
126
+ if (!end.t.timestamp) return `grant ends ${JSON.stringify(rel)} has no timestamp tag`;
127
+ }
128
+ return null;
129
+ }
130
+
60
131
  export function checkDynamic(iface, value, want, diag, at) {
61
132
  if (!value || typeof value !== "object") return;
62
133
  const from = value.from;
package/src/resolve.mjs CHANGED
@@ -141,7 +141,9 @@ async function resolveWith(iface, opts, diagnostics, statuses, rebuild = null) {
141
141
  continue;
142
142
  }
143
143
  checkDeployments(doc, note);
144
- items.push({ doc, pid, rebinding: doc.binds.abi !== undefined && abiInterfaceId(own.functions) !== iface.abiId, apply: false, status, eip712 });
144
+ // It binds by the identity of its own ABI (spec section 15.1), compared with the given ABI's functions only.
145
+ const bound = doc.binds.abi === undefined ? null : { at: "binds.abi", id: abiInterfaceId(own.functions), given: iface.abiId };
146
+ items.push({ doc, pid, rebinding: bound !== null && bound.id !== bound.given, bound, apply: false, status, eip712 });
145
147
  } else if (doc.kind === "pack") {
146
148
  const m = await matchPack(iface, doc);
147
149
  if (m.error) {
package/src/served.mjs CHANGED
@@ -2,11 +2,11 @@
2
2
  * A profile served by its canister (spec section 8.1, D-048): the public custom section `icp:public candid:profile`,
3
3
  * read by a consumer through a certified `read_state` at `/canister/<id>/metadata/candid:profile`.
4
4
  *
5
- * This module reads the section's bytes; it does not fetch them, so the library keeps no agent dependency (the kit's
6
- * `fetch` does the network part). What it checks is what only a served profile can get wrong: the bytes are UTF-8
7
- * JSON of at most 1 MiB, the document is a `canister` profile of origin `author`, and its `binds.canister_id`, when
8
- * given, is the canister that served it. Everything else (the schema, the binding to the interface the same canister
9
- * serves as `candid:service`, every tag) is `resolveService`'s, as for any profile.
5
+ * This module reads the section's bytes; it does not fetch them, so the library keeps no agent dependency (the caller
6
+ * does the network part, for example with `@icp-sdk/core`'s `readState`). What it checks is what only a served profile
7
+ * can get wrong: the bytes are UTF-8 JSON of at most 1 MiB, the document is a `canister` profile of origin `author`,
8
+ * and its `binds.canister_id`, when given, is the canister that served it. Everything else (the schema, the binding to
9
+ * the interface the same canister serves as `candid:service`, every tag) is `resolveService`'s, as for any profile.
10
10
  *
11
11
  * New diagnostic code (spec section 13): E_SERVED.
12
12
  */
@@ -23,7 +23,7 @@ const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
23
23
  /**
24
24
  * @param bytes the content of the canister's `candid:profile` metadata (a Uint8Array)
25
25
  * @param opts.canisterId the textual id of the canister that served it; null only before the module is installed
26
- * anywhere (the kit's `embed`), which leaves `binds.canister_id` unchecked
26
+ * anywhere (a section added to a built module), which leaves `binds.canister_id` unchecked
27
27
  * @param opts.label how diagnostics name the input (default "candid:profile")
28
28
  * @returns { ok, doc, diagnostics } `doc` is the parsed profile, or null when it cannot be used (E_SERVED)
29
29
  */