@gregiteen/ssss-cli 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +42 -4
  2. package/README.md +19 -4
  3. package/VERSION +1 -1
  4. package/conformance/README.md +11 -7
  5. package/conformance/fixtures.json +24 -2
  6. package/conformance/reference-bundle.ucw.json +2 -2
  7. package/docs/help/bundle.md +6 -2
  8. package/docs/help/import.md +2 -0
  9. package/docs/help/overview.md +8 -4
  10. package/docs/help/semantic.md +38 -0
  11. package/docs/ssss-spec.md +67 -7
  12. package/package.json +27 -3
  13. package/registry/core.json +36 -3
  14. package/scripts/audit-registry-field-usage.mjs +10 -1
  15. package/scripts/build-reference-bundle.mjs +4 -1
  16. package/scripts/cmd-adapter.mjs +29 -0
  17. package/scripts/cmd-export.mjs +3 -3
  18. package/scripts/cmd-import.mjs +1 -1
  19. package/scripts/cmd-migrate.mjs +26 -0
  20. package/scripts/cmd-new.mjs +5 -5
  21. package/scripts/cmd-primitive.mjs +48 -0
  22. package/scripts/cmd-registry.mjs +25 -0
  23. package/scripts/cmd-semantic.mjs +86 -0
  24. package/scripts/conformance-09.mjs +199 -0
  25. package/scripts/conformance.mjs +320 -28
  26. package/scripts/ssss.mjs +10 -0
  27. package/skills/ssss/SKILL.md +221 -0
  28. package/skills/ssss/evals/evals.json +37 -0
  29. package/skills/ssss/references/contract-reference.md +95 -0
  30. package/skills/ssss/scripts/validate-schema.mjs +215 -0
  31. package/skills/ssss/subagents/audit-ssss-contract.md +22 -0
  32. package/src/authorization.mjs +61 -0
  33. package/src/bundle.mjs +171 -35
  34. package/src/engine.mjs +101 -429
  35. package/src/events.mjs +90 -0
  36. package/src/guard.mjs +32 -0
  37. package/src/http.mjs +26 -0
  38. package/src/idempotency.mjs +55 -0
  39. package/src/kernel.mjs +260 -0
  40. package/src/leases.mjs +125 -0
  41. package/src/primitive.mjs +227 -0
  42. package/src/projections.mjs +62 -0
  43. package/src/registry.mjs +408 -17
  44. package/src/semantic.mjs +210 -0
  45. package/src/ui.mjs +100 -0
  46. package/src/validator.mjs +87 -0
  47. package/src/vfs.mjs +203 -0
  48. package/types/kernel.d.ts +48 -0
package/src/engine.mjs CHANGED
@@ -1,452 +1,124 @@
1
1
  /**
2
- * The canonical SSSS Operation Contract engine (spec §6).
3
- *
4
- * Harvested and canonicalized from three reference implementations:
5
- * - total-recall `src/core/operation-validator.mjs` — the §6.3 pipeline shape,
6
- * idempotency replay from the audit log, and the `buildRepair` mechanism (§9).
7
- * - festech `SsssOperationService.ts` — the `delete` envelope and the
8
- * `resolveContainedPath` path-traversal guard.
9
- * - festech `SsssValidator.ts` — registry-driven content validation (no per-type code).
10
- *
11
- * Dependency-free by design: a host should not need Zod or a YAML library to be
12
- * SSSS-conformant. Content is validated against `registry/core.json` (+ extensions).
2
+ * Compatibility façade for the pre-0.9 `createEngine().processOperation()` API.
3
+ * All behavior delegates to the shared kernel; this file contains no second
4
+ * mutation implementation. New hosts should import `@gregiteen/ssss-cli/kernel`.
13
5
  */
14
6
  import fs from 'node:fs';
15
7
  import path from 'node:path';
16
8
  import crypto from 'node:crypto';
17
- import { parseDocument, serializeDocument } from './frontmatter.mjs';
18
- import { loadRegistries, isAppendType } from './registry.mjs';
9
+ import { createKernel } from './kernel.mjs';
10
+ import { FileSystemVfs } from './vfs.mjs';
11
+ import { JsonlEventStore } from './events.mjs';
12
+ import { createValidator } from './validator.mjs';
13
+ import { loadRegistries } from './registry.mjs';
14
+ import { parseDocument } from './frontmatter.mjs';
19
15
 
20
- const ENVELOPE_TYPES = ['operation', 'patch', 'event', 'delete'];
21
- const ENVELOPE_REQUIRED = {
22
- operation: ['type', 'idempotency_key', 'path', 'workspace_id', 'content'],
23
- patch: ['type', 'idempotency_key', 'path', 'workspace_id', 'patches'],
24
- event: ['type', 'idempotency_key', 'path', 'workspace_id', 'content'],
25
- delete: ['type', 'idempotency_key', 'path', 'workspace_id'],
26
- };
27
-
28
- const IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000;
29
- const SAFE_IDENTIFIER_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
30
-
31
- function cacheKey(wid, key) { return JSON.stringify([wid, key]); }
32
-
33
- function isSafeIdentifier(value) {
34
- return typeof value === 'string' && SAFE_IDENTIFIER_RE.test(value);
35
- }
36
-
37
- function stableStringify(value) {
38
- if (value === null || typeof value !== 'object') return JSON.stringify(value);
39
- if (Array.isArray(value)) return `[${value.map((v) => stableStringify(v)).join(',')}]`;
40
- return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;
41
- }
42
-
43
- function requestHash(envelope) {
44
- const payload = {
45
- type: envelope.type,
46
- path: envelope.path,
47
- workspace_id: envelope.workspace_id,
48
- actor: envelope.actor || null,
49
- lease_id: envelope.lease_id || null,
50
- dry_run: !!envelope.dry_run,
51
- };
52
- if (Object.prototype.hasOwnProperty.call(envelope, 'content')) payload.content = envelope.content;
53
- if (Object.prototype.hasOwnProperty.call(envelope, 'patches')) payload.patches = envelope.patches;
54
- return 'sha256:' + crypto.createHash('sha256').update(stableStringify(payload)).digest('hex');
55
- }
56
-
57
- /** festech `resolveContainedPath`: resolve a VFS path, refusing escape from the vault. */
58
16
  export function resolveContainedPath(vaultRoot, vfsPath) {
59
- const root = path.resolve(vaultRoot);
60
- const raw = String(vfsPath);
61
- if (!raw || raw === '.' || raw.includes('\0')) throw new Error(`Invalid VFS path: ${vfsPath}`);
62
- if (raw.startsWith('/') || raw.startsWith('\\') || raw.includes('\\')) throw new Error(`Absolute or platform-native paths are not valid VFS paths: ${vfsPath}`);
63
- const parts = raw.split('/');
64
- if (parts.some((p) => p === '' || p === '.' || p === '..')) throw new Error(`Invalid VFS path segment in: ${vfsPath}`);
65
- const cleaned = parts.join('/');
66
- const resolved = path.resolve(root, cleaned);
67
- if (resolved !== root && !resolved.startsWith(root + path.sep)) {
68
- throw new Error(`Path traversal refused: ${vfsPath}`);
17
+ if (typeof vfsPath !== 'string' || !vfsPath || vfsPath.includes('\0') || vfsPath.includes('\\') || vfsPath.startsWith('/')) {
18
+ throw new Error(`Unsafe VFS path '${vfsPath}'.`);
69
19
  }
70
- if (resolved === root) throw new Error(`VFS path must identify a file, not the vault root: ${vfsPath}`);
71
- return resolved;
72
- }
73
-
74
- function atomicWrite(absPath, content) {
75
- const dir = path.dirname(absPath);
76
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
77
- const tmp = path.join(dir, `.${path.basename(absPath)}.${crypto.randomUUID()}.tmp`);
78
- fs.writeFileSync(tmp, content);
79
- fs.renameSync(tmp, absPath);
20
+ const parts = vfsPath.split('/');
21
+ if (parts.some((part) => !part || part === '.' || part === '..')) throw new Error(`Unsafe VFS path '${vfsPath}'.`);
22
+ const root = path.resolve(vaultRoot);
23
+ const target = path.resolve(root, ...parts);
24
+ if (target === root || !target.startsWith(`${root}${path.sep}`)) throw new Error(`VFS path '${vfsPath}' escapes the vault root.`);
25
+ return target;
80
26
  }
81
27
 
82
- function ok(type, opId, p, resolvedType, committedAt, warnings = []) {
28
+ function legacyPrincipal(envelope, vaultRoot, callOptions = {}) {
29
+ if (callOptions.principal) return callOptions.principal;
30
+ const role = envelope.actor?.role;
31
+ if (!role) return null;
32
+ let capabilities = [];
33
+ let kind = 'human';
34
+ if (role === 'system') { kind = 'system'; capabilities = ['*:*']; }
35
+ else if (role === 'admin') capabilities = ['*:*'];
36
+ else if (/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(role)) {
37
+ const roleFile = path.join(vaultRoot, 'roles', role, 'ROLE.md');
38
+ if (fs.existsSync(roleFile)) {
39
+ try { capabilities = parseDocument(fs.readFileSync(roleFile, 'utf8')).data.permissions || []; }
40
+ catch { capabilities = []; }
41
+ }
42
+ }
83
43
  return {
84
- success: true, type, operation_id: opId, path: p,
85
- committed_at: committedAt, dry_run: false,
86
- validation: { valid: true, type: resolvedType, errors: [], warnings },
44
+ id: `legacy-role:${role}`,
45
+ kind,
46
+ workspaceIds: [envelope.workspace_id],
47
+ capabilities,
48
+ authentication: { provider: 'legacy-engine-adapter', assurance: 'adapter-verified' },
87
49
  };
88
50
  }
89
51
 
90
- function fail(type, opId, p, resolvedType, errors, repairEntries) {
91
- const resp = {
92
- success: false, type, operation_id: opId, path: p,
93
- committed_at: null, dry_run: false,
94
- validation: { valid: false, type: resolvedType, errors, warnings: [] },
95
- };
96
- resp.repair = {
97
- field_errors: repairEntries || errors.map((e) => ({
98
- field: e.split(':')[0]?.trim() || '(root)', issue: e,
99
- })),
100
- };
101
- return resp;
102
- }
103
-
104
- /**
105
- * Create an engine bound to a registry directory. The returned object holds the
106
- * in-process idempotency cache, so re-applying an envelope within TTL is a no-op.
107
- */
108
- export function createEngine({ registryDir, leaseStore } = {}) {
109
- const { types, core } = loadRegistries(registryDir);
110
- const idempotencyCache = new Map();
111
-
112
- function pruneExpired() {
113
- const now = Date.now();
114
- for (const [k, v] of idempotencyCache) if (now - v.ts > IDEMPOTENCY_TTL_MS) idempotencyCache.delete(k);
52
+ class LegacyLeaseAdapter {
53
+ constructor(root) { this.root = root; }
54
+ #file(workspaceId, target) {
55
+ const key = crypto.createHash('sha256').update(String(target)).digest('hex');
56
+ return path.join(this.root, workspaceId, `${key}.lease.json`);
115
57
  }
116
-
117
- function warmFromAudit(vaultRoot, eventLogDir) {
118
- const auditFile = path.join(eventLogDir || path.join(vaultRoot, '.events'), 'audit.jsonl');
119
- if (!fs.existsSync(auditFile)) return;
120
- const now = Date.now();
121
- for (const line of fs.readFileSync(auditFile, 'utf8').split('\n')) {
122
- if (!line.trim()) continue;
123
- try {
124
- const r = JSON.parse(line);
125
- if (r.event_type !== 'audit' || !r.payload) continue;
126
- const ts = Date.parse(r.ts);
127
- if (isNaN(ts) || now - ts > IDEMPOTENCY_TTL_MS) continue;
128
- const ck = cacheKey(r.payload.workspace_id, r.payload.idempotency_key);
129
- if (!idempotencyCache.has(ck)) {
130
- idempotencyCache.set(ck, {
131
- ts,
132
- request_hash: r.payload.request_hash || null,
133
- response: ok(r.payload.envelope_type, r.correlation_id, r.subject, r.payload.resolved_type, r.ts),
134
- });
135
- }
136
- } catch { /* skip corrupt line */ }
137
- }
138
- }
139
-
140
- function isEmpty(v) {
141
- return v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0);
142
- }
143
-
144
- /** Parse a registry `required_when` condition key, e.g. "schema_version==2". */
145
- function parseCondition(condition) {
146
- const m = String(condition).match(/^(\w+)==(.+)$/);
147
- if (!m) return null;
148
- const [, field, raw] = m;
149
- const value = /^-?\d+$/.test(raw) ? parseInt(raw, 10) : raw;
150
- return { field, value };
151
- }
152
-
153
- function validateContent(resolvedType, data, filePath) {
154
- const def = types.get(resolvedType);
155
- const errors = [];
156
- const repair = [];
157
- if (!def) {
158
- errors.push(`Unknown SSSS type '${resolvedType}' in ${filePath}.`);
159
- repair.push({ field: 'type', issue: `Unknown SSSS type '${resolvedType}'.` });
160
- return { errors, repair };
161
- }
162
- const requiredFields = [
163
- ...new Set([
164
- ...(core.universal_frontmatter?.required || ['type']),
165
- ...(def.required_fields || []),
166
- ]),
167
- ];
168
- for (const f of requiredFields) {
169
- if (isEmpty(data[f])) {
170
- errors.push(`Missing required field '${f}' for type '${resolvedType}' in ${filePath}.`);
171
- repair.push({ field: f, issue: `Missing required field '${f}' for type '${resolvedType}'.` });
172
- }
173
- }
174
- // required_when (§9): fields required only when a condition field matches a value.
175
- for (const [condition, fields] of Object.entries(def.required_when || {})) {
176
- const cond = parseCondition(condition);
177
- if (!cond || data[cond.field] !== cond.value) continue;
178
- for (const f of fields) {
179
- if (isEmpty(data[f])) {
180
- errors.push(`Missing required field '${f}' for type '${resolvedType}' when ${condition} in ${filePath}.`);
181
- repair.push({ field: f, issue: `Required when ${condition}.` });
182
- }
183
- }
184
- }
185
- // enums: a present field's value(s) must be drawn from the registry's declared set.
186
- for (const [field, allowedValues] of Object.entries(def.enums || {})) {
187
- if (isEmpty(data[field])) continue;
188
- const values = Array.isArray(data[field]) ? data[field] : [data[field]];
189
- for (const v of values) {
190
- if (!allowedValues.includes(v)) {
191
- errors.push(`Invalid value '${v}' for field '${field}' on type '${resolvedType}'; must be one of: ${allowedValues.join(', ')}.`);
192
- repair.push({ field, issue: `Must be one of: ${allowedValues.join(', ')}.` });
193
- }
194
- }
195
- }
196
- return { errors, repair };
58
+ isRequired(workspaceId, target) {
59
+ const file = this.#file(workspaceId, target);
60
+ if (!fs.existsSync(file)) return false;
61
+ try {
62
+ const lease = JSON.parse(fs.readFileSync(file, 'utf8'));
63
+ return new Date(lease.expires_at).getTime() > Date.now();
64
+ } catch { return true; }
197
65
  }
198
-
199
- function appendEvent(vaultRoot, eventLogDir, record) {
200
- const dir = eventLogDir || path.join(vaultRoot, '.events');
201
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
202
- fs.appendFileSync(path.join(dir, `${record.workspace_id}.jsonl`), JSON.stringify(record) + '\n');
66
+ async verify(input) {
67
+ const file = this.#file(input.workspace_id, input.target);
68
+ if (!fs.existsSync(file)) return { valid: false, reason: 'Required lease is missing.' };
69
+ try {
70
+ const lease = JSON.parse(fs.readFileSync(file, 'utf8'));
71
+ if (new Date(lease.expires_at).getTime() <= Date.now()) return { valid: true, expired: true };
72
+ if (!input.lease_id) return { valid: false, reason: 'Path is leased; lease_id is required.' };
73
+ if (lease.lease_id !== input.lease_id) return { valid: false, reason: 'Lease mismatch.' };
74
+ return { valid: true, lease };
75
+ } catch { return { valid: false, reason: 'Lease state is unreadable.' }; }
203
76
  }
77
+ }
204
78
 
205
- function audit(vaultRoot, eventLogDir, opId, committedAt, envelope, resolvedType, hash) {
206
- const dir = eventLogDir || path.join(vaultRoot, '.events');
207
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
208
- fs.appendFileSync(path.join(dir, 'audit.jsonl'), JSON.stringify({
209
- event_id: crypto.randomUUID(), event_type: 'audit', correlation_id: opId,
210
- ts: committedAt, subject: envelope.path,
211
- payload: {
212
- envelope_type: envelope.type, idempotency_key: envelope.idempotency_key,
213
- workspace_id: envelope.workspace_id, resolved_type: resolvedType,
214
- request_hash: hash,
215
- },
216
- }) + '\n');
79
+ export function createEngine(options = {}) {
80
+ const registrySet = options.registrySet || loadRegistries(options.registryDir);
81
+ const validator = options.validator || createValidator({ registrySet });
82
+ const kernels = new Map();
83
+ const legacyLease = typeof options.leaseStore === 'string' ? new LegacyLeaseAdapter(options.leaseStore) : options.leaseStore;
84
+
85
+ function bound(vaultRoot, eventLogDir) {
86
+ const key = `${path.resolve(vaultRoot)}\0${eventLogDir ? path.resolve(eventLogDir) : ''}`;
87
+ if (!kernels.has(key)) {
88
+ fs.mkdirSync(vaultRoot, { recursive: true });
89
+ const eventRoot = eventLogDir || path.join(vaultRoot, '.events');
90
+ kernels.set(key, createKernel({
91
+ vfs: new FileSystemVfs(vaultRoot),
92
+ eventStore: new JsonlEventStore(eventRoot),
93
+ leaseStore: legacyLease,
94
+ validator,
95
+ }));
96
+ }
97
+ return kernels.get(key);
217
98
  }
218
99
 
219
- /**
220
- * Process one envelope through the §6.3 pipeline.
221
- * @param {object} envelope
222
- * @param {string} vaultRoot - absolute path to the vault
223
- * @param {object} [options] - { agentRole, eventLogDir, leaseStore }
224
- */
225
- function processOperation(envelope, vaultRoot, options = {}) {
226
- const opId = crypto.randomUUID();
227
- const eventLogDir = options.eventLogDir;
228
- const warnings = [];
229
-
230
- // Stage 1 — Envelope validation
231
- if (!envelope || !ENVELOPE_TYPES.includes(envelope.type)) {
232
- return fail(envelope?.type, opId, envelope?.path || '', null,
233
- [`Invalid or missing 'type'. Must be one of: ${ENVELOPE_TYPES.join(', ')}.`],
234
- [{ field: 'type', issue: `Must be one of: ${ENVELOPE_TYPES.join(', ')}.` }]);
235
- }
236
- const missingEnv = ENVELOPE_REQUIRED[envelope.type].filter((f) => envelope[f] === undefined || envelope[f] === null);
237
- if (missingEnv.length) {
238
- return fail(envelope.type, opId, envelope.path || '', null,
239
- missingEnv.map((f) => `Missing required envelope field '${f}' for ${envelope.type}.`),
240
- missingEnv.map((f) => ({ field: f, issue: `Required for envelope type '${envelope.type}'.` })));
241
- }
242
- if (!isSafeIdentifier(envelope.workspace_id)) {
243
- return fail(envelope.type, opId, envelope.path || '', null,
244
- [`Invalid workspace_id '${envelope.workspace_id}'. Use 1-128 characters: letters, numbers, dot, underscore, hyphen.`],
245
- [{ field: 'workspace_id', issue: 'Invalid or unsafe filesystem identifier.' }]);
246
- }
247
- if (!isSafeIdentifier(envelope.idempotency_key)) {
248
- return fail(envelope.type, opId, envelope.path || '', null,
249
- [`Invalid idempotency_key '${envelope.idempotency_key}'. Use 1-128 characters: letters, numbers, dot, underscore, hyphen.`],
250
- [{ field: 'idempotency_key', issue: 'Invalid or unsafe replay identifier.' }]);
251
- }
252
-
253
- let abs;
254
- try { abs = resolveContainedPath(vaultRoot, envelope.path); }
255
- catch (err) { return fail(envelope.type, opId, envelope.path, null, [err.message], [{ field: 'path', issue: err.message }]); }
256
- if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
257
- return fail(envelope.type, opId, envelope.path, null,
258
- [`VFS path '${envelope.path}' resolves to a directory, not a file.`],
259
- [{ field: 'path', issue: 'Must identify a file.' }]);
260
- }
261
-
262
- // Stage 2 — Idempotency
263
- const hash = requestHash(envelope);
264
- warmFromAudit(vaultRoot, eventLogDir);
265
- pruneExpired();
266
- const ck = cacheKey(envelope.workspace_id, envelope.idempotency_key);
267
- const cached = idempotencyCache.get(ck);
268
- if (cached) {
269
- if (cached.request_hash && cached.request_hash !== hash) {
270
- return fail(envelope.type, opId, envelope.path, null,
271
- [`Idempotency conflict: key '${envelope.idempotency_key}' was already committed for a different request payload.`],
272
- [{ field: 'idempotency_key', issue: 'Same key cannot be reused for a different request payload.' }]);
273
- }
274
- return { ...cached.response, replay: cached.response };
275
- }
276
-
277
- // Stage 3 — Authorization (protocol-path hook)
278
- // Granular RBAC is now evaluated in Stage 5.5 once the primitive type is resolved.
279
-
280
- // Stage 4 — Lease check
281
- const store = options.leaseStore || leaseStore;
282
- if (store) {
283
- const lc = checkLease(envelope, store);
284
- if (!lc.ok) return fail(envelope.type, opId, envelope.path, null, [lc.error], [{ field: 'lease_id', issue: lc.error }]);
285
- }
286
-
287
- // Stage 5 — Content validation
288
- let resolvedType = null;
289
- let errors = [];
290
- let repair = [];
291
- let mergedForCommit = null;
292
-
293
- if (envelope.type === 'operation') {
294
- if (envelope.path.endsWith('/index.md')) {
295
- resolvedType = 'index';
296
- } else {
297
- const { data } = parseDocument(envelope.content);
298
- resolvedType = data.type || null;
299
- if (!resolvedType) { errors.push(`Missing required frontmatter field 'type' in ${envelope.path}.`); repair.push({ field: 'type', issue: 'Frontmatter must declare a type.' }); }
300
- else ({ errors, repair } = validateContent(resolvedType, data, envelope.path));
301
- }
302
- if (!errors.length && fs.existsSync(abs) && !envelope.path.endsWith('/index.md')) {
303
- const { data: existingData } = parseDocument(fs.readFileSync(abs, 'utf8'));
304
- if (existingData.type && existingData.type !== resolvedType) {
305
- errors.push(`Type rewrite refused for ${envelope.path}: existing type '${existingData.type}' cannot be replaced by '${resolvedType}'.`);
306
- repair.push({ field: 'type', issue: 'Use a migration controlled by a privileged host; operation writes may not change an existing file type.' });
307
- }
308
- }
309
- // append-type rewrite guard
310
- if (!errors.length && isAppendType(types.get(resolvedType)) && fs.existsSync(abs)) {
311
- const existing = parseDocument(fs.readFileSync(abs, 'utf8')).body.replace(/\s+$/, '');
312
- const next = parseDocument(envelope.content).body.replace(/\s+$/, '');
313
- if (existing && next !== existing && !next.startsWith(existing + '\n')) {
314
- errors.push(`Append-type '${resolvedType}' may not rewrite existing records.`);
315
- repair.push({ field: '__body__', issue: 'Append-only: existing records are immutable.' });
316
- }
317
- }
318
- } else if (envelope.type === 'patch') {
319
- if (!fs.existsSync(abs)) { errors.push(`Target file does not exist for patch: ${envelope.path}.`); repair.push({ field: 'path', issue: 'Patch target not found.' }); }
320
- else {
321
- const { data, body } = parseDocument(fs.readFileSync(abs, 'utf8'));
322
- if (Object.prototype.hasOwnProperty.call(envelope.patches, 'type') && envelope.patches.type !== data.type) {
323
- errors.push(`Patch may not change immutable field 'type' for ${envelope.path}.`);
324
- repair.push({ field: 'type', issue: 'Use an explicit migration rather than patching the primitive discriminator.' });
325
- }
326
- const merged = { ...data };
327
- for (const [k, v] of Object.entries(envelope.patches)) if (k !== '__body__') merged[k] = v;
328
- resolvedType = merged.type || null;
329
- const append = isAppendType(types.get(resolvedType));
330
- let newBody = body;
331
- if (envelope.patches.__body__ !== undefined) {
332
- if (append) newBody = body.replace(/\s+$/, '') + '\n' + envelope.patches.__body__;
333
- else newBody = envelope.patches.__body__;
334
- }
335
- ({ errors, repair } = resolvedType ? validateContent(resolvedType, merged, envelope.path) : { errors: ["Missing required field 'type'."], repair: [{ field: 'type', issue: 'No type after merge.' }] });
336
- mergedForCommit = { data: merged, body: newBody };
337
- }
338
- } else if (envelope.type === 'event') {
339
- try { JSON.parse(envelope.content); }
340
- catch { errors.push('Event content must be valid JSON.'); repair.push({ field: 'content', issue: 'Must be a JSON string.' }); }
341
- } else if (envelope.type === 'delete') {
342
- if (!fs.existsSync(abs)) { errors.push(`Target file does not exist for delete: ${envelope.path}.`); repair.push({ field: 'path', issue: 'Delete target not found.' }); }
343
- else {
344
- const { data } = parseDocument(fs.readFileSync(abs, 'utf8'));
345
- resolvedType = data.type || null;
346
- if (isAppendType(types.get(resolvedType))) { errors.push(`Append-type '${resolvedType}' may not be deleted.`); repair.push({ field: 'type', issue: 'Append-only documents are immutable.' }); }
347
- }
348
- }
349
-
350
- let valid = errors.length === 0;
351
-
352
- // Stage 5.5 — Granular Authorization (Default RBAC)
353
- if (valid) {
354
- // Fail-closed by design: a host that forgets to overwrite `actor` with a
355
- // verified identity (spec §6.3 Stage 3 MUST) gets denied, not admin. Only
356
- // an EXPLICIT actor.role === 'admin' still resolves to full access.
357
- const actorRole = envelope.actor?.role || null;
358
- // `event` envelopes never resolve a document-primitive type (they're a raw
359
- // log entry, not a typed file), so gate them on the `event` contract
360
- // primitive itself rather than the ever-null resolvedType (which would
361
- // otherwise require the unsatisfiable permission 'write:null').
362
- const permType = envelope.type === 'event' ? 'event' : resolvedType;
363
- const requiredPerm = `write:${permType}`;
364
- let allowed = false;
365
- if (actorRole === 'system') {
366
- allowed = true;
367
- } else if (actorRole) {
368
- let perms = [];
369
- if (actorRole === 'admin') perms = ['write:*', 'read:*'];
370
- const safeRole = isSafeIdentifier(actorRole);
371
- if (!safeRole) {
372
- perms = [];
373
- } else {
374
- const roleFile = path.join(vaultRoot, 'roles', actorRole, 'ROLE.md');
375
- if (fs.existsSync(roleFile)) {
376
- try {
377
- const { data } = parseDocument(fs.readFileSync(roleFile, 'utf8'));
378
- if (data.permissions) perms = data.permissions;
379
- } catch {}
380
- }
100
+ return {
101
+ async processOperation(envelope, vaultRoot, callOptions = {}) {
102
+ const principal = options.verifyPrincipal
103
+ ? await options.verifyPrincipal(envelope, callOptions)
104
+ : legacyPrincipal(envelope, vaultRoot, callOptions);
105
+ const response = await bound(vaultRoot, callOptions.eventLogDir).execute(envelope, {
106
+ ...callOptions,
107
+ principal,
108
+ requireLease: legacyLease?.isRequired?.(envelope.workspace_id, envelope.path) || false,
109
+ });
110
+ // Preserve the pre-0.9 façade's unqualified validation type while the
111
+ // canonical kernel and events use qualified identities.
112
+ if (response.validation) {
113
+ if (response.validation.type?.startsWith('ssss:')) response.validation.type = response.validation.type.slice('ssss:'.length);
114
+ else if (!response.validation.type && typeof envelope.content === 'string' && envelope.type === 'operation') {
115
+ try { response.validation.type = parseDocument(envelope.content).data.type || null; } catch {}
381
116
  }
382
- allowed = perms.includes('*:*') || perms.includes('write:*') || perms.includes(`*:${permType}`) || perms.includes(requiredPerm);
383
- }
384
- // else: actorRole is absent — allowed stays false (fail closed).
385
-
386
- if (!allowed) {
387
- errors.push(`Access denied: role '${actorRole || '(none)'}' lacks permission '${requiredPerm}'.`);
388
- repair.push({
389
- field: 'actor.role',
390
- issue: actorRole ? 'Insufficient permissions.' : 'Missing actor.role — the envelope was not authorized by a verified identity.',
391
- });
392
- valid = false;
393
- }
394
- }
395
-
396
- // dry_run: stop after stage 5
397
- if (envelope.dry_run || !valid) {
398
- const resp = {
399
- success: valid, type: envelope.type, operation_id: opId, path: envelope.path,
400
- committed_at: null, dry_run: !!envelope.dry_run,
401
- validation: { valid, type: resolvedType, errors, warnings },
402
- };
403
- if (!valid) resp.repair = { field_errors: repair };
404
- return resp;
405
- }
406
-
407
- // Stage 6 — Commit
408
- const committedAt = new Date().toISOString();
409
- try {
410
- if (envelope.type === 'operation') atomicWrite(abs, envelope.content);
411
- else if (envelope.type === 'patch') atomicWrite(abs, serializeDocument(mergedForCommit.data, mergedForCommit.body));
412
- else if (envelope.type === 'event') {
413
- appendEvent(vaultRoot, eventLogDir, {
414
- event_id: opId, event_type: 'log', ts: committedAt, workspace_id: envelope.workspace_id,
415
- subject: envelope.path, content: envelope.content, idempotency_key: envelope.idempotency_key,
416
- });
417
- } else if (envelope.type === 'delete') {
418
- fs.rmSync(abs, { force: true });
419
- // §6.2: a delete emits an auditable deletion event — history is never lost.
420
- appendEvent(vaultRoot, eventLogDir, {
421
- event_id: opId, event_type: 'delete', ts: committedAt, workspace_id: envelope.workspace_id,
422
- subject: envelope.path, resolved_type: resolvedType, idempotency_key: envelope.idempotency_key,
423
- });
424
117
  }
425
- } catch (err) {
426
- return fail(envelope.type, opId, envelope.path, resolvedType, [`Commit failed: ${err.message}`], [{ field: '(commit)', issue: err.message }]);
427
- }
428
-
429
- // Stage 7 — Audit
430
- try { audit(vaultRoot, eventLogDir, opId, committedAt, envelope, resolvedType, hash); } catch { /* non-fatal */ }
431
-
432
- const response = ok(envelope.type, opId, envelope.path, resolvedType, committedAt, warnings);
433
- idempotencyCache.set(ck, { response, request_hash: hash, ts: Date.now() });
434
- return response;
435
- }
436
-
437
- return { processOperation, resolveContainedPath, _types: types };
438
- }
439
-
440
- function checkLease(envelope, leaseStore) {
441
- if (!isSafeIdentifier(envelope.workspace_id)) return { ok: false, error: `Invalid workspace_id '${envelope.workspace_id}'.` };
442
- const pathKey = crypto.createHash('sha256').update(String(envelope.path)).digest('hex');
443
- const lp = path.join(leaseStore, envelope.workspace_id, `${pathKey}.lease.json`);
444
- if (!fs.existsSync(lp)) return { ok: true };
445
- try {
446
- const lease = JSON.parse(fs.readFileSync(lp, 'utf8'));
447
- if (new Date(lease.expires_at) < new Date()) { fs.rmSync(lp, { force: true }); return { ok: true }; }
448
- if (!envelope.lease_id) return { ok: false, error: `Path '${envelope.path}' is leased (${lease.lease_id}). Supply a matching lease_id.` };
449
- if (envelope.lease_id !== lease.lease_id) return { ok: false, error: `Lease mismatch for '${envelope.path}'.` };
450
- return { ok: true };
451
- } catch { return { ok: false, error: `Lease state for '${envelope.path}' is unreadable; refusing to fail open.` }; }
118
+ return response;
119
+ },
120
+ resolveContainedPath,
121
+ _types: registrySet.types,
122
+ _registry: registrySet,
123
+ };
452
124
  }
package/src/events.mjs ADDED
@@ -0,0 +1,90 @@
1
+ /** Canonical immutable SSSS event envelope and stores. */
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+
6
+ export function createCanonicalEvent(input, options = {}) {
7
+ const required = ['workspace_id', 'action', 'subject', 'operation_id', 'idempotency_key'];
8
+ for (const field of required) if (typeof input?.[field] !== 'string' || !input[field]) throw new Error(`Event ${field} is required.`);
9
+ if (!input.principal?.id || !input.principal?.kind) throw new Error('Event verified principal is required.');
10
+ return {
11
+ event_id: input.event_id || crypto.randomUUID(),
12
+ event_type: input.event_type || 'ssss.mutation',
13
+ schema_version: 1,
14
+ timestamp: input.timestamp || (options.clock ? options.clock() : new Date().toISOString()),
15
+ workspace_id: input.workspace_id,
16
+ primitive_id: input.primitive_id || null,
17
+ primitive_version: input.primitive_version || 1,
18
+ action: input.action,
19
+ subject: input.subject,
20
+ principal: input.principal,
21
+ correlation_id: input.correlation_id || input.operation_id,
22
+ causation_id: input.causation_id || null,
23
+ operation_id: input.operation_id,
24
+ idempotency_key: input.idempotency_key,
25
+ request_hash: input.request_hash || null,
26
+ before_hash: input.before_hash || null,
27
+ after_hash: input.after_hash || null,
28
+ changed_fields: input.changed_fields || [],
29
+ resource_status: input.resource_status || null,
30
+ payload: input.payload || null,
31
+ };
32
+ }
33
+
34
+ export class MemoryEventStore {
35
+ #events = [];
36
+ async append(event) {
37
+ if (this.#events.some((existing) => existing.event_id === event.event_id)) {
38
+ throw new Error(`Duplicate event_id '${event.event_id}'.`);
39
+ }
40
+ const frozen = structuredClone(event);
41
+ this.#events.push(frozen);
42
+ return structuredClone(frozen);
43
+ }
44
+ async *replay(options = {}) {
45
+ for (let index = options.cursor || 0; index < this.#events.length; index++) {
46
+ const event = this.#events[index];
47
+ if (options.workspaceId && event.workspace_id !== options.workspaceId) continue;
48
+ yield { cursor: index + 1, event: structuredClone(event) };
49
+ }
50
+ }
51
+ async size() { return this.#events.length; }
52
+ }
53
+
54
+ export class JsonlEventStore {
55
+ constructor(root) {
56
+ fs.mkdirSync(root, { recursive: true });
57
+ this.root = fs.realpathSync(root);
58
+ }
59
+ #file(workspaceId) {
60
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(workspaceId || '')) throw new Error('Unsafe event workspace_id.');
61
+ return path.join(this.root, `${workspaceId}.jsonl`);
62
+ }
63
+ async append(event) {
64
+ const file = this.#file(event.workspace_id);
65
+ if (fs.existsSync(file) && fs.lstatSync(file).isSymbolicLink()) throw new Error('Symlinked event logs are forbidden.');
66
+ if (fs.existsSync(file)) {
67
+ for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
68
+ if (line.trim() && JSON.parse(line).event_id === event.event_id) throw new Error(`Duplicate event_id '${event.event_id}'.`);
69
+ }
70
+ }
71
+ fs.appendFileSync(file, `${JSON.stringify(event)}\n`, { mode: 0o600 });
72
+ return structuredClone(event);
73
+ }
74
+ async *replay(options = {}) {
75
+ const files = options.workspaceId
76
+ ? [this.#file(options.workspaceId)]
77
+ : fs.readdirSync(this.root).filter((name) => name.endsWith('.jsonl')).sort().map((name) => path.join(this.root, name));
78
+ let cursor = 0;
79
+ for (const file of files) {
80
+ if (!fs.existsSync(file)) continue;
81
+ if (fs.lstatSync(file).isSymbolicLink()) throw new Error('Symlinked event logs are forbidden.');
82
+ for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
83
+ if (!line.trim()) continue;
84
+ cursor++;
85
+ if (cursor <= (options.cursor || 0)) continue;
86
+ yield { cursor, event: JSON.parse(line) };
87
+ }
88
+ }
89
+ }
90
+ }