@gregiteen/ssss-cli 0.13.1 → 0.14.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 +17 -0
- package/VERSION +1 -1
- package/conformance/reference-bundle.ucw.json +1 -1
- package/docs/ssss-spec.md +112 -7
- package/package.json +1 -1
- package/scripts/cmd-adapter.mjs +2 -0
- package/scripts/cmd-bridge.mjs +113 -0
- package/scripts/cmd-import.mjs +21 -1
- package/scripts/cmd-provision.mjs +33 -2
- package/scripts/cmd-serve.mjs +155 -0
- package/scripts/cmd-upgrade.mjs +110 -0
- package/scripts/conformance-09.mjs +20 -0
- package/scripts/conformance.mjs +473 -1
- package/scripts/ssss.mjs +7 -1
- package/src/authorization.mjs +28 -5
- package/src/bundle.mjs +469 -27
- package/src/kernel.mjs +28 -3
- package/src/projections.mjs +93 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.14.0] - 2026-11-02
|
|
11
|
+
### Added
|
|
12
|
+
- **Parameter bindings & Resource bindings (D2, D4)**: `provisionBundle` supports `x_bind` in template frontmatter and resource bindings (`bindings` array with `relation`, `field`, `mode`), with type validation and parameter substitution.
|
|
13
|
+
- **Deterministic ID, slug, and link remapping (D3)**: Target workspace and path isolation with automatic Markdown link and typed references rewriting.
|
|
14
|
+
- **Topological dependency resolution (D5)**: Bundles declare dependencies via `manifest.dependencies.bundles` (`required`, `recommended`, `optional`). Dependencies are resolved in topological order with circular dependency detection.
|
|
15
|
+
- **Base-version precondition & conflict protection (D8)**: `importBundle` assigns `expected_before_hash`; kernel enforces match at Stage 3, preventing regression of concurrent local edits.
|
|
16
|
+
- **Structural bundle upgrade (D6)**: `upgradeBundle` and `ssss upgrade` CLI produce structural patches and `migration` primitive records; engine fail-closed refusal of `tenant_private` targets for upgrade envelopes.
|
|
17
|
+
- **Cross-language HTTP wire protocol & reference server (G3)**: Spec §6.8 HTTP wire transport, `ssss serve` reference server with bearer token authentication (`Authorization: Bearer <token>`), health check (`GET /health`), and canonical HTTP status mapping matching `ERROR_STATUS`.
|
|
18
|
+
- **Subprocess stdin-JSON bridge (G3)**: `ssss bridge` command for non-JS hosts (e.g. Python Dabber CRM), supporting single JSON object, JSON array batch, and streaming NDJSON with `--stream`.
|
|
19
|
+
- **Projection adapter conformance suite (G4)**: `runProjectionContract` in `@gregiteen/ssss-cli/projections` and `projection_memory` in `ssss adapter conformance`, covering log rebuild, incremental apply parity, drift detection, and replay idempotency.
|
|
20
|
+
- **Path-scoped capability grants (G5)**: Capability authorizer supports `<capability>@<glob>` format (e.g. `ssss:rule:*@rules/marketing/**`) enforced at Stage 5 authorization.
|
|
21
|
+
|
|
22
|
+
### Behavior changes hosts should check
|
|
23
|
+
- `provisionBundle` returns `{ manifest, envelopes, bindings, dependencies, unmappedLinks }`. An unbound required resource fails provisioning unless `dryRun: true`.
|
|
24
|
+
- Envelopes with `envelope.intent === 'upgrade'` targeting `tenant_private` documents are rejected with `forbidden`.
|
|
25
|
+
- `importBundle` envelopes include `expected_before_hash` when the source document existed at export time.
|
|
26
|
+
|
|
10
27
|
## [0.13.1] - 2026-11-02
|
|
11
28
|
### Changed
|
|
12
29
|
- No change to the package's code, registry, spec or bundled skill. This
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.14.0
|
|
@@ -106,7 +106,7 @@
|
|
|
106
106
|
"file_count": 14,
|
|
107
107
|
"provenance": {
|
|
108
108
|
"content_hash": "sha256:7febd673938a3dc04c068e0bf08097a9783c09e612606d2df9a4db8dcb8907f5",
|
|
109
|
-
"exporter": "@gregiteen/ssss-cli@0.
|
|
109
|
+
"exporter": "@gregiteen/ssss-cli@0.14.0"
|
|
110
110
|
}
|
|
111
111
|
},
|
|
112
112
|
"files": [
|
package/docs/ssss-spec.md
CHANGED
|
@@ -957,6 +957,7 @@ An envelope is a JSON object:
|
|
|
957
957
|
"path": "assistants/bot/ASSISTANT.md", // vault-relative, no leading "/", no "." or ".." segments
|
|
958
958
|
"content": "---\ntype: …", // operation: full document; event: JSON payload string
|
|
959
959
|
"patches": { }, // patch only
|
|
960
|
+
"expected_before_hash": "sha256:…", // OPTIONAL — asserts target content hash before mutation (§6.1)
|
|
960
961
|
"primitive_type": "assistant", // OPTIONAL — asserts the target's type
|
|
961
962
|
"operation_id": "uuid", // OPTIONAL — client-chosen; generated if absent
|
|
962
963
|
"lease_id": "uuid", // OPTIONAL — §7
|
|
@@ -970,6 +971,14 @@ verified principal, next to the envelope and never inside it (§6.6). A kernel
|
|
|
970
971
|
MUST ignore any identity-bearing field a client places in the envelope, including
|
|
971
972
|
the pre-0.9 `actor` object.
|
|
972
973
|
|
|
974
|
+
A client or bundle provisioner MAY include `expected_before_hash` on an
|
|
975
|
+
`operation` or `patch` envelope to assert that the target document has not been
|
|
976
|
+
modified locally since the intended baseline was read. For a new document creation,
|
|
977
|
+
`expected_before_hash` is `null`. For an update, it is the `sha256:...` digest of
|
|
978
|
+
the baseline content. If the target document's current hash diverges from
|
|
979
|
+
`expected_before_hash` (and its content is not already equal to the intended write),
|
|
980
|
+
the kernel rejects the mutation with `version_conflict`.
|
|
981
|
+
|
|
973
982
|
A `path` MUST be rejected with `invalid_request` if it is empty, absolute, contains
|
|
974
983
|
a backslash or NUL, or has an empty, `.`, or `..` segment. A host MUST also refuse
|
|
975
984
|
to follow a symbolic link out of the vault.
|
|
@@ -1016,7 +1025,7 @@ Kernels MUST NOT reorder or skip stages. Adapters (§6.7) implement mechanics on
|
|
|
1016
1025
|
|---|-------|------------|
|
|
1017
1026
|
| 1 | **Envelope & identity** — known `type`, required fields present and well-typed (§6.2); a principal with an `id` and `kind` is attached. Nothing is read before this passes, so an anonymous caller learns nothing about the vault. | `invalid_request`, `unauthorized` |
|
|
1018
1027
|
| 2 | **Idempotency** — look up (`workspace_id`, `idempotency_key`). Same request hash: return the stored response with `replay: true` and stop. Different hash: stop (§6.4). | `idempotency_conflict` |
|
|
1019
|
-
| 3 | **Read current state** — load the target, if any, and its version. An unsafe path fails here
|
|
1028
|
+
| 3 | **Read current state & base-version check** — load the target, if any, and its version. An unsafe path fails here with `invalid_request`. If `expected_before_hash` is specified: if the target is absent and `expected_before_hash !== null`, or if the target exists and its hash does not match `expected_before_hash` (and its content differs from the intended write), reject with `version_conflict`. | `invalid_request`, `version_conflict`, or `internal_error` for unreadable stored state |
|
|
1020
1029
|
| 4 | **Content validation** — resolve the primitive and validate the resulting document (§9). Enforce type immutability, `immutable_fields`, patch/delete target existence, and append-only rules. | `not_found`, `validation_failed` |
|
|
1021
1030
|
| 5 | **Authorization** — the verified principal must be complete (§6.6), scoped to `workspace_id`, and hold every capability the primitive and action require. | `unauthorized`, `forbidden` |
|
|
1022
1031
|
| 6 | **Lease** — if the primitive is `lease_required` or the host requires a lease for this path, verify it (§7). | `lease_conflict` |
|
|
@@ -1181,6 +1190,11 @@ envelope, after authenticating the caller:
|
|
|
1181
1190
|
it ends in `:*` and the required capability starts with everything before that
|
|
1182
1191
|
`*`. So `ssss:*` covers every core primitive and `ssss:assistant:*` covers every
|
|
1183
1192
|
action on assistants.
|
|
1193
|
+
- A granted capability string MAY include an optional path selector separated by `@`:
|
|
1194
|
+
`<capability>@<glob>` (for example, `ssss:rule:*@rules/marketing/**` or
|
|
1195
|
+
`*:*@capabilities/messaging/**`). When a selector is present, the capability grants
|
|
1196
|
+
permission only when the envelope's target path matches the glob pattern. When omitted,
|
|
1197
|
+
the capability applies workspace-wide.
|
|
1184
1198
|
- A `system` principal bypasses capability checks. It exists for trusted internal
|
|
1185
1199
|
callers such as import (§17). A host MUST NOT grant `system` to anything a remote
|
|
1186
1200
|
caller controls.
|
|
@@ -1215,6 +1229,73 @@ each with a shared contract test in the conformance suite (§12):
|
|
|
1215
1229
|
"Across every writer" means across threads, processes, and hosts sharing the same
|
|
1216
1230
|
storage, not just within one instance.
|
|
1217
1231
|
|
|
1232
|
+
### 6.8 HTTP/JSON Wire Protocol & Subprocess Bridge
|
|
1233
|
+
|
|
1234
|
+
To enable portable, cross-language interoperation across diverse runtimes (Node.js, Python,
|
|
1235
|
+
Ruby, Go, Rust), SSSS defines a normative HTTP/JSON wire transport contract and a standard
|
|
1236
|
+
subprocess bridge. A host or engine that exposes SSSS over a network MUST conform to this
|
|
1237
|
+
wire contract.
|
|
1238
|
+
|
|
1239
|
+
#### Endpoint & HTTP Headers
|
|
1240
|
+
|
|
1241
|
+
- **Endpoint**: `POST /v1/operations` (hosts MAY also route `POST /` to this handler for convenience).
|
|
1242
|
+
- **Request Content-Type**: `application/json`.
|
|
1243
|
+
- **Response Content-Type**: `application/json`.
|
|
1244
|
+
- **Authentication**: When remote authentication is required, the client MUST present
|
|
1245
|
+
a bearer token via the `Authorization: Bearer <token>` HTTP header. The host verifies
|
|
1246
|
+
the token and resolves it out-of-band to a verified principal (§6.6). The envelope body
|
|
1247
|
+
itself carries no secret credentials.
|
|
1248
|
+
- **Health check**: A host exposing the HTTP interface SHOULD expose `GET /health`, returning
|
|
1249
|
+
HTTP 200 with `{ "ok": true, "version": "<version>" }`.
|
|
1250
|
+
|
|
1251
|
+
#### Request and Response Wire Contract
|
|
1252
|
+
|
|
1253
|
+
- **Request Body**: An Operation Contract envelope (§6.1, §6.2) as a JSON object:
|
|
1254
|
+
```json
|
|
1255
|
+
{
|
|
1256
|
+
"type": "operation",
|
|
1257
|
+
"idempotency_key": "00000000-0000-0000-0000-000000000001",
|
|
1258
|
+
"path": "assistants/support-bot.md",
|
|
1259
|
+
"workspace_id": "ws-main",
|
|
1260
|
+
"content": "---\ntype: assistant\nname: Support Bot\ntitle: Support Bot\ndescription: Customer support assistant\ntimestamp: 2026-07-02T00:00:00Z\n---\nHelp customers.\n"
|
|
1261
|
+
}
|
|
1262
|
+
```
|
|
1263
|
+
Callers attaching an explicit principal context MAY submit:
|
|
1264
|
+
```json
|
|
1265
|
+
{
|
|
1266
|
+
"envelope": {
|
|
1267
|
+
"type": "operation",
|
|
1268
|
+
"idempotency_key": "00000000-0000-0000-0000-000000000001",
|
|
1269
|
+
"path": "assistants/support-bot.md",
|
|
1270
|
+
"workspace_id": "ws-main",
|
|
1271
|
+
"content": "---\ntype: assistant\nname: Support Bot\ntitle: Support Bot\ndescription: Customer support assistant\ntimestamp: 2026-07-02T00:00:00Z\n---\nHelp customers.\n"
|
|
1272
|
+
},
|
|
1273
|
+
"principal": {
|
|
1274
|
+
"id": "agent-1",
|
|
1275
|
+
"kind": "agent",
|
|
1276
|
+
"workspaceIds": ["ws-main"],
|
|
1277
|
+
"capabilities": ["*:*"],
|
|
1278
|
+
"authentication": { "provider": "token", "assurance": "verified" }
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
```
|
|
1282
|
+
- **Response Body**: The kernel response object (§6.5) serialized as JSON:
|
|
1283
|
+
- On commit: `201 Created` with `{ "success": true, "type": "operation", ... }`.
|
|
1284
|
+
- On idempotent replay or dry run: `200 OK` with `{ "success": true, "replay": true, ... }`.
|
|
1285
|
+
- On error: status mapped from `ERROR_STATUS` (§6.5) with `{ "success": false, "error": { "code": "...", "message": "..." } }`.
|
|
1286
|
+
|
|
1287
|
+
#### Subprocess Bridge Contract (`ssss bridge`)
|
|
1288
|
+
|
|
1289
|
+
For local process orchestration without opening network ports (for example, Python hosts
|
|
1290
|
+
integrating SSSS as a subprocess), the reference CLI provides `ssss bridge`:
|
|
1291
|
+
|
|
1292
|
+
- Reads JSON from standard input (`stdin`). Supports:
|
|
1293
|
+
1. A single JSON request object.
|
|
1294
|
+
2. A JSON array of requests (`[...]`).
|
|
1295
|
+
3. Newline-delimited JSON (`NDJSON`), with one request per line when running with `--stream`.
|
|
1296
|
+
- Writes JSON response objects to standard output (`stdout`) matching the wire contract shape.
|
|
1297
|
+
- Does not alter envelope semantics, schema validation, or error status mappings.
|
|
1298
|
+
|
|
1218
1299
|
---
|
|
1219
1300
|
|
|
1220
1301
|
## 7. Leases — Concurrency Control
|
|
@@ -1868,6 +1949,23 @@ of `source: user` or `source: system` — e.g. the phone number, domain, and mai
|
|
|
1868
1949
|
must bind. This is how the standard guarantees a sold business says exactly what real-world
|
|
1869
1950
|
resources it needs without shipping the seller's own.
|
|
1870
1951
|
|
|
1952
|
+
#### 16.5.1 Frontmatter Parameter Binding (`x_bind`)
|
|
1953
|
+
|
|
1954
|
+
Values bind exclusively through **frontmatter**, never through free-text string substitution in Markdown body text. A template document declares frontmatter parameter bindings using the reserved `x_bind` mapping:
|
|
1955
|
+
|
|
1956
|
+
```yaml
|
|
1957
|
+
x_bind:
|
|
1958
|
+
channel_id: support_channel_id
|
|
1959
|
+
notify_email: primary_admin_email
|
|
1960
|
+
```
|
|
1961
|
+
|
|
1962
|
+
During `provisionBundle` (§17.1):
|
|
1963
|
+
1. For each `field: parameter_key` entry in `x_bind`, the provisioner resolves the value from the supplied `parameters` dictionary, or the parameter's `defaultValue` if omitted.
|
|
1964
|
+
2. If a parameter is `required: true` and has no supplied value or default, provisioning fails with `unresolved`.
|
|
1965
|
+
3. The resolved value MUST match the parameter's declared `type` (`string`, `boolean`, `number`, etc.).
|
|
1966
|
+
4. The target frontmatter field is assigned the resolved value, and the `x_bind` property is stripped from the provisioned output document.
|
|
1967
|
+
5. In `template` and `sale` export profiles (§5.5), when `reduceResourceBoundFile()` strips sensitive infrastructure values from `resource.binds` (such as domain names or phone numbers), it automatically emits corresponding `x_bind` bindings targeting the synthesized resource requirement parameters.
|
|
1968
|
+
|
|
1871
1969
|
---
|
|
1872
1970
|
|
|
1873
1971
|
## 17. The Provisioning Contract
|
|
@@ -1916,12 +2014,18 @@ binding each requires.
|
|
|
1916
2014
|
### 17.3 Determinism, Id-Remap & Link Integrity
|
|
1917
2015
|
|
|
1918
2016
|
When a bundle is provisioned into a target vault, identifiers (slugs, workspace ids,
|
|
1919
|
-
cross-file links) are **remapped** so the imported business is a fresh, self-consistent
|
|
1920
|
-
instance rather than a clone aliasing the seller's ids.
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
2017
|
+
cross-file links, and typed references) are **remapped** so the imported business is a fresh, self-consistent
|
|
2018
|
+
instance rather than a clone aliasing the seller's ids.
|
|
2019
|
+
|
|
2020
|
+
Given a target `workspaceId` and optional `pathPrefix`:
|
|
2021
|
+
1. **Deterministic ID Map:** Every source identifier or slug derives a deterministic target identifier:
|
|
2022
|
+
`newId = "<pathPrefix>-" + sha256(workspaceId + ":" + sourceId).slice(0, 12)`
|
|
2023
|
+
2. **Path Prefixing:** Target file paths are placed under the path prefix:
|
|
2024
|
+
`targetPath = pathPrefix ? `${pathPrefix}/${sourcePath}` : sourcePath`
|
|
2025
|
+
3. **Identifier Fields:** Frontmatter identifier fields (`slug`, `id`, `workflow_id`, `run_id`, `task_id`, `migration_id`) are mapped to their derived `newId`.
|
|
2026
|
+
4. **Link Rewriting:** Body wiki-links `[[source-slug]]` and relative Markdown links `[title](source-path.md)` are rewritten to the target slug and target relative path.
|
|
2027
|
+
5. **Registry-Declared Typed References (§9):** Frontmatter fields constrained by `references` (e.g. `asset.resource_ref`, `trigger.target_workflow`) that point to relative paths within the bundle MUST be rewritten to point to their remapped target paths under `pathPrefix`.
|
|
2028
|
+
6. **Link & Reference Integrity:** Remapping MUST preserve link integrity: every internal `[[link]]`, relative markdown link, and typed frontmatter reference that resolved in the source bundle MUST resolve to the remapped target after import. An importer that cannot resolve a link or reference MUST fail the provision (`danglingLinks`) rather than emit a dangling reference.
|
|
1925
2029
|
|
|
1926
2030
|
### 17.4 Composition & Upgrade
|
|
1927
2031
|
|
|
@@ -1946,6 +2050,7 @@ MUST NOT be repurposed by hosts:
|
|
|
1946
2050
|
`aliases`, `feedback`, `confidence`.
|
|
1947
2051
|
- Lifecycle: `slug`, `schema_version`, `status`.
|
|
1948
2052
|
- Portability (§5.5): `x_portability`.
|
|
2053
|
+
- Parameter Binding (§16.5): `x_bind`.
|
|
1949
2054
|
|
|
1950
2055
|
The patch key `__body__` (§6.2) is reserved and can never be a frontmatter key.
|
|
1951
2056
|
|
package/package.json
CHANGED
package/scripts/cmd-adapter.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { MemoryVfs, FileSystemVfs, runVfsContract } from '../src/vfs.mjs';
|
|
|
6
6
|
import { MemoryLeaseStore, FileLeaseStore, runLeaseContract } from '../src/leases.mjs';
|
|
7
7
|
import { MemoryIdempotencyStore, FileIdempotencyStore, runIdempotencyContract } from '../src/idempotency.mjs';
|
|
8
8
|
import { MemoryIntentJournal, FileIntentJournal, runIntentJournalContract } from '../src/intents.mjs';
|
|
9
|
+
import { createSqlProjectionAdapter, runProjectionContract } from '../src/projections.mjs';
|
|
9
10
|
import { parseArgs, die, wantsHelp } from './lib/cli.mjs';
|
|
10
11
|
|
|
11
12
|
export async function run(argv) {
|
|
@@ -23,6 +24,7 @@ export async function run(argv) {
|
|
|
23
24
|
idempotency_filesystem: await runIdempotencyContract(new FileIdempotencyStore(path.join(root, 'idempotency'))),
|
|
24
25
|
intent_journal_memory: await runIntentJournalContract(new MemoryIntentJournal()),
|
|
25
26
|
intent_journal_filesystem: await runIntentJournalContract(new FileIntentJournal(path.join(root, 'intents'))),
|
|
27
|
+
projection_memory: await runProjectionContract(createSqlProjectionAdapter),
|
|
26
28
|
};
|
|
27
29
|
const report = { version: 1, passed: Object.values(suites).every((suite) => suite.passed), suites };
|
|
28
30
|
const text = `${JSON.stringify(report, null, 2)}\n`;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ssss bridge — stdin-to-stdout JSON bridge for the SSSS Operation Contract (spec §6.8).
|
|
3
|
+
* Enables non-JavaScript hosts (e.g. Python, Ruby, Go, shell) to execute operations
|
|
4
|
+
* without running an HTTP server or maintaining custom node runners.
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import readline from 'node:readline';
|
|
9
|
+
import { createEngine } from '../src/engine.mjs';
|
|
10
|
+
import { parseArgs, wantsHelp } from './lib/cli.mjs';
|
|
11
|
+
|
|
12
|
+
export async function run(argv) {
|
|
13
|
+
if (wantsHelp(argv)) {
|
|
14
|
+
console.log(`Usage: ssss bridge [options]
|
|
15
|
+
|
|
16
|
+
Execute Operation Contract requests from stdin and emit JSON responses to stdout.
|
|
17
|
+
Accepts a single JSON object, a JSON array of requests, or newline-delimited JSON (NDJSON).
|
|
18
|
+
|
|
19
|
+
Options:
|
|
20
|
+
--vault <dir> Path to the target vault directory (default: '.')
|
|
21
|
+
--registry <dir> Path to registry directory
|
|
22
|
+
--extension <file> Path to extension registry file
|
|
23
|
+
--stream Process input as continuous newline-delimited JSON stream
|
|
24
|
+
--help, -h Show help`);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const { flags } = parseArgs(argv, { booleans: ['stream'] });
|
|
29
|
+
const vaultRoot = path.resolve(flags.vault || '.');
|
|
30
|
+
const engine = createEngine({
|
|
31
|
+
registryDir: flags.registry,
|
|
32
|
+
extensionFile: flags.extension,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const processOne = async (payload) => {
|
|
36
|
+
const envelope = payload?.envelope || payload;
|
|
37
|
+
const callOptions = {};
|
|
38
|
+
if (payload?.principal) callOptions.principal = payload.principal;
|
|
39
|
+
try {
|
|
40
|
+
return await engine.processOperation(envelope, vaultRoot, callOptions);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
return {
|
|
43
|
+
success: false,
|
|
44
|
+
error: { code: 'internal_error', message: err.message },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
if (flags.stream) {
|
|
50
|
+
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
51
|
+
for await (const line of rl) {
|
|
52
|
+
const trimmed = line.trim();
|
|
53
|
+
if (!trimmed) continue;
|
|
54
|
+
try {
|
|
55
|
+
const payload = JSON.parse(trimmed);
|
|
56
|
+
const res = await processOne(payload);
|
|
57
|
+
process.stdout.write(`${JSON.stringify(res)}\n`);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
process.stdout.write(`${JSON.stringify({ success: false, error: { code: 'invalid_request', message: `Malformed JSON: ${err.message}` } })}\n`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Buffered mode (EOF-terminated)
|
|
66
|
+
const chunks = [];
|
|
67
|
+
for await (const chunk of process.stdin) {
|
|
68
|
+
chunks.push(chunk);
|
|
69
|
+
}
|
|
70
|
+
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
|
71
|
+
if (!raw) return;
|
|
72
|
+
|
|
73
|
+
// Case 1: JSON array
|
|
74
|
+
if (raw.startsWith('[') && raw.endsWith(']')) {
|
|
75
|
+
let list;
|
|
76
|
+
try {
|
|
77
|
+
list = JSON.parse(raw);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
process.stdout.write(`${JSON.stringify({ success: false, error: { code: 'invalid_request', message: `Malformed JSON array: ${err.message}` } })}\n`);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const results = [];
|
|
83
|
+
for (const item of list) {
|
|
84
|
+
results.push(await processOne(item));
|
|
85
|
+
}
|
|
86
|
+
process.stdout.write(`${JSON.stringify(results)}\n`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Case 2: single JSON object
|
|
91
|
+
if (raw.startsWith('{') && raw.endsWith('}')) {
|
|
92
|
+
try {
|
|
93
|
+
const payload = JSON.parse(raw);
|
|
94
|
+
const res = await processOne(payload);
|
|
95
|
+
process.stdout.write(`${JSON.stringify(res)}\n`);
|
|
96
|
+
return;
|
|
97
|
+
} catch {
|
|
98
|
+
// If parsing failed, fall through to NDJSON check
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Case 3: NDJSON
|
|
103
|
+
const lines = raw.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
104
|
+
for (const line of lines) {
|
|
105
|
+
try {
|
|
106
|
+
const payload = JSON.parse(line);
|
|
107
|
+
const res = await processOne(payload);
|
|
108
|
+
process.stdout.write(`${JSON.stringify(res)}\n`);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
process.stdout.write(`${JSON.stringify({ success: false, error: { code: 'invalid_request', message: `Malformed JSON line: ${err.message}` } })}\n`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
package/scripts/cmd-import.mjs
CHANGED
|
@@ -16,6 +16,7 @@ const HELP = usage({
|
|
|
16
16
|
['--vault <dir>', 'Target vault directory (created if missing). Required.'],
|
|
17
17
|
['--plan <file>', 'Import a pre-built plan (from `ssss provision`) instead of the bundle.'],
|
|
18
18
|
['--param <k=v>', 'Bind a bundle parameter (repeatable).'],
|
|
19
|
+
['--dependency <id=file>', 'Provide a dependency bundle (repeatable).'],
|
|
19
20
|
['--workspace <id>', 'Target workspace id (default: ws-provision).'],
|
|
20
21
|
['--prefix <path>', 'Place every file under this path prefix (id-remap).'],
|
|
21
22
|
['--dry-run', 'Validate every envelope without committing.'],
|
|
@@ -30,7 +31,7 @@ const HELP = usage({
|
|
|
30
31
|
|
|
31
32
|
export async function run(argv) {
|
|
32
33
|
if (wantsHelp(argv)) { console.log(HELP); return; }
|
|
33
|
-
const { positionals, flags } = parseArgs(argv, { booleans: ['dry-run'], multi: ['param'] });
|
|
34
|
+
const { positionals, flags } = parseArgs(argv, { booleans: ['dry-run'], multi: ['param', 'dependency'] });
|
|
34
35
|
// Extension namespaces this import must resolve. Seeded from the bundle's own
|
|
35
36
|
// `required_extensions` so the engine loads exactly the namespace the vault was
|
|
36
37
|
// exported against, rather than every extension in the registry directory.
|
|
@@ -51,11 +52,30 @@ export async function run(argv) {
|
|
|
51
52
|
const bundle = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
52
53
|
const declared = bundle?.manifest?.required_extensions;
|
|
53
54
|
if (Array.isArray(declared) && declared.length) registryNames = declared;
|
|
55
|
+
|
|
56
|
+
const dependencies = {};
|
|
57
|
+
for (const dep of flags.dependency || []) {
|
|
58
|
+
const eq = dep.indexOf('=');
|
|
59
|
+
let id = null;
|
|
60
|
+
let depPath = dep;
|
|
61
|
+
if (eq !== -1) {
|
|
62
|
+
id = dep.slice(0, eq);
|
|
63
|
+
depPath = dep.slice(eq + 1);
|
|
64
|
+
}
|
|
65
|
+
if (!fs.existsSync(depPath)) die(`no such dependency file: ${depPath}`);
|
|
66
|
+
let depBundle;
|
|
67
|
+
try { depBundle = JSON.parse(fs.readFileSync(depPath, 'utf8')); }
|
|
68
|
+
catch (err) { die(`dependency ${depPath} is not valid JSON: ${err.message}`); }
|
|
69
|
+
id = id || depBundle.manifest?.name || depBundle.manifest?.id || path.basename(depPath, '.json');
|
|
70
|
+
dependencies[id] = depBundle;
|
|
71
|
+
}
|
|
72
|
+
|
|
54
73
|
const prov = provisionBundle(bundle, {
|
|
55
74
|
parameters: parseKeyValues(flags.param),
|
|
56
75
|
workspaceId: flags.workspace || 'ws-provision',
|
|
57
76
|
pathPrefix: flags.prefix || '',
|
|
58
77
|
dryRun: !!flags['dry-run'],
|
|
78
|
+
dependencies,
|
|
59
79
|
});
|
|
60
80
|
if (!prov.ok) {
|
|
61
81
|
if (prov.unresolved.length) console.error(`✗ unresolved parameter(s): ${prov.unresolved.join(', ')}`);
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* deterministic idempotency keys so a later `import` is a no-op on re-run.
|
|
6
6
|
*/
|
|
7
7
|
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
8
9
|
import { provisionBundle } from '../src/bundle.mjs';
|
|
9
10
|
import { parseArgs, parseKeyValues, usage, die, wantsHelp } from './lib/cli.mjs';
|
|
10
11
|
|
|
@@ -13,13 +14,16 @@ const HELP = usage({
|
|
|
13
14
|
usage: 'ssss provision <bundle.ucw.json> [options]',
|
|
14
15
|
options: [
|
|
15
16
|
['--param <k=v>', 'Bind a bundle parameter (repeatable).'],
|
|
17
|
+
['--dependency <id=file>', 'Provide a dependency bundle (repeatable).'],
|
|
16
18
|
['--workspace <id>', 'Target workspace id (default: ws-provision).'],
|
|
17
19
|
['--prefix <path>', 'Place every file under this path prefix (id-remap).'],
|
|
18
20
|
['--out <file>', 'Write the envelope plan here (default: stdout).'],
|
|
19
21
|
['--dry-run', 'Mark every envelope dry_run (import will validate, not commit).'],
|
|
22
|
+
['--json', 'Emit complete JSON plan including bindings and dependency summary.'],
|
|
20
23
|
],
|
|
21
24
|
examples: [
|
|
22
25
|
'ssss provision festival.ucw.json --param business_name="Acme Fest" --param domain=acme.live',
|
|
26
|
+
'ssss provision festival.ucw.json --dependency ticketing=./ticketing.ucw.json',
|
|
23
27
|
'ssss provision festival.ucw.json --workspace ws-42 --out plan.json',
|
|
24
28
|
],
|
|
25
29
|
seeAlso: ['ssss import', 'ssss inspect', 'ssss help provisioning'],
|
|
@@ -27,7 +31,7 @@ const HELP = usage({
|
|
|
27
31
|
|
|
28
32
|
export async function run(argv) {
|
|
29
33
|
if (wantsHelp(argv)) { console.log(HELP); return; }
|
|
30
|
-
const { positionals, flags } = parseArgs(argv, { booleans: ['dry-run'], multi: ['param'] });
|
|
34
|
+
const { positionals, flags } = parseArgs(argv, { booleans: ['dry-run', 'json'], multi: ['param', 'dependency'] });
|
|
31
35
|
const file = positionals[0];
|
|
32
36
|
if (!file) die('provision needs a bundle file.\n\n' + HELP);
|
|
33
37
|
if (!fs.existsSync(file)) die(`no such file: ${file}`);
|
|
@@ -36,11 +40,29 @@ export async function run(argv) {
|
|
|
36
40
|
try { bundle = JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
37
41
|
catch (err) { die(`not valid JSON: ${err.message}`); }
|
|
38
42
|
|
|
43
|
+
const dependencies = {};
|
|
44
|
+
for (const dep of flags.dependency || []) {
|
|
45
|
+
const eq = dep.indexOf('=');
|
|
46
|
+
let id = null;
|
|
47
|
+
let depPath = dep;
|
|
48
|
+
if (eq !== -1) {
|
|
49
|
+
id = dep.slice(0, eq);
|
|
50
|
+
depPath = dep.slice(eq + 1);
|
|
51
|
+
}
|
|
52
|
+
if (!fs.existsSync(depPath)) die(`no such dependency file: ${depPath}`);
|
|
53
|
+
let depBundle;
|
|
54
|
+
try { depBundle = JSON.parse(fs.readFileSync(depPath, 'utf8')); }
|
|
55
|
+
catch (err) { die(`dependency ${depPath} is not valid JSON: ${err.message}`); }
|
|
56
|
+
id = id || depBundle.manifest?.name || depBundle.manifest?.id || path.basename(depPath, '.json');
|
|
57
|
+
dependencies[id] = depBundle;
|
|
58
|
+
}
|
|
59
|
+
|
|
39
60
|
const result = provisionBundle(bundle, {
|
|
40
61
|
parameters: parseKeyValues(flags.param),
|
|
41
62
|
workspaceId: flags.workspace || 'ws-provision',
|
|
42
63
|
pathPrefix: flags.prefix || '',
|
|
43
64
|
dryRun: !!flags['dry-run'],
|
|
65
|
+
dependencies,
|
|
44
66
|
});
|
|
45
67
|
|
|
46
68
|
if (result.unresolved.length) {
|
|
@@ -55,8 +77,17 @@ export async function run(argv) {
|
|
|
55
77
|
console.error(`✗ ${result.errors.length} bundle validation error(s):`);
|
|
56
78
|
for (const e of result.errors) console.error(` - ${e}`);
|
|
57
79
|
}
|
|
80
|
+
if (result.dependencies?.suggested?.length) {
|
|
81
|
+
console.error(`ℹ suggested optional/recommended dependencies: ${result.dependencies.suggested.map((d) => d.id).join(', ')}`);
|
|
82
|
+
}
|
|
58
83
|
|
|
59
|
-
const plan = {
|
|
84
|
+
const plan = {
|
|
85
|
+
workspace_id: flags.workspace || 'ws-provision',
|
|
86
|
+
steps: result.steps,
|
|
87
|
+
bindings: result.bindings,
|
|
88
|
+
dependencies: result.dependencies,
|
|
89
|
+
envelopes: result.plan,
|
|
90
|
+
};
|
|
60
91
|
const json = JSON.stringify(plan, null, 2);
|
|
61
92
|
if (flags.out) {
|
|
62
93
|
fs.writeFileSync(flags.out, json + '\n');
|
|
@@ -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
|
+
}
|