@meantjs/profile 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,70 @@
1
+ // Types for @meantjs/profile (the Node entry). Hand-written; the source is plain ES modules (src/*.mjs). The
2
+ // declarations it shares with the browser entry live in browser.d.mts. test/types.test.mjs keeps the declared names
3
+ // equal to the runtime exports, and `npm run types` compiles a consumer against them under strict TypeScript.
4
+ import type { Pack, SchemaValidator } from "./browser.mjs";
5
+
6
+ export type {
7
+ Address,
8
+ ContractNode,
9
+ Diagnostic,
10
+ DocDiagnostic,
11
+ Pack,
12
+ PathSegment,
13
+ Position,
14
+ ProfileDocument,
15
+ ProfileStatus,
16
+ ResolveOptions,
17
+ ResolveResult,
18
+ ResolvedForm,
19
+ SchemaValidator,
20
+ ValidateResult,
21
+ } from "./browser.mjs";
22
+ export {
23
+ DOC_ATTRIBUTES_VERSION,
24
+ Iface,
25
+ LAYERS,
26
+ LayeredTags,
27
+ SubtypeChecker,
28
+ TagIndex,
29
+ analyzeProfiles,
30
+ buildTree,
31
+ compileInterface,
32
+ idlHash,
33
+ interfaceFromEnvelope,
34
+ jcs,
35
+ matchPack,
36
+ matchService,
37
+ parseAddress,
38
+ parseRelPath,
39
+ positions,
40
+ profileFromDocAttributes,
41
+ profileId,
42
+ readServedProfile,
43
+ resolveService,
44
+ resolvedForm,
45
+ SERVED_MAX_BYTES,
46
+ SERVED_METADATA,
47
+ SERVED_SECTION,
48
+ typeSubtype,
49
+ validateProfile,
50
+ } from "./browser.mjs";
51
+
52
+ /** The schema this package ships (a copy of the repository's spec/profile-v0.schema.json). */
53
+ export const DEFAULT_SCHEMA_PATH: string;
54
+ /** The directory of the standard packs this package ships (copies of the repository's packs/). */
55
+ export const DEFAULT_PACKS_DIR: string;
56
+
57
+ /**
58
+ * The validate function for the profile schema. The shipped schema (the default, or a file or document equal to it) is
59
+ * compiled ahead of time; any other schema is compiled by ajv at run time, with `new Function`.
60
+ *
61
+ * @param schema a path to a schema file, or the schema document (default: the schema this package ships)
62
+ */
63
+ export function createSchemaValidator(schema?: string | Record<string, unknown>): Promise<SchemaValidator>;
64
+ /** Every `*.profile.json` of kind "pack" in a directory, sorted by file name (default: the packs this package ships). */
65
+ export function loadPacks(dir?: string): Promise<Pack[]>;
66
+
67
+ /** `candid-profile` as a function: the exit code (0 no error, 1 an error diagnostic, 2 usage or I/O). */
68
+ export function runCli(argv: string[], io: { stdout: { write(text: string): unknown }; stderr: { write(text: string): unknown } }): Promise<0 | 1 | 2>;
69
+ /** The CLI's help text. */
70
+ export const USAGE: string;
package/src/index.mjs ADDED
@@ -0,0 +1,15 @@
1
+ export { idlHash, jcs, profileId } from "./util.mjs";
2
+ export { parseAddress, parseRelPath } from "./address.mjs";
3
+ export { Iface } from "./iface.mjs";
4
+ export { TagIndex, LayeredTags, positions } from "./tags.mjs";
5
+ export { createSchemaValidator, DEFAULT_SCHEMA_PATH } from "./schema.mjs";
6
+ export { compileInterface, interfaceFromEnvelope } from "./compile.mjs";
7
+ export { validateProfile } from "./validate.mjs";
8
+ export { resolveService } from "./resolve.mjs";
9
+ export { matchPack, loadPacks, DEFAULT_PACKS_DIR } from "./packs.mjs";
10
+ export { matchService, typeSubtype, SubtypeChecker } from "./subtype.mjs";
11
+ export { analyzeProfiles, LAYERS } from "./analyze.mjs";
12
+ export { main as runCli, USAGE } from "./cli.mjs";
13
+ export { resolvedForm, buildTree } from "./resolved.mjs";
14
+ export { profileFromDocAttributes, DOC_ATTRIBUTES_VERSION } from "./doc-annotations.mjs";
15
+ export { readServedProfile, SERVED_SECTION, SERVED_METADATA, SERVED_MAX_BYTES } from "./served.mjs";
package/src/keccak.mjs ADDED
@@ -0,0 +1,108 @@
1
+ // Keccak-256 in plain JavaScript, synchronous, as Ethereum uses it: the original Keccak padding (0x01), not the NIST
2
+ // SHA3-256 one (0x06). For function selectors, EIP-55 checksums, ERC-8176 descriptor hashes and EIP-712 digests
3
+ // (spec section 15, D-054). test/evm.test.mjs checks it against the known hashes of the empty string and of the
4
+ // ERC-20 selectors, and against the ERC-8176 schema UID.
5
+ //
6
+ // The 1600-bit state is 25 lanes of 64 bits, each kept as two 32-bit halves: s[2i] the low half, s[2i + 1] the high.
7
+
8
+ const RC = new Uint32Array([
9
+ // round constants, [low, high] per round
10
+ 0x00000001, 0x00000000, 0x00008082, 0x00000000, 0x0000808a, 0x80000000, 0x80008000, 0x80000000,
11
+ 0x0000808b, 0x00000000, 0x80000001, 0x00000000, 0x80008081, 0x80000000, 0x00008009, 0x80000000,
12
+ 0x0000008a, 0x00000000, 0x00000088, 0x00000000, 0x80008009, 0x00000000, 0x8000000a, 0x00000000,
13
+ 0x8000808b, 0x00000000, 0x0000008b, 0x80000000, 0x00008089, 0x80000000, 0x00008003, 0x80000000,
14
+ 0x00008002, 0x80000000, 0x00000080, 0x80000000, 0x0000800a, 0x00000000, 0x8000000a, 0x80000000,
15
+ 0x80008081, 0x80000000, 0x00008080, 0x80000000, 0x80000001, 0x00000000, 0x80008008, 0x80000000,
16
+ ]);
17
+ // rotation offsets of the rho step, by lane index x + 5y
18
+ const ROT = [0, 1, 62, 28, 27, 36, 44, 6, 55, 20, 3, 10, 43, 25, 39, 41, 45, 15, 21, 8, 18, 2, 61, 56, 14];
19
+ // the lane the pi step moves lane x + 5y to: (y, 2x + 3y)
20
+ const PI = ROT.map((_, i) => {
21
+ const x = i % 5;
22
+ const y = (i - x) / 5;
23
+ return y + 5 * ((2 * x + 3 * y) % 5);
24
+ });
25
+ const RATE = 136; // bytes: 1600 - 2 * 256 bits
26
+
27
+ function permute(s) {
28
+ const c = new Uint32Array(10);
29
+ const b = new Uint32Array(50);
30
+ for (let round = 0; round < 24; round++) {
31
+ // theta
32
+ for (let x = 0; x < 5; x++) {
33
+ c[2 * x] = s[2 * x] ^ s[2 * x + 10] ^ s[2 * x + 20] ^ s[2 * x + 30] ^ s[2 * x + 40];
34
+ c[2 * x + 1] = s[2 * x + 1] ^ s[2 * x + 11] ^ s[2 * x + 21] ^ s[2 * x + 31] ^ s[2 * x + 41];
35
+ }
36
+ for (let x = 0; x < 5; x++) {
37
+ const next = (x + 1) % 5;
38
+ const prev = (x + 4) % 5;
39
+ const lo = c[2 * prev] ^ ((c[2 * next] << 1) | (c[2 * next + 1] >>> 31));
40
+ const hi = c[2 * prev + 1] ^ ((c[2 * next + 1] << 1) | (c[2 * next] >>> 31));
41
+ for (let y = 0; y < 25; y += 5) {
42
+ s[2 * (x + y)] ^= lo;
43
+ s[2 * (x + y) + 1] ^= hi;
44
+ }
45
+ }
46
+ // rho and pi
47
+ for (let i = 0; i < 25; i++) {
48
+ const lo = s[2 * i];
49
+ const hi = s[2 * i + 1];
50
+ const n = ROT[i];
51
+ const j = PI[i];
52
+ if (n === 0) {
53
+ b[2 * j] = lo;
54
+ b[2 * j + 1] = hi;
55
+ } else if (n < 32) {
56
+ b[2 * j] = (lo << n) | (hi >>> (32 - n));
57
+ b[2 * j + 1] = (hi << n) | (lo >>> (32 - n));
58
+ } else if (n === 32) {
59
+ b[2 * j] = hi;
60
+ b[2 * j + 1] = lo;
61
+ } else {
62
+ const m = n - 32;
63
+ b[2 * j] = (hi << m) | (lo >>> (32 - m));
64
+ b[2 * j + 1] = (lo << m) | (hi >>> (32 - m));
65
+ }
66
+ }
67
+ // chi
68
+ for (let y = 0; y < 25; y += 5) {
69
+ for (let x = 0; x < 5; x++) {
70
+ const i = x + y;
71
+ const i1 = ((x + 1) % 5) + y;
72
+ const i2 = ((x + 2) % 5) + y;
73
+ s[2 * i] = b[2 * i] ^ (~b[2 * i1] & b[2 * i2]);
74
+ s[2 * i + 1] = b[2 * i + 1] ^ (~b[2 * i1 + 1] & b[2 * i2 + 1]);
75
+ }
76
+ }
77
+ // iota
78
+ s[0] ^= RC[2 * round];
79
+ s[1] ^= RC[2 * round + 1];
80
+ }
81
+ }
82
+
83
+ /** Keccak-256 of a byte array (or a string, as its UTF-8 bytes), as 32 bytes. */
84
+ export function keccak256(input) {
85
+ const bytes = typeof input === "string" ? new TextEncoder().encode(input) : input;
86
+ const s = new Uint32Array(50);
87
+ const blocks = Math.floor(bytes.length / RATE) + 1;
88
+ const padded = new Uint8Array(blocks * RATE);
89
+ padded.set(bytes);
90
+ padded[bytes.length] ^= 0x01;
91
+ padded[padded.length - 1] ^= 0x80;
92
+ for (let off = 0; off < padded.length; off += RATE) {
93
+ for (let k = 0; k < RATE; k += 4) {
94
+ s[k >> 2] ^= padded[off + k] | (padded[off + k + 1] << 8) | (padded[off + k + 2] << 16) | (padded[off + k + 3] << 24);
95
+ }
96
+ permute(s);
97
+ }
98
+ const out = new Uint8Array(32);
99
+ for (let k = 0; k < 32; k++) out[k] = (s[k >> 2] >>> (8 * (k & 3))) & 0xff;
100
+ return out;
101
+ }
102
+
103
+ /** Keccak-256 as 64 lowercase hex digits, without `0x`. */
104
+ export function keccak256Hex(input) {
105
+ let hex = "";
106
+ for (const b of keccak256(input)) hex += b.toString(16).padStart(2, "0");
107
+ return hex;
108
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Pack matching (spec section 7, D-005, D-019): a pack applies to a canister whose service is a subtype of the
3
+ * Candid fragment in `binds.requires`, with result variants allowed to be wider. No file system here; packs.mjs adds
4
+ * reading packs from a directory.
5
+ */
6
+ import { compileInterface } from "./compile.mjs";
7
+ import { matchService } from "./subtype.mjs";
8
+
9
+ /**
10
+ * @param canister an Iface (label "canister")
11
+ * @param pack a parsed, schema-valid pack document
12
+ * @returns { matched, reasons, relaxed } or { error: [candid-core diagnostics] } when `requires` does not compile
13
+ */
14
+ export async function matchPack(canister, pack, opts = {}) {
15
+ const compiled = await compileInterface(pack.binds.requires, "pack fragment");
16
+ if (compiled.failure) return { error: compiled.failure };
17
+ const r = matchService(canister, compiled.iface, { resultVariants: "relaxed", lossyOpt: false, ...opts });
18
+ return { matched: r.ok, reasons: r.reasons, relaxed: r.relaxed, fragment: compiled.iface };
19
+ }
package/src/packs.mjs ADDED
@@ -0,0 +1,25 @@
1
+ /** Reading packs from a directory (Node). Matching a pack to a service is pack-match.mjs. */
2
+ import { readdir, readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ export { matchPack } from "./pack-match.mjs";
7
+
8
+ /**
9
+ * The standard packs this package ships (data/packs/), so the default works wherever the package is installed.
10
+ * They are copies of the repository's packs/ (W0's paths), regenerated by `npm run data -w packages/resolver`;
11
+ * test/data.test.mjs fails when a copy is stale.
12
+ */
13
+ export const DEFAULT_PACKS_DIR = fileURLToPath(new URL("../data/packs", import.meta.url));
14
+
15
+ /** Read every `*.profile.json` of kind "pack" in a directory, sorted by file name. Unparseable files are reported, not skipped. */
16
+ export async function loadPacks(dir = DEFAULT_PACKS_DIR) {
17
+ const out = [];
18
+ for (const f of (await readdir(dir)).sort()) {
19
+ if (!f.endsWith(".profile.json")) continue;
20
+ const file = path.join(dir, f);
21
+ const doc = JSON.parse(await readFile(file, "utf8"));
22
+ if (doc?.kind === "pack") out.push({ file, doc });
23
+ }
24
+ return out;
25
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Resolve a service with any number of profiles (spec sections 7, 8 and 10).
3
+ *
4
+ * explicit profiles applied as given: canister profiles are checked for binding (rebound/stale), packs must match;
5
+ * candidate packs tried by Candid subtyping against `binds.requires`; those that match are applied, the rest
6
+ * are reported and left out;
7
+ * layers author > third_party > pack > inferred, merged per tag per position (W_OVERRIDDEN, E_CONFLICT).
8
+ *
9
+ * New diagnostic codes introduced here (additive; proposed in docs/proposals/w1-resolved-form-additions.md):
10
+ * I_PACK_MATCHED, I_PACK_NOT_MATCHED, E_PACK_NOT_MATCHED, I_PACK_ANNOTATION_SKIPPED; and an optional `profile`
11
+ * field on diagnostics that come from a profile. E_ENVELOPE (an `envelope` input that is not a readable candid-core
12
+ * ContractEnvelope) is proposed in docs/proposals/w1-envelope-input.md.
13
+ */
14
+ import { profileId } from "./util.mjs";
15
+ import { compileInterface, interfaceFromEnvelope } from "./compile.mjs";
16
+ import { analyzeProfiles } from "./analyze.mjs";
17
+ import { matchPack } from "./pack-match.mjs";
18
+ import { abiInterface, checkDeployments } from "./abi-iface.mjs";
19
+ import { matchAbiPack, translatePack } from "./abi-pack.mjs";
20
+ import { abiInterfaceId, parseAbi } from "./abi.mjs";
21
+ import { resolvedForm } from "./resolved.mjs";
22
+ import { schemaDiagnostics } from "./schema-core.mjs";
23
+
24
+ const describeReasons = (reasons) => {
25
+ const shown = reasons.slice(0, 3).map((r) => `${r.at}: ${r.message}`).join("; ");
26
+ return reasons.length > 3 ? `${shown}; and ${reasons.length - 3} more` : shown;
27
+ };
28
+
29
+ /**
30
+ * @param opts.did Candid source of the service, compiled here; or
31
+ * @param opts.envelope the service's candid-core ContractEnvelope, already compiled; or
32
+ * @param opts.abi an EVM contract's ABI: fragments or JSON ABI entries (spec section 15; exactly one of the three)
33
+ * @param opts.didLabel how E_CANDID (or E_ENVELOPE, E_ABI) names the input
34
+ * @param opts.profiles [{ doc, label? }] explicit profiles, applied or rejected
35
+ * @param opts.packs [{ doc, label? }] candidate packs, applied only if they match
36
+ * @param opts.validateSchema from createSchemaValidator()
37
+ * @returns { ok, resolved, diagnostics, profiles } `resolved` is null when the service does not compile (or the
38
+ * envelope cannot be read).
39
+ * `profiles[i]` = { name, origin, kind, profile_id, status, explicit } with status one of
40
+ * applied | rebound | stale | not_matched | invalid.
41
+ */
42
+ export async function resolveService(opts) {
43
+ const fromEnvelope = opts.envelope !== undefined;
44
+ if ([opts.did, opts.envelope, opts.abi].filter((x) => x !== undefined).length !== 1) throw new TypeError("resolveService: give exactly one of `did`, `envelope` and `abi`");
45
+ if (opts.abi !== undefined) return resolveCompiled(opts, false);
46
+ if (!fromEnvelope) return resolveCompiled(opts, false);
47
+ // interfaceFromEnvelope checks the graph before anything walks it; anything it still misses in someone else's
48
+ // document is reported against the envelope rather than thrown.
49
+ try {
50
+ return await resolveCompiled(opts, true);
51
+ } catch (e) {
52
+ const at = opts.didLabel ?? "envelope";
53
+ return { ok: false, resolved: null, diagnostics: [{ severity: "error", code: "E_ENVELOPE", at, message: `the envelope's graph could not be resolved: ${e?.message ?? e}` }], profiles: [] };
54
+ }
55
+ }
56
+
57
+ async function resolveCompiled(opts, fromEnvelope) {
58
+ const diagnostics = [];
59
+ const statuses = [];
60
+ if (opts.abi !== undefined) {
61
+ const label = opts.didLabel ?? "abi";
62
+ const compiled = abiInterface(opts.abi, label);
63
+ if (compiled.failure) {
64
+ for (const d of compiled.failure) diagnostics.push({ severity: "error", code: "E_ABI", at: d.at, message: d.message });
65
+ return { ok: false, resolved: null, diagnostics, profiles: statuses };
66
+ }
67
+ return resolveWith(compiled.iface, opts, diagnostics, statuses);
68
+ }
69
+ const compiled = fromEnvelope ? interfaceFromEnvelope(opts.envelope, "canister") : await compileInterface(opts.did, "canister");
70
+ if (compiled.failure) {
71
+ const [code, at] = fromEnvelope ? ["E_ENVELOPE", opts.didLabel ?? "envelope"] : ["E_CANDID", opts.didLabel ?? "service.did"];
72
+ for (const d of compiled.failure) diagnostics.push({ severity: "error", code, at, message: `${d.code}: ${d.message}` });
73
+ return { ok: false, resolved: null, diagnostics, profiles: statuses };
74
+ }
75
+ return resolveWith(compiled.iface, opts, diagnostics, statuses);
76
+ }
77
+
78
+ /** A profile or pack for an EVM contract (spec section 15) rather than a Candid service. */
79
+ const isEvmDoc = (doc) => doc.kind === "contract" || (doc.kind === "pack" && doc.binds.abi !== undefined);
80
+
81
+ async function resolveWith(iface, opts, diagnostics, statuses) {
82
+ const evm = iface.chain === "evm";
83
+
84
+ const candidates = [
85
+ ...(opts.profiles ?? []).map((p, i) => ({ ...p, explicit: true, label: p.label ?? `profile #${i + 1}` })),
86
+ ...(opts.packs ?? []).map((p, i) => ({ ...p, explicit: false, label: p.label ?? `pack #${i + 1}` })),
87
+ ];
88
+ const items = [];
89
+ const taken = new Set();
90
+ for (const c of candidates) {
91
+ const name = c.doc?.name ?? c.label;
92
+ const note = (severity, code, at, message) => diagnostics.push({ severity, code, at, message, profile: name });
93
+ if (!opts.validateSchema(c.doc)) {
94
+ for (const d of schemaDiagnostics(opts.validateSchema)) diagnostics.push({ ...d, profile: name });
95
+ statuses.push({ name, origin: c.doc?.origin ?? null, kind: c.doc?.kind ?? null, profile_id: null, status: "invalid", explicit: c.explicit });
96
+ continue;
97
+ }
98
+ const doc = c.doc;
99
+ const pid = profileId(doc);
100
+ if (taken.has(pid)) continue; // the same document given twice (for example explicitly and through the pack search)
101
+ const status = { name: doc.name, origin: doc.origin, kind: doc.kind, profile_id: pid, status: "applied", explicit: c.explicit };
102
+ if (isEvmDoc(doc) !== evm) {
103
+ if (!c.explicit) continue; // a pack for the other kind of interface can never apply
104
+ const what = doc.kind === "pack" ? "this pack" : `a ${doc.kind} profile`;
105
+ note("error", "E_CHAIN", "kind", `${what} describes ${isEvmDoc(doc) ? "an EVM contract" : "a Candid service"}, but the interface is ${evm ? "an EVM contract's ABI" : "a Candid service"}`);
106
+ statuses.push({ ...status, status: "invalid" });
107
+ continue;
108
+ }
109
+ if (evm && doc.kind === "pack") {
110
+ const m = matchAbiPack(iface, doc);
111
+ if (m.error) {
112
+ for (const d of m.error) note("error", "E_ABI", d.at, d.message);
113
+ statuses.push({ ...status, status: "invalid" });
114
+ continue;
115
+ }
116
+ if (!m.matched) {
117
+ const why = describeReasons(m.reasons);
118
+ if (c.explicit) note("error", "E_PACK_NOT_MATCHED", doc.name, `the contract lacks functions of the pack's binds.abi: ${why}`);
119
+ else note("info", "I_PACK_NOT_MATCHED", doc.name, `pack does not apply: ${why}`);
120
+ statuses.push({ ...status, status: "not_matched" });
121
+ continue;
122
+ }
123
+ note("info", "I_PACK_MATCHED", doc.name, "the contract has every function of the pack's binds.abi (same selectors, outputs and kind of call)");
124
+ items.push({ doc: translatePack(doc, m.functions, iface), pid, rebinding: false, apply: true, status });
125
+ } else if (evm) {
126
+ const own = parseAbi(doc.binds.abi, "binds.abi");
127
+ if (own.errors.length) {
128
+ for (const d of own.errors) note("error", "E_ABI", d.at, d.message);
129
+ statuses.push({ ...status, status: "invalid" });
130
+ continue;
131
+ }
132
+ checkDeployments(doc, note);
133
+ items.push({ doc, pid, rebinding: abiInterfaceId(own.functions) !== iface.interfaceId, apply: false, status });
134
+ } else if (doc.kind === "pack") {
135
+ const m = await matchPack(iface, doc);
136
+ if (m.error) {
137
+ for (const d of m.error) note("error", "E_CANDID", "binds.requires", `${d.code}: ${d.message}`);
138
+ statuses.push({ ...status, status: "invalid" });
139
+ continue;
140
+ }
141
+ if (!m.matched) {
142
+ const why = describeReasons(m.reasons);
143
+ if (c.explicit) note("error", "E_PACK_NOT_MATCHED", doc.name, `the service is not a subtype of the pack's binds.requires: ${why}`);
144
+ else note("info", "I_PACK_NOT_MATCHED", doc.name, `pack does not apply: ${why}`);
145
+ statuses.push({ ...status, status: "not_matched" });
146
+ continue;
147
+ }
148
+ const wider = m.relaxed.map((r) => `${r.at} has extra cases ${r.cases.join(", ")}`);
149
+ note("info", "I_PACK_MATCHED", doc.name, `the service is a subtype of the pack's binds.requires${wider.length ? ` (result variants wider than the pack, D-019: ${wider.join("; ")})` : ""}`);
150
+ items.push({ doc, pid, rebinding: false, apply: true, status });
151
+ } else {
152
+ items.push({ doc, pid, rebinding: doc.binds.interface_id !== iface.interfaceId, apply: false, status });
153
+ }
154
+ taken.add(pid);
155
+ }
156
+
157
+ const analysis = analyzeProfiles(iface, items, { attribute: true });
158
+ diagnostics.push(...analysis.diagnostics);
159
+ for (const item of analysis.ordered) {
160
+ item.status.status = analysis.stale.has(item) ? "stale" : item.rebinding ? "rebound" : "applied";
161
+ }
162
+ // Report profiles in precedence order, then the ones that were left out.
163
+ const applied = analysis.ordered.map((i) => i.status);
164
+ const left = statuses.splice(0);
165
+ const resolved = resolvedForm(iface, analysis.ordered, analysis.layered, diagnostics);
166
+ return { ok: !diagnostics.some((d) => d.severity === "error"), resolved, diagnostics, profiles: [...applied, ...left] };
167
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The resolved form (spec section 10): one tree per method with the effective tags attached to every position.
3
+ * buildTree is moved verbatim from scripts/validate.mjs. The shape is a shared contract (fixtures/*.resolved.json).
4
+ */
5
+ import { positions } from "./tags.mjs";
6
+
7
+ export function buildTree(iface, methodName, side, tags) {
8
+ const out = [];
9
+ const stack = []; // [node object, depth]
10
+ for (const pos of positions(iface, methodName, side)) {
11
+ const { ref: r, optional } = iface.unwrapOpt(pos.ref);
12
+ const n = iface.node(r);
13
+ const node = iface.chain === "evm" ? { abi: iface.abiType(r) } : { candid: n.kind === "primitive" ? n.primitive : iface.isBlob(r) ? "blob" : n.kind };
14
+ if (n.kind === "vec" && typeof n.length === "number") node.length = n.length; // an EVM fixed-size array, T[k]
15
+ if (optional) node.optional = true;
16
+ const t = tags.at(pos, iface);
17
+ if (t) node.tags = t;
18
+ if (pos.cyclic) node.recursive = true;
19
+ const depth = pos.key.split("|")[3] === "" ? 0 : pos.key.split("|")[3].split("/").length;
20
+ while (stack.length && stack[stack.length - 1].depth >= depth) stack.pop();
21
+ if (depth === 0) out.push(node);
22
+ else {
23
+ const parentNode = stack[stack.length - 1].node;
24
+ const last = pos.display.endsWith("[]") ? null : pos.display.slice(pos.display.lastIndexOf(".") + 1);
25
+ if (last === null) parentNode.element = node;
26
+ else {
27
+ const bucket = pos.parent.kind === "record" ? "fields" : "cases";
28
+ parentNode[bucket] ??= {};
29
+ parentNode[bucket][last] = node;
30
+ }
31
+ }
32
+ stack.push({ node, depth });
33
+ }
34
+ return out;
35
+ }
36
+
37
+ /**
38
+ * @param iface the interface the tree is built over
39
+ * @param profiles every profile that contributes, highest precedence first: [{ doc, pid }]
40
+ * @param tags a LayeredTags
41
+ */
42
+ export function resolvedForm(iface, profiles, tags, diagnostics) {
43
+ const methods = {};
44
+ for (const [name, m] of [...iface.methods.entries()].sort(([a], [b]) => (a < b ? -1 : 1))) {
45
+ const mt = { ...tags.methodTags(name) };
46
+ if (!mt.effect) {
47
+ mt.effect = m.mode === "update" || m.mode === "oneway"
48
+ ? { args: { kind: "write", irreversible: true }, origin: "default" }
49
+ : { args: { kind: "read", irreversible: false }, origin: "default" };
50
+ }
51
+ const f = iface.functions?.get(name); // an EVM function: what a consumer needs to match and decode calldata
52
+ const abi = f ? { selector: f.selector, signature: f.signature, state_mutability: f.stateMutability } : {};
53
+ methods[name] = { mode: m.mode, ...abi, tags: mt, args: buildTree(iface, name, "args", tags), results: buildTree(iface, name, "results", tags) };
54
+ }
55
+ return {
56
+ format: "candid-profile-resolved",
57
+ format_version: 0,
58
+ ...(iface.chain === "evm" ? { chain: "evm" } : {}),
59
+ interface_id: iface.contract.identities.interface ?? null,
60
+ profiles: profiles.map(({ doc, pid }) => ({ name: doc.name, origin: doc.origin, profile_id: pid, ...(doc.binds?.deployments ? { deployments: doc.binds.deployments } : {}) })),
61
+ methods,
62
+ diagnostics,
63
+ };
64
+ }
@@ -0,0 +1,45 @@
1
+ // The schema checks without the file system: compile a schema document, report its errors. schema.mjs adds reading
2
+ // the schema from a path (Node); the browser entry passes the document the package ships.
3
+ //
4
+ // The schema this package ships is compiled ahead of time (data/profile-v0.validate.mjs, ajv's standalone code,
5
+ // written by scripts/data.mjs), so validating against it generates no code at run time and runs under a
6
+ // Content-Security-Policy without 'unsafe-eval'. Any other schema document is compiled by ajv here, at run time, which
7
+ // uses `new Function`; ajv is loaded only then.
8
+ import shipped from "../data/profile-v0.schema.json" with { type: "json" };
9
+ import { validate as precompiled } from "../data/profile-v0.validate.mjs";
10
+ import { jcs } from "./util.mjs";
11
+
12
+ const SHIPPED_JCS = jcs(shipped);
13
+
14
+ /**
15
+ * Compile a profile schema document once; the result is a validate function with ajv's `errors`. A document equal
16
+ * to the shipped schema (as JSON, whatever its key order) gets the precompiled validator.
17
+ */
18
+ export async function compileSchema(doc) {
19
+ if (doc === null || typeof doc !== "object" || Array.isArray(doc)) throw new TypeError("createSchemaValidator: expected a path or a schema object");
20
+ if (doc === shipped || jcs(doc) === SHIPPED_JCS) return ownErrors(precompiled);
21
+ const { default: Ajv2020Module } = await import("ajv/dist/2020.js");
22
+ const Ajv2020 = Ajv2020Module.default ?? Ajv2020Module;
23
+ return new Ajv2020({ allErrors: true, strict: true }).compile(doc);
24
+ }
25
+
26
+ /** A validate function with its own `errors`, as each run-time compile has, over the one shared precompiled one. */
27
+ function ownErrors(validate) {
28
+ const own = (doc) => {
29
+ const ok = validate(doc);
30
+ own.errors = validate.errors;
31
+ return ok;
32
+ };
33
+ own.errors = null;
34
+ return own;
35
+ }
36
+
37
+ /** E_SCHEMA diagnostics for the errors of a failed validate call. */
38
+ export function schemaDiagnostics(validateSchema) {
39
+ return validateSchema.errors.map((e) => ({
40
+ severity: "error",
41
+ code: "E_SCHEMA",
42
+ at: e.instancePath || "/",
43
+ message: `${e.message}${e.params ? ` ${JSON.stringify(e.params)}` : ""}`,
44
+ }));
45
+ }
package/src/schema.mjs ADDED
@@ -0,0 +1,23 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { fileURLToPath } from "node:url";
3
+ import { compileSchema, schemaDiagnostics } from "./schema-core.mjs";
4
+
5
+ export { schemaDiagnostics };
6
+
7
+ /**
8
+ * The schema this package ships (data/profile-v0.schema.json), so the default works wherever the package is
9
+ * installed. It is a copy of the repository's spec/profile-v0.schema.json (W0's shared contract), regenerated by
10
+ * `npm run data -w packages/resolver`; test/data.test.mjs fails when the copy is stale.
11
+ */
12
+ export const DEFAULT_SCHEMA_PATH = fileURLToPath(new URL("../data/profile-v0.schema.json", import.meta.url));
13
+
14
+ /**
15
+ * The validate function for the profile schema, with ajv's `errors`. The shipped schema (the default, or a document or
16
+ * file equal to it) gets the validator compiled ahead of time; any other schema is compiled by ajv at run time.
17
+ *
18
+ * @param schema a path to the schema file (default: the copy this package ships), or the schema document itself
19
+ * (`import schema from "@meantjs/profile/schema.json" with { type: "json" }` is the same copy).
20
+ */
21
+ export async function createSchemaValidator(schema = DEFAULT_SCHEMA_PATH) {
22
+ return compileSchema(typeof schema === "string" ? JSON.parse(await readFile(schema, "utf8")) : schema);
23
+ }
package/src/served.mjs ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * A profile served by its canister (spec section 8.1, D-048): the public custom section `icp:public candid:profile`,
3
+ * read by a consumer through a certified `read_state` at `/canister/<id>/metadata/candid:profile`.
4
+ *
5
+ * This module reads the section's bytes; it does not fetch them, so the library keeps no agent dependency (the kit's
6
+ * `fetch` does the network part). What it checks is what only a served profile can get wrong: the bytes are UTF-8
7
+ * JSON of at most 1 MiB, the document is a `canister` profile of origin `author`, and its `binds.canister_id`, when
8
+ * given, is the canister that served it. Everything else (the schema, the binding to the interface the same canister
9
+ * serves as `candid:service`, every tag) is `resolveService`'s, as for any profile.
10
+ *
11
+ * New diagnostic code (spec section 13): E_SERVED.
12
+ */
13
+
14
+ /** The name of the custom section a canister serves its profile in. */
15
+ export const SERVED_SECTION = "icp:public candid:profile";
16
+ /** The metadata name a consumer reads: `/canister/<id>/metadata/candid:profile`. */
17
+ export const SERVED_METADATA = "candid:profile";
18
+ /** The largest served profile read: the platform's limit for all of a canister's custom sections together. */
19
+ export const SERVED_MAX_BYTES = 1024 * 1024;
20
+
21
+ const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
22
+
23
+ /**
24
+ * @param bytes the content of the canister's `candid:profile` metadata (a Uint8Array)
25
+ * @param opts.canisterId the textual id of the canister that served it; null only before the module is installed
26
+ * anywhere (the kit's `embed`), which leaves `binds.canister_id` unchecked
27
+ * @param opts.label how diagnostics name the input (default "candid:profile")
28
+ * @returns { ok, doc, diagnostics } `doc` is the parsed profile, or null when it cannot be used (E_SERVED)
29
+ */
30
+ export function readServedProfile(bytes, opts = {}) {
31
+ const at = opts.label ?? SERVED_METADATA;
32
+ const refuse = (message) => ({ ok: false, doc: null, diagnostics: [{ severity: "error", code: "E_SERVED", at, message }] });
33
+ if (opts.canisterId !== null && (typeof opts.canisterId !== "string" || opts.canisterId === "")) {
34
+ throw new TypeError("readServedProfile: give the serving canister's id as opts.canisterId (null before it is installed)");
35
+ }
36
+ if (!(bytes instanceof Uint8Array)) return refuse("the served profile is not bytes");
37
+ if (bytes.length > SERVED_MAX_BYTES) return refuse(`the served profile is ${bytes.length} bytes, over the ${SERVED_MAX_BYTES} a canister can serve`);
38
+ let text;
39
+ try {
40
+ text = decoder.decode(bytes);
41
+ } catch {
42
+ return refuse("the served profile is not UTF-8");
43
+ }
44
+ if (text.startsWith("")) return refuse("the served profile starts with a byte order mark");
45
+ let doc;
46
+ try {
47
+ doc = JSON.parse(text);
48
+ } catch (e) {
49
+ return refuse(`the served profile is not JSON: ${e.message}`);
50
+ }
51
+ if (doc === null || typeof doc !== "object" || Array.isArray(doc)) return refuse("the served profile is not a JSON object");
52
+ if (doc.kind !== "canister") return refuse(`a canister serves a profile of kind "canister", not ${JSON.stringify(doc.kind ?? null)}`);
53
+ if (doc.origin !== "author") return refuse(`a profile its canister serves is the author's: origin "author", not ${JSON.stringify(doc.origin ?? null)}`);
54
+ const bound = doc.binds?.canister_id;
55
+ if (bound !== undefined && opts.canisterId !== null && bound !== opts.canisterId) {
56
+ return refuse(`the served profile is bound to canister ${JSON.stringify(bound)}, but ${opts.canisterId} served it`);
57
+ }
58
+ return { ok: true, doc, diagnostics: [] };
59
+ }
package/src/sha256.mjs ADDED
@@ -0,0 +1,59 @@
1
+ // SHA-256 in plain JavaScript, synchronous, so that `profileId` needs no node:crypto (WebCrypto is asynchronous, and
2
+ // profileId is called inside synchronous code). test/browser-entry.test.mjs compares it with node:crypto.
3
+
4
+ const K = new Uint32Array([
5
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
6
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
7
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
8
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
9
+ ]);
10
+
11
+ const rotr = (x, n) => (x >>> n) | (x << (32 - n));
12
+
13
+ /** SHA-256 of a byte array, as 32 bytes. */
14
+ export function sha256(bytes) {
15
+ const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]);
16
+ const bitLength = bytes.length * 8;
17
+ const padded = new Uint8Array(((bytes.length + 9 + 63) >> 6) << 6);
18
+ padded.set(bytes);
19
+ padded[bytes.length] = 0x80;
20
+ const view = new DataView(padded.buffer);
21
+ view.setUint32(padded.length - 8, Math.floor(bitLength / 2 ** 32), false);
22
+ view.setUint32(padded.length - 4, bitLength >>> 0, false);
23
+ const w = new Uint32Array(64);
24
+ for (let off = 0; off < padded.length; off += 64) {
25
+ for (let i = 0; i < 16; i++) w[i] = view.getUint32(off + 4 * i, false);
26
+ for (let i = 16; i < 64; i++) {
27
+ const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
28
+ const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
29
+ w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0;
30
+ }
31
+ let [a, b, c, d, e, f, g, hh] = h;
32
+ for (let i = 0; i < 64; i++) {
33
+ const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
34
+ const ch = (e & f) ^ (~e & g);
35
+ const t1 = (hh + S1 + ch + K[i] + w[i]) | 0;
36
+ const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
37
+ const maj = (a & b) ^ (a & c) ^ (b & c);
38
+ const t2 = (S0 + maj) | 0;
39
+ hh = g;
40
+ g = f;
41
+ f = e;
42
+ e = (d + t1) | 0;
43
+ d = c;
44
+ c = b;
45
+ b = a;
46
+ a = (t1 + t2) | 0;
47
+ }
48
+ h[0] += a; h[1] += b; h[2] += c; h[3] += d; h[4] += e; h[5] += f; h[6] += g; h[7] += hh;
49
+ }
50
+ const out = new Uint8Array(32);
51
+ const ov = new DataView(out.buffer);
52
+ h.forEach((x, i) => ov.setUint32(4 * i, x, false));
53
+ return out;
54
+ }
55
+
56
+ /** Lowercase hex SHA-256 of a string's UTF-8 bytes. */
57
+ export function sha256Hex(text) {
58
+ return Array.from(sha256(new TextEncoder().encode(text)), (b) => b.toString(16).padStart(2, "0")).join("");
59
+ }