@meantjs/consent 0.0.0-stage → 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/LICENSE +202 -0
- package/README.md +214 -2
- package/package.json +61 -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/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
|
+
}
|
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
|
+
}
|