@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,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Group threads — the `pillar-group` v1 app convention (INTERACTION-CONVENTION.md
|
|
3
|
+
* "group chats"), as the mobile app and chat-web already speak it.
|
|
4
|
+
*
|
|
5
|
+
* There is still no group key and no wire change: a group message is one
|
|
6
|
+
* ordinary 1:1 envelope per member, and the group rides INSIDE each sealed
|
|
7
|
+
* payload as `_group`. What this adds over the old `{ name, members }` tag is
|
|
8
|
+
* what makes it a thread rather than a fan-out:
|
|
9
|
+
*
|
|
10
|
+
* id stable `grp-<16 hex>`, so every member files it under one group
|
|
11
|
+
* msgId `gm-…`, identical across the fan-out copies of one message
|
|
12
|
+
* op message | create | rename | add | leave (roster changes travel
|
|
13
|
+
* as ordinary group messages)
|
|
14
|
+
* keys optional sealing hints, binding-checked on receipt, so members
|
|
15
|
+
* can reply to the whole group without a prior contact exchange
|
|
16
|
+
*
|
|
17
|
+
* Every member keeps its own roster (mailbox `groups` table) and applies the
|
|
18
|
+
* same rules: only a current member may rename or add, nobody can remove
|
|
19
|
+
* anyone but themselves, and a group a non-contact drops you into is a
|
|
20
|
+
* pending invitation until you accept it.
|
|
21
|
+
*/
|
|
22
|
+
import { randomBytes } from "node:crypto";
|
|
23
|
+
import { localIdFromKey } from "./identity/keychain.mjs";
|
|
24
|
+
|
|
25
|
+
export const GROUP_TAG_KEY = "_group";
|
|
26
|
+
export const GROUP_TAG_V = 1;
|
|
27
|
+
export const GROUP_OPS = Object.freeze(["message", "create", "rename", "add", "leave"]);
|
|
28
|
+
export const MAX_GROUP_MEMBERS = 256;
|
|
29
|
+
export const MAX_GROUP_NAME = 100;
|
|
30
|
+
|
|
31
|
+
const GROUP_ID_RE = /^grp-[a-z0-9][a-z0-9-]{3,63}$/i;
|
|
32
|
+
const UUAID_RE = /^uuaid:[a-z0-9-]+:[a-z0-9-]+:[a-z0-9][a-z0-9-]*$/i;
|
|
33
|
+
const PUBKEY_RE = /^[0-9a-f]{64}$/i;
|
|
34
|
+
|
|
35
|
+
export function isGroupId(id) {
|
|
36
|
+
return typeof id === "string" && GROUP_ID_RE.test(id);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function newGroupId() {
|
|
40
|
+
return `grp-${randomBytes(8).toString("hex")}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function newGroupMsgId() {
|
|
44
|
+
return `gm-${Date.now().toString(36)}-${randomBytes(6).toString("hex")}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The key really owns the uuaid (the same check seal() makes). */
|
|
48
|
+
export function keyBindsUuaid(uuaid, publicKeyHex) {
|
|
49
|
+
if (typeof uuaid !== "string" || typeof publicKeyHex !== "string" || !PUBKEY_RE.test(publicKeyHex)) return false;
|
|
50
|
+
try { return localIdFromKey(Buffer.from(publicKeyHex, "hex")) === uuaid.split(":")[3]; }
|
|
51
|
+
catch { return false; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function djb2(s) {
|
|
55
|
+
let h = 5381;
|
|
56
|
+
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0;
|
|
57
|
+
return h;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A local id for an id-less tag (an older pillar that sent only
|
|
62
|
+
* `{ name, members }`). Same derivation as the mobile app, so the same
|
|
63
|
+
* conversation groups the same way there and here. Never an authority.
|
|
64
|
+
*/
|
|
65
|
+
export function deriveGroupId(name, members) {
|
|
66
|
+
const canon = `${String(name).trim().toLowerCase()}|${[...members].sort().join(",")}`;
|
|
67
|
+
const a = djb2(canon).toString(16).padStart(8, "0");
|
|
68
|
+
const b = djb2(canon.split("").reverse().join("")).toString(16).padStart(8, "0");
|
|
69
|
+
return `grp-ref-${a}${b}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Deduplicated, validated member list, order kept. */
|
|
73
|
+
export function normalizeMembers(members) {
|
|
74
|
+
const out = [];
|
|
75
|
+
const seen = new Set();
|
|
76
|
+
for (const m of Array.isArray(members) ? members : []) {
|
|
77
|
+
if (typeof m !== "string" || !UUAID_RE.test(m) || seen.has(m)) continue;
|
|
78
|
+
seen.add(m);
|
|
79
|
+
out.push(m);
|
|
80
|
+
if (out.length >= MAX_GROUP_MEMBERS) break; // fan-out amplification guard
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Parse a `_group` tag out of a decrypted payload; null when absent or unusable. */
|
|
86
|
+
export function extractGroupTag(payload) {
|
|
87
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
|
|
88
|
+
const g = payload[GROUP_TAG_KEY];
|
|
89
|
+
if (!g || typeof g !== "object" || Array.isArray(g)) return null;
|
|
90
|
+
const name = typeof g.name === "string" ? g.name.trim().slice(0, MAX_GROUP_NAME) : "";
|
|
91
|
+
const members = normalizeMembers(g.members);
|
|
92
|
+
if (!members.length) return null;
|
|
93
|
+
const derivedId = !isGroupId(g.id);
|
|
94
|
+
const id = derivedId ? deriveGroupId(name || "group", members) : g.id;
|
|
95
|
+
let keys;
|
|
96
|
+
if (g.keys && typeof g.keys === "object" && !Array.isArray(g.keys)) {
|
|
97
|
+
for (const [u, pk] of Object.entries(g.keys)) {
|
|
98
|
+
if (UUAID_RE.test(u) && typeof pk === "string" && PUBKEY_RE.test(pk)) (keys ??= {})[u] = pk.toLowerCase();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const msgId = typeof g.msgId === "string" && g.msgId.length > 0 && g.msgId.length <= 64 ? g.msgId : null;
|
|
102
|
+
const op = typeof g.op === "string" && GROUP_OPS.includes(g.op) ? g.op : "message";
|
|
103
|
+
return { v: typeof g.v === "number" ? g.v : GROUP_TAG_V, id, name: name || "Group", members, keys, msgId, op, derivedId };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The `_group` tag for an outbound payload. */
|
|
107
|
+
export function buildGroupTag(group, { msgId, keys, op = "message" }) {
|
|
108
|
+
const tag = { v: GROUP_TAG_V, id: group.id, name: group.name, members: [...group.members], msgId };
|
|
109
|
+
if (keys && Object.keys(keys).length) tag.keys = keys;
|
|
110
|
+
if (op && op !== "message") tag.op = op;
|
|
111
|
+
return tag;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Fold an inbound tag into the local roster (mailbox `groups`). Returns the
|
|
116
|
+
* group as stored, or null when the tag changed nothing we keep.
|
|
117
|
+
*
|
|
118
|
+
* unknown group -> join it; pending when the sender is not a contact. A
|
|
119
|
+
* wire id (not derived here) needs the SENDER on its own
|
|
120
|
+
* roster: the first tag seen for an id sets the roster, so
|
|
121
|
+
* a stranger must not be able to seed it, and after that
|
|
122
|
+
* the real members would be the strangers
|
|
123
|
+
* known group -> only a current MEMBER may change it: `leave` drops the
|
|
124
|
+
* sender, `create`/`rename` set the name, every op unions
|
|
125
|
+
* new members in; a re-`add` naming us clears our `left`
|
|
126
|
+
* key hints -> kept per group, only when the key derives its uuaid;
|
|
127
|
+
* they let us SEAL to a member, and never make anyone a
|
|
128
|
+
* trusted sender (the address book and trust.json do that)
|
|
129
|
+
*/
|
|
130
|
+
export function absorbGroupTag(mailbox, tag, { sender, self, text = "", fromContact = false }) {
|
|
131
|
+
const hints = {};
|
|
132
|
+
for (const [u, pk] of Object.entries(tag.keys ?? {})) {
|
|
133
|
+
if (u !== self && keyBindsUuaid(u, pk)) hints[u] = pk;
|
|
134
|
+
}
|
|
135
|
+
const existing = mailbox.getGroup(tag.id);
|
|
136
|
+
if (!existing) {
|
|
137
|
+
if (!tag.derivedId && !tag.members.includes(sender)) return null;
|
|
138
|
+
const members = tag.members.includes(self) ? tag.members : [...tag.members, self];
|
|
139
|
+
return mailbox.putGroup({
|
|
140
|
+
id: tag.id, name: tag.name, members, keys: hints, createdBy: sender, pending: !fromContact,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
if (!existing.members.includes(sender)) return existing; // strangers cannot touch our roster
|
|
144
|
+
const keys = { ...hints, ...existing.keys }; // first key wins
|
|
145
|
+
if (tag.op === "leave") {
|
|
146
|
+
return mailbox.putGroup({ ...existing, keys, members: existing.members.filter((m) => m !== sender) });
|
|
147
|
+
}
|
|
148
|
+
const members = [...existing.members];
|
|
149
|
+
for (const m of tag.members) if (!members.includes(m) && members.length < MAX_GROUP_MEMBERS) members.push(m);
|
|
150
|
+
const addedUs = tag.op === "add" && String(text).split(",").map((x) => x.trim()).includes(self);
|
|
151
|
+
return mailbox.putGroup({
|
|
152
|
+
...existing,
|
|
153
|
+
keys,
|
|
154
|
+
members,
|
|
155
|
+
name: tag.op === "rename" || tag.op === "create" ? tag.name : existing.name,
|
|
156
|
+
left: existing.left && !addedUs,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Resolve a group by exact id, else by unique case-insensitive name.
|
|
162
|
+
* Throws with the candidates listed when the name is ambiguous or unknown.
|
|
163
|
+
*/
|
|
164
|
+
export function findGroup(mailbox, ref) {
|
|
165
|
+
const byId = mailbox.getGroup(ref);
|
|
166
|
+
if (byId) return byId;
|
|
167
|
+
const all = mailbox.listGroups();
|
|
168
|
+
const hits = all.filter((g) => g.name.toLowerCase() === String(ref).toLowerCase());
|
|
169
|
+
if (hits.length === 1) return hits[0];
|
|
170
|
+
const e = new Error(hits.length
|
|
171
|
+
? `group name "${ref}" is ambiguous: ${hits.map((g) => g.id).join(", ")} — use the id`
|
|
172
|
+
: `no group "${ref}" in this home${all.length ? ` (known: ${all.map((g) => `${g.name} ${g.id}`).join("; ")})` : ""} — list them with: pillar groups`);
|
|
173
|
+
e.code = hits.length ? "group-ambiguous" : "group-unknown";
|
|
174
|
+
throw e;
|
|
175
|
+
}
|
|
@@ -24,11 +24,10 @@ function _ser(v) {
|
|
|
24
24
|
if (typeof v === "bigint") return v.toString();
|
|
25
25
|
if (Array.isArray(v)) return "[" + v.map(_ser).join(",") + "]";
|
|
26
26
|
if (typeof v === "object") {
|
|
27
|
-
// UTF-16 code
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
});
|
|
27
|
+
// RFC 8785 §3.2.3: keys sort by their UTF-16 code units, which is exactly
|
|
28
|
+
// JavaScript's default string order. (Comparing UTF-16LE *bytes* is not: it
|
|
29
|
+
// puts "Ā" (U+0100) before "a", and disagreed with @uuaid/pillar-client.)
|
|
30
|
+
const keys = Object.keys(v).sort(); // @rule jcs.code-unit-order
|
|
32
31
|
return "{" + keys.map(k => JSON.stringify(k) + ":" + _ser(v[k])).join(",") + "}";
|
|
33
32
|
}
|
|
34
33
|
throw new Error("JCS: unsupported type " + typeof v);
|
|
@@ -65,6 +65,54 @@ export function localIdFromKey(rawPub) {
|
|
|
65
65
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/** Registry namespaces cannot be asserted by possession of a key alone. */
|
|
69
|
+
export const SELF_CERTIFYING_NAMESPACES = Object.freeze(["foundation", "pillar", "agentnet"]);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The namespace `pillar init` mints a NEW agent identity in when no --namespace is given.
|
|
73
|
+
* Expand before contract (owner order 2026-10-24; Uuaid-Codex review K1): 2.2.0 makes every
|
|
74
|
+
* verifier accept foundation, pillar and agentnet but still mints `foundation`, because a
|
|
75
|
+
* `pillar:` identity cannot read its inbox on a pre-2.2.0 carrier. 2.2.1 flips this to
|
|
76
|
+
* `pillar` once the carrier fleet, the main relay and the blob brokers run 2.2.x/1.2.0.
|
|
77
|
+
* Existing keychains keep the UUAID they were saved with. `Keychain.generate`'s own default
|
|
78
|
+
* stays `foundation` for library callers; CLI mint sites pass `mintNamespace(...)` instead.
|
|
79
|
+
*/
|
|
80
|
+
export const NEW_IDENTITY_NAMESPACE = "foundation";
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Carrier-infrastructure identities (pillar-seed, pillar-directory, pillar-escrow) stay in
|
|
84
|
+
* `foundation`: they are spoken to by third-party seeds and DHT peers on any version, and
|
|
85
|
+
* the owner's rule covers the identities people create, not the fleet's service keys
|
|
86
|
+
* (Uuaid-Codex review K2).
|
|
87
|
+
*/
|
|
88
|
+
export const SERVICE_IDENTITY_NAMESPACE = "foundation";
|
|
89
|
+
|
|
90
|
+
/** The namespace to mint in: `requested` if it is self-certifying, else NEW_IDENTITY_NAMESPACE. Throws on any other namespace. */
|
|
91
|
+
export function mintNamespace(requested) {
|
|
92
|
+
if (requested === undefined || requested === null) return NEW_IDENTITY_NAMESPACE;
|
|
93
|
+
if (typeof requested !== "string" || !SELF_CERTIFYING_NAMESPACES.includes(requested)) {
|
|
94
|
+
throw new Error(`namespace must be one of ${SELF_CERTIFYING_NAMESPACES.join(", ")} (a key alone cannot claim ${String(requested)})`);
|
|
95
|
+
}
|
|
96
|
+
return requested;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Rebuild the complete key-derived identity; minting still defaults to foundation. */
|
|
100
|
+
export function keyDerivedUuaidFor(publicKeyHex, { namespace = "foundation", objectType = "agent" } = {}) {
|
|
101
|
+
if (!SELF_CERTIFYING_NAMESPACES.includes(namespace)
|
|
102
|
+
|| typeof objectType !== "string" || !/^[a-z0-9-]+$/.test(objectType)
|
|
103
|
+
|| typeof publicKeyHex !== "string" || !/^[0-9a-f]{64}$/i.test(publicKeyHex)) return null;
|
|
104
|
+
return `uuaid:${namespace}:${objectType}:${localIdFromKey(Buffer.from(publicKeyHex, "hex"))}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Compare the full identity, optionally constraining a path's existing object type. */
|
|
108
|
+
export function keyDerivesUuaid(publicKeyHex, uuaid, objectType = null) {
|
|
109
|
+
if (typeof uuaid !== "string") return false;
|
|
110
|
+
const parts = uuaid.split(":");
|
|
111
|
+
if (parts.length !== 4 || parts[0] !== "uuaid" || (objectType !== null && parts[2] !== objectType)) return false;
|
|
112
|
+
const expected = keyDerivedUuaidFor(publicKeyHex, { namespace: parts[1], objectType: parts[2] });
|
|
113
|
+
return expected !== null && uuaid === expected;
|
|
114
|
+
}
|
|
115
|
+
|
|
68
116
|
export class Keychain {
|
|
69
117
|
constructor(path) {
|
|
70
118
|
this.path = path;
|
|
@@ -78,8 +126,9 @@ export class Keychain {
|
|
|
78
126
|
/**
|
|
79
127
|
* Generate a fresh identity. Never overwrites.
|
|
80
128
|
*
|
|
81
|
-
* uuaidNamespace: default "foundation"
|
|
82
|
-
*
|
|
129
|
+
* uuaidNamespace: default "foundation" (the genesis namespace) for library
|
|
130
|
+
* callers. The Pillar CLIs pass mintNamespace(...) explicitly, so a new CLI
|
|
131
|
+
* identity is `pillar` unless the operator asks for agentnet or foundation.
|
|
83
132
|
* objectType: default "agent"; overridable to "service" etc.
|
|
84
133
|
* localId: default random UUIDv4-like; caller may supply.
|
|
85
134
|
*/
|
|
@@ -20,6 +20,21 @@ function byTier(tiers) {
|
|
|
20
20
|
return (a, b) => seedTier(tiers[a] ?? "probation").listRank - seedTier(tiers[b] ?? "probation").listRank;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* The carrier's signed acceptance receipt (uuaid.carrier-receipt.v1), kept as
|
|
25
|
+
* the carrier sent it, plus this client's own clock when the answer arrived
|
|
26
|
+
* (design §12 rule 3: record received-at beside the carrier's acceptedAt,
|
|
27
|
+
* which is only the carrier's claim). NOT verified here: verify against the
|
|
28
|
+
* pinned key for this carrier and the exact envelope posted. {} when the
|
|
29
|
+
* carrier sent no receipt (an older or keyless carrier): that never fails a
|
|
30
|
+
* delivery, the outcome is simply unreceipted.
|
|
31
|
+
*/
|
|
32
|
+
function keptReceipt(body) {
|
|
33
|
+
const r = body?.receipt;
|
|
34
|
+
if (!r || typeof r !== "object" || Array.isArray(r) || typeof body.receiptSig !== "string") return {};
|
|
35
|
+
return { receipt: r, receiptSig: body.receiptSig, receivedAt: new Date().toISOString() }; // @rule client.keeps-receipt
|
|
36
|
+
}
|
|
37
|
+
|
|
23
38
|
export class CarrierClient {
|
|
24
39
|
/**
|
|
25
40
|
* @param {{keychain: import('../identity/keychain.mjs').Keychain, carriers: string[]}} opts
|
|
@@ -66,8 +81,13 @@ export class CarrierClient {
|
|
|
66
81
|
* Resolves once every carrier has answered. Succeeds if at least one
|
|
67
82
|
* accepted; throws if every carrier rejects. Returns the receipt from the
|
|
68
83
|
* most PROVEN accepting carrier as the primary ({ carrier, seq, sha,
|
|
69
|
-
* duplicate }) plus `all` with every
|
|
70
|
-
* seq?, sha?, duplicate?,
|
|
84
|
+
* duplicate, receipt?, receiptSig?, receivedAt? }) plus `all` with every
|
|
85
|
+
* carrier's outcome ({ carrier, ok, seq?, sha?, duplicate?, receipt?,
|
|
86
|
+
* receiptSig?, receivedAt?, error?, status?, reason?, fix? }).
|
|
87
|
+
*
|
|
88
|
+
* `receipt` / `receiptSig` are present only when that carrier signed one
|
|
89
|
+
* (uuaid.carrier-receipt.v1); they are unverified carrier output, and
|
|
90
|
+
* `receivedAt` is this client's clock, not the carrier's.
|
|
71
91
|
*
|
|
72
92
|
* When every carrier refuses, the thrown error carries `code` (the most
|
|
73
93
|
* proven carrier's `reason`, else carrier-unreachable / carrier-bad-response),
|
|
@@ -105,7 +125,7 @@ export class CarrierClient {
|
|
|
105
125
|
let body = {};
|
|
106
126
|
try { body = raw ? JSON.parse(raw) : {}; } catch (_error) {}
|
|
107
127
|
if (res.status === 202 && body.accepted) {
|
|
108
|
-
return { carrier: base, ok: true, seq: body.seq, sha: body.sha, duplicate: !!body.duplicate };
|
|
128
|
+
return { carrier: base, ok: true, seq: body.seq, sha: body.sha, duplicate: !!body.duplicate, ...keptReceipt(body) };
|
|
109
129
|
}
|
|
110
130
|
const reason = typeof body.reason === "string" && body.reason ? body.reason
|
|
111
131
|
: typeof body.error === "string" && body.error ? body.error : null;
|
|
@@ -132,15 +152,15 @@ export class CarrierClient {
|
|
|
132
152
|
throw e;
|
|
133
153
|
}
|
|
134
154
|
const ranked = accepted.slice().sort((a, b) => byTier(this.carrierTiers)(a.carrier, b.carrier));
|
|
135
|
-
const { carrier, seq, sha, duplicate } = ranked[0];
|
|
136
|
-
return { carrier, seq, sha, duplicate, all };
|
|
155
|
+
const { carrier, seq, sha, duplicate, receipt, receiptSig, receivedAt } = ranked[0];
|
|
156
|
+
return { carrier, seq, sha, duplicate, ...(receipt ? { receipt, receiptSig, receivedAt } : {}), all };
|
|
137
157
|
}
|
|
138
158
|
|
|
139
159
|
/**
|
|
140
160
|
* Fetch envelopes addressed to us with seq > since from a specific carrier.
|
|
141
161
|
* Signed request; carrier verifies our key before serving.
|
|
142
162
|
*/
|
|
143
|
-
async fetchInbox(base, { since = 0, waitS = 0, timeoutMs = 40000 } = {}) {
|
|
163
|
+
async fetchInbox(base, { since = 0, waitS = 0, timeoutMs = 40000, signal = null } = {}) {
|
|
144
164
|
const path = `GET /v1/inbox/${this.uuaid}?since=${since}`;
|
|
145
165
|
const ts = String(Date.now());
|
|
146
166
|
const sig = this.keychain.sign(Buffer.from(`${path}\n${ts}`, "utf-8")).toString("hex");
|
|
@@ -150,7 +170,9 @@ export class CarrierClient {
|
|
|
150
170
|
"x-pillar-ts": ts,
|
|
151
171
|
"x-pillar-sig": sig,
|
|
152
172
|
},
|
|
153
|
-
signal
|
|
173
|
+
// `signal` lets a caller cancel a long-poll early (peekCarrierInbox stops
|
|
174
|
+
// the other carriers once one has mail).
|
|
175
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs),
|
|
154
176
|
});
|
|
155
177
|
if (res.status !== 200) {
|
|
156
178
|
const body = await res.json().catch(() => ({}));
|
|
@@ -34,6 +34,7 @@ export const REFUSAL_FIXES = Object.freeze({
|
|
|
34
34
|
"bad-signature": "sign again with this identity's own key over the exact bytes; the signature does not verify",
|
|
35
35
|
"weak-publicKey": "use a real Ed25519 identity key; this key is a small-order or non-canonical point that can sign anything, so no signature under it counts",
|
|
36
36
|
"delegation-invalid": "mint a fresh delegation from the principal, then resend",
|
|
37
|
+
"envelope-id-conflict": "seal a new envelope with a fresh id; this carrier already holds a different envelope under this id",
|
|
37
38
|
// Carrier, envelope shape and crypto (envelope.mjs open()): one fix for all
|
|
38
39
|
"not-object": RESEAL,
|
|
39
40
|
"bad-version": RESEAL,
|
|
@@ -69,8 +70,9 @@ export const REFUSAL_FIXES = Object.freeze({
|
|
|
69
70
|
"carrier-unreachable": "check the carrier URLs and your network, then retry",
|
|
70
71
|
"carrier-bad-response": "retry, or use another carrier; the carrier or its proxy answered without a reason",
|
|
71
72
|
"over-tier-limit": "send the payload as a blob (pillar-blob send, or Pillar.sendBlob)",
|
|
72
|
-
"blob-key-unproven": "use a
|
|
73
|
-
"sender-key-not-active": "ask the sender to send from a
|
|
73
|
+
"blob-key-unproven": "use a self-certifying agent UUAID derived from this key (pillar init), or get a registry badge that names this key",
|
|
74
|
+
"sender-key-not-active": "ask the sender to send from a self-certifying agent UUAID derived from its own key, or to get a registry badge naming its key",
|
|
75
|
+
"group-v2-use-v2-send": "send to this pillar-group/2 group with pillar group send <group>; v1 group sends (send-group, sendGroup) do not reach it",
|
|
74
76
|
});
|
|
75
77
|
|
|
76
78
|
/**
|
package/test/channels.test.mjs
DELETED
|
@@ -1,268 +0,0 @@
|
|
|
1
|
-
import test from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
import {
|
|
4
|
-
classifyThread,
|
|
5
|
-
assertChannelSendAdmission,
|
|
6
|
-
assertChannelReceiveAdmission,
|
|
7
|
-
assertForwardingPermission,
|
|
8
|
-
validateTenantBinding,
|
|
9
|
-
CANONICAL_CHANNEL_REGEX,
|
|
10
|
-
RESERVED_CHANNEL_PREFIX
|
|
11
|
-
} from "../src/channels.mjs";
|
|
12
|
-
|
|
13
|
-
test("Channel classifier: C2 Input / Mode Decision Table (two-pole)", () => {
|
|
14
|
-
const canonicalUris = [
|
|
15
|
-
"channel://commons/dm/agent-session-123",
|
|
16
|
-
"channel://zilligon/community/welcome",
|
|
17
|
-
"channel://local/topic/node-events",
|
|
18
|
-
"channel://commons/session/delib-456"
|
|
19
|
-
];
|
|
20
|
-
|
|
21
|
-
// 1. Canonical structured channels succeed across all policy modes
|
|
22
|
-
for (const uri of canonicalUris) {
|
|
23
|
-
for (const channelsEnabled of [false, true, undefined]) {
|
|
24
|
-
const result = classifyThread(uri, { channelsEnabled });
|
|
25
|
-
assert.equal(result.ok, true, `Expected ${uri} to succeed with channelsEnabled=${channelsEnabled}`);
|
|
26
|
-
assert.equal(result.classification, "structured");
|
|
27
|
-
assert.equal(result.channelUri, uri);
|
|
28
|
-
assert.ok(result.parsed.provider);
|
|
29
|
-
assert.ok(result.parsed.channelType);
|
|
30
|
-
assert.ok(result.parsed.channelId);
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// 2. Non-canonical channel-like spellings fail closed with INVALID_CHANNEL_URI
|
|
35
|
-
const nonCanonicalUris = [
|
|
36
|
-
"CHANNEL://commons/dm/agent-123", // Uppercase scheme (must NOT fall back to legacy bypass)
|
|
37
|
-
"channel:/commons/dm/123", // Missing slash
|
|
38
|
-
"channel://commons", // Missing segments
|
|
39
|
-
"channel://commons/dm", // Only two segments
|
|
40
|
-
"channel://comm@ns/dm/123", // Disallowed characters
|
|
41
|
-
"Channel://local/topic/test", // Mixed case scheme
|
|
42
|
-
"CHANNEL://LOCAL/TOPIC/TEST" // All uppercase
|
|
43
|
-
];
|
|
44
|
-
|
|
45
|
-
for (const badUri of nonCanonicalUris) {
|
|
46
|
-
for (const channelsEnabled of [false, true, undefined]) {
|
|
47
|
-
const result = classifyThread(badUri, { channelsEnabled });
|
|
48
|
-
// Control pole: Must fail closed with INVALID_CHANNEL_URI
|
|
49
|
-
assert.equal(
|
|
50
|
-
result.ok,
|
|
51
|
-
false,
|
|
52
|
-
`Expected non-canonical ${badUri} to fail with channelsEnabled=${channelsEnabled}`
|
|
53
|
-
);
|
|
54
|
-
assert.equal(result.classification, "reserved-malformed");
|
|
55
|
-
assert.equal(result.error, "INVALID_CHANNEL_URI");
|
|
56
|
-
|
|
57
|
-
// Mutant pole check: verify that a naive startsWith("channel://") would have incorrectly bypassed
|
|
58
|
-
const naivePrefixMatches = badUri.startsWith("channel://");
|
|
59
|
-
const reservedPrefixMatches = RESERVED_CHANNEL_PREFIX.test(badUri);
|
|
60
|
-
assert.equal(
|
|
61
|
-
reservedPrefixMatches,
|
|
62
|
-
true,
|
|
63
|
-
`Reserved regex must catch channel-like spelling ${badUri}`
|
|
64
|
-
);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// 3. Ordinary legacy strings under default / disabled mode: ALLOWED
|
|
69
|
-
const legacyStrings = ["commons", "app-topic", "default-thread", "session-789"];
|
|
70
|
-
for (const legacy of legacyStrings) {
|
|
71
|
-
for (const channelsEnabled of [false, undefined]) {
|
|
72
|
-
const result = classifyThread(legacy, { channelsEnabled });
|
|
73
|
-
assert.equal(result.ok, true);
|
|
74
|
-
assert.equal(result.classification, "legacy-allowed");
|
|
75
|
-
assert.equal(result.thread, legacy);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// 4. Ordinary legacy strings under channelsEnabled === true: DENIED
|
|
80
|
-
for (const legacy of legacyStrings) {
|
|
81
|
-
const result = classifyThread(legacy, { channelsEnabled: true });
|
|
82
|
-
// Control pole: Denied with STRUCTURED_CHANNELS_REQUIRED
|
|
83
|
-
assert.equal(result.ok, false);
|
|
84
|
-
assert.equal(result.classification, "legacy-denied");
|
|
85
|
-
assert.equal(result.error, "STRUCTURED_CHANNELS_REQUIRED");
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// 5. Invalid thread inputs
|
|
89
|
-
assert.equal(classifyThread("").error, "INVALID_THREAD_LENGTH");
|
|
90
|
-
assert.equal(classifyThread("a".repeat(121)).error, "INVALID_THREAD_LENGTH");
|
|
91
|
-
assert.equal(classifyThread(null).error, "INVALID_THREAD_LENGTH");
|
|
92
|
-
assert.equal(classifyThread(undefined).error, "INVALID_THREAD_LENGTH");
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
test("Bidirectional channel admission: outbound send assertions (two-pole)", () => {
|
|
96
|
-
const channelUri = "channel://commons/dm/thread-1";
|
|
97
|
-
const policy = {
|
|
98
|
-
channels: {
|
|
99
|
-
[channelUri]: {
|
|
100
|
-
visibility: "private",
|
|
101
|
-
members: ["agent-alice", "agent-bob"]
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
// Control pole: sender and all recipients admitted
|
|
107
|
-
assert.doesNotThrow(() => {
|
|
108
|
-
assertChannelSendAdmission(channelUri, "agent-alice", ["agent-bob"], policy);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
// Mutant pole 1: unauthorized sender fails closed
|
|
112
|
-
assert.throws(
|
|
113
|
-
() => assertChannelSendAdmission(channelUri, "agent-eve", ["agent-bob"], policy),
|
|
114
|
-
(err) => err.code === "UNAUTHORIZED_CHANNEL_SENDER"
|
|
115
|
-
);
|
|
116
|
-
|
|
117
|
-
// Mutant pole 2: unauthorized recipient fails closed
|
|
118
|
-
assert.throws(
|
|
119
|
-
() => assertChannelSendAdmission(channelUri, "agent-alice", ["agent-eve"], policy),
|
|
120
|
-
(err) => err.code === "UNAUTHORIZED_CHANNEL_RECIPIENT"
|
|
121
|
-
);
|
|
122
|
-
|
|
123
|
-
// Mutant pole 3: unconfigured channel fails closed
|
|
124
|
-
assert.throws(
|
|
125
|
-
() => assertChannelSendAdmission("channel://commons/dm/unconfigured", "agent-alice", ["agent-bob"], policy),
|
|
126
|
-
(err) => err.code === "UNCONFIGURED_CHANNEL"
|
|
127
|
-
);
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
test("Bidirectional channel admission: inbound receive assertions (two-pole)", () => {
|
|
131
|
-
const channelUri = "channel://commons/dm/thread-1";
|
|
132
|
-
const policy = {
|
|
133
|
-
channels: {
|
|
134
|
-
[channelUri]: {
|
|
135
|
-
visibility: "private",
|
|
136
|
-
members: ["agent-alice", "agent-bob"]
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
};
|
|
140
|
-
|
|
141
|
-
// Control pole: decrypted sender and local receiver admitted
|
|
142
|
-
assert.doesNotThrow(() => {
|
|
143
|
-
assertChannelReceiveAdmission(channelUri, "agent-alice", "agent-bob", policy);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
// Mutant pole 1: unauthorized sender fails closed
|
|
147
|
-
assert.throws(
|
|
148
|
-
() => assertChannelReceiveAdmission(channelUri, "agent-eve", "agent-bob", policy),
|
|
149
|
-
(err) => err.code === "UNAUTHORIZED_CHANNEL_SENDER"
|
|
150
|
-
);
|
|
151
|
-
|
|
152
|
-
// Mutant pole 2: unauthorized receiver fails closed
|
|
153
|
-
assert.throws(
|
|
154
|
-
() => assertChannelReceiveAdmission(channelUri, "agent-alice", "agent-eve", policy),
|
|
155
|
-
(err) => err.code === "UNAUTHORIZED_CHANNEL_RECEIVER"
|
|
156
|
-
);
|
|
157
|
-
|
|
158
|
-
// Mutant pole 3: unconfigured inbound channel fails closed with UNCONFIGURED_CHANNEL
|
|
159
|
-
assert.throws(
|
|
160
|
-
() => assertChannelReceiveAdmission("channel://commons/dm/unconfigured", "agent-alice", "agent-bob", policy),
|
|
161
|
-
(err) => err.code === "UNCONFIGURED_CHANNEL"
|
|
162
|
-
);
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
test("Cross-channel forwarding assertions (two-pole)", () => {
|
|
166
|
-
const sourceChannel = "channel://commons/dm/source-1";
|
|
167
|
-
const targetChannel = "channel://zilligon/community/announcements";
|
|
168
|
-
|
|
169
|
-
const policy = {
|
|
170
|
-
agents: {
|
|
171
|
-
"agent-forwarder": { capabilities: ["commons:message", "commons:forward"] },
|
|
172
|
-
"agent-regular": { capabilities: ["commons:message"] }
|
|
173
|
-
},
|
|
174
|
-
channels: {
|
|
175
|
-
[targetChannel]: {
|
|
176
|
-
visibility: "public",
|
|
177
|
-
allowForwarding: true
|
|
178
|
-
},
|
|
179
|
-
"channel://commons/dm/strict-target": {
|
|
180
|
-
visibility: "private",
|
|
181
|
-
allowForwarding: false
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
};
|
|
185
|
-
|
|
186
|
-
// Control pole: authorized forwarder and target permits forwarding
|
|
187
|
-
assert.doesNotThrow(() => {
|
|
188
|
-
assertForwardingPermission(sourceChannel, targetChannel, "agent-forwarder", policy);
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
// Mutant pole 1: sender lacks commons:forward capability
|
|
192
|
-
assert.throws(
|
|
193
|
-
() => assertForwardingPermission(sourceChannel, targetChannel, "agent-regular", policy),
|
|
194
|
-
(err) => err.code === "CROSS_CHANNEL_FORWARDING_DENIED"
|
|
195
|
-
);
|
|
196
|
-
|
|
197
|
-
// Mutant pole 2: target channel disallows forwarding
|
|
198
|
-
assert.throws(
|
|
199
|
-
() => assertForwardingPermission(sourceChannel, "channel://commons/dm/strict-target", "agent-forwarder", policy),
|
|
200
|
-
(err) => err.code === "CROSS_CHANNEL_FORWARDING_DENIED"
|
|
201
|
-
);
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
test("Tenant namespace binding assertions (two-pole)", () => {
|
|
205
|
-
const boundTenantProfile = {
|
|
206
|
-
id: "sha256-bound-profile-digest",
|
|
207
|
-
scope: "tenant"
|
|
208
|
-
};
|
|
209
|
-
const unboundTenantProfile = {
|
|
210
|
-
id: "sha256-unbound-tenant-profile",
|
|
211
|
-
scope: "tenant"
|
|
212
|
-
};
|
|
213
|
-
const unboundLocalProfile = {
|
|
214
|
-
id: "sha256-unbound-local-profile",
|
|
215
|
-
scope: "local"
|
|
216
|
-
};
|
|
217
|
-
|
|
218
|
-
const policy = {
|
|
219
|
-
tenantProfiles: {
|
|
220
|
-
[boundTenantProfile.id]: "tenant-acme"
|
|
221
|
-
},
|
|
222
|
-
agents: {
|
|
223
|
-
"agent-acme-1": { tenantId: "tenant-acme" },
|
|
224
|
-
"agent-acme-2": { tenantId: "tenant-acme" },
|
|
225
|
-
"agent-corp-x": { tenantId: "tenant-corp" }
|
|
226
|
-
}
|
|
227
|
-
};
|
|
228
|
-
|
|
229
|
-
// Control pole 1: matching tenant passes
|
|
230
|
-
assert.doesNotThrow(() => {
|
|
231
|
-
validateTenantBinding("agent-acme-1", "agent-acme-2", boundTenantProfile, policy);
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
// Mutant pole 1: sender tenant mismatch fails closed
|
|
235
|
-
assert.throws(
|
|
236
|
-
() => validateTenantBinding("agent-corp-x", "agent-acme-2", boundTenantProfile, policy),
|
|
237
|
-
(err) => err.code === "TENANT_NAMESPACE_MISMATCH"
|
|
238
|
-
);
|
|
239
|
-
|
|
240
|
-
// Mutant pole 2: recipient tenant mismatch fails closed
|
|
241
|
-
assert.throws(
|
|
242
|
-
() => validateTenantBinding("agent-acme-1", "agent-corp-x", boundTenantProfile, policy),
|
|
243
|
-
(err) => err.code === "TENANT_NAMESPACE_MISMATCH"
|
|
244
|
-
);
|
|
245
|
-
|
|
246
|
-
// Control pole 2: unbound tenant profile MUST fail closed with UNBOUND_PROFILE_TENANT (R4)
|
|
247
|
-
assert.throws(
|
|
248
|
-
() => validateTenantBinding("agent-acme-1", "agent-acme-2", unboundTenantProfile, policy),
|
|
249
|
-
(err) => err.code === "UNBOUND_PROFILE_TENANT"
|
|
250
|
-
);
|
|
251
|
-
|
|
252
|
-
// Control pole 3: unbound local profile passes unconstrained without tenant binding requirement
|
|
253
|
-
assert.doesNotThrow(() => {
|
|
254
|
-
validateTenantBinding("agent-acme-1", "agent-corp-x", unboundLocalProfile, policy);
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
// Mutant pole: unbound bare digest string fails closed with UNBOUND_PROFILE_TENANT (R4)
|
|
258
|
-
assert.throws(
|
|
259
|
-
() => validateTenantBinding("agent-acme-1", "agent-corp-x", "sha256-unbound-digest", policy),
|
|
260
|
-
(err) => err.code === "UNBOUND_PROFILE_TENANT"
|
|
261
|
-
);
|
|
262
|
-
|
|
263
|
-
// Mutant pole: unbound scope-less ProfileRef fails closed with UNBOUND_PROFILE_TENANT (R4)
|
|
264
|
-
assert.throws(
|
|
265
|
-
() => validateTenantBinding("agent-acme-1", "agent-corp-x", { id: "profile-no-scope" }, policy),
|
|
266
|
-
(err) => err.code === "UNBOUND_PROFILE_TENANT"
|
|
267
|
-
);
|
|
268
|
-
});
|
package/test/cohort.test.mjs
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import test from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
import { mkdtempSync, rmSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
-
import { tmpdir } from "node:os";
|
|
5
|
-
import { join } from "node:path";
|
|
6
|
-
import { initializeCohort, cohortStatus, FOUNDING_ROSTER } from "../src/cohort.mjs";
|
|
7
|
-
|
|
8
|
-
test("Seven nodes persist distinct identity and scoped policy without remote enrollment",()=>{
|
|
9
|
-
const root=mkdtempSync(join(tmpdir(),"commons-cohort-")),home=join(root,"nodes");
|
|
10
|
-
try{
|
|
11
|
-
const manifest=initializeCohort({home,target:"zilligon.com"});
|
|
12
|
-
assert.equal(manifest.members.length,7);
|
|
13
|
-
assert.equal(new Set(manifest.members.map(m=>m.uuaid)).size,7);
|
|
14
|
-
assert.equal(manifest.registration,"not-performed");
|
|
15
|
-
assert.equal(manifest.certified,false);
|
|
16
|
-
const next=cohortStatus(home);
|
|
17
|
-
assert.deepEqual(next.members.map(m=>m.uuaid),manifest.members.map(m=>m.uuaid));
|
|
18
|
-
assert(next.members.every(m=>m.status.auditVerified));
|
|
19
|
-
const config=JSON.parse(readFileSync(join(home,"security","config.json"),"utf8"));
|
|
20
|
-
assert.equal(Object.keys(config.policy.agents).length,7);
|
|
21
|
-
const security=manifest.members.find(m=>m.id==="security");
|
|
22
|
-
assert(!config.policy.agents[security.uuaid].capabilities.includes("commons:evolve"));
|
|
23
|
-
assert(Object.values(config.policy.agents).every(a=>!a.capabilities.includes("commons:recover")));
|
|
24
|
-
assert.throws(()=>initializeCohort({home}),/not overwritten/);
|
|
25
|
-
}finally{rmSync(root,{recursive:true,force:true})}
|
|
26
|
-
});
|
|
27
|
-
test("Cohort manifest cannot redirect identity loading to arbitrary paths",()=>{
|
|
28
|
-
const root=mkdtempSync(join(tmpdir(),"commons-cohort-")),home=join(root,"nodes");
|
|
29
|
-
try{
|
|
30
|
-
const m=initializeCohort({home});
|
|
31
|
-
m.members[0].home=root;
|
|
32
|
-
writeFileSync(join(home,"cohort.json"),JSON.stringify(m));
|
|
33
|
-
assert.throws(()=>cohortStatus(home),/Unsafe/);
|
|
34
|
-
assert.equal(FOUNDING_ROSTER.length,7);
|
|
35
|
-
}finally{rmSync(root,{recursive:true,force:true})}
|
|
36
|
-
});
|