@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.
- package/CHANGELOG.md +42 -4
- package/README.md +19 -4
- package/VERSION +1 -1
- package/conformance/README.md +11 -7
- package/conformance/fixtures.json +24 -2
- package/conformance/reference-bundle.ucw.json +2 -2
- package/docs/help/bundle.md +6 -2
- package/docs/help/import.md +2 -0
- package/docs/help/overview.md +8 -4
- package/docs/help/semantic.md +38 -0
- package/docs/ssss-spec.md +67 -7
- package/package.json +27 -3
- package/registry/core.json +36 -3
- package/scripts/audit-registry-field-usage.mjs +10 -1
- package/scripts/build-reference-bundle.mjs +4 -1
- package/scripts/cmd-adapter.mjs +29 -0
- package/scripts/cmd-export.mjs +3 -3
- package/scripts/cmd-import.mjs +1 -1
- package/scripts/cmd-migrate.mjs +26 -0
- package/scripts/cmd-new.mjs +5 -5
- package/scripts/cmd-primitive.mjs +48 -0
- package/scripts/cmd-registry.mjs +25 -0
- package/scripts/cmd-semantic.mjs +86 -0
- package/scripts/conformance-09.mjs +199 -0
- package/scripts/conformance.mjs +320 -28
- package/scripts/ssss.mjs +10 -0
- package/skills/ssss/SKILL.md +221 -0
- package/skills/ssss/evals/evals.json +37 -0
- package/skills/ssss/references/contract-reference.md +95 -0
- package/skills/ssss/scripts/validate-schema.mjs +215 -0
- package/skills/ssss/subagents/audit-ssss-contract.md +22 -0
- package/src/authorization.mjs +61 -0
- package/src/bundle.mjs +171 -35
- package/src/engine.mjs +101 -429
- package/src/events.mjs +90 -0
- package/src/guard.mjs +32 -0
- package/src/http.mjs +26 -0
- package/src/idempotency.mjs +55 -0
- package/src/kernel.mjs +260 -0
- package/src/leases.mjs +125 -0
- package/src/primitive.mjs +227 -0
- package/src/projections.mjs +62 -0
- package/src/registry.mjs +408 -17
- package/src/semantic.mjs +210 -0
- package/src/ui.mjs +100 -0
- package/src/validator.mjs +87 -0
- package/src/vfs.mjs +203 -0
- package/types/kernel.d.ts +48 -0
package/src/engine.mjs
CHANGED
|
@@ -1,452 +1,124 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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 {
|
|
18
|
-
import {
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
+
}
|