@gregiteen/ssss-cli 0.9.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +55 -4
- package/README.md +41 -82
- package/VERSION +1 -1
- package/conformance/reference-bundle.ucw.json +19 -1
- package/docs/help/overview.md +6 -3
- package/docs/ssss-spec.md +11 -10
- package/package.json +1 -1
- package/registry/core.json +1 -1
- package/scripts/conformance-09.mjs +139 -7
- package/scripts/conformance.mjs +47 -0
- package/scripts/generate-sbom.mjs +96 -0
- package/scripts/phase9-cross-host-corpus.mjs +223 -0
- package/skills/ssss/SKILL.md +32 -204
- package/src/authorization.mjs +2 -1
- package/src/bundle.mjs +37 -1
- package/src/events.mjs +74 -9
- package/src/leases.mjs +50 -2
- package/src/projections.mjs +72 -1
package/src/bundle.mjs
CHANGED
|
@@ -197,6 +197,17 @@ export function exportBundle(vaultRoot, opts = {}) {
|
|
|
197
197
|
}
|
|
198
198
|
|
|
199
199
|
const files = canonicalFiles(kept);
|
|
200
|
+
const hash = contentHash(files);
|
|
201
|
+
const primitiveDeps = Object.keys(inventory).sort();
|
|
202
|
+
const migrationDeps = files
|
|
203
|
+
.map((file) => {
|
|
204
|
+
try {
|
|
205
|
+
const { data } = parseDocument(file.content);
|
|
206
|
+
return data.type === 'migration' ? (data.migration_id || data.slug || file.path) : null;
|
|
207
|
+
} catch { return null; }
|
|
208
|
+
})
|
|
209
|
+
.filter(Boolean)
|
|
210
|
+
.sort();
|
|
200
211
|
const manifest = {
|
|
201
212
|
name, description, version,
|
|
202
213
|
exported_at: exportedAt,
|
|
@@ -204,10 +215,16 @@ export function exportBundle(vaultRoot, opts = {}) {
|
|
|
204
215
|
required_extensions: requiredExtensions,
|
|
205
216
|
export_profile: profile,
|
|
206
217
|
primitive_inventory: inventory,
|
|
218
|
+
dependencies: {
|
|
219
|
+
primitives: primitiveDeps,
|
|
220
|
+
extensions: [...requiredExtensions].sort(),
|
|
221
|
+
migrations: migrationDeps,
|
|
222
|
+
integrity: { content_hash: hash, ssss_core_version: core.spec_version },
|
|
223
|
+
},
|
|
207
224
|
provisioning: manifestProvisioning,
|
|
208
225
|
parameters: manifestParameters,
|
|
209
226
|
file_count: files.length,
|
|
210
|
-
provenance: { content_hash:
|
|
227
|
+
provenance: { content_hash: hash, exporter },
|
|
211
228
|
};
|
|
212
229
|
// source_workspace_id is operator-identifying — only carried in a backup (§16.2).
|
|
213
230
|
if (profile === 'backup' && sourceWorkspaceId) manifest.source_workspace_id = sourceWorkspaceId;
|
|
@@ -240,6 +257,25 @@ export function validateBundle(bundle, opts = {}) {
|
|
|
240
257
|
if (!m.primitive_inventory || typeof m.primitive_inventory !== 'object' || Array.isArray(m.primitive_inventory)) {
|
|
241
258
|
errors.push('manifest.primitive_inventory must be an object');
|
|
242
259
|
}
|
|
260
|
+
if (m.dependencies !== undefined) {
|
|
261
|
+
if (!m.dependencies || typeof m.dependencies !== 'object' || Array.isArray(m.dependencies)) {
|
|
262
|
+
errors.push('manifest.dependencies must be an object');
|
|
263
|
+
} else {
|
|
264
|
+
for (const key of ['primitives', 'extensions', 'migrations']) {
|
|
265
|
+
if (!Array.isArray(m.dependencies[key])) errors.push(`manifest.dependencies.${key} must be an array`);
|
|
266
|
+
}
|
|
267
|
+
if (!m.dependencies.integrity || typeof m.dependencies.integrity !== 'object' || Array.isArray(m.dependencies.integrity)) {
|
|
268
|
+
errors.push('manifest.dependencies.integrity must be an object');
|
|
269
|
+
} else if (typeof m.dependencies.integrity.content_hash !== 'string' || !m.dependencies.integrity.content_hash.startsWith('sha256:')) {
|
|
270
|
+
errors.push('manifest.dependencies.integrity.content_hash must be a sha256 digest');
|
|
271
|
+
} else if (
|
|
272
|
+
m.provenance?.content_hash
|
|
273
|
+
&& m.dependencies.integrity.content_hash !== m.provenance.content_hash
|
|
274
|
+
) {
|
|
275
|
+
errors.push('manifest.dependencies.integrity.content_hash must match provenance.content_hash');
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
243
279
|
for (const ext of requiredExtensions) {
|
|
244
280
|
if (typeof ext !== 'string' || !ext) {
|
|
245
281
|
errors.push('required extension ids must be non-empty strings');
|
package/src/events.mjs
CHANGED
|
@@ -33,12 +33,12 @@ export function createCanonicalEvent(input, options = {}) {
|
|
|
33
33
|
|
|
34
34
|
export class MemoryEventStore {
|
|
35
35
|
#events = [];
|
|
36
|
+
#ids = new Set();
|
|
36
37
|
async append(event) {
|
|
37
|
-
if (this.#
|
|
38
|
-
throw new Error(`Duplicate event_id '${event.event_id}'.`);
|
|
39
|
-
}
|
|
38
|
+
if (this.#ids.has(event.event_id)) throw new Error(`Duplicate event_id '${event.event_id}'.`);
|
|
40
39
|
const frozen = structuredClone(event);
|
|
41
40
|
this.#events.push(frozen);
|
|
41
|
+
this.#ids.add(frozen.event_id);
|
|
42
42
|
return structuredClone(frozen);
|
|
43
43
|
}
|
|
44
44
|
async *replay(options = {}) {
|
|
@@ -51,7 +51,46 @@ export class MemoryEventStore {
|
|
|
51
51
|
async size() { return this.#events.length; }
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
const NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
|
|
55
|
+
const APPEND_FLAGS = fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | NOFOLLOW;
|
|
56
|
+
|
|
57
|
+
function openLog(file, flags, mode) {
|
|
58
|
+
try { return fs.openSync(file, flags, mode); }
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (error.code === 'ELOOP') throw new Error('Symlinked event logs are forbidden.');
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function sameStat(a, b) {
|
|
66
|
+
return a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Read every event_id in a log, bounded to the bytes covered by the returned stat. */
|
|
70
|
+
function loadIndex(file) {
|
|
71
|
+
const fd = openLog(file, fs.constants.O_RDONLY | NOFOLLOW);
|
|
72
|
+
try {
|
|
73
|
+
const stat = fs.fstatSync(fd);
|
|
74
|
+
const bytes = Buffer.alloc(stat.size);
|
|
75
|
+
let offset = 0;
|
|
76
|
+
while (offset < bytes.length) {
|
|
77
|
+
const read = fs.readSync(fd, bytes, offset, bytes.length - offset, offset);
|
|
78
|
+
if (read === 0) break;
|
|
79
|
+
offset += read;
|
|
80
|
+
}
|
|
81
|
+
const ids = new Set();
|
|
82
|
+
for (const line of bytes.subarray(0, offset).toString('utf8').split('\n')) {
|
|
83
|
+
if (line.trim()) ids.add(JSON.parse(line).event_id);
|
|
84
|
+
}
|
|
85
|
+
return { ids, stat };
|
|
86
|
+
} finally { fs.closeSync(fd); }
|
|
87
|
+
}
|
|
88
|
+
|
|
54
89
|
export class JsonlEventStore {
|
|
90
|
+
// Per-log index of event_ids, built on the first append to that log. Each
|
|
91
|
+
// index records the stat of the bytes it covers; any change (another store
|
|
92
|
+
// instance or process appended, or the file was replaced) rebuilds it.
|
|
93
|
+
#indexes = new Map();
|
|
55
94
|
constructor(root) {
|
|
56
95
|
fs.mkdirSync(root, { recursive: true });
|
|
57
96
|
this.root = fs.realpathSync(root);
|
|
@@ -60,15 +99,41 @@ export class JsonlEventStore {
|
|
|
60
99
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(workspaceId || '')) throw new Error('Unsafe event workspace_id.');
|
|
61
100
|
return path.join(this.root, `${workspaceId}.jsonl`);
|
|
62
101
|
}
|
|
102
|
+
#index(file) {
|
|
103
|
+
let stat;
|
|
104
|
+
try { stat = fs.lstatSync(file); }
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (error.code !== 'ENOENT') throw error;
|
|
107
|
+
this.#indexes.delete(file);
|
|
108
|
+
return { ids: new Set(), stat: null };
|
|
109
|
+
}
|
|
110
|
+
if (stat.isSymbolicLink()) throw new Error('Symlinked event logs are forbidden.');
|
|
111
|
+
const cached = this.#indexes.get(file);
|
|
112
|
+
if (cached && sameStat(cached.stat, stat)) return cached;
|
|
113
|
+
const index = loadIndex(file);
|
|
114
|
+
this.#indexes.set(file, index);
|
|
115
|
+
return index;
|
|
116
|
+
}
|
|
63
117
|
async append(event) {
|
|
64
118
|
const file = this.#file(event.workspace_id);
|
|
65
|
-
|
|
66
|
-
if (
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
119
|
+
const index = this.#index(file);
|
|
120
|
+
if (index.ids.has(event.event_id)) throw new Error(`Duplicate event_id '${event.event_id}'.`);
|
|
121
|
+
const line = `${JSON.stringify(event)}\n`;
|
|
122
|
+
this.#indexes.delete(file);
|
|
123
|
+
const fd = openLog(file, APPEND_FLAGS, 0o600);
|
|
124
|
+
let after;
|
|
125
|
+
try {
|
|
126
|
+
fs.writeFileSync(fd, line);
|
|
127
|
+
after = fs.fstatSync(fd);
|
|
128
|
+
} finally { fs.closeSync(fd); }
|
|
129
|
+
// Keep the index only when the log grew by exactly this line on the same
|
|
130
|
+
// file; otherwise another writer interleaved and the next append rebuilds.
|
|
131
|
+
const before = index.stat;
|
|
132
|
+
const expectedSize = (before?.size || 0) + Buffer.byteLength(line);
|
|
133
|
+
if (after.size === expectedSize && (!before || (before.dev === after.dev && before.ino === after.ino))) {
|
|
134
|
+
index.ids.add(event.event_id);
|
|
135
|
+
this.#indexes.set(file, { ids: index.ids, stat: after });
|
|
70
136
|
}
|
|
71
|
-
fs.appendFileSync(file, `${JSON.stringify(event)}\n`, { mode: 0o600 });
|
|
72
137
|
return structuredClone(event);
|
|
73
138
|
}
|
|
74
139
|
async *replay(options = {}) {
|
package/src/leases.mjs
CHANGED
|
@@ -46,6 +46,11 @@ export class MemoryLeaseStore {
|
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
export function leaseStatePath(root, workspaceId, target) {
|
|
50
|
+
const hash = crypto.createHash('sha256').update(`${workspaceId}\0${target}`).digest('hex');
|
|
51
|
+
return path.join(root, `${hash}.lease.json`);
|
|
52
|
+
}
|
|
53
|
+
|
|
49
54
|
export class FileLeaseStore {
|
|
50
55
|
constructor(root, options = {}) {
|
|
51
56
|
fs.mkdirSync(root, { recursive: true });
|
|
@@ -53,8 +58,7 @@ export class FileLeaseStore {
|
|
|
53
58
|
this.clock = options.clock || (() => Date.now());
|
|
54
59
|
}
|
|
55
60
|
#file(workspaceId, target) {
|
|
56
|
-
|
|
57
|
-
return path.join(this.root, `${hash}.lease.json`);
|
|
61
|
+
return leaseStatePath(this.root, workspaceId, target);
|
|
58
62
|
}
|
|
59
63
|
#load(workspaceId, target) {
|
|
60
64
|
const file = this.#file(workspaceId, target);
|
|
@@ -117,9 +121,53 @@ export async function runLeaseContract(store, { clock } = {}) {
|
|
|
117
121
|
record('acquire returns bound lease', acquired.lease_id && acquired.principal_id === input.principal_id);
|
|
118
122
|
record('matching lease verifies', (await store.verify({ ...input, lease_id: acquired.lease_id })).valid);
|
|
119
123
|
record('mismatched owner fails closed', !(await store.verify({ ...input, principal_id: 'other', lease_id: acquired.lease_id })).valid);
|
|
124
|
+
record('mismatched lease_id fails closed', !(await store.verify({ ...input, lease_id: crypto.randomUUID() })).valid);
|
|
125
|
+
record('missing lease fails closed', !(await store.verify({
|
|
126
|
+
workspace_id: input.workspace_id,
|
|
127
|
+
target: `missing/${crypto.randomUUID()}.md`,
|
|
128
|
+
principal_id: input.principal_id,
|
|
129
|
+
operation_id: input.operation_id,
|
|
130
|
+
lease_id: acquired.lease_id,
|
|
131
|
+
})).valid);
|
|
120
132
|
const renewed = await store.renew({ ...input, lease_id: acquired.lease_id }, 20_000);
|
|
121
133
|
record('renew extends expiry', renewed.expires_at >= acquired.expires_at);
|
|
122
134
|
await store.release({ ...input, lease_id: acquired.lease_id });
|
|
123
135
|
record('released lease is missing', !(await store.verify({ ...input, lease_id: acquired.lease_id })).valid);
|
|
136
|
+
|
|
137
|
+
// Controllable clock proves expiry fail-closed for any store that accepts injection.
|
|
138
|
+
if (typeof store.clock === 'function' || clock) {
|
|
139
|
+
let now = 1_000_000;
|
|
140
|
+
const timed = store.constructor === MemoryLeaseStore
|
|
141
|
+
? new MemoryLeaseStore({ clock: () => now })
|
|
142
|
+
: store.constructor === FileLeaseStore
|
|
143
|
+
? new FileLeaseStore(path.join(store.root, `expiry-${crypto.randomUUID()}`), { clock: () => now })
|
|
144
|
+
: null;
|
|
145
|
+
if (timed) {
|
|
146
|
+
const timedInput = { ...input, target: `rules/expiry-${crypto.randomUUID()}.md` };
|
|
147
|
+
const short = await timed.acquire(timedInput, 50);
|
|
148
|
+
now = short.expires_at + 1;
|
|
149
|
+
record('expired lease fails closed', !(await timed.verify({ ...timedInput, lease_id: short.lease_id })).valid);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Filesystem stores must fail closed on corrupt/unreadable state.
|
|
154
|
+
if (store instanceof FileLeaseStore) {
|
|
155
|
+
const corruptInput = {
|
|
156
|
+
workspace_id: `ws-${crypto.randomUUID()}`,
|
|
157
|
+
target: `rules/corrupt-${crypto.randomUUID()}.md`,
|
|
158
|
+
principal_id: 'principal',
|
|
159
|
+
operation_id: 'operation',
|
|
160
|
+
};
|
|
161
|
+
const good = await store.acquire(corruptInput, 30_000);
|
|
162
|
+
fs.writeFileSync(leaseStatePath(store.root, corruptInput.workspace_id, corruptInput.target), '{not-json', { mode: 0o600 });
|
|
163
|
+
let unreadable = false;
|
|
164
|
+
try {
|
|
165
|
+
await store.verify({ ...corruptInput, lease_id: good.lease_id });
|
|
166
|
+
} catch (error) {
|
|
167
|
+
unreadable = /unreadable/i.test(error.message);
|
|
168
|
+
}
|
|
169
|
+
record('unreadable lease state fails closed', unreadable);
|
|
170
|
+
}
|
|
171
|
+
|
|
124
172
|
return { passed: checks.every((check) => check.passed), checks };
|
|
125
173
|
}
|
package/src/projections.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Replayable, cursor-aware SSSS projection coordinator. */
|
|
1
|
+
/** Replayable, cursor-aware SSSS projection coordinator and host adapter examples. */
|
|
2
2
|
import crypto from 'node:crypto';
|
|
3
3
|
|
|
4
4
|
function stable(value) {
|
|
@@ -11,6 +11,77 @@ export function projectionHash(value) {
|
|
|
11
11
|
return `sha256:${crypto.createHash('sha256').update(JSON.stringify(stable(value))).digest('hex')}`;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Reference projection destination adapters.
|
|
16
|
+
* Hosts own durable destination writes; these prove the shared apply/reset/snapshot contract.
|
|
17
|
+
*/
|
|
18
|
+
export function createSqlProjectionAdapter({ table = 'ssss_events' } = {}) {
|
|
19
|
+
const rows = new Map();
|
|
20
|
+
return {
|
|
21
|
+
id: `sql:${table}`,
|
|
22
|
+
kind: 'sql',
|
|
23
|
+
apply: async (event) => {
|
|
24
|
+
rows.set(event.event_id, {
|
|
25
|
+
event_id: event.event_id,
|
|
26
|
+
subject: event.subject,
|
|
27
|
+
action: event.action,
|
|
28
|
+
after_hash: event.after_hash,
|
|
29
|
+
workspace_id: event.workspace_id,
|
|
30
|
+
});
|
|
31
|
+
},
|
|
32
|
+
reset: async () => rows.clear(),
|
|
33
|
+
snapshot: async () => Object.fromEntries([...rows.entries()].sort()),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createSearchProjectionAdapter({ index = 'ssss' } = {}) {
|
|
38
|
+
const documents = new Map();
|
|
39
|
+
return {
|
|
40
|
+
id: `search:${index}`,
|
|
41
|
+
kind: 'search',
|
|
42
|
+
apply: async (event) => {
|
|
43
|
+
documents.set(event.subject, {
|
|
44
|
+
subject: event.subject,
|
|
45
|
+
terms: [event.action, event.primitive_id, event.subject].filter(Boolean).join(' '),
|
|
46
|
+
after_hash: event.after_hash,
|
|
47
|
+
});
|
|
48
|
+
},
|
|
49
|
+
reset: async () => documents.clear(),
|
|
50
|
+
snapshot: async () => Object.fromEntries([...documents.entries()].sort()),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function createQueueProjectionAdapter({ topic = 'ssss.mutations' } = {}) {
|
|
55
|
+
const messages = [];
|
|
56
|
+
return {
|
|
57
|
+
id: `queue:${topic}`,
|
|
58
|
+
kind: 'queue',
|
|
59
|
+
apply: async (event) => {
|
|
60
|
+
messages.push({ topic, event_id: event.event_id, subject: event.subject, action: event.action });
|
|
61
|
+
},
|
|
62
|
+
reset: async () => { messages.length = 0; },
|
|
63
|
+
snapshot: async () => messages.map((message) => ({ ...message })),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createViewModelProjectionAdapter({ view = 'detail' } = {}) {
|
|
68
|
+
const models = new Map();
|
|
69
|
+
return {
|
|
70
|
+
id: `view:${view}`,
|
|
71
|
+
kind: 'view_model',
|
|
72
|
+
apply: async (event) => {
|
|
73
|
+
models.set(event.subject, {
|
|
74
|
+
subject: event.subject,
|
|
75
|
+
version: event.after_hash || event.before_hash || null,
|
|
76
|
+
last_action: event.action,
|
|
77
|
+
principal_id: event.principal?.id || null,
|
|
78
|
+
});
|
|
79
|
+
},
|
|
80
|
+
reset: async () => models.clear(),
|
|
81
|
+
snapshot: async () => Object.fromEntries([...models.entries()].sort()),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
14
85
|
export class ProjectionCoordinator {
|
|
15
86
|
constructor({ eventStore, cursorStore = new Map() } = {}) {
|
|
16
87
|
if (!eventStore) throw new Error('ProjectionCoordinator requires an eventStore.');
|