@meantjs/profile 0.3.0 → 0.4.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.4.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,
13
13
  } from "./checks.mjs";
14
14
 
15
15
  /** Spec section 8, highest precedence first. */
@@ -147,6 +147,18 @@ export function analyzeProfiles(iface, items, { attribute = true } = {}) {
147
147
  }
148
148
  };
149
149
 
150
+ /**
151
+ * A grant that does not hold (spec 5.3, D-056). A pack applied to a service loses only its grant, never the effect:
152
+ * dropping the effect would leave a transfer or approve with the default effect, which needs no confirmation.
153
+ */
154
+ const grants = []; // effects whose grant holds, checked again once dropped tags are known
155
+ const badGrant = (e, diag, why) => {
156
+ if (!e.item.apply) return diag("error", "E_GRANT", e.ann.at, why);
157
+ const { grant, ...args } = e.entry.args;
158
+ e.entry.args = args;
159
+ e.item.diag("info", "I_PACK_ANNOTATION_SKIPPED", e.ann.at, `effect grant dropped, the effect kept: E_GRANT: ${why}`);
160
+ };
161
+
150
162
  const sets = new Map(); // names set -> { at, method, fields: { name, value, show?, kind? } } (spec 5.3, D-045)
151
163
  const named = []; // named positions, checked once every set is known
152
164
  for (const e of entries) {
@@ -167,8 +179,10 @@ export function analyzeProfiles(iface, items, { attribute = true } = {}) {
167
179
  const why = caseTargetError(iface, addr, res, a.tag);
168
180
  if (why) { diag("error", "E_TAG_TARGET", at, why); continue; }
169
181
  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);
182
+ if (a.tag === "effect") {
183
+ checkEffectMode(m, a.args, diag, at);
184
+ 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)");
185
+ } else checkTemplate(item, addr.method, a.args.template, diag, at, addr);
172
186
  caseEntries.push({ e, diag });
173
187
  continue;
174
188
  }
@@ -176,6 +190,11 @@ export function analyzeProfiles(iface, items, { attribute = true } = {}) {
176
190
  const m = res.method;
177
191
  const methodName = addr.method;
178
192
  if (a.tag === "effect") checkEffectMode(m, a.args, diag, at);
193
+ if (a.tag === "effect" && a.args.grant) {
194
+ const why = grantError(iface, tags, methodName, a.args);
195
+ if (why) badGrant(e, diag, why);
196
+ else grants.push({ e, diag });
197
+ }
179
198
  if (a.tag === "idempotency") {
180
199
  if (m.mode !== "update") diag("error", "E_IDEMPOTENCY_MODE", at, "idempotency applies to update methods");
181
200
  const r = iface.resolveRel(methodName, "args", a.args.stamp);
@@ -289,9 +308,14 @@ export function analyzeProfiles(iface, items, { attribute = true } = {}) {
289
308
  }
290
309
  }
291
310
 
292
- // Dropping a value tag can leave an idempotency stamp without its timestamp tag: drop that idempotency too.
311
+ // Dropping a value tag can leave an idempotency stamp without its timestamp tag: drop that idempotency too. It can
312
+ // also leave a grant without the amount, timestamp or account tag it needs.
293
313
  if (dropped.size) {
294
314
  const without = buildLayers(entries, (e) => !dropped.has(e));
315
+ for (const { e, diag } of grants) {
316
+ const why = !dropped.has(e) && grantError(iface, without, e.addr.method, e.entry.args);
317
+ if (why) badGrant(e, diag, `${why} (a tag it needs was dropped in this service)`);
318
+ }
295
319
  for (const e of entries) {
296
320
  if (!e.item.apply || e.ann.tag !== "idempotency" || dropped.has(e)) continue;
297
321
  const r = iface.resolveRel(e.addr.method, "args", e.ann.args.stamp);
package/src/checks.mjs CHANGED
@@ -18,7 +18,7 @@ export const CASE_TAGS = new Set(["intent", "effect"]);
18
18
  export const CONFIRM_KINDS = new Set(["transfer", "approve", "admin", "destroy"]);
19
19
  /** Tags that convert a value between its wire form and a human form; a stale profile must not supply them (spec section 7). */
20
20
  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"]);
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", "E_GRANT"]);
22
22
 
23
23
  export function checkApplicability(iface, tag, ref) {
24
24
  const { ref: r } = iface.unwrapOpt(ref);
@@ -57,6 +57,55 @@ export function tokenPathError(iface, addr, token) {
57
57
  return null;
58
58
  }
59
59
 
60
+ /**
61
+ * An effect's grant (spec 5.3, D-056): who may take the tokens, how many and until when, as positions in the arguments
62
+ * of the method the effect is on. `tags` is the merged view: the amount, timestamp and account tags a grant needs may
63
+ * come from another profile. The reason it is wrong, or null.
64
+ */
65
+ export function grantError(iface, tags, methodName, { kind, grant }) {
66
+ if (kind !== "transfer" && kind !== "approve") return `a grant goes with effect kind transfer or approve, not ${kind}`;
67
+ if ((grant.spender === undefined) === (grant.to === undefined)) return "a grant names exactly one of spender and to";
68
+ 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";
69
+ const at = (name, rel, mayBeAbsent) => {
70
+ const p = parseRelPath(rel);
71
+ const r = iface.resolveRel(methodName, "args", rel);
72
+ const what = `grant ${name} ${JSON.stringify(rel)}`;
73
+ if (!p || r.error) return { error: `${what}: ${r.error ?? "not a relative path"}` };
74
+ 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` };
75
+ // The value must be in every call: no case of a variant on the way, and no opt unless it may be left out.
76
+ let cur = iface.methods.get(methodName).args[p.index ?? 0];
77
+ let optional = false;
78
+ for (const s of p.segs) {
79
+ const u = iface.unwrapOpt(cur);
80
+ optional ||= u.optional;
81
+ const n = iface.node(u.ref);
82
+ if (n.kind === "variant") return { error: `${what} passes through a case of a variant, so a call need not hold it` };
83
+ cur = n.fields.find((f) => f.id === s.id).type;
84
+ }
85
+ if ((optional || iface.unwrapOpt(cur).optional) && !mayBeAbsent) return { error: `${what} is optional, but a grant needs its ${name} in every call` };
86
+ return { r, t: tags.at(r, iface) ?? {} };
87
+ };
88
+ const who = grant.spender !== undefined ? "spender" : "to";
89
+ const party = at(who, grant[who], false);
90
+ if (party.error) return party.error;
91
+ if (iface.prim(party.r.ref) !== "address" && !party.t.account) {
92
+ return `grant ${who} ${JSON.stringify(grant[who])} is ${iface.describeType(party.r.ref)}; it must be an address or carry an account tag`;
93
+ }
94
+ const amount = at("amount", grant.amount, false);
95
+ if (amount.error) return amount.error;
96
+ const p = iface.prim(amount.r.ref);
97
+ if (p !== "bool") {
98
+ 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`;
99
+ 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`;
100
+ }
101
+ for (const rel of grant.ends ?? []) {
102
+ const end = at("ends", rel, true);
103
+ if (end.error) return end.error;
104
+ if (!end.t.timestamp) return `grant ends ${JSON.stringify(rel)} has no timestamp tag`;
105
+ }
106
+ return null;
107
+ }
108
+
60
109
  export function checkDynamic(iface, value, want, diag, at) {
61
110
  if (!value || typeof value !== "object") return;
62
111
  const from = value.from;