@meantjs/consent 0.0.0-bootstrap → 0.1.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 +20 -0
- package/README.md +214 -4
- package/package.json +51 -4
- package/src/bytes.mjs +32 -0
- package/src/consent.mjs +42 -0
- package/src/decode.mjs +140 -0
- package/src/display.mjs +218 -0
- package/src/errors.mjs +51 -0
- package/src/icrc21-codec.mjs +160 -0
- package/src/icrc21.mjs +139 -0
- package/src/index.d.mts +234 -0
- package/src/index.mjs +13 -0
- package/src/model.mjs +159 -0
- package/src/paths.mjs +74 -0
- package/src/render.mjs +86 -0
- package/src/resolved.mjs +45 -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/sns.mjs
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// SNS proposals that execute a generic nervous system function: the validator method as a function (the governance
|
|
2
|
+
// counterpart of ICRC-21), and the reviewer's check of what a proposal shows.
|
|
3
|
+
//
|
|
4
|
+
// What SNS governance does (fact: dfinity/ic commit 649156c2bca1315da5699e2125b34be6397c9987,
|
|
5
|
+
// rs/sns/governance/src/proposal.rs `validate_and_render_execute_nervous_system_function`, canister_control.rs
|
|
6
|
+
// `perform_execute_generic_nervous_system_function_validate_and_render_call`, types.rs `summarize_blob_field`):
|
|
7
|
+
// - When the proposal is submitted, governance calls `validator_method_name` on `validator_canister_id` with the
|
|
8
|
+
// proposal's payload bytes, unchanged. The reply must decode as `variant { Ok : text; Err : text }` (Rust
|
|
9
|
+
// `Result<String, String>`). `Err(e)` rejects the proposal ("Invalid proposal: e").
|
|
10
|
+
// - `Ok(text)` is stored as the proposal's `payload_text_rendering`, inside governance's own Markdown:
|
|
11
|
+
// # Proposal to execute nervous system function:
|
|
12
|
+
// ## Nervous system function: (the function record, Rust {:#?} debug text)
|
|
13
|
+
// ## Payload sha256: (lowercase hex of the payload)
|
|
14
|
+
// ## Payload: (the validator's text, verbatim)
|
|
15
|
+
// - When the proposal is adopted, governance calls `target_method_name` on `target_canister_id` with the same bytes.
|
|
16
|
+
// - `get_proposal` returns the rendering, but the payload only when it is at most 64 bytes; a longer payload comes
|
|
17
|
+
// back as a text summary with its SHA-256. `list_proposals` returns neither.
|
|
18
|
+
// Fact: the NNS dapp shows `payload_text_rendering` as Markdown (dfinity/nns-dapp 94b6cde,
|
|
19
|
+
// SnsProposalPayloadSection.svelte -> ProposalSummary.svelte -> gix-components Markdown).
|
|
20
|
+
//
|
|
21
|
+
// So the validator's text is what voters read about the call, and it is written by the dapp that proposes it.
|
|
22
|
+
// Rendering it from a published profile lets anyone with the payload bytes and the profile render it again and
|
|
23
|
+
// compare (`verifySnsRendering`).
|
|
24
|
+
//
|
|
25
|
+
// Rules (`SNS_RENDERING_RULES`; proposal docs/proposals/w4-sns-validator-rendering.md):
|
|
26
|
+
// 1. The body is the generic consent message of spec section 12 with the title at heading level 3, so it nests
|
|
27
|
+
// under governance's `## Payload:`. Times are shown in UTC (`2026-10-09 12:00:00 UTC`, rule 6 of section 12):
|
|
28
|
+
// a proposal has many readers and no user preferences.
|
|
29
|
+
// 2. Then the method the rendering describes and its effect (spec section 5.3; the default for an update without
|
|
30
|
+
// an `effect` tag is said to be a default).
|
|
31
|
+
// 3. Then the arguments as Candid text with no formatter applied (base units, nanoseconds, bytes), always: there is
|
|
32
|
+
// no signer between the text and the voter to show them (spec section 11 item 1).
|
|
33
|
+
// 4. Then where the words come from: every profile with its profile_id, the interface_id, and these rules.
|
|
34
|
+
// 5. Anything the generator cannot describe is `Err`, so governance rejects the proposal: an unknown method,
|
|
35
|
+
// bytes that do not decode, an untypeable position, a missing dynamic ref, more than the message limits.
|
|
36
|
+
// A proposal never reaches voters undescribed.
|
|
37
|
+
|
|
38
|
+
import { createHash } from "node:crypto";
|
|
39
|
+
import { c } from "@candid-core/schema";
|
|
40
|
+
import { decode, encode } from "@candid-core/schema/codec";
|
|
41
|
+
import { MAX_MESSAGE_CHARS, Renderer } from "./display.mjs";
|
|
42
|
+
import { bytesOf } from "./bytes.mjs";
|
|
43
|
+
import { ConsentError, CODES, fail } from "./errors.mjs";
|
|
44
|
+
import { buildModel } from "./model.mjs";
|
|
45
|
+
import { renderGeneric } from "./render.mjs";
|
|
46
|
+
import { methodOf } from "./resolved.mjs";
|
|
47
|
+
import { codeBlock, codeSpan, escapeMarkdown, oneLine } from "./text.mjs";
|
|
48
|
+
|
|
49
|
+
/** The name and version of the rules this module renders by. Part of every rendering, so a re-render knows what to compare. */
|
|
50
|
+
export const SNS_RENDERING_RULES = "candid-profile-sns/v0";
|
|
51
|
+
|
|
52
|
+
const MAX_ERROR = 1000;
|
|
53
|
+
const ValidatorResult = c.variant({ Ok: c.text, Err: c.text });
|
|
54
|
+
|
|
55
|
+
// ------------------------------------------------------------------------------------------------ rendering
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The validator method as a function.
|
|
59
|
+
*
|
|
60
|
+
* @param {object} input
|
|
61
|
+
* @param {Uint8Array | ArrayBuffer | number[]} input.payload `ExecuteGenericNervousSystemFunction.payload`: the Candid
|
|
62
|
+
* argument bytes of the target method, exactly as governance passes them
|
|
63
|
+
* @param {object} input.resolved the resolved form of the target canister (docs/profile-v0.md section 10)
|
|
64
|
+
* @param {string} input.method the target method this validator is registered for (`target_method_name`)
|
|
65
|
+
* @param {Record<string, unknown>} [input.refs] resolved dynamic refs, keyed by `from`
|
|
66
|
+
* @param {object} [input.limits] decoder limits
|
|
67
|
+
* @returns {{ Ok: string } | { Err: string }} plus two non-enumerable properties: `diagnostics` and `provenance`
|
|
68
|
+
*/
|
|
69
|
+
export function snsValidatorRendering({ payload, resolved, method, refs = {}, limits } = {}) {
|
|
70
|
+
let result;
|
|
71
|
+
let extras = { diagnostics: [], provenance: null };
|
|
72
|
+
try {
|
|
73
|
+
extras = renderSnsPayload({ payload, resolved, method, refs, limits });
|
|
74
|
+
result = { Ok: extras.text };
|
|
75
|
+
} catch (e) {
|
|
76
|
+
result = { Err: errorText(e) };
|
|
77
|
+
extras.diagnostics.push({ code: e instanceof ConsentError ? e.code : "E_INTERNAL", at: "payload", message: String(e?.message ?? e) });
|
|
78
|
+
}
|
|
79
|
+
Object.defineProperty(result, "diagnostics", { value: extras.diagnostics, enumerable: false });
|
|
80
|
+
Object.defineProperty(result, "provenance", { value: extras.provenance, enumerable: false });
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The canister-side wrapper: the payload bytes in (the validator method takes the target method's arguments), the
|
|
86
|
+
* Candid-encoded `variant { Ok : text; Err : text }` out.
|
|
87
|
+
*
|
|
88
|
+
* @param {Uint8Array} payload
|
|
89
|
+
* @param {{ resolved: object, method: string, refs?: Record<string, unknown>, limits?: object }} context
|
|
90
|
+
* @returns {Uint8Array}
|
|
91
|
+
*/
|
|
92
|
+
export function handleSnsValidatorCall(payload, context) {
|
|
93
|
+
return encodeValidatorResult(snsValidatorRendering({ ...context, payload }));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Candid-encode a validator reply. */
|
|
97
|
+
export function encodeValidatorResult(result) {
|
|
98
|
+
const keys = result !== null && typeof result === "object" ? Object.keys(result) : [];
|
|
99
|
+
if (keys.length !== 1 || (keys[0] !== "Ok" && keys[0] !== "Err") || typeof result[keys[0]] !== "string") fail(CODES.REQUEST, "a validator result is { Ok: text } or { Err: text }");
|
|
100
|
+
const r = encode(ValidatorResult, { tag: keys[0], value: result[keys[0]] });
|
|
101
|
+
if (!r.ok) fail(CODES.REQUEST, `the validator result does not encode: ${r.issues[0].code} at ${r.issues[0].path}`);
|
|
102
|
+
return r.bytes;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Decode a validator reply, as governance does (`Decode!(&reply, Result<String, String>)`). */
|
|
106
|
+
export function decodeValidatorResult(bytes) {
|
|
107
|
+
const r = decode(ValidatorResult, bytes);
|
|
108
|
+
if (!r.ok) fail(CODES.DECODE, `not a variant { Ok : text; Err : text }: ${r.issues[0].code} at ${r.issues[0].path}`);
|
|
109
|
+
return { [r.value.tag]: r.value.value };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function renderSnsPayload({ payload, resolved, method: name, refs, limits }) {
|
|
113
|
+
const method = methodOf(resolved, name);
|
|
114
|
+
const model = buildModel({ resolved, method: name, arg: payload, limits });
|
|
115
|
+
const renderer = new Renderer({ refs, utcOffsetMinutes: 0, diagnostics: model.diagnostics });
|
|
116
|
+
const blocks = [renderGeneric(model, renderer, { headingLevel: 3 })];
|
|
117
|
+
blocks.push(`**Method:** ${codeSpan(name)}. **Effect:** ${effectText(method)}.`);
|
|
118
|
+
blocks.push(`**Arguments (Candid, as sent, no units applied):**\n\n${codeBlock(renderer.plainTupleText(model.argNodes, model.argValues))}`);
|
|
119
|
+
blocks.push(sourceText(resolved));
|
|
120
|
+
const text = blocks.join("\n\n");
|
|
121
|
+
if (text.length > MAX_MESSAGE_CHARS) fail(CODES.TOO_LARGE, `the rendering would exceed ${MAX_MESSAGE_CHARS} characters; it is never truncated`);
|
|
122
|
+
return { text, diagnostics: model.diagnostics, provenance: model.provenance };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const isQuery = (method) => method.mode === "query" || method.mode === "composite_query";
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The effect kind and reversibility (spec section 5.3), and whether a profile stated them. The resolver writes the
|
|
129
|
+
* section 5.3 default with origin "default" when no profile has an `effect` tag; this says so instead of passing a
|
|
130
|
+
* default off as the author's word.
|
|
131
|
+
*/
|
|
132
|
+
function effectText(method) {
|
|
133
|
+
const tag = method.tags?.effect;
|
|
134
|
+
const query = isQuery(method);
|
|
135
|
+
const kind = typeof tag?.args?.kind === "string" ? tag.args.kind : query ? "read" : "write";
|
|
136
|
+
const irreversible = typeof tag?.args?.irreversible === "boolean" ? tag.args.irreversible : tag ? null : !query;
|
|
137
|
+
const reversibility = kind === "read" || irreversible === null ? "" : irreversible ? ", irreversible" : ", reversible";
|
|
138
|
+
const stated = tag !== undefined && tag.origin !== "default";
|
|
139
|
+
const note = stated ? "" : ` (the default for ${query ? "a query" : "an update"}; no profile states an effect)`;
|
|
140
|
+
return `${escapeMarkdown(oneLine(kind))}${reversibility}${note}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function sourceText(resolved) {
|
|
144
|
+
const lines = [];
|
|
145
|
+
const profiles = Array.isArray(resolved.profiles) ? resolved.profiles : [];
|
|
146
|
+
for (const p of profiles) {
|
|
147
|
+
const id = typeof p?.profile_id === "string" ? `: ${codeSpan(p.profile_id)}` : "";
|
|
148
|
+
lines.push(`- Profile ${codeSpan(String(p?.name ?? "(unnamed)"))} (${escapeMarkdown(oneLine(String(p?.origin ?? "unknown origin")))})${id}`);
|
|
149
|
+
}
|
|
150
|
+
if (profiles.length === 0) lines.push("- No profile: every argument is listed as Candid");
|
|
151
|
+
if (typeof resolved.interface_id === "string") lines.push(`- Interface: ${codeSpan(resolved.interface_id)}`);
|
|
152
|
+
lines.push(`- Rules: ${codeSpan(SNS_RENDERING_RULES)}`);
|
|
153
|
+
return `**Rendered from:**\n\n${lines.join("\n")}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function errorText(e) {
|
|
157
|
+
const s = e instanceof ConsentError ? `${e.code}: ${e.message}` : `E_INTERNAL: ${String(e?.message ?? e)}`;
|
|
158
|
+
return s.length > MAX_ERROR ? `${s.slice(0, MAX_ERROR)}...` : s;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ------------------------------------------------------------------------------------------------ the reviewer's check
|
|
162
|
+
|
|
163
|
+
const HEAD = "# Proposal to execute nervous system function:\n\n## Nervous system function:\n\n";
|
|
164
|
+
const SHA_HEAD = "\n\n## Payload sha256: \n\n";
|
|
165
|
+
const PAYLOAD_HEAD = "\n\n## Payload:\n\n";
|
|
166
|
+
const SUMMARY_MARK = "NOT THE ORIGINAL CONTENTS OF THIS FIELD";
|
|
167
|
+
|
|
168
|
+
const DEBUG_STRING = String.raw`"(?:[^"\\\n]|\\.)*"`;
|
|
169
|
+
const DEBUG_PRINCIPAL = "[a-z0-9]+(?:-[a-z0-9]+)+";
|
|
170
|
+
/** `field: None,` or `field: Some(` value `),` on lines of their own at `indent` spaces, as `{:#?}` prints an Option. */
|
|
171
|
+
const optionField = (indent, field, value) => {
|
|
172
|
+
const pad = " ".repeat(indent);
|
|
173
|
+
return `${pad}${field}: (?:None|Some\\(\\n${pad} (${value}),\\n${pad}\\)),\\n`;
|
|
174
|
+
};
|
|
175
|
+
/**
|
|
176
|
+
* The whole function block, exactly as `{:#?}` prints a generic NervousSystemFunction (dfinity/ic 649156c): one field
|
|
177
|
+
* per line at a fixed indent. Debug strings escape line breaks and quotes, so text in the name or the description stays
|
|
178
|
+
* on its own line and can never pass for a field. Groups: description, target canister, target method, validator
|
|
179
|
+
* canister, validator method, topic.
|
|
180
|
+
*/
|
|
181
|
+
const FUNCTION_BLOCK = new RegExp(
|
|
182
|
+
"^NervousSystemFunction \\{\\n" +
|
|
183
|
+
" id: \\d+,\\n" +
|
|
184
|
+
` name: ${DEBUG_STRING},\\n` +
|
|
185
|
+
optionField(4, "description", DEBUG_STRING) +
|
|
186
|
+
" function_type: Some\\(\\n" +
|
|
187
|
+
" GenericNervousSystemFunction\\(\\n" +
|
|
188
|
+
" GenericNervousSystemFunction \\{\\n" +
|
|
189
|
+
optionField(16, "target_canister_id", DEBUG_PRINCIPAL) +
|
|
190
|
+
optionField(16, "target_method_name", DEBUG_STRING) +
|
|
191
|
+
optionField(16, "validator_canister_id", DEBUG_PRINCIPAL) +
|
|
192
|
+
optionField(16, "validator_method_name", DEBUG_STRING) +
|
|
193
|
+
optionField(16, "topic", "[A-Za-z]+") +
|
|
194
|
+
" \\},\\n" +
|
|
195
|
+
" \\),\\n" +
|
|
196
|
+
" \\),\\n" +
|
|
197
|
+
"\\}$",
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Split a stored `payload_text_rendering` into governance's parts. Strings in the function block are Rust debug text,
|
|
202
|
+
* which escapes line breaks, so the first `## Payload sha256:` header after the function block is governance's own.
|
|
203
|
+
* The function block must be the generic function's debug text exactly (`FUNCTION_BLOCK`); anything else is not
|
|
204
|
+
* governance's layout, and the fields are read only from their own lines.
|
|
205
|
+
*
|
|
206
|
+
* @param {string} text
|
|
207
|
+
* @returns {{ functionText: string, payloadSha256: string, payloadRendering: string, targetMethod: string | null,
|
|
208
|
+
* targetCanisterId: string | null, validatorMethod: string | null, validatorCanisterId: string | null } | null}
|
|
209
|
+
*/
|
|
210
|
+
export function parseGovernanceRendering(text) {
|
|
211
|
+
if (typeof text !== "string" || !text.startsWith(HEAD)) return null;
|
|
212
|
+
const sha = text.indexOf(SHA_HEAD, HEAD.length);
|
|
213
|
+
if (sha < 0) return null;
|
|
214
|
+
const hexStart = sha + SHA_HEAD.length;
|
|
215
|
+
const payloadSha256 = text.slice(hexStart, hexStart + 64);
|
|
216
|
+
if (!/^[0-9a-f]{64}$/.test(payloadSha256) || !text.startsWith(PAYLOAD_HEAD, hexStart + 64)) return null;
|
|
217
|
+
const functionText = text.slice(HEAD.length, sha);
|
|
218
|
+
const fn = FUNCTION_BLOCK.exec(functionText);
|
|
219
|
+
if (fn === null) return null;
|
|
220
|
+
const debugString = (s) => (s === undefined ? null : s.slice(1, -1).replace(/\\(.)/g, "$1"));
|
|
221
|
+
return {
|
|
222
|
+
functionText,
|
|
223
|
+
payloadSha256,
|
|
224
|
+
payloadRendering: text.slice(hexStart + 64 + PAYLOAD_HEAD.length),
|
|
225
|
+
targetMethod: debugString(fn[3]),
|
|
226
|
+
targetCanisterId: fn[2] ?? null,
|
|
227
|
+
validatorMethod: debugString(fn[5]),
|
|
228
|
+
validatorCanisterId: fn[4] ?? null,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The reviewer's check of an SNS proposal that executes a generic nervous system function: do the payload bytes the
|
|
234
|
+
* proposer published hash to what governance recorded, does the function call the canister and the method the profile
|
|
235
|
+
* describes, and is the validator's text exactly what the profile renders for those bytes?
|
|
236
|
+
*
|
|
237
|
+
* @param {object} input
|
|
238
|
+
* @param {string} input.payloadTextRendering `ProposalData.payload_text_rendering`, from `get_proposal`
|
|
239
|
+
* @param {Uint8Array | ArrayBuffer | number[]} input.payload the payload bytes, from the proposer (`get_proposal`
|
|
240
|
+
* returns them only up to 64 bytes)
|
|
241
|
+
* @param {object} input.resolved the resolved form of the target canister
|
|
242
|
+
* @param {string} input.targetCanisterId the canister the profile describes, as text: the one the reviewer expects
|
|
243
|
+
* governance to call. A resolved form names no canister, so without it the `target_canister` check fails.
|
|
244
|
+
* @param {string} input.method the method the reviewer expects the proposal to call
|
|
245
|
+
* @param {Record<string, unknown>} [input.refs]
|
|
246
|
+
* @param {object} [input.limits]
|
|
247
|
+
* @returns {{ ok: boolean, checks: Array<{ name: string, ok: boolean, expected?: string, found?: string | null, note?: string }>,
|
|
248
|
+
* expectedRendering: string | null, parsed: object | null }}
|
|
249
|
+
*/
|
|
250
|
+
export function verifySnsRendering({ payloadTextRendering, payload, resolved, targetCanisterId, method, refs = {}, limits }) {
|
|
251
|
+
const checks = [];
|
|
252
|
+
const parsed = parseGovernanceRendering(payloadTextRendering);
|
|
253
|
+
checks.push({ name: "governance_layout", ok: parsed !== null, ...(parsed === null ? { note: "not the rendering SNS governance stores for an ExecuteGenericNervousSystemFunction proposal" } : {}) });
|
|
254
|
+
|
|
255
|
+
const bytes = toBytes(payload);
|
|
256
|
+
const digest = createHash("sha256").update(bytes).digest("hex");
|
|
257
|
+
const summary = new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes).includes(SUMMARY_MARK);
|
|
258
|
+
checks.push({
|
|
259
|
+
name: "payload_sha256",
|
|
260
|
+
ok: parsed !== null && parsed.payloadSha256 === digest,
|
|
261
|
+
expected: digest,
|
|
262
|
+
found: parsed?.payloadSha256 ?? null,
|
|
263
|
+
...(summary ? { note: "these bytes are get_proposal's summary of a payload over 64 bytes, not the payload; get the original bytes from the proposer" } : {}),
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const expectedTarget = typeof targetCanisterId === "string" ? targetCanisterId : null;
|
|
267
|
+
checks.push({
|
|
268
|
+
name: "target_canister",
|
|
269
|
+
ok: parsed !== null && expectedTarget !== null && parsed.targetCanisterId === expectedTarget,
|
|
270
|
+
expected: expectedTarget,
|
|
271
|
+
found: parsed?.targetCanisterId ?? null,
|
|
272
|
+
...(expectedTarget === null ? { note: "give targetCanisterId, the canister the profile describes: a function may target another canister with the same method" } : {}),
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
checks.push({
|
|
276
|
+
name: "target_method",
|
|
277
|
+
ok: parsed !== null && parsed.targetMethod === method,
|
|
278
|
+
expected: method,
|
|
279
|
+
found: parsed?.targetMethod ?? null,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const r = snsValidatorRendering({ payload: bytes, resolved, method, refs, limits });
|
|
283
|
+
checks.push({
|
|
284
|
+
name: "payload_rendering",
|
|
285
|
+
ok: parsed !== null && "Ok" in r && parsed.payloadRendering === r.Ok,
|
|
286
|
+
...("Err" in r ? { note: `the profile cannot render these bytes: ${r.Err}` } : {}),
|
|
287
|
+
});
|
|
288
|
+
return { ok: checks.every((x) => x.ok), checks, expectedRendering: r.Ok ?? null, parsed };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function toBytes(payload) {
|
|
292
|
+
return bytesOf(payload) ?? fail(CODES.REQUEST, "the payload must be bytes: a Uint8Array, an ArrayBuffer or an array of integers 0 to 255");
|
|
293
|
+
}
|
package/src/template.mjs
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Intent templates (docs/profile-v0.md section 6):
|
|
2
|
+
//
|
|
3
|
+
// Send {amount} to {to}[ from subaccount {from_subaccount}][ with memo {memo}]
|
|
4
|
+
//
|
|
5
|
+
// Placeholders `{relpath}`, optional segments `[ ... ]` (no nesting in v0), escapes `{{ }} [[ ]]`. The scan is
|
|
6
|
+
// the one of `parseTemplate` in packages/resolver/src/checks.mjs, character for character, so a template the
|
|
7
|
+
// resolver accepted parses here into the same placeholders (a test compares the two on the fixtures and on
|
|
8
|
+
// generated templates). Unlike that function, this one keeps the literal text.
|
|
9
|
+
|
|
10
|
+
import { CODES, fail } from "./errors.mjs";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {{ t: "lit", text: string } | { t: "ph", path: string }} Part
|
|
14
|
+
* @typedef {Part | { t: "opt", parts: Part[] }} TemplatePart
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** @returns {TemplatePart[]} */
|
|
18
|
+
export function parseTemplate(template) {
|
|
19
|
+
if (typeof template !== "string") fail(CODES.TEMPLATE, "the intent template is not a string");
|
|
20
|
+
/** @type {TemplatePart[]} */
|
|
21
|
+
const top = [];
|
|
22
|
+
/** @type {Part[] | null} */
|
|
23
|
+
let segment = null;
|
|
24
|
+
let lit = "";
|
|
25
|
+
const target = () => segment ?? top;
|
|
26
|
+
const flush = () => {
|
|
27
|
+
if (lit !== "") target().push({ t: "lit", text: lit });
|
|
28
|
+
lit = "";
|
|
29
|
+
};
|
|
30
|
+
for (let i = 0; i < template.length; i++) {
|
|
31
|
+
const ch = template[i];
|
|
32
|
+
if ((ch === "{" || ch === "}" || ch === "[" || ch === "]") && template[i + 1] === ch) {
|
|
33
|
+
lit += ch;
|
|
34
|
+
i++;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (ch === "[") {
|
|
38
|
+
if (segment) fail(CODES.TEMPLATE, "nested [ ] segments are not allowed in v0");
|
|
39
|
+
flush();
|
|
40
|
+
segment = [];
|
|
41
|
+
} else if (ch === "]") {
|
|
42
|
+
if (!segment) fail(CODES.TEMPLATE, "unbalanced ]");
|
|
43
|
+
flush();
|
|
44
|
+
top.push({ t: "opt", parts: segment });
|
|
45
|
+
segment = null;
|
|
46
|
+
} else if (ch === "{") {
|
|
47
|
+
const end = template.indexOf("}", i);
|
|
48
|
+
if (end < 0) fail(CODES.TEMPLATE, "unclosed {");
|
|
49
|
+
flush();
|
|
50
|
+
target().push({ t: "ph", path: template.slice(i + 1, end) });
|
|
51
|
+
i = end;
|
|
52
|
+
} else if (ch === "}") {
|
|
53
|
+
fail(CODES.TEMPLATE, "unbalanced }");
|
|
54
|
+
} else lit += ch;
|
|
55
|
+
}
|
|
56
|
+
if (segment) fail(CODES.TEMPLATE, "unclosed [");
|
|
57
|
+
flush();
|
|
58
|
+
return top;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Every placeholder of a parsed template, in order, with whether it sits in an optional segment. */
|
|
62
|
+
export function placeholdersOf(parts) {
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const p of parts) {
|
|
65
|
+
if (p.t === "ph") out.push({ path: p.path, inOptional: false });
|
|
66
|
+
else if (p.t === "opt") for (const q of p.parts) if (q.t === "ph") out.push({ path: q.path, inOptional: true });
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
package/src/text.mjs
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Text safety: making untrusted strings visible and escaping them for the target format (spec section 6,
|
|
2
|
+
// "Escaping values"; section 11 item 4).
|
|
3
|
+
//
|
|
4
|
+
// Two targets:
|
|
5
|
+
// - plain text (ICRC-21 FieldsDisplayMessage): no formatting elements, so only invisible and control
|
|
6
|
+
// characters are made visible.
|
|
7
|
+
// - Markdown (GenericDisplayMessage): values from the call (the relying party is not trusted) go into a code
|
|
8
|
+
// span, where nothing is parsed: no link, image, autolink, HTML, emphasis or entity. Text that comes from
|
|
9
|
+
// the profile (template literals, labels) is plain text: its Markdown punctuation is backslash-escaped.
|
|
10
|
+
|
|
11
|
+
/** Control (Cc), format (Cf, including bidi overrides), line separator (Zl) and paragraph separator (Zp) characters. */
|
|
12
|
+
const UNSAFE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu;
|
|
13
|
+
const LINE_BREAKS = /[\r\n\u0085\u2028\u2029]+/g;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Show invisible characters instead of passing them through: U+202E becomes the six characters `\u{202e}`.
|
|
17
|
+
* Never drops a character. A lone surrogate (possible in JSON read from disk) becomes U+FFFD.
|
|
18
|
+
*/
|
|
19
|
+
export function visible(s) {
|
|
20
|
+
return String(s).toWellFormed().replace(UNSAFE, (ch) => `\\u{${ch.codePointAt(0).toString(16)}}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Template text and labels come from the profile: one line, so line breaks become one space. */
|
|
24
|
+
export function oneLine(s) {
|
|
25
|
+
return visible(String(s).toWellFormed().replace(LINE_BREAKS, " "));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Backslash-escape the characters that start inline Markdown constructs (CommonMark: any ASCII punctuation may be
|
|
30
|
+
* escaped). An underscore between two letters or digits cannot open emphasis in CommonMark, so `snake_case` stays
|
|
31
|
+
* readable in the raw message; every other underscore is escaped.
|
|
32
|
+
*/
|
|
33
|
+
export function escapeMarkdown(s) {
|
|
34
|
+
return s.replace(/[\\`*[\]<>&~|#]|_/gu, (ch, i) => {
|
|
35
|
+
if (ch === "_" && /[\p{L}\p{N}]/u.test(s[i - 1] ?? "") && /[\p{L}\p{N}]/u.test(s[i + 1] ?? "")) return ch;
|
|
36
|
+
return `\\${ch}`;
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A list marker or ordered-list number at the start of a line would open a block; escape it. */
|
|
41
|
+
export function escapeLineStart(s) {
|
|
42
|
+
return s.replace(/^(\s*)(\d+)([.)])/, "$1$2\\$3").replace(/^(\s*)([-+=])/, "$1\\$2");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* One string as a Markdown code span: verbatim, whatever it contains. The fence is one backtick longer than the
|
|
47
|
+
* longest backtick run inside; a space pads content that starts or ends with a backtick or a space (CommonMark
|
|
48
|
+
* strips one space from each side when both are present). Control and invisible characters are made visible
|
|
49
|
+
* first. An empty string has no code span; the caller shows `*(empty)*` instead (see emptyMarkdown).
|
|
50
|
+
*/
|
|
51
|
+
export function codeSpan(s) {
|
|
52
|
+
const text = visible(s);
|
|
53
|
+
if (text === "") return emptyMarkdown();
|
|
54
|
+
let longest = 0;
|
|
55
|
+
for (const run of text.match(/`+/g) ?? []) longest = Math.max(longest, run.length);
|
|
56
|
+
const fence = "`".repeat(longest + 1);
|
|
57
|
+
const allSpaces = /^ +$/.test(text);
|
|
58
|
+
const pad = !allSpaces && (/^[ `]/.test(text) || /[ `]$/.test(text)) ? " " : "";
|
|
59
|
+
return `${fence}${pad}${text}${pad}${fence}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** An empty string, distinguishable from a code span that contains the same words. */
|
|
63
|
+
export function emptyMarkdown() {
|
|
64
|
+
return "*(empty)*";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A fenced block for one line of technical text; the fence outgrows any backtick run inside. */
|
|
68
|
+
export function codeBlock(s) {
|
|
69
|
+
const text = visible(s);
|
|
70
|
+
let longest = 2;
|
|
71
|
+
for (const run of text.match(/`+/g) ?? []) longest = Math.max(longest, run.length);
|
|
72
|
+
const fence = "`".repeat(longest + 1);
|
|
73
|
+
return `${fence}\n${text}\n${fence}`;
|
|
74
|
+
}
|