@uuaid/agent-commons 0.2.0-alpha.2 → 0.2.0-alpha.4
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/CONFORMANCE.md +1 -1
- package/NOTICE +4 -7
- package/PROVENANCE.json +12 -17
- package/README.md +103 -12
- package/bin/agent-commons.mjs +8 -3
- package/examples/Dockerfile +1 -1
- package/package.json +5 -2
- package/src/carrier.mjs +7 -2
- package/src/channels.mjs +1 -1
- package/src/config.mjs +2 -1
- package/src/continuity.mjs +14 -7
- package/src/governance-scope.mjs +1 -1
- package/src/governance.mjs +4 -2
- package/src/index.d.ts +3 -1
- package/src/pillar.mjs +2 -2
- package/src/profiles.mjs +2 -1
- package/src/relay/auth.mjs +294 -0
- package/src/relay/board.mjs +223 -0
- package/src/relay/claims.mjs +25 -0
- package/src/relay/cli.mjs +49 -0
- package/src/relay/client.mjs +437 -0
- package/src/relay/docs/reconnect-v1.md +27 -0
- package/src/relay/docs/working-rules-v1.md +42 -0
- package/src/relay/enroll.mjs +232 -0
- package/src/relay/export-cli.mjs +47 -0
- package/src/relay/governance.mjs +1328 -0
- package/src/relay/mailbox.mjs +103 -0
- package/src/relay/records.mjs +146 -0
- package/src/relay/render.mjs +38 -0
- package/src/relay/roster.mjs +134 -0
- package/src/relay/routes.mjs +87 -0
- package/src/relay/schema.mjs +1081 -0
- package/src/relay/seed.mjs +89 -0
- package/src/relay/serve.mjs +116 -0
- package/src/relay/server.mjs +633 -0
- package/src/relay/signing.mjs +693 -0
- package/src/relay/test-mode.mjs +73 -0
- package/src/runtime.mjs +25 -12
- package/src/spaces/manifest.mjs +245 -0
- package/src/spaces/pillar-group.mjs +12 -0
- package/src/spaces/policy.mjs +229 -0
- package/src/spaces/store.mjs +325 -0
- package/src/trust.mjs +44 -5
- package/src/uuaid-namespace.d.mts +6 -0
- package/src/uuaid-namespace.mjs +48 -0
- package/src/vendor/pillar/blob/blob.mjs +12 -14
- package/src/vendor/pillar/groups-v2.mjs +1108 -0
- package/src/vendor/pillar/groups.mjs +175 -0
- package/src/vendor/pillar/identity/jcs.mjs +4 -5
- package/src/vendor/pillar/identity/keychain.mjs +51 -2
- package/src/vendor/pillar/net/carrier-client.mjs +29 -7
- package/src/vendor/pillar/net/refusals.mjs +4 -2
- package/test/channels.test.mjs +0 -268
- package/test/cohort.test.mjs +0 -36
- package/test/collaboration.test.mjs +0 -459
- package/test/continuity.test.mjs +0 -229
- package/test/custody.negative.test.mjs +0 -165
- package/test/derivation-parity.test.mjs +0 -19
- package/test/fixtures/derivation-vectors.json +0 -26
- package/test/fixtures/ed25519-small-order.vectors.json +0 -15
- package/test/governance-expected-red.expected-red.mjs +0 -241
- package/test/governance-scope.test.mjs +0 -142
- package/test/governance-t1-t10.test.mjs +0 -477
- package/test/governance.test.mjs +0 -514
- package/test/hold-admission.test.mjs +0 -1185
- package/test/keychain-strict-ed25519.test.mjs +0 -240
- package/test/offline.test.mjs +0 -152
- package/test/runtime-channels.test.mjs +0 -1140
- package/test/runtime.test.mjs +0 -201
- package/test/security-runtime.test.mjs +0 -104
- package/test/strict-ed25519-consumers.test.mjs +0 -234
- package/test/transport.test.mjs +0 -353
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Cloud Commons relay: the --test-mode hooks (spec v3.4.5 13.2 "Test mode").
|
|
2
|
+
//
|
|
3
|
+
// server.mjs loads this module only when the relay starts with --test-mode, and only after the
|
|
4
|
+
// conjunctive start refusal has passed (the test aud AND a loopback or named test-interface
|
|
5
|
+
// bind). A non-test build can leave this file out entirely (security invariant: its entrypoint must not carry the
|
|
6
|
+
// flag).
|
|
7
|
+
//
|
|
8
|
+
// - Clock: relay time is the integer (ms) in <dir>/clock, read on every use. The harness moves it;
|
|
9
|
+
// nothing in a request can set or move it.
|
|
10
|
+
// - Barriers: a closed list of named points in the request path. While <dir>/barriers/<name>.hold
|
|
11
|
+
// exists, a request reaching that point writes <dir>/barriers/<name>.reached and waits until the
|
|
12
|
+
// .hold file is gone. No request can name, create or clear a barrier.
|
|
13
|
+
// - Container-local only: <dir> is one absolute, normalized path given at start, never
|
|
14
|
+
// taken from a request, with no symlink anywhere in it (its realpath is itself). The directory and
|
|
15
|
+
// every file read from it must be a real directory or regular file (never a symlink, so no path is
|
|
16
|
+
// redirected elsewhere), owned by this process's user, and not writable by group or others. The
|
|
17
|
+
// harness asserts no host mounts (13.2).
|
|
18
|
+
|
|
19
|
+
import { lstatSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { isAbsolute, join, normalize } from "node:path";
|
|
21
|
+
import { RelayError } from "./records.mjs";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The closed barrier list. Relay: before the record handler runs, and after it committed (before the
|
|
25
|
+
* response). Reference client (13.2: "the reference client takes the same barrier hooks"): after it
|
|
26
|
+
* read an inbox record and before handling it, after handling and before acknowledging, and before
|
|
27
|
+
* it drains its outbox.
|
|
28
|
+
*/
|
|
29
|
+
export const BARRIERS = Object.freeze(["before-dispatch", "after-commit", "client-after-read", "client-after-handle", "client-before-drain"]);
|
|
30
|
+
const POLL_MS = 10;
|
|
31
|
+
|
|
32
|
+
function privateEntry(path, kind) {
|
|
33
|
+
let st;
|
|
34
|
+
try { st = lstatSync(path); } catch { return null; }
|
|
35
|
+
const ok = (kind === "dir" ? st.isDirectory() : st.isFile()) && !st.isSymbolicLink()
|
|
36
|
+
&& (typeof process.getuid !== "function" || st.uid === process.getuid()) && (st.mode & 0o022) === 0;
|
|
37
|
+
if (!ok) throw new RelayError("test-mode-path", 500, `${kind} must be a private ${kind === "dir" ? "directory" : "regular file"} of this user, not a symlink`);
|
|
38
|
+
return st;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Open the hooks for one test directory. Throws (and the relay refuses to start) unless the
|
|
43
|
+
* directory and its clock file pass the checks above and the clock holds a valid time.
|
|
44
|
+
* Returns { now(), barrier(name, signal), dir }.
|
|
45
|
+
*/
|
|
46
|
+
export function openTestHooks(dir) {
|
|
47
|
+
if (typeof dir !== "string" || !isAbsolute(dir) || normalize(dir) !== dir || dir.length > 512) throw new RelayError("test-mode-path", 500, "test dir must be an absolute, normalized path");
|
|
48
|
+
if (!privateEntry(dir, "dir")) throw new RelayError("test-mode-path", 500, "test dir missing");
|
|
49
|
+
if (realpathSync(dir) !== dir) throw new RelayError("test-mode-path", 500, "test dir path must contain no symlink");
|
|
50
|
+
const clockPath = join(dir, "clock");
|
|
51
|
+
const barrierDir = join(dir, "barriers");
|
|
52
|
+
|
|
53
|
+
function now() {
|
|
54
|
+
if (!privateEntry(clockPath, "file")) throw new RelayError("test-clock", 500, "clock file missing");
|
|
55
|
+
const text = readFileSync(clockPath, "utf8");
|
|
56
|
+
if (!/^(0|[1-9][0-9]{0,15})\n?$/.test(text)) throw new RelayError("test-clock", 500, "clock file must hold one integer (ms)");
|
|
57
|
+
const t = Number(text.trim());
|
|
58
|
+
if (!Number.isSafeInteger(t)) throw new RelayError("test-clock", 500, "clock out of range");
|
|
59
|
+
return t;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Wait at a named point while its .hold file exists. Resolves at once when there is none. */
|
|
63
|
+
async function barrier(name, signal) {
|
|
64
|
+
if (!BARRIERS.includes(name)) throw new RelayError("test-barrier", 500, "unknown barrier");
|
|
65
|
+
const hold = join(barrierDir, `${name}.hold`);
|
|
66
|
+
if (!privateEntry(barrierDir, "dir") || !privateEntry(hold, "file")) return;
|
|
67
|
+
writeFileSync(join(barrierDir, `${name}.reached`), `${process.pid}\n`, { mode: 0o600 });
|
|
68
|
+
while (!signal?.aborted && privateEntry(hold, "file")) await new Promise(r => setTimeout(r, POLL_MS));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
now(); // refuse to start on a missing or invalid clock
|
|
72
|
+
return Object.freeze({ now, barrier, dir });
|
|
73
|
+
}
|
package/src/runtime.mjs
CHANGED
|
@@ -3,12 +3,15 @@ import { Keychain, seal, open, decrypt, envelopeSha, CarrierClient, jcs } from "
|
|
|
3
3
|
import { CommonsStore } from "./store.mjs";
|
|
4
4
|
import { RegistryTrust } from "./trust.mjs";
|
|
5
5
|
import { BASE_PROTOCOL, createProfile, verifyProfile, proposeAlias, encode, decode, digest, signDocument, verifyDocument, contribution } from "./profiles.mjs";
|
|
6
|
-
//
|
|
6
|
+
// Pluggable transport and channel/tenant admission
|
|
7
7
|
import { PillarCarrierTransport } from "./transport.mjs";
|
|
8
8
|
import { classifyThread, assertChannelSendAdmission, assertChannelReceiveAdmission, validateTenantBinding } from "./channels.mjs";
|
|
9
9
|
|
|
10
|
+
// D-12: only a string envelope id goes into an audit event; anything else a peer sends (absent, 1.5, an object) becomes null.
|
|
11
|
+
const auditEnvelopeId=id=>typeof id==="string"?id:null;
|
|
12
|
+
|
|
10
13
|
export class AgentCommons {
|
|
11
|
-
//
|
|
14
|
+
// options.transport and channel/tenant policy flags
|
|
12
15
|
constructor({keychain,store=new CommonsStore(),policy={mode:"local",agents:{}},carriers=[],transport,trust}={}){
|
|
13
16
|
if(!keychain?._identity)throw new Error("A loaded Pillar keychain is required");
|
|
14
17
|
this.keychain=keychain;this.store=store;this.policy=policy;this.trust=trust??new RegistryTrust({policy});this.carriers=carriers;
|
|
@@ -111,7 +114,7 @@ export class AgentCommons {
|
|
|
111
114
|
await this.trust.authorize(this.uuaid,this.publicKey,"commons:contribute");
|
|
112
115
|
return signDocument(this.keychain,"profile-contribution",{contribution:contribution(this.profile(profileId),options)});
|
|
113
116
|
}
|
|
114
|
-
//
|
|
117
|
+
// Channel/tenant admission, atomic authenticated admission record
|
|
115
118
|
async send({recipient,profileId,namespace,body,thread="commons",kind="message",deferFlush=false}){
|
|
116
119
|
if(typeof body!=="string"||Buffer.byteLength(body)>32000||typeof thread!=="string"||thread.length>120||!["message","profile-control"].includes(kind))throw new Error("Invalid bounded message");
|
|
117
120
|
const classified=classifyThread(thread,this.policy);
|
|
@@ -146,7 +149,7 @@ export class AgentCommons {
|
|
|
146
149
|
return {queued:queued.map(x=>({recipient:x.envelope.recipient,id:x.envelope.id})),deliveries:await this.flush(),groupPrivacy:"individually-sealed-recipient-copies"};
|
|
147
150
|
}
|
|
148
151
|
flush(){const next=this._flushTail.then(()=>this.flushInternal());this._flushTail=next.catch(()=>{});return next}
|
|
149
|
-
//
|
|
152
|
+
// Pre-delivery rechecks, signature verification, and delivery
|
|
150
153
|
async flushInternal(){
|
|
151
154
|
if(!this.transport)return [];
|
|
152
155
|
const results=[];
|
|
@@ -192,7 +195,7 @@ export class AgentCommons {
|
|
|
192
195
|
return results;
|
|
193
196
|
}
|
|
194
197
|
receive(envelope){const next=this._receiveTail.then(()=>this.receiveInternal(envelope));this._receiveTail=next.catch(()=>{});return next}
|
|
195
|
-
//
|
|
198
|
+
// Inbound channel and tenant admission
|
|
196
199
|
async receiveInternal(envelope){
|
|
197
200
|
const valid=open(envelope);if(!valid.ok)throw new Error(`Invalid Pillar envelope: ${valid.reason}`);
|
|
198
201
|
if(envelope.delegation!==undefined)throw new Error("Delegated host admission requires a configured principal-status adapter; not yet enabled");
|
|
@@ -289,7 +292,7 @@ export class AgentCommons {
|
|
|
289
292
|
}
|
|
290
293
|
return results;
|
|
291
294
|
}
|
|
292
|
-
//
|
|
295
|
+
// Foreign item skip with cursor advance and recipient isolation
|
|
293
296
|
async poll(){
|
|
294
297
|
if(!this.transport)throw new Error("No carrier configured");
|
|
295
298
|
const cursors=this.store.get("cursors",{}),health=this.store.get("carrierHealth",{}),results=[];
|
|
@@ -303,10 +306,14 @@ export class AgentCommons {
|
|
|
303
306
|
for(const item of inbox.envelopes){
|
|
304
307
|
// A carrier may not rewind or repeat sequence numbers within a response.
|
|
305
308
|
if(!Number.isSafeInteger(item?.seq)||item.seq<=last){results.push({carrier,rejected:true,reason:"non-monotonic-carrier-sequence"});continue}
|
|
306
|
-
|
|
309
|
+
// D-12: open() can throw on peer input (it interpolates fields); a throw is one more invalid envelope, not a poll failure.
|
|
310
|
+
let valid;
|
|
311
|
+
try{valid=open(item?.envelope)}catch{valid={ok:false,reason:"validation-threw"}}
|
|
307
312
|
if(!valid.ok){
|
|
308
|
-
|
|
309
|
-
|
|
313
|
+
// D-12: the peer's id is data inside the event, never the audit key; the cursor moves only after the audit write lands.
|
|
314
|
+
const id=auditEnvelopeId(item?.envelope?.id),reason=`Invalid Pillar envelope: ${typeof valid.reason==="string"?valid.reason:"invalid"}`;
|
|
315
|
+
results.push({id,rejected:true,reason});
|
|
316
|
+
this.store.append(randomUUID(),{kind:"quarantined-inbound",id,reason});
|
|
310
317
|
last=item.seq;cursors[carrier]=item.seq;this.store.set("cursors",cursors);
|
|
311
318
|
continue;
|
|
312
319
|
}
|
|
@@ -321,10 +328,16 @@ export class AgentCommons {
|
|
|
321
328
|
if(error?.transient===true){
|
|
322
329
|
// Trust backend outage is not a verdict on the sender: keep the cursor so the envelope is retried, and stop to preserve order.
|
|
323
330
|
const failures=(health[carrier]?.failures??0)+1;health[carrier]={failures,nextProbeAt:Date.now()+Math.min(60000,1000*2**Math.min(failures,6)),error:error.code??"trust-unavailable"};this.store.set("carrierHealth",health);
|
|
324
|
-
|
|
325
|
-
|
|
331
|
+
// D-12: JSON.stringify keeps the key one-to-one with id (null vs "null", lone surrogates), and the event is a
|
|
332
|
+
// function of id alone, so a retry rewrites the same row and two different ids never share a key. The
|
|
333
|
+
// "deferred/v2:" namespace keeps these keys apart from rows written as `deferred-${id}` before this change.
|
|
334
|
+
const id=auditEnvelopeId(item.envelope?.id);
|
|
335
|
+
this.store.append(`deferred/v2:${JSON.stringify(id)}`,{kind:"inbound-deferred-trust-unavailable",id});
|
|
336
|
+
results.push({id,rejected:false,deferred:true,state:"trust-unavailable-retry",reason:error.code??"trust-unavailable"});break;
|
|
326
337
|
}
|
|
327
|
-
|
|
338
|
+
// D-12: a thrown value need not carry a string message; never serialize it into the audit.
|
|
339
|
+
const id=auditEnvelopeId(item.envelope?.id),reason=typeof error?.message==="string"?error.message:"receive-failed";
|
|
340
|
+
results.push({id,rejected:true,reason});this.store.append(randomUUID(),{kind:"quarantined-inbound",id,reason});
|
|
328
341
|
}
|
|
329
342
|
last=item.seq;cursors[carrier]=item.seq;this.store.set("cursors",cursors);
|
|
330
343
|
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manifest verifier wrapper, admin signer and key→uuaid binding helpers
|
|
3
|
+
* (design §3, §4). Additive; imports the vendored pillar engine ONLY through
|
|
4
|
+
* ./pillar-group.mjs (C9). Computes no engine-vs-client hash (C13/V-7): the
|
|
5
|
+
* manifest path uses the vendored canonicalJson, never the engine's jcs (V-1).
|
|
6
|
+
*
|
|
7
|
+
* In scope here: V-3 (only whole chains yield a head), V-6's
|
|
8
|
+
* sign → verify → own-store-save → return order, B-1/B-2 (one binding,
|
|
9
|
+
* keyBindsRow: AC's parse-based rule-1 check plus the vendored strict-key
|
|
10
|
+
* rule), B-3 (policy-record candidates from the verified head), B-4 (full-uuaid
|
|
11
|
+
* keys admit only the roster's exact spelling at the engine trust gate), B-6
|
|
12
|
+
* (self check) and V-7's unit-level ASCII-key guard.
|
|
13
|
+
*
|
|
14
|
+
* R1: the admin change is the VENDORED `adminChange` (V-1, "vendor, don't
|
|
15
|
+
* write"), so a frozen record is refused and a leave/remove clears the target
|
|
16
|
+
* from `leaving` exactly as pillar's reference does (groups-v2.mjs:534, :541).
|
|
17
|
+
*
|
|
18
|
+
* Deferred by design, NOT stubbed: P-7 (a remove/leave manifest adds the
|
|
19
|
+
* pendingRemovals entry in the same step; it spans adapter.json),
|
|
20
|
+
* the sends themselves, V-7's send/receive wiring and
|
|
21
|
+
* B-5's transport-key snapshot.
|
|
22
|
+
*/
|
|
23
|
+
import {
|
|
24
|
+
adminChange, adminOf, isStrictPublicKey, manifestHash, verifyChain,
|
|
25
|
+
} from "./pillar-group.mjs";
|
|
26
|
+
import { keyBindsAgentUuaid } from "../uuaid-namespace.mjs";
|
|
27
|
+
|
|
28
|
+
/** A named error, so a caller (and a pole) can tell refusals apart. */
|
|
29
|
+
export class ManifestError extends Error {
|
|
30
|
+
constructor(name, message) { super(message); this.name = name; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** P-2: capabilities are exactly ["commons:message"] for every record. */
|
|
34
|
+
const AGENT_CAPABILITIES = Object.freeze(["commons:message"]);
|
|
35
|
+
|
|
36
|
+
// ── V-3: only whole chains ────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The usable head of a record, only if its chain verifies from version 1.
|
|
40
|
+
* Never takes a head from an unverified record: verifyChain runs here, in
|
|
41
|
+
* this call, whatever the record's provenance (SpaceStore.loadRoster output
|
|
42
|
+
* re-verifies too — this is the explicit gate for a supplied record).
|
|
43
|
+
*
|
|
44
|
+
* If `rec.head` is present it must agree with the verified chain's last
|
|
45
|
+
* manifest; a record without one is tolerated because the chain itself is
|
|
46
|
+
* the verified source of the head.
|
|
47
|
+
*/
|
|
48
|
+
export function verifiedHead(rec) {
|
|
49
|
+
if (!rec || !Array.isArray(rec.chain) || rec.chain.length === 0) {
|
|
50
|
+
throw new ManifestError("ChainVerifyError", "record: no chain to verify");
|
|
51
|
+
}
|
|
52
|
+
const v = verifyChain(rec.chain);
|
|
53
|
+
if (!v.ok) throw new ManifestError("ChainVerifyError", `chain rejected: ${v.reason}@${v.at}`);
|
|
54
|
+
const head = rec.chain[rec.chain.length - 1];
|
|
55
|
+
if (rec.head !== undefined && rec.head !== null) {
|
|
56
|
+
if (rec.head.version !== head.version || rec.head.hash !== manifestHash(head)) {
|
|
57
|
+
throw new ManifestError("HeadMismatchError", "rec.head does not match the verified chain's last manifest");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return head;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── B-6: self check ───────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
/** Normalize a Keychain instance (._identity) or a raw identity object. */
|
|
66
|
+
function keychainIdentity(keychain) {
|
|
67
|
+
const id = keychain?._identity ?? keychain;
|
|
68
|
+
if (!id || typeof id.uuaid !== "string" || id.uuaid.length === 0 ||
|
|
69
|
+
typeof id.publicKeyHex !== "string" || id.publicKeyHex.length === 0) {
|
|
70
|
+
throw new ManifestError("SelfCheckError", "keychain: no loaded identity {uuaid, publicKeyHex}");
|
|
71
|
+
}
|
|
72
|
+
return id;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* B-1/B-2: does `key` bind `uuaid`? Namespace alignment rule 1 — a key-derived
|
|
77
|
+
* agent UUAID in foundation, pillar or agentnet whose local id is the key's,
|
|
78
|
+
* PARSED, never rebuilt and compared (rule 2) — plus the vendored strict-key
|
|
79
|
+
* rule (no small-order or non-canonical point). One key therefore binds three
|
|
80
|
+
* spellings; B-4 is what keeps them apart at the gate.
|
|
81
|
+
*/
|
|
82
|
+
export function keyBindsRow(uuaid, key) {
|
|
83
|
+
return keyBindsAgentUuaid(uuaid, key) && isStrictPublicKey(key);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* B-6. Passes only when (a) the keychain's uuaid is bound by its own public key
|
|
88
|
+
* (keyBindsRow) AND (b) the VERIFIED head holds a row for that uuaid carrying
|
|
89
|
+
* that same key. Otherwise throws SelfCheckError. On a chain that verified,
|
|
90
|
+
* (b)'s key-mismatch arm is unreachable when (a) held (B-1: the local id is a
|
|
91
|
+
* function of the key, and rowChecks binds every row), so it is kept as a
|
|
92
|
+
* guard against a future vendored change and for callers that hand-build a
|
|
93
|
+
* head — same posture as the space store's keep-only-if clauses.
|
|
94
|
+
*/
|
|
95
|
+
export function selfCheck(keychain, rec) {
|
|
96
|
+
const id = keychainIdentity(keychain);
|
|
97
|
+
if (!keyBindsRow(id.uuaid, id.publicKeyHex)) {
|
|
98
|
+
throw new ManifestError("SelfCheckError", "keychain uuaid does not derive from its own public key");
|
|
99
|
+
}
|
|
100
|
+
const head = verifiedHead(rec);
|
|
101
|
+
const row = head.members.find((r) => r.uuaid === id.uuaid);
|
|
102
|
+
if (!row) throw new ManifestError("SelfCheckError", "the verified head holds no row for this keychain's uuaid");
|
|
103
|
+
if (row.key !== id.publicKeyHex) throw new ManifestError("SelfCheckError", "the head's own row carries a different key");
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── V-6: the admin signer (sign → verify → own-store-save → return) ──────
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The store the vendored `adminChange` writes through: SpaceStore's own
|
|
111
|
+
* roster load/save, so `loadRoster`'s absent-means-null (F13) and
|
|
112
|
+
* `saveRoster`'s verify-before-create plus 0700 tightening (S-1) are the only
|
|
113
|
+
* path to disk. `loadGroupForCommand` needs has/load; :542 needs save.
|
|
114
|
+
*/
|
|
115
|
+
function rosterStoreFor(store) {
|
|
116
|
+
return {
|
|
117
|
+
has: (id) => store.loadRoster(id) !== null,
|
|
118
|
+
load: (id) => {
|
|
119
|
+
const r = store.loadRoster(id);
|
|
120
|
+
if (!r) throw new ManifestError("RosterAbsentError", `no verified roster for ${String(id)}`);
|
|
121
|
+
return r;
|
|
122
|
+
},
|
|
123
|
+
save: (rec) => store.saveRoster(rec), // OWN STORE FIRST (S-1: refuses an unverified record)
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Build, sign and apply the next manifest to the ADMIN's own store, then
|
|
129
|
+
* return it for the caller to send (the send itself is not done here — this never
|
|
130
|
+
* sends). Order is load-bearing:
|
|
131
|
+
* 1. load through SpaceStore.loadRoster, which re-verifies (F13, V-3);
|
|
132
|
+
* 2. explicit verifiedHead (V-3) and selfCheck (B-6);
|
|
133
|
+
* 3. the signer must be the head's single active admin (F14);
|
|
134
|
+
* 4. a frozen record is refused BEFORE anything is built — the named error
|
|
135
|
+
* for pillar's own refusal (groups-v2.mjs:534);
|
|
136
|
+
* 5. the vendored `adminChange` builds, signs with the admin's own keychain
|
|
137
|
+
* (F14), verifies the next version, extends the chain, clears the target
|
|
138
|
+
* from `leaving` on leave/remove (:541) and saves to the admin's OWN
|
|
139
|
+
* store FIRST (:542);
|
|
140
|
+
* 6. only then return { manifest, record }.
|
|
141
|
+
*
|
|
142
|
+
* `mailbox: null` makes the vendored mailbox coupling a no-op
|
|
143
|
+
* (`syncMailboxRoster`, :457-458) — this wrapper owns no mailbox row.
|
|
144
|
+
*
|
|
145
|
+
* P-7 (a remove/leave manifest puts the member into pendingRemovals in the
|
|
146
|
+
* same step) is not done here: it spans the roster and adapter.json. The record
|
|
147
|
+
* fields other than chain/head/leaving (seen, frozen, …) are preserved
|
|
148
|
+
* verbatim; the receiver-side application semantics are not applied here.
|
|
149
|
+
*
|
|
150
|
+
* Synchronous; the adapter's serial queue (I-5) is the caller's job.
|
|
151
|
+
* @returns {{ manifest: object, record: object }}
|
|
152
|
+
*/
|
|
153
|
+
export function signAndApplyNext(store, groupId, keychain, action, buildOpts = {}) {
|
|
154
|
+
const rec = store.loadRoster(groupId); // re-verifies from version 1 (F13); null = absent
|
|
155
|
+
if (!rec) throw new ManifestError("RosterAbsentError", `no verified roster for ${String(groupId)}`);
|
|
156
|
+
const head = verifiedHead(rec); // V-3, explicit
|
|
157
|
+
const id = keychainIdentity(keychain);
|
|
158
|
+
selfCheck(keychain, rec); // B-6
|
|
159
|
+
const admin = adminOf(head);
|
|
160
|
+
if (!admin || admin.uuaid !== id.uuaid || admin.key !== id.publicKeyHex) {
|
|
161
|
+
throw new ManifestError("NotAdminError", "the signer is not the head's single active admin");
|
|
162
|
+
}
|
|
163
|
+
// D1: pillar refuses an equivicated group here (:534); this is the named
|
|
164
|
+
// form of that refusal, and the vendored call below refuses it again.
|
|
165
|
+
if (rec.frozen) {
|
|
166
|
+
throw new ManifestError("FrozenGroupError",
|
|
167
|
+
`${String(groupId)} is frozen (equivocation); a frozen group can only be replaced by a new group`);
|
|
168
|
+
}
|
|
169
|
+
// A raw identity is accepted by signManifest but NOT by :543, which reads
|
|
170
|
+
// keychain._identity.uuaid unguarded — normalize to the keychain shape.
|
|
171
|
+
const signer = keychain?._identity ? keychain : { _identity: id };
|
|
172
|
+
const { rec: record, manifest } = adminChange({
|
|
173
|
+
store: rosterStoreFor(store), mailbox: null, keychain: signer,
|
|
174
|
+
groupId, action, opts: buildOpts,
|
|
175
|
+
});
|
|
176
|
+
return { manifest, record }; // …and only then handed back for sending
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── B-3: policy-record candidates from the verified head ─────────────────
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* One candidate `policy.agents` entry from one roster row. The binding is
|
|
183
|
+
* keyBindsRow (B-1/B-2: rule 1 plus the vendored strict-key rule). A failing
|
|
184
|
+
* row THROWS; it is never
|
|
185
|
+
* skipped silently. Exported so the binding re-check is unit-testable on a
|
|
186
|
+
* hand-built row (on a verified chain rowChecks already binds every row, so
|
|
187
|
+
* the aggregate below re-checks as defense in depth).
|
|
188
|
+
*/
|
|
189
|
+
export function agentRecordForRow(row) {
|
|
190
|
+
if (!row || typeof row.uuaid !== "string" || typeof row.key !== "string") {
|
|
191
|
+
throw new ManifestError("KeyBindingError", "row: expected {uuaid, key} strings");
|
|
192
|
+
}
|
|
193
|
+
if (!keyBindsRow(row.uuaid, row.key)) {
|
|
194
|
+
throw new ManifestError("KeyBindingError", `row ${row.uuaid}: key does not bind its uuaid`);
|
|
195
|
+
}
|
|
196
|
+
return { kind: "agent", publicKey: row.key, capabilities: [...AGENT_CAPABILITIES] };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* B-3, pure: candidate `policy.agents` entries ONLY from the verified head's
|
|
201
|
+
* non-removed rows (active ∪ invited, design §5), keyed by the FULL row.uuaid
|
|
202
|
+
* — which is what makes B-4 hold. One key has a valid spelling in each of
|
|
203
|
+
* foundation, pillar and agentnet, all passing KEY_BINDING; a sender re-spelling
|
|
204
|
+
* a member's key in another of them is refused NOT_ADMITTED, because a policy
|
|
205
|
+
* built here holds only each row's EXACT spelling. A spelling outside the set
|
|
206
|
+
* never reaches policy: trust.authorize refuses it AGENT_ONLY. Writes no
|
|
207
|
+
* policy: the policy writer is the only writer and consumes this helper.
|
|
208
|
+
*/
|
|
209
|
+
export function agentRecordsFromVerifiedHead(rec) {
|
|
210
|
+
const head = verifiedHead(rec);
|
|
211
|
+
const agents = {};
|
|
212
|
+
for (const row of head.members) {
|
|
213
|
+
if (row.status === "removed") continue;
|
|
214
|
+
agents[row.uuaid] = agentRecordForRow(row); // full-uuaid key; throws, never skips
|
|
215
|
+
}
|
|
216
|
+
return agents;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── V-7 (unit level): no non-ASCII object keys, any depth ────────────────
|
|
220
|
+
|
|
221
|
+
function isAsciiKey(k) {
|
|
222
|
+
for (const ch of k) if (ch.codePointAt(0) > 0x7f) return false;
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* V-7's guard, unit-tested here; its wiring into send and receive is
|
|
228
|
+
* slices 8/9. Refuses any non-ASCII object key at any depth — commons
|
|
229
|
+
* objects, charter objects and space profile documents including the
|
|
230
|
+
* lexicon. Pillar 2.0.2's jcs sorted by UTF-16LE bytes and disagreed with
|
|
231
|
+
* pillar-client's JCS on such keys (F16); the vendored 2.2.0 jcs sorts by code
|
|
232
|
+
* unit as RFC 8785 requires, but peers on older code still disagree, so no
|
|
233
|
+
* engine-vs-client hash may be computed over such keys (C13). Array indices are
|
|
234
|
+
* keys of an array object and are ASCII by construction. Contract: parsed-
|
|
235
|
+
* JSON or freshly built objects; a cyclic input is out of contract.
|
|
236
|
+
*/
|
|
237
|
+
export function assertAsciiKeys(obj, path = "$") {
|
|
238
|
+
if (obj === null || typeof obj !== "object") return;
|
|
239
|
+
for (const k of Object.keys(obj)) {
|
|
240
|
+
if (!isAsciiKey(k)) {
|
|
241
|
+
throw new ManifestError("NonAsciiKeyError", `${path}: non-ASCII object key ${JSON.stringify(k)}`);
|
|
242
|
+
}
|
|
243
|
+
assertAsciiKeys(obj[k], `${path}.${k}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONLY module under src/spaces/ that imports the vendored pillar groups-v2
|
|
3
|
+
* engine (C9). Later slices add names HERE; they never open a second import
|
|
4
|
+
* site. Re-exported by name, limited to what the space-store slice uses.
|
|
5
|
+
*/
|
|
6
|
+
export {
|
|
7
|
+
RosterStore, isGroupIdV2, STORE_V,
|
|
8
|
+
// the manifest verifier wrapper and admin signer, and the vendored
|
|
9
|
+
// strict-key rule that manifest.mjs's keyBindsRow pairs with AC's rule-1
|
|
10
|
+
// binding (the engine's own uuaidForKey/keyBindsUuaid are not used here):
|
|
11
|
+
adminChange, adminOf, isStrictPublicKey, manifestHash, verifyChain,
|
|
12
|
+
} from "../vendor/pillar/groups-v2.mjs";
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/** Synchronous space policy and operator stops. Queue ownership and sends belong to the adapter. */
|
|
2
|
+
import { isDeepStrictEqual } from "node:util";
|
|
3
|
+
import { manifestHash } from "./pillar-group.mjs";
|
|
4
|
+
import { agentRecordForRow, agentRecordsFromVerifiedHead, keyBindsRow, verifiedHead, selfCheck, signAndApplyNext } from "./manifest.mjs";
|
|
5
|
+
import { prunePendingRemovals, validateAdapterState } from "./store.mjs";
|
|
6
|
+
import { RegistryTrust } from "../trust.mjs";
|
|
7
|
+
|
|
8
|
+
const POLICY_FIELDS=["mode","channelsEnabled","holdBeforeAdmission","agents","tenantProfiles","channels"];
|
|
9
|
+
const emergencyEngines=new WeakSet(); // replacement invalidates the cached shared identity until a fresh engine is constructed
|
|
10
|
+
const quarantinedEngines=new WeakSet();
|
|
11
|
+
const isCanonicalTrust=RegistryTrust.isCanonicalTrust;
|
|
12
|
+
const denyTrust=RegistryTrust.prototype.denyPermanently;
|
|
13
|
+
const reflectApply=Reflect.apply;
|
|
14
|
+
function plainPolicy(value){return value!==null&&typeof value==="object"&&[Object.prototype,null].includes(Object.getPrototypeOf(value))}
|
|
15
|
+
function latched(state){return state.hardStop!==null}
|
|
16
|
+
export class PolicyError extends Error {
|
|
17
|
+
constructor(name,message){super(message);this.name=name}
|
|
18
|
+
}
|
|
19
|
+
export function assertPolicyIdentity(engine){
|
|
20
|
+
if(quarantinedEngines.has(engine)||!isCanonicalTrust(engine?.trust))throw new PolicyError("PolicyIdentityError","A fresh canonical RegistryTrust receiver is required");
|
|
21
|
+
const policy=engine?.policy,prototype=policy&&Object.getPrototypeOf(policy);
|
|
22
|
+
if(policy===null||typeof policy!=="object"||(prototype!==Object.prototype&&prototype!==null)||Object.isFrozen(policy)||!Object.isExtensible(policy)||policy!==engine?.trust?.policy){
|
|
23
|
+
throw new PolicyError("PolicyIdentityError","Engine and trust must share one mutable plain policy object");
|
|
24
|
+
}
|
|
25
|
+
return policy;
|
|
26
|
+
}
|
|
27
|
+
function channels(groupId){return {content:`channel://agentnet/group/${groupId}`,control:`channel://agentnet/group-ctl/${groupId}`}}
|
|
28
|
+
function closed(groupId){
|
|
29
|
+
const uris=channels(groupId);
|
|
30
|
+
return {mode:"local",channelsEnabled:true,holdBeforeAdmission:true,agents:{},tenantProfiles:{},channels:{[uris.content]:{members:[]},[uris.control]:{members:[]}}};
|
|
31
|
+
}
|
|
32
|
+
function outsideAllowlist(rows,allowlist){
|
|
33
|
+
return rows.find(row=>!allowlist.some(allowed=>allowed.uuaid===row.uuaid&&allowed.key===row.key));
|
|
34
|
+
}
|
|
35
|
+
function outsider(rec,state,allowlist){
|
|
36
|
+
const head=verifiedHead(rec),rows=[...head.members.filter(row=>row.status!=="removed"),...state.pendingRemovals];
|
|
37
|
+
return outsideAllowlist(rows,allowlist);
|
|
38
|
+
}
|
|
39
|
+
function closePolicies(policy,groupId,engine){
|
|
40
|
+
let failure;
|
|
41
|
+
const references=[engine,engine?.trust].filter(Boolean);
|
|
42
|
+
for(const candidate of new Set([policy,engine?.policy,engine?.trust?.policy])){
|
|
43
|
+
try{
|
|
44
|
+
if(!plainPolicy(candidate)||!Object.isExtensible(candidate))throw new TypeError("Authority policy cannot be closed in place");
|
|
45
|
+
Object.assign(candidate,closed(groupId));
|
|
46
|
+
}catch(error){
|
|
47
|
+
failure??=error;
|
|
48
|
+
for(const owner of references){
|
|
49
|
+
if(owner.policy!==candidate)continue;
|
|
50
|
+
try{
|
|
51
|
+
const replacement=closed(groupId);owner.policy=replacement;
|
|
52
|
+
if(owner.policy!==replacement)throw new TypeError("Authority policy replacement did not take effect");
|
|
53
|
+
emergencyEngines.add(engine);
|
|
54
|
+
}catch(fallbackError){failure??=fallbackError}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
let allClosed=false;
|
|
59
|
+
try{allClosed=(engine?[engine.policy,engine.trust?.policy]:[policy]).every(value=>plainPolicy(value)&&POLICY_FIELDS.every(field=>isDeepStrictEqual(value[field],closed(groupId)[field])))}catch(error){failure??=error}
|
|
60
|
+
if(!allClosed&&engine){
|
|
61
|
+
if(!isCanonicalTrust(engine.trust))throw new PolicyError("PolicyIdentityError","Cannot deny an unsupported trust receiver");
|
|
62
|
+
reflectApply(denyTrust,engine.trust,[]);
|
|
63
|
+
quarantinedEngines.add(engine);
|
|
64
|
+
}
|
|
65
|
+
return {closed:allClosed,error:failure??(allClosed?null:new PolicyError("PolicyCloseError","An authority consumer remains open"))};
|
|
66
|
+
}
|
|
67
|
+
function requireSharedPolicy(policy,engine){
|
|
68
|
+
if(assertPolicyIdentity(engine)!==policy)throw new PolicyError("PolicyIdentityError","Restart with a fresh engine to recover the shared policy identity");
|
|
69
|
+
}
|
|
70
|
+
function expectedPolicy(rec,self,state,ext,now){
|
|
71
|
+
if(latched(state))return closed(state.groupId);
|
|
72
|
+
const head=verifiedHead(rec),rows=head.members.filter(row=>row.status!=="removed");
|
|
73
|
+
const pending=prunePendingRemovals(state.pendingRemovals,rec,now).kept;
|
|
74
|
+
const agents=agentRecordsFromVerifiedHead(rec);
|
|
75
|
+
for(const entry of pending)agents[entry.uuaid]=agentRecordForRow(entry);
|
|
76
|
+
const tenantProfiles={};
|
|
77
|
+
if(ext.tenant!=null){for(const agent of Object.values(agents))agent.tenantId=ext.tenant;tenantProfiles[ext.profile.id]=ext.tenant}
|
|
78
|
+
const selfId=self?._identity?.uuaid??self?.uuaid??self,me=head.members.find(row=>row.uuaid===selfId);
|
|
79
|
+
const stopped=!!rec.frozen||state.softStop!==null;
|
|
80
|
+
const content=stopped?[]:head.members.filter(row=>row.status==="active"&&!rec.leaving.includes(row.uuaid)).map(row=>row.uuaid);
|
|
81
|
+
const control=[...new Set((rec.frozen?rows:[...rows,...pending]).map(row=>row.uuaid))];
|
|
82
|
+
// P-6: a self-removed instance has no content or control channel authority.
|
|
83
|
+
if(!me||me.status==="removed"){content.length=0;control.length=0}
|
|
84
|
+
const uris=channels(state.groupId);
|
|
85
|
+
return {mode:"local",channelsEnabled:true,holdBeforeAdmission:true,agents,tenantProfiles,channels:{[uris.content]:{members:content},[uris.control]:{members:control}}};
|
|
86
|
+
}
|
|
87
|
+
function stop(policy,state,reason,{engine,store,onSignal=()=>{},now=Date.now()}={}){
|
|
88
|
+
if(state.hardStop===null)state.hardStop={reason,at:now};
|
|
89
|
+
let persisted=false,failure;
|
|
90
|
+
try{store.saveAdapter(state.groupId,state);persisted=true}catch(error){failure=error}
|
|
91
|
+
const outcome=closePolicies(policy,state.groupId,engine);failure??=outcome.error;
|
|
92
|
+
try{onSignal({code:"SPACE_CLOSED",groupId:state.groupId,...state.hardStop,persisted,closed:outcome.closed})}catch(error){failure??=error}
|
|
93
|
+
if(failure)throw failure;
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
/** P-9 reads the verified files again; a policy file is never an input. */
|
|
97
|
+
export function checkPolicy(policy,rec,self,state,ext,allowlist,options){
|
|
98
|
+
// rec, ext and allowlist remain for caller compatibility; the check reads the persisted inputs again.
|
|
99
|
+
const {engine,store,now=Date.now()}=options;
|
|
100
|
+
if(!isCanonicalTrust(engine?.trust))throw new PolicyError("PolicyIdentityError","A canonical RegistryTrust receiver is required");
|
|
101
|
+
if(quarantinedEngines.has(engine))requireSharedPolicy(policy,engine);
|
|
102
|
+
const loaded=store.loadAdapter(state.groupId,{now});
|
|
103
|
+
const saved=store.loadRoster(state.groupId),savedState=loaded?.state;
|
|
104
|
+
if(!savedState)throw new PolicyError("AdapterAbsentError","No persisted adapter state for policy check");
|
|
105
|
+
const expected=latched(savedState)?closed(state.groupId):expectedPolicy(saved,self,savedState,verifiedHead(saved).ext.commons,now);
|
|
106
|
+
const bound=Object.entries(policy.agents??{}).every(([uuaid,agent])=>keyBindsRow(uuaid,agent?.publicKey)&&agent?.kind==="agent"&&isDeepStrictEqual(agent?.capabilities,["commons:message"]));
|
|
107
|
+
if(policy===engine.policy&&policy===engine.trust.policy&&bound&&POLICY_FIELDS.every(field=>isDeepStrictEqual(policy[field],expected[field])))return true;
|
|
108
|
+
return stop(policy,state,"policy-mismatch",options);
|
|
109
|
+
}
|
|
110
|
+
/** One run-to-completion write. The options supply the actual engine, store and operator signal. */
|
|
111
|
+
export function writePolicy(policy,rec,self,state,ext,allowlist,options){
|
|
112
|
+
try{
|
|
113
|
+
if(!isCanonicalTrust(options.engine?.trust))throw new PolicyError("PolicyIdentityError","A canonical RegistryTrust receiver is required");
|
|
114
|
+
if(quarantinedEngines.has(options.engine))requireSharedPolicy(policy,options.engine);
|
|
115
|
+
if(latched(state)){Object.assign(policy,closed(state.groupId));checkPolicy(policy,rec,self,state,ext,allowlist,options);return policy}
|
|
116
|
+
if(emergencyEngines.has(options.engine))requireSharedPolicy(policy,options.engine);
|
|
117
|
+
if(outsider(rec,state,allowlist)){stop(policy,state,"outsider",options);checkPolicy(policy,rec,self,state,ext,allowlist,options);return policy}
|
|
118
|
+
const expected=expectedPolicy(rec,self,state,ext,options.now??Date.now());
|
|
119
|
+
Object.assign(policy,expected);
|
|
120
|
+
checkPolicy(policy,rec,self,state,ext,allowlist,options);
|
|
121
|
+
return policy;
|
|
122
|
+
}catch(error){
|
|
123
|
+
closePolicies(policy,state.groupId,options.engine);
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Owns only policy/state operations; the future adapter invokes these on its serial queue. */
|
|
129
|
+
export class SpacePolicy {
|
|
130
|
+
constructor({engine,store,groupId,allowlist,onSignal=()=>{},clock=Date.now}){
|
|
131
|
+
this.policy=assertPolicyIdentity(engine);
|
|
132
|
+
this.engine=engine;this.store=store;this.groupId=groupId;this.self=engine.keychain;
|
|
133
|
+
this.allowlist=allowlist;this.onSignal=onSignal;this.clock=clock;
|
|
134
|
+
if(!Array.isArray(allowlist))throw new PolicyError("AllowlistError","An explicit operator allowlist is required");
|
|
135
|
+
Object.assign(this.policy,closed(this.groupId));
|
|
136
|
+
this.rebuild();
|
|
137
|
+
}
|
|
138
|
+
_load(){
|
|
139
|
+
const now=this.clock(),loaded=this.store.loadAdapter(this.groupId,{now});
|
|
140
|
+
if(!loaded)throw new PolicyError("AdapterAbsentError","No adapter state; installation is a separate operation");
|
|
141
|
+
return {rec:this.store.loadRoster(this.groupId),state:loaded.state,now};
|
|
142
|
+
}
|
|
143
|
+
_options(now){return {engine:this.engine,store:this.store,onSignal:this.onSignal,now}}
|
|
144
|
+
rebuild(){
|
|
145
|
+
try{
|
|
146
|
+
if(!isCanonicalTrust(this.engine.trust))throw new PolicyError("PolicyIdentityError","A canonical RegistryTrust receiver is required");
|
|
147
|
+
if(quarantinedEngines.has(this.engine))requireSharedPolicy(this.policy,this.engine);
|
|
148
|
+
const {rec,state,now}=this._load();
|
|
149
|
+
if(latched(state))writePolicy(this.policy,rec,this.self,state,null,this.allowlist,this._options(now));
|
|
150
|
+
if(!rec){if(latched(state))return this.policy;throw new PolicyError("RosterAbsentError","No verified roster for policy rebuild")}
|
|
151
|
+
const head=verifiedHead(rec);selfCheck(this.self,rec);
|
|
152
|
+
if(!latched(state)&&emergencyEngines.has(this.engine))requireSharedPolicy(this.policy,this.engine);
|
|
153
|
+
// Inspect every verified change since consent, including changes reverted while this instance was down.
|
|
154
|
+
const after=rec.chain.filter(manifest=>manifest.version>state.consent.manifestVersion);
|
|
155
|
+
let previous=rec.chain.find(manifest=>manifest.version===state.consent.manifestVersion)?.ext.commons;
|
|
156
|
+
if(!previous)throw new PolicyError("ConsentHeadError","Consent must name a verified manifest version");
|
|
157
|
+
let changed=false,reconsentSignal=false;
|
|
158
|
+
for(const manifest of after){
|
|
159
|
+
const current=manifest.ext.commons;
|
|
160
|
+
if(!isDeepStrictEqual(previous.profile,current.profile)||previous.tenant!==current.tenant){
|
|
161
|
+
if(state.softStop===null){state.softStop={reason:"reconsent",at:now};changed=true;reconsentSignal=true}
|
|
162
|
+
}
|
|
163
|
+
previous=current;
|
|
164
|
+
}
|
|
165
|
+
const scanned=rec.chain.find(manifest=>manifest.version===state.outsiderScan.version);
|
|
166
|
+
const scanVersion=scanned&&manifestHash(scanned)===state.outsiderScan.hash?scanned.version:0;
|
|
167
|
+
const unseen=rec.chain.filter(manifest=>manifest.version>scanVersion);
|
|
168
|
+
const hit=unseen.some(manifest=>outsideAllowlist(manifest.members.filter(row=>row.status!=="removed"),this.allowlist));
|
|
169
|
+
const from=state.outsiderScan,to={version:head.version,hash:manifestHash(head)};
|
|
170
|
+
if(!isDeepStrictEqual(from,to)){state.outsiderScan=to;changed=true}
|
|
171
|
+
if(hit&&!latched(state))stop(this.policy,state,"outsider",this._options(now));
|
|
172
|
+
else{
|
|
173
|
+
if(changed)this.store.saveAdapter(this.groupId,state);
|
|
174
|
+
if(hit)this.onSignal({code:"SPACE_OUTSIDER_SEEN",groupId:this.groupId,from,to});
|
|
175
|
+
}
|
|
176
|
+
if(reconsentSignal)this.onSignal({code:"SPACE_RECONSENT_REQUIRED",groupId:this.groupId,at:now});
|
|
177
|
+
return writePolicy(this.policy,rec,this.self,state,head.ext.commons,this.allowlist,this._options(now));
|
|
178
|
+
}catch(error){
|
|
179
|
+
try{closePolicies(this.policy,this.groupId,this.engine)}catch{} // retain the original read/write failure
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/** Call after rebuild(), including after pending TTL expiry; the adapter owns the expiry timer. */
|
|
184
|
+
check(){
|
|
185
|
+
try{
|
|
186
|
+
const {rec,state,now}=this._load();
|
|
187
|
+
return checkPolicy(this.policy,rec,this.self,state,latched(state)?null:verifiedHead(rec).ext.commons,this.allowlist,this._options(now));
|
|
188
|
+
}catch(error){
|
|
189
|
+
try{closePolicies(this.policy,this.groupId,this.engine)}catch{}
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
hardResume(){
|
|
194
|
+
requireSharedPolicy(this.policy,this.engine);
|
|
195
|
+
const {rec,state,now}=this._load(),head=verifiedHead(rec);selfCheck(this.self,rec);
|
|
196
|
+
if(!isDeepStrictEqual(state.outsiderScan,{version:head.version,hash:manifestHash(head)}))throw new PolicyError("OutsiderScanBehindError","Run rebuild first: outsider scan is behind the verified head");
|
|
197
|
+
if(outsider(rec,state,this.allowlist))throw new PolicyError("OutsiderError","Hard resume requires an outsider-free verified head and pending set");
|
|
198
|
+
state.hardStop=null;this.store.saveAdapter(this.groupId,state);
|
|
199
|
+
return this.rebuild();
|
|
200
|
+
}
|
|
201
|
+
reconsent(consent){
|
|
202
|
+
if(!isCanonicalTrust(this.engine.trust)||emergencyEngines.has(this.engine)||quarantinedEngines.has(this.engine))requireSharedPolicy(this.policy,this.engine);
|
|
203
|
+
const {rec,state}=this._load(),head=verifiedHead(rec);
|
|
204
|
+
state.consent=structuredClone(consent);state.profile=structuredClone(head.ext.commons.profile);state.softStop=null;
|
|
205
|
+
validateAdapterState(state,this.groupId);
|
|
206
|
+
if(state.consent.manifestVersion!==head.version)throw new PolicyError("ConsentHeadError","Reconsent must name the current verified manifest");
|
|
207
|
+
this.store.saveAdapter(this.groupId,state);
|
|
208
|
+
return this.rebuild(); // E-3's checked profile installation remains the installer's responsibility.
|
|
209
|
+
}
|
|
210
|
+
/** P-7: reserved envelope id is mandatory for a remove/leave; roster FIRST, adapter SECOND. */
|
|
211
|
+
applyOwnChange(action,buildOpts={}, {outboxId}={}){
|
|
212
|
+
if(!isCanonicalTrust(this.engine.trust)||emergencyEngines.has(this.engine)||quarantinedEngines.has(this.engine))requireSharedPolicy(this.policy,this.engine);
|
|
213
|
+
const {rec,state,now}=this._load(),head=verifiedHead(rec);
|
|
214
|
+
let nextState=state;
|
|
215
|
+
if(action==="remove"||action==="leave"){
|
|
216
|
+
const row=head.members.find(member=>member.uuaid===buildOpts.target);
|
|
217
|
+
if(!row)throw new PolicyError("RemovalTargetError","A pending removal requires a row in the verified head");
|
|
218
|
+
const pending={uuaid:row.uuaid,key:row.key,manifestVersion:head.version+1,queuedAt:now,outboxId};
|
|
219
|
+
nextState={...state,pendingRemovals:[...state.pendingRemovals.filter(entry=>entry.uuaid!==row.uuaid),pending]};
|
|
220
|
+
validateAdapterState(nextState,this.groupId); // preflight before either file write
|
|
221
|
+
}
|
|
222
|
+
let result;
|
|
223
|
+
try{
|
|
224
|
+
result=signAndApplyNext(this.store,this.groupId,this.self,action,buildOpts);
|
|
225
|
+
if(nextState!==state)this.store.saveAdapter(this.groupId,nextState);
|
|
226
|
+
}finally{this.rebuild()}
|
|
227
|
+
return result;
|
|
228
|
+
}
|
|
229
|
+
}
|