@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/display.mjs
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// Turning one value of the tree into text, and into an ICRC-21 `Value`.
|
|
2
|
+
//
|
|
3
|
+
// Formatting goes through `formatterFor` of @meantjs/format for every tagged position
|
|
4
|
+
// (spec section 6: "rendered with the formatter of the tag at that position"); nothing here converts a unit,
|
|
5
|
+
// a decimal or an account by itself. An untagged position renders as Candid text.
|
|
6
|
+
//
|
|
7
|
+
// A formatter can fail on a value (for example a 5-byte subaccount, which no ledger accepts). The value is then
|
|
8
|
+
// shown as raw Candid text instead of hiding it, and the failure is recorded in `diagnostics`. A formatter that
|
|
9
|
+
// cannot be built (a dynamic ref the caller did not supply, a bad tag argument) is a configuration error and
|
|
10
|
+
// stops the message: showing a number without its decimals would be worse than no message.
|
|
11
|
+
|
|
12
|
+
import { FormatError, convertUnit, displayTimestamp, formatterFor } from "@meantjs/format";
|
|
13
|
+
import { CODES, ConsentError, fail } from "./errors.mjs";
|
|
14
|
+
import { entriesOf } from "./resolved.mjs";
|
|
15
|
+
import { visible } from "./text.mjs";
|
|
16
|
+
|
|
17
|
+
const U64_MAX = 2n ** 64n - 1n;
|
|
18
|
+
const CONFIG_CODES = new Set(["E_REF_UNRESOLVED", "E_ARG", "E_TAG_TYPE", "E_TAG_CONFLICT", "E_TYPE"]);
|
|
19
|
+
|
|
20
|
+
/** Budget for the text of one message: a consent message is read by a person (ICRC-21: short and concise). */
|
|
21
|
+
export const MAX_MESSAGE_CHARS = 32 * 1024;
|
|
22
|
+
|
|
23
|
+
export class Renderer {
|
|
24
|
+
/** Set while `plainTupleText` runs: no formatter applies. */
|
|
25
|
+
#plain = false;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {{ refs?: Record<string, unknown>, utcOffsetMinutes?: number | null, diagnostics?: object[] }} options
|
|
29
|
+
* `refs`: the resolved dynamic values, keyed by their `from` string (for example `query:icrc1_decimals`).
|
|
30
|
+
* `utcOffsetMinutes`: when a number, timestamps are shown in that zone (the generic form); otherwise in UTC.
|
|
31
|
+
*/
|
|
32
|
+
constructor({ refs = {}, utcOffsetMinutes = null, diagnostics = [] } = {}) {
|
|
33
|
+
this.refs = refs;
|
|
34
|
+
this.utcOffsetMinutes = utcOffsetMinutes;
|
|
35
|
+
this.diagnostics = diagnostics;
|
|
36
|
+
this.chars = 0;
|
|
37
|
+
this.formatters = new WeakMap();
|
|
38
|
+
/** Set when a timestamp was shown in the requested zone, so the response can say the text is zone-sensitive. */
|
|
39
|
+
this.usedZone = false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** @returns {import("@meantjs/format").Formatter | null} */
|
|
43
|
+
formatter(node) {
|
|
44
|
+
if (this.formatters.has(node)) return this.formatters.get(node);
|
|
45
|
+
let f;
|
|
46
|
+
try {
|
|
47
|
+
f = formatterFor(node, { refs: this.refs });
|
|
48
|
+
} catch (e) {
|
|
49
|
+
if (e instanceof FormatError && CONFIG_CODES.has(e.code)) throw new ConsentError(e.code, e.message, { cause: e });
|
|
50
|
+
throw e;
|
|
51
|
+
}
|
|
52
|
+
this.formatters.set(node, f);
|
|
53
|
+
return f;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
charge(text) {
|
|
57
|
+
this.chars += text.length;
|
|
58
|
+
if (this.chars > MAX_MESSAGE_CHARS) fail(CODES.TOO_LARGE, `the message would exceed ${MAX_MESSAGE_CHARS} characters; it is never truncated`);
|
|
59
|
+
return text;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Plain display text of one value, not yet escaped for any format: the formatter of its tag, or Candid text
|
|
64
|
+
* (spec section 6; a bare principal is its canonical text).
|
|
65
|
+
* @param {object} node the resolved-form position
|
|
66
|
+
* @param {unknown} value its value (a tree node, never absent)
|
|
67
|
+
* @param {string} where for diagnostics
|
|
68
|
+
*/
|
|
69
|
+
text(node, value, where) {
|
|
70
|
+
const shown = this.#formatted(node, value, where);
|
|
71
|
+
if (shown !== null) return this.charge(shown);
|
|
72
|
+
// An untagged principal is its canonical text, as everywhere else on the Internet Computer. Inside a composite it
|
|
73
|
+
// stays `principal "..."` so that the Candid text of the composite is still Candid.
|
|
74
|
+
if (node.candid === "principal") return this.charge(value);
|
|
75
|
+
return this.charge(this.#raw(node, value, where));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Candid text of a whole argument tuple (the "raw arguments" of spec section 11 item 1), tagged children formatted. */
|
|
79
|
+
tupleText(nodes, values) {
|
|
80
|
+
return this.charge(`(${nodes.map((n, i) => this.#child(n, values[i], `argument ${i}`)).join(", ")})`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Candid text of a whole argument tuple with no formatter applied: base units, nanoseconds, blobs as bytes. A view
|
|
85
|
+
* that does not depend on the profile's units, for a reader who checks the profile itself (spec section 11 item 1).
|
|
86
|
+
*/
|
|
87
|
+
plainTupleText(nodes, values) {
|
|
88
|
+
this.#plain = true;
|
|
89
|
+
try {
|
|
90
|
+
return this.charge(`(${nodes.map((n, i) => this.#child(n, values[i], `argument ${i}`)).join(", ")})`);
|
|
91
|
+
} finally {
|
|
92
|
+
this.#plain = false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The formatter's text, or null when the position has no value tag or the formatter refuses the value. */
|
|
97
|
+
#formatted(node, value, where) {
|
|
98
|
+
if (this.#plain) return null;
|
|
99
|
+
const f = this.formatter(node);
|
|
100
|
+
if (f === null) return null;
|
|
101
|
+
try {
|
|
102
|
+
return f.tag === "timestamp" && this.utcOffsetMinutes !== null ? this.#zoned(f, value) : f.display(value);
|
|
103
|
+
} catch (e) {
|
|
104
|
+
if (!(e instanceof FormatError)) throw e;
|
|
105
|
+
this.diagnostics.push({ code: "W_FORMAT_FALLBACK", at: where, message: `the ${f.tag} formatter refused the value (${e.code}: ${e.message}); it is shown as raw Candid text` });
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A timestamp in the requested zone: the instant shifted by whole minutes, then shown with the library's calendar code. */
|
|
111
|
+
#zoned(f, value) {
|
|
112
|
+
const offset = this.utcOffsetMinutes;
|
|
113
|
+
const local = BigInt(convertUnit(value, f.args.unit, "ns")) + BigInt(offset) * 60_000_000_000n;
|
|
114
|
+
const utc = displayTimestamp(local, { unit: "ns", candid: "int" });
|
|
115
|
+
this.usedZone = true;
|
|
116
|
+
return `${utc.slice(0, -1).replace("T", " ")} ${zoneName(offset)}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A child of a composite: absent is `null`, a tagged child uses its formatter, any other is raw (`opt` marks a present optional). */
|
|
120
|
+
#child(node, value, where) {
|
|
121
|
+
if (node.optional === true && value === null) return "null";
|
|
122
|
+
const shown = this.#formatted(node, value, where);
|
|
123
|
+
if (shown !== null) return shown;
|
|
124
|
+
const raw = this.#raw(node, value, where);
|
|
125
|
+
return node.optional === true ? `opt ${raw}` : raw;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
#raw(node, value, where) {
|
|
129
|
+
switch (node.candid) {
|
|
130
|
+
case "null":
|
|
131
|
+
case "reserved":
|
|
132
|
+
return "null";
|
|
133
|
+
case "bool":
|
|
134
|
+
return value ? "true" : "false";
|
|
135
|
+
case "text":
|
|
136
|
+
return candidString(value);
|
|
137
|
+
case "principal":
|
|
138
|
+
return `principal "${value}"`;
|
|
139
|
+
case "blob":
|
|
140
|
+
return `blob "${[...value].map((b) => `\\${b.toString(16).padStart(2, "0")}`).join("")}"`;
|
|
141
|
+
case "float32":
|
|
142
|
+
case "float64":
|
|
143
|
+
return Number.isNaN(value) ? "nan" : value === Infinity ? "inf" : value === -Infinity ? "-inf" : String(value);
|
|
144
|
+
case "vec":
|
|
145
|
+
return value.length === 0 ? "vec {}" : `vec { ${value.map((e, i) => this.#child(node.element, e, `${where}[${i}]`)).join("; ")} }`;
|
|
146
|
+
case "record": {
|
|
147
|
+
const parts = entriesOf(node.fields).map(([key, child]) => `${candidLabel(key)} = ${this.#child(child, value[key], `${where}.${key}`)}`);
|
|
148
|
+
return parts.length === 0 ? "record {}" : `record { ${parts.join("; ")} }`;
|
|
149
|
+
}
|
|
150
|
+
case "variant": {
|
|
151
|
+
const arm = entriesOf(node.cases).find(([key]) => key === value.tag);
|
|
152
|
+
const key = arm === undefined ? String(value.tag) : arm[0];
|
|
153
|
+
if (arm === undefined || (arm[1].candid === "null" && arm[1].optional !== true)) return `variant { ${candidLabel(key)} }`;
|
|
154
|
+
return `variant { ${candidLabel(key)} = ${this.#child(arm[1], value.value, `${where}.${key}`)} }`;
|
|
155
|
+
}
|
|
156
|
+
default:
|
|
157
|
+
return String(value); // integers (decimal strings)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The ICRC-21 `Value` of one value (spec section 12). Falls back to `Text` whenever the typed form cannot carry
|
|
163
|
+
* the value exactly: an amount above nat64 or negative or without a symbol, a timestamp or duration that is not a
|
|
164
|
+
* whole number of seconds, below zero or above nat64.
|
|
165
|
+
*
|
|
166
|
+
* @returns {{ TokenAmount: object } | { TimestampSeconds: object } | { DurationSeconds: object } | { Text: { content: string } }}
|
|
167
|
+
*/
|
|
168
|
+
icrc21Value(node, value, where) {
|
|
169
|
+
const f = this.formatter(node);
|
|
170
|
+
if (f !== null && typeof value === "string") {
|
|
171
|
+
const typed = this.#typed(f, value);
|
|
172
|
+
if (typed !== null) return typed;
|
|
173
|
+
}
|
|
174
|
+
return { Text: { content: this.text(node, value, where) } };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
#typed(f, value) {
|
|
178
|
+
try {
|
|
179
|
+
if (f.tag === "amount") {
|
|
180
|
+
const amount = BigInt(value);
|
|
181
|
+
const { decimals, symbol } = f.args;
|
|
182
|
+
if (amount < 0n || amount > U64_MAX || typeof symbol !== "string") return null;
|
|
183
|
+
f.display(value); // the library must accept the value too (type range), or it is shown as raw text
|
|
184
|
+
return { TokenAmount: { decimals, amount, symbol } };
|
|
185
|
+
}
|
|
186
|
+
if (f.tag === "timestamp" || f.tag === "duration") {
|
|
187
|
+
const seconds = BigInt(convertUnit(value, f.args.unit, "s")); // throws E_INEXACT when not whole seconds
|
|
188
|
+
if (seconds < 0n || seconds > U64_MAX) return null;
|
|
189
|
+
f.display(value);
|
|
190
|
+
return f.tag === "timestamp" ? { TimestampSeconds: { amount: seconds } } : { DurationSeconds: { amount: seconds } };
|
|
191
|
+
}
|
|
192
|
+
} catch (e) {
|
|
193
|
+
if (e instanceof FormatError) return null;
|
|
194
|
+
throw e;
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** `UTC`, `UTC+02:00`, `UTC-05:30`. */
|
|
201
|
+
export function zoneName(offsetMinutes) {
|
|
202
|
+
if (offsetMinutes === 0) return "UTC";
|
|
203
|
+
const abs = Math.abs(offsetMinutes);
|
|
204
|
+
return `UTC${offsetMinutes < 0 ? "-" : "+"}${String(Math.floor(abs / 60)).padStart(2, "0")}:${String(abs % 60).padStart(2, "0")}`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** A Candid text literal: quotes, backslashes and invisible characters escaped. */
|
|
208
|
+
function candidString(s) {
|
|
209
|
+
const escaped = s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
|
|
210
|
+
return `"${visible(escaped)}"`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** A record field or variant case label as Candid writes it: an identifier, a number for `#id`, else quoted. */
|
|
214
|
+
function candidLabel(key) {
|
|
215
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return key;
|
|
216
|
+
if (/^#(0|[1-9][0-9]*)$/.test(key)) return key.slice(1);
|
|
217
|
+
return candidString(key);
|
|
218
|
+
}
|
package/src/errors.mjs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Every failure of this package is a ConsentError with a stable `code`. The ICRC-21 entry point maps
|
|
2
|
+
// the codes to the ICRC-21 error variants (icrc21.mjs); the lower-level functions throw them.
|
|
3
|
+
|
|
4
|
+
export class ConsentError extends Error {
|
|
5
|
+
/**
|
|
6
|
+
* @param {string} code one of CODES
|
|
7
|
+
* @param {string} message written for a developer, not for the end user (ICRC-21: icrc21_error_info.description)
|
|
8
|
+
* @param {{ cause?: unknown }} [options]
|
|
9
|
+
*/
|
|
10
|
+
constructor(code, message, options = {}) {
|
|
11
|
+
super(message, options.cause === undefined ? undefined : { cause: options.cause });
|
|
12
|
+
this.name = "ConsentError";
|
|
13
|
+
this.code = code;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const CODES = Object.freeze({
|
|
18
|
+
// the call is malformed (ICRC-21 UnsupportedCanisterCall)
|
|
19
|
+
METHOD_UNKNOWN: "E_METHOD_UNKNOWN", // the resolved form has no such method
|
|
20
|
+
DECODE: "E_DECODE", // the argument bytes do not decode against the method's signature
|
|
21
|
+
TOO_LARGE: "E_TOO_LARGE", // the arguments exceed the bounds of a consent message (never truncated)
|
|
22
|
+
LANGUAGE: "E_LANGUAGE", // only with languagePolicy "error": the requested language is not supported
|
|
23
|
+
REQUEST: "E_REQUEST", // the ICRC-21 request itself is malformed
|
|
24
|
+
// this generator cannot describe the call (ICRC-21 ConsentMessageUnavailable)
|
|
25
|
+
UNSUPPORTED_TYPE: "E_UNSUPPORTED_TYPE", // a position the resolved form cannot type (recursive cut, func, service)
|
|
26
|
+
// the profile or the caller's context is wrong (ICRC-21 GenericError)
|
|
27
|
+
RESOLVED_FORM: "E_RESOLVED_FORM", // the resolved form is malformed
|
|
28
|
+
TEMPLATE: "E_TEMPLATE", // the intent template does not parse
|
|
29
|
+
TEMPLATE_PATH: "E_TEMPLATE_PATH", // a placeholder names a position the method does not have
|
|
30
|
+
// pass-through of the format library's configuration errors (a dynamic ref the caller did not supply, a bad tag argument)
|
|
31
|
+
REF_UNRESOLVED: "E_REF_UNRESOLVED",
|
|
32
|
+
ARG: "E_ARG",
|
|
33
|
+
TAG_TYPE: "E_TAG_TYPE",
|
|
34
|
+
TAG_CONFLICT: "E_TAG_CONFLICT",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/** ICRC-21 GenericError.error_code values this package uses (a nat chosen by the canister; see the README). */
|
|
38
|
+
export const GENERIC_ERROR_CODES = Object.freeze({
|
|
39
|
+
[CODES.RESOLVED_FORM]: 1000n,
|
|
40
|
+
[CODES.TEMPLATE]: 1001n,
|
|
41
|
+
[CODES.TEMPLATE_PATH]: 1002n,
|
|
42
|
+
[CODES.REF_UNRESOLVED]: 1100n,
|
|
43
|
+
[CODES.ARG]: 1101n,
|
|
44
|
+
[CODES.TAG_TYPE]: 1102n,
|
|
45
|
+
[CODES.TAG_CONFLICT]: 1103n,
|
|
46
|
+
INTERNAL: 1999n,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export function fail(code, message, options) {
|
|
50
|
+
throw new ConsentError(code, message, options);
|
|
51
|
+
}
|
|
@@ -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
|
+
}
|