@gregiteen/ssss-cli 0.8.0 → 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/README.md +11 -12
- package/VERSION +1 -1
- package/conformance/README.md +1 -1
- package/conformance/fixtures.json +4 -48
- package/conformance/reference-bundle.ucw.json +4 -21
- package/docs/help/bundle.md +2 -2
- package/docs/help/overview.md +4 -4
- package/docs/help/semantic.md +5 -4
- package/docs/ssss-spec.md +32 -77
- package/package.json +22 -3
- package/registry/core.json +21 -28
- package/scripts/build-reference-bundle.mjs +0 -20
- package/scripts/cmd-adapter.mjs +29 -0
- 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 +6 -8
- package/scripts/conformance-09.mjs +199 -0
- package/scripts/conformance.mjs +106 -186
- package/scripts/ssss.mjs +8 -2
- package/skills/ssss/SKILL.md +11 -15
- package/skills/ssss/evals/evals.json +3 -3
- package/skills/ssss/references/contract-reference.md +7 -8
- package/skills/ssss/scripts/validate-schema.mjs +1 -44
- package/src/authorization.mjs +61 -0
- package/src/bundle.mjs +4 -4
- package/src/engine.mjs +101 -503
- 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 +190 -39
- package/src/semantic.mjs +124 -316
- 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/docs/help/localization.md +0 -43
- package/scripts/cmd-localize.mjs +0 -44
package/src/engine.mjs
CHANGED
|
@@ -1,526 +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 {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
} from './
|
|
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';
|
|
23
15
|
|
|
24
|
-
const ENVELOPE_TYPES = ['operation', 'patch', 'event', 'delete'];
|
|
25
|
-
const ENVELOPE_REQUIRED = {
|
|
26
|
-
operation: ['type', 'idempotency_key', 'path', 'workspace_id', 'content'],
|
|
27
|
-
patch: ['type', 'idempotency_key', 'path', 'workspace_id', 'patches'],
|
|
28
|
-
event: ['type', 'idempotency_key', 'path', 'workspace_id', 'content'],
|
|
29
|
-
delete: ['type', 'idempotency_key', 'path', 'workspace_id'],
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
const IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000;
|
|
33
|
-
const SAFE_IDENTIFIER_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
34
|
-
|
|
35
|
-
function cacheKey(wid, key) { return JSON.stringify([wid, key]); }
|
|
36
|
-
|
|
37
|
-
function isSafeIdentifier(value) {
|
|
38
|
-
return typeof value === 'string' && SAFE_IDENTIFIER_RE.test(value);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function stableStringify(value) {
|
|
42
|
-
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
43
|
-
if (Array.isArray(value)) return `[${value.map((v) => stableStringify(v)).join(',')}]`;
|
|
44
|
-
return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function requestHash(envelope) {
|
|
48
|
-
const payload = {
|
|
49
|
-
type: envelope.type,
|
|
50
|
-
path: envelope.path,
|
|
51
|
-
workspace_id: envelope.workspace_id,
|
|
52
|
-
actor: envelope.actor || null,
|
|
53
|
-
lease_id: envelope.lease_id || null,
|
|
54
|
-
dry_run: !!envelope.dry_run,
|
|
55
|
-
};
|
|
56
|
-
if (Object.prototype.hasOwnProperty.call(envelope, 'content')) payload.content = envelope.content;
|
|
57
|
-
if (Object.prototype.hasOwnProperty.call(envelope, 'patches')) payload.patches = envelope.patches;
|
|
58
|
-
return 'sha256:' + crypto.createHash('sha256').update(stableStringify(payload)).digest('hex');
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** festech `resolveContainedPath`: resolve a VFS path, refusing escape from the vault. */
|
|
62
16
|
export function resolveContainedPath(vaultRoot, vfsPath) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (!raw || raw === '.' || raw.includes('\0')) throw new Error(`Invalid VFS path: ${vfsPath}`);
|
|
66
|
-
if (raw.startsWith('/') || raw.startsWith('\\') || raw.includes('\\')) throw new Error(`Absolute or platform-native paths are not valid VFS paths: ${vfsPath}`);
|
|
67
|
-
const parts = raw.split('/');
|
|
68
|
-
if (parts.some((p) => p === '' || p === '.' || p === '..')) throw new Error(`Invalid VFS path segment in: ${vfsPath}`);
|
|
69
|
-
const cleaned = parts.join('/');
|
|
70
|
-
const resolved = path.resolve(root, cleaned);
|
|
71
|
-
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
|
|
72
|
-
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}'.`);
|
|
73
19
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
81
|
-
const tmp = path.join(dir, `.${path.basename(absPath)}.${crypto.randomUUID()}.tmp`);
|
|
82
|
-
fs.writeFileSync(tmp, content);
|
|
83
|
-
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;
|
|
84
26
|
}
|
|
85
27
|
|
|
86
|
-
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
|
+
}
|
|
87
43
|
return {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function fail(type, opId, p, resolvedType, errors, repairEntries) {
|
|
95
|
-
const resp = {
|
|
96
|
-
success: false, type, operation_id: opId, path: p,
|
|
97
|
-
committed_at: null, dry_run: false,
|
|
98
|
-
validation: { valid: false, 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' },
|
|
99
49
|
};
|
|
100
|
-
resp.repair = {
|
|
101
|
-
field_errors: repairEntries || errors.map((e) => ({
|
|
102
|
-
field: e.split(':')[0]?.trim() || '(root)', issue: e,
|
|
103
|
-
})),
|
|
104
|
-
};
|
|
105
|
-
return resp;
|
|
106
50
|
}
|
|
107
51
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const { types, core } = loadRegistries(registryDir);
|
|
114
|
-
const idempotencyCache = new Map();
|
|
115
|
-
|
|
116
|
-
function pruneExpired() {
|
|
117
|
-
const now = Date.now();
|
|
118
|
-
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`);
|
|
119
57
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
try {
|
|
128
|
-
const r = JSON.parse(line);
|
|
129
|
-
if (r.event_type !== 'audit' || !r.payload) continue;
|
|
130
|
-
const ts = Date.parse(r.ts);
|
|
131
|
-
if (isNaN(ts) || now - ts > IDEMPOTENCY_TTL_MS) continue;
|
|
132
|
-
const ck = cacheKey(r.payload.workspace_id, r.payload.idempotency_key);
|
|
133
|
-
if (!idempotencyCache.has(ck)) {
|
|
134
|
-
idempotencyCache.set(ck, {
|
|
135
|
-
ts,
|
|
136
|
-
request_hash: r.payload.request_hash || null,
|
|
137
|
-
response: ok(r.payload.envelope_type, r.correlation_id, r.subject, r.payload.resolved_type, r.ts),
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
} catch { /* skip corrupt line */ }
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function isEmpty(v) {
|
|
145
|
-
return v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** Parse a registry `required_when` condition key, e.g. "schema_version==2". */
|
|
149
|
-
function parseCondition(condition) {
|
|
150
|
-
const m = String(condition).match(/^(\w+)==(.+)$/);
|
|
151
|
-
if (!m) return null;
|
|
152
|
-
const [, field, raw] = m;
|
|
153
|
-
const value = /^-?\d+$/.test(raw) ? parseInt(raw, 10) : raw;
|
|
154
|
-
return { field, value };
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function validateContent(resolvedType, data, filePath, vaultRoot, referenceResolver) {
|
|
158
|
-
const def = types.get(resolvedType);
|
|
159
|
-
const errors = [];
|
|
160
|
-
const repair = [];
|
|
161
|
-
if (!def) {
|
|
162
|
-
errors.push(`Unknown SSSS type '${resolvedType}' in ${filePath}.`);
|
|
163
|
-
repair.push({ field: 'type', issue: `Unknown SSSS type '${resolvedType}'.` });
|
|
164
|
-
return { errors, repair };
|
|
165
|
-
}
|
|
166
|
-
const requiredFields = [
|
|
167
|
-
...new Set([
|
|
168
|
-
...(core.universal_frontmatter?.required || ['type']),
|
|
169
|
-
...(def.required_fields || []),
|
|
170
|
-
]),
|
|
171
|
-
];
|
|
172
|
-
for (const f of requiredFields) {
|
|
173
|
-
if (isEmpty(data[f])) {
|
|
174
|
-
errors.push(`Missing required field '${f}' for type '${resolvedType}' in ${filePath}.`);
|
|
175
|
-
repair.push({ field: f, issue: `Missing required field '${f}' for type '${resolvedType}'.` });
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
// required_when (§9): fields required only when a condition field matches a value.
|
|
179
|
-
for (const [condition, fields] of Object.entries(def.required_when || {})) {
|
|
180
|
-
const cond = parseCondition(condition);
|
|
181
|
-
if (!cond || data[cond.field] !== cond.value) continue;
|
|
182
|
-
for (const f of fields) {
|
|
183
|
-
if (isEmpty(data[f])) {
|
|
184
|
-
errors.push(`Missing required field '${f}' for type '${resolvedType}' when ${condition} in ${filePath}.`);
|
|
185
|
-
repair.push({ field: f, issue: `Required when ${condition}.` });
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
// enums: a present field's value(s) must be drawn from the registry's declared set.
|
|
190
|
-
for (const [field, allowedValues] of Object.entries(def.enums || {})) {
|
|
191
|
-
if (isEmpty(data[field])) continue;
|
|
192
|
-
const values = Array.isArray(data[field]) ? data[field] : [data[field]];
|
|
193
|
-
for (const v of values) {
|
|
194
|
-
if (!allowedValues.includes(v)) {
|
|
195
|
-
errors.push(`Invalid value '${v}' for field '${field}' on type '${resolvedType}'; must be one of: ${allowedValues.join(', ')}.`);
|
|
196
|
-
repair.push({ field, issue: `Must be one of: ${allowedValues.join(', ')}.` });
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
// patterns: portable string constraints declared by the registry. These
|
|
201
|
-
// are anchored by each pattern author and compiled during registry load.
|
|
202
|
-
for (const [field, pattern] of Object.entries(def.patterns || {})) {
|
|
203
|
-
if (isEmpty(data[field])) continue;
|
|
204
|
-
const values = Array.isArray(data[field]) ? data[field] : [data[field]];
|
|
205
|
-
const expression = new RegExp(pattern, 'u');
|
|
206
|
-
for (const value of values) {
|
|
207
|
-
if (typeof value !== 'string' || !expression.test(value)) {
|
|
208
|
-
errors.push(`Invalid format for field '${field}' on type '${resolvedType}'.`);
|
|
209
|
-
repair.push({ field, issue: `Must match registry pattern '${pattern}'.` });
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
if (vaultRoot && def.references) {
|
|
214
|
-
const referenceIssues = validateReferenceConstraints(
|
|
215
|
-
def,
|
|
216
|
-
data,
|
|
217
|
-
(referencePath) => {
|
|
218
|
-
if (referenceResolver) {
|
|
219
|
-
const planned = referenceResolver(referencePath);
|
|
220
|
-
if (planned !== undefined && planned !== null) return planned;
|
|
221
|
-
}
|
|
222
|
-
const target = resolveContainedPath(vaultRoot, referencePath);
|
|
223
|
-
if (!fs.existsSync(target)) return null;
|
|
224
|
-
const stat = fs.lstatSync(target);
|
|
225
|
-
if (stat.isSymbolicLink()) throw new Error('symlinked references are not allowed');
|
|
226
|
-
if (!stat.isFile()) throw new Error('reference target is not a file');
|
|
227
|
-
const content = fs.readFileSync(target, 'utf8');
|
|
228
|
-
return { path: referencePath, content, data: parseDocument(content).data };
|
|
229
|
-
},
|
|
230
|
-
types
|
|
231
|
-
);
|
|
232
|
-
for (const issue of referenceIssues) {
|
|
233
|
-
errors.push(`${issue.field}: ${issue.issue}`);
|
|
234
|
-
repair.push(issue);
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
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; }
|
|
238
65
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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.' }; }
|
|
244
76
|
}
|
|
77
|
+
}
|
|
245
78
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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);
|
|
258
98
|
}
|
|
259
99
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
}
|
|
277
|
-
const missingEnv = ENVELOPE_REQUIRED[envelope.type].filter((f) => envelope[f] === undefined || envelope[f] === null);
|
|
278
|
-
if (missingEnv.length) {
|
|
279
|
-
return fail(envelope.type, opId, envelope.path || '', null,
|
|
280
|
-
missingEnv.map((f) => `Missing required envelope field '${f}' for ${envelope.type}.`),
|
|
281
|
-
missingEnv.map((f) => ({ field: f, issue: `Required for envelope type '${envelope.type}'.` })));
|
|
282
|
-
}
|
|
283
|
-
if (!isSafeIdentifier(envelope.workspace_id)) {
|
|
284
|
-
return fail(envelope.type, opId, envelope.path || '', null,
|
|
285
|
-
[`Invalid workspace_id '${envelope.workspace_id}'. Use 1-128 characters: letters, numbers, dot, underscore, hyphen.`],
|
|
286
|
-
[{ field: 'workspace_id', issue: 'Invalid or unsafe filesystem identifier.' }]);
|
|
287
|
-
}
|
|
288
|
-
if (!isSafeIdentifier(envelope.idempotency_key)) {
|
|
289
|
-
return fail(envelope.type, opId, envelope.path || '', null,
|
|
290
|
-
[`Invalid idempotency_key '${envelope.idempotency_key}'. Use 1-128 characters: letters, numbers, dot, underscore, hyphen.`],
|
|
291
|
-
[{ field: 'idempotency_key', issue: 'Invalid or unsafe replay identifier.' }]);
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
let abs;
|
|
295
|
-
try { abs = resolveContainedPath(vaultRoot, envelope.path); }
|
|
296
|
-
catch (err) { return fail(envelope.type, opId, envelope.path, null, [err.message], [{ field: 'path', issue: err.message }]); }
|
|
297
|
-
if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
|
|
298
|
-
return fail(envelope.type, opId, envelope.path, null,
|
|
299
|
-
[`VFS path '${envelope.path}' resolves to a directory, not a file.`],
|
|
300
|
-
[{ field: 'path', issue: 'Must identify a file.' }]);
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// Stage 2 — Idempotency
|
|
304
|
-
const hash = requestHash(envelope);
|
|
305
|
-
warmFromAudit(vaultRoot, eventLogDir);
|
|
306
|
-
pruneExpired();
|
|
307
|
-
const ck = cacheKey(envelope.workspace_id, envelope.idempotency_key);
|
|
308
|
-
const cached = idempotencyCache.get(ck);
|
|
309
|
-
if (cached) {
|
|
310
|
-
if (cached.request_hash && cached.request_hash !== hash) {
|
|
311
|
-
return fail(envelope.type, opId, envelope.path, null,
|
|
312
|
-
[`Idempotency conflict: key '${envelope.idempotency_key}' was already committed for a different request payload.`],
|
|
313
|
-
[{ field: 'idempotency_key', issue: 'Same key cannot be reused for a different request payload.' }]);
|
|
314
|
-
}
|
|
315
|
-
return { ...cached.response, replay: cached.response };
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
// Stage 3 — Authorization (protocol-path hook)
|
|
319
|
-
// Granular RBAC is now evaluated in Stage 5.5 once the primitive type is resolved.
|
|
320
|
-
|
|
321
|
-
// Stage 4 — Lease check
|
|
322
|
-
const store = options.leaseStore || leaseStore;
|
|
323
|
-
if (store) {
|
|
324
|
-
const lc = checkLease(envelope, store);
|
|
325
|
-
if (!lc.ok) return fail(envelope.type, opId, envelope.path, null, [lc.error], [{ field: 'lease_id', issue: lc.error }]);
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
// Stage 5 — Content validation
|
|
329
|
-
let resolvedType = null;
|
|
330
|
-
let errors = [];
|
|
331
|
-
let repair = [];
|
|
332
|
-
let mergedForCommit = null;
|
|
333
|
-
|
|
334
|
-
if (envelope.type === 'operation') {
|
|
335
|
-
if (envelope.path.endsWith('/index.md')) {
|
|
336
|
-
resolvedType = 'index';
|
|
337
|
-
} else {
|
|
338
|
-
const { data } = parseDocument(envelope.content);
|
|
339
|
-
resolvedType = data.type || null;
|
|
340
|
-
if (!resolvedType) { errors.push(`Missing required frontmatter field 'type' in ${envelope.path}.`); repair.push({ field: 'type', issue: 'Frontmatter must declare a type.' }); }
|
|
341
|
-
else ({ errors, repair } = validateContent(
|
|
342
|
-
resolvedType,
|
|
343
|
-
data,
|
|
344
|
-
envelope.path,
|
|
345
|
-
vaultRoot,
|
|
346
|
-
options.referenceResolver
|
|
347
|
-
));
|
|
348
|
-
}
|
|
349
|
-
if (!errors.length && fs.existsSync(abs) && !envelope.path.endsWith('/index.md')) {
|
|
350
|
-
const { data: existingData } = parseDocument(fs.readFileSync(abs, 'utf8'));
|
|
351
|
-
if (existingData.type && existingData.type !== resolvedType) {
|
|
352
|
-
errors.push(`Type rewrite refused for ${envelope.path}: existing type '${existingData.type}' cannot be replaced by '${resolvedType}'.`);
|
|
353
|
-
repair.push({ field: 'type', issue: 'Use a migration controlled by a privileged host; operation writes may not change an existing file type.' });
|
|
354
|
-
}
|
|
355
|
-
const { data: nextData } = parseDocument(envelope.content);
|
|
356
|
-
for (const field of types.get(resolvedType)?.immutable_fields || []) {
|
|
357
|
-
if (stableStringify(existingData[field]) !== stableStringify(nextData[field])) {
|
|
358
|
-
errors.push(`Operation may not change immutable field '${field}' for ${envelope.path}.`);
|
|
359
|
-
repair.push({ field, issue: 'Use an explicit migration rather than replacing an immutable identity field.' });
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
// append-type rewrite guard
|
|
364
|
-
if (!errors.length && isAppendType(types.get(resolvedType)) && fs.existsSync(abs)) {
|
|
365
|
-
const existing = parseDocument(fs.readFileSync(abs, 'utf8')).body.replace(/\s+$/, '');
|
|
366
|
-
const next = parseDocument(envelope.content).body.replace(/\s+$/, '');
|
|
367
|
-
if (existing && next !== existing && !next.startsWith(existing + '\n')) {
|
|
368
|
-
errors.push(`Append-type '${resolvedType}' may not rewrite existing records.`);
|
|
369
|
-
repair.push({ field: '__body__', issue: 'Append-only: existing records are immutable.' });
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
} else if (envelope.type === 'patch') {
|
|
373
|
-
if (!fs.existsSync(abs)) { errors.push(`Target file does not exist for patch: ${envelope.path}.`); repair.push({ field: 'path', issue: 'Patch target not found.' }); }
|
|
374
|
-
else {
|
|
375
|
-
const { data, body } = parseDocument(fs.readFileSync(abs, 'utf8'));
|
|
376
|
-
if (Object.prototype.hasOwnProperty.call(envelope.patches, 'type') && envelope.patches.type !== data.type) {
|
|
377
|
-
errors.push(`Patch may not change immutable field 'type' for ${envelope.path}.`);
|
|
378
|
-
repair.push({ field: 'type', issue: 'Use an explicit migration rather than patching the primitive discriminator.' });
|
|
379
|
-
}
|
|
380
|
-
const merged = { ...data };
|
|
381
|
-
for (const [k, v] of Object.entries(envelope.patches)) if (k !== '__body__') merged[k] = v;
|
|
382
|
-
resolvedType = merged.type || null;
|
|
383
|
-
for (const field of types.get(resolvedType)?.immutable_fields || []) {
|
|
384
|
-
if (Object.prototype.hasOwnProperty.call(envelope.patches, field) &&
|
|
385
|
-
stableStringify(data[field]) !== stableStringify(merged[field])) {
|
|
386
|
-
errors.push(`Patch may not change immutable field '${field}' for ${envelope.path}.`);
|
|
387
|
-
repair.push({ field, issue: 'Use an explicit migration rather than patching an immutable identity field.' });
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
const append = isAppendType(types.get(resolvedType));
|
|
391
|
-
let newBody = body;
|
|
392
|
-
if (envelope.patches.__body__ !== undefined) {
|
|
393
|
-
if (append) newBody = body.replace(/\s+$/, '') + '\n' + envelope.patches.__body__;
|
|
394
|
-
else newBody = envelope.patches.__body__;
|
|
395
|
-
}
|
|
396
|
-
if (resolvedType) {
|
|
397
|
-
const contentValidation = validateContent(
|
|
398
|
-
resolvedType,
|
|
399
|
-
merged,
|
|
400
|
-
envelope.path,
|
|
401
|
-
vaultRoot,
|
|
402
|
-
options.referenceResolver
|
|
403
|
-
);
|
|
404
|
-
errors.push(...contentValidation.errors);
|
|
405
|
-
repair.push(...contentValidation.repair);
|
|
406
|
-
} else {
|
|
407
|
-
errors.push("Missing required field 'type'.");
|
|
408
|
-
repair.push({ field: 'type', issue: 'No type after merge.' });
|
|
409
|
-
}
|
|
410
|
-
mergedForCommit = { data: merged, body: newBody };
|
|
411
|
-
}
|
|
412
|
-
} else if (envelope.type === 'event') {
|
|
413
|
-
try { JSON.parse(envelope.content); }
|
|
414
|
-
catch { errors.push('Event content must be valid JSON.'); repair.push({ field: 'content', issue: 'Must be a JSON string.' }); }
|
|
415
|
-
} else if (envelope.type === 'delete') {
|
|
416
|
-
if (!fs.existsSync(abs)) { errors.push(`Target file does not exist for delete: ${envelope.path}.`); repair.push({ field: 'path', issue: 'Delete target not found.' }); }
|
|
417
|
-
else {
|
|
418
|
-
const { data } = parseDocument(fs.readFileSync(abs, 'utf8'));
|
|
419
|
-
resolvedType = data.type || null;
|
|
420
|
-
if (isAppendType(types.get(resolvedType))) { errors.push(`Append-type '${resolvedType}' may not be deleted.`); repair.push({ field: 'type', issue: 'Append-only documents are immutable.' }); }
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
let valid = errors.length === 0;
|
|
425
|
-
|
|
426
|
-
// Stage 5.5 — Granular Authorization (Default RBAC)
|
|
427
|
-
if (valid) {
|
|
428
|
-
// Fail-closed by design: a host that forgets to overwrite `actor` with a
|
|
429
|
-
// verified identity (spec §6.3 Stage 3 MUST) gets denied, not admin. Only
|
|
430
|
-
// an EXPLICIT actor.role === 'admin' still resolves to full access.
|
|
431
|
-
const actorRole = envelope.actor?.role || null;
|
|
432
|
-
// `event` envelopes never resolve a document-primitive type (they're a raw
|
|
433
|
-
// log entry, not a typed file), so gate them on the `event` contract
|
|
434
|
-
// primitive itself rather than the ever-null resolvedType (which would
|
|
435
|
-
// otherwise require the unsatisfiable permission 'write:null').
|
|
436
|
-
const permType = envelope.type === 'event' ? 'event' : resolvedType;
|
|
437
|
-
const requiredPerm = `write:${permType}`;
|
|
438
|
-
let allowed = false;
|
|
439
|
-
if (actorRole === 'system') {
|
|
440
|
-
allowed = true;
|
|
441
|
-
} else if (actorRole) {
|
|
442
|
-
let perms = [];
|
|
443
|
-
if (actorRole === 'admin') perms = ['write:*', 'read:*'];
|
|
444
|
-
const safeRole = isSafeIdentifier(actorRole);
|
|
445
|
-
if (!safeRole) {
|
|
446
|
-
perms = [];
|
|
447
|
-
} else {
|
|
448
|
-
const roleFile = path.join(vaultRoot, 'roles', actorRole, 'ROLE.md');
|
|
449
|
-
if (fs.existsSync(roleFile)) {
|
|
450
|
-
try {
|
|
451
|
-
const { data } = parseDocument(fs.readFileSync(roleFile, 'utf8'));
|
|
452
|
-
if (data.permissions) perms = data.permissions;
|
|
453
|
-
} catch {}
|
|
454
|
-
}
|
|
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 {}
|
|
455
116
|
}
|
|
456
|
-
allowed = perms.includes('*:*') || perms.includes('write:*') || perms.includes(`*:${permType}`) || perms.includes(requiredPerm);
|
|
457
|
-
}
|
|
458
|
-
// else: actorRole is absent — allowed stays false (fail closed).
|
|
459
|
-
|
|
460
|
-
if (!allowed) {
|
|
461
|
-
errors.push(`Access denied: role '${actorRole || '(none)'}' lacks permission '${requiredPerm}'.`);
|
|
462
|
-
repair.push({
|
|
463
|
-
field: 'actor.role',
|
|
464
|
-
issue: actorRole ? 'Insufficient permissions.' : 'Missing actor.role — the envelope was not authorized by a verified identity.',
|
|
465
|
-
});
|
|
466
|
-
valid = false;
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
// dry_run: stop after stage 5
|
|
471
|
-
if (envelope.dry_run || !valid) {
|
|
472
|
-
const resp = {
|
|
473
|
-
success: valid, type: envelope.type, operation_id: opId, path: envelope.path,
|
|
474
|
-
committed_at: null, dry_run: !!envelope.dry_run,
|
|
475
|
-
validation: { valid, type: resolvedType, errors, warnings },
|
|
476
|
-
};
|
|
477
|
-
if (!valid) resp.repair = { field_errors: repair };
|
|
478
|
-
return resp;
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
// Stage 6 — Commit
|
|
482
|
-
const committedAt = new Date().toISOString();
|
|
483
|
-
try {
|
|
484
|
-
if (envelope.type === 'operation') atomicWrite(abs, envelope.content);
|
|
485
|
-
else if (envelope.type === 'patch') atomicWrite(abs, serializeDocument(mergedForCommit.data, mergedForCommit.body));
|
|
486
|
-
else if (envelope.type === 'event') {
|
|
487
|
-
appendEvent(vaultRoot, eventLogDir, {
|
|
488
|
-
event_id: opId, event_type: 'log', ts: committedAt, workspace_id: envelope.workspace_id,
|
|
489
|
-
subject: envelope.path, content: envelope.content, idempotency_key: envelope.idempotency_key,
|
|
490
|
-
});
|
|
491
|
-
} else if (envelope.type === 'delete') {
|
|
492
|
-
fs.rmSync(abs, { force: true });
|
|
493
|
-
// §6.2: a delete emits an auditable deletion event — history is never lost.
|
|
494
|
-
appendEvent(vaultRoot, eventLogDir, {
|
|
495
|
-
event_id: opId, event_type: 'delete', ts: committedAt, workspace_id: envelope.workspace_id,
|
|
496
|
-
subject: envelope.path, resolved_type: resolvedType, idempotency_key: envelope.idempotency_key,
|
|
497
|
-
});
|
|
498
117
|
}
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
const response = ok(envelope.type, opId, envelope.path, resolvedType, committedAt, warnings);
|
|
507
|
-
idempotencyCache.set(ck, { response, request_hash: hash, ts: Date.now() });
|
|
508
|
-
return response;
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
return { processOperation, resolveContainedPath, _types: types };
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
function checkLease(envelope, leaseStore) {
|
|
515
|
-
if (!isSafeIdentifier(envelope.workspace_id)) return { ok: false, error: `Invalid workspace_id '${envelope.workspace_id}'.` };
|
|
516
|
-
const pathKey = crypto.createHash('sha256').update(String(envelope.path)).digest('hex');
|
|
517
|
-
const lp = path.join(leaseStore, envelope.workspace_id, `${pathKey}.lease.json`);
|
|
518
|
-
if (!fs.existsSync(lp)) return { ok: true };
|
|
519
|
-
try {
|
|
520
|
-
const lease = JSON.parse(fs.readFileSync(lp, 'utf8'));
|
|
521
|
-
if (new Date(lease.expires_at) < new Date()) { fs.rmSync(lp, { force: true }); return { ok: true }; }
|
|
522
|
-
if (!envelope.lease_id) return { ok: false, error: `Path '${envelope.path}' is leased (${lease.lease_id}). Supply a matching lease_id.` };
|
|
523
|
-
if (envelope.lease_id !== lease.lease_id) return { ok: false, error: `Lease mismatch for '${envelope.path}'.` };
|
|
524
|
-
return { ok: true };
|
|
525
|
-
} 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
|
+
};
|
|
526
124
|
}
|