@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.
@@ -0,0 +1,160 @@
1
+ // The ICRC-21 types (topics/ICRC-21/ICRC-21.did of dfinity/wg-identity-authentication, commit pinned in
2
+ // test/upstream/icrc21/SOURCE.json) as @candid-core/schema schemas, and the conversion between the JS shapes
3
+ // this package uses and the shapes of the codec.
4
+ //
5
+ // JS shape (what `icrc21ConsentMessage` takes and returns):
6
+ // variant { Tag: payload } a bare case is { Tag: null }
7
+ // opt T null when absent, else the value
8
+ // nat, nat64 bigint nat8, int16 number blob Uint8Array
9
+ // vec record { text; Value } array of [label, value] pairs
10
+ // Codec shape: variants are { tag, value } (a bare case is { tag }).
11
+
12
+ import { c } from "@candid-core/schema";
13
+ import { decode, encode } from "@candid-core/schema/codec";
14
+ import { asUint8Array } from "@meantjs/format";
15
+ import { CODES, fail } from "./errors.mjs";
16
+
17
+ const Metadata = c.record({ language: c.text, utc_offset_minutes: c.opt(c.int16) });
18
+ const DeviceSpec = c.variant({ GenericDisplay: c.null, FieldsDisplay: c.null });
19
+ const MessageSpec = c.record({ metadata: Metadata, device_spec: c.opt(DeviceSpec) });
20
+ export const RequestSchema = c.record({ method: c.text, arg: c.blob(), user_preferences: MessageSpec });
21
+
22
+ const TokenAmount = c.record({ decimals: c.nat8, amount: c.nat64, symbol: c.text });
23
+ const TimestampSeconds = c.record({ amount: c.nat64 });
24
+ const DurationSeconds = c.record({ amount: c.nat64 });
25
+ const TextValue = c.record({ content: c.text });
26
+ const Value = c.variant({ TokenAmount, TimestampSeconds, DurationSeconds, Text: TextValue });
27
+ const ConsentMessage = c.variant({
28
+ GenericDisplayMessage: c.text,
29
+ FieldsDisplayMessage: c.record({ intent: c.text, fields: c.vec(c.tuple([c.text, Value])) }),
30
+ });
31
+ const ConsentInfo = c.record({ consent_message: ConsentMessage, metadata: Metadata });
32
+ const ErrorInfo = c.record({ description: c.text });
33
+ const Icrc21Error = c.variant({
34
+ UnsupportedCanisterCall: ErrorInfo,
35
+ ConsentMessageUnavailable: ErrorInfo,
36
+ InsufficientPayment: ErrorInfo,
37
+ GenericError: c.record({ error_code: c.nat, description: c.text }),
38
+ });
39
+ export const ResponseSchema = c.variant({ Ok: ConsentInfo, Err: Icrc21Error });
40
+
41
+ // ------------------------------------------------------------------------------------------ shape conversion
42
+
43
+ function toCodec(schema, v, where) {
44
+ switch (schema.kind) {
45
+ case "variant": {
46
+ const keys = v !== null && typeof v === "object" ? Object.keys(v) : [];
47
+ if (keys.length !== 1 || !Object.hasOwn(schema.arms, keys[0])) fail(CODES.REQUEST, `${where}: expected a variant with exactly one of ${Object.keys(schema.arms).join(", ")}`);
48
+ const arm = schema.arms[keys[0]];
49
+ return arm.kind === "primitive" && arm.primitive === "null" ? { tag: keys[0] } : { tag: keys[0], value: toCodec(arm, v[keys[0]], `${where}.${keys[0]}`) };
50
+ }
51
+ case "record":
52
+ return Object.fromEntries(Object.entries(schema.fields).map(([k, s]) => [k, toCodec(s, v?.[k], `${where}.${k}`)]));
53
+ case "tuple":
54
+ return schema.elements.map((s, i) => toCodec(s, v?.[i], `${where}[${i}]`));
55
+ case "vec":
56
+ return v.map((e, i) => toCodec(schema.inner, e, `${where}[${i}]`));
57
+ case "opt":
58
+ return v === null || v === undefined ? null : toCodec(schema.inner, v, where);
59
+ default:
60
+ return v;
61
+ }
62
+ }
63
+
64
+ function fromCodec(schema, v) {
65
+ switch (schema.kind) {
66
+ case "variant": {
67
+ const arm = schema.arms[v.tag];
68
+ return { [v.tag]: arm.kind === "primitive" && arm.primitive === "null" ? null : fromCodec(arm, v.value) };
69
+ }
70
+ case "record":
71
+ return Object.fromEntries(Object.entries(schema.fields).map(([k, s]) => [k, fromCodec(s, v[k])]));
72
+ case "tuple":
73
+ return schema.elements.map((s, i) => fromCodec(s, v[i]));
74
+ case "vec":
75
+ return v.map((e) => fromCodec(schema.inner, e));
76
+ case "opt":
77
+ return v === null ? null : fromCodec(schema.inner, v);
78
+ default:
79
+ return v;
80
+ }
81
+ }
82
+
83
+ /** The widths the JS shape accepts for the numbers the codec wants as bigint. */
84
+ function widen(schema, v) {
85
+ switch (schema.kind) {
86
+ case "primitive":
87
+ return schema.primitive === "nat" || schema.primitive === "nat64" ? (typeof v === "number" && Number.isSafeInteger(v) ? BigInt(v) : v) : v;
88
+ case "variant":
89
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, schema.arms[k] ? widen(schema.arms[k], x) : x]));
90
+ case "record":
91
+ return Object.fromEntries(Object.entries(schema.fields).map(([k, s]) => [k, widen(s, v?.[k])]));
92
+ case "tuple":
93
+ return schema.elements.map((s, i) => widen(s, v?.[i]));
94
+ case "vec":
95
+ return Array.isArray(v) ? v.map((e) => widen(schema.inner, e)) : v;
96
+ case "opt":
97
+ return v === null || v === undefined ? null : widen(schema.inner, v);
98
+ default:
99
+ return v;
100
+ }
101
+ }
102
+
103
+ function run(schema, value, what) {
104
+ const r = encode(schema, toCodec(schema, widen(schema, value), what));
105
+ if (!r.ok) fail(CODES.REQUEST, `${what} does not match the ICRC-21 type: ${r.issues[0].code} at ${r.issues[0].path}: ${r.issues[0].message}`);
106
+ return r.bytes;
107
+ }
108
+
109
+ /**
110
+ * Candid-encode an `icrc21_consent_message_response` (the reply of `icrc21_canister_call_consent_message`).
111
+ * @param {{ Ok: object } | { Err: object }} response the JS shape of `icrc21ConsentMessage`'s result
112
+ * @returns {Uint8Array}
113
+ */
114
+ export function encodeConsentResponse(response) {
115
+ return run(ResponseSchema, response, "response");
116
+ }
117
+
118
+ /** Decode a Candid-encoded `icrc21_consent_message_response` into the JS shape. */
119
+ export function decodeConsentResponse(bytes) {
120
+ const r = decode(ResponseSchema, bytes);
121
+ if (!r.ok) fail(CODES.DECODE, `not an icrc21_consent_message_response: ${r.issues[0].code} at ${r.issues[0].path}`);
122
+ return fromCodec(ResponseSchema, r.value);
123
+ }
124
+
125
+ /** Candid-encode an `icrc21_consent_message_request` (the argument of the ICRC-21 call). */
126
+ export function encodeConsentRequest(request) {
127
+ return run(RequestSchema, request, "request");
128
+ }
129
+
130
+ /** Decode the argument of `icrc21_canister_call_consent_message` into the JS shape. Throws E_REQUEST on bytes that are not one. */
131
+ export function decodeConsentRequest(bytes) {
132
+ const r = decode(RequestSchema, bytes, { maxBytes: 2 * 1024 * 1024 + 4096 });
133
+ if (!r.ok) fail(CODES.REQUEST, `not an icrc21_consent_message_request: ${r.issues[0].code} at ${r.issues[0].path}: ${r.issues[0].message}`);
134
+ return fromCodec(RequestSchema, r.value);
135
+ }
136
+
137
+ const HEX_DIGITS = new TextEncoder().encode("0123456789abcdef");
138
+ const ascii = new TextDecoder();
139
+ /**
140
+ * Lowercase hex of the bytes (no Buffer: this module runs in a browser too). The digits go into one byte array and
141
+ * are decoded once, so a blob near the 2 MiB request limit costs two buffers, not a string per byte.
142
+ */
143
+ function hex(bytes) {
144
+ const out = new Uint8Array(bytes.length * 2);
145
+ for (let i = 0; i < bytes.length; i++) {
146
+ out[2 * i] = HEX_DIGITS[bytes[i] >> 4];
147
+ out[2 * i + 1] = HEX_DIGITS[bytes[i] & 15];
148
+ }
149
+ return ascii.decode(out);
150
+ }
151
+
152
+ /** JSON-safe copy of a JS-shape value: bigint as a decimal string (D-006), Uint8Array as { hex }. For fixtures and logs. */
153
+ export function toJson(value) {
154
+ if (typeof value === "bigint") return value.toString();
155
+ const bytes = asUint8Array(value); // from any realm
156
+ if (bytes !== null) return { hex: hex(bytes) };
157
+ if (Array.isArray(value)) return value.map(toJson);
158
+ if (value !== null && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, toJson(v)]));
159
+ return value;
160
+ }
package/src/icrc21.mjs ADDED
@@ -0,0 +1,139 @@
1
+ // The ICRC-21 entry point: `icrc21_canister_call_consent_message` as a function.
2
+ //
3
+ // Request : { method, arg, user_preferences: { metadata: { language, utc_offset_minutes }, device_spec } }
4
+ // Response : { Ok: { consent_message, metadata } } | { Err: { UnsupportedCanisterCall | ConsentMessageUnavailable
5
+ // | InsufficientPayment | GenericError } } (shapes: see icrc21-codec.mjs)
6
+ //
7
+ // Behaviors the ICRC-21 text fixes (topics/ICRC-21/ICRC-21.did and icrc_21_consent_msg.md of dfinity/wg-identity-authentication):
8
+ // - "Providing a message should be preferred over sending an icrc21_error": a language other than English gets the
9
+ // English message, and `metadata.language` says "en" ("the metadata must match the consent_message provided").
10
+ // Set `languagePolicy: "error"` to refuse instead (UnsupportedCanisterCall).
11
+ // - A missing `device_spec` falls back to GenericDisplay (the spec: "one of the values defined in this spec").
12
+ // - `utc_offset_minutes` absent in the request: the generic message is in UTC. Absent in the response means the
13
+ // message is not time-zone sensitive; the response carries the applied offset exactly when a time is shown in
14
+ // the generic form. The fields form carries seconds since the epoch and is never zone-sensitive.
15
+ // - The consent message must not depend on state: this function has no network access; dynamic refs (decimals,
16
+ // symbol) are values the caller resolved (spec section 4).
17
+
18
+ import { bytesOf } from "./bytes.mjs";
19
+ import { ConsentError, CODES, GENERIC_ERROR_CODES } from "./errors.mjs";
20
+ import { generateConsent } from "./consent.mjs";
21
+ import { decodeConsentRequest, encodeConsentResponse } from "./icrc21-codec.mjs";
22
+
23
+ /** The only language v0 emits (profile `language: "en"`; spec section 1: multi-language templates are a non-goal). */
24
+ export const SUPPORTED_LANGUAGE = "en";
25
+ const MAX_OFFSET_MINUTES = 24 * 60;
26
+ const MAX_DESCRIPTION = 1000;
27
+
28
+ const BCP47 = /^[A-Za-z]{2,3}(?:[-_][A-Za-z0-9]{1,8})*$/;
29
+
30
+ /**
31
+ * @param {object} request the ICRC-21 request in the JS shape
32
+ * @param {object} context
33
+ * @param {object} context.resolved the resolved form of the target canister (docs/profile-v0.md section 10)
34
+ * @param {Record<string, unknown>} [context.refs] resolved dynamic refs, keyed by `from`: { "query:icrc1_decimals": 8, "query:icrc1_symbol": "ICP" }
35
+ * @param {"fallback" | "error"} [context.languagePolicy] default "fallback" (the ICRC-21 preference)
36
+ * @param {boolean} [context.rawArguments] append the decoded raw arguments (spec section 11 item 1)
37
+ * @param {object} [context.limits] decoder limits
38
+ * @returns {{ Ok: object } | { Err: object }} plus two non-enumerable properties on the result: `diagnostics` (what was
39
+ * adjusted on the way: format fallbacks, language fallback) and `provenance` (`{ intent: { origin, profile } | null, profiles }`,
40
+ * so a signer can warn when the words come from a profile and not from the canister)
41
+ */
42
+ export function icrc21ConsentMessage(request, context) {
43
+ const diagnostics = [];
44
+ const extras = { provenance: null };
45
+ let result;
46
+ try {
47
+ result = { Ok: build(request, context, diagnostics, extras) };
48
+ } catch (e) {
49
+ result = { Err: toIcrc21Error(e) };
50
+ diagnostics.push({ code: e instanceof ConsentError ? e.code : "E_INTERNAL", at: "request", message: String(e?.message ?? e) });
51
+ }
52
+ Object.defineProperty(result, "diagnostics", { value: diagnostics, enumerable: false });
53
+ Object.defineProperty(result, "provenance", { value: extras.provenance, enumerable: false });
54
+ return result;
55
+ }
56
+
57
+ function build(request, context, diagnostics, extras) {
58
+ const { resolved, refs = {}, languagePolicy = "fallback", rawArguments = false, limits } = context ?? {};
59
+ if (request === null || typeof request !== "object") throw new ConsentError(CODES.REQUEST, "the request is not an object");
60
+ const { method, user_preferences: prefs } = request;
61
+ const arg = bytesOf(request.arg);
62
+ if (typeof method !== "string") throw new ConsentError(CODES.REQUEST, "request.method must be text");
63
+ if (arg === null) throw new ConsentError(CODES.REQUEST, "request.arg must be bytes: a Uint8Array, an ArrayBuffer or an array of integers 0 to 255");
64
+ const language = prefs?.metadata?.language;
65
+ if (typeof language !== "string") throw new ConsentError(CODES.REQUEST, "user_preferences.metadata.language must be text");
66
+
67
+ // language
68
+ const primary = BCP47.test(language) ? language.split(/[-_]/)[0].toLowerCase() : null;
69
+ if (primary !== SUPPORTED_LANGUAGE && languagePolicy === "error") {
70
+ throw new ConsentError(CODES.LANGUAGE, `language ${JSON.stringify(language.slice(0, 40))} is not supported; this generator writes "${SUPPORTED_LANGUAGE}"`);
71
+ }
72
+ const answeredLanguage = primary === SUPPORTED_LANGUAGE ? language.replace("_", "-") : SUPPORTED_LANGUAGE;
73
+ if (primary !== SUPPORTED_LANGUAGE) diagnostics.push({ code: "I_LANGUAGE_FALLBACK", at: "user_preferences", message: `language ${JSON.stringify(language.slice(0, 40))} is not supported; the message is in "${SUPPORTED_LANGUAGE}" and the metadata says so` });
74
+
75
+ // device spec
76
+ const spec = deviceSpecOf(prefs.device_spec);
77
+
78
+ // time zone
79
+ let offset = prefs.metadata.utc_offset_minutes ?? null;
80
+ if (offset !== null && (!Number.isInteger(offset) || Math.abs(offset) > MAX_OFFSET_MINUTES)) {
81
+ diagnostics.push({ code: "W_UTC_OFFSET_IGNORED", at: "user_preferences", message: `utc_offset_minutes ${String(offset)} is outside +-${MAX_OFFSET_MINUTES}; UTC is used` });
82
+ offset = null;
83
+ }
84
+
85
+ const r = generateConsent({ resolved, method, arg, refs, form: spec === "FieldsDisplay" ? "fields" : "generic", utcOffsetMinutes: offset, rawArguments, limits });
86
+ diagnostics.push(...r.diagnostics);
87
+ extras.provenance = r.provenance;
88
+ return {
89
+ consent_message: r.message,
90
+ metadata: { language: answeredLanguage, utc_offset_minutes: r.zoneSensitive ? (offset ?? 0) : null },
91
+ };
92
+ }
93
+
94
+ function deviceSpecOf(spec) {
95
+ if (spec === undefined || spec === null) return "GenericDisplay";
96
+ if (spec === "GenericDisplay" || spec === "FieldsDisplay") return spec;
97
+ const keys = typeof spec === "object" ? Object.keys(spec) : [];
98
+ if (keys.length === 1 && (keys[0] === "GenericDisplay" || keys[0] === "FieldsDisplay")) return keys[0];
99
+ throw new ConsentError(CODES.REQUEST, "device_spec must be GenericDisplay or FieldsDisplay");
100
+ }
101
+
102
+ const clip = (s) => (s.length > MAX_DESCRIPTION ? `${s.slice(0, MAX_DESCRIPTION)}...` : s);
103
+
104
+ /** ConsentError (or any thrown value) -> the ICRC-21 `icrc21_error` variant. */
105
+ export function toIcrc21Error(e) {
106
+ if (!(e instanceof ConsentError)) return { GenericError: { error_code: GENERIC_ERROR_CODES.INTERNAL, description: clip(`internal error: ${String(e?.message ?? e)}`) } };
107
+ const description = clip(`${e.code}: ${e.message}`);
108
+ switch (e.code) {
109
+ case CODES.METHOD_UNKNOWN:
110
+ case CODES.DECODE:
111
+ case CODES.TOO_LARGE:
112
+ case CODES.LANGUAGE:
113
+ case CODES.REQUEST:
114
+ return { UnsupportedCanisterCall: { description } };
115
+ case CODES.UNSUPPORTED_TYPE:
116
+ return { ConsentMessageUnavailable: { description } };
117
+ default:
118
+ return { GenericError: { error_code: GENERIC_ERROR_CODES[e.code] ?? GENERIC_ERROR_CODES.INTERNAL, description } };
119
+ }
120
+ }
121
+
122
+ /**
123
+ * The canister-side wrapper: the Candid-encoded argument of `icrc21_canister_call_consent_message` in, the
124
+ * Candid-encoded `icrc21_consent_message_response` out. A request that does not decode gets an
125
+ * UnsupportedCanisterCall reply, as the ICRC-21 interface prescribes for a malformed call.
126
+ *
127
+ * @param {Uint8Array} requestBytes
128
+ * @param {object} context as for icrc21ConsentMessage
129
+ * @returns {Uint8Array}
130
+ */
131
+ export function handleConsentRequest(requestBytes, context) {
132
+ let response;
133
+ try {
134
+ response = icrc21ConsentMessage(decodeConsentRequest(requestBytes), context);
135
+ } catch (e) {
136
+ response = { Err: toIcrc21Error(e) };
137
+ }
138
+ return encodeConsentResponse(response);
139
+ }
@@ -0,0 +1,236 @@
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
+ DEPLOYMENT: "E_DEPLOYMENT";
210
+ UNSUPPORTED_TYPE: "E_UNSUPPORTED_TYPE";
211
+ RESOLVED_FORM: "E_RESOLVED_FORM";
212
+ TEMPLATE: "E_TEMPLATE";
213
+ TEMPLATE_PATH: "E_TEMPLATE_PATH";
214
+ REF_UNRESOLVED: "E_REF_UNRESOLVED";
215
+ ARG: "E_ARG";
216
+ TAG_TYPE: "E_TAG_TYPE";
217
+ TAG_CONFLICT: "E_TAG_CONFLICT";
218
+ EXPORT: "E_EXPORT";
219
+ }>;
220
+ export type ConsentErrorCode = (typeof CODES)[keyof typeof CODES];
221
+ /** ICRC-21 `GenericError.error_code` values this package uses. */
222
+ export const GENERIC_ERROR_CODES: Readonly<{
223
+ E_RESOLVED_FORM: 1000n;
224
+ E_TEMPLATE: 1001n;
225
+ E_TEMPLATE_PATH: 1002n;
226
+ E_REF_UNRESOLVED: 1100n;
227
+ E_ARG: 1101n;
228
+ E_TAG_TYPE: 1102n;
229
+ E_TAG_CONFLICT: 1103n;
230
+ INTERNAL: 1999n;
231
+ }>;
232
+ export declare class ConsentError extends Error {
233
+ constructor(code: ConsentErrorCode, message: string, options?: { cause?: unknown });
234
+ name: "ConsentError";
235
+ code: ConsentErrorCode;
236
+ }
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).