@gregiteen/ssss-cli 0.9.3 → 0.9.5

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/src/kernel.mjs CHANGED
@@ -5,8 +5,8 @@
5
5
  import crypto from 'node:crypto';
6
6
  import { parseDocument, serializeDocument } from './frontmatter.mjs';
7
7
  import { createValidator } from './validator.mjs';
8
- import { isAppendType, resolvePrimitiveDefinition } from './registry.mjs';
9
- import { createCapabilityAuthorizer } from './authorization.mjs';
8
+ import { isAppendType, isSafeDocumentPath, resolvePrimitiveDefinition } from './registry.mjs';
9
+ import { createCapabilityAuthorizer, validateVerifiedPrincipal } from './authorization.mjs';
10
10
  import { createCanonicalEvent, idempotentEventId } from './events.mjs';
11
11
  import { MemoryIdempotencyStore } from './idempotency.mjs';
12
12
 
@@ -14,6 +14,17 @@ export { MemoryIdempotencyStore } from './idempotency.mjs';
14
14
 
15
15
  const COMMAND_TYPES = new Set(['operation', 'patch', 'event', 'delete']);
16
16
 
17
+ /**
18
+ * Symbolic failure codes (spec §6.5). Every failed response carries exactly one
19
+ * in `error.code`; transports map codes to status without parsing messages.
20
+ */
21
+ export const ERROR_CODES = Object.freeze([
22
+ 'invalid_request', 'unauthorized', 'forbidden', 'not_found', 'lease_conflict',
23
+ 'version_conflict', 'idempotency_conflict', 'validation_failed', 'internal_error',
24
+ ]);
25
+
26
+ function isVfsError(error, name, code) { return error?.name === name || error?.code === code; }
27
+
17
28
  function stable(value) {
18
29
  if (Array.isArray(value)) return value.map(stable);
19
30
  if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])]));
@@ -34,9 +45,10 @@ export function canonicalRequestHash(envelope, principal) {
34
45
  return `sha256:${crypto.createHash('sha256').update(JSON.stringify(stable(clean))).digest('hex')}`;
35
46
  }
36
47
 
37
- function failure(envelope, operationId, errors, fieldErrors = []) {
48
+ function failure(code, envelope, operationId, errors, fieldErrors = []) {
38
49
  return {
39
50
  success: false,
51
+ error: { code, message: errors[0] || code },
40
52
  type: envelope?.type || null,
41
53
  operation_id: operationId,
42
54
  path: envelope?.path || '',
@@ -70,6 +82,29 @@ export function createKernel(options = {}) {
70
82
  const validator = options.validator || createValidator(options);
71
83
  const registrySet = validator.registry;
72
84
 
85
+ // Registry `references` are enforced on every write (spec §9). A host may
86
+ // supply its own resolver; otherwise targets are read through the VFS.
87
+ async function referenceResolver(definition, data, context) {
88
+ if (context.resolveReference) return context.resolveReference;
89
+ const fields = Object.keys(definition?.references || {});
90
+ if (!fields.length || !data) return undefined;
91
+ const found = new Map();
92
+ for (const field of fields) {
93
+ const target = data[field];
94
+ if (!isSafeDocumentPath(target) || found.has(target)) continue;
95
+ let entry = null;
96
+ try {
97
+ const file = await vfs.read(target);
98
+ if (file) {
99
+ const content = file.bytes.toString('utf8');
100
+ entry = { content, data: parseDocument(content).data };
101
+ }
102
+ } catch {}
103
+ found.set(target, entry);
104
+ }
105
+ return (target) => found.get(target) || null;
106
+ }
107
+
73
108
  async function execute(rawEnvelope, context = {}) {
74
109
  const envelope = { ...rawEnvelope };
75
110
  delete envelope.actor;
@@ -77,18 +112,24 @@ export function createKernel(options = {}) {
77
112
  const principal = context.principal;
78
113
 
79
114
  if (!COMMAND_TYPES.has(envelope.type)) {
80
- return failure(envelope, operationId, [`Invalid command type '${envelope.type}'.`], [{ field: 'type', issue: 'Use operation, patch, event, or delete.' }]);
115
+ return failure('invalid_request', envelope, operationId, [`Invalid command type '${envelope.type}'.`], [{ field: 'type', issue: 'Use operation, patch, event, or delete.' }]);
81
116
  }
82
117
  for (const field of ['workspace_id', 'idempotency_key', 'path']) {
83
118
  if (typeof envelope[field] !== 'string' || !envelope[field]) {
84
- return failure(envelope, operationId, [`Missing required field '${field}'.`], [{ field, issue: 'Required.' }]);
119
+ return failure('invalid_request', envelope, operationId, [`Missing required field '${field}'.`], [{ field, issue: 'Required.' }]);
85
120
  }
86
121
  }
87
122
  if (envelope.type === 'operation' && typeof envelope.content !== 'string') {
88
- return failure(envelope, operationId, ['Operation content must be a string.'], [{ field: 'content', issue: 'Required string.' }]);
123
+ return failure('invalid_request', envelope, operationId, ['Operation content must be a string.'], [{ field: 'content', issue: 'Required string.' }]);
89
124
  }
90
125
  if (envelope.type === 'patch' && (!envelope.patches || typeof envelope.patches !== 'object')) {
91
- return failure(envelope, operationId, ['Patch requires a patches object.'], [{ field: 'patches', issue: 'Required object.' }]);
126
+ return failure('invalid_request', envelope, operationId, ['Patch requires a patches object.'], [{ field: 'patches', issue: 'Required object.' }]);
127
+ }
128
+
129
+ // Authenticate before touching storage, so an anonymous caller learns
130
+ // nothing about the vault. The id and kind are what every event records.
131
+ if (!principal || typeof principal.id !== 'string' || !principal.id || typeof principal.kind !== 'string' || !principal.kind) {
132
+ return failure('unauthorized', envelope, operationId, ['Access denied: Verified principal is required.'], [{ field: 'principal', issue: 'Verified principal is required.' }]);
92
133
  }
93
134
 
94
135
  const requestHash = canonicalRequestHash(envelope, principal);
@@ -97,7 +138,7 @@ export function createKernel(options = {}) {
97
138
  const stored = await idempotencyStore.get(envelope.workspace_id, envelope.idempotency_key);
98
139
  if (!stored) return null;
99
140
  if (stored.request_hash !== requestHash) {
100
- return failure(envelope, operationId, ['Idempotency key was already used for a different request.'], [{ field: 'idempotency_key', issue: 'Request hash conflict.' }]);
141
+ return failure('idempotency_conflict', envelope, operationId, ['Idempotency key was already used for a different request.'], [{ field: 'idempotency_key', issue: 'Request hash conflict.' }]);
101
142
  }
102
143
  return { ...stored.response, replay: true };
103
144
  };
@@ -106,7 +147,10 @@ export function createKernel(options = {}) {
106
147
 
107
148
  let current;
108
149
  try { current = await vfs.read(envelope.path); }
109
- catch (error) { return failure(envelope, operationId, [error.message], [{ field: 'path', issue: error.message }]); }
150
+ catch (error) {
151
+ const code = isVfsError(error, 'VfsPathError', 'SSSS_VFS_PATH') ? 'invalid_request' : 'internal_error';
152
+ return failure(code, envelope, operationId, [error.message], [{ field: 'path', issue: error.message }]);
153
+ }
110
154
 
111
155
  let definition = null;
112
156
  let declaredType = envelope.primitive_type || null;
@@ -116,7 +160,7 @@ export function createKernel(options = {}) {
116
160
 
117
161
  if (current) {
118
162
  try { currentData = parseDocument(current.bytes.toString('utf8')).data; }
119
- catch (error) { return failure(envelope, operationId, [`Existing document is invalid: ${error.message}`]); }
163
+ catch (error) { return failure('internal_error', envelope, operationId, [`Existing document is invalid: ${error.message}`]); }
120
164
  declaredType ||= currentData.type;
121
165
  }
122
166
  if (envelope.type === 'operation') {
@@ -126,27 +170,35 @@ export function createKernel(options = {}) {
126
170
  nextContent = envelope.content;
127
171
  nextData = {};
128
172
  } else {
129
- const validation = validator.validateDocument(envelope.content, { resolveReference: context.resolveReference });
130
- if (!validation.valid) return failure(envelope, operationId, validation.errors, validation.field_errors);
173
+ let draft = null;
174
+ try { draft = parseDocument(envelope.content).data; } catch {}
175
+ const draftDefinition = draft && resolvePrimitiveDefinition(registrySet, draft.type);
176
+ const resolveReference = await referenceResolver(draftDefinition, draft, context);
177
+ const validation = validator.validateDocument(envelope.content, { resolveReference });
178
+ if (!validation.valid) return failure('validation_failed', envelope, operationId, validation.errors, validation.field_errors);
131
179
  declaredType = validation.declared_type;
132
180
  definition = resolvePrimitiveDefinition(registrySet, declaredType);
133
181
  nextContent = envelope.content;
134
182
  nextData = validation.data;
135
183
  }
136
184
  if (currentData?.type && currentData.type !== nextData.type) {
137
- return failure(envelope, operationId, ['Operation may not change an existing document type.'], [{ field: 'type', issue: 'Use an explicit migration.' }]);
185
+ return failure('validation_failed', envelope, operationId, ['Operation may not change an existing document type.'], [{ field: 'type', issue: 'Use an explicit migration.' }]);
186
+ }
187
+ // A full replace would rewrite recorded history (spec §5.3).
188
+ if (current && isAppendType(definition)) {
189
+ return failure('validation_failed', envelope, operationId, [`Append-only primitive '${declaredType}' cannot be replaced.`], [{ field: 'content', issue: 'Append with a patch carrying __body__.' }]);
138
190
  }
139
191
  } else if (envelope.type === 'patch') {
140
- if (!current) return failure(envelope, operationId, ['Patch target does not exist.'], [{ field: 'path', issue: 'Target not found.' }]);
192
+ if (!current) return failure('not_found', envelope, operationId, ['Patch target does not exist.'], [{ field: 'path', issue: 'Target not found.' }]);
141
193
  definition = resolvePrimitiveDefinition(registrySet, declaredType);
142
- if (!definition) return failure(envelope, operationId, [`Unknown SSSS primitive '${declaredType}'.`], [{ field: 'type', issue: 'Unknown primitive.' }]);
194
+ if (!definition) return failure('validation_failed', envelope, operationId, [`Unknown SSSS primitive '${declaredType}'.`], [{ field: 'type', issue: 'Unknown primitive.' }]);
143
195
  if (Object.hasOwn(envelope.patches, 'type') && envelope.patches.type !== currentData.type) {
144
- return failure(envelope, operationId, ['Patch may not change document type.'], [{ field: 'type', issue: 'Use an explicit migration.' }]);
196
+ return failure('validation_failed', envelope, operationId, ['Patch may not change document type.'], [{ field: 'type', issue: 'Use an explicit migration.' }]);
145
197
  }
146
198
  nextData = { ...currentData, ...Object.fromEntries(Object.entries(envelope.patches).filter(([key]) => key !== '__body__')) };
147
199
  for (const field of definition.immutable_fields || []) {
148
200
  if (Object.hasOwn(envelope.patches, field) && JSON.stringify(currentData[field]) !== JSON.stringify(nextData[field])) {
149
- return failure(envelope, operationId, [`Patch may not change immutable field '${field}'.`], [{ field, issue: 'Use an explicit migration.' }]);
201
+ return failure('validation_failed', envelope, operationId, [`Patch may not change immutable field '${field}'.`], [{ field, issue: 'Use an explicit migration.' }]);
150
202
  }
151
203
  }
152
204
  const parsedCurrent = parseDocument(current.bytes.toString('utf8'));
@@ -155,24 +207,29 @@ export function createKernel(options = {}) {
155
207
  if (envelope.patches.__body__ !== undefined) {
156
208
  body = appendOnly ? `${body.replace(/\s+$/, '')}\n${envelope.patches.__body__}` : String(envelope.patches.__body__);
157
209
  }
158
- const validation = validator.validateData(nextData, { resolveReference: context.resolveReference });
159
- if (!validation.valid) return failure(envelope, operationId, validation.errors, validation.field_errors);
210
+ const resolveReference = await referenceResolver(definition, nextData, context);
211
+ const validation = validator.validateData(nextData, { resolveReference });
212
+ if (!validation.valid) return failure('validation_failed', envelope, operationId, validation.errors, validation.field_errors);
160
213
  nextContent = serializeDocument(nextData, body);
161
214
  } else if (envelope.type === 'delete') {
162
- if (!current) return failure(envelope, operationId, ['Delete target does not exist.'], [{ field: 'path', issue: 'Target not found.' }]);
215
+ if (!current) return failure('not_found', envelope, operationId, ['Delete target does not exist.'], [{ field: 'path', issue: 'Target not found.' }]);
163
216
  definition = resolvePrimitiveDefinition(registrySet, declaredType);
164
- if (!definition) return failure(envelope, operationId, [`Unknown SSSS primitive '${declaredType}'.`]);
165
- if (isAppendType(definition)) return failure(envelope, operationId, [`Append-only primitive '${declaredType}' cannot be deleted.`]);
217
+ if (!definition) return failure('validation_failed', envelope, operationId, [`Unknown SSSS primitive '${declaredType}'.`]);
218
+ if (isAppendType(definition)) return failure('validation_failed', envelope, operationId, [`Append-only primitive '${declaredType}' cannot be deleted.`]);
166
219
  } else {
167
220
  definition = { type: 'event', qualified_type: 'ssss:event', primitive_version: 1, capabilities: { event: ['write:event'] } };
168
221
  try { if (typeof envelope.content === 'string') JSON.parse(envelope.content); }
169
- catch { return failure(envelope, operationId, ['Event content must be valid JSON.'], [{ field: 'content', issue: 'Expected JSON string.' }]); }
222
+ catch { return failure('invalid_request', envelope, operationId, ['Event content must be valid JSON.'], [{ field: 'content', issue: 'Expected JSON string.' }]); }
170
223
  }
171
224
 
172
225
  const appendOnly = isAppendType(definition);
173
226
  const action = actionFor(envelope, !!current, appendOnly);
174
227
  const auth = await authorize({ principal, workspaceId: envelope.workspace_id, definition, action, context });
175
- if (!auth.allowed) return failure(envelope, operationId, [`Access denied: ${auth.reason}`], [{ field: 'principal', issue: auth.reason }]);
228
+ if (!auth.allowed) {
229
+ // An absent or malformed principal is an authentication failure, not a permission one.
230
+ const code = validateVerifiedPrincipal(principal).valid ? 'forbidden' : 'unauthorized';
231
+ return failure(code, envelope, operationId, [`Access denied: ${auth.reason}`], [{ field: 'principal', issue: auth.reason }]);
232
+ }
176
233
 
177
234
  if ((definition?.lease_required || context.requireLease) && leaseStore) {
178
235
  let lease;
@@ -185,18 +242,30 @@ export function createKernel(options = {}) {
185
242
  lease_id: envelope.lease_id,
186
243
  });
187
244
  } catch (error) {
188
- return failure(envelope, operationId, [`Lease state is unreadable: ${error.message}`], [{ field: 'lease_id', issue: 'Lease state is unreadable.' }]);
245
+ return failure('internal_error', envelope, operationId, [`Lease state is unreadable: ${error.message}`], [{ field: 'lease_id', issue: 'Lease state is unreadable.' }]);
189
246
  }
190
- if (!lease.valid) return failure(envelope, operationId, [lease.reason], [{ field: 'lease_id', issue: lease.reason }]);
247
+ if (!lease.valid) return failure('lease_conflict', envelope, operationId, [lease.reason], [{ field: 'lease_id', issue: lease.reason }]);
191
248
  }
192
249
 
193
250
  let resourceState = null;
251
+ let prepared = false;
194
252
  if (resourceCoordinator?.prepare) {
195
- try { resourceState = await resourceCoordinator.prepare({ envelope, principal, definition, action, current, nextContent }); }
196
- catch (error) { return failure(envelope, operationId, [`Resource prepare failed: ${error.message}`]); }
253
+ try {
254
+ resourceState = await resourceCoordinator.prepare({ envelope, principal, definition, action, current, nextContent });
255
+ prepared = true;
256
+ }
257
+ catch (error) { return failure('internal_error', envelope, operationId, [`Resource prepare failed: ${error.message}`]); }
197
258
  }
198
259
 
260
+ // Every prepare ends in exactly one finalize or reconcile; a dry run never commits.
261
+ const reconcile = async (phase, error) => {
262
+ if (!resourceCoordinator?.reconcile) return null;
263
+ try { await resourceCoordinator.reconcile({ resourceState, error, envelope, principal, phase }); return null; }
264
+ catch (reconcileError) { return reconcileError; }
265
+ };
266
+
199
267
  if (envelope.dry_run) {
268
+ if (prepared) await reconcile('dry_run', null);
200
269
  return {
201
270
  success: true, type: envelope.type, operation_id: operationId, path: envelope.path,
202
271
  committed_at: null, dry_run: true,
@@ -205,6 +274,7 @@ export function createKernel(options = {}) {
205
274
  }
206
275
 
207
276
  let commit = null;
277
+ let event;
208
278
  try {
209
279
  if (envelope.type === 'operation') {
210
280
  commit = await vfs.writeAtomic(envelope.path, nextContent, current ? { version: current.version } : { ifAbsent: true });
@@ -214,7 +284,7 @@ export function createKernel(options = {}) {
214
284
  commit = await vfs.remove(envelope.path, { version: current.version });
215
285
  }
216
286
 
217
- const event = createCanonicalEvent({
287
+ event = createCanonicalEvent({
218
288
  // `event` envelopes have no VFS compare-and-swap, so a key-derived id
219
289
  // lets the event store reject a concurrent request with the same key.
220
290
  event_id: envelope.type === 'event' ? idempotentEventId(envelope.workspace_id, envelope.idempotency_key) : undefined,
@@ -230,33 +300,19 @@ export function createKernel(options = {}) {
230
300
  before_hash: current?.hash || null,
231
301
  after_hash: commit?.hash || null,
232
302
  changed_fields: envelope.type === 'patch' ? Object.keys(envelope.patches).sort() : [],
233
- resource_status: resourceState ? 'prepared' : null,
303
+ resource_status: prepared ? 'prepared' : null,
234
304
  payload: envelope.type === 'event' && envelope.content ? JSON.parse(envelope.content) : null,
235
305
  }, { clock });
236
306
  await eventStore.append(event);
237
-
238
- if (resourceCoordinator?.finalize) await resourceCoordinator.finalize({ resourceState, event });
239
- const projectionResults = projectionCoordinator ? await projectionCoordinator.dispatch(event) : [];
240
- const warnings = projectionResults.filter((item) => !item.success).map((item) => `Projection '${item.id}' failed: ${item.error}`);
241
- const response = {
242
- success: true, type: envelope.type, operation_id: operationId, path: envelope.path,
243
- committed_at: event.timestamp, dry_run: false,
244
- event_id: event.event_id,
245
- validation: { valid: true, type: definition.qualified_type, errors: [], warnings },
246
- };
247
- await idempotencyStore.put(envelope.workspace_id, envelope.idempotency_key, { request_hash: requestHash, response });
248
- return response;
249
307
  } catch (error) {
250
- if (resourceCoordinator?.reconcile) {
251
- try { await resourceCoordinator.reconcile({ resourceState, error, envelope, principal }); } catch {}
252
- }
308
+ if (prepared) await reconcile('commit', error);
253
309
  // Roll back a VFS commit when event persistence fails. Event envelopes have no VFS commit.
254
310
  if (commit && envelope.type !== 'event') {
255
311
  try {
256
312
  if (!current) await vfs.remove(envelope.path, { version: commit.version });
257
313
  else await vfs.writeAtomic(envelope.path, current.bytes, commit.version ? { version: commit.version } : {});
258
314
  } catch (rollbackError) {
259
- return failure(envelope, operationId, [`Commit failed: ${error.message}; rollback failed: ${rollbackError.message}`]);
315
+ return failure('internal_error', envelope, operationId, [`Commit failed: ${error.message}; rollback failed: ${rollbackError.message}`]);
260
316
  }
261
317
  }
262
318
  // A concurrent request with the same key may have won the commit (VFS
@@ -265,8 +321,36 @@ export function createKernel(options = {}) {
265
321
  const winner = await settled();
266
322
  if (winner) return winner;
267
323
  } catch {}
268
- return failure(envelope, operationId, [`Commit failed: ${error.message}`]);
324
+ const code = isVfsError(error, 'VfsConflictError', 'SSSS_VFS_CONFLICT') ? 'version_conflict' : 'internal_error';
325
+ return failure(code, envelope, operationId, [`Commit failed: ${error.message}`]);
269
326
  }
327
+
328
+ // The appended event is the commit point (spec §6.3): nothing after it may
329
+ // roll the mutation back. Post-commit failures become warnings.
330
+ const warnings = [];
331
+ if (resourceCoordinator?.finalize) {
332
+ try { await resourceCoordinator.finalize({ resourceState, event }); }
333
+ catch (error) {
334
+ warnings.push(`Resource finalize failed: ${error.message}`);
335
+ const reconcileError = await reconcile('finalize', error);
336
+ if (reconcileError) warnings.push(`Resource reconcile failed: ${reconcileError.message}`);
337
+ }
338
+ }
339
+ let projectionResults = [];
340
+ if (projectionCoordinator) {
341
+ try { projectionResults = await projectionCoordinator.dispatch(event); }
342
+ catch (error) { warnings.push(`Projection dispatch failed: ${error.message}`); }
343
+ }
344
+ warnings.push(...projectionResults.filter((item) => !item.success).map((item) => `Projection '${item.id}' failed: ${item.error}`));
345
+ const response = {
346
+ success: true, type: envelope.type, operation_id: operationId, path: envelope.path,
347
+ committed_at: event.timestamp, dry_run: false,
348
+ event_id: event.event_id,
349
+ validation: { valid: true, type: definition.qualified_type, errors: [], warnings },
350
+ };
351
+ try { await idempotencyStore.put(envelope.workspace_id, envelope.idempotency_key, { request_hash: requestHash, response }); }
352
+ catch (error) { warnings.push(`Idempotency record failed: ${error.message}`); }
353
+ return response;
270
354
  }
271
355
 
272
356
  return { execute, registry: registrySet, validator };
package/types/kernel.d.ts CHANGED
@@ -21,8 +21,14 @@ export interface SsssCommand {
21
21
  dry_run?: boolean;
22
22
  }
23
23
 
24
+ export type SsssErrorCode =
25
+ | 'invalid_request' | 'unauthorized' | 'forbidden' | 'not_found' | 'lease_conflict'
26
+ | 'version_conflict' | 'idempotency_conflict' | 'validation_failed' | 'internal_error';
27
+
24
28
  export interface SsssResponse {
25
29
  success: boolean;
30
+ /** Present exactly when success is false (spec §6.5). */
31
+ error?: { code: SsssErrorCode; message: string };
26
32
  type: SsssCommandType | null;
27
33
  operation_id: string;
28
34
  path: string;
@@ -40,6 +46,7 @@ export interface SsssKernel {
40
46
  validator: unknown;
41
47
  }
42
48
 
49
+ export const ERROR_CODES: readonly SsssErrorCode[];
43
50
  export function canonicalRequestHash(command: SsssCommand, principal: VerifiedPrincipal): string;
44
51
  export class MemoryIdempotencyStore {
45
52
  get(workspaceId: string, idempotencyKey: string): Promise<unknown>;