@gregiteen/ssss-cli 0.13.1 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,155 @@
1
+ /**
2
+ * ssss serve — reference HTTP server for the SSSS Operation Contract (spec §6.8).
3
+ * Dependency-free implementation using Node's built-in http module.
4
+ */
5
+ import http from 'node:http';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { createEngine } from '../src/engine.mjs';
10
+ import { statusForResponse } from '../src/http.mjs';
11
+ import { parseArgs, wantsHelp } from './lib/cli.mjs';
12
+
13
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
14
+ const VERSION = (() => {
15
+ try { return fs.readFileSync(path.resolve(HERE, '..', 'VERSION'), 'utf8').trim(); }
16
+ catch { return '0.0.0'; }
17
+ })();
18
+
19
+ export function createServer(options = {}) {
20
+ const vaultRoot = path.resolve(options.vault || '.');
21
+ const engine = options.engine || createEngine({
22
+ registryDir: options.registryDir,
23
+ extensionFile: options.extensionFile,
24
+ });
25
+
26
+ let expectedToken = null;
27
+ if (options.token) {
28
+ expectedToken = options.token.trim();
29
+ } else if (options.tokenFile) {
30
+ expectedToken = fs.readFileSync(path.resolve(options.tokenFile), 'utf8').trim();
31
+ }
32
+
33
+ const server = http.createServer(async (req, res) => {
34
+ res.setHeader('Content-Type', 'application/json');
35
+
36
+ if (req.method === 'GET' && (req.url === '/health' || req.url === '/healthz')) {
37
+ res.writeHead(200);
38
+ res.end(JSON.stringify({ ok: true, version: VERSION }));
39
+ return;
40
+ }
41
+
42
+ const hostHeader = req.headers.host || 'localhost';
43
+ const parsedUrl = new URL(req.url, `http://${hostHeader}`);
44
+
45
+ if (req.method !== 'POST' || (parsedUrl.pathname !== '/v1/operations' && parsedUrl.pathname !== '/')) {
46
+ res.writeHead(404);
47
+ res.end(JSON.stringify({
48
+ success: false,
49
+ error: { code: 'not_found', message: `Route '${req.method} ${parsedUrl.pathname}' not found.` },
50
+ }));
51
+ return;
52
+ }
53
+
54
+ if (expectedToken) {
55
+ const authHeader = req.headers.authorization || '';
56
+ const match = authHeader.match(/^Bearer\s+(.+)$/i);
57
+ const token = match ? match[1].trim() : null;
58
+ if (!token || token !== expectedToken) {
59
+ res.writeHead(401);
60
+ res.end(JSON.stringify({
61
+ success: false,
62
+ error: { code: 'unauthorized', message: 'Missing or invalid bearer token.' },
63
+ }));
64
+ return;
65
+ }
66
+ }
67
+
68
+ const chunks = [];
69
+ req.on('data', (chunk) => chunks.push(chunk));
70
+ req.on('end', async () => {
71
+ let payload;
72
+ try {
73
+ const text = Buffer.concat(chunks).toString('utf8');
74
+ payload = JSON.parse(text);
75
+ } catch (err) {
76
+ res.writeHead(400);
77
+ res.end(JSON.stringify({
78
+ success: false,
79
+ error: { code: 'invalid_request', message: `Malformed JSON: ${err.message}` },
80
+ }));
81
+ return;
82
+ }
83
+
84
+ const envelope = payload?.envelope || payload;
85
+ const callOptions = {};
86
+ if (payload?.principal) callOptions.principal = payload.principal;
87
+
88
+ try {
89
+ const response = await engine.processOperation(envelope, vaultRoot, callOptions);
90
+ const status = statusForResponse(response);
91
+ res.writeHead(status);
92
+ res.end(JSON.stringify(response));
93
+ } catch (err) {
94
+ res.writeHead(500);
95
+ res.end(JSON.stringify({
96
+ success: false,
97
+ error: { code: 'internal_error', message: err.message },
98
+ }));
99
+ }
100
+ });
101
+
102
+ req.on('error', (err) => {
103
+ if (!res.headersSent) {
104
+ res.writeHead(500);
105
+ res.end(JSON.stringify({ success: false, error: { code: 'internal_error', message: err.message } }));
106
+ }
107
+ });
108
+ });
109
+
110
+ return server;
111
+ }
112
+
113
+ export async function run(argv) {
114
+ if (wantsHelp(argv)) {
115
+ console.log(`Usage: ssss serve [options]
116
+
117
+ Start a reference HTTP server exposing the SSSS Operation Contract (POST /v1/operations).
118
+
119
+ Options:
120
+ --vault <dir> Path to vault directory (default: '.')
121
+ --port <num> Port to listen on (default: 8443, 0 for dynamic)
122
+ --host <ip> Host address to bind to (default: 127.0.0.1)
123
+ --token <pat> Optional bearer token required for requests
124
+ --token-file <file> Optional path to bearer token file
125
+ --registry <dir> Path to registry directory
126
+ --extension <file> Path to extension registry file
127
+ --quiet Suppress listening message
128
+ --help, -h Show help`);
129
+ return;
130
+ }
131
+
132
+ const { flags } = parseArgs(argv, { booleans: ['quiet'] });
133
+ const vault = flags.vault || '.';
134
+ const port = flags.port != null ? parseInt(flags.port, 10) : 8443;
135
+ const host = flags.host || '127.0.0.1';
136
+ const token = flags.token;
137
+ const tokenFile = flags['token-file'];
138
+ const registryDir = flags.registry;
139
+ const extensionFile = flags.extension;
140
+
141
+ const server = createServer({ vault, token, tokenFile, registryDir, extensionFile });
142
+ server.listen(port, host, () => {
143
+ const addr = server.address();
144
+ const actualPort = typeof addr === 'object' ? addr.port : port;
145
+ if (!flags.quiet) {
146
+ console.log(`SSSS reference server listening on http://${host}:${actualPort} (vault: ${path.resolve(vault)})`);
147
+ }
148
+ });
149
+
150
+ const close = () => {
151
+ server.close(() => process.exit(0));
152
+ };
153
+ process.on('SIGINT', close);
154
+ process.on('SIGTERM', close);
155
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * `ssss upgrade` — move an installed capability bundle to a new version via
3
+ * structural-only patch envelopes (spec §17.4). Refuses any tenant_private
4
+ * modification.
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { exportBundle, upgradeBundle, importBundle } from '../src/bundle.mjs';
9
+ import { createEngine } from '../src/engine.mjs';
10
+ import { parseArgs, usage, die, wantsHelp } from './lib/cli.mjs';
11
+
12
+ const HELP = usage({
13
+ summary: 'Upgrade an installed bundle to a new version via structural-only patches (§17.4).',
14
+ usage: 'ssss upgrade <bundle.ucw.json> --vault <dir> [options]',
15
+ options: [
16
+ ['--vault <dir>', 'Target vault directory. Required.'],
17
+ ['--from <file>', 'Baseline bundle or plan (if omitted, exported from vault).'],
18
+ ['--workspace <id>', 'Target workspace id (default: ws-upgrade).'],
19
+ ['--prefix <path>', 'Path prefix for the bundle within the vault.'],
20
+ ['--dry-run', 'Validate every patch envelope without committing.'],
21
+ ['--json', 'Emit machine-readable JSON upgrade summary.'],
22
+ ['--registry <dir>', 'Registry directory (default: core registry).'],
23
+ ],
24
+ examples: [
25
+ 'ssss upgrade festival-v2.ucw.json --vault ./tenant',
26
+ 'ssss upgrade festival-v2.ucw.json --vault ./tenant --dry-run',
27
+ 'ssss upgrade festival-v2.ucw.json --vault ./tenant --from festival-v1.ucw.json',
28
+ ],
29
+ seeAlso: ['ssss import', 'ssss provision', 'ssss export'],
30
+ });
31
+
32
+ export async function run(argv) {
33
+ if (wantsHelp(argv)) { console.log(HELP); return; }
34
+ const { positionals, flags } = parseArgs(argv, { booleans: ['dry-run', 'json'] });
35
+ const file = positionals[0];
36
+ if (!file) die('upgrade needs a new bundle file.\n\n' + HELP);
37
+ if (!fs.existsSync(file)) die(`no such file: ${file}`);
38
+ if (!flags.vault) die('upgrade needs --vault <dir>.\n\n' + HELP);
39
+
40
+ const vault = path.resolve(flags.vault);
41
+ if (!fs.existsSync(vault)) die(`no such vault directory: ${vault}`);
42
+
43
+ let nextBundle;
44
+ try { nextBundle = JSON.parse(fs.readFileSync(file, 'utf8')); }
45
+ catch (err) { die(`bundle is not valid JSON: ${err.message}`); }
46
+
47
+ let installedBundle;
48
+ if (flags.from) {
49
+ if (!fs.existsSync(flags.from)) die(`no such baseline file: ${flags.from}`);
50
+ try { installedBundle = JSON.parse(fs.readFileSync(flags.from, 'utf8')); }
51
+ catch (err) { die(`baseline file is not valid JSON: ${err.message}`); }
52
+ } else {
53
+ try {
54
+ installedBundle = exportBundle(vault, {
55
+ profile: 'backup',
56
+ registryDir: flags.registry,
57
+ });
58
+ } catch (err) {
59
+ die(`failed to inspect current vault state: ${err.message}`);
60
+ }
61
+ }
62
+
63
+ const upgrade = upgradeBundle(installedBundle, nextBundle, {
64
+ workspaceId: flags.workspace || 'ws-upgrade',
65
+ pathPrefix: flags.prefix || '',
66
+ dryRun: !!flags['dry-run'],
67
+ registryDir: flags.registry,
68
+ });
69
+
70
+ if (!upgrade.ok) {
71
+ for (const e of upgrade.errors) console.error(`✗ upgrade error: ${e}`);
72
+ process.exit(1);
73
+ }
74
+
75
+ const declared = nextBundle?.manifest?.required_extensions;
76
+ const registryNames = Array.isArray(declared) && declared.length ? declared : null;
77
+ const engine = createEngine({ registryDir: flags.registry, ...(registryNames ? { registryNames } : {}) });
78
+
79
+ const result = await importBundle(upgrade.plan, vault, engine);
80
+ const failed = result.results.filter((r) => !r.success);
81
+
82
+ if (flags.json) {
83
+ const summary = {
84
+ ok: result.ok,
85
+ from_version: upgrade.fromVersion,
86
+ to_version: upgrade.toVersion,
87
+ migration: upgrade.migration,
88
+ patches_count: upgrade.patchesCount,
89
+ operations_count: upgrade.operationsCount,
90
+ committed: result.committed,
91
+ would_commit: result.wouldCommit,
92
+ failed_count: failed.length,
93
+ dry_run: !!flags['dry-run'],
94
+ };
95
+ console.log(JSON.stringify(summary, null, 2));
96
+ if (!result.ok) process.exit(1);
97
+ return;
98
+ }
99
+
100
+ if (flags['dry-run']) {
101
+ console.log(`${result.ok ? '✓' : '✗'} upgrade dry-run ${upgrade.fromVersion} → ${upgrade.toVersion}: ${result.wouldCommit || 0} would apply, ${upgrade.plan.length - (result.wouldCommit || 0)} unchanged, ${failed.length} failed → ${vault}`);
102
+ } else {
103
+ console.log(`${result.ok ? '✓' : '✗'} upgrade ${upgrade.fromVersion} → ${upgrade.toVersion}: ${result.committed} committed, ${upgrade.plan.length - result.committed} unchanged, ${failed.length} failed → ${vault}`);
104
+ }
105
+
106
+ if (failed.length) {
107
+ for (const r of failed) console.error(` ✗ ${r.path}: ${(r.validation?.errors || []).join('; ')}`);
108
+ process.exit(1);
109
+ }
110
+ }
@@ -303,6 +303,12 @@ async function checkCapabilities(root, check) {
303
303
  capabilityCovers('ssss:assistant:*', 'ssss:assistant:create') && !capabilityCovers('ssss:assistant:*', 'ssss:rule:create'));
304
304
  check('capabilities: a namespace wildcard covers its namespace only',
305
305
  capabilityCovers('ssss:*', 'ssss:rule:patch') && !capabilityCovers('acme:*', 'ssss:rule:patch') && !capabilityCovers('ss:*', 'ssss:rule:patch'));
306
+ check('capabilities: path selector @<glob> grants matching path',
307
+ capabilityCovers('ssss:rule:*@rules/marketing/**', 'ssss:rule:create', 'rules/marketing/promo.md')
308
+ && !capabilityCovers('ssss:rule:*@rules/marketing/**', 'ssss:rule:create', 'rules/finance/audit.md'));
309
+ check('capabilities: path selector @<glob> with *:* grants only matching paths',
310
+ capabilityCovers('*:*@capabilities/messaging/**', 'ssss:rule:create', 'capabilities/messaging/rules.md')
311
+ && !capabilityCovers('*:*@capabilities/messaging/**', 'ssss:rule:create', 'capabilities/other/rules.md'));
306
312
 
307
313
  const engine = createEngine();
308
314
  const vault = path.join(root, 'legacy-roles');
@@ -313,11 +319,123 @@ async function checkCapabilities(root, check) {
313
319
  await write('lr-1', 'roles/editor/ROLE.md', role('editor', ['"write:rule"']), 'system');
314
320
  await write('lr-2', 'roles/owner/ROLE.md', role('owner', ['"write:*"']), 'system');
315
321
  await write('lr-3', 'roles/typed/ROLE.md', role('typed', ['"*:assistant"']), 'system');
322
+ await write('lr-rw', 'roles/rolewriter/ROLE.md', role('rolewriter', ['"write:role"']), 'system');
316
323
  check('legacy roles: write:<type> grants writes to that type', (await write('lr-4', 'rules/a.md', doc('rule', 'A'), 'editor')).success);
317
324
  check('legacy roles: write:<type> denies other types', (await write('lr-5', 'assistants/b/ASSISTANT.md', doc('assistant', 'B'), 'editor')).error?.code === 'forbidden');
318
325
  check('legacy roles: *:<type> grants writes to that type', (await write('lr-6', 'assistants/c/ASSISTANT.md', doc('assistant', 'C'), 'typed')).success);
319
326
  check('legacy roles: write:* grants writes to every type', (await write('lr-7', 'workflows/d/WORKFLOW.md', doc('workflow', 'D'), 'owner')).success);
320
327
  check('legacy roles: an unknown role is forbidden, not promoted', (await write('lr-8', 'rules/e.md', doc('rule', 'E'), 'ghost')).error?.code === 'forbidden');
328
+ check('legacy roles: write:role grants writes to security_role (alias unification)',
329
+ (await write('lr-aliased-1', 'roles/sec/ROLE.md', role('sec', ['"write:rule"']), 'rolewriter')).success);
330
+
331
+ const roleDoc = (name, permissions) => `---\ntype: role\ntitle: "${name}"\ndescription: "Canonical role."\ntimestamp: "2026-09-24T00:00:00Z"\nname: "${name}"\npermissions: [${permissions.join(', ')}]\n---\n`;
332
+ const replaceRes = await write('lr-aliased-replace', 'roles/sec/ROLE.md', roleDoc('sec', ['"write:rule"', '"write:task"']), 'rolewriter');
333
+ check('kernel mutation: replace security_role with role succeeds (alias unification)', replaceRes.success);
334
+
335
+ const illegalTypeChange = await write('lr-illegal-type', 'roles/sec/ROLE.md', doc('assistant', 'SecAsst'), 'owner');
336
+ check('kernel mutation: replace role with assistant fails closed',
337
+ illegalTypeChange.error?.code === 'validation_failed' &&
338
+ illegalTypeChange.error?.message?.includes('Operation may not change an existing document type.'));
339
+
340
+ const convDoc = `---\ntype: conversation\ntitle: "Thread"\ndescription: "Chat transcript."\ntimestamp: "2026-09-24T00:00:00Z"\nthread_id: "t-1"\n---\n\nInitial message.\n`;
341
+ await engine.processOperation({ type: 'operation', workspace_id: 'w', idempotency_key: 'conv-init', path: 'threads/t1/CONVERSATION.md', content: convDoc, actor: { role: 'system' } }, vault);
342
+ const patchRes = await engine.processOperation({
343
+ type: 'patch', workspace_id: 'w', idempotency_key: 'conv-patch-alias', path: 'threads/t1/CONVERSATION.md',
344
+ patches: { type: 'thread', __body__: 'New reply.' },
345
+ actor: { role: 'system' },
346
+ }, vault);
347
+ check('kernel mutation: patch conversation with type thread succeeds (alias unification)', patchRes.success);
348
+
349
+ const regTypes = engine._registry.types;
350
+ check('core registry: release, model, page, conflict declare deprecated: true',
351
+ regTypes.get('release')?.deprecated === true &&
352
+ regTypes.get('model')?.deprecated === true &&
353
+ regTypes.get('page')?.deprecated === true &&
354
+ regTypes.get('conflict')?.deprecated === true);
355
+
356
+ let selfAliasRejected = false;
357
+ try {
358
+ composeRegistries({
359
+ core: {
360
+ registry: 'ssss',
361
+ version: '0.14.0',
362
+ universal_fields: ['type'],
363
+ portability: { classes: { structural: {} } },
364
+ document_primitives: {
365
+ rule: { family: 'core', append_only: false, portability: 'structural', required_fields: ['type'], aliases: ['rule'] },
366
+ },
367
+ },
368
+ extensions: [],
369
+ });
370
+ } catch (err) {
371
+ selfAliasRejected = err.message.includes('cannot alias itself');
372
+ }
373
+ check('core registry: self-alias on a primitive is rejected', selfAliasRejected);
374
+
375
+ let asymmetricAliasRejected = false;
376
+ try {
377
+ composeRegistries({
378
+ core: {
379
+ registry: 'ssss',
380
+ version: '0.14.0',
381
+ universal_fields: ['type'],
382
+ portability: { classes: { structural: {} } },
383
+ document_primitives: {
384
+ rule: { family: 'core', append_only: false, portability: 'structural', required_fields: ['type'], aliases: ['assistant'] },
385
+ assistant: { family: 'core', append_only: false, portability: 'structural', required_fields: ['type'] },
386
+ },
387
+ },
388
+ extensions: [],
389
+ });
390
+ } catch (err) {
391
+ asymmetricAliasRejected = err.message.includes('collides with existing primitive');
392
+ }
393
+ check('core registry: non-mutual core alias collision is rejected', asymmetricAliasRejected);
394
+
395
+ const floorAuthorizer = createCapabilityAuthorizer({
396
+ policyFloors: { 'ssss:role': { create: ['governance:elevated'] } },
397
+ });
398
+ const secRoleDef = regTypes.get('security_role');
399
+ const floorBlocked = await floorAuthorizer({
400
+ principal: {
401
+ id: 'u1',
402
+ kind: 'agent',
403
+ workspaceIds: ['w'],
404
+ capabilities: ['ssss:security_role:create'],
405
+ authentication: { provider: 'token', assurance: 'verified' },
406
+ },
407
+ workspaceId: 'w',
408
+ definition: secRoleDef,
409
+ action: 'create',
410
+ });
411
+ const floorAllowed = await floorAuthorizer({
412
+ principal: {
413
+ id: 'u1',
414
+ kind: 'agent',
415
+ workspaceIds: ['w'],
416
+ capabilities: ['ssss:security_role:create', 'governance:elevated'],
417
+ authentication: { provider: 'token', assurance: 'verified' },
418
+ },
419
+ workspaceId: 'w',
420
+ definition: secRoleDef,
421
+ action: 'create',
422
+ });
423
+ check('authorization: policyFloors on ssss:role apply when mutating security_role alias',
424
+ floorBlocked.allowed === false && floorBlocked.missing?.includes('governance:elevated') && floorAllowed.allowed === true);
425
+
426
+ const pathPrincipal = {
427
+ id: 'scoped-user',
428
+ kind: 'agent',
429
+ workspaceIds: ['w'],
430
+ capabilities: ['ssss:rule:*@rules/marketing/**'],
431
+ authentication: { provider: 'token', assurance: 'verified' },
432
+ };
433
+ const writeScoped = (key, pathName, content) => engine.processOperation(
434
+ { type: 'operation', workspace_id: 'w', idempotency_key: key, path: pathName, content }, vault, { principal: pathPrincipal });
435
+ check('kernel authorization: path-scoped grant allows matching path (G5)',
436
+ (await writeScoped('ps-1', 'rules/marketing/banner.md', doc('rule', 'Banner'))).success);
437
+ check('kernel authorization: path-scoped grant forbids non-matching path (G5)',
438
+ (await writeScoped('ps-2', 'rules/engineering/lint.md', doc('rule', 'Lint'))).error?.code === 'forbidden');
321
439
  }
322
440
 
323
441
  // Resource hooks (spec §6.3): every prepare ends in exactly one finalize or
@@ -412,6 +530,27 @@ async function checkResourceLifecycle(check) {
412
530
  check('references: a reference to a disallowed type is rejected', wrongType.error?.code === 'validation_failed');
413
531
  check('references: a patch cannot repoint a reference at a missing document', repointed.error?.code === 'validation_failed');
414
532
 
533
+ const withRoleRefs = createValidator({ extensions: [{
534
+ registry: 'acme', extends: 'core',
535
+ document_primitives: {
536
+ grant_doc: {
537
+ family: 'extension', append_only: false, portability: 'structural', required_fields: ['type', 'name', 'role_ref'],
538
+ references: { role_ref: { allowed_types: ['role'] } },
539
+ },
540
+ deny_role_doc: {
541
+ family: 'extension', append_only: false, portability: 'structural', required_fields: ['type', 'name', 'target_ref'],
542
+ references: { target_ref: { disallowed_types: ['role'] } },
543
+ },
544
+ },
545
+ }] });
546
+ const roleRefKernel = createKernel({ vfs: new MemoryVfs(), eventStore: new MemoryEventStore(), validator: withRoleRefs });
547
+ const writeRef = (key, pathName, content) => roleRefKernel.execute({ type: 'operation', workspace_id: 'w', idempotency_key: key, path: pathName, content }, { principal });
548
+ await writeRef('rr-1', 'roles/admin.md', `---\ntype: security_role\ntitle: "Admin"\ndescription: "D"\ntimestamp: "2026-09-24T00:00:00Z"\nrole_id: "admin"\nname: "Admin"\npermissions: ["*:*"]\n---\n`);
549
+ const allowedViaAlias = await writeRef('rr-2', 'grants/g1.md', `---\ntype: grant_doc\ntitle: "G"\ndescription: "D"\ntimestamp: "2026-09-24T00:00:00Z"\nname: "G"\nrole_ref: "roles/admin.md"\n---\n`);
550
+ const disallowedViaAlias = await writeRef('rr-3', 'denies/d1.md', `---\ntype: deny_role_doc\ntitle: "D"\ndescription: "D"\ntimestamp: "2026-09-24T00:00:00Z"\nname: "D"\ntarget_ref: "roles/admin.md"\n---\n`);
551
+ check('references: allowed_types role accepts aliased security_role target', allowedViaAlias.success);
552
+ check('references: disallowed_types role rejects aliased security_role target', disallowedViaAlias.error?.code === 'validation_failed');
553
+
415
554
  // Every failure carries a symbolic code that maps to the §6.5 status table.
416
555
  const plain = createKernel({ vfs: new MemoryVfs(), eventStore: new MemoryEventStore(), leaseStore: new MemoryLeaseStore() });
417
556
  const missing = await plain.execute({ type: 'patch', workspace_id: 'w', idempotency_key: 'ec-404', path: 'rules/none.md', patches: { name: 'x' } }, { principal });