@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/index.d.mts
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Types for @meantjs/consent. Hand-written; the source is plain ES modules (src/*.mjs).
|
|
2
|
+
// test/types.test.mjs keeps the declared names equal to the runtime exports, and `npm run types` compiles a consumer
|
|
3
|
+
// against these declarations under strict TypeScript.
|
|
4
|
+
import type { Formatter } from "@meantjs/format";
|
|
5
|
+
|
|
6
|
+
/** A candid-profile-resolved v0 document (docs/profile-v0.md section 10). */
|
|
7
|
+
export interface ResolvedForm {
|
|
8
|
+
format: "candid-profile-resolved";
|
|
9
|
+
format_version: 0;
|
|
10
|
+
methods: Record<string, unknown>;
|
|
11
|
+
[key: string]: unknown;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Argument bytes as this package takes them. */
|
|
15
|
+
export type Bytes = Uint8Array | ArrayBuffer | readonly number[];
|
|
16
|
+
|
|
17
|
+
export interface DecodeLimits {
|
|
18
|
+
maxBytes?: number;
|
|
19
|
+
maxDepth?: number;
|
|
20
|
+
maxElements?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ------------------------------------------------------------------ the ICRC-21 shapes (ICRC-21.did, JS shape)
|
|
24
|
+
// A variant is { Tag: payload }, opt T is null or the value, nat and nat64 are bigint, nat8 and int16 numbers.
|
|
25
|
+
|
|
26
|
+
export interface Icrc21Metadata {
|
|
27
|
+
language: string;
|
|
28
|
+
utc_offset_minutes: number | null;
|
|
29
|
+
}
|
|
30
|
+
export type Icrc21DeviceSpec = { GenericDisplay: null } | { FieldsDisplay: null };
|
|
31
|
+
export interface Icrc21ConsentMessageSpec {
|
|
32
|
+
metadata: Icrc21Metadata;
|
|
33
|
+
device_spec: Icrc21DeviceSpec | null;
|
|
34
|
+
}
|
|
35
|
+
export interface Icrc21ConsentMessageRequest {
|
|
36
|
+
method: string;
|
|
37
|
+
arg: Uint8Array;
|
|
38
|
+
user_preferences: Icrc21ConsentMessageSpec;
|
|
39
|
+
}
|
|
40
|
+
export type Icrc21Value =
|
|
41
|
+
| { TokenAmount: { decimals: number; amount: bigint; symbol: string } }
|
|
42
|
+
| { TimestampSeconds: { amount: bigint } }
|
|
43
|
+
| { DurationSeconds: { amount: bigint } }
|
|
44
|
+
| { Text: { content: string } };
|
|
45
|
+
export type Icrc21ConsentMessage =
|
|
46
|
+
| { GenericDisplayMessage: string }
|
|
47
|
+
| { FieldsDisplayMessage: { intent: string; fields: [string, Icrc21Value][] } };
|
|
48
|
+
export interface Icrc21ConsentInfo {
|
|
49
|
+
consent_message: Icrc21ConsentMessage;
|
|
50
|
+
metadata: Icrc21Metadata;
|
|
51
|
+
}
|
|
52
|
+
export type Icrc21Error =
|
|
53
|
+
| { UnsupportedCanisterCall: { description: string } }
|
|
54
|
+
| { ConsentMessageUnavailable: { description: string } }
|
|
55
|
+
| { InsufficientPayment: { description: string } }
|
|
56
|
+
| { GenericError: { error_code: bigint; description: string } };
|
|
57
|
+
export type Icrc21ConsentMessageResponse = { Ok: Icrc21ConsentInfo } | { Err: Icrc21Error };
|
|
58
|
+
|
|
59
|
+
/** Where the words of a message come from (ICRC-49: a signer warns when a profile, not the canister, wrote them). */
|
|
60
|
+
export interface Provenance {
|
|
61
|
+
intent: { origin: string; profile?: string } | null;
|
|
62
|
+
profiles: unknown[];
|
|
63
|
+
}
|
|
64
|
+
export interface ConsentDiagnostic {
|
|
65
|
+
code: string;
|
|
66
|
+
at: string;
|
|
67
|
+
message: string;
|
|
68
|
+
[key: string]: unknown;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface Icrc21Context {
|
|
72
|
+
resolved: ResolvedForm;
|
|
73
|
+
/** Resolved dynamic refs, keyed by `from`: `{ "query:icrc1_decimals": 8, "query:icrc1_symbol": "ICP" }`. */
|
|
74
|
+
refs?: Record<string, unknown>;
|
|
75
|
+
/** Default "fallback" (the ICRC-21 preference). */
|
|
76
|
+
languagePolicy?: "fallback" | "error";
|
|
77
|
+
/** Append the decoded raw arguments (spec section 11 item 1). */
|
|
78
|
+
rawArguments?: boolean;
|
|
79
|
+
limits?: DecodeLimits;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The response, with two non-enumerable properties: what was adjusted, and where the words come from. */
|
|
83
|
+
export type Icrc21Result = Icrc21ConsentMessageResponse & { readonly diagnostics: ConsentDiagnostic[]; readonly provenance: Provenance };
|
|
84
|
+
|
|
85
|
+
/** The ICRC-21 call as a function. `arg` may also be an ArrayBuffer or an array of byte values. */
|
|
86
|
+
export function icrc21ConsentMessage(request: Omit<Icrc21ConsentMessageRequest, "arg"> & { arg: Bytes }, context: Icrc21Context): Icrc21Result;
|
|
87
|
+
/** Candid bytes of `icrc21_consent_message_request` in, Candid bytes of `icrc21_consent_message_response` out. */
|
|
88
|
+
export function handleConsentRequest(requestBytes: Uint8Array, context: Icrc21Context): Uint8Array;
|
|
89
|
+
/** A ConsentError (or any thrown value) as the ICRC-21 error variant. */
|
|
90
|
+
export function toIcrc21Error(error: unknown): Icrc21Error;
|
|
91
|
+
/** The only language v0 emits. */
|
|
92
|
+
export const SUPPORTED_LANGUAGE: "en";
|
|
93
|
+
|
|
94
|
+
export function encodeConsentResponse(response: Icrc21ConsentMessageResponse): Uint8Array;
|
|
95
|
+
export function decodeConsentResponse(bytes: Uint8Array): Icrc21ConsentMessageResponse;
|
|
96
|
+
export function encodeConsentRequest(request: Icrc21ConsentMessageRequest): Uint8Array;
|
|
97
|
+
/** Throws E_REQUEST on bytes that are not a request. */
|
|
98
|
+
export function decodeConsentRequest(bytes: Uint8Array): Icrc21ConsentMessageRequest;
|
|
99
|
+
/** A JSON-safe copy: bigint as a decimal string, Uint8Array as `{ hex }`. */
|
|
100
|
+
export function toJson(value: unknown): unknown;
|
|
101
|
+
|
|
102
|
+
// ------------------------------------------------------------------ one form, and the parts
|
|
103
|
+
|
|
104
|
+
export interface ModelField {
|
|
105
|
+
/** The placeholder path, or the dotted path of a flattened leaf. */
|
|
106
|
+
path: string;
|
|
107
|
+
/** `describe.label` of the position, else its field name (raw, not yet escaped). */
|
|
108
|
+
label: string;
|
|
109
|
+
node: unknown;
|
|
110
|
+
/** The present values: none (absent), one, or one per list element. */
|
|
111
|
+
values: unknown[];
|
|
112
|
+
}
|
|
113
|
+
export type SentencePart = { lit: string } | { field: ModelField };
|
|
114
|
+
export interface Model {
|
|
115
|
+
method: string;
|
|
116
|
+
/** The method's effect kind (spec section 5.3). */
|
|
117
|
+
effect: string;
|
|
118
|
+
title: string;
|
|
119
|
+
/** null for a method without an intent template. */
|
|
120
|
+
sentence: SentencePart[] | null;
|
|
121
|
+
fields: ModelField[];
|
|
122
|
+
argNodes: unknown[];
|
|
123
|
+
argValues: unknown[];
|
|
124
|
+
diagnostics: ConsentDiagnostic[];
|
|
125
|
+
provenance: Provenance;
|
|
126
|
+
[key: string]: unknown;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface GenerateConsentInput {
|
|
130
|
+
resolved: ResolvedForm;
|
|
131
|
+
method: string;
|
|
132
|
+
/** The Candid-encoded argument tuple. */
|
|
133
|
+
arg: Bytes;
|
|
134
|
+
refs?: Record<string, unknown>;
|
|
135
|
+
/** Default "generic". */
|
|
136
|
+
form?: "fields" | "generic";
|
|
137
|
+
/** Generic form only: show timestamps in this zone (default UTC). */
|
|
138
|
+
utcOffsetMinutes?: number | null;
|
|
139
|
+
rawArguments?: boolean;
|
|
140
|
+
limits?: DecodeLimits;
|
|
141
|
+
}
|
|
142
|
+
/** One form without the ICRC-21 envelope. `zoneSensitive` is true when a timestamp was shown in the requested zone. */
|
|
143
|
+
export function generateConsent(input: GenerateConsentInput): {
|
|
144
|
+
form: "fields" | "generic";
|
|
145
|
+
message: Icrc21ConsentMessage;
|
|
146
|
+
zoneSensitive: boolean;
|
|
147
|
+
diagnostics: ConsentDiagnostic[];
|
|
148
|
+
provenance: Provenance;
|
|
149
|
+
model: Model;
|
|
150
|
+
};
|
|
151
|
+
export function buildModel(input: { resolved: ResolvedForm; method: string; arg: Bytes; limits?: DecodeLimits }): Model;
|
|
152
|
+
/** Argument bytes to one value tree per argument. Throws E_DECODE, E_TOO_LARGE, E_UNSUPPORTED_TYPE or E_RESOLVED_FORM. */
|
|
153
|
+
export function decodeArguments(method: { args: unknown[] }, arg: Bytes, limits?: DecodeLimits): unknown[];
|
|
154
|
+
/** The candid-core codec schema of one resolved-form position. */
|
|
155
|
+
export function schemaFor(node: unknown, where?: string, depth?: number): unknown;
|
|
156
|
+
|
|
157
|
+
export type TemplateLiteralPart = { t: "lit"; text: string } | { t: "ph"; path: string };
|
|
158
|
+
export type TemplatePart = TemplateLiteralPart | { t: "opt"; parts: TemplateLiteralPart[] };
|
|
159
|
+
/** Throws E_TEMPLATE. */
|
|
160
|
+
export function parseTemplate(template: string): TemplatePart[];
|
|
161
|
+
|
|
162
|
+
export type PathStep = { kind: "field" | "case"; key: string } | { kind: "elem" };
|
|
163
|
+
export interface ResolvedPath {
|
|
164
|
+
index: number;
|
|
165
|
+
steps: PathStep[];
|
|
166
|
+
node: unknown;
|
|
167
|
+
nodes: unknown[];
|
|
168
|
+
}
|
|
169
|
+
export function resolvePath(method: { args: unknown[] }, relpath: string): ResolvedPath | { error: string };
|
|
170
|
+
/** The present values at a resolved path. */
|
|
171
|
+
export function valuesAt(path: ResolvedPath, values: unknown[]): unknown[];
|
|
172
|
+
|
|
173
|
+
/** Renders positions as text, with a budget per message. Used by `renderFields` and `renderGeneric`. */
|
|
174
|
+
export declare class Renderer {
|
|
175
|
+
/** `utcOffsetMinutes` as a number shows timestamps in that zone (the generic form); otherwise UTC. */
|
|
176
|
+
constructor(options?: { refs?: Record<string, unknown>; utcOffsetMinutes?: number | null; diagnostics?: ConsentDiagnostic[] });
|
|
177
|
+
refs: Record<string, unknown>;
|
|
178
|
+
utcOffsetMinutes: number | null;
|
|
179
|
+
diagnostics: ConsentDiagnostic[];
|
|
180
|
+
chars: number;
|
|
181
|
+
/** Set when a timestamp was shown in the requested zone. */
|
|
182
|
+
usedZone: boolean;
|
|
183
|
+
formatter(node: unknown): Formatter | null;
|
|
184
|
+
charge(text: string): string;
|
|
185
|
+
text(node: unknown, value: unknown, where?: string): string;
|
|
186
|
+
tupleText(nodes: unknown[], values: unknown[]): string;
|
|
187
|
+
plainTupleText(nodes: unknown[], values: unknown[]): string;
|
|
188
|
+
}
|
|
189
|
+
/** `UTC`, `UTC+02:00`, `UTC-05:30`. */
|
|
190
|
+
export function zoneName(offsetMinutes: number): string;
|
|
191
|
+
export function renderFields(model: Model, renderer: Renderer, options?: { rawArguments?: boolean }): { intent: string; fields: [string, Icrc21Value][] };
|
|
192
|
+
/** `headingLevel`: the title's heading level (default 1). */
|
|
193
|
+
export function renderGeneric(model: Model, renderer: Renderer, options?: { rawArguments?: boolean; headingLevel?: number }): string;
|
|
194
|
+
|
|
195
|
+
/** A Markdown code span that shows the string verbatim (invisible characters made visible). */
|
|
196
|
+
export function codeSpan(s: string): string;
|
|
197
|
+
export function escapeMarkdown(s: string): string;
|
|
198
|
+
/** Invisible characters shown as `\u{202e}`; nothing is dropped. */
|
|
199
|
+
export function visible(s: string): string;
|
|
200
|
+
|
|
201
|
+
// ------------------------------------------------------------------ errors
|
|
202
|
+
|
|
203
|
+
export const CODES: Readonly<{
|
|
204
|
+
METHOD_UNKNOWN: "E_METHOD_UNKNOWN";
|
|
205
|
+
DECODE: "E_DECODE";
|
|
206
|
+
TOO_LARGE: "E_TOO_LARGE";
|
|
207
|
+
LANGUAGE: "E_LANGUAGE";
|
|
208
|
+
REQUEST: "E_REQUEST";
|
|
209
|
+
UNSUPPORTED_TYPE: "E_UNSUPPORTED_TYPE";
|
|
210
|
+
RESOLVED_FORM: "E_RESOLVED_FORM";
|
|
211
|
+
TEMPLATE: "E_TEMPLATE";
|
|
212
|
+
TEMPLATE_PATH: "E_TEMPLATE_PATH";
|
|
213
|
+
REF_UNRESOLVED: "E_REF_UNRESOLVED";
|
|
214
|
+
ARG: "E_ARG";
|
|
215
|
+
TAG_TYPE: "E_TAG_TYPE";
|
|
216
|
+
TAG_CONFLICT: "E_TAG_CONFLICT";
|
|
217
|
+
}>;
|
|
218
|
+
export type ConsentErrorCode = (typeof CODES)[keyof typeof CODES];
|
|
219
|
+
/** ICRC-21 `GenericError.error_code` values this package uses. */
|
|
220
|
+
export const GENERIC_ERROR_CODES: Readonly<{
|
|
221
|
+
E_RESOLVED_FORM: 1000n;
|
|
222
|
+
E_TEMPLATE: 1001n;
|
|
223
|
+
E_TEMPLATE_PATH: 1002n;
|
|
224
|
+
E_REF_UNRESOLVED: 1100n;
|
|
225
|
+
E_ARG: 1101n;
|
|
226
|
+
E_TAG_TYPE: 1102n;
|
|
227
|
+
E_TAG_CONFLICT: 1103n;
|
|
228
|
+
INTERNAL: 1999n;
|
|
229
|
+
}>;
|
|
230
|
+
export declare class ConsentError extends Error {
|
|
231
|
+
constructor(code: ConsentErrorCode, message: string, options?: { cause?: unknown });
|
|
232
|
+
name: "ConsentError";
|
|
233
|
+
code: ConsentErrorCode;
|
|
234
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { icrc21ConsentMessage, handleConsentRequest, toIcrc21Error, SUPPORTED_LANGUAGE } from "./icrc21.mjs";
|
|
2
|
+
export { encodeConsentResponse, decodeConsentResponse, encodeConsentRequest, decodeConsentRequest, toJson } from "./icrc21-codec.mjs";
|
|
3
|
+
export { generateConsent } from "./consent.mjs";
|
|
4
|
+
export { buildModel } from "./model.mjs";
|
|
5
|
+
export { decodeArguments, schemaFor } from "./decode.mjs";
|
|
6
|
+
export { parseTemplate } from "./template.mjs";
|
|
7
|
+
export { resolvePath, valuesAt } from "./paths.mjs";
|
|
8
|
+
export { Renderer, zoneName } from "./display.mjs";
|
|
9
|
+
export { renderFields, renderGeneric } from "./render.mjs";
|
|
10
|
+
export { codeSpan, escapeMarkdown, visible } from "./text.mjs";
|
|
11
|
+
export { ConsentError, CODES, GENERIC_ERROR_CODES } from "./errors.mjs";
|
|
12
|
+
// SNS validator renderings are the `./sns` subpath (`@meantjs/consent/sns`): they hash with node:crypto,
|
|
13
|
+
// and this entry must bundle for a browser (test/browser-bundle.test.mjs).
|
package/src/model.mjs
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
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 method with an `intent` tag: the placeholders of its template that are present (spec section 6; section 12:
|
|
7
|
+
// "one (label, Value) row per placeholder"), in template order, once per distinct path;
|
|
8
|
+
// - a method without one: the generic intent. Title from `describe.label` of the method, else `Call <method>`;
|
|
9
|
+
// one field per leaf of the decoded arguments (records are flattened, tagged positions such as accounts are
|
|
10
|
+
// leaves, absent optionals are left out). More than MAX_FIELDS fields or MAX_ELEMENTS list elements is
|
|
11
|
+
// refused (E_TOO_LARGE), never truncated.
|
|
12
|
+
|
|
13
|
+
import { VALUE_TAGS } from "@meantjs/format";
|
|
14
|
+
import { decodeArguments } from "./decode.mjs";
|
|
15
|
+
import { CODES, ConsentError, fail } from "./errors.mjs";
|
|
16
|
+
import { effectKind, entriesOf, labelOf, methodOf } from "./resolved.mjs";
|
|
17
|
+
import { parseTemplate } from "./template.mjs";
|
|
18
|
+
import { resolvePath, valuesAt } from "./paths.mjs";
|
|
19
|
+
|
|
20
|
+
export const MAX_FIELDS = 64;
|
|
21
|
+
export const MAX_ELEMENTS = 256;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {object} Field
|
|
25
|
+
* @property {string} path the placeholder path, or the dotted path of a flattened leaf
|
|
26
|
+
* @property {string} label describe.label of the position, else its field name (raw, not yet escaped)
|
|
27
|
+
* @property {object} node the resolved-form position
|
|
28
|
+
* @property {unknown[]} values the present values: none (absent), one, or one per list element
|
|
29
|
+
*
|
|
30
|
+
* @typedef {{ lit: string } | { field: Field }} SentencePart
|
|
31
|
+
*
|
|
32
|
+
* @typedef {object} Model
|
|
33
|
+
* @property {string} method
|
|
34
|
+
* @property {string} effect the effect kind of the method (spec section 5.3)
|
|
35
|
+
* @property {string} title
|
|
36
|
+
* @property {SentencePart[] | null} sentence null for a method without an intent template
|
|
37
|
+
* @property {Field[]} fields
|
|
38
|
+
* @property {object[]} argNodes
|
|
39
|
+
* @property {unknown[]} argValues
|
|
40
|
+
* @property {object[]} diagnostics
|
|
41
|
+
* @property {{ intent: { origin: string, profile?: string } | null, profiles: object[] }} provenance where the words come from
|
|
42
|
+
* (ICRC-49: a signer warns when it, not the canister, wrote the message; spec section 11 item 1: a profile is a claim)
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {{ resolved: object, method: string, arg: Uint8Array | ArrayBuffer | number[], limits?: object }} input
|
|
47
|
+
* @returns {Model}
|
|
48
|
+
*/
|
|
49
|
+
export function buildModel({ resolved, method: name, arg, limits }) {
|
|
50
|
+
const method = methodOf(resolved, name);
|
|
51
|
+
const argValues = decodeArguments(method, arg, limits);
|
|
52
|
+
const diagnostics = [];
|
|
53
|
+
const intent = method.tags?.intent?.args;
|
|
54
|
+
const title = typeof intent?.title === "string" && intent.title.trim() !== "" ? intent.title : (labelOf(method) ?? `Call ${name}`);
|
|
55
|
+
const tagOrigin = intent ? { origin: method.tags.intent.origin, ...(method.tags.intent.profile === undefined ? {} : { profile: method.tags.intent.profile }) } : null;
|
|
56
|
+
const provenance = { intent: tagOrigin, profiles: Array.isArray(resolved.profiles) ? resolved.profiles : [] };
|
|
57
|
+
const base = { method: name, effect: effectKind(method), title, argNodes: method.args, argValues, diagnostics, provenance };
|
|
58
|
+
|
|
59
|
+
if (intent && typeof intent.template === "string") {
|
|
60
|
+
try {
|
|
61
|
+
const { sentence, fields } = applyTemplate(method, argValues, parseTemplate(intent.template));
|
|
62
|
+
return { ...base, sentence, fields };
|
|
63
|
+
} catch (e) {
|
|
64
|
+
// A template the profile got wrong must not leave the user without a message: fall back to the generic
|
|
65
|
+
// intent, which lists every argument, and say so.
|
|
66
|
+
if (!(e instanceof ConsentError) || (e.code !== CODES.TEMPLATE && e.code !== CODES.TEMPLATE_PATH)) throw e;
|
|
67
|
+
diagnostics.push({ code: "W_TEMPLATE_FALLBACK", at: name, message: `${e.code}: ${e.message}; the generic intent is used instead` });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { ...base, sentence: null, fields: flatten(method, argValues) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------------------------- template
|
|
74
|
+
|
|
75
|
+
function nameOf(path, resolvedPath) {
|
|
76
|
+
for (let i = resolvedPath.steps.length - 1; i >= 0; i--) {
|
|
77
|
+
const s = resolvedPath.steps[i];
|
|
78
|
+
if (s.kind !== "elem") return s.key;
|
|
79
|
+
}
|
|
80
|
+
return `Argument ${resolvedPath.index + 1}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function applyTemplate(method, argValues, parts) {
|
|
84
|
+
const known = new Map(); // path -> Field
|
|
85
|
+
const sentence = [];
|
|
86
|
+
const fields = [];
|
|
87
|
+
|
|
88
|
+
const lookup = (path) => {
|
|
89
|
+
const hit = known.get(path);
|
|
90
|
+
if (hit) return { field: hit, fresh: false };
|
|
91
|
+
const rp = resolvePath(method, path);
|
|
92
|
+
if (rp.error) return { error: rp.error };
|
|
93
|
+
const values = valuesAt(rp, argValues);
|
|
94
|
+
if (values.length > MAX_ELEMENTS) fail(CODES.TOO_LARGE, `{${path}} has ${values.length} values; the limit is ${MAX_ELEMENTS}`);
|
|
95
|
+
return { field: { path, label: labelOf(rp.node) ?? nameOf(path, rp), node: rp.node, values }, fresh: true };
|
|
96
|
+
};
|
|
97
|
+
const register = ({ field, fresh }) => {
|
|
98
|
+
if (!fresh) return field;
|
|
99
|
+
known.set(field.path, field);
|
|
100
|
+
fields.push(field);
|
|
101
|
+
if (fields.length > MAX_FIELDS) fail(CODES.TOO_LARGE, `the template has more than ${MAX_FIELDS} distinct placeholders`);
|
|
102
|
+
return field;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
for (const part of parts) {
|
|
106
|
+
if (part.t === "lit") sentence.push({ lit: part.text });
|
|
107
|
+
else if (part.t === "ph") {
|
|
108
|
+
const found = lookup(part.path);
|
|
109
|
+
if (found.error) fail(CODES.TEMPLATE_PATH, `{${part.path}}: ${found.error}`);
|
|
110
|
+
sentence.push({ field: register(found) });
|
|
111
|
+
} else {
|
|
112
|
+
// [ ... ]: dropped when any placeholder inside is absent (null) or names a position this method lacks.
|
|
113
|
+
const looked = part.parts.map((p) => (p.t === "ph" ? lookup(p.path) : null));
|
|
114
|
+
const unresolved = looked.find((l) => l?.error);
|
|
115
|
+
if (unresolved || looked.some((l) => l && l.field.values.length === 0)) continue;
|
|
116
|
+
for (let i = 0; i < part.parts.length; i++) {
|
|
117
|
+
const p = part.parts[i];
|
|
118
|
+
sentence.push(p.t === "lit" ? { lit: p.text } : { field: register(looked[i]) });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { sentence, fields };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------------------------- generic intent
|
|
126
|
+
|
|
127
|
+
const hasValueTag = (node) => VALUE_TAGS.some((t) => node.tags && Object.hasOwn(node.tags, t));
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Field order of the resolved form is canonical id order (spec section 10), which reads as random. `ui.order`
|
|
131
|
+
* ("use ui.order for presentation") puts the fields that have one first, ascending; the rest keep id order.
|
|
132
|
+
*/
|
|
133
|
+
function presentationOrder(entries) {
|
|
134
|
+
const order = (child) => {
|
|
135
|
+
const n = child.tags?.ui?.args?.order;
|
|
136
|
+
return Number.isFinite(n) ? n : Infinity;
|
|
137
|
+
};
|
|
138
|
+
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);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function flatten(method, argValues) {
|
|
142
|
+
const fields = [];
|
|
143
|
+
const multi = method.args.length > 1;
|
|
144
|
+
const emit = (node, value, keys, index) => {
|
|
145
|
+
const dotted = keys.join(".");
|
|
146
|
+
const label = labelOf(node) ?? (multi ? `Argument ${index + 1}${dotted ? ` ${dotted}` : ""}` : dotted || `Argument ${index + 1}`);
|
|
147
|
+
if (node.candid === "vec" && value.length > MAX_ELEMENTS) fail(CODES.TOO_LARGE, `${label} has ${value.length} elements; the limit is ${MAX_ELEMENTS}`);
|
|
148
|
+
fields.push({ path: `(${index})${keys.map((k) => `.${k}`).join("")}`, label, node, values: [value] });
|
|
149
|
+
if (fields.length > MAX_FIELDS) fail(CODES.TOO_LARGE, `the arguments have more than ${MAX_FIELDS} fields`);
|
|
150
|
+
};
|
|
151
|
+
const walk = (node, value, keys, index) => {
|
|
152
|
+
if (node.optional === true && value === null) return;
|
|
153
|
+
const children = node.candid === "record" && node.recursive !== true && !hasValueTag(node) ? presentationOrder(entriesOf(node.fields)) : [];
|
|
154
|
+
if (children.length > 0) for (const [key, child] of children) walk(child, value[key], [...keys, key], index);
|
|
155
|
+
else emit(node, value, keys, index);
|
|
156
|
+
};
|
|
157
|
+
method.args.forEach((node, i) => walk(node, argValues[i], [], i));
|
|
158
|
+
return fields;
|
|
159
|
+
}
|
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,86 @@
|
|
|
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
|
+
/** One row's ICRC-21 Value: typed when the field is a single value the typed form carries exactly, else Text. */
|
|
54
|
+
function fieldValue(field, renderer) {
|
|
55
|
+
if (field.values.length === 1) {
|
|
56
|
+
const v = renderer.icrc21Value(field.node, field.values[0], field.path);
|
|
57
|
+
if (v.Text) v.Text.content = visible(v.Text.content);
|
|
58
|
+
return v;
|
|
59
|
+
}
|
|
60
|
+
return { Text: { content: fieldPlain(field, renderer) } };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {import("./model.mjs").Model} model
|
|
65
|
+
* @param {import("./display.mjs").Renderer} renderer zoned when the request carried a utc_offset_minutes
|
|
66
|
+
* @param {{ rawArguments?: boolean, headingLevel?: number }} [options] `headingLevel`: the title's heading level
|
|
67
|
+
* (default 1; an SNS validator rendering sits under governance's own `##` headings and uses 3)
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
export function renderGeneric(model, renderer, { rawArguments = false, headingLevel = 1 } = {}) {
|
|
71
|
+
const blocks = [`${"#".repeat(headingLevel)} ${escapeMarkdown(plainLabel(model.title, model.method))}`];
|
|
72
|
+
if (model.sentence !== null) {
|
|
73
|
+
let paragraph = model.sentence.map((p) => ("lit" in p ? escapeMarkdown(oneLine(p.lit)) : fieldMarkdown(p.field, renderer))).join("").trim();
|
|
74
|
+
if (model.sentence[0] && "lit" in model.sentence[0]) paragraph = escapeLineStart(paragraph);
|
|
75
|
+
if (paragraph !== "") blocks.push(paragraph);
|
|
76
|
+
}
|
|
77
|
+
if (model.fields.length > 0) {
|
|
78
|
+
blocks.push(model.fields.map((f) => `- **${escapeMarkdown(plainLabel(f.label, f.path))}:** ${fieldMarkdown(f, renderer)}`).join("\n"));
|
|
79
|
+
} else if (model.sentence === null) {
|
|
80
|
+
blocks.push("This call has no arguments.");
|
|
81
|
+
}
|
|
82
|
+
if (rawArguments) blocks.push(`**Raw arguments:**\n\n${codeBlock(renderer.tupleText(model.argNodes, model.argValues))}`);
|
|
83
|
+
return blocks.join("\n\n");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export { emptyMarkdown };
|
package/src/resolved.mjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
/** The method entry of a resolved form, or a ConsentError. */
|
|
10
|
+
export function methodOf(resolved, name) {
|
|
11
|
+
if (!isObject(resolved) || resolved.format !== "candid-profile-resolved" || !isObject(resolved.methods)) {
|
|
12
|
+
fail(CODES.RESOLVED_FORM, "not a candid-profile-resolved document (format, methods)");
|
|
13
|
+
}
|
|
14
|
+
if (typeof name !== "string" || !Object.hasOwn(resolved.methods, name)) {
|
|
15
|
+
fail(CODES.METHOD_UNKNOWN, `the resolved form has no method ${JSON.stringify(String(name))}`);
|
|
16
|
+
}
|
|
17
|
+
const m = resolved.methods[name];
|
|
18
|
+
if (!isObject(m) || !Array.isArray(m.args)) fail(CODES.RESOLVED_FORM, `method ${name} has no args array`);
|
|
19
|
+
return m;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The Candid field or case id of a resolved-form key: `#123` is the raw id, any other key is a name. */
|
|
23
|
+
export function keyId(key) {
|
|
24
|
+
return /^#(0|[1-9][0-9]{0,9})$/.test(key) ? Number(key.slice(1)) : idlHash(key);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Own entries of a `fields` or `cases` object (never walks the prototype chain). */
|
|
28
|
+
export function entriesOf(bucket) {
|
|
29
|
+
return isObject(bucket) ? Object.entries(bucket) : [];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `describe.label` of a position or method, when it is a non-empty string. */
|
|
33
|
+
export function labelOf(node) {
|
|
34
|
+
const label = node?.tags?.describe?.args?.label;
|
|
35
|
+
return typeof label === "string" && label.trim() !== "" ? label : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function tagArgs(node, tag) {
|
|
39
|
+
return node?.tags && Object.hasOwn(node.tags, tag) ? (node.tags[tag]?.args ?? {}) : undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The effect kind of a method: its tag, or the spec default (section 5.3: queries read, updates write). */
|
|
43
|
+
export function effectKind(method) {
|
|
44
|
+
return method.tags?.effect?.args?.kind ?? (method.mode === "query" || method.mode === "composite_query" ? "read" : "write");
|
|
45
|
+
}
|
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 };
|