sentinelayer-cli 0.41.0 → 0.43.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/README.md +55 -0
- package/package.json +3 -2
- package/src/auth/admission-scope.js +34 -0
- package/src/auth/gate.js +6 -0
- package/src/auth/http.js +4 -0
- package/src/auth/service.js +6 -0
- package/src/board/done-gate.js +155 -0
- package/src/board/evidence-resolvers.js +138 -0
- package/src/cli.js +24 -1
- package/src/commands/board.js +116 -0
- package/src/commands/session.js +779 -47
- package/src/engram/build-state.js +183 -0
- package/src/engram/canonical.js +75 -0
- package/src/engram/document.js +190 -0
- package/src/engram/index.js +11 -2
- package/src/engram/ingest-document.js +107 -0
- package/src/engram/store.js +6 -1
- package/src/engram/tools.js +6 -0
- package/src/events/schema.js +18 -0
- package/src/legacy-cli.js +2 -2
- package/src/net/test-egress-guard.js +158 -0
- package/src/review/investor-dd-orchestrator.js +14 -2
- package/src/session/admission-access.js +256 -0
- package/src/session/admission-auth.js +319 -0
- package/src/session/admission-preimage.js +90 -0
- package/src/session/admission.js +641 -0
- package/src/session/checkpoints.js +25 -1
- package/src/session/codebase-context.js +54 -0
- package/src/session/coordination-guidance.js +1 -1
- package/src/session/daemon-spawn.js +20 -2
- package/src/session/daemon.js +7 -1
- package/src/session/event-identity.js +9 -2
- package/src/session/first-message.js +11 -3
- package/src/session/listener.js +3 -0
- package/src/session/live-source.js +47 -16
- package/src/session/message-edits.js +89 -0
- package/src/session/recap.js +26 -4
- package/src/session/remote-hydrate.js +7 -19
- package/src/session/store.js +13 -14
- package/src/session/stream.js +23 -3
- package/src/session/sync.js +128 -11
- package/src/session/tickets.js +316 -0
- package/src/session/title-sync.js +2 -1
- package/src/session/usage-report.js +41 -2
- package/src/session/wake/resolve-target.js +10 -5
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// admission-auth.js — which credential a session command runs on.
|
|
2
|
+
//
|
|
3
|
+
// An admitted agent must act with ITS admission credential: room-scoped, expiring,
|
|
4
|
+
// revocable, and limited to what the owner approved. The human's own token is
|
|
5
|
+
// broader (every room they belong to, owner rights where they have them), so
|
|
6
|
+
// quietly falling back to it would let an agent step around its grant.
|
|
7
|
+
//
|
|
8
|
+
// A command that knows its (session, agent) wraps its work in withAgentAdmission:
|
|
9
|
+
//
|
|
10
|
+
// - nothing stored for that pair -> the legacy path, unchanged (human token)
|
|
11
|
+
// - a live credential -> every session API call made inside the
|
|
12
|
+
// callback resolves to that credential, sent
|
|
13
|
+
// only to the API that issued it
|
|
14
|
+
// - a tombstone (expired, malformed, bound elsewhere) -> refused before any
|
|
15
|
+
// request is made; never treated as absence
|
|
16
|
+
//
|
|
17
|
+
// The scope is an AsyncLocalStorage context (src/auth/admission-scope.js) that
|
|
18
|
+
// resolveActiveAuthSession consults first. It therefore covers every helper a
|
|
19
|
+
// command calls, however deep, and every credential lookup, not only the session
|
|
20
|
+
// transport's.
|
|
21
|
+
// A revoked credential looks live here; the API refuses it (401) and the command
|
|
22
|
+
// fails. There is no retry with another credential anywhere on this path.
|
|
23
|
+
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import process from "node:process";
|
|
26
|
+
|
|
27
|
+
import { admittedAgentScope } from "../auth/admission-scope.js";
|
|
28
|
+
import { readAdmissionCredentialState } from "./admission.js";
|
|
29
|
+
|
|
30
|
+
export class AdmissionCredentialRefused extends Error {
|
|
31
|
+
constructor(message, { reason, sessionId, agentId } = {}) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.name = "AdmissionCredentialRefused";
|
|
34
|
+
this.code = "ADMISSION_CREDENTIAL_REFUSED";
|
|
35
|
+
this.reason = reason;
|
|
36
|
+
this.sessionId = sessionId;
|
|
37
|
+
this.agentId = agentId;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const REFUSAL_HINT = {
|
|
42
|
+
expired: "its admission has expired",
|
|
43
|
+
malformed: "its stored admission credential is unreadable",
|
|
44
|
+
bound_to_another_session_or_agent: "its stored admission credential names a different session or agent",
|
|
45
|
+
no_issuing_authority: "its stored admission credential does not record the API that issued it",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* THE canonical agent id: exactly what every session command acts as. Commands
|
|
50
|
+
* canonicalise through this (normalizeAgentId delegates here), so the identity that
|
|
51
|
+
* authorises and the identity that executes are computed by one function.
|
|
52
|
+
*/
|
|
53
|
+
export function canonicalAgentId(value) {
|
|
54
|
+
return String(value ?? "")
|
|
55
|
+
.trim()
|
|
56
|
+
.toLowerCase()
|
|
57
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
58
|
+
.replace(/^-+|-+$/g, "");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Refuse, before any I/O, an agent id whose canonical form is a DIFFERENT identity than
|
|
63
|
+
* its spelling (case folding aside): "a:b" would act as "a-b", a separate identity with
|
|
64
|
+
* its own key and credential. Two spellings are never silently aliased.
|
|
65
|
+
*/
|
|
66
|
+
export function assertCanonicalAgentId(value, source) {
|
|
67
|
+
const raw = String(value ?? "").trim();
|
|
68
|
+
if (!raw) return;
|
|
69
|
+
const canonical = canonicalAgentId(raw);
|
|
70
|
+
if (canonical !== raw.toLowerCase()) {
|
|
71
|
+
throw new AdmissionCredentialRefused(
|
|
72
|
+
`${source} "${raw}" is not a canonical agent id: commands would act as "${canonical || "(none)"}", ` +
|
|
73
|
+
`a different identity. Use the canonical spelling.`,
|
|
74
|
+
{ reason: "non_canonical_agent_id" }
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Options whose value decides WHICH identity, and so which credential, a command
|
|
80
|
+
// runs as. Given more than once they are ambiguous (Commander keeps the LAST value),
|
|
81
|
+
// so they are refused outright rather than guessed at.
|
|
82
|
+
const IDENTITY_OPTIONS = ["--agent", "--session", "--id", "--path"];
|
|
83
|
+
|
|
84
|
+
// Options end at a bare `--`; everything after it is an operand, as Commander reads it.
|
|
85
|
+
function optionTokens(args) {
|
|
86
|
+
const tokens = args.map((arg) => String(arg ?? ""));
|
|
87
|
+
const end = tokens.indexOf("--");
|
|
88
|
+
return end < 0 ? tokens : tokens.slice(0, end);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function optionValue(args, name) {
|
|
92
|
+
const tokens = optionTokens(args);
|
|
93
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
94
|
+
const arg = tokens[i];
|
|
95
|
+
if (arg === name) return String(tokens[i + 1] || "").trim();
|
|
96
|
+
if (arg.startsWith(`${name}=`)) return arg.slice(name.length + 1).trim();
|
|
97
|
+
}
|
|
98
|
+
return "";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Refuse a `session` command that gives an identity-deciding option more than once. */
|
|
102
|
+
export function assertUnambiguousIdentityOptions(args = []) {
|
|
103
|
+
if (String(args[0] || "").trim().toLowerCase() !== "session") return;
|
|
104
|
+
const tokens = optionTokens(args);
|
|
105
|
+
for (const name of IDENTITY_OPTIONS) {
|
|
106
|
+
const count = tokens.filter((t) => t === name || t.startsWith(`${name}=`)).length;
|
|
107
|
+
if (count > 1) {
|
|
108
|
+
throw new AdmissionCredentialRefused(
|
|
109
|
+
`${name} is given ${count} times. It decides which identity and credential this ` +
|
|
110
|
+
`command runs as, so it may be given at most once.`,
|
|
111
|
+
{ reason: "ambiguous_identity_option" }
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Who `--agent` names, per `session` subcommand ("group sub" for nested groups).
|
|
118
|
+
// actor -- the named agent performs the operation: it runs on THAT agent's
|
|
119
|
+
// admission (or refuses, if what is stored is a tombstone)
|
|
120
|
+
// target -- a human acts ON that agent (stop its listener, kill or unregister
|
|
121
|
+
// it): it stays on the human's own credential, so an owner can always
|
|
122
|
+
// recover from a misbehaving or expired agent
|
|
123
|
+
// control -- join: the human's control plane; it scopes its own post-claim steps
|
|
124
|
+
// A `session` subcommand missing from this table fails CLOSED when an admission
|
|
125
|
+
// is stored for its (session, agent): new commands must be classified.
|
|
126
|
+
export const SESSION_AGENT_PRINCIPAL = Object.freeze({
|
|
127
|
+
say: "actor",
|
|
128
|
+
"post-agent": "actor",
|
|
129
|
+
observe: "actor",
|
|
130
|
+
action: "actor",
|
|
131
|
+
react: "actor",
|
|
132
|
+
reply: "actor",
|
|
133
|
+
comment: "actor",
|
|
134
|
+
edit: "actor",
|
|
135
|
+
read: "actor",
|
|
136
|
+
listen: "actor",
|
|
137
|
+
pins: "actor",
|
|
138
|
+
recap: "actor",
|
|
139
|
+
now: "actor",
|
|
140
|
+
lock: "actor",
|
|
141
|
+
unlock: "actor",
|
|
142
|
+
renew: "actor",
|
|
143
|
+
guard: "actor",
|
|
144
|
+
"guard-hook": "actor",
|
|
145
|
+
"guard-install": "actor",
|
|
146
|
+
"codex-notify": "actor",
|
|
147
|
+
daemon: "actor",
|
|
148
|
+
"checkpoint create": "actor",
|
|
149
|
+
"checkpoint generate": "actor",
|
|
150
|
+
"ticket list": "actor",
|
|
151
|
+
"ticket claim": "actor",
|
|
152
|
+
"ticket renew": "actor",
|
|
153
|
+
"ticket release": "actor",
|
|
154
|
+
"ticket submit": "actor",
|
|
155
|
+
"ticket events": "actor",
|
|
156
|
+
"access mode": "control",
|
|
157
|
+
"access request": "control",
|
|
158
|
+
"access list": "control",
|
|
159
|
+
"access status": "control",
|
|
160
|
+
"access approve": "control",
|
|
161
|
+
"access deny": "control",
|
|
162
|
+
"access revoke": "control",
|
|
163
|
+
kill: "target",
|
|
164
|
+
"stop-listener": "target",
|
|
165
|
+
leave: "target",
|
|
166
|
+
"guard-uninstall": "target",
|
|
167
|
+
join: "control",
|
|
168
|
+
});
|
|
169
|
+
const SESSION_COMMAND_GROUPS = new Set(["access", "checkpoint", "ticket"]);
|
|
170
|
+
|
|
171
|
+
async function implicitAgent(sessionId, targetPath, env) {
|
|
172
|
+
// The same identity the command itself resolves when --agent is omitted
|
|
173
|
+
// (SENTINELAYER_AGENT_ID, then the sole joined local agent). Imported lazily:
|
|
174
|
+
// the commands module imports this one.
|
|
175
|
+
const { resolveSessionSayIdentity } = await import("../commands/session.js");
|
|
176
|
+
const identity = await resolveSessionSayIdentity({ sessionId, agentId: "", targetPath, env }).catch(() => null);
|
|
177
|
+
const agentId = String(identity?.agentId || "").trim();
|
|
178
|
+
return agentId && agentId !== "cli-user" ? agentId : "";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The (session, agent) a `session` command must run as, when this machine holds an
|
|
183
|
+
* admission for it -- live OR tombstoned -- else null. Used by BOTH the auth gate
|
|
184
|
+
* and the dispatch choke point in runCli, so the two can never disagree.
|
|
185
|
+
*
|
|
186
|
+
* Only ACTOR commands are scoped (see SESSION_AGENT_PRINCIPAL). The agent is
|
|
187
|
+
* --agent, else SENTINELAYER_AGENT_ID, else the implicit joined identity. A
|
|
188
|
+
* subcommand outside the table is scoped only if it NAMES an agent with --agent,
|
|
189
|
+
* and then it refuses (unclassified); otherwise it is a human command. The
|
|
190
|
+
* session is --session or any positional argument for which an admission is stored:
|
|
191
|
+
* a credential file is keyed by (session, agent), so a positional that is not a
|
|
192
|
+
* session can never match one. An unclassified subcommand with a stored admission
|
|
193
|
+
* throws: it is never silently run on the human token.
|
|
194
|
+
*/
|
|
195
|
+
export async function resolveAgentAdmissionTarget(args = [], { env = process.env, homeDir } = {}) {
|
|
196
|
+
if (String(args[0] || "").trim().toLowerCase() !== "session") return null;
|
|
197
|
+
assertUnambiguousIdentityOptions(args);
|
|
198
|
+
const tokens = args.map((a) => String(a ?? "").trim());
|
|
199
|
+
const terminator = tokens.indexOf("--");
|
|
200
|
+
const positional = [
|
|
201
|
+
...(terminator < 0 ? tokens : tokens.slice(0, terminator)).slice(1).filter((a) => a && !a.startsWith("-")),
|
|
202
|
+
...(terminator < 0 ? [] : tokens.slice(terminator + 1).filter(Boolean)),
|
|
203
|
+
];
|
|
204
|
+
if (!positional.length) return null;
|
|
205
|
+
// Every `session` subcommand, join included: an identity-changing spelling is refused
|
|
206
|
+
// before any lookup, dispatch or request.
|
|
207
|
+
assertCanonicalAgentId(optionValue(args, "--agent"), "--agent");
|
|
208
|
+
assertCanonicalAgentId(env.SENTINELAYER_AGENT_ID, "SENTINELAYER_AGENT_ID");
|
|
209
|
+
let command = positional[0].toLowerCase();
|
|
210
|
+
let rest = positional.slice(1);
|
|
211
|
+
if (SESSION_COMMAND_GROUPS.has(command) && rest.length) {
|
|
212
|
+
command = `${command} ${rest[0].toLowerCase()}`;
|
|
213
|
+
rest = rest.slice(1);
|
|
214
|
+
}
|
|
215
|
+
const principal = SESSION_AGENT_PRINCIPAL[command];
|
|
216
|
+
if (principal === "target" || principal === "control") return null;
|
|
217
|
+
const flagAgent = optionValue(args, "--agent");
|
|
218
|
+
// A subcommand outside the table that names NO agent is a human command (archive,
|
|
219
|
+
// members, ...): it is not agent-attributed, whatever SENTINELAYER_AGENT_ID says.
|
|
220
|
+
if (!principal && !flagAgent) return null;
|
|
221
|
+
const explicitAgent = flagAgent || String(env.SENTINELAYER_AGENT_ID || "").trim();
|
|
222
|
+
const candidates = [optionValue(args, "--session"), optionValue(args, "--id"), ...rest].filter(Boolean);
|
|
223
|
+
for (const sessionId of candidates) {
|
|
224
|
+
const targetPath = path.resolve(process.cwd(), optionValue(args, "--path") || ".");
|
|
225
|
+
const agentId = explicitAgent || (await implicitAgent(sessionId, targetPath, env));
|
|
226
|
+
if (!agentId) continue;
|
|
227
|
+
const held = await readAdmissionCredentialState(sessionId, agentId, { homeDir }).catch(() => ({ state: "none" }));
|
|
228
|
+
if (held.state === "none") continue;
|
|
229
|
+
if (!principal) {
|
|
230
|
+
throw new AdmissionCredentialRefused(
|
|
231
|
+
`\`sl session ${command}\` is not classified as acting as "${agentId}" or on it, and "${agentId}" ` +
|
|
232
|
+
`holds an admission in session ${sessionId}. Refusing rather than guessing which credential it should use.`,
|
|
233
|
+
{ reason: "unclassified_command", sessionId, agentId }
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
return { sessionId, agentId };
|
|
237
|
+
}
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* preAction guard: the identity Commander ACTUALLY resolved for this action must be
|
|
243
|
+
* the one whose admission scope (if any) the dispatch was wrapped in. Computed from
|
|
244
|
+
* Commander's own resolved options -- counting only values given on the command line,
|
|
245
|
+
* not defaults such as read's `--agent cli-user` -- so no argv reading of ours can
|
|
246
|
+
* disagree with execution. A mismatch refuses before the action runs.
|
|
247
|
+
*/
|
|
248
|
+
export async function assertDispatchMatchesScope(actionCommand, { env = process.env, homeDir } = {}) {
|
|
249
|
+
const names = [];
|
|
250
|
+
for (let cmd = actionCommand; cmd && cmd.parent; cmd = cmd.parent) names.unshift(cmd.name());
|
|
251
|
+
if (String(names[0] || "").toLowerCase() !== "session") return;
|
|
252
|
+
const command = names.slice(1).join(" ").toLowerCase();
|
|
253
|
+
const principal = SESSION_AGENT_PRINCIPAL[command];
|
|
254
|
+
const opts = actionCommand.opts();
|
|
255
|
+
const given = (key) =>
|
|
256
|
+
actionCommand.getOptionValueSource?.(key) === "cli" ? String(opts[key] ?? "").trim() : "";
|
|
257
|
+
let actual = null;
|
|
258
|
+
if (principal === "actor" || (!principal && given("agent"))) {
|
|
259
|
+
const sessionId = given("session") || given("id") || String(actionCommand.processedArgs?.[0] ?? "").trim();
|
|
260
|
+
const targetPath = path.resolve(process.cwd(), String(opts.path || "."));
|
|
261
|
+
const agentId =
|
|
262
|
+
given("agent") ||
|
|
263
|
+
String(env.SENTINELAYER_AGENT_ID || "").trim() ||
|
|
264
|
+
(sessionId ? await implicitAgent(sessionId, targetPath, env) : "");
|
|
265
|
+
if (sessionId && agentId) {
|
|
266
|
+
const held = await readAdmissionCredentialState(sessionId, agentId, { homeDir }).catch(() => ({ state: "none" }));
|
|
267
|
+
if (held.state !== "none") actual = { sessionId, agentId };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const inForce = currentAdmittedAgent();
|
|
271
|
+
const key = (pair) => (pair ? `${String(pair.sessionId).toLowerCase()}/${String(pair.agentId).toLowerCase()}` : "none");
|
|
272
|
+
if (key(actual) !== key(inForce)) {
|
|
273
|
+
throw new AdmissionCredentialRefused(
|
|
274
|
+
`Refusing: \`sl session ${command}\` resolved to ${key(actual)} but was dispatched under ` +
|
|
275
|
+
`${key(inForce)}. Authorization and execution must name the same identity.`,
|
|
276
|
+
{ reason: "scope_mismatch" }
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Run `fn` with the right credential for (sessionId, agentId). See the module header. */
|
|
282
|
+
export async function withAgentAdmission(sessionId, agentId, fn, { homeDir, now } = {}) {
|
|
283
|
+
const held = await readAdmissionCredentialState(sessionId, agentId, { homeDir, now });
|
|
284
|
+
if (held.state === "none") return fn({ admitted: false });
|
|
285
|
+
if (held.state !== "live") {
|
|
286
|
+
const why = REFUSAL_HINT[held.reason] || `its stored admission is unusable (${held.reason})`;
|
|
287
|
+
throw new AdmissionCredentialRefused(
|
|
288
|
+
`Agent "${agentId}" cannot act in session ${sessionId}: ${why}. ` +
|
|
289
|
+
`It will not fall back to your own credentials. Request a new admission with ` +
|
|
290
|
+
`\`sl session join ${sessionId} --agent ${agentId} --goal "<what it is here to do>"\`.`,
|
|
291
|
+
{ reason: held.reason, sessionId, agentId }
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
const credential = Object.freeze({ ...held.credential });
|
|
295
|
+
try {
|
|
296
|
+
return await admittedAgentScope.run(credential, () =>
|
|
297
|
+
fn({ admitted: true, admissionId: credential.admissionId, agentId: credential.agentId })
|
|
298
|
+
);
|
|
299
|
+
} catch (error) {
|
|
300
|
+
if (error instanceof Error && /\bapi_401\b|not_authenticated/.test(error.message)) {
|
|
301
|
+
error.message +=
|
|
302
|
+
` The API no longer accepts agent "${agentId}"'s admission for this room (revoked, expired,` +
|
|
303
|
+
` or outside its grant). It did not fall back to your own credentials.`;
|
|
304
|
+
}
|
|
305
|
+
throw error;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** The admission this code is running under, without the token, or null. */
|
|
310
|
+
export function currentAdmittedAgent() {
|
|
311
|
+
const credential = admittedAgentScope.getStore();
|
|
312
|
+
if (!credential) return null;
|
|
313
|
+
return {
|
|
314
|
+
admissionId: credential.admissionId,
|
|
315
|
+
sessionId: credential.sessionId,
|
|
316
|
+
agentId: credential.agentId,
|
|
317
|
+
expiresAt: credential.expiresAt,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// admission-preimage.js — the bytes an agent signs to claim an admission.
|
|
2
|
+
//
|
|
3
|
+
// This MUST reproduce sentinelayer-api src/services/canonical_preimage.py byte for
|
|
4
|
+
// byte, or every claim signature fails to verify. The API SPEC ("Agent Passport
|
|
5
|
+
// Contract", preimage construction) is normative:
|
|
6
|
+
//
|
|
7
|
+
// ASCII(domain) || 0x00 || JCS( select(payload, fields) with every string NFC )
|
|
8
|
+
//
|
|
9
|
+
// It deliberately COMPOSES rather than re-implements. JSON encoding is the one JCS
|
|
10
|
+
// encoder this CLI already has (src/engram/canonical.js); the NFC step is applied
|
|
11
|
+
// here, BEFORE encoding, because that is where the API format puts it. The engram
|
|
12
|
+
// encoder must stay non-normalising (content addressing needs NFC != NFD), so the
|
|
13
|
+
// normalisation belongs to this format, not to the shared encoder.
|
|
14
|
+
//
|
|
15
|
+
// Rules copied from the Python, each a refusal rather than a coercion:
|
|
16
|
+
// - the domain is required and ASCII
|
|
17
|
+
// - fields is a non-empty SET: the caller's order is ignored
|
|
18
|
+
// - a named field absent from the payload is an error, never an omission
|
|
19
|
+
// - object keys are ASCII, so UTF-16 and code-point key order agree
|
|
20
|
+
// - no floats: integers must be safe integers (enforced by the encoder)
|
|
21
|
+
// - every key is an OWN data property, "__proto__" included. Objects built here
|
|
22
|
+
// have a null prototype, so assigning that key defines it rather than invoking
|
|
23
|
+
// the legacy prototype setter (which would silently drop it from the bytes).
|
|
24
|
+
//
|
|
25
|
+
// Conformance: tests/fixtures/admission/canonical-preimage-vectors-v1.json is
|
|
26
|
+
// generated FROM the Python implementation; tests/unit.admission-preimage.test.mjs
|
|
27
|
+
// asserts these bytes match every vector exactly.
|
|
28
|
+
|
|
29
|
+
import { canonicalize } from "../engram/canonical.js";
|
|
30
|
+
|
|
31
|
+
const ASCII = /^[\x00-\x7f]*$/;
|
|
32
|
+
|
|
33
|
+
export class CanonicalPreimageError extends TypeError {}
|
|
34
|
+
|
|
35
|
+
function nfcDeep(value, path) {
|
|
36
|
+
if (typeof value === "string") return value.normalize("NFC");
|
|
37
|
+
if (value === null || typeof value !== "object") return value;
|
|
38
|
+
if (Array.isArray(value)) return value.map((item, i) => nfcDeep(item, `${path}[${i}]`));
|
|
39
|
+
const out = Object.create(null);
|
|
40
|
+
for (const [key, item] of Object.entries(value)) {
|
|
41
|
+
if (!ASCII.test(key)) {
|
|
42
|
+
throw new CanonicalPreimageError(`object keys must be ASCII at ${path}: ${JSON.stringify(key)}`);
|
|
43
|
+
}
|
|
44
|
+
out[key] = nfcDeep(item, `${path}.${key}`);
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The exact bytes to sign or verify, as a Buffer. */
|
|
50
|
+
export function canonicalPreimage(payload, { domain, fields }) {
|
|
51
|
+
const normalizedDomain = String(domain ?? "").normalize("NFC").trim();
|
|
52
|
+
if (!normalizedDomain) throw new CanonicalPreimageError("domain is required");
|
|
53
|
+
if (!ASCII.test(normalizedDomain)) throw new CanonicalPreimageError("domain must be ASCII");
|
|
54
|
+
if (!Array.isArray(fields) || fields.length === 0) {
|
|
55
|
+
throw new CanonicalPreimageError(
|
|
56
|
+
"fields is required: an empty selection would sign nothing while appearing to sign a document"
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
|
|
60
|
+
throw new CanonicalPreimageError("payload must be an object");
|
|
61
|
+
}
|
|
62
|
+
const selected = Object.create(null);
|
|
63
|
+
const missing = [];
|
|
64
|
+
for (const name of fields) {
|
|
65
|
+
if (typeof name !== "string" || !ASCII.test(name)) {
|
|
66
|
+
throw new CanonicalPreimageError(`field names must be ASCII strings: ${JSON.stringify(name)}`);
|
|
67
|
+
}
|
|
68
|
+
if (!Object.prototype.hasOwnProperty.call(payload, name)) {
|
|
69
|
+
missing.push(name);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
selected[name] = payload[name];
|
|
73
|
+
}
|
|
74
|
+
if (missing.length) {
|
|
75
|
+
throw new CanonicalPreimageError(
|
|
76
|
+
`fields absent from payload cannot be covered: ${missing.sort().join(", ")}`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
let body;
|
|
80
|
+
try {
|
|
81
|
+
body = canonicalize(nfcDeep(selected, "$"));
|
|
82
|
+
} catch (error) {
|
|
83
|
+
throw new CanonicalPreimageError(error.message);
|
|
84
|
+
}
|
|
85
|
+
return Buffer.concat([
|
|
86
|
+
Buffer.from(normalizedDomain, "ascii"),
|
|
87
|
+
Buffer.from([0x00]),
|
|
88
|
+
Buffer.from(body, "utf8"),
|
|
89
|
+
]);
|
|
90
|
+
}
|