@meantjs/consent 0.0.0-stage → 0.2.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/CHANGELOG.md +35 -0
- package/LICENSE +202 -0
- package/README.md +246 -2
- package/package.json +74 -4
- package/src/bytes.mjs +32 -0
- package/src/consent.mjs +42 -0
- package/src/decode.mjs +140 -0
- package/src/display.mjs +248 -0
- package/src/erc7730.d.mts +81 -0
- package/src/erc7730.mjs +304 -0
- package/src/errors.mjs +54 -0
- package/src/evm.d.mts +51 -0
- package/src/evm.mjs +129 -0
- package/src/icrc21-codec.mjs +160 -0
- package/src/icrc21.mjs +139 -0
- package/src/index.d.mts +236 -0
- package/src/index.mjs +13 -0
- package/src/model.mjs +165 -0
- package/src/paths.mjs +74 -0
- package/src/render.mjs +104 -0
- package/src/resolved.mjs +102 -0
- package/src/sns.d.mts +54 -0
- package/src/sns.mjs +293 -0
- package/src/template.mjs +69 -0
- package/src/text.mjs +74 -0
package/src/model.mjs
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// The neutral model of one consent message: a title, an optional sentence (the rendered intent template as
|
|
2
|
+
// literal and field parts) and the fields. Both ICRC-21 forms are rendered from it (render.mjs), so they cannot
|
|
3
|
+
// disagree about which values the call carries.
|
|
4
|
+
//
|
|
5
|
+
// Which fields appear:
|
|
6
|
+
// - a call with an intent, its case's (spec section 5.2, D-053) or else its method's: the placeholders of its template
|
|
7
|
+
// that are present (spec section 6; section 12: "one (label, Value) row per placeholder"), in template order,
|
|
8
|
+
// once per distinct path;
|
|
9
|
+
// - a call without one: the generic intent. Title from `describe.label` of the method, else `Call <method>`;
|
|
10
|
+
// one field per leaf of the decoded arguments (records are flattened, tagged positions such as accounts are
|
|
11
|
+
// leaves, absent optionals are left out). More than MAX_FIELDS fields or MAX_ELEMENTS list elements is
|
|
12
|
+
// refused (E_TOO_LARGE), never truncated.
|
|
13
|
+
|
|
14
|
+
import { VALUE_TAGS } from "@meantjs/format";
|
|
15
|
+
import { decodeArguments } from "./decode.mjs";
|
|
16
|
+
import { CODES, ConsentError, fail } from "./errors.mjs";
|
|
17
|
+
import { callTag, effectKind, entriesOf, labelOf, methodOf } from "./resolved.mjs";
|
|
18
|
+
import { parseTemplate } from "./template.mjs";
|
|
19
|
+
import { resolvePath, valuesAt } from "./paths.mjs";
|
|
20
|
+
|
|
21
|
+
export const MAX_FIELDS = 64;
|
|
22
|
+
export const MAX_ELEMENTS = 256;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {object} Field
|
|
26
|
+
* @property {string} path the placeholder path, or the dotted path of a flattened leaf
|
|
27
|
+
* @property {string} label describe.label of the position, else its field name (raw, not yet escaped)
|
|
28
|
+
* @property {object} node the resolved-form position
|
|
29
|
+
* @property {unknown[]} values the present values: none (absent), one, or one per list element
|
|
30
|
+
*
|
|
31
|
+
* @typedef {{ lit: string } | { field: Field }} SentencePart
|
|
32
|
+
*
|
|
33
|
+
* @typedef {object} Model
|
|
34
|
+
* @property {string} method
|
|
35
|
+
* @property {string} effect the effect kind of the call: its case's, else its method's (spec section 5.3, D-053)
|
|
36
|
+
* @property {string} title
|
|
37
|
+
* @property {SentencePart[] | null} sentence null for a call without an intent template
|
|
38
|
+
* @property {Field[]} fields
|
|
39
|
+
* @property {object[]} argNodes
|
|
40
|
+
* @property {unknown[]} argValues
|
|
41
|
+
* @property {object[]} diagnostics
|
|
42
|
+
* @property {{ intent: { origin: string, profile?: string } | null, profiles: object[] }} provenance where the words come from
|
|
43
|
+
* (ICRC-49: a signer warns when it, not the canister, wrote the message; spec section 11 item 1: a profile is a claim)
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {{ resolved: object, method: string, arg: Uint8Array | ArrayBuffer | number[], limits?: object }} input
|
|
48
|
+
* @returns {Model}
|
|
49
|
+
*/
|
|
50
|
+
export function buildModel({ resolved, method: name, arg, limits }) {
|
|
51
|
+
const method = methodOf(resolved, name);
|
|
52
|
+
return modelOf(resolved, name, method, decodeArguments(method, arg, limits));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The model of a call whose arguments are decoded already (evm.mjs decodes calldata, then comes here). */
|
|
56
|
+
export function modelOf(resolved, name, method, argValues) {
|
|
57
|
+
const diagnostics = [];
|
|
58
|
+
const intentTag = callTag(method, argValues, "intent"); // the call's intent: its case's, else its method's (D-053)
|
|
59
|
+
const intent = intentTag?.args;
|
|
60
|
+
const title = typeof intent?.title === "string" && intent.title.trim() !== "" ? intent.title : (labelOf(method) ?? `Call ${name}`);
|
|
61
|
+
const tagOrigin = intent ? { origin: intentTag.origin, ...(intentTag.profile === undefined ? {} : { profile: intentTag.profile }) } : null;
|
|
62
|
+
const provenance = { intent: tagOrigin, profiles: Array.isArray(resolved.profiles) ? resolved.profiles : [] };
|
|
63
|
+
const base = { method: name, effect: effectKind(method, callTag(method, argValues, "effect")), title, argNodes: method.args, argValues, diagnostics, provenance };
|
|
64
|
+
|
|
65
|
+
if (intent && typeof intent.template === "string") {
|
|
66
|
+
try {
|
|
67
|
+
const { sentence, fields } = applyTemplate(method, argValues, parseTemplate(intent.template));
|
|
68
|
+
return { ...base, sentence, fields };
|
|
69
|
+
} catch (e) {
|
|
70
|
+
// A template the profile got wrong must not leave the user without a message: fall back to the generic
|
|
71
|
+
// intent, which lists every argument, and say so.
|
|
72
|
+
if (!(e instanceof ConsentError) || (e.code !== CODES.TEMPLATE && e.code !== CODES.TEMPLATE_PATH)) throw e;
|
|
73
|
+
diagnostics.push({ code: "W_TEMPLATE_FALLBACK", at: name, message: `${e.code}: ${e.message}; the generic intent is used instead` });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { ...base, sentence: null, fields: flatten(method, argValues) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------------------------- template
|
|
80
|
+
|
|
81
|
+
function nameOf(path, resolvedPath) {
|
|
82
|
+
for (let i = resolvedPath.steps.length - 1; i >= 0; i--) {
|
|
83
|
+
const s = resolvedPath.steps[i];
|
|
84
|
+
if (s.kind !== "elem") return s.key;
|
|
85
|
+
}
|
|
86
|
+
return `Argument ${resolvedPath.index + 1}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function applyTemplate(method, argValues, parts) {
|
|
90
|
+
const known = new Map(); // path -> Field
|
|
91
|
+
const sentence = [];
|
|
92
|
+
const fields = [];
|
|
93
|
+
|
|
94
|
+
const lookup = (path) => {
|
|
95
|
+
const hit = known.get(path);
|
|
96
|
+
if (hit) return { field: hit, fresh: false };
|
|
97
|
+
const rp = resolvePath(method, path);
|
|
98
|
+
if (rp.error) return { error: rp.error };
|
|
99
|
+
const values = valuesAt(rp, argValues);
|
|
100
|
+
if (values.length > MAX_ELEMENTS) fail(CODES.TOO_LARGE, `{${path}} has ${values.length} values; the limit is ${MAX_ELEMENTS}`);
|
|
101
|
+
return { field: { path, label: labelOf(rp.node) ?? nameOf(path, rp), node: rp.node, values }, fresh: true };
|
|
102
|
+
};
|
|
103
|
+
const register = ({ field, fresh }) => {
|
|
104
|
+
if (!fresh) return field;
|
|
105
|
+
known.set(field.path, field);
|
|
106
|
+
fields.push(field);
|
|
107
|
+
if (fields.length > MAX_FIELDS) fail(CODES.TOO_LARGE, `the template has more than ${MAX_FIELDS} distinct placeholders`);
|
|
108
|
+
return field;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
for (const part of parts) {
|
|
112
|
+
if (part.t === "lit") sentence.push({ lit: part.text });
|
|
113
|
+
else if (part.t === "ph") {
|
|
114
|
+
const found = lookup(part.path);
|
|
115
|
+
if (found.error) fail(CODES.TEMPLATE_PATH, `{${part.path}}: ${found.error}`);
|
|
116
|
+
sentence.push({ field: register(found) });
|
|
117
|
+
} else {
|
|
118
|
+
// [ ... ]: dropped when any placeholder inside is absent (null) or names a position this method lacks.
|
|
119
|
+
const looked = part.parts.map((p) => (p.t === "ph" ? lookup(p.path) : null));
|
|
120
|
+
const unresolved = looked.find((l) => l?.error);
|
|
121
|
+
if (unresolved || looked.some((l) => l && l.field.values.length === 0)) continue;
|
|
122
|
+
for (let i = 0; i < part.parts.length; i++) {
|
|
123
|
+
const p = part.parts[i];
|
|
124
|
+
sentence.push(p.t === "lit" ? { lit: p.text } : { field: register(looked[i]) });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return { sentence, fields };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------------------------- generic intent
|
|
132
|
+
|
|
133
|
+
const hasValueTag = (node) => VALUE_TAGS.some((t) => node.tags && Object.hasOwn(node.tags, t));
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Field order of the resolved form is canonical id order (spec section 10), which reads as random. `ui.order`
|
|
137
|
+
* ("use ui.order for presentation") puts the fields that have one first, ascending; the rest keep id order.
|
|
138
|
+
*/
|
|
139
|
+
function presentationOrder(entries) {
|
|
140
|
+
const order = (child) => {
|
|
141
|
+
const n = child.tags?.ui?.args?.order;
|
|
142
|
+
return Number.isFinite(n) ? n : Infinity;
|
|
143
|
+
};
|
|
144
|
+
return entries.map((e, i) => ({ e, i })).sort((a, b) => order(a.e[1]) - order(b.e[1]) || a.i - b.i).map(({ e }) => e);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function flatten(method, argValues) {
|
|
148
|
+
const fields = [];
|
|
149
|
+
const multi = method.args.length > 1;
|
|
150
|
+
const emit = (node, value, keys, index) => {
|
|
151
|
+
const dotted = keys.join(".");
|
|
152
|
+
const label = labelOf(node) ?? (multi ? `Argument ${index + 1}${dotted ? ` ${dotted}` : ""}` : dotted || `Argument ${index + 1}`);
|
|
153
|
+
if (node.candid === "vec" && value.length > MAX_ELEMENTS) fail(CODES.TOO_LARGE, `${label} has ${value.length} elements; the limit is ${MAX_ELEMENTS}`);
|
|
154
|
+
fields.push({ path: `(${index})${keys.map((k) => `.${k}`).join("")}`, label, node, values: [value] });
|
|
155
|
+
if (fields.length > MAX_FIELDS) fail(CODES.TOO_LARGE, `the arguments have more than ${MAX_FIELDS} fields`);
|
|
156
|
+
};
|
|
157
|
+
const walk = (node, value, keys, index) => {
|
|
158
|
+
if (node.optional === true && value === null) return;
|
|
159
|
+
const children = node.candid === "record" && node.recursive !== true && !hasValueTag(node) ? presentationOrder(entriesOf(node.fields)) : [];
|
|
160
|
+
if (children.length > 0) for (const [key, child] of children) walk(child, value[key], [...keys, key], index);
|
|
161
|
+
else emit(node, value, keys, index);
|
|
162
|
+
};
|
|
163
|
+
method.args.forEach((node, i) => walk(node, argValues[i], [], i));
|
|
164
|
+
return fields;
|
|
165
|
+
}
|
package/src/paths.mjs
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Placeholder paths (docs/profile-v0.md section 3.2 rule 9): `{amount}`, `{to.owner}`, `{(0).amount}`, `{items[]}`.
|
|
2
|
+
// A relpath resolves against the method's argument tuple; with one argument it is relative to argument 0.
|
|
3
|
+
//
|
|
4
|
+
// Beyond what section 6 says (decisions of this package, listed in the README):
|
|
5
|
+
// - a path that crosses a variant case selects that case; when the value has another case the placeholder is absent;
|
|
6
|
+
// - a path that crosses `[]` yields one value per element (absent elements dropped);
|
|
7
|
+
// - a path the method does not have is "unresolved": absent inside `[ ... ]`, an error outside (the resolver
|
|
8
|
+
// already reports it as E_TEMPLATE_PATH; a pack placeholder the service lacks is skipped by the resolver).
|
|
9
|
+
|
|
10
|
+
import { parseRelPath } from "@meantjs/profile/address";
|
|
11
|
+
import { entriesOf, keyId } from "./resolved.mjs";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {{ kind: "field" | "case", key: string } | { kind: "elem" }} Step
|
|
15
|
+
* @typedef {{ index: number, steps: Step[], node: object, nodes: object[] }} ResolvedPath
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** @returns {ResolvedPath | { error: string }} */
|
|
19
|
+
export function resolvePath(method, relpath) {
|
|
20
|
+
const parsed = parseRelPath(relpath);
|
|
21
|
+
if (!parsed) return { error: `bad relative path ${JSON.stringify(relpath)}` };
|
|
22
|
+
let index = parsed.index;
|
|
23
|
+
if (index === null) {
|
|
24
|
+
if (method.args.length !== 1) return { error: `path ${JSON.stringify(relpath)} must start with (i): the method has ${method.args.length} arguments` };
|
|
25
|
+
index = 0;
|
|
26
|
+
}
|
|
27
|
+
if (index >= method.args.length) return { error: `argument index ${index} is out of range` };
|
|
28
|
+
let node = method.args[index];
|
|
29
|
+
const nodes = [node];
|
|
30
|
+
const steps = [];
|
|
31
|
+
for (const seg of parsed.segs) {
|
|
32
|
+
if (node.recursive === true) return { error: `${JSON.stringify(relpath)} enters a position the resolved form cuts at a cycle` };
|
|
33
|
+
if (seg.kind === "elem") {
|
|
34
|
+
if (node.candid !== "vec" || node.element === undefined) return { error: `"[]" applied to ${node.candid}` };
|
|
35
|
+
steps.push({ kind: "elem" });
|
|
36
|
+
node = node.element;
|
|
37
|
+
} else {
|
|
38
|
+
const field = entriesOf(node.fields).find(([key]) => keyId(key) === seg.id);
|
|
39
|
+
const arm = field === undefined ? entriesOf(node.cases).find(([key]) => keyId(key) === seg.id) : undefined;
|
|
40
|
+
const hit = field ?? arm;
|
|
41
|
+
if (hit === undefined) return { error: `no field or case ${JSON.stringify(seg.label)} in ${node.candid}` };
|
|
42
|
+
steps.push({ kind: field ? "field" : "case", key: hit[0] });
|
|
43
|
+
node = hit[1];
|
|
44
|
+
}
|
|
45
|
+
nodes.push(node);
|
|
46
|
+
}
|
|
47
|
+
return { index, steps, node, nodes };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The present values at a resolved path. An absent optional anywhere on the way (null), a variant with another
|
|
52
|
+
* case, or an empty vector gives no values.
|
|
53
|
+
*
|
|
54
|
+
* @param {ResolvedPath} path
|
|
55
|
+
* @param {unknown[]} values the decoded argument trees
|
|
56
|
+
* @returns {unknown[]}
|
|
57
|
+
*/
|
|
58
|
+
export function valuesAt(path, values) {
|
|
59
|
+
let current = [values[path.index]];
|
|
60
|
+
for (let depth = 0; depth < path.steps.length; depth++) {
|
|
61
|
+
const step = path.steps[depth];
|
|
62
|
+
const parentNode = path.nodes[depth];
|
|
63
|
+
const next = [];
|
|
64
|
+
for (const v of current) {
|
|
65
|
+
if (parentNode.optional === true && v === null) continue;
|
|
66
|
+
if (step.kind === "elem") next.push(...v);
|
|
67
|
+
else if (step.kind === "field") next.push(v[step.key]);
|
|
68
|
+
else if (v.tag === step.key) next.push(v.value);
|
|
69
|
+
}
|
|
70
|
+
current = next;
|
|
71
|
+
}
|
|
72
|
+
const leaf = path.node;
|
|
73
|
+
return current.filter((v) => !(leaf.optional === true && v === null));
|
|
74
|
+
}
|
package/src/render.mjs
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// The two ICRC-21 forms from a Model (docs/profile-v0.md section 12).
|
|
2
|
+
//
|
|
3
|
+
// FieldsDisplayMessage (plain text, no formatting; times are UTC seconds, so the message does not depend on the
|
|
4
|
+
// user's time zone):
|
|
5
|
+
// intent = title + ": " + the rendered template (no template: the title alone)
|
|
6
|
+
// fields = one (label, Value) row per placeholder; label = describe.label, else the field name
|
|
7
|
+
//
|
|
8
|
+
// GenericDisplayMessage (Markdown, the layout of the ICRC-21 examples: a `#` title, one paragraph, then
|
|
9
|
+
// `**label:**` lines). Every value comes from the call, which the relying party controls, so it goes into a
|
|
10
|
+
// code span, where Markdown is not parsed. Template text and labels come from the profile and are plain text:
|
|
11
|
+
// their Markdown punctuation is escaped.
|
|
12
|
+
|
|
13
|
+
import { codeBlock, codeSpan, emptyMarkdown, escapeLineStart, escapeMarkdown, oneLine, visible } from "./text.mjs";
|
|
14
|
+
|
|
15
|
+
const NONE_PLAIN = "(none)";
|
|
16
|
+
const NONE_MARKDOWN = "*(none)*";
|
|
17
|
+
|
|
18
|
+
/** Label and title: one line, trimmed, never empty. */
|
|
19
|
+
function plainLabel(raw, fallback) {
|
|
20
|
+
const s = oneLine(raw).trim();
|
|
21
|
+
return s === "" ? fallback : s;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The text of a field's values, plain: the values joined by ", ", or "(none)". */
|
|
25
|
+
function fieldPlain(field, renderer) {
|
|
26
|
+
if (field.values.length === 0) return NONE_PLAIN;
|
|
27
|
+
return field.values.map((v, i) => visible(renderer.text(field.node, v, field.path + (field.values.length > 1 ? `[${i}]` : "")))).join(", ");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function fieldMarkdown(field, renderer) {
|
|
31
|
+
if (field.values.length === 0) return NONE_MARKDOWN;
|
|
32
|
+
return field.values.map((v, i) => codeSpan(renderer.text(field.node, v, field.path + (field.values.length > 1 ? `[${i}]` : "")))).join(", ");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {import("./model.mjs").Model} model
|
|
37
|
+
* @param {import("./display.mjs").Renderer} renderer UTC (no zone)
|
|
38
|
+
* @param {{ rawArguments?: boolean }} [options]
|
|
39
|
+
* @returns {{ intent: string, fields: Array<[string, object]> }}
|
|
40
|
+
*/
|
|
41
|
+
export function renderFields(model, renderer, { rawArguments = false } = {}) {
|
|
42
|
+
const title = plainLabel(model.title, model.method);
|
|
43
|
+
let intent = title;
|
|
44
|
+
if (model.sentence !== null) {
|
|
45
|
+
const sentence = model.sentence.map((p) => ("lit" in p ? oneLine(p.lit) : fieldPlain(p.field, renderer))).join("");
|
|
46
|
+
if (sentence.trim() !== "") intent = `${title}: ${sentence.trim()}`;
|
|
47
|
+
}
|
|
48
|
+
const fields = model.fields.map((f) => [plainLabel(f.label, f.path), fieldValue(f, renderer)]);
|
|
49
|
+
if (rawArguments) fields.push(["Raw arguments", { Text: { content: visible(renderer.tupleText(model.argNodes, model.argValues)) } }]);
|
|
50
|
+
return { intent, fields };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The fields form as plain text, for a message that is not ICRC-21's (an EVM call, spec section 15): the same intent
|
|
55
|
+
* line as renderFields, and each row's values joined by ", ".
|
|
56
|
+
*
|
|
57
|
+
* @returns {{ intent: string, fields: Array<[string, string]> }}
|
|
58
|
+
*/
|
|
59
|
+
export function renderPlain(model, renderer, { rawArguments = false } = {}) {
|
|
60
|
+
const title = plainLabel(model.title, model.method);
|
|
61
|
+
let intent = title;
|
|
62
|
+
if (model.sentence !== null) {
|
|
63
|
+
const sentence = model.sentence.map((p) => ("lit" in p ? oneLine(p.lit) : fieldPlain(p.field, renderer))).join("");
|
|
64
|
+
if (sentence.trim() !== "") intent = `${title}: ${sentence.trim()}`;
|
|
65
|
+
}
|
|
66
|
+
const fields = model.fields.map((f) => [plainLabel(f.label, f.path), fieldPlain(f, renderer)]);
|
|
67
|
+
if (rawArguments) fields.push(["Raw arguments", visible(renderer.tupleText(model.argNodes, model.argValues))]);
|
|
68
|
+
return { intent, fields };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** One row's ICRC-21 Value: typed when the field is a single value the typed form carries exactly, else Text. */
|
|
72
|
+
function fieldValue(field, renderer) {
|
|
73
|
+
if (field.values.length === 1) {
|
|
74
|
+
const v = renderer.icrc21Value(field.node, field.values[0], field.path);
|
|
75
|
+
if (v.Text) v.Text.content = visible(v.Text.content);
|
|
76
|
+
return v;
|
|
77
|
+
}
|
|
78
|
+
return { Text: { content: fieldPlain(field, renderer) } };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {import("./model.mjs").Model} model
|
|
83
|
+
* @param {import("./display.mjs").Renderer} renderer zoned when the request carried a utc_offset_minutes
|
|
84
|
+
* @param {{ rawArguments?: boolean, headingLevel?: number }} [options] `headingLevel`: the title's heading level
|
|
85
|
+
* (default 1; an SNS validator rendering sits under governance's own `##` headings and uses 3)
|
|
86
|
+
* @returns {string}
|
|
87
|
+
*/
|
|
88
|
+
export function renderGeneric(model, renderer, { rawArguments = false, headingLevel = 1 } = {}) {
|
|
89
|
+
const blocks = [`${"#".repeat(headingLevel)} ${escapeMarkdown(plainLabel(model.title, model.method))}`];
|
|
90
|
+
if (model.sentence !== null) {
|
|
91
|
+
let paragraph = model.sentence.map((p) => ("lit" in p ? escapeMarkdown(oneLine(p.lit)) : fieldMarkdown(p.field, renderer))).join("").trim();
|
|
92
|
+
if (model.sentence[0] && "lit" in model.sentence[0]) paragraph = escapeLineStart(paragraph);
|
|
93
|
+
if (paragraph !== "") blocks.push(paragraph);
|
|
94
|
+
}
|
|
95
|
+
if (model.fields.length > 0) {
|
|
96
|
+
blocks.push(model.fields.map((f) => `- **${escapeMarkdown(plainLabel(f.label, f.path))}:** ${fieldMarkdown(f, renderer)}`).join("\n"));
|
|
97
|
+
} else if (model.sentence === null) {
|
|
98
|
+
blocks.push("This call has no arguments.");
|
|
99
|
+
}
|
|
100
|
+
if (rawArguments) blocks.push(`**Raw arguments:**\n\n${codeBlock(renderer.tupleText(model.argNodes, model.argValues))}`);
|
|
101
|
+
return blocks.join("\n\n");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export { emptyMarkdown };
|
package/src/resolved.mjs
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Read-only accessors over the resolved form (docs/profile-v0.md section 10). The resolved form is data from
|
|
2
|
+
// a resolver, so nothing here trusts its shape beyond what the code reads.
|
|
3
|
+
|
|
4
|
+
import { idlHash } from "@meantjs/profile/address";
|
|
5
|
+
import { CODES, fail } from "./errors.mjs";
|
|
6
|
+
|
|
7
|
+
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The method entry of a resolved form, or a ConsentError. `chain` is "evm" for an EVM contract's form (spec section 15);
|
|
11
|
+
* a Candid entry point never reads one, whose positions are ABI types, not Candid.
|
|
12
|
+
*/
|
|
13
|
+
export function methodOf(resolved, name, chain = undefined) {
|
|
14
|
+
if (!isObject(resolved) || resolved.format !== "candid-profile-resolved" || !isObject(resolved.methods)) {
|
|
15
|
+
fail(CODES.RESOLVED_FORM, "not a candid-profile-resolved document (format, methods)");
|
|
16
|
+
}
|
|
17
|
+
if (resolved.chain !== chain) {
|
|
18
|
+
fail(CODES.RESOLVED_FORM, resolved.chain === "evm" ? "this resolved form is an EVM contract's (chain evm): use evmConsent, not a Candid entry point" : `expected the resolved form of an EVM contract (chain evm), found ${resolved.chain === undefined ? "a Candid service's" : `chain ${JSON.stringify(resolved.chain)}`}`);
|
|
19
|
+
}
|
|
20
|
+
if (typeof name !== "string" || !Object.hasOwn(resolved.methods, name)) {
|
|
21
|
+
fail(CODES.METHOD_UNKNOWN, `the resolved form has no method ${JSON.stringify(String(name))}`);
|
|
22
|
+
}
|
|
23
|
+
const m = resolved.methods[name];
|
|
24
|
+
if (!isObject(m) || !Array.isArray(m.args)) fail(CODES.RESOLVED_FORM, `method ${name} has no args array`);
|
|
25
|
+
return m;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The Candid field or case id of a resolved-form key: `#123` is the raw id, any other key is a name. */
|
|
29
|
+
export function keyId(key) {
|
|
30
|
+
return /^#(0|[1-9][0-9]{0,9})$/.test(key) ? Number(key.slice(1)) : idlHash(key);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A position of an EVM contract's resolved form (spec section 15) read as the Candid shapes the model walks: a tuple is
|
|
35
|
+
* a record, an array a vec, any other ABI type a primitive of that name. `abi` is kept, so display writes ABI text.
|
|
36
|
+
*/
|
|
37
|
+
export function abiView(node, where, depth = 0) {
|
|
38
|
+
if (!isObject(node) || typeof node.abi !== "string") fail(CODES.RESOLVED_FORM, `${where}: not an EVM resolved-form position`);
|
|
39
|
+
if (depth > 64) fail(CODES.UNSUPPORTED_TYPE, `${where}: nested deeper than 64`);
|
|
40
|
+
if (node.abi === "tuple") {
|
|
41
|
+
return { ...node, candid: "record", fields: Object.fromEntries(entriesOf(node.fields).map(([k, c]) => [k, abiView(c, `${where}.${k}`, depth + 1)])) };
|
|
42
|
+
}
|
|
43
|
+
if (node.abi === "array") return { ...node, candid: "vec", element: abiView(node.element, `${where}[]`, depth + 1) };
|
|
44
|
+
return { ...node, candid: node.abi };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Own entries of a `fields` or `cases` object (never walks the prototype chain). */
|
|
48
|
+
export function entriesOf(bucket) {
|
|
49
|
+
return isObject(bucket) ? Object.entries(bucket) : [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `describe.label` of a position or method, when it is a non-empty string. */
|
|
53
|
+
export function labelOf(node) {
|
|
54
|
+
const label = node?.tags?.describe?.args?.label;
|
|
55
|
+
return typeof label === "string" && label.trim() !== "" ? label : undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function tagArgs(node, tag) {
|
|
59
|
+
return node?.tags && Object.hasOwn(node.tags, tag) ? (node.tags[tag]?.args ?? {}) : undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The effect kind of a call: its effect tag (the call's, `callTag`; the method's by default), or the spec default
|
|
64
|
+
* (section 5.3: queries read, updates write).
|
|
65
|
+
*/
|
|
66
|
+
export function effectKind(method, tag = method.tags?.effect) {
|
|
67
|
+
return tag?.args?.kind ?? (method.mode === "query" || method.mode === "composite_query" ? "read" : "write");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The `intent` or `effect` entry (`{ args, origin, profile }`) a call takes: that of the deepest tagged variant case
|
|
72
|
+
* its decoded arguments hold (spec section 5.2, D-053), else the method's. The resolver lets the tagged cases of one
|
|
73
|
+
* method only exclude each other or nest, so the cases one call holds are a chain; a resolved form with tagged cases
|
|
74
|
+
* that one call holds side by side is not one a resolver writes, and the method's entry is used for it.
|
|
75
|
+
*
|
|
76
|
+
* @param {object} method a method of the resolved form
|
|
77
|
+
* @param {unknown[]} argValues its decoded arguments (decode.mjs: records by key, variants as { tag, value })
|
|
78
|
+
* @param {"intent" | "effect"} tag
|
|
79
|
+
*/
|
|
80
|
+
export function callTag(method, argValues, tag) {
|
|
81
|
+
let best = null; // { entry, chain }: the hit with the most hits above and at it
|
|
82
|
+
let hits = 0;
|
|
83
|
+
const visit = (node, value, chain) => {
|
|
84
|
+
if (!isObject(node) || node.recursive === true || !isObject(value)) return; // null: an absent optional, a bare case
|
|
85
|
+
if (node.candid === "record") {
|
|
86
|
+
for (const [key, child] of entriesOf(node.fields)) if (Object.hasOwn(value, key)) visit(child, value[key], chain);
|
|
87
|
+
} else if (node.candid === "variant") {
|
|
88
|
+
const hit = entriesOf(node.cases).find(([key]) => key === value.tag);
|
|
89
|
+
if (hit === undefined) return;
|
|
90
|
+
const child = hit[1];
|
|
91
|
+
const entry = isObject(child) && isObject(child.tags) && Object.hasOwn(child.tags, tag) ? child.tags[tag] : undefined;
|
|
92
|
+
if (isObject(entry)) {
|
|
93
|
+
chain += 1;
|
|
94
|
+
hits += 1;
|
|
95
|
+
if (best === null || chain > best.chain) best = { entry, chain };
|
|
96
|
+
}
|
|
97
|
+
visit(child, value.value, chain);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
method.args.forEach((node, i) => visit(node, argValues[i], 0));
|
|
101
|
+
return best !== null && best.chain === hits ? best.entry : method.tags?.[tag];
|
|
102
|
+
}
|
package/src/sns.d.mts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Types for @meantjs/consent/sns (Node only: it hashes with node:crypto). Hand-written; the source is
|
|
2
|
+
// src/sns.mjs. test/types.test.mjs keeps the declared names equal to the runtime exports.
|
|
3
|
+
import type { Bytes, ConsentDiagnostic, DecodeLimits, Provenance, ResolvedForm } from "./index.mjs";
|
|
4
|
+
|
|
5
|
+
export type ValidatorResult = { Ok: string } | { Err: string };
|
|
6
|
+
|
|
7
|
+
/** The name and version of the rules a rendering follows; part of every rendering. */
|
|
8
|
+
export const SNS_RENDERING_RULES: "candid-profile-sns/v0";
|
|
9
|
+
|
|
10
|
+
/** An SNS generic-function validator as a function, with non-enumerable `diagnostics` and `provenance`. */
|
|
11
|
+
export function snsValidatorRendering(input: {
|
|
12
|
+
/** The target method's Candid argument bytes, as governance passes them. */
|
|
13
|
+
payload: Bytes;
|
|
14
|
+
resolved: ResolvedForm;
|
|
15
|
+
/** The target method this validator is registered for. */
|
|
16
|
+
method: string;
|
|
17
|
+
refs?: Record<string, unknown>;
|
|
18
|
+
limits?: DecodeLimits;
|
|
19
|
+
}): ValidatorResult & { readonly diagnostics: ConsentDiagnostic[]; readonly provenance: Provenance };
|
|
20
|
+
/** The canister-side wrapper: payload bytes in, the Candid-encoded `variant { Ok : text; Err : text }` out. */
|
|
21
|
+
export function handleSnsValidatorCall(payload: Uint8Array, context: { resolved: ResolvedForm; method: string; refs?: Record<string, unknown>; limits?: DecodeLimits }): Uint8Array;
|
|
22
|
+
export function encodeValidatorResult(result: ValidatorResult): Uint8Array;
|
|
23
|
+
export function decodeValidatorResult(bytes: Uint8Array): ValidatorResult;
|
|
24
|
+
|
|
25
|
+
export interface GovernanceRendering {
|
|
26
|
+
functionText: string;
|
|
27
|
+
payloadSha256: string;
|
|
28
|
+
payloadRendering: string;
|
|
29
|
+
targetMethod: string | null;
|
|
30
|
+
targetCanisterId: string | null;
|
|
31
|
+
validatorMethod: string | null;
|
|
32
|
+
validatorCanisterId: string | null;
|
|
33
|
+
}
|
|
34
|
+
/** A stored `payload_text_rendering` split into governance's parts, or null when it is not governance's layout. */
|
|
35
|
+
export function parseGovernanceRendering(text: string): GovernanceRendering | null;
|
|
36
|
+
|
|
37
|
+
export interface SnsCheck {
|
|
38
|
+
name: string;
|
|
39
|
+
ok: boolean;
|
|
40
|
+
expected?: string;
|
|
41
|
+
found?: string | null;
|
|
42
|
+
note?: string;
|
|
43
|
+
}
|
|
44
|
+
/** The reviewer's check of a stored proposal rendering against what the profile renders for the payload. */
|
|
45
|
+
export function verifySnsRendering(input: {
|
|
46
|
+
payloadTextRendering: string;
|
|
47
|
+
payload: Bytes;
|
|
48
|
+
resolved: ResolvedForm;
|
|
49
|
+
/** The canister the reviewer expects governance to call, as text. */
|
|
50
|
+
targetCanisterId: string;
|
|
51
|
+
method: string;
|
|
52
|
+
refs?: Record<string, unknown>;
|
|
53
|
+
limits?: DecodeLimits;
|
|
54
|
+
}): { ok: boolean; checks: SnsCheck[]; expectedRendering: string | null; parsed: GovernanceRendering | null };
|