Initial release — OpenRig Studio SDK, contract v0.1

An app shell and a contract, not a framework you link against. A rail of
surfaces, a stage, an agent sidebar, and a protocol for how a surface talks to
them. An app conforms to the contract; it never imports this package.

- app/            the reference runtime and shell
- contract/       the contract: shell protocol, manifest, row schema, runtime API
- create-studio/  a scaffolder for a new studio
- skills/         how to build an app on this SDK, written from experience
- fixtures/       example rig state for the FLOOR surface
- test/           73 tests, including the consumer-surface seam and redeploy survival

Consumers register surfaces from OUTSIDE the package via --surfaces, so nothing
is written into node_modules and a copied install cannot carry another
instance's rows.

Licensed Apache-2.0.
This commit is contained in:
mvschwarz committed 2026-08-02 03:03:14 -07:00
commit 2c1e40e6b1
33 files changed
+4868

No files matched your search

+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+4
View File
@@ -0,0 +1,4 @@
OpenRig Studio
Copyright 2026 mvschwarz
This product includes software developed by mvschwarz.
+58
View File
@@ -0,0 +1,58 @@
# openrig studio
An SDK for building agent-driven mini-apps: a thin browser surface paired
with an agent, running in a shared shell. Point your coding agent at this
repository and it can build you a surface.
## Quickstart
Requires Node 18+. No dependencies, no build step.
```sh
node app/serve-studio.mjs
```
Open http://127.0.0.1:8890/ — the shell. It starts with one surface on the rail, FLOOR, rendering the fixture rig state. Flags: `--port <n>`, `--fixtures <dir>`.
## Where everything is
| Path | What it is |
|---|---|
| `contract/` | **The contract — start here.** Four documents + the manifest row schema. |
| `create-studio/` | The scaffolder: generates a working surface project. |
| `app/serve-studio.mjs` | The reference runtime (zero-dependency Node HTTP server). |
| `app/shell.html` | The shell: rail + stage + agent sidebar. |
| `app/surfaces.json` | The surface manifest the shell renders. |
| `app/surfaces/` | Surface pages live here, served at `/surfaces/…`. |
| `fixtures/` | The data behind the runtime's API verbs (rig floor state, files root). |
## Starting a new surface
The scaffolder emits a working surface project — page, schema-valid manifest
row, and instructions — already wired to contract v0.1:
```sh
node create-studio/index.mjs my-surface
```
The intended public form is `npm create @openrig/studio my-surface`, which npm
resolves to `@openrig/create-studio`. **That command is not available yet** —
the package is unpublished and its final name is a deferred decision. Until
then use the invocation above. See `create-studio/README.md`.
## Building a surface
Read `contract/contract-meta.md` first — it explains how to read the rest of
the contract. Then: `contract/manifest.md` shows how a surface registers,
`contract/shell-protocol.md` what the shell provides, and
`contract/runtime-api.md` the HTTP verbs and their exact shapes. A running
runtime self-describes at `GET /api/contract`.
The reference runtime is fixture-backed: its API verbs serve data from
`fixtures/`, so everything works on any machine with no external processes.
Editing a fixture file fires the change-signal stream — a convenient way to
watch a live-updating surface work.
## Status
Contract v0.1. Pre-release.
+738
View File
@@ -0,0 +1,738 @@
#!/usr/bin/env node
// openrig studio — reference runtime, contract v0.1.
//
// A zero-dependency HTTP server that is the SDK's contract boundary:
// - serves the shell (shell.html) and the surfaces it hosts
// - reads + validates the surface manifest (surfaces.json), WARN-FIRST
// - GET /api/contract — version, capabilities, manifest validation report
// - GET /api/factory/state — the observe envelope (fixture-backed)
// - GET /api/events — SSE change-signal stream (signal-only contract)
// - GET /api/files/* — read-only, root-pinned file verbs
//
// Fixture-backed by design: this runtime runs anywhere with no external
// process. Data comes from --fixtures <dir> (default ./fixtures beside the
// repo root). Live backings arrive behind the same contract in later
// versions; the API shapes here are the contract, the fixtures are the demo.
//
// Usage: node app/serve-studio.mjs [--port 8890] [--fixtures <dir>]
import http from "node:http";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(HERE, "..");
const arg = (name, dflt) => {
const i = process.argv.indexOf(name);
return i > -1 && process.argv[i + 1] ? process.argv[i + 1] : dflt;
};
const PORT = Number(arg("--port", 8890));
const FIXTURES = path.resolve(arg("--fixtures", path.join(ROOT, "fixtures")));
const FILES_ROOT = path.join(FIXTURES, "files-root");
const MANIFEST_PATH = path.join(HERE, "surfaces.json");
// ---- the consumer-surface seam ---------------------------------------------
// A consumer of this SDK (a studio built ON it) needs to register ITS OWN
// surfaces. Before this seam the only registration path pointed INSIDE the
// package — which in a real install is node_modules/@openrig/studio/app/ —
// gitignored, wiped by npm ci, and, worse, CARRIED BY A COPY: duplicating a
// node_modules tree duplicated phantom surface state, so a contaminated
// instance served 200 with a healthy manifest and the WRONG surfaces.
//
// The overlay puts BOTH halves outside the package: the consumer's own
// surfaces.json AND its pages. A configurable manifest path alone would not
// have done it — the rows would move out while the .html stayed in, so the
// contamination class would survive in the half that actually carries state.
// Because consumer surfaces never live in node_modules, copying node_modules
// cannot inject one, and there is nothing to reconcile.
//
// Opt-in and additive. Unset, everything below behaves exactly as before and
// the SDK's own FLOOR needs zero configuration.
const OVERLAY_DIR = (() => {
const raw = arg("--surfaces", process.env.OPENRIG_STUDIO_SURFACES);
return raw ? path.resolve(raw) : null;
})();
const OVERLAY_MANIFEST = OVERLAY_DIR ? path.join(OVERLAY_DIR, "surfaces.json") : null;
const CONTRACT_VERSION = "0.1";
const CAPABILITIES = ["contract.meta", "observe.factory-state", "stream.events", "files.read", "shell.protocol"];
const TYPES = { ".html": "text/html; charset=utf-8", ".json": "application/json", ".css": "text/css",
".js": "text/javascript", ".mjs": "text/javascript", ".svg": "image/svg+xml", ".png": "image/png",
".md": "text/plain; charset=utf-8", ".txt": "text/plain; charset=utf-8" };
const sendJson = (res, code, obj) =>
{ res.writeHead(code, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };
// ---- manifest: read + WARN-FIRST validation --------------------------------
// Contract rule: a malformed manifest must LOOK wrong — named errors in the
// startup log and in GET /api/contract's manifest block — but the runtime
// keeps serving the valid rows. It never dies and never silently ignores.
const ROW_OPTIONAL = { glyph: "string", hint: "string", category: "string", samehost: "boolean",
port: "number", sub: "string", url: "string", hosts: "array", popout: "boolean",
hideAgents: "boolean", unmountOnBlur: "boolean", railMin: "boolean", project: "string" };
// `label` is "" for a single-manifest runtime, so a zero-config install emits
// byte-identical error strings to before the seam existed — labelling only
// appears once there are two sources to tell apart, and only for consumers who
// opted in. Changing an error contract is listed as BREAKING in contract-meta;
// this keeps the unconfigured path genuinely untouched rather than nearly so.
// `keep` filters rows BEFORE validation. Order matters: contamination must be
// removed before the duplicate-id check, or a forged row PREPENDED to the
// manifest gets the genuine row rejected as its duplicate and then gets dropped
// itself as undeclared — suppressing the authoritative surface entirely. The
// guarantee is that the served set IS the declared set, so a declared surface
// has to survive contamination, not merely fail to be replaced by it.
function validateManifest(raw, label = "", keep = null) {
const tag = label ? `${label} ` : "";
const errors = [], warnings = [], valid = [];
let doc;
try { doc = JSON.parse(raw); }
catch (e) { return { errors: [`${tag}surfaces.json is not valid JSON: ${e.message}`], warnings, valid, doc: null }; }
if (!Array.isArray(doc.surfaces)) {
return { errors: [`${tag}surfaces.json must carry a top-level surfaces[] array`], warnings, valid, doc };
}
let rows = doc.surfaces;
if (keep) {
rows = [];
doc.surfaces.forEach((row, i) => {
if (row && typeof row === "object" && !keep(row)) {
warnings.push(
`${tag}surfaces[${i}]${row.id ? ` (id: ${row.id})` : ""}: NOT declared by this SDK — ` +
`IGNORED. An undeclared row in the installed package is contamination (a copied tree, a ` +
`stale materialisation, or a dependency writing into node_modules); surfaces you own ` +
`belong in your own directory, registered via --surfaces`
);
return;
}
rows.push(row);
});
}
const seen = new Set();
rows.forEach((row, i) => {
const where = `${tag}surfaces[${i}]${row && row.id ? ` (id: ${row.id})` : ""}`;
const rowErrors = [];
if (!row || typeof row !== "object") { errors.push(`${where}: row must be an object`); return; }
if (typeof row.id !== "string" || !row.id) rowErrors.push(`${where}: id is required (non-empty string)`);
else if (seen.has(row.id)) rowErrors.push(`${where}: duplicate id — ids must be unique`);
else seen.add(row.id);
if (typeof row.name !== "string" || !row.name) rowErrors.push(`${where}: name is required (non-empty string)`);
// exactly ONE of path | url, and url must be absolute
const hasPath = "path" in row, hasUrl = "url" in row;
if (hasPath && hasUrl) rowErrors.push(`${where}: carries BOTH path and url — a row targets exactly one`);
else if (!hasPath && !hasUrl) rowErrors.push(`${where}: needs exactly one of path (starting with "/") or an absolute url`);
if (hasPath && !(typeof row.path === "string" && row.path.startsWith("/")))
rowErrors.push(`${where}: path must be a string starting with "/"`);
if (hasUrl) {
// Two checks, both required (the same authority rule the schema's pattern
// declares, then a real parse):
// 1. syntactic: https?:// followed immediately by a non-empty authority —
// the WHATWG parser NORMALIZES forms like "http:///path" (empty
// authority) into "http://path/", silently diverging from the written
// string the shell would actually load, so a parse alone cannot catch
// them;
// 2. parse: protocol + non-empty hostname (catches "https://" etc.).
let parsed = null;
try { parsed = typeof row.url === "string" ? new URL(row.url) : null; } catch {}
const syntacticAuthority = typeof row.url === "string" && /^https?:\/\/[^/?#\s]+/.test(row.url);
if (!syntacticAuthority || !parsed || !["http:", "https:"].includes(parsed.protocol) || !parsed.hostname)
rowErrors.push(`${where}: url must be an absolute http(s) URL with a non-empty host (e.g. https://host/path)`);
}
for (const [k, v] of Object.entries(row)) {
if (["id", "name", "path", "url"].includes(k)) continue; // handled above
const want = ROW_OPTIONAL[k];
if (want === undefined) { warnings.push(`${where}: unknown field "${k}" (ignored; fine for forward-compat)`); continue; }
const got = Array.isArray(v) ? "array" : typeof v;
if (got !== want) rowErrors.push(`${where}: ${k} must be ${want}, got ${got}`);
}
if (rowErrors.length) errors.push(...rowErrors);
else valid.push(row);
});
return { errors, warnings, valid, doc };
}
// ---- manifest sources: the package's own, plus the optional consumer overlay
// Both go through the SAME load/recovery/guard code. Two copies of this logic
// would drift, and the consumer manifest is the one a real deployment actually
// redeploys — so it needs the redeploy-survival guarantees at least as much as the
// package's does, not a simplified version of them.
const signatureOf = (file) => {
try {
const st = fs.statSync(file);
return `${st.ino}:${st.mtimeMs}:${st.size}`;
} catch {
return "missing";
}
};
function makeSource(manifestPath, label) {
return {
path: manifestPath,
label,
loadedSignature: "unloaded",
result: { errors: [`${label ? label + " " : ""}surfaces.json not read yet`], warnings: [], valid: [], doc: null },
state: {
// "init" until the first load resolves. A process that starts healthy
// must report recoveries: 0 — booting is not recovering, and a counter
// that counted it would make "never broke" indistinguishable from
// "recovered", which is the confusion these fields exist to remove.
state: "init", // "init" | "ok" | "invalid" | "unreadable"
lastLoadedAt: null,
reloads: 0,
recoveries: 0,
lastRecoveryAt: null,
// Reloads the WATCH did not cause — the file at the served path changed
// identity and no event fired. Non-zero means the fast path is not
// holding for this deployment shape and correctness rests on the stat
// floor. (A directory swap is the usual reason.)
integrityReloads: 0,
lastIntegrityReloadAt: null,
},
};
}
// ---- what the SDK declares as ITS OWN surfaces ------------------------------
// AUTHORITY ROOT: in runtime source, because
// app/surfaces.json is the artifact contamination mutates. Materialisation
// appended rows to it and copied pages beside it; it never edited this file,
// and a copied node_modules tree carries this source unmodified.
//
// The declaration is the WHOLE ROW, not the id. An id-only authority is
// bypassable by reusing a declared id with a different path or label: an
// injected { id: "floor", name: "HIJACKED FLOOR", path: "/surfaces/hijack.html" }
// would be accepted as the SDK's own and serve attacker content under a trusted
// name. Declaring the row means the SDK vouches for exactly what it ships.
//
// A hardcoded declaration is discipline, so a committed test asserts this set
// deep-equals the rows the package actually ships — change FLOOR's path or name
// without updating here and the suite fails.
//
// NOT covered, deliberately: a dependency shipping a MODIFIED runtime source.
// That is supply-chain compromise, a higher-bar class already accepted by
// installing the SDK at all. See contract/manifest.md.
const SDK_DECLARED_SURFACES = [
{ id: "floor", name: "FLOOR", glyph: "●", path: "/surfaces/floor.html",
hint: "Live rig-floor seats and queue activity" },
];
// Canonical form so field ORDER cannot smuggle a difference past equality.
const canonical = (row) => JSON.stringify(Object.keys(row).sort().map((k) => [k, row[k]]));
const DECLARED_CANONICAL = new Set(SDK_DECLARED_SURFACES.map(canonical));
const isSdkDeclared = (row) => DECLARED_CANONICAL.has(canonical(row));
const packageSource = makeSource(MANIFEST_PATH, OVERLAY_MANIFEST ? "package" : "");
const consumerSource = OVERLAY_MANIFEST ? makeSource(OVERLAY_MANIFEST, "consumer") : null;
const sources = () => (consumerSource ? [packageSource, consumerSource] : [packageSource]);
function loadSource(src) {
const previous = src.state.state;
src.state.reloads += 1;
let raw;
try {
raw = fs.readFileSync(src.path, "utf8");
} catch (e) {
src.result = { errors: [`cannot read ${src.label ? src.label + " " : ""}surfaces.json: ${e.message}`], warnings: [], valid: [], doc: null };
src.state.state = "unreadable";
console.error(`manifest ERROR: ${src.result.errors[0]}`);
// Gone. If it returns at the same path it will be a NEW inode, so re-arm
// rather than trusting a watch that may be bound to the old one.
src.loadedSignature = signatureOf(src.path);
rearmWatches();
return;
}
src.loadedSignature = signatureOf(src.path);
// Authority is applied HERE, before validation, for the package source only
// when a consumer overlay makes enforcement active.
const keep = src === packageSource && consumerSource ? isSdkDeclared : null;
src.result = validateManifest(raw, src.label, keep);
src.state.state = src.result.errors.length === 0 ? "ok" : "invalid";
if (src.state.state === "ok") src.state.lastLoadedAt = new Date().toISOString();
for (const e of src.result.errors) console.error(`manifest ERROR: ${e}`);
for (const w of src.result.warnings) console.error(`manifest warn: ${w}`);
// A recovery is a TRANSITION into health, not merely being healthy.
if (src.state.state === "ok" && previous !== "ok" && previous !== "init") {
src.state.recoveries += 1;
src.state.lastRecoveryAt = src.state.lastLoadedAt;
console.log(
`manifest RECOVERED: ${src.path} — ${src.result.valid.length} ${src.label || "package"} surface(s) ` +
`after "${previous}" (recovery #${src.state.recoveries}, no restart)`
);
}
}
function loadManifest() {
for (const src of sources()) loadSource(src);
}
// ---- the merged rail --------------------------------------------------------
// Package rows first, then the consumer's. A consumer MAY deliberately replace
// a stock surface by reusing its id — but never silently: shadowing is named in
// warnings, because an operator who sees their own surface appear would
// otherwise have no way to learn that something was displaced.
function mergedSurfaces() {
const warnings = [];
const errors = [];
const owner = new Map(); // row.path -> "package" | "consumer"
// ONE RULE: serve exactly what is authoritatively DECLARED — the SDK's own
// surfaces plus the consumer's overlay-declared ones — and nothing else.
// Trust what is declared, not what is present.
//
// With an overlay active, a row in the package manifest that the SDK does not
// declare as its own is contamination: appended by materialisation, carried
// by a copied tree, or injected by a dependency. It is IGNORED for serving
// and WARNED, so a dirty package is visible rather than silently masked —
// dropping it quietly would hide the contamination instead of surfacing it.
// Undeclared package rows were already removed at validation time, before the
// duplicate-id check, so everything surviving here is authoritative.
const enforcing = Boolean(consumerSource);
const packageRows = packageSource.result.valid;
const byId = new Map(packageRows.map((row) => [row.id, row]));
for (const row of packageRows) {
if (typeof row.path === "string") owner.set(row.path, "package");
}
if (consumerSource) {
// A declared package row OWNS its path. A consumer row may take that path
// ONLY by shadowing at the ID level — the documented, warned way to replace
// a stock surface. Letting a different-id row claim it would hijack the
// package's page silently: both rail entries would load consumer bytes with
// no shadow warning, and replacement would become a filename act rather
// than an id act, which is exactly what the stable rule forbids.
const packagePaths = new Map(packageRows.map((r) => [r.path, r.id]).filter(([p]) => typeof p === "string"));
for (const row of consumerSource.result.valid) {
const collidesWith = typeof row.path === "string" ? packagePaths.get(row.path) : undefined;
if (collidesWith !== undefined && collidesWith !== row.id) {
errors.push(
`consumer surfaces.json (id: ${row.id}): path "${row.path}" is already owned by the ` +
`package surface "${collidesWith}" — EXCLUDED. Replace a package surface by reusing its ` +
`id (which warns), not by reusing its path`
);
continue;
}
if (byId.has(row.id)) {
warnings.push(
`consumer surfaces.json: id "${row.id}" SHADOWS the package surface of the same id — ` +
`the consumer row is served`
);
}
byId.set(row.id, row); // Map keeps the original position on overwrite
if (typeof row.path === "string") owner.set(row.path, "consumer");
}
}
// A path only belongs to a source if a row that SURVIVED merge still claims it.
const live = new Set([...byId.values()].map((r) => r.path).filter(Boolean));
for (const key of [...owner.keys()]) if (!live.has(key)) owner.delete(key);
// Undeclared FILES sitting in the package are warned too — a phantom page can
// be copied in without a row, and silence would hide it.
if (enforcing) {
const served = new Set([...owner.entries()].filter(([, src]) => src === "package").map(([p]) => path.basename(p)));
let present = [];
try { present = fs.readdirSync(path.join(HERE, "surfaces")).filter((f) => !f.startsWith(".")); } catch { /* none */ }
for (const file of present) {
if (!served.has(file)) {
warnings.push(
`package app/surfaces/${file}: present but NOT declared by this SDK — not served. ` +
`An undeclared file in the installed package is contamination`
);
}
}
}
return { rows: [...byId.values()], warnings, errors, owner };
}
// The aggregate the rail actually is: every source's errors and warnings, and
// the merged row count. Per-source detail hangs off it so a reader can tell
// WHICH manifest is unhealthy rather than only that something is.
function manifestReport() {
const merged = mergedSurfaces();
// Merge-level problems (a consumer row colliding with a package-owned path)
// are errors in their own right, not just per-source validation failures.
const errors = [...sources().flatMap((s) => s.result.errors), ...merged.errors];
const warnings = [...sources().flatMap((s) => s.result.warnings), ...merged.warnings];
return {
ok: errors.length === 0,
errors,
warnings,
surfaces: merged.rows.length,
// Top-level state fields describe the PACKAGE source, unchanged in meaning
// from before the seam existed. The consumer's are under `consumer`.
...packageSource.state,
consumer: consumerSource
? { dir: OVERLAY_DIR, surfaces: consumerSource.result.valid.length, ...consumerSource.state }
: null,
};
}
// NOTE: the startup load is deliberately NOT called here. loadManifest's
// read-failure path reaches for the watcher machinery declared further down, so
// calling it at this point put a `let` binding in its temporal dead zone and
// the process died at boot whenever surfaces.json happened to be missing —
// precisely the moment a redeploy is mid-flight. Hoisting the one binding that
// tripped first only moved the tripwire. See the init block at the bottom of
// this file: nothing runs until every declaration exists.
// ---- observe: the factory/state envelope (fixture-backed) ------------------
// The envelope is runtime-designed and declared field-by-field in
// contract/runtime-api.md. generatedAt is stamped fresh on every read so a
// consumer can tell a live response from a cached one.
function factoryState() {
const f = path.join(FIXTURES, "factory-state.json");
let d;
try { d = JSON.parse(fs.readFileSync(f, "utf8")); }
catch (e) { return { ok: false, degraded: `fixture unreadable: ${e.message}`, rig: null, seats: [], queue: [] }; }
return { ok: true, rig: d.rig || "fixture", generatedAt: new Date().toISOString(),
seats: Array.isArray(d.seats) ? d.seats : [], queue: Array.isArray(d.queue) ? d.queue : [] };
}
// ---- stream: SSE change-signal (signal-only contract) ----------------------
// Contract: a message means "state changed — re-fetch what you care about via
// the observe verbs." Payload contents are unspecified in v0 and must not be
// parsed. A `degraded` event means the signal source is unhealthy; fall back
// to polling. Heartbeat comments keep intermediaries from closing the stream.
const sseClients = new Set();
function sseBroadcast() {
for (const res of sseClients) { try { res.write(`data: change\n\n`); } catch {} }
}
// ---- watching: must survive delete -> recreate -----------------------------
// Watching surfaces.json BY PATH binds the watch to that file's inode. Delete
// and recreate it — which is what npm ci, write-tmp+rename deploys, redeploys
// over an existing install, and save-by-rename editors all do — and the new
// file has a NEW inode. The old watch never fires again, so the runtime sits on
// ENOENT forever, serving an empty rail with a 200, until someone restarts it.
//
// Fix: watch the manifest's PARENT DIRECTORY and match by filename. A directory
// inode survives files being replaced inside it, which is what makes this the
// atomic-rename-safe pattern — and it covers in-place edit, delete+recreate and
// tmp+rename with one mechanism.
//
// Belt and braces: rearmWatches() is also called when a load hits ENOENT and
// when a watcher emits an error, so a watch lost some other way — including the
// watched DIRECTORY itself being replaced, which the parent-dir watch does not
// cover — re-establishes instead of dying silently.
const MANIFEST_DIR = path.dirname(MANIFEST_PATH);
const MANIFEST_FILE = path.basename(MANIFEST_PATH);
let watchers = [];
let rearmTimer = null;
function watchForChanges() {
for (const w of watchers) { try { w.close(); } catch {} }
watchers = [];
const targets = [
// The manifest's PARENT directory — not the manifest file itself.
{ dir: MANIFEST_DIR, recursive: false, onEvent: (name) => {
if (name === MANIFEST_FILE) loadSource(packageSource);
} },
{ dir: FIXTURES, recursive: true, onEvent: () => {} },
{ dir: path.join(HERE, "surfaces"), recursive: true, onEvent: (name) => {
if (name === MANIFEST_FILE) loadSource(packageSource);
} },
];
// The consumer overlay gets the SAME treatment. Without this, the redeploy-survival defect class returns and the
// entire defect class returns for consumer surfaces — which are precisely the
// ones a real deployment redeploys.
if (consumerSource) {
targets.push({ dir: OVERLAY_DIR, recursive: true, onEvent: (name) => {
if (name === MANIFEST_FILE) loadSource(consumerSource);
} });
}
for (const target of targets) {
try {
const w = fs.watch(target.dir, { recursive: target.recursive }, (_ev, name) => {
target.onEvent(name);
sseBroadcast();
});
// A dead watcher must not fail silently — that is the whole defect class.
w.on("error", () => rearmWatches());
watchers.push(w);
} catch { /* a missing watch target is fine; rearm will retry */ }
}
}
// Debounced so a burst of filesystem events (npm ci, a deploy) re-arms once
// rather than once per event.
function rearmWatches() {
if (rearmTimer) return;
rearmTimer = setTimeout(() => {
rearmTimer = null;
watchForChanges();
}, 150);
rearmTimer.unref?.();
}
// ---- the integrity guard: watches are an optimisation, this is correctness --
//
// Every fs.watch in this file follows an INODE. That is fine until a redeploy
// replaces the thing being watched, and there are two shapes that do:
//
// npm ci / delete+recreate — the FILE is replaced. The parent-dir watch
// catches it, and the runtime goes ENOENT in
// between, so the failure is at least visible.
// mv new current — the DIRECTORY is replaced. The watch keeps
// following the OLD directory inode, which still
// exists under its new name. No event ever fires,
// no error is raised, and the runtime serves the
// PREVIOUS manifest indefinitely while reporting
// state "ok". That is worse than the original ENOENT bug,
// slice started on, because it looks healthy.
//
// No arrangement of watches fixes the second case: the watch is not wrong, it
// is watching a directory that is no longer the one at that path. So the
// backstop is not a better watch, it is to stop trusting watches for
// correctness — stat the path we actually serve and compare identity.
//
// One stat per second. The watch still provides the fast path; this bounds the
// worst case for every replacement shape, including ones not thought of here.
// unref'd so it never holds the process open.
function integrityCheck() {
// Every source, not just the package's. The consumer manifest is the one a
// real deployment actually redeploys, so it needs this floor at least as much.
for (const src of sources()) {
const current = signatureOf(src.path);
if (current === src.loadedSignature) continue;
// Say so BEFORE correcting it. A silent correction rebuilds this slice's
// own pathology one level up: on a clean directory swap the source goes
// ok -> ok, so no recovery is reported and nothing distinguishes "the watch
// saw it" from "the watch missed it and the floor caught it".
const wasHealthy = src.state.state === "ok";
src.state.integrityReloads += 1;
src.state.lastIntegrityReloadAt = new Date().toISOString();
console.log(
`manifest REPLACED: ${src.path} — the file at this path is not the one ` +
`loaded (was ${wasHealthy ? "serving normally" : `"${src.state.state}"`}); ` +
`no watch event fired, caught by the integrity guard ` +
`(integrity reload #${src.state.integrityReloads})`
);
watchForChanges(); // the directory may be a different inode now
loadSource(src);
}
}
setInterval(integrityCheck, 1000).unref?.();
setInterval(() => { for (const res of sseClients) { try { res.write(`: heartbeat\n\n`); } catch {} } }, 25000).unref();
// ---- files: read-only, root-pinned ----------------------------------------
// Containment is enforced on the REAL path (symlinks fully resolved), not the
// lexical one: a symlink under the root that resolves outside it is refused.
// (A lexical startsWith check follows a symlink straight out of the root.)
const insideRoot = (p) => {
let real, rootReal;
try { real = fs.realpathSync(p); rootReal = fs.realpathSync(FILES_ROOT); } catch { return null; }
return real === rootReal || real.startsWith(rootReal + path.sep) ? real : null;
};
const FILE_KINDS = { image: /\.(png|jpe?g|gif|webp|svg)$/i, video: /\.(mp4|mov|webm)$/i,
audio: /\.(mp3|wav|m4a|flac)$/i, markdown: /\.(md|markdown)$/i, html: /\.html?$/i,
text: /\.(txt|json|jsonl|yaml|yml|mjs|js|ts|css|py|sh|toml|csv)$/i };
const fileKind = (name) => Object.entries(FILE_KINDS).find(([, re]) => re.test(name))?.[0] || "other";
function filesTree(dir) {
const r = dir ? insideRoot(dir) : insideRoot(FILES_ROOT);
if (!r || !fs.existsSync(r)) return null;
const dirs = [], files = [];
for (const name of fs.readdirSync(r).sort()) {
if (name.startsWith(".")) continue;
const full = path.join(r, name);
const real = insideRoot(full); // symlink resolving outside the root: omit
if (!real) continue;
let st; try { st = fs.statSync(full); } catch { continue; }
if (st.isDirectory()) dirs.push({ name, path: full });
else files.push({ name, path: full, size: st.size, mtime: st.mtimeMs, kind: fileKind(name) });
}
return { path: r, dirs, files };
}
function filesSearch(q) {
const needle = q.toLowerCase(), hits = [];
const seen = new Set(); // real paths already walked — symlink-cycle safety
const walk = (dir, depth) => {
if (hits.length >= 60 || depth > 6) return;
const realDir = insideRoot(dir);
if (!realDir || seen.has(realDir)) return;
seen.add(realDir);
let names; try { names = fs.readdirSync(realDir); } catch { return; }
for (const name of names) {
if (name.startsWith(".")) continue;
const full = path.join(realDir, name);
if (!insideRoot(full)) continue; // escaping symlink: omit
let st; try { st = fs.statSync(full); } catch { continue; }
if (st.isDirectory()) walk(full, depth + 1);
else if (name.toLowerCase().includes(needle)) hits.push({ name, path: full, kind: fileKind(name) });
}
};
walk(FILES_ROOT, 0);
return hits.slice(0, 60);
}
// ---- HTTP ------------------------------------------------------------------
// ---- init: everything above is declarations -------------------------------
// Ordering here is a correctness property, not a style preference. This block
// sits after EVERY module binding — verified by a test, not by reading — so
// startup cannot land in a temporal dead zone no matter what these functions
// grow to touch later.
//
// The weaker arrangement (init partway down, "safe" because the current call
// graph happens not to reach the later bindings) is what produced two TDZ
// crashes in this file already. The second was found by QA after the first was
// "fixed" by hoisting the single binding that tripped. Add new startup work
// HERE, not next to the function it calls.
watchForChanges();
loadManifest();
http.createServer((req, res) => {
const u = new URL(req.url, `http://127.0.0.1:${PORT}`);
try {
if (u.pathname === "/api/contract") {
return sendJson(res, 200, {
contractVersion: CONTRACT_VERSION,
runtime: { name: "openrig-studio", flavor: "reference-fixture" },
capabilities: CAPABILITIES,
manifest: manifestReport(),
});
}
if (u.pathname === "/api/factory/state") return sendJson(res, 200, factoryState());
if (u.pathname === "/api/events") {
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
res.write(`: connected\n\n`);
sseClients.add(res);
req.on("close", () => sseClients.delete(res));
return;
}
if (u.pathname === "/api/files/tree") {
const t = filesTree(u.searchParams.get("dir"));
return t ? sendJson(res, 200, { ok: true, ...t })
: sendJson(res, 400, { ok: false, error: "path outside the files root — files verbs are pinned to the runtime's files-root" });
}
if (u.pathname === "/api/files/read") {
const r = insideRoot(u.searchParams.get("path") || "");
if (!r || !fs.existsSync(r) || fs.statSync(r).isDirectory())
return sendJson(res, 404, { ok: false, error: "not found or outside the files root" });
const kind = fileKind(r);
if (["markdown", "text", "html"].includes(kind) || (kind === "other" && fs.statSync(r).size < 512 * 1024))
return sendJson(res, 200, { ok: true, kind: kind === "other" ? "text" : kind,
content: fs.readFileSync(r, "utf8"), mtime: fs.statSync(r).mtimeMs });
return sendJson(res, 200, { ok: true, kind, raw: "/api/files/raw?path=" + encodeURIComponent(r), mtime: fs.statSync(r).mtimeMs });
}
if (u.pathname === "/api/files/raw") {
const r = insideRoot(u.searchParams.get("path") || "");
if (!r || !fs.existsSync(r)) { res.writeHead(404); return res.end(); }
res.writeHead(200, { "content-type": TYPES[path.extname(r).toLowerCase()] || "application/octet-stream",
"content-length": fs.statSync(r).size, "cache-control": "no-store" });
return fs.createReadStream(r).pipe(res);
}
if (u.pathname === "/api/files/search") {
const q = (u.searchParams.get("q") || "").trim();
return sendJson(res, 200, { ok: true, hits: q.length < 2 ? [] : filesSearch(q) });
}
if (u.pathname.startsWith("/api/")) {
return sendJson(res, 404, { ok: false,
error: `no such contract route: ${u.pathname} — see GET /api/contract for capabilities and contract/runtime-api.md for the verb set` });
}
} catch (e) { return sendJson(res, 500, { ok: false, error: String(e.message || e) }); }
// the manifest is served as its VALIDATED projection, not the raw file —
// this is where "invalid rows are excluded from the served rail" is true.
// The raw file stays on disk for editing; consumers see valid rows only,
// and the validation report lives at /api/contract.
if (u.pathname === "/surfaces.json") {
const doc = packageSource.result.doc && typeof packageSource.result.doc === "object" ? packageSource.result.doc : {};
return sendJson(res, 200, { ...doc, surfaces: mergedSurfaces().rows });
}
// static: shell.html at /, surfaces + assets from the app dir — and, when a
// consumer overlay is configured, from the consumer's dir FIRST so that a row
// and its page cannot disagree about which file wins.
//
// Containment is enforced on the REAL path for both roots. The overlay is a
// new file-serving root, and a new root that skips the containment the files
// verbs already have would be the one place traversal works.
const rel = (u.pathname === "/" ? "shell.html" : u.pathname).replace(/^\/+/, "");
// Resolve `sub` under `root`, refusing anything that escapes it. Containment
// is checked on the REAL path, so a symlink inside the root that points out
// of it is refused rather than followed.
const within = (root, sub) => {
const candidate = path.join(root, path.normalize(sub));
let real, realRoot;
try { real = fs.realpathSync(candidate); realRoot = fs.realpathSync(root); } catch { return null; }
if (real !== realRoot && !real.startsWith(realRoot + path.sep)) return null;
if (fs.statSync(real).isDirectory()) return null;
return real;
};
// Which source owns this URL is decided by the VALIDATED, MERGED rows — not
// by which directory happens to contain a matching file.
//
// Serving the overlay path-first made it a public static root: any file under
// it answered /surfaces/<name> whether or not a row registered it, so an
// unregistered consumer file silently outranked a registered package page and
// an unrelated file in that directory was published. It also let row
// ownership and page ownership disagree, which is exactly what the id-based,
// warned shadow semantics exist to prevent.
const ownedBy = OVERLAY_DIR ? mergedSurfaces().owner.get(u.pathname) : undefined;
if (ownedBy === "consumer") {
const file = within(OVERLAY_DIR, rel.slice("surfaces/".length));
if (!file) {
// NO cross-owner fallback. A registered consumer row whose page is
// missing must fail as itself — quietly serving the package's bytes
// under the consumer's row would be a lie about what is being served.
res.writeHead(404, { "content-type": "text/plain" });
return res.end(
`not found: ${u.pathname}\n` +
`a consumer surface is registered for this path but its page is missing from ${OVERLAY_DIR}\n` +
`(the package's page is NOT substituted — registration and file must agree)`
);
}
res.writeHead(200, { "content-type": TYPES[path.extname(file)] || "application/octet-stream", "cache-control": "no-store" });
return res.end(fs.readFileSync(file));
}
// With the seam active, an UNDECLARED file under /surfaces/ is unreachable.
// Rejecting a contaminated ROW while still serving its PAGE would leave the
// artifact half-published: off the rail but fetchable by anyone who knows the
// name, which is how a "removed" surface keeps working. It is warned in the
// manifest report, so it is visible rather than merely absent.
//
// Restricted to /surfaces/ deliberately: the shell and package assets resolve
// as they always have, and with no overlay configured nothing here changes.
if (OVERLAY_DIR && rel.startsWith("surfaces/") && ownedBy === undefined) {
res.writeHead(404, { "content-type": "text/plain" });
return res.end(
`not found: ${u.pathname}\n` +
`no declared surface claims this path — the runtime serves only the SDK's own declared ` +
`surfaces plus your overlay-declared ones\n` +
`(if this file is in the installed package, it is undeclared content; see manifest.warnings ` +
`at /api/contract)`
);
}
// Everything else — the shell, package surfaces, package assets — resolves
// inside the package exactly as it did before the seam existed.
const file = within(HERE, rel);
if (!file) {
res.writeHead(404, { "content-type": "text/plain" });
return res.end(`not found: ${u.pathname}\n(surfaces live under /surfaces/, contract meta at /api/contract)`);
}
res.writeHead(200, { "content-type": TYPES[path.extname(file)] || "application/octet-stream", "cache-control": "no-store" });
res.end(fs.readFileSync(file));
}).listen(PORT, "127.0.0.1", () => {
console.log(`openrig studio runtime: http://127.0.0.1:${PORT}/ (contract v${CONTRACT_VERSION})`);
console.log(`fixtures: ${FIXTURES}`);
const report = manifestReport();
if (OVERLAY_DIR) console.log(`consumer surfaces: ${OVERLAY_DIR} (repo-owned; nothing is written into the package)`);
if (report.errors.length) console.error(`manifest has ${report.errors.length} error(s) — serving ${report.surfaces} valid surface(s); see /api/contract`);
});
+187
View File
@@ -0,0 +1,187 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>openrig studio</title>
<style>
:root {
--ink: #121214; --panel: rgba(255,255,255,.025); --edge: rgba(255,255,255,.10);
--edge2: rgba(255,255,255,.16); --bone: rgba(255,255,255,.95); --dim: rgba(255,255,255,.52);
--body: rgba(255,255,255,.68); --faint: rgba(255,255,255,.38); --red: #ff6b5a;
--sans: Helvetica, "Helvetica Neue", Arial, sans-serif;
}
* { box-sizing: border-box; }
html { color-scheme: dark; }
html, body { height: 100%; margin: 0; background: var(--ink); font-family: var(--sans); }
#app { display: flex; height: 100vh; }
#rail { width: 212px; min-width: 212px; background: var(--panel); border-right: 1px solid var(--edge);
display: flex; flex-direction: column; }
#rail-head { padding: 20px 16px 10px; color: var(--bone); font-weight: 700; letter-spacing: 4px; font-size: 13px; }
#rail-head small { display: block; color: var(--faint); font-weight: 400; letter-spacing: .14em;
font-size: 9.5px; margin-top: 5px; text-transform: uppercase; }
#tabs { flex: 1; overflow-y: auto; padding: 6px 8px; }
.tab { display: flex; align-items: center; gap: 10px; width: 100%; padding: 9px 10px; margin: 2px 0;
border: 1px solid transparent; border-radius: 2px; background: none; color: var(--body); cursor: pointer;
font: inherit; font-size: 12.5px; text-align: left; }
.tab:hover { color: var(--bone); border-color: var(--edge2); }
.tab.active { color: #111; background: var(--bone); border-color: var(--bone); font-weight: 600; }
.tab .glyph { font-size: 14px; width: 18px; text-align: center; }
.tab .dot { margin-left: auto; width: 6px; height: 6px; border-radius: 50%; background: var(--edge2); flex: none; }
.tab .dot.live { background: var(--bone); box-shadow: 0 0 6px rgba(255,255,255,.5); }
.tab.active .dot.live { background: #111; }
.tab .dot.down { background: var(--red); }
#manifest-health { padding: 8px 16px; font-size: 10px; letter-spacing: .06em; color: var(--faint); }
#manifest-health.bad { color: var(--red); }
#rail-foot { padding: 12px 16px; border-top: 1px solid var(--edge); color: var(--faint);
font-size: 10px; line-height: 1.6; }
#stage { flex: 1; position: relative; background: var(--ink); }
#stage iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; background: var(--ink); }
#stage iframe.hidden { visibility: hidden; pointer-events: none; }
#empty { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
color: var(--faint); font-size: 11px; letter-spacing: .2em; text-transform: uppercase; }
#agents { width: 320px; min-width: 320px; display: none; flex-direction: column;
border-left: 1px solid var(--edge); background: var(--panel); }
#agents.open { display: flex; }
#agents-head { display: flex; align-items: center; padding: 12px 14px; border-bottom: 1px solid var(--edge); }
#agents-head .word { color: var(--bone); font-size: 9.5px; letter-spacing: .22em; font-weight: 700; cursor: pointer; }
#agents-body { padding: 14px; color: var(--body); font-size: 12px; line-height: 1.6; }
#agents-body .seatrow { padding: 8px 10px; border: 1px solid var(--edge2); border-radius: 2px; margin-bottom: 6px; }
#agents-body .honest { color: var(--faint); font-size: 10.5px; margin-top: 10px; }
#agentToggle { position: absolute; right: 14px; top: 14px; z-index: 40; background: var(--bone);
border: none; border-radius: 2px; color: #111; font: inherit; font-size: 9.5px; letter-spacing: .18em;
text-transform: uppercase; font-weight: 600; padding: 9px 16px; cursor: pointer; }
/* hideAgents rows suppress the sidebar toggle while active (shell-protocol) */
body.hide-agents #agentToggle { display: none !important; }
</style>
</head>
<body>
<div id="app">
<nav id="rail">
<div id="rail-head">STUDIO<small>openrig studio · contract v0.1</small></div>
<div id="tabs"></div>
<div id="manifest-health"></div>
<div id="rail-foot">surfaces are rooms.<br/>this is the hallway.</div>
</nav>
<main id="stage"><div id="empty">no surfaces in the manifest yet</div>
<button id="agentToggle" title="the seat roster">AGENTS ▸</button>
</main>
<aside id="agents">
<div id="agents-head"><span class="word" id="agentsWord">AGENTS ▾</span></div>
<div id="agents-body"></div>
</aside>
</div>
<script>
(async () => {
// --- contract meta first: the shell itself is a contract consumer -------
let meta = null;
try { meta = await (await fetch("/api/contract", { cache: "no-store" })).json(); } catch {}
const health = document.getElementById("manifest-health");
if (!meta) { health.className = "bad"; health.textContent = "runtime unreachable"; }
else if (!meta.manifest.ok) {
health.className = "bad";
health.textContent = `manifest: ${meta.manifest.errors.length} error(s) — see /api/contract`;
} else if (meta.manifest.warnings.length) {
health.textContent = `manifest ok · ${meta.manifest.warnings.length} warning(s)`;
} else { health.textContent = "manifest ok"; }
// --- surfaces from the manifest (valid rows only are served) ------------
let reg = { surfaces: [] };
try { reg = await (await fetch("/surfaces.json", { cache: "no-store" })).json(); } catch {}
// defense in depth: the runtime already serves valid rows only; still never
// render a row that lacks a target (a path-less row would recurse into "/")
const surfaces = (reg.surfaces || [])
.filter((s) => s && s.id && (s.url || (typeof s.path === "string" && s.path.startsWith("/"))))
.map((s) => s.url ? s : ({ ...s, url: location.origin + s.path }));
const tabsEl = document.getElementById("tabs");
const stage = document.getElementById("stage");
const frames = new Map();
let activeId = null;
const byId = (id) => surfaces.find((s) => s.id === id);
function activate(id, fromNav) {
const s = byId(id); if (!s) return;
if (!fromNav && activeId !== id) { try { history.pushState({ s: id }, "", "?s=" + encodeURIComponent(id)); } catch {} }
if (s.popout) { window.open(s.url, "os-" + s.id); return; }
activeId = id;
document.getElementById("empty").style.display = "none";
// unmountOnBlur rows release their frame when backgrounded (terminal-
// style surfaces must not keep hidden attached clients alive)
for (const [fid, f] of frames) {
if (fid !== id && (byId(fid) || {}).unmountOnBlur) { f.remove(); frames.delete(fid); }
}
if (!frames.has(id)) {
const f = document.createElement("iframe");
f.src = s.url; f.title = s.name;
stage.appendChild(f); frames.set(id, f);
}
for (const [fid, f] of frames) f.classList.toggle("hidden", fid !== id);
for (const el of tabsEl.children) el.classList.toggle("active", el.dataset.id === id);
document.body.classList.toggle("hide-agents", !!s.hideAgents);
document.title = "openrig studio · " + s.name.toLowerCase();
}
function renderTabs() {
tabsEl.innerHTML = "";
for (const s of surfaces) {
const b = document.createElement("button");
b.className = "tab"; b.dataset.id = s.id;
b.innerHTML = `<span class="glyph">${s.glyph || "□"}</span><span>${s.name}</span><span class="dot" id="dot-${s.id}"></span>`;
b.title = s.hint || "";
b.onclick = () => activate(s.id);
if (s.id === activeId) b.classList.add("active");
tabsEl.appendChild(b);
}
}
renderTabs();
// liveness dots: an opaque no-cors fetch succeeds iff something answers
async function pulse() {
await Promise.all(surfaces.map(async (s) => {
const dot = document.getElementById("dot-" + s.id); if (!dot) return;
try { await fetch(s.url, { mode: "no-cors", cache: "no-store", signal: AbortSignal.timeout(2500) });
dot.className = "dot live"; } catch { dot.className = "dot down"; }
}));
}
pulse(); setInterval(pulse, 15000);
// deep link
const qs = new URLSearchParams(location.search).get("s");
if (byId(qs)) activate(qs, true); else if (surfaces[0]) activate(surfaces[0].id, true);
addEventListener("popstate", (e) => {
const id = (e.state && e.state.s) || new URLSearchParams(location.search).get("s");
if (id && byId(id)) activate(id, true);
});
// --- the shell protocol: the ONLY surface->shell API --------------------
addEventListener("message", (e) => {
if (!e.data || typeof e.data !== "object") return;
if (e.data.t === "open-surface" && byId(e.data.id)) activate(e.data.id);
if (e.data.t === "open-agent") openAgents(e.data.seat);
});
// --- agent sidebar: the contracted seat surface is roster + summon ------
// This reference runtime has no live seat backing; the sidebar says so
// honestly instead of pretending (a seat that looks attached but isn't
// would be worse than none).
const agents = document.getElementById("agents");
const agentsBody = document.getElementById("agents-body");
function openAgents(seat) {
const roster = reg.chatSeats || [];
agentsBody.innerHTML = (roster.length
? roster.map((r) => `<div class="seatrow"${r.seat === seat ? ' style="border-color:rgba(255,255,255,.5)"' : ""}>${r.name || r.seat}<br/><span style="color:var(--faint);font-size:10px">${r.seat}</span></div>`).join("")
: `<div>no seats in the manifest roster</div>`)
+ `<div class="honest">reference runtime: seat rows are declared in the manifest; live seat attach requires a seat backend, which this fixture-backed runtime does not include. Summoning works; typing is runtime-internal.</div>`;
agents.classList.add("open");
document.getElementById("agentToggle").style.display = "none";
}
document.getElementById("agentToggle").onclick = () => openAgents();
document.getElementById("agentsWord").onclick = () => {
agents.classList.remove("open");
document.getElementById("agentToggle").style.display = "";
};
})();
</script>
</body>
</html>
+18
View File
@@ -0,0 +1,18 @@
{
"_note": "Surface manifest \u2014 the registry the shell renders and the runtime validates (warn-first). Row schema: contract/surface-row.schema.json; field semantics: contract/manifest.md. surfaces[] is the app-facing population. chatSeats[] is runtime-owned roster state (rendered by the shell's agent sidebar; not part of the app-facing schema).",
"surfaces": [
{
"id": "floor",
"name": "FLOOR",
"glyph": "●",
"path": "/surfaces/floor.html",
"hint": "Live rig-floor seats and queue activity"
}
],
"chatSeats": [
{
"seat": "studio-lead@fixture",
"name": "LEAD"
}
]
}
View File
Whitespace-only changes.
+206
View File
@@ -0,0 +1,206 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Rig Floor</title>
<style>
:root {
color-scheme: dark;
--bg: #0b0e13;
--panel: #121722;
--line: #252c3a;
--text: #f3f5f8;
--muted: #929cad;
--working: #47d18c;
--idle: #6d9eff;
--blocked: #ffb44a;
--stalled: #ff795f;
--down: #77808f;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(circle at 80% 0%, #172032 0, transparent 34%), var(--bg);
color: var(--text);
font: 14px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
main { width: min(1040px, 100%); margin: 0 auto; padding: 28px; }
header { display: flex; align-items: end; justify-content: space-between; gap: 18px; margin-bottom: 24px; }
.eyebrow { color: var(--working); font-size: 11px; letter-spacing: .16em; text-transform: uppercase; }
h1 { margin: 4px 0 0; font: 600 clamp(28px, 5vw, 46px)/1.05 system-ui, sans-serif; letter-spacing: -.04em; }
.connection { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; }
.pulse { width: 8px; height: 8px; border-radius: 50%; background: var(--blocked); box-shadow: 0 0 0 4px #ffb44a18; }
.connection.live .pulse { background: var(--working); box-shadow: 0 0 0 4px #47d18c18; }
.connection.polling .pulse { background: var(--idle); box-shadow: 0 0 0 4px #6d9eff18; }
.summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 20px; }
.stat, .seat, .queue-item, .notice { border: 1px solid var(--line); background: color-mix(in srgb, var(--panel) 94%, transparent); border-radius: 12px; }
.stat { padding: 14px 16px; }
.stat b { display: block; font: 650 24px/1 system-ui, sans-serif; margin-bottom: 5px; }
.stat span, .meta, .empty { color: var(--muted); font-size: 12px; }
section { margin-top: 24px; }
h2 { margin: 0 0 10px; color: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; }
.seats { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px; }
.seat { padding: 15px; position: relative; overflow: hidden; }
.seat::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 3px; background: var(--state); }
.seat-top { display: flex; justify-content: space-between; gap: 12px; align-items: start; }
.seat-name { overflow-wrap: anywhere; font-weight: 650; }
.badge { color: var(--state); border: 1px solid color-mix(in srgb, var(--state) 45%, transparent); border-radius: 999px; padding: 2px 7px; font-size: 10px; text-transform: uppercase; }
.seat .meta { margin-top: 12px; display: flex; justify-content: space-between; gap: 8px; }
.working { --state: var(--working); }
.idle { --state: var(--idle); }
.blocked { --state: var(--blocked); }
.stalled { --state: var(--stalled); }
.down { --state: var(--down); }
.queue { display: grid; gap: 8px; }
.queue-item { padding: 13px 15px; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px 16px; }
.queue-title { font-weight: 600; }
.queue-state { color: var(--idle); font-size: 11px; text-transform: uppercase; }
.queue-route { grid-column: 1 / -1; color: var(--muted); font-size: 11px; overflow-wrap: anywhere; }
.notice { padding: 18px; color: var(--blocked); }
footer { margin-top: 22px; color: var(--muted); font-size: 11px; }
@media (max-width: 600px) {
main { padding: 18px; }
header { align-items: start; flex-direction: column; }
.summary { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main>
<header>
<div><div class="eyebrow">Live activity</div><h1 id="rig">Rig floor</h1></div>
<div id="connection" class="connection"><span class="pulse"></span><span>connecting</span></div>
</header>
<div id="content"><div class="notice">Reading the runtime contract…</div></div>
<footer id="updated"></footer>
</main>
<script>
const content = document.querySelector('#content');
const rig = document.querySelector('#rig');
const updated = document.querySelector('#updated');
const connection = document.querySelector('#connection');
let events;
let pollTimer;
let reconnectTimer;
const escapeHtml = (value) => String(value ?? '').replace(/[&<>'"]/g, char => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;'
})[char]);
function setConnection(mode, label) {
connection.className = `connection ${mode}`;
connection.lastElementChild.textContent = label;
}
function render(state) {
if (!state.ok) {
content.innerHTML = `<div class="notice">Data degraded: ${escapeHtml(state.degraded || 'unknown reason')}</div>`;
rig.textContent = 'Rig floor';
updated.textContent = '';
return;
}
rig.textContent = state.rig;
const working = state.seats.filter(seat => seat.state === 'working').length;
const attention = state.seats.filter(seat => ['blocked', 'stalled', 'down'].includes(seat.state)).length;
const pending = state.queue.length;
const seats = state.seats.map(seat => `
<article class="seat ${escapeHtml(seat.state)}">
<div class="seat-top">
<div class="seat-name">${escapeHtml(seat.seat)}</div>
<span class="badge">${escapeHtml(seat.state)}</span>
</div>
<div class="meta">
<span>${escapeHtml(seat.pod)} / ${escapeHtml(seat.member)}</span>
<span>${seat.ageMinutes == null ? 'age unknown' : `${escapeHtml(seat.ageMinutes)}m`}</span>
</div>
<div class="meta">
<span>${escapeHtml(seat.lifecycle)}</span>
<span>${escapeHtml(seat.pendingWork)} pending</span>
</div>
</article>`).join('') || '<div class="empty">No seats reported.</div>';
const queue = state.queue.map(item => `
<article class="queue-item">
<div class="queue-title">${escapeHtml(item.title)}</div>
<div class="queue-state">${escapeHtml(item.state)}</div>
<div class="queue-route">${escapeHtml(item.source)} → ${escapeHtml(item.destination)}</div>
</article>`).join('') || '<div class="empty">Queue is clear.</div>';
content.innerHTML = `
<div class="summary">
<div class="stat"><b>${working}</b><span>working now</span></div>
<div class="stat"><b>${attention}</b><span>need attention</span></div>
<div class="stat"><b>${pending}</b><span>queue items</span></div>
</div>
<section><h2>Seats · ${state.seats.length}</h2><div class="seats">${seats}</div></section>
<section><h2>Queue · ${state.queue.length}</h2><div class="queue">${queue}</div></section>`;
updated.textContent = `Runtime snapshot · ${new Date(state.generatedAt).toLocaleString()}`;
}
async function refresh() {
try {
const response = await fetch('/api/factory/state', { cache: 'no-store' });
const state = await response.json();
render(state);
} catch (error) {
content.innerHTML = `<div class="notice">Unable to read floor state: ${escapeHtml(error.message)}</div>`;
}
}
function startPolling() {
clearInterval(pollTimer);
pollTimer = setInterval(refresh, 15000);
setConnection('polling', 'polling fallback');
}
function connectEvents() {
clearTimeout(reconnectTimer);
if (events) events.close();
events = new EventSource('/api/events');
events.onopen = () => {
clearInterval(pollTimer);
setConnection('live', 'live signal');
refresh();
};
events.onmessage = () => refresh();
events.addEventListener('degraded', () => {
events.close();
startPolling();
reconnectTimer = setTimeout(connectEvents, 15000);
});
events.onerror = () => {
events.close();
startPolling();
reconnectTimer = setTimeout(connectEvents, 15000);
};
}
async function boot() {
try {
const response = await fetch('/api/contract', { cache: 'no-store' });
const contract = await response.json();
const needed = ['observe.factory-state', 'stream.events'];
const missing = needed.filter(capability => !contract.capabilities.includes(capability));
// contract-meta compatibility rule: capabilities present + same major.
// (Exact-match rejects contract-compatible minor runtimes.)
const major = String(contract.contractVersion ?? '').split('.')[0];
if (major !== '0' || missing.length) {
throw new Error(`Unsupported runtime contract ${contract.contractVersion}${missing.length ? `; missing ${missing.join(', ')}` : ''}`);
}
await refresh();
connectEvents();
} catch (error) {
content.innerHTML = `<div class="notice">Cannot start: ${escapeHtml(error.message)}</div>`;
startPolling();
}
}
boot();
</script>
</body>
</html>
+102
View File
@@ -0,0 +1,102 @@
# Contract meta — versioning, stability, and how to read this contract
contractVersion: 0.1
This directory IS the openrig studio SDK contract. If you are a coding agent
building a surface: read these four documents plus `surface-row.schema.json`,
then build. Nothing outside this directory and the running runtime is required.
## The four documents
| Document | What it defines |
|---|---|
| `contract-meta.md` (this file) | Version, stability classes, what counts as breaking |
| `manifest.md` | The surface manifest: how a surface registers |
| `shell-protocol.md` | The shell runtime: lifecycle, URL rules, the postMessage API |
| `runtime-api.md` | The HTTP verb set: routes, shapes, failure semantics |
## Discovering the contract at runtime
`GET /api/contract` returns:
```json
{
"contractVersion": "0.1",
"runtime": { "name": "openrig-studio", "flavor": "reference-fixture" },
"capabilities": ["contract.meta", "observe.factory-state", "stream.events", "files.read", "shell.protocol"],
"manifest": {
"ok": true, "errors": [], "warnings": [], "surfaces": 1,
"state": "ok",
"lastLoadedAt": "<ISO timestamp of the last successful load>",
"reloads": 1,
"recoveries": 0,
"lastRecoveryAt": null,
"integrityReloads": 0,
"lastIntegrityReloadAt": null,
"consumer": null
}
}
```
- `contractVersion` — the version of this contract the runtime implements.
- `capabilities` — which contract namespaces this runtime serves. Check before use.
- `manifest` — the live validation report for `surfaces.json` (see `manifest.md`).
`surfaces` is the count of valid rows currently served; it changes as the
manifest changes.
This endpoint is the first thing to read against a live runtime.
## Stability classes
Every route, field, and message type in this contract carries one of:
- **stable** — changes only additively while the contract is 0.x. Removal or
rename requires a major version bump with a deprecation note.
- **provisional** — may change with a minor bump. Usable, but re-check on
version changes. Marked in-line wherever it appears.
- **runtime-internal** — exists in a runtime but is NOT contract. Do not build
on it. Listed in `runtime-api.md` so you are warned at the point of
temptation.
## What counts as a breaking change
- Removing or renaming a route, response field, manifest field, or postMessage type.
- Changing a response shape non-additively.
- Changing an error or conflict contract.
- Changing URL-resolution or surface-lifecycle semantics.
Additive fields and new routes are non-breaking. Breaking changes move
`contractVersion` and are named in a changelog entry.
## How a surface should decide compatibility
Check two things at boot, in this order:
1. **Capabilities**: every namespace you use appears in `capabilities`. A
missing capability means this runtime cannot serve you — fail with a
visible message.
2. **Version**: parse `contractVersion` as `major.minor`. Within the same
major version, minor bumps are additive for **stable**-marked items — a
surface built on stable items of 0.1 keeps working on 0.2. A **major**
change may break you: fail visibly rather than guessing.
Exact-match checks (`contractVersion !== "0.1"`) are safe but over-strict:
they refuse runtimes that are contractually guaranteed to work. Prefer
same-major + capabilities-present. Surfaces using **provisional** items
should re-check them on any version change, including minor.
## Design stance (why this contract is shaped this way)
- **This contract is the boundary.** The runtime may internally consult other
systems; surfaces talk only to this API. Anything a surface needs from the
wider system arrives through a versioned verb here — never by reaching
around the runtime. A surface that routes around the boundary is out of
contract by definition.
- **Declared-independent.** The shapes here are designed and owned by this
contract; they are not mirrors of any other system's API. Backing systems
can change without this contract changing.
- **Honest failure.** When something is wrong it must look wrong: degraded
states are named in responses, malformed manifests are reported at
`/api/contract`, and nothing silently pretends health.
- **No credential ever appears in a contract response.** Auth state, tokens,
key paths, fingerprints — none of it crosses this boundary.
+169
View File
@@ -0,0 +1,169 @@
# The surface manifest — how a surface registers
contractVersion: 0.1
The manifest is `app/surfaces.json`. The shell renders its rail from it; the
runtime validates it. One row in `surfaces[]` = one surface = one door.
There are two registration paths, and which one you want depends on whose
surface it is:
| You are… | Use |
|---|---|
| adding a surface **to this SDK itself** | *Registering a surface* below |
| **building a studio ON the SDK** and adding your own surface | *Registering surfaces as a consumer* below |
If you installed this SDK as a dependency, you are the second case. Its `app/`
directory is inside `node_modules` — gitignored, destroyed by `npm ci`, and
carried by any copy of the tree. **Do not put your surfaces there.**
## Registering a surface (the whole procedure)
This is the path for surfaces owned by the SDK itself.
1. Put your surface page under `app/surfaces/` (e.g. `app/surfaces/mine.html`).
It is served same-origin at `/surfaces/mine.html`.
2. Add one row to `surfaces[]` in `app/surfaces.json`:
```json
{ "id": "mine", "name": "MINE", "glyph": "◆", "path": "/surfaces/mine.html",
"hint": "one line about what this surface is" }
```
3. Reload the shell. Your surface appears on the rail. That is the entire
registration surface — there is no build step and no other file to touch.
**Rediscovery semantics (v0.1):** the **runtime** re-validates the manifest
live whenever the file changes — `GET /api/contract` reflects an edit within
about a second, no restart needed. The **shell**, however, reads the manifest
once at load: an already-open shell tab does **not** rediscover new or changed
rows — reload the shell tab to see rail changes. Live rail rediscovery is a
candidate contract addition, not an unstated current behavior.
Field semantics and stability marks: `surface-row.schema.json` (each property
carries a `description` and an `x-stability` mark). Required: `id` (unique),
`name`, and exactly one of `path` (must start with `/`) or `url`.
## Registering surfaces as a consumer (stable)
If you are building a studio **on** this SDK, your surfaces are yours: they live
in **your** repository, are versioned by **you**, and are never written into the
installed package.
Point the runtime at a directory you own:
```sh
node node_modules/@openrig/studio/app/serve-studio.mjs --surfaces ./surfaces
# or
OPENRIG_STUDIO_SURFACES=./surfaces npm start
```
That directory holds your pages **and** your own `surfaces.json`, using exactly
the row schema documented here:
```
surfaces/
surfaces.json { "surfaces": [ { "id": "mine", "name": "MINE", "path": "/surfaces/mine.html" } ] }
mine.html
```
Your pages are served at `/surfaces/<file>`, the same URLs as any other surface.
**Merge and precedence.** The rail is the SDK's own surfaces followed by yours.
If you reuse an id, **your row wins** — that is how you deliberately replace a
stock surface — and the runtime emits a warning naming the shadowed id, so a
replacement is never silent.
**Serving follows registration, not the filesystem (stable).** A file in your
overlay directory is served only when a **valid row registers it**. Putting a
file there does not publish it: an unregistered file is a 404, and it never
outranks a registered package page. If you register a row whose page is missing,
that path returns **404 naming the problem** — the package's page is not
substituted underneath your row, because then the rail and the bytes would
disagree about whose surface you are looking at. Replacing a stock surface is
done by **id**, which warns; it is not done by filename, which would be silent.
**With the seam active, the runtime serves exactly what is DECLARED (stable).**
That is: the SDK's own declared surfaces, plus the surfaces your overlay
declares, and nothing else.
The SDK's own set is declared **in its runtime source**, not in
`app/surfaces.json`. That is deliberate. The failure this seam removes is
contamination *of the installed package* — a copied tree, a stale
materialisation, a dependency writing into `node_modules` — and every one of
those mutates the manifest and the surfaces directory. Rooting the SDK's
authority in the file the contamination writes to would make an injected row
authoritative by construction.
So, when an overlay is configured:
- a row in the package manifest that the SDK does not declare as its own is
**ignored for serving and reported in `manifest.warnings`**
- a file under the package's `app/surfaces/` not backed by a served declared row
is **not served and reported the same way**
Contamination is made **visible**, not silently dropped — an operator learns the
installed package is dirty rather than merely not seeing a surface.
**Scope of that guarantee, stated so it is not read as more than it is.** It
covers surface state written *into* an installed package. It does **not** cover
a dependency that ships a **modified runtime source**: that is a supply-chain
compromise, a different and higher-bar class, and it is already accepted by the
act of installing the SDK at all.
**Zero config is unaffected.** With no overlay configured none of the above
applies and the package manifest is served exactly as it always has been.
**Validation** is the same warn-first contract, applied per source. With an
overlay configured, errors and warnings are prefixed with which manifest they
came from (`package` / `consumer`), because a bare `surfaces[0]` no longer says
enough. Without an overlay the messages are unchanged.
**Per-source health** appears at `GET /api/contract` under `manifest.consumer`
— its own `state`, `reloads`, `recoveries` and `integrityReloads`, with the same
meanings as the top-level fields (which describe the SDK's own manifest). It is
`null` when no overlay is configured.
**Why the seam exists, not just how.** The registration path above is inside the
package. In a consumer install that is `node_modules/@openrig/studio/app/` —
gitignored, wiped by `npm ci`, and, worst of all, **carried by a copy**:
duplicating a `node_modules` tree duplicates any surface state inside it, so a
copied deployment can serve a healthy manifest with the wrong surfaces. Because
consumer surfaces never live in the package, copying it cannot carry them and
there is nothing to reconcile. The runtime writes nothing into your overlay
directory and copies nothing out of it.
**Zero config stays zero config.** With no `--surfaces` and no
`OPENRIG_STUDIO_SURFACES`, everything above is inert and the SDK's own surfaces
behave exactly as they did before this seam existed. The overlay is opt-in.
## Validation — warn-first (stable behavior)
The runtime validates the manifest at startup and re-validates when the file
changes. The behavior is **warn-first**:
- Rows that fail a required check are **excluded from the served rail** and
named, row-by-row, in `GET /api/contract` → `manifest.errors[]` and in the
runtime's startup log.
- Unknown fields are **warnings**, never errors — forward-compatibility is
deliberate.
- The runtime keeps serving throughout. A malformed manifest never kills the
runtime; it becomes visible instead.
Check `GET /api/contract` after editing the manifest. If your row does not
appear on the rail, the reason is spelled out there.
## Populations that are not the app-facing schema
`surfaces.json` may also carry runtime-owned state:
- `chatSeats[]` — the agent-sidebar roster (`{seat, name}` rows). The shell
renders it; surfaces may summon it (see `shell-protocol.md`). Surfaces do
**not** write it, and it is not part of the surface-row schema.
- `_note` — a human/agent-facing comment field. Ignored by the runtime.
## The example fixture posture
The reference runtime ships with one surface, FLOOR, which renders the fixture rig state. Your first surface's row goes beside it. The fixture data behind the runtime's API verbs
(`fixtures/`) is already populated, so a new surface has real data to render
on its first load.
+116
View File
@@ -0,0 +1,116 @@
# The runtime API — HTTP verbs, shapes, failure semantics
contractVersion: 0.1
Base: the runtime's own origin (default `http://127.0.0.1:8890`). All routes
are GET unless noted. Start every integration by reading `GET /api/contract`
(shape in `contract-meta.md`) and checking `capabilities`.
## Failure semantics (stable)
- **Degraded reads** return HTTP 200 with `{ "ok": false, "degraded": "<why>" }`
— the runtime is healthy, the backing data is not. Render the degradation;
do not retry-loop.
- **Client errors** return 4xx with `{ "ok": false, "error": "<what> — <what to do>" }`.
- **Unexpected errors** return 500 with `{ "ok": false, "error": "<message>" }`.
- Unknown `/api/*` paths return 404 with an error naming where the verb set
is documented.
- No contract response ever contains a credential value, token, key path, or
fingerprint.
## contract.meta
### `GET /api/contract` — stable
Version, capabilities, and the manifest validation report. Documented in
`contract-meta.md`. Read it first.
## observe
### `GET /api/factory/state` — stable
The rig-floor envelope: who is working and what is queued. The shape is owned
by this contract (declared here, field-by-field), not by any backing system.
```json
{
"ok": true,
"rig": "<rig name>",
"generatedAt": "<ISO timestamp, stamped fresh per read>",
"seats": [
{ "seat": "<session name>", "pod": "<pod id>", "member": "<member id>",
"state": "working | idle | blocked | stalled | down",
"ageMinutes": 0, "hasWork": false, "pendingWork": 0,
"lifecycle": "<runtime lifecycle string>" }
],
"queue": [
{ "id": "<item id>", "state": "<item state string>",
"destination": "<session>", "source": "<session>",
"tags": ["<tag>"], "updated": "<ISO timestamp>", "title": "<one line>" }
]
}
```
- `seats[].state` is a closed set (stable). `queue[].state` is an open string
set (provisional) — render it, do not switch on it exhaustively.
- `ageMinutes` is **whole minutes elapsed since the seat's most recent
observed activity** (any runtime-visible action by that seat), rounded to
the nearest minute, or `null` when no activity timestamp is known. It
measures quietness, not uptime: a `working` seat can have a nonzero age.
- Degraded shape: `{ "ok": false, "degraded": "<why>", "rig": null, "seats": [], "queue": [] }`.
## stream
### `GET /api/events` — stable (as a signal)
Server-sent events. **Signal-only contract**: any received message means
"state changed — re-fetch via the observe/files verbs." Event payload
contents are unspecified in v0.1 and must not be parsed or switched on.
- **Event naming (closed set):** every change signal is a **default, unnamed
`message` event** — an `EventSource.onmessage` handler receives all of
them. The **only** named event in v0.1 is `degraded`. Stable runtimes will
not introduce additional named events within 0.x; a new named event is a
contract addition and arrives with a version change.
- Comment frames (lines starting `:`) are keepalives; EventSource ignores
them natively.
- A `degraded` named event means the signal source is unhealthy: close and
fall back to polling (cadence guidance in `shell-protocol.md`).
- Reconnection is EventSource-native. Delivery is at-least-once-ish with no
replay guarantee: after any reconnect, re-fetch once rather than assuming
you missed nothing.
## files (read-only)
All paths are pinned inside the runtime's files root; anything outside it is
refused with a 4xx naming the pin. There are no write verbs in this contract.
### `GET /api/files/tree?dir=<abs path>` — stable
Without `dir`: the root listing. With `dir` (inside the root):
`{ "ok": true, "path": "...", "dirs": [{name, path}], "files": [{name, path, size, mtime, kind}] }`.
`kind` ∈ `image | video | audio | markdown | html | text | other`.
### `GET /api/files/read?path=<abs path>` — stable
Text-like files: `{ "ok": true, "kind": "...", "content": "<utf8>", "mtime": <ms> }`.
Binary/media files: `{ "ok": true, "kind": "...", "raw": "/api/files/raw?path=...", "mtime": <ms> }` —
fetch the `raw` URL for bytes.
### `GET /api/files/raw?path=<abs path>` — stable
The file's bytes with a best-effort content-type.
### `GET /api/files/search?q=<term>` — stable
Case-insensitive filename search under the root:
`{ "ok": true, "hits": [{name, path, kind}] }`. Bounded result count; queries
under 2 characters return empty hits.
## runtime-internal — exists in some runtimes; DO NOT build on
Listed so you are warned at the point of temptation. None of these are
contract, none are present in the reference runtime's public surface, and
each becomes contract only through its own future security/ownership gate:
- files mutation (write / goto / roots overlays)
- shell sidebar-arrangement persistence
- typing text into an agent seat or terminal
- OAuth / credential-connect flows
- any verb that reaches a backing system's API directly
If your surface needs one of these, it needs a contract version that includes
it — not a workaround.
+95
View File
@@ -0,0 +1,95 @@
# The shell protocol — lifecycle, URL rules, and the postMessage API
contractVersion: 0.1
The shell (`/`, served from `app/shell.html`) is the hallway: a rail of
surfaces on the left, a stage that hosts one surface at a time in an iframe,
and an agent sidebar. This document is what a surface may rely on.
## URL resolution (stable)
- A row with `path` is loaded same-origin: `{origin}{path}`.
- A row with `url` is loaded as given (external surfaces).
- Deep link: `?s=<id>` opens that surface directly and survives reload;
the shell keeps the address bar in sync as the user switches surfaces.
## Surface lifecycle (stable)
- The stage iframe for a surface is created on **first visit** and **kept
alive** when the user switches away — your surface keeps its state and its
open connections while backgrounded.
- Exception: rows with `unmountOnBlur: true` have their iframe **removed** on
switch-away and recreated on return. Declare this if your surface holds an
attachment that must not persist invisibly (terminal viewers are the
canonical case: a hidden kept-alive client stays attached and fights the
visible ones). Cost: your surface cold-starts on each return.
- `popout: true` rows open in a new browser tab instead of the stage.
- `hideAgents: true` suppresses the agent-sidebar toggle while your surface
is active.
## Liveness (stable behavior, cosmetic result)
The shell polls every surface URL with an opaque no-cors fetch on an interval
and renders a rail dot: answered = live, failed = down. Your surface needs no
code for this — just answer HTTP.
## The postMessage API — the ONLY surface→shell channel (stable)
Send from inside your iframe with `parent.postMessage(msg, "*")`:
| Message | Effect |
|---|---|
| `{ "t": "open-surface", "id": "<surface-id>" }` | The shell activates that surface (same as a rail click). Unknown ids are ignored. |
| `{ "t": "open-agent" }` | The shell opens the agent sidebar. |
| `{ "t": "open-agent", "seat": "<seat-name>" }` | Opens the sidebar with that roster seat highlighted. Unknown seats open the sidebar unhighlighted. |
This message set is closed for v0.1: the shell ignores any other `t` value.
New message types arrive as contract additions, never by surfaces inventing
them.
## The agent sidebar (what "seat" means in v0.1)
The contracted seat surface is deliberately small:
- Seats are **declared** as `chatSeats[]` roster rows in the manifest.
- A surface may **summon** the sidebar (`open-agent` above).
That is the whole contracted seat surface in v0.1. Typing into a seat and
richer seat→surface channels exist in some runtimes as **runtime-internal**
capability (see `runtime-api.md`) and are not contract. The reference runtime
renders the roster and says honestly that no live seat backing is present.
## The change-signal loop (how a surface stays fresh)
The canonical read loop for a live surface:
1. Fetch what you need from the observe/files verbs (`runtime-api.md`).
2. Open `GET /api/events` (SSE). On **any** message: re-fetch what you care
about. Do not parse event payloads — the stream is a signal, not a data
channel.
3. If the stream errors or emits a `degraded` event: close it and fall back
to polling, and keep retrying the stream when convenient.
**Freshness bound (fallback polling):** poll every **10–30 seconds** while the
stream is down; **15 seconds is the recommended default**. Faster than 10s
wastes work against a signal-driven runtime; slower than 30s reads as stale to
a human watching the surface. When the stream reconnects, stop polling and
re-fetch once.
This loop is the intended pattern; the fixture runtime fires the signal when
fixture or manifest files change, so you can watch it work by editing a
fixture file.
## Surface expectations (visual / accessibility baseline)
v0.1 deliberately declares **no required visual system and no enforced
accessibility baseline** — surfaces are sovereign inside their iframe. Two
conventions are stated so surfaces cohere with the shell without a rulebook:
- Declare `color-scheme: dark` (the shell is dark; a light-flashing iframe
reads as broken).
- Degraded and error states must be **visible in the UI**, not only in the
console — when something is wrong it must look wrong.
A formal baseline (contrast, keyboard reachability, test convention) is a
candidate for a later contract version, not an unstated v0.1 rule.
+46
View File
@@ -0,0 +1,46 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/mvschwarz/openrig-studio/blob/main/contract/surface-row.schema.json",
"title": "openrig studio — surface manifest row (contract v0.1)",
"description": "One row in surfaces.json surfaces[]. Stability: fields marked x-stability 'stable' change only additively while the contract is 0.x; 'provisional' fields may change with a minor bump. The runtime validates warn-first: rows failing required checks are excluded from the served rail and named in GET /api/contract manifest.errors[]; unknown fields are warnings, never errors.",
"type": "object",
"required": ["id", "name"],
"oneOf": [
{ "required": ["path"], "not": { "required": ["url"] } },
{ "required": ["url"], "not": { "required": ["path"] } }
],
"properties": {
"id": {
"type": "string", "minLength": 1,
"description": "Unique surface id. Used by deep links (?s=<id>) and the open-surface shell message.",
"x-stability": "stable"
},
"name": {
"type": "string", "minLength": 1,
"description": "Rail label. Short, uppercase by convention.",
"x-stability": "stable"
},
"path": {
"type": "string", "pattern": "^/",
"description": "Same-origin path the shell loads in the stage iframe (e.g. /surfaces/mine.html). Exactly one of path or url is required.",
"x-stability": "stable"
},
"url": {
"type": "string", "pattern": "^https?://[^/?#\\s]+",
"description": "Absolute http(s) URL with a non-empty host (e.g. https://host/path) for external surfaces. Exactly one of path or url is required — never both.",
"x-stability": "provisional"
},
"glyph": { "type": "string", "description": "One-character rail glyph.", "x-stability": "stable" },
"hint": { "type": "string", "description": "One-line tooltip shown on the rail tab.", "x-stability": "stable" },
"category": { "type": "string", "description": "Grouping hint (create | build | grow | system by convention).", "x-stability": "provisional" },
"popout": { "type": "boolean", "description": "Open in a new tab instead of the stage.", "x-stability": "stable" },
"hideAgents": { "type": "boolean", "description": "Suppress the agent-sidebar toggle while this surface is active.", "x-stability": "stable" },
"unmountOnBlur": { "type": "boolean", "description": "Remove the iframe when backgrounded instead of keeping it alive. Required for terminal-style surfaces: a hidden kept-alive client would stay attached and fight visible clients.", "x-stability": "stable" },
"railMin": { "type": "boolean", "description": "Ask the shell to condense its rail while this surface is active.", "x-stability": "provisional" },
"project": { "type": "string", "description": "Project-view scoping tag; surfaces without it show in every view.", "x-stability": "provisional" },
"port": { "type": "number", "description": "Multi-process deployments only: the localhost port this surface's own server listens on. Single-runtime deployments use path.", "x-stability": "provisional" },
"sub": { "type": "string", "description": "Multi-host deployments only: subdomain token for hosted resolution.", "x-stability": "provisional" },
"samehost": { "type": "boolean", "description": "Resolve against the shell's own origin in hosted deployments.", "x-stability": "provisional" },
"hosts": { "type": "array", "items": { "type": "string" }, "description": "Restrict the row to named host classes; omitted = everywhere.", "x-stability": "provisional" }
}
}
+109
View File
@@ -0,0 +1,109 @@
# @openrig/create-studio
The create-app on-ramp: scaffold a working openrig studio surface, wired to
contract v0.1, in one command.
## Status of the public command
The intended front door is:
```sh
npm create @openrig/studio my-surface
```
npm resolves that to the package **`@openrig/create-studio`** and runs its bin.
**That command does not work yet.** This package is unpublished and its final
name is a deferred decision, so there is nothing on the registry to resolve.
Publishing is a metadata step — flip `private`, settle the name, publish — and
changes nothing about the code below.
## Running it today
Directly, from the repository root:
```sh
node create-studio/index.mjs my-surface
```
Or through the real npm resolution path, against a local pack — same bin, same
entrypoint, no registry:
```sh
npm pack ./create-studio
npm exec --package=./openrig-create-studio-0.1.0.tgz -- create-studio my-surface
```
## Options
```
create-studio <name> [--dir <parent>] [--glyph <char>] [--hint <text>]
```
| Option | Meaning |
|---|---|
| `<name>` | Project directory and surface id. Lowercase letters, digits and dashes. |
| `--dir` | Where to create the project (default: current directory). |
| `--glyph` | One-character rail glyph (default `◆`). |
| `--hint` | One-line rail tooltip. |
## What it emits
```
my-surface/
surface.html the surface — one self-contained page, no build step
surfaces.row.json the schema-valid manifest row that registers it
README.md how to install it, and where the contract docs are
```
The emitted surface uses **only stable-classified** contract items: the
`/api/contract` boot check, `/api/factory/state`, the `/api/events` change
signal (refresh-only, with the documented degraded/polling fallback), and the
`open-agent` shell message. Nothing marked *provisional* or *runtime-internal*
is generated.
## How it refuses
Every check runs **before** anything is written, so a rejected run leaves the
filesystem untouched:
- names must be a single path segment — no traversal, no absolute paths, no `~`
- no leading dots, no reserved names, 64 characters max
- lowercase letters, digits and dashes only
- an existing **non-empty** destination is refused
Generation stages into a sibling temp directory and lands with one atomic
rename, so an interrupted run cannot leave a half-written project that looks
usable.
`OPENRIG_STUDIO_CREATE_FAIL_AFTER=<n>` forces a failure after `n` files have
been staged. It exists so the no-partial-output guarantee can be demonstrated
rather than asserted; it is inert when unset.
## Option values are escaped per output context
`--hint` takes arbitrary text and `--glyph` takes any single character, so both
can carry quotes, backslashes, newlines or markup. Each output context handles
them differently and the generator treats them separately:
- **JSON** — the manifest row is built as an object and serialized. It is never
string-substituted into a JSON literal, because that is what previously let a
quoted hint emit a file `JSON.parse` rejects while the CLI reported success.
- **HTML** — every interpolated value is escaped, so a hint containing markup
renders as text rather than becoming an element.
- **JS** — the one value crossing into a `<script>` is JSON-encoded, not
HTML-escaped; escaping there would put a literal `&quot;` into code.
- **Markdown** — deliberately not escaped, because escaping inside a code span
is wrong. Safety rests on the project-name rules, and the generator asserts
that invariant rather than assuming it.
## Tests
```sh
npm test # from the repo root, or from create-studio/
```
Zero dependencies — `node:test` only. The suite covers generation, every
admission boundary, no-partial-output on forced failure, schema shape and
stability class of the emitted row, the hostile-value matrix for every
user-controlled field, and the packed-tarball bin path.
+187
View File
@@ -0,0 +1,187 @@
#!/usr/bin/env node
// create-studio — scaffold an openrig studio surface project.
//
// The intended public front door is:
//
// npm create @openrig/studio <name>
//
// which npm resolves to the package @openrig/create-studio and runs this bin.
// That command is NOT AVAILABLE YET: this package is unpublished (private),
// and its final name is a deferred decision. Until it is published, run the
// shipped entrypoint directly — see --help. Publishing is a metadata step,
// not a rewrite: nothing below changes when it happens.
//
// Order of operations is the whole design: ADMIT everything first, RENDER into
// memory, then write once. Nothing reaches the filesystem until every check has
// passed, and generation stages into a sibling directory that is renamed into
// place atomically. A failed run leaves no partial project.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { admitName, admitDestination } from "./lib/admission.mjs";
import { emitProject } from "./lib/emit.mjs";
import { escapeHtml, toJsLiteral, assertMarkdownSafe, render } from "./lib/render.mjs";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const TEMPLATES = path.join(HERE, "templates");
const CONTRACT_VERSION = "0.1";
const VERSION = "0.1.0";
const USAGE = `create-studio — scaffold an openrig studio surface (contract v${CONTRACT_VERSION})
USAGE
create-studio <name> [--dir <parent>] [--glyph <char>] [--hint <text>]
<name> project directory and surface id: lowercase letters, digits
and dashes (e.g. my-surface)
--dir <parent> where to create it (default: the current directory)
--glyph <char> one-character rail glyph (default: ◆)
--hint <text> one-line rail tooltip
--help, --version
RUNNING IT TODAY
node create-studio/index.mjs my-surface
The public form 'npm create @openrig/studio my-surface' resolves to the
package @openrig/create-studio. That package is NOT PUBLISHED YET, so the
command does not work today. Use the direct invocation above, or exercise the
real npm path against a local pack:
npm pack ./create-studio
npm exec --package=./openrig-create-studio-${VERSION}.tgz -- create-studio my-surface
`;
function fail(message) {
console.error(`create-studio: ${message}`);
process.exit(1);
}
function parseArgs(argv) {
const opts = { name: undefined, dir: process.cwd(), glyph: "◆", hint: undefined };
const rest = [];
for (let i = 0; i < argv.length; i += 1) {
const a = argv[i];
if (a === "--help" || a === "-h") return { help: true };
if (a === "--version" || a === "-v") return { version: true };
if (a === "--dir" || a === "--glyph" || a === "--hint") {
const value = argv[i + 1];
if (value === undefined || value.startsWith("--")) {
fail(`${a} needs a value`);
}
if (a === "--dir") opts.dir = value;
if (a === "--glyph") opts.glyph = value;
if (a === "--hint") opts.hint = value;
i += 1;
continue;
}
if (a.startsWith("-")) fail(`unknown option ${a} — see --help`);
rest.push(a);
}
if (rest.length > 1) fail(`expected one project name, got ${rest.length}: ${rest.join(" ")}`);
opts.name = rest[0];
return opts;
}
function main() {
const opts = parseArgs(process.argv.slice(2));
if (opts.help) { process.stdout.write(USAGE); return; }
if (opts.version) { process.stdout.write(`${VERSION} (contract v${CONTRACT_VERSION})\n`); return; }
// ---- admission: everything that can say no happens before any write ------
const named = admitName(opts.name);
if (!named.ok) fail(named.error);
if (opts.glyph !== undefined && [...opts.glyph].length !== 1) {
fail(`--glyph must be exactly one character, got ${JSON.stringify(opts.glyph)}`);
}
const placed = admitDestination(named.name, opts.dir);
if (!placed.ok) fail(placed.error);
// ---- render: build every file in memory ---------------------------------
const id = named.name;
const label = id.replace(/-/g, " ").toUpperCase();
const hint = opts.hint ?? `${label} — starter surface`;
const vars = {
ID: id,
NAME: named.name,
LABEL: label,
GLYPH: opts.glyph,
HINT: hint,
CONTRACT_VERSION,
};
const read = (file) => fs.readFileSync(path.join(TEMPLATES, file), "utf8");
// JSON is built STRUCTURALLY and serialized — never string-substituted into a
// JSON literal. Templating JSON as text is what let `--hint 'say "hello"'` and
// `--glyph '"'` emit a file that JSON.parse rejects while the CLI exited 0.
// JSON.stringify also escapes newlines and backslashes, which is what keeps a
// hint from breaking out of the fenced code block in the emitted README.
const row = { id, name: label, glyph: opts.glyph, path: `/surfaces/${id}.html`, hint };
const rowPretty = JSON.stringify(row, null, 2);
const rowJson = rowPretty + "\n";
// The emitted README shows the row inline so it can be pasted without opening
// a second file. The README places the token inside a numbered-list code
// fence indented by three spaces, and that indent only applies to the FIRST
// line — so every subsequent line must carry it explicitly or the block comes
// out ragged (opening brace at 3, closing brace at 2). Keep README_INDENT in
// step with the template's indentation.
const README_INDENT = " ";
const rowInline = rowPretty
.split("\n")
.map((line, i) => (i === 0 ? line : README_INDENT + line))
.join("\n");
// Markdown is not escaped by design; this asserts the invariant that makes
// that safe. See lib/render.mjs for why escaping code spans would be wrong.
assertMarkdownSafe({ ID: id, LABEL: label });
const files = {
// HTML context: escape every interpolated value. The one JS-string context
// (`const TARGET_CONTRACT = ...`) takes a JSON-encoded *_JS token instead —
// HTML-escaping there would emit a literal &quot; into executable code.
"surface.html": render(
read("surface.html"),
{ ...vars, CONTRACT_VERSION_JS: toJsLiteral(CONTRACT_VERSION) },
(value, key) => (key.endsWith("_JS") ? value : escapeHtml(value))
),
"surfaces.row.json": rowJson,
"README.md": render(read("README.md"), { ...vars, ROW_INLINE: rowInline }),
};
// ---- emit: stage, then one atomic rename --------------------------------
let result;
try {
result = emitProject(placed.dest, files);
} catch (error) {
fail(`generation failed, nothing was written: ${error.message}`);
}
// Show the shortest path that is still unambiguous: a relative path only
// when the project is actually under the cwd, otherwise the absolute one.
// (A "../../../.." chain is technically correct and useless to read.)
const relative = path.relative(process.cwd(), result.dest);
const rel = !relative ? "." : relative.startsWith("..") ? result.dest : relative;
process.stdout.write(
`\ncreated ${rel}/ (contract v${CONTRACT_VERSION})\n` +
result.files.map((f) => ` ${f}\n`).join("") +
// These next steps deliberately keep the surface in the developer's OWN
// repo. The older form told them to cp into <studio>/app/surfaces/, which in
// a real install is node_modules — wiped by npm ci and carried by any copy
// of the tree. The scaffolder is the first artifact a new developer reads;
// it should not teach the path the SDK exists to remove.
`\nnext:\n` +
` rename ${rel}/surface.html to ${id}.html and put your row in a surfaces.json beside it\n` +
` (${rel}/surfaces.row.json is that row; ${rel}/README.md shows the shape)\n` +
` node <studio>/app/serve-studio.mjs --surfaces ${rel} then open http://127.0.0.1:8890/\n\n` +
`Your surface stays in YOUR repo — nothing is copied into the studio package.\n` +
`${rel}/README.md has the details and points at the contract docs.\n`
);
}
main();
+92
View File
@@ -0,0 +1,92 @@
// Admission — every check that can reject a run happens HERE, before the
// emitter touches the filesystem. That ordering is the contract: a rejected
// invocation writes nothing at all, so there is no partial project to clean up.
//
// Kept deliberately boring and dependency-free. Each rejection names what was
// wrong AND what to do instead, per the honest-failure stance in
// contract/contract-meta.md.
import fs from "node:fs";
import path from "node:path";
// A project name is also a directory name and a surface id, so it has to be
// safe as all three: one path segment, no traversal, no shell/OS surprises.
const NAME_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
const MAX_NAME = 64;
// "." and ".." are caught by the leading-dot rule too; listed anyway so the
// reserved set reads as a complete statement of intent rather than a leftover.
const RESERVED = new Set([
".", "..", "node_modules",
"con", "prn", "aux", "nul",
...Array.from({ length: 9 }, (_, i) => `com${i + 1}`),
...Array.from({ length: 9 }, (_, i) => `lpt${i + 1}`),
]);
const reject = (error) => ({ ok: false, error });
export function admitName(raw) {
if (raw === undefined || raw === null || raw === "") {
return reject('a project name is required — try: create-studio my-surface');
}
const name = String(raw);
if (!name.trim()) {
return reject('project name is blank — try: create-studio my-surface');
}
if (name !== name.trim()) {
return reject(`project name has surrounding whitespace: ${JSON.stringify(name)} — remove it`);
}
if (name.startsWith("~")) {
return reject(`project name must not start with "~": ${name} — pass a bare name and use --dir for the parent directory`);
}
if (path.isAbsolute(name)) {
return reject(`project name must not be an absolute path: ${name} — pass a bare name and use --dir for the parent directory`);
}
if (name.includes("/") || name.includes("\\")) {
return reject(`project name must be a single path segment (no "/" or "\\"): ${name} — use --dir to choose the parent directory`);
}
if (name.startsWith(".")) {
return reject(`project name must not start with "." : ${name} — dotted names hide the project and ".." would escape the parent`);
}
if (name.length > MAX_NAME) {
return reject(`project name is ${name.length} characters; the limit is ${MAX_NAME}`);
}
if (RESERVED.has(name.toLowerCase())) {
return reject(`project name "${name}" is reserved — choose another`);
}
if (!NAME_RE.test(name)) {
return reject(`project name must be lowercase letters, digits and dashes, starting and ending alphanumeric: ${name} (e.g. my-surface)`);
}
return { ok: true, name };
}
// Destination admission assumes the name already passed admitName(), so `name`
// is a single safe segment. The containment assertion below is defence in
// depth: it would catch a future caller that skipped that ordering.
export function admitDestination(name, parentRaw) {
const parent = path.resolve(parentRaw);
if (!fs.existsSync(parent)) {
return reject(`destination parent does not exist: ${parent}`);
}
if (!fs.statSync(parent).isDirectory()) {
return reject(`destination parent is not a directory: ${parent}`);
}
const dest = path.join(parent, name);
if (path.dirname(dest) !== parent) {
return reject(`refusing to write outside the parent directory: ${dest} is not directly inside ${parent}`);
}
if (fs.existsSync(dest)) {
const stat = fs.statSync(dest);
if (!stat.isDirectory()) {
return reject(`destination already exists and is not a directory: ${dest}`);
}
const entries = fs.readdirSync(dest);
if (entries.length > 0) {
return reject(`destination already exists and is not empty: ${dest} (${entries.length} entr${entries.length === 1 ? "y" : "ies"}) — choose another name or remove it first`);
}
}
return { ok: true, dest };
}
+46
View File
@@ -0,0 +1,46 @@
// Emit — stage every file into a sibling temp directory, then ONE atomic
// rename into place.
//
// Why a sibling and not the system temp dir: rename() is only atomic within a
// filesystem. A sibling of the destination is guaranteed to be on the same
// filesystem; /tmp is not, and would silently degrade to a copy that can be
// interrupted halfway.
//
// The guarantee this buys: the destination either does not exist, or is the
// complete project. A crash mid-generation leaves a hidden staging directory
// that is removed on the way out — never a half-written project a developer
// might mistake for a working one.
import fs from "node:fs";
import path from "node:path";
export function emitProject(dest, files) {
const parent = path.dirname(dest);
const staging = fs.mkdtempSync(path.join(parent, ".create-studio-staging-"));
// Test affordance ONLY: throw after N files have been staged, to demonstrate
// that a mid-generation failure leaves no partial project. Inert when unset.
const failAfter = Number(process.env.OPENRIG_STUDIO_CREATE_FAIL_AFTER || 0);
let staged = 0;
try {
for (const [rel, content] of Object.entries(files)) {
const target = path.join(staging, rel);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, content, "utf8");
staged += 1;
if (failAfter > 0 && staged >= failAfter) {
throw new Error(
`OPENRIG_STUDIO_CREATE_FAIL_AFTER=${failAfter} — simulated mid-generation failure after ${staged} file(s)`
);
}
}
// POSIX rename() onto an existing EMPTY directory succeeds; admission has
// already refused a non-empty destination.
fs.renameSync(staging, dest);
return { dest, files: Object.keys(files) };
} catch (error) {
try { fs.rmSync(staging, { recursive: true, force: true }); } catch { /* best effort */ }
throw error;
}
}
+60
View File
@@ -0,0 +1,60 @@
// Rendering — substitution is ALWAYS output-context aware.
//
// The bug this file exists to prevent: a `--hint 'say "hello"'` or a `--glyph '"'`
// interpolated raw into a JSON template produced an INVALID surfaces.row.json
// while the CLI still exited 0, and hint markup landed in the surface as live
// HTML. A generator that emits a broken artifact and reports success is the
// worst failure shape there is — it looks like it worked.
//
// Rules, one per output context:
// JSON — never templated as text. Build an object, JSON.stringify it.
// (See the row construction in index.mjs. Escaping JSON by hand is the bug.)
// HTML — escape &<>"' on every interpolated value.
// JS — inside <script>, values are JSON-encoded, not HTML-escaped;
// HTML-escaping there would emit a literal &quot; into code.
// Markdown — see assertMarkdownSafe below.
export const escapeHtml = (value) =>
String(value ?? "").replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
})[c]);
// A value destined for a JS string literal inside <script>. Returns the quotes
// too, so the template writes `= {{TOKEN_JS}};` with no quotes of its own.
export const toJsLiteral = (value) => JSON.stringify(String(value ?? ""));
// Markdown is deliberately NOT escaped, and that is a decision rather than an
// oversight. The values reaching the emitted README (id, and the label derived
// from it) land inside headings, inline code and fenced blocks. Backslash-
// escaping inside a code span is WRONG — markdown does not interpret specials
// there, so the escape itself would show up as literal backslashes in the
// output the developer reads.
//
// Those values are safe because admission constrains the project name to
// ^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$ — no markdown-active character can survive
// it. That makes markdown safety DEPENDENT on a rule enforced in another file,
// which is exactly the kind of invisible coupling that rots. So assert it here:
// if admission is ever loosened, generation fails loudly instead of quietly
// emitting malformed markdown.
const MARKDOWN_ACTIVE = /[\\`*_{}\[\]()#+!|<>]/;
export function assertMarkdownSafe(values) {
for (const [key, value] of Object.entries(values)) {
if (MARKDOWN_ACTIVE.test(String(value ?? ""))) {
throw new Error(
`refusing to emit: ${key} contains a markdown-active character (${JSON.stringify(value)}). ` +
`Values reaching the emitted README must be markdown-inert; admission should have ` +
`rejected this earlier. If the name rules were loosened, the README template needs ` +
`context-aware escaping before that change can ship.`
);
}
}
}
// `escape` receives (value, key) so a caller can vary handling per token —
// e.g. leave an already-JSON-encoded *_JS token alone while HTML-escaping the rest.
export function render(template, vars, escape = (value) => value) {
return template.replace(/\{\{(\w+)\}\}/g, (whole, key) =>
key in vars ? escape(vars[key], key) : whole
);
}
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@openrig/create-studio",
"version": "0.1.0",
"description": "Scaffold a new openrig studio surface project wired to contract v0.1.",
"type": "module",
"bin": {
"create-studio": "./index.mjs"
},
"scripts": {
"test": "node --test 'test/*.test.mjs'"
},
"files": [
"index.mjs",
"lib",
"templates",
"README.md"
],
"engines": {
"node": ">=18"
},
"license": "Apache-2.0"
}
+105
View File
@@ -0,0 +1,105 @@
# {{LABEL}}
An openrig studio **surface**: a thin browser page that runs inside the studio
shell and talks to the studio runtime over a versioned contract.
Built against **contract v{{CONTRACT_VERSION}}**.
> Generated by `create-studio`. The intended public front door,
> `npm create @openrig/studio <name>`, is **not available yet** — that package
> is unpublished and its final name is still an open decision. This project was
> produced by running the scaffolder directly from the SDK repository. Nothing
> here depends on that command existing.
## Files
| File | What it is |
|---|---|
| `surface.html` | The surface itself. One self-contained page — no build step, no dependencies. |
| `surfaces.row.json` | The manifest row that registers this surface with a studio. |
## Register it
A surface runs inside a studio; it is not a server of its own. **This project is
yours** — it stays in your repository and the studio reads it from there. You do
not copy anything into the SDK.
1. Keep this directory (or move it) wherever you like in your own repo, and put
your `surfaces.json` beside the page:
```
surfaces/
surfaces.json the row below, in a { "surfaces": [ … ] } array
{{ID}}.html this project's surface.html, renamed
```
```json
{{ROW_INLINE}}
```
2. Point the runtime at that directory:
```sh
node <studio>/app/serve-studio.mjs --surfaces ./surfaces
# or
OPENRIG_STUDIO_SURFACES=./surfaces node <studio>/app/serve-studio.mjs
```
Then reload the shell. `{{LABEL}}` appears on the rail.
**Do not copy your surface into the studio's `app/surfaces/`.** If you installed
the SDK as a dependency that directory is inside `node_modules` — gitignored,
destroyed by `npm ci`, and carried by any copy of the tree, so a copied
deployment can serve a healthy manifest with the wrong surfaces. Surfaces you
own belong in your repository. See `contract/manifest.md` →
*Registering surfaces as a consumer*.
The shell reads the manifest **once at load**, so an already-open tab will not
show a newly added row until you reload it. The runtime re-validates the
manifest live — check `GET /api/contract` to see whether your row was accepted.
## Run it
Start the studio runtime (from the studio directory):
```sh
node app/serve-studio.mjs
```
Open <http://127.0.0.1:8890/> and pick **{{LABEL}}** from the rail.
If your row does not appear, the reason is spelled out in
`GET /api/contract` → `manifest.errors[]`. Validation is warn-first: a bad row
is excluded and named, and the runtime keeps serving.
## What this skeleton already does
- Checks `GET /api/contract` at boot and refuses to run against a runtime that
cannot serve it — visibly, not silently.
- Reads `GET /api/factory/state` and renders it.
- Subscribes to `GET /api/events` and re-fetches on any signal, with a
15-second polling fallback when the stream drops or reports `degraded`.
- Opens the agent sidebar via the `open-agent` shell message.
It uses **only stable-classified** contract items. Nothing marked
*provisional* or *runtime-internal* appears in the generated code.
## Extend it
Read these four documents — they are the whole contract, and they are enough:
| Document | What it defines |
|---|---|
| `contract/contract-meta.md` | Version, stability classes, compatibility rules. **Start here.** |
| `contract/manifest.md` | How a surface registers |
| `contract/shell-protocol.md` | Lifecycle, URL rules, the postMessage API |
| `contract/runtime-api.md` | The HTTP verbs, their shapes and failure semantics |
Plus `contract/surface-row.schema.json` for the manifest row fields.
In `surface.html`, replace the body of `render()` with whatever your surface is
for. The boot check, the change-signal loop and the failure handling around it
are already correct — a normal surface should not need to change them.
A live runtime self-describes at `GET /api/contract`; that is the fastest way
to see which capabilities you actually have.
+271
View File
@@ -0,0 +1,271 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{LABEL}}</title>
<!--
{{LABEL}} — an openrig studio surface skeleton.
Targets contract v{{CONTRACT_VERSION}}. Read contract/contract-meta.md first.
Everything below uses ONLY stable-classified contract items:
GET /api/contract capability + version check at boot
GET /api/factory/state the read-only observe verb this skeleton renders
GET /api/events change signal — refresh-only, payload never parsed
postMessage open-agent the surface -> shell channel
It deliberately uses nothing marked provisional or runtime-internal.
Extend it by adding your own render; the loop below already keeps you fresh.
-->
<style>
:root {
/* The shell is dark. Declaring this stops a light flash on load, which
reads as a broken surface. (contract/shell-protocol.md) */
color-scheme: dark;
--bg: #0b0e13;
--panel: #121722;
--line: #252c3a;
--text: #f3f5f8;
--muted: #929cad;
--accent: #47d18c;
--warn: #ffb44a;
--info: #6d9eff;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
main { width: min(880px, 100%); margin: 0 auto; padding: 26px; }
header {
display: flex; align-items: center; justify-content: space-between;
gap: 16px; flex-wrap: wrap; margin-bottom: 22px;
}
.eyebrow {
color: var(--accent); font-size: 11px;
letter-spacing: .16em; text-transform: uppercase;
}
h1 {
margin: 4px 0 0;
font: 600 clamp(24px, 4vw, 34px)/1.1 system-ui, sans-serif;
letter-spacing: -.03em;
}
.status { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--warn); }
.status.live .dot { background: var(--accent); }
.status.polling .dot { background: var(--info); }
.cards { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
.card {
border: 1px solid var(--line); border-radius: 12px;
background: var(--panel); padding: 16px;
}
.card h2 {
margin: 0 0 10px; font-size: 11px; font-weight: 600; color: var(--muted);
letter-spacing: .14em; text-transform: uppercase;
}
.big { font: 650 26px/1 system-ui, sans-serif; }
.row { display: flex; justify-content: space-between; gap: 12px; padding: 5px 0; }
.row + .row { border-top: 1px solid var(--line); }
.key { color: var(--muted); }
.val { overflow-wrap: anywhere; text-align: right; }
.notice {
border: 1px solid var(--warn); border-radius: 12px;
padding: 15px; color: var(--warn); background: #ffb44a10;
}
button {
font: inherit; color: var(--text); cursor: pointer;
background: var(--panel); border: 1px solid var(--line);
border-radius: 9px; padding: 9px 13px;
}
button:hover { border-color: var(--accent); color: var(--accent); }
footer { margin-top: 20px; color: var(--muted); font-size: 11px; }
@media (max-width: 600px) {
main { padding: 16px; }
header { flex-direction: column; align-items: flex-start; gap: 10px; }
.val { text-align: left; }
.row { flex-direction: column; gap: 2px; }
}
</style>
</head>
<body>
<main>
<header>
<div>
<div class="eyebrow">{{HINT}}</div>
<h1>{{LABEL}}</h1>
</div>
<div class="status" id="status"><span class="dot"></span><span id="statusText">connecting</span></div>
</header>
<div id="content">
<div class="notice">Reading the runtime contract&hellip;</div>
</div>
<p style="margin-top:18px">
<button id="agents" type="button">Open agent sidebar</button>
</p>
<footer id="footer">Targets contract v{{CONTRACT_VERSION}}.</footer>
</main>
<script>
// ---- what this surface needs from the runtime ---------------------------
// Every namespace listed here is checked at boot. A runtime that cannot
// serve one of them gets a visible failure, not a silent empty render.
const NEEDED = ["observe.factory-state", "stream.events"];
const TARGET_CONTRACT = {{CONTRACT_VERSION_JS}};
const POLL_MS = 15000; // contract/shell-protocol.md: 10-30s, 15s recommended
const content = document.querySelector("#content");
const statusEl = document.querySelector("#status");
const statusText = document.querySelector("#statusText");
const footer = document.querySelector("#footer");
let events = null;
let pollTimer = null;
let retryTimer = null;
const esc = (value) => String(value ?? "").replace(/[&<>'"]/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#39;", '"': "&quot;"
})[c]);
function setStatus(mode, label) {
statusEl.className = "status " + mode;
statusText.textContent = label;
}
function showNotice(message) {
content.innerHTML = '<div class="notice">' + esc(message) + "</div>";
}
// ---- render ------------------------------------------------------------
// TODO: this is your starting point. Replace the body of render() with
// whatever your surface is actually for. The boot check, the change-signal
// loop and the failure handling around it are already correct — you should
// not need to touch them to build a normal surface.
function render(state) {
if (!state.ok) {
// A degraded read is the runtime being honest: healthy server, unhealthy
// data. Show it. Do not retry-loop. (contract/runtime-api.md)
showNotice("Data degraded: " + (state.degraded || "unknown reason"));
return;
}
const seats = Array.isArray(state.seats) ? state.seats : [];
const queue = Array.isArray(state.queue) ? state.queue : [];
const working = seats.filter((s) => s.state === "working").length;
const seatRows = seats.slice(0, 6).map((seat) => (
'<div class="row"><span class="key">' + esc(seat.seat) + "</span>" +
'<span class="val">' + esc(seat.state) + "</span></div>"
)).join("") || '<div class="row"><span class="key">no seats reported</span></div>';
content.innerHTML =
'<div class="cards">' +
'<div class="card"><h2>Rig</h2>' +
'<div class="big">' + esc(state.rig) + "</div>" +
'<div class="row"><span class="key">seats</span><span class="val">' + seats.length + "</span></div>" +
'<div class="row"><span class="key">working</span><span class="val">' + working + "</span></div>" +
'<div class="row"><span class="key">queue</span><span class="val">' + queue.length + "</span></div>" +
"</div>" +
'<div class="card"><h2>Seats</h2>' + seatRows + "</div>" +
"</div>";
footer.textContent =
"Targets contract v" + TARGET_CONTRACT +
" · snapshot " + new Date(state.generatedAt).toLocaleTimeString();
}
async function refresh() {
try {
const response = await fetch("/api/factory/state", { cache: "no-store" });
render(await response.json());
} catch (error) {
showNotice("Unable to read runtime state: " + error.message);
}
}
// ---- the change-signal loop --------------------------------------------
// A message means "something changed, re-fetch". The payload is NOT data
// and must never be parsed. (contract/runtime-api.md, stream section)
function startPolling() {
clearInterval(pollTimer);
pollTimer = setInterval(refresh, POLL_MS);
setStatus("polling", "polling fallback");
}
function fallback() {
if (events) { try { events.close(); } catch (e) { /* already closed */ } }
startPolling();
// Re-fetch NOW, not at the next poll tick. Without this the surface keeps
// showing the last good render for up to POLL_MS after the runtime goes
// away — stale data that still looks live. When something is wrong it has
// to LOOK wrong immediately. (contract/shell-protocol.md)
refresh();
clearTimeout(retryTimer);
retryTimer = setTimeout(connect, POLL_MS); // keep trying the stream
}
function connect() {
clearTimeout(retryTimer);
if (events) { try { events.close(); } catch (e) { /* already closed */ } }
events = new EventSource("/api/events");
events.onopen = () => {
clearInterval(pollTimer);
setStatus("live", "live signal");
refresh(); // no replay guarantee: re-fetch once on every (re)connect
};
events.onmessage = () => refresh();
events.addEventListener("degraded", fallback); // signal source unhealthy
events.onerror = fallback;
}
// ---- boot --------------------------------------------------------------
async function boot() {
try {
const response = await fetch("/api/contract", { cache: "no-store" });
const contract = await response.json();
// The compatibility rule from contract/contract-meta.md: capabilities
// present + same MAJOR version. An exact-match check would refuse
// runtimes that are contractually guaranteed to work.
const missing = NEEDED.filter((n) => !(contract.capabilities || []).includes(n));
const major = String(contract.contractVersion ?? "").split(".")[0];
const wantMajor = TARGET_CONTRACT.split(".")[0];
if (major !== wantMajor || missing.length) {
throw new Error(
"runtime contract v" + contract.contractVersion +
" cannot serve this surface (built for v" + TARGET_CONTRACT + ")" +
(missing.length ? "; missing capability: " + missing.join(", ") : "")
);
}
await refresh();
connect();
} catch (error) {
// Fail visibly, then degrade to polling rather than showing nothing.
showNotice("Cannot start: " + error.message);
startPolling();
}
}
// The only surface -> shell channel. Message set is closed for v0.1.
document.querySelector("#agents").addEventListener("click", () => {
parent.postMessage({ t: "open-agent" }, "*");
});
boot();
</script>
</body>
</html>
+278
View File
@@ -0,0 +1,278 @@
// Regression suite for create-studio. Zero dependencies — node:test only.
//
// node --test create-studio/test/
//
// Two things the
// original ad-hoc smoke testing missed, and which this suite exists to keep
// missing-proof:
//
// 1. Option VALUES were never exercised. Admission was covered thoroughly, so
// the harness looked strong, but `--hint 'say "hello"'` emitted invalid
// JSON while the CLI exited 0. Every user-controlled field is now run
// through a hostile-value matrix.
// 2. Nothing ran twice. A manual pass is not regression coverage: it
// does not run tomorrow.
//
// Assertion discipline: assert on CONTENT, never on exit status alone. A test
// that only checks a non-zero exit passes just as happily on a syntax error in
// the CLI as on a correct rejection. Rejection tests therefore assert the
// expected REASON, and `positive control` proves the suite can actually fail.
import { test } from "node:test";
import assert from "node:assert/strict";
import { execFileSync, execSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const PKG = path.resolve(HERE, "..");
const CLI = path.join(PKG, "index.mjs");
const SCHEMA = JSON.parse(
fs.readFileSync(path.resolve(PKG, "..", "contract", "surface-row.schema.json"), "utf8")
);
const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), "create-studio-test-"));
function run(args, { cwd, env } = {}) {
try {
const stdout = execFileSync(process.execPath, [CLI, ...args], {
cwd: cwd ?? os.tmpdir(),
env: { ...process.env, ...env },
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
return { code: 0, stdout, stderr: "" };
} catch (e) {
return { code: e.status ?? 1, stdout: e.stdout ?? "", stderr: e.stderr ?? "" };
}
}
const emitted = (dir, name) => ({
row: JSON.parse(fs.readFileSync(path.join(dir, name, "surfaces.row.json"), "utf8")),
html: fs.readFileSync(path.join(dir, name, "surface.html"), "utf8"),
readme: fs.readFileSync(path.join(dir, name, "README.md"), "utf8"),
});
// Entries left behind under a parent, ignoring nothing — staging directories are
// hidden, so a naive readdir that skipped dotfiles would hide exactly the
// failure this checks for.
const entries = (dir) => fs.readdirSync(dir);
// ---------------------------------------------------------------- generation
test("emits exactly the three project files and reports success", () => {
const dir = tmp();
const r = run(["my-surface", "--dir", dir]);
assert.equal(r.code, 0, r.stderr);
assert.deepEqual(fs.readdirSync(path.join(dir, "my-surface")).sort(),
["README.md", "surface.html", "surfaces.row.json"]);
assert.match(r.stdout, /created/);
});
test("emitted row is schema-shaped: required present, exactly one target, stable fields only", () => {
const dir = tmp();
assert.equal(run(["my-surface", "--dir", dir]).code, 0);
const { row } = emitted(dir, "my-surface");
for (const key of SCHEMA.required) assert.ok(key in row, `missing required field ${key}`);
assert.ok(("path" in row) !== ("url" in row), "must carry exactly one of path|url");
for (const key of Object.keys(row)) {
const prop = SCHEMA.properties[key];
assert.ok(prop, `emitted unknown field ${key}`);
assert.equal(prop["x-stability"], "stable",
`emitted ${key}, which is ${prop["x-stability"]} — the scaffolder emits stable fields only`);
}
assert.match(row.path, /^\//);
});
test("emitted surface targets the contract version and only stable endpoints", () => {
const dir = tmp();
assert.equal(run(["my-surface", "--dir", dir]).code, 0);
const { html } = emitted(dir, "my-surface");
assert.match(html, /const TARGET_CONTRACT = "0\.1"/);
const endpoints = [...html.matchAll(/\/api\/[a-z/]+/g)].map((m) => m[0]);
for (const e of new Set(endpoints)) {
assert.ok(["/api/contract", "/api/factory/state", "/api/events"].includes(e),
`emitted a non-stable endpoint: ${e}`);
}
// runtime-internal surfaces named in contract/runtime-api.md must never appear
assert.doesNotMatch(html, /files\/(write|goto|roots)|sidebar-arrangement|oauth|credential/i);
});
// ------------------------------------------------------------------ escaping
// The regression that produced this suite. Every user-controlled field crossed
// with every metacharacter class that has a different meaning in JSON, HTML or
// Markdown.
const HOSTILE = [
['double quote', 'say "hello" now'],
['backslash', "back\\slash and \\\" mix"],
['newline', "line one\nline two"],
['html metachars', '<strong>OWNED</strong> & <img src=x onerror=alert(1)>'],
['markdown fence', "```json\nnot really\n```"],
['json braces', '{"injected": true}'],
['unicode + quote', '◆ "◆" ◆'],
];
for (const [label, value] of HOSTILE) {
test(`hint survives ${label} in every output context`, () => {
const dir = tmp();
const r = run(["my-surface", "--dir", dir, "--hint", value]);
assert.equal(r.code, 0, r.stderr);
// JSON context: must parse, and must round-trip the value EXACTLY.
const { row, html, readme } = emitted(dir, "my-surface");
assert.equal(row.hint, value, "hint did not round-trip through the emitted JSON");
// HTML context: no raw markup may survive into the document.
assert.doesNotMatch(html, /<strong>OWNED<\/strong>/);
assert.doesNotMatch(html, /<img src=x/);
// Markdown context: the fenced blocks in the README must still be balanced.
// Fences here are list-indented, so anchor on optional leading whitespace —
// matching only column-0 fences would silently skip them.
const fences = (readme.match(/^\s*```/gm) || []).length;
assert.equal(fences % 2, 0, "emitted README has an unbalanced code fence");
// The README's inline row must still be parseable JSON, and must be indented
// consistently — a ragged block is what a developer copy-pastes out.
const block = readme.match(/```json\n([\s\S]*?)\n\s*```/);
assert.ok(block, "emitted README lost its json block");
const lines = block[1].split("\n");
const indent = (l) => l.match(/^ */)[0].length;
assert.equal(indent(lines[0]), indent(lines[lines.length - 1]),
"json block is ragged: opening and closing braces are at different indents");
JSON.parse(block[1]);
});
}
for (const [label, value] of [['double quote', '"'], ['backslash', "\\"], ['newline', "\n"], ['angle bracket', "<"]]) {
test(`glyph survives ${label}`, () => {
const dir = tmp();
const r = run(["my-surface", "--dir", dir, "--glyph", value]);
assert.equal(r.code, 0, r.stderr);
const { row } = emitted(dir, "my-surface");
assert.equal(row.glyph, value, "glyph did not round-trip through the emitted JSON");
});
}
test("negative control: a plain hint also passes (the matrix is not vacuously green)", () => {
const dir = tmp();
assert.equal(run(["my-surface", "--dir", dir, "--hint", "plain text"]).code, 0);
assert.equal(emitted(dir, "my-surface").row.hint, "plain text");
});
// ----------------------------------------------------------------- admission
const REJECTIONS = [
["traversal", ["../evil"], /single path segment/],
["absolute path", ["/tmp/evil"], /must not be an absolute/],
["nested segment", ["a/b"], /single path segment/],
["uppercase and punctuation", ["My Surface!"], /lowercase letters/],
["empty name", [""], /project name is required/],
["missing name", [], /project name is required/],
["parent traversal name", [".."], /must not start with/],
["leading dot", [".hidden"], /must not start with/],
["reserved device name", ["con"], /is reserved/],
["tilde", ["~evil"], /must not start with/],
["trailing dash", ["bad-"], /lowercase letters/],
["over-long name", ["a".repeat(65)], /characters; the limit/],
];
for (const [label, args, expected] of REJECTIONS) {
test(`rejects ${label} and writes nothing`, () => {
const dir = tmp();
const r = run([...args, "--dir", dir]);
assert.notEqual(r.code, 0, `expected rejection for ${label}`);
assert.match(r.stderr, expected, `wrong reason for ${label}: ${r.stderr}`);
assert.deepEqual(entries(dir), [], "a rejected run must leave the filesystem untouched");
});
}
test("rejects a multi-character glyph", () => {
const dir = tmp();
const r = run(["ok-name", "--dir", dir, "--glyph", "ab"]);
assert.notEqual(r.code, 0);
assert.match(r.stderr, /exactly one character/);
assert.deepEqual(entries(dir), []);
});
test("rejects an option missing its value, and an unknown option", () => {
const dir = tmp();
assert.match(run(["ok-name", "--dir"]).stderr, /needs a value/);
const r = run(["ok-name", "--bogus", "--dir", dir]);
assert.notEqual(r.code, 0);
assert.match(r.stderr, /unknown option/);
assert.deepEqual(entries(dir), []);
});
test("refuses a non-empty destination and leaves its contents intact", () => {
const dir = tmp();
fs.mkdirSync(path.join(dir, "occupied"));
fs.writeFileSync(path.join(dir, "occupied", "keep.txt"), "existing work");
const r = run(["occupied", "--dir", dir]);
assert.notEqual(r.code, 0);
assert.match(r.stderr, /is not empty/);
assert.equal(fs.readFileSync(path.join(dir, "occupied", "keep.txt"), "utf8"), "existing work");
assert.deepEqual(fs.readdirSync(path.join(dir, "occupied")), ["keep.txt"]);
});
test("accepts an existing EMPTY destination", () => {
const dir = tmp();
fs.mkdirSync(path.join(dir, "empty-dest"));
const r = run(["empty-dest", "--dir", dir]);
assert.equal(r.code, 0, r.stderr);
assert.ok(fs.existsSync(path.join(dir, "empty-dest", "surface.html")));
});
test("refuses a destination whose parent does not exist", () => {
const r = run(["my-surface", "--dir", path.join(tmp(), "nope")]);
assert.notEqual(r.code, 0);
assert.match(r.stderr, /parent does not exist/);
});
// ------------------------------------------------------------------ atomicity
test("a mid-generation failure leaves no project and no staging residue", () => {
const dir = tmp();
const r = run(["halfway", "--dir", dir], { env: { OPENRIG_STUDIO_CREATE_FAIL_AFTER: "2" } });
assert.notEqual(r.code, 0);
assert.match(r.stderr, /nothing was written/);
assert.deepEqual(entries(dir), [],
"destination or hidden staging directory survived a failed generation");
});
test("failure injection is inert when unset (the atomicity test is not self-fulfilling)", () => {
const dir = tmp();
assert.equal(run(["halfway", "--dir", dir]).code, 0);
assert.ok(fs.existsSync(path.join(dir, "halfway", "surface.html")));
});
// -------------------------------------------------------------- packaged bin
test("the packed tarball exposes a working bin through npm's own resolution", { timeout: 180_000 }, () => {
const dir = tmp();
const packed = execSync(`npm pack ${JSON.stringify(PKG)} --pack-destination ${JSON.stringify(dir)}`,
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim().split("\n").pop();
const tarball = path.join(dir, packed);
assert.ok(fs.existsSync(tarball), "npm pack produced no tarball");
const out = path.join(dir, "out");
fs.mkdirSync(out);
execSync(`npm exec --yes --package=${JSON.stringify(tarball)} -- create-studio packed-check`,
{ cwd: out, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
assert.deepEqual(fs.readdirSync(path.join(out, "packed-check")).sort(),
["README.md", "surface.html", "surfaces.row.json"]);
JSON.parse(fs.readFileSync(path.join(out, "packed-check", "surfaces.row.json"), "utf8"));
});
// ------------------------------------------------------------ suite sanity
test("positive control: this suite is capable of failing", () => {
assert.throws(() => assert.equal(1, 2));
});
+21
View File
@@ -0,0 +1,21 @@
{
"rig": "fixture-rig",
"seats": [
{ "seat": "dev-driver@fixture-rig", "pod": "dev", "member": "driver", "state": "working",
"ageMinutes": 2, "hasWork": true, "pendingWork": 1, "lifecycle": "running" },
{ "seat": "dev-qa@fixture-rig", "pod": "dev", "member": "qa", "state": "idle",
"ageMinutes": 14, "hasWork": false, "pendingWork": 0, "lifecycle": "running" },
{ "seat": "orch-lead@fixture-rig", "pod": "orch", "member": "lead", "state": "blocked",
"ageMinutes": 31, "hasWork": true, "pendingWork": 3, "lifecycle": "running" },
{ "seat": "review-r1@fixture-rig", "pod": "review", "member": "r1", "state": "down",
"ageMinutes": null, "hasWork": false, "pendingWork": 0, "lifecycle": "stopped" }
],
"queue": [
{ "id": "qitem-fixture-0001", "state": "in-progress", "destination": "dev-driver@fixture-rig",
"source": "orch-lead@fixture-rig", "tags": ["fixture", "demo"], "updated": "2026-07-31T00:00:00.000Z",
"title": "Implement the widget endpoint" },
{ "id": "qitem-fixture-0002", "state": "pending", "destination": "dev-qa@fixture-rig",
"source": "dev-driver@fixture-rig", "tags": ["fixture", "review"], "updated": "2026-07-31T00:05:00.000Z",
"title": "Review the widget endpoint change" }
]
}
+6
View File
@@ -0,0 +1,6 @@
# files-root
This directory is the pinned root the runtime's read-only files verbs serve.
Everything under it is reachable through `/api/files/*`; nothing outside it is.
It is fixture content: safe demo files a surface can list, search, and read.
+7
View File
@@ -0,0 +1,7 @@
# Welcome note
A small markdown file that lives inside the pinned files root, so surfaces
have something real to read through `/api/files/read`.
- It renders as markdown if your surface chooses to.
- Its mtime changes if you edit it, which fires the change-signal stream.
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@openrig/studio",
"version": "0.3.0",
"description": "SDK for building agent-driven mini-apps: thin surfaces paired with agents, running in a shared shell.",
"type": "module",
"scripts": {
"start": "node app/serve-studio.mjs",
"create": "node create-studio/index.mjs",
"test": "node --test 'create-studio/test/*.test.mjs' 'test/*.test.mjs'"
},
"engines": {
"node": ">=18"
},
"license": "Apache-2.0"
}
+91
View File
@@ -0,0 +1,91 @@
---
name: building-studio-apps
description: Use when building, packaging, or registering an app, surface, or tab for the OpenRig Studio SDK (@openrig/studio) — including deciding whether an app needs its own server, why a surface loads but its panes stay empty, why a tab 404s or shows another project's data, or why an app that works on the machine that built it fails on a fresh install.
---
# Building Studio Apps
## What the SDK actually is
`@openrig/studio` is an **app shell and a contract** — a rail of tabs, a stage, an agent
sidebar, and a protocol for how a surface talks to them. It is **not a framework you import.**
Nothing in your app needs to `require` it.
That single fact determines everything else: your app can be plain HTML, Next.js, Go, Rust, or
a container. If it answers on a URL and conforms to the contract, it can be a tab.
**The shell is a directory + a frame + an agent bridge. You do not install an app INTO it.
You register a ROW.**
## The one decision that shapes everything
**Ultralight vs heavy is not about size. It is about WHO SERVES THE BYTES.**
| | ultralight | heavy |
|---|---|---|
| what it is | HTML + a script, riding verbs the shell already serves | brings its own server |
| registered as | `path: /surfaces/foo.html` | `url: https://…` |
| needs the SDK to run? | **yes — the shell IS its runtime** | **no — only to appear as a tab** |
**This inverts the intuitive reading and you will get it backwards.** The *small* app is the one
that genuinely depends on the SDK. The *big* one runs fine without it and only needs a row.
Consequence worth internalising: a heavy app just runs your service, adds a row, and gets the agent panel beside it — zero integration code.
## The three steps
1. **Install it** however that kind of software normally installs — git clone, npm, docker.
The SDK has no opinion and no installer.
2. **Register a row** so it appears on the rail. → `app-manifest.md`
3. **Make it work**, which is a different claim from step 2 and is where the time goes.
→ `providers-and-roots.md`, `failure-modes.md`
## Read next
| file | read it when |
|---|---|
| `app-manifest.md` | writing `app.json`; declaring a provider, roots, or vendored assets |
| `shell-contract.md` | your surface needs to talk to the shell — focus, navigation, the agent sidebar |
| `providers-and-roots.md` | your app needs a backend, files on disk, or a binary like ffmpeg |
| `failure-modes.md` | **read this before you ship, not after.** Every entry is a real failure with the symptom you will actually see |
## The rule that saves the most time
**Verbs answering is not apps working, and only a browser catches the difference.**
Every API check can return 200 with real data while the pane renders empty — a field renamed, a
vocabulary the icon map does not know, a bundle that never loaded. This was the single most
common failure in building the first five apps.
**Open the app. Look at it. Click the thing.** A passing test suite is a shape that a working
app also produces.
## Versions: the package and the contract move separately
`@openrig/studio` is at **0.3.0**; the **contract is 0.1**. Those are different numbers on
purpose and you will trip on it once. Additive changes — new fields, new routes, the
consumer-surface seam — bump the package and leave `contractVersion` alone. A surface built on
stable items of contract 0.1 keeps working across package minors.
**Check compatibility the documented way:** capabilities present + same contract *major*. An
exact-match check on `contractVersion` is safe but over-strict and will refuse runtimes that are
guaranteed to work. Ask the running box:
```sh
curl -s localhost:<port>/api/contract | jq '{contractVersion, capabilities}'
```
## Status of this skill
Grounded in building and migrating the first five applications built on this SDK. Every failure mode listed is one that actually happened and was
measured, not anticipated.
**One skill, two authorship perspectives** — consumer-side and producer-side, deliberately not
two documents. The consumer half is the original and remains the spine: it is the half that knows
what actually goes wrong when you build on this thing. Producer additions are marked `[producer]`
and are limited to what the SDK side can state authoritatively — mechanism, version facts, and
which behaviours are contract rather than accident. Where the two would disagree, the measured
consumer experience wins and the producer note explains why the runtime does that.
If you are extending this: extend it here. A second copy in another repo is the drift this
placement exists to prevent.
+137
View File
@@ -0,0 +1,137 @@
# app.json — declaring an app
One file beside your app's code. It is what lets a box install your app without a human
reading your README and guessing.
## Minimum — an ultralight app
```json
{
"manifest_version": 1,
"id": "notes",
"name": "NOTES",
"summary": "Scratch notes that live with the project.",
"category": "create",
"surface": {
"entry": "app/notes.html",
"path": "/surfaces/notes.html",
"glyph": "▤",
"hint": "jot and search"
}
}
```
- **`entry`** — where the file lives in YOUR repo.
- **`path`** — where it is served on the rail. The composer copies `entry` → the overlay and
serves it at `path`. These are two different things and conflating them is a common slip.
- **`glyph`** — one character. The rail is dense; an emoji will look wrong beside the others.
## A heavy app
A heavy app serves itself. It has no `entry` to copy — only a URL to point at.
```json
{
"manifest_version": 1,
"id": "beatline",
"name": "BEATLINE",
"surface": {
"url": "http://127.0.0.1:8794/beatline.html",
"glyph": "∿",
"hint": "music-video beat timeline"
}
}
```
**`path:` and `url:` are mutually exclusive.** `path:` means "the shell serves this file";
`url:` means "something else already serves it, just frame it."
## The fields that make it installable
Everything above makes an app *appear*. These make it *work* on a machine that is not yours.
```json
{
"provider": {
"package": "@example/my-provider",
"required": true
},
"roots": {
"project": { "required": true },
"media": { "required": true }
},
"verbs": ["/api/timelines", "/api/patch", "/api/probe-duration"],
"vendor": ["tldraw-v5.2.5"]
}
```
| field | why it exists |
|---|---|
| `provider` | the backend your surface calls. **Omit it and installing your app yields an HTML file that 502s.** |
| `roots` | the KINDS of directory you need — not paths. The install binds kinds to real locations. |
| `verbs` | what you call. Makes the dependency auditable rather than discovered at runtime. |
| `vendor` | directories that travel with the app. Ship them; do not fetch from a CDN. |
### roots are KINDS, never paths
```json
"roots": { "media": { "required": true } } ✅ the install binds it
"roots": { "media": "/Users/me/videos" } ❌ works on exactly one machine
```
Every hardcoded path in the apps migrated so far existed because **nothing in the format ever
asked where the user's data lives.** Declaring kinds kills the whole class.
### Declare the closure; do not discover it
List what your app needs. Do not expect an installer to find it by scanning your imports — a
scan that matches `./` will miss `../../vendor/tldraw`, and the app will load to a spinner with
every verb returning 200.
**An app is not its surface file.** It is surface + backend + vendored deps + a resolved
closure + the roots it needs + first-run seeding.
## [producer] Where your surfaces actually live, and what the runtime will serve
Register from **outside** the package. Point the runtime at a directory you own:
```sh
node node_modules/@openrig/studio/app/serve-studio.mjs --surfaces ./surfaces
# or
OPENRIG_STUDIO_SURFACES=./surfaces npm start
```
That directory holds your pages **and** your own `surfaces.json`, using the same row schema.
Nothing is ever written into the package, so a copied `node_modules` cannot carry your
registrations — which is failure mode #8, removed structurally rather than mitigated.
Three consequences that will bite you if you assume otherwise:
**Serving follows registration, not the filesystem.** A file in your overlay is served **only**
when a valid row registers it. Dropping `notes.html` next to your manifest does not publish it —
it is a 404. So is anything else in that directory: a stray `secret.txt` is not reachable. The
overlay is a registration seam, not a static web root.
**A registered row whose page is missing fails as itself.** You get a 404 naming the problem, not
the package's page quietly substituted underneath your row. Row ownership and page ownership are
never allowed to disagree about whose surface you are looking at.
**Replacing a stock surface is an ID act, and it warns.** Reuse the id and your row wins with a
named warning in `manifest.warnings`. Reusing another row's `path` under a different id is
refused with an error instead — otherwise replacement would become a silent filename act.
**With an overlay configured, the runtime serves exactly what is declared** — the SDK's own
surfaces plus yours — and anything else in the installed package is ignored for serving and
reported in `manifest.warnings`. That guarantee covers surface state written *into* a package
(a copied tree, a stale materialisation, a dependency writing into `node_modules`). It does not
cover a dependency shipping a modified runtime source; that is supply-chain, a higher bar you
accept by installing at all.
## The test that matters
**Install the manifest somewhere that has nothing.**
A manifest written on a machine that already has everything will always under-declare — not
through carelessness, but because nothing forced it to be honest. The first app to fail this
way declared a surface and a screenshot, and its own verify step admitted the runtime provider
was "not shipped in this package." It was listed in a registry as installable. It was not.
@@ -0,0 +1,197 @@
# Failure modes
Every entry below actually happened while building or migrating the first five apps on this
SDK. They are ordered by how much time they cost. **The symptom is listed first, because the
symptom is what you will have.**
The unifying shape: **a result whose form looks like an answer while its content is not one.**
Almost every one of these produced HTTP 200, a clean exit, or a green check.
---
## 1. The pane is empty and every verb returns 200
**Cause:** the response shape does not match what the surface reads. A field renamed, a
wrapper added, a vocabulary the UI does not know.
Three real instances, all in one afternoon, all from hand-rewriting working code:
- returned `{dir, entries}` where the surface reads `{roots}` or `{path, dirs, files}`
- read every file as `utf8`, corrupting video, and hung the pane
- returned a `kind` vocabulary (`"file"`) the icon map had no entry for
**Fix:** treat the response shape as a **contract with the surface**, not an implementation
detail. When porting, port — do not retype. Assert on the shape in a test, including the
"no argument" case, because that is usually the one the UI actually calls first.
**Why it hides:** the verb genuinely works. Real data, correct status, sensible JSON.
---
## 2. The tab loads but sits on a spinner forever
**Cause:** a vendored dependency did not load. The surface's own verbs are all fine.
Real instance: an import scan that matched `./` missed `../../vendor/tldraw`, so the app was
installed without its renderer.
**Fix:** declare the vendor closure in `app.json`. Do not discover it by scanning.
---
## 3. The app shows an empty state on a project that is not empty
**Cause:** your page is **not served from inside your project.** A relative
`fetch("timeline.json")` resolves against the surfaces overlay, not your data directory.
Real instance: an app fetched a bare relative filename, which had always worked because its
previous host served the page *out of the project bundle*. Under the SDK studio the shell
serves surfaces from an overlay and only `/api/*` reaches the backend — so it 404'd on **every
project**, fell back to an embedded sample, and reported success.
**Fix:** ask the backend for the document over `/api/…`. Never assume you are inside the data.
---
## 4. A load failure that looks exactly like a new project
**Cause:** a fallback that renders as a plausible empty state.
This is #3's second half and it is worse than #3. The fallback was sample data, so a failure rendered as a different project with broken media — which reads as
"my project loaded wrong." Emptying the fallback fixed the lie and created a subtler one: a
total failure to load became a clean empty board, indistinguishable from a fresh project.
**Fix:** **an empty state and a failed load must not share an appearance.** Name the failure,
say what was tried, and withdraw any affordance that would write into data you are not showing.
---
## 5. A dependency answers, but it is the wrong one
**Cause:** a hardcoded default port, plus a degradation path that cannot tell "absent" from
"answered wrong."
Real instance: a proxy read an env var or fell back to a hardcoded `127.0.0.1:<port>`. On a
busy host something else was there. It returned `ok: true` with a **stranger's data** while the
real service held the correct data.
**Fix:** bind dependencies explicitly. **A degradation path must distinguish "dependency
absent" from "dependency answered wrong"** — otherwise graceful degradation is
indistinguishable from misconfiguration, because both return 200.
---
## 6. The button says it worked and nothing happens
**Cause:** the write is real — data changes on disk — but the process that performs the effect
is not running or not in scope.
Real instance: a verb appended a job for a render watcher, returned `ok: true`, and nothing
ever rendered. **This defeats both obvious checks**: verbs-answer passes, and real-data-changed
passes, because the job file genuinely gains a line.
**Fix:** the bar is **"the verb's real-world effect is observably performed,"** not "data
changed." Observe the render, not the enqueue. If the performer is genuinely out of scope, make
the control **visibly unavailable** — never a button returning success that silently does
nothing.
---
## 7. Works on the author's machine, silently no-ops elsewhere
**Cause:** platform-specific shell behaviour inside a guard.
Real instance: a watcher script used `stat -c %Y` (GNU). On macOS (BSD `stat`) the call failed,
the guard fell through, and **the lane never ran at all** — silently, for an unknown length of
time.
**Fix:** no shell-outs for things a runtime does natively. If you must, test on both platforms
or fail loud when the tool is not the one you expect.
---
## 8. A fresh-looking install serving another instance's content
**Cause:** state written into the installed package directory, then copied.
If a boot step writes into `node_modules`, then `node_modules` is **state**, not just
dependencies. Copying it to skip a slow install carries another instance's registrations.
**Fix:** keep consumer state outside the package (the SDK's `--surfaces` seam exists for this).
And: **an instance's rail is not proof of its commit** — healthy and correct are two claims.
---
## 9. The app is schema-invalid the moment it is created
**Cause:** a required field the creation path does not set — masked by a bigger failure
upstream.
Real instance: every beat created from the UI lacked a required `template` field. Completely
invisible, because the board never loaded a real project (see #3), so validation never ran
against a UI-created record.
**Fix:** when you fix a load path, **re-run everything downstream of it.** Bugs hide behind
bugs, and the second one only becomes reachable when the first is fixed.
---
## 10. Evidence collected from the wrong server
**Cause:** a port collision on a busy host.
A studio that lands on a stranger's port produces screenshots and API responses that are
evidence for something you did not build.
**Fix:** fail loud on an occupied port; never attach to whatever is listening. Confirm with
`lsof -nP -iTCP:<port> -sTCP:LISTEN`, the process args, and in-browser `location.origin`.
---
## 11. [producer] Your file is right there in the overlay and the URL 404s
**Cause:** no valid row registers it. Serving follows **registration**, not the filesystem —
putting a file in your surfaces directory does not publish it.
The same rule catches the case you want caught: a stray `secret.txt` beside your manifest is not
reachable either. The overlay is a registration seam, not a static web root.
**Fix:** check `/api/contract` → `manifest.errors[]`. If your row was rejected it is named there,
row by row. A row that fails validation is excluded from the rail *and* its path stops resolving,
which is why the symptom is a 404 rather than a missing tab.
**Related, and it surprises people:** a registered row whose page is missing returns a 404 naming
the problem — the package's page is **not** substituted underneath your row. That is deliberate.
Serving someone else's bytes under your row would make the rail and the page disagree about whose
surface you are looking at.
---
## 12. [producer] The package is contaminated and your check says it is clean
**Cause:** you checked for **errors**. Contamination is reported as a **warning**.
With an overlay configured, the runtime serves exactly the SDK's own declared surfaces plus your
overlay-declared ones. An undeclared row or file in the installed package — from a copied tree, a
stale materialisation, a dependency writing into `node_modules` — is ignored for serving and
named in `manifest.warnings`. It is deliberately *not* an error: it is not the consumer's fault
and it must not stop the runtime serving.
So `manifest.ok === true` and `errors: []` on a **dirty** package is the correct, healthy-looking
output. A monitor watching for errors sees nothing wrong.
**Fix:** **no-errors is not the clean signal — the warning's absence is.** Assert on
`manifest.warnings` being empty, not just `manifest.errors`. This is the same shape as everything
else on this page: the check's failure output is indistinguishable from its success output unless
you look at the field that actually carries the answer.
---
## The checklist this reduces to
- Open the app in a browser and use it. Do not infer working from responding.
- Ask of every check: **what does this look like when it fails?** If that resembles success,
the check proves nothing — run a negative control that makes it fail on purpose.
- Assert that the **legitimate** operation still works, not only that the bad one is rejected.
A boundary that rejects everything passes every attack test and ships a dead feature.
- Install your manifest somewhere that has nothing.
- Never let a failure borrow an empty state's appearance.
@@ -0,0 +1,94 @@
# Providers, roots, and the closure
The gap between "my app appears on the rail" and "my app works."
## Do you need a provider?
A **provider** is the backend your surface calls. Decide with one question:
> Does my surface call any verb the shell does not serve?
Check what the shell actually gives you — `curl /api/contract` → `capabilities`. At contract
v0.1 that is a deliberately small set (contract meta, observe, an event stream, **read-only**
file access, the shell protocol).
- **No** → ultralight. No provider. You ride the shell's verbs.
- **Yes** → you need a provider, and **`app.json` must declare it.**
```json
"provider": { "package": "@example/my-provider", "required": true }
```
**Omit this and installing your app produces an HTML file that 502s.** That is not
hypothetical — an app can be listed as installable while its manifest declares only a surface and a screenshot, with no provider shipped in the package.
## Do not extract a "generic core" prematurely
When your provider grows, the instinct is to split the reusable half out. Resist it until a
**second consumer exists to show you where the seam is.**
Measured reason: hand-extracting a working backend broke three response contracts in one
afternoon, each returning 200 with real data while the pane rendered empty. Doing that
speculatively at 10× scale, with no second consumer, is the same operation with worse odds.
**Name it and package it as-is.** The seam reveals itself from demand.
## Roots are KINDS
Declare the kinds of directory you need. The install binds kinds to real paths.
```json
"roots": {
"project": { "required": true },
"media": { "required": true }
}
```
Every hardcoded path found in the migrated apps — a specific project folder, one machine's working directory, `$HOME/studio/app` — existed for the same reason: **nothing in the format
ever asked where the user's data lives.** They were not sloppiness. They were a missing
convention, and declaring kinds removes the class permanently.
Corollary for your code: **parameterise the roots** (`--media-root`, `--project-root`) so
binding is configuration, not a patch.
## Declare the closure
List everything your app needs to run:
- the provider package
- vendored directories (`vendor: ["tldraw-v5.2.5"]`) — ship them; the box should not call a CDN
- binaries you shell out to (`ffmpeg`, `ffprobe`) — **name the binary AND the verb that uses
it**, so the dependency is auditable and cannot quietly grow by analogy
- first-run seeding, if your app needs a scaffolded project to be usable
**Do not expect an installer to discover this by scanning your imports.** A scan matching `./`
misses `../../vendor/…`, and the result is an app that loads to a spinner with every verb
returning 200.
## First run
An app whose backend answers `ENOENT` on a fresh box is honest but unusable. Decide explicitly
which you are shipping:
- **honestly empty** — no data yet, and a *working way to add some*. This is fine.
- **stub empty** — returns `[]` regardless of what is on disk. This is not.
If your app needs a project structure to exist, seed it with the product's **own** scaffolder.
Hand-writing a plausible-looking data file gives the app something that parses and then behaves
wrong.
## Serving your own documents
If your surface needs to read your project's files, **serve them from your provider over
`/api/…`.** Do not let the surface fetch them as siblings, and do not read them through some
other backend's file verbs.
Both shortcuts work on the machine you are testing on and fail elsewhere:
- the sibling fetch works only while something serves your page from inside your data directory
- another backend's file verbs work only while your data happens to sit inside *its* configured
roots
The document belongs to the provider that owns it. **The path should be server-derived** — a
read verb that takes no caller-supplied path cannot be pointed somewhere it should not go, and
needs no validation to prove it.
@@ -0,0 +1,83 @@
# Talking to the shell
**The contract ships inside the package. Read it there, not here** — restating it would create
a second source that silently drifts:
| document | in `node_modules/@openrig/studio/contract/` |
|---|---|
| the shell protocol, lifecycle, postMessage, the change-signal loop | `shell-protocol.md` |
| the row schema, field by field, with stability markers | `surface-row.schema.json` |
| the runtime's own verbs | `runtime-api.md` |
| what the contract version means | `contract-meta.md` |
Check the live contract of the box you are on:
```sh
curl -s localhost:<port>/api/contract | jq '{contractVersion, capabilities, manifest}'
```
## The short orientation
Your surface lives in an iframe and is **sovereign inside it**. The shell gives you four
things, and only four:
1. **A frame and a rail tab.** Registered by a row; `path` (shell serves it) or `url`
(something else does) — exactly one, never both.
2. **A liveness dot**, from the shell polling your URL. You write no code for it. It means
*answered HTTP*, nothing more — a dot is not evidence the app works.
3. **An agent sidebar** you may summon with `postMessage`.
4. **A change signal** (`GET /api/events`, SSE) — **a signal, not a data channel.** On any
message, re-fetch what you care about. Do not parse payloads.
The surface→shell channel is a **closed** message set in v0.1 (`open-surface`, `open-agent`).
The shell ignores anything else; new types arrive as contract additions, not by surfaces
inventing them.
## Consumer-side notes the contract docs do not cover
**Your iframe is kept alive when backgrounded.** State and open connections survive a tab
switch. If your surface holds an attachment that must not persist invisibly — a terminal
client, a device handle — declare `unmountOnBlur: true` or a hidden instance will fight the
visible one.
**Declare `color-scheme: dark`.** The shell is dark; a light flash on load reads as broken.
**Show degraded states in the UI, not the console.** When something is wrong it must *look*
wrong. This is the same rule as `failure-modes.md` #4, stated by the contract itself.
**`chatSeats` had no consumer path at contract v0.1.** The runtime merges `surfaces[]` from a
consumer overlay and passes the rest of the document through, so a consumer's declared seat
roster was ignored and every install showed the SDK's own fixture seat in the agent sidebar.
Verify what your box actually serves before trusting the roster in that panel; a consumer front
that owns the origin can compose the real roster onto `/surfaces.json` itself. **Re-check
whether this is still true on your contract version** rather than assuming either way.
> **[producer] Answering that re-check, as of package `0.3.0` / contract `0.1`: STILL TRUE, and
> here is the mechanism so you can confirm it yourself rather than trust this line.**
> `/surfaces.json` is served as `{ ...packageDocument, surfaces: <merged rows> }` — the package
> document spread with only `surfaces` overridden. **The overlay document is never spread**, so
> overlay `chatSeats` is read by nothing and the package's fixture roster reaches every install.
>
> **The part that will cost you time: it fails silently.** Putting `chatSeats` in your overlay is
> the obvious move — it mirrors exactly how you register surfaces — and it produces no warning,
> no error, and nothing at `/api/contract`. A plausible path that does nothing.
>
> This is a known defect with a ruled fix pending, not a design intent. The likely shape is that
> the package roster stops leaking into consumer installs and `/api/contract` starts declaring
> that seats are package-sourced and **not** overlay-merged — so the obvious attempt hits a
> visible signal instead of silence. **Check `/api/contract` on your version before assuming
> either behaviour.**
>
> One correction to the workaround above: composing the roster onto `/surfaces.json` yourself
> works only if **you own the origin** — i.e. your own front serves that path. Under the SDK
> runtime you do not, so there is no consumer-side fix on this version; the honest move is to
> treat the sidebar roster as unreliable and say so in your UI rather than paper over it.
>
> The fixture seat **is labelled**: the sidebar renders it beside an explicit note that the
> reference runtime has no live seat backing. Wrong for a real box, but it is a labelled fixture,
> not a silent lie — do not report it as one.
**The fixture floor is fiction by design.** The SDK ships a `factory-state.json` describing an
invented rig, rendered under a live-activity header with a green dot. Correct as an SDK
example, actively misleading on a real box — generate the same contract from your real source
and point the runtime at that.
+600
View File
@@ -0,0 +1,600 @@
// Regression suite for the consumer-surface-registration seam.
//
// node --test 'test/*.test.mjs'
//
// The seam exists so a consumer of this SDK can register ITS OWN surfaces from
// outside the package. Before it, the only documented path pointed inside the
// package — which in a real install is node_modules/@openrig/studio/app/ —
// gitignored, wiped by npm ci, and carried by a copy: duplicating a
// node_modules tree duplicated phantom surface state, so a contaminated
// instance served 200 with a healthy manifest and the WRONG surfaces.
//
// So the tests below split into two claims that are NOT the same:
//
// REGISTRATION works — a consumer surface loads from outside the package.
// THE CLASS IS GONE — there is ZERO consumer state inside the package, so
// copying node_modules cannot inject a surface at all.
//
// The second is the one that matters and the one that is easy to pass by
// accident while failing in spirit: any cache, copy or materialisation of
// consumer surfaces into the package would satisfy the first and break the
// second. It is tested directly rather than inferred from the first.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn, execSync } from "node:child_process";
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const REPO = path.resolve(HERE, "..");
const CONSUMER_ROW = {
id: "mine", name: "MINE", glyph: "▲", path: "/surfaces/mine.html", hint: "consumer-owned",
};
const CONSUMER_PAGE = "<!doctype html><html><head><title>MINE</title></head><body>consumer surface</body></html>";
const freePort = () => new Promise((resolve, reject) => {
const s = net.createServer();
s.once("error", reject);
s.listen(0, "127.0.0.1", () => { const { port } = s.address(); s.close(() => resolve(port)); });
});
// A throwaway copy of the runtime plus a SEPARATE consumer dir — the point of
// the seam is that these two are not the same tree.
function scaffold({ withConsumer = true } = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "seam-test-"));
fs.cpSync(path.join(REPO, "app"), path.join(dir, "app"), { recursive: true });
fs.cpSync(path.join(REPO, "fixtures"), path.join(dir, "fixtures"), { recursive: true });
const consumer = path.join(dir, "consumer");
if (withConsumer) {
fs.mkdirSync(consumer);
fs.writeFileSync(path.join(consumer, "surfaces.json"), JSON.stringify({ surfaces: [CONSUMER_ROW] }, null, 2));
fs.writeFileSync(path.join(consumer, "mine.html"), CONSUMER_PAGE);
}
return { dir, consumer, appDir: path.join(dir, "app") };
}
async function start(dir, args = []) {
const port = await freePort();
const proc = spawn(process.execPath, [path.join(dir, "app", "serve-studio.mjs"), "--port", String(port), ...args],
{ cwd: dir, stdio: ["ignore", "pipe", "pipe"] });
let log = "";
proc.stdout.on("data", (d) => { log += d; });
proc.stderr.on("data", (d) => { log += d; });
const get = async (p) => fetch(`http://127.0.0.1:${port}${p}`, { cache: "no-store" });
const contract = async () => (await (await get("/api/contract")).json()).manifest;
const deadline = Date.now() + 10_000;
for (;;) {
try { await contract(); break; } catch {
if (Date.now() > deadline) { proc.kill(); throw new Error(`server never came up:\n${log}`); }
await new Promise((r) => setTimeout(r, 100));
}
}
const until = async (pred, ms = 8000) => {
const end = Date.now() + ms;
let last;
for (;;) {
last = await contract();
if (pred(last)) return last;
if (Date.now() > end) return last;
await new Promise((r) => setTimeout(r, 100));
}
};
return {
port, get, contract, until, log: () => log,
rows: async () => (await (await get("/surfaces.json")).json()).surfaces,
text: async (p) => (await get(p)).text(),
status: async (p) => (await get(p)).status,
stop: () => proc.kill(),
};
}
// Every file under a root, relative — used to assert the package is untouched.
function treeOf(root) {
const out = [];
const walk = (d, prefix) => {
for (const name of fs.readdirSync(d).sort()) {
const full = path.join(d, name);
if (fs.statSync(full).isDirectory()) walk(full, path.join(prefix, name));
else out.push(path.join(prefix, name));
}
};
walk(root, "");
return out;
}
// ------------------------------------------------------- registration works
test("a consumer surface registers from OUTSIDE the package and is served", async (t) => {
const { dir, consumer } = scaffold();
const srv = await start(dir, ["--surfaces", consumer]);
t.after(() => srv.stop());
const m = await srv.contract();
assert.equal(m.ok, true, JSON.stringify(m.errors));
assert.equal(m.surfaces, 2, "expected FLOOR plus the consumer surface");
assert.deepEqual((await srv.rows()).map((r) => r.id), ["floor", "mine"]);
// The rail must derive from the ARTIFACT, not merely look right: the served
// bytes have to be the consumer's file.
assert.equal(await srv.text("/surfaces/mine.html"), CONSUMER_PAGE);
assert.equal(fs.readFileSync(path.join(consumer, "mine.html"), "utf8"), CONSUMER_PAGE);
assert.equal(m.consumer.dir, consumer);
assert.equal(m.consumer.surfaces, 1);
assert.equal(m.consumer.state, "ok");
});
test("the env var registers the same way as the flag", async (t) => {
const { dir, consumer } = scaffold();
const port = await freePort();
const proc = spawn(process.execPath, [path.join(dir, "app", "serve-studio.mjs"), "--port", String(port)],
{ cwd: dir, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, OPENRIG_STUDIO_SURFACES: consumer } });
t.after(() => proc.kill());
const deadline = Date.now() + 10_000;
let m;
for (;;) {
try { m = (await (await fetch(`http://127.0.0.1:${port}/api/contract`)).json()).manifest; break; }
catch { if (Date.now() > deadline) throw new Error("server never came up"); await new Promise((r) => setTimeout(r, 100)); }
}
assert.equal(m.surfaces, 2);
assert.equal(m.consumer.dir, consumer);
});
// ------------------------------------------- THE CLASS IS GONE
test("ZERO consumer state is written into the package — the package tree is byte-identical after serving", async (t) => {
// This is the claim the slice exists for, and it is stronger than
// "registration happens outside". A cache, copy or materialisation of
// consumer surfaces into the package would pass every registration test
// above and destroy the property.
const { dir, consumer, appDir } = scaffold();
const before = treeOf(appDir).map((f) => [f, fs.readFileSync(path.join(appDir, f), "utf8")]);
const srv = await start(dir, ["--surfaces", consumer]);
t.after(() => srv.stop());
await srv.contract();
await srv.text("/surfaces/mine.html"); // exercise the serving path
await srv.rows();
await new Promise((r) => setTimeout(r, 1500)); // let the integrity guard run
const after = treeOf(appDir).map((f) => [f, fs.readFileSync(path.join(appDir, f), "utf8")]);
assert.deepEqual(after.map(([f]) => f), before.map(([f]) => f),
"a file appeared in or vanished from the package tree");
assert.deepEqual(after, before, "package file CONTENT changed — consumer state leaked into the package");
// And specifically: no trace of the consumer surface in the places consumer
// state would actually materialise — the package manifest and the package's
// surfaces dir. Scoped deliberately: a blob search across the whole tree also
// matches this runtime's own source COMMENTS, which discuss consumer surfaces
// at length and were there before the server ever started. That would be a
// test failing on its own documentation rather than on a leak.
const packageManifest = fs.readFileSync(path.join(appDir, "surfaces.json"), "utf8");
assert.doesNotMatch(packageManifest, /"id":\s*"mine"/, "consumer ROW written into the package manifest");
assert.equal(JSON.parse(packageManifest).surfaces.length, 1, "the package manifest gained a row");
const surfacesDir = path.join(appDir, "surfaces");
for (const name of fs.readdirSync(surfacesDir)) {
const body = fs.readFileSync(path.join(surfacesDir, name), "utf8");
assert.doesNotMatch(body, /consumer surface/, `consumer PAGE content materialised into the package as ${name}`);
}
assert.ok(!fs.existsSync(path.join(surfacesDir, "mine.html")),
"the consumer page was copied into the package");
});
test("a PRE-CONTAMINATED package cannot inject — undeclared rows and pages are rejected and warned", async (t) => {
// The guarantee : with the seam active the runtime
// serves EXACTLY the SDK's own declared surfaces plus the consumer's
// overlay-declared ones, and nothing else. Undeclared rows and files in the
// package are ignored for serving AND warned, so contamination is visible
// rather than silently masked.
//
// This test has been wrong twice, in two different ways, and both are worth
// remembering:
// 1. it once wrote a phantom PAGE and claimed to write a page AND a row, so
// the assertion could not fail — a test that cannot fail is not a test;
// 2. it once pinned the WEAK policy positively ("contaminated rows do
// serve") as though that were the property. the prose
// still said so after the assertions had been corrected. A passing suite
// encoding a known-inadequate guarantee is worse than no test, because
// it makes the inadequacy look ratified.
// The prose and the assertions now say the same thing, deliberately.
const origin = scaffold();
// contaminate the package the way materialisation used to: page AND row, in
// the PACKAGE's own manifest
fs.writeFileSync(path.join(origin.appDir, "surfaces", "phantom.html"), "<html>PHANTOM</html>");
const pkgManifest = path.join(origin.appDir, "surfaces.json");
const pkgDoc = JSON.parse(fs.readFileSync(pkgManifest, "utf8"));
pkgDoc.surfaces.push({ id: "phantom", name: "PHANTOM", path: "/surfaces/phantom.html" });
fs.writeFileSync(pkgManifest, JSON.stringify(pkgDoc, null, 2));
// copy the contaminated package into a FRESH deployment with a DIFFERENT consumer
const dest = fs.mkdtempSync(path.join(os.tmpdir(), "seam-copy-"));
fs.cpSync(origin.appDir, path.join(dest, "app"), { recursive: true });
fs.cpSync(path.join(origin.dir, "fixtures"), path.join(dest, "fixtures"), { recursive: true });
const freshConsumer = path.join(dest, "consumer");
fs.mkdirSync(freshConsumer);
fs.writeFileSync(path.join(freshConsumer, "surfaces.json"),
JSON.stringify({ surfaces: [{ id: "theirs", name: "THEIRS", path: "/surfaces/theirs.html" }] }, null, 2));
fs.writeFileSync(path.join(freshConsumer, "theirs.html"), "<html>theirs</html>");
const srv = await start(dest, ["--surfaces", freshConsumer]);
t.after(() => srv.stop());
const ids = (await srv.rows()).map((r) => r.id);
// WHAT THE SEAM DELIVERS: the origin deployment's CONSUMER surface did not
// ride along, because it was never in the package to be copied.
assert.ok(!ids.includes("mine"),
"a consumer surface from the origin deployment rode along inside the copied package");
assert.ok(ids.includes("theirs"), "the fresh consumer's own surface must be served");
// THE RAISED GUARANTEE : a pre-contaminated package
// CANNOT inject. The earlier version of this test pinned the OPPOSITE as an
// accepted limitation — that limitation is unacceptable, because a seam
// that only prevents NEW contamination does not remove the class, which is
// what this slice is for.
assert.ok(!ids.includes("phantom"),
"a phantom row injected into the PACKAGE manifest was served — the runtime must serve exactly " +
"the SDK's own declared surfaces plus the consumer's overlay-declared ones, and nothing else");
// and the contamination must be VISIBLE rather than silently dropped, so an
// operator learns the package is dirty instead of just not seeing a surface
const m = await srv.contract();
assert.ok(m.warnings.some((w) => /phantom/.test(w)),
"undeclared package content must be WARNED, not silently ignored — masking it would hide the " +
"contamination instead of surfacing it");
// the phantom PAGE must not be reachable either
assert.equal(await srv.status("/surfaces/phantom.html"), 404,
"the phantom page is still served even though its row was rejected");
});
// ------------------------------------------------------------- npm ci
test("npm ci does not wipe the consumer registration, and WOULD wipe an in-package one", async (t) => {
// The discriminating pair. "Still there after npm ci" proves nothing unless
// the old path is shown to be destroyed by the same command in the same run.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "seam-npmci-"));
const packed = execSync(`npm pack ${JSON.stringify(REPO)} --pack-destination ${JSON.stringify(dir)}`,
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim().split("\n").pop();
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({
name: "seam-consumer", version: "1.0.0", private: true,
dependencies: { "@openrig/studio": `file:${path.join(dir, packed)}` },
}, null, 2));
execSync("npm install --silent", { cwd: dir, stdio: "ignore" });
const pkgApp = path.join(dir, "node_modules", "@openrig", "studio", "app");
const consumer = path.join(dir, "surfaces");
fs.mkdirSync(consumer);
fs.writeFileSync(path.join(consumer, "surfaces.json"), JSON.stringify({ surfaces: [CONSUMER_ROW] }, null, 2));
fs.writeFileSync(path.join(consumer, "mine.html"), CONSUMER_PAGE);
// the OLD path, for the negative control: a surface materialised INTO the package
fs.writeFileSync(path.join(pkgApp, "surfaces", "legacy.html"), "<html>legacy</html>");
assert.ok(fs.existsSync(path.join(pkgApp, "surfaces", "legacy.html")));
execSync("npm ci --silent", { cwd: dir, stdio: "ignore" });
// NEGATIVE CONTROL: the in-package surface is gone, so npm ci really did
// recreate node_modules and the survival below means something.
assert.ok(!fs.existsSync(path.join(pkgApp, "surfaces", "legacy.html")),
"npm ci did not recreate node_modules — the survival assertion would be vacuous");
// the consumer's registration is untouched
assert.ok(fs.existsSync(path.join(consumer, "surfaces.json")));
assert.ok(fs.existsSync(path.join(consumer, "mine.html")));
const srv = await start(dir === dir ? path.join(dir, "node_modules", "@openrig", "studio") : dir,
["--surfaces", consumer, "--fixtures", path.join(dir, "node_modules", "@openrig", "studio", "fixtures")]);
t.after(() => srv.stop());
const ids = (await srv.rows()).map((r) => r.id);
assert.ok(ids.includes("mine"), "the consumer surface did not survive npm ci");
});
// --------------------------------------------------- opt-in / non-breaking
test("FLOOR works with ZERO consumer config", async (t) => {
// Proven by running that path, not by reasoning that it was not touched.
const { dir } = scaffold({ withConsumer: false });
const srv = await start(dir);
t.after(() => srv.stop());
const m = await srv.contract();
assert.equal(m.ok, true);
assert.equal(m.surfaces, 1, "FLOOR alone");
assert.deepEqual((await srv.rows()).map((r) => r.id), ["floor"]);
assert.equal(m.consumer, null, "the consumer block must be null when no overlay is configured");
assert.equal(await srv.status("/surfaces/floor.html"), 200);
});
test("zero-config validation errors are UNLABELLED, exactly as before the seam", async (t) => {
// contract-meta lists changing an error contract as BREAKING, and a
// zero-config consumer never opted into anything. Labels are for people who
// configured a second manifest.
const { dir, appDir } = scaffold({ withConsumer: false });
fs.writeFileSync(path.join(appDir, "surfaces.json"), JSON.stringify({ surfaces: [{ name: "NO ID" }] }));
const srv = await start(dir);
t.after(() => srv.stop());
const m = await srv.until((x) => x.errors.length > 0);
assert.equal(m.errors[0], "surfaces[0]: id is required (non-empty string)");
});
test("with an overlay, errors ARE source-labelled so a merged rail stays legible", async (t) => {
const { dir, appDir, consumer } = scaffold();
fs.writeFileSync(path.join(appDir, "surfaces.json"), JSON.stringify({ surfaces: [{ name: "NO ID" }] }));
fs.writeFileSync(path.join(consumer, "surfaces.json"), JSON.stringify({ surfaces: [{ name: "ALSO NO ID" }] }));
const srv = await start(dir, ["--surfaces", consumer]);
t.after(() => srv.stop());
// With enforcement active an undeclared PACKAGE row is dropped as
// contamination BEFORE validation, so it surfaces as a labelled WARNING
// rather than a validation error — a malformed row cannot deep-equal a
// declared one, so it is undeclared by construction. The consumer's own
// malformed row still errors, labelled. Both are labelled, which is what this
// test is about: in a merged world a bare surfaces[i] identifies nothing.
const m = await srv.until((x) => x.errors.length >= 1 && x.warnings.length >= 1);
assert.ok(m.errors.some((e) => e.startsWith("consumer surfaces[0]")), JSON.stringify(m.errors));
assert.ok(m.warnings.some((w) => w.startsWith("package surfaces[0]")), JSON.stringify(m.warnings));
});
// ------------------------------------------------------------ precedence
test("a consumer row SHADOWS a package row of the same id — and says so", async (t) => {
const { dir, consumer } = scaffold();
fs.writeFileSync(path.join(consumer, "surfaces.json"), JSON.stringify({
surfaces: [{ id: "floor", name: "MY FLOOR", path: "/surfaces/myfloor.html" }],
}, null, 2));
fs.writeFileSync(path.join(consumer, "myfloor.html"), "<html>my floor</html>");
const srv = await start(dir, ["--surfaces", consumer]);
t.after(() => srv.stop());
const rows = await srv.rows();
assert.equal(rows.length, 1, "the shadowed package row must not also appear");
assert.equal(rows[0].name, "MY FLOOR", "the consumer row must win");
const m = await srv.contract();
assert.ok(m.warnings.some((w) => /SHADOWS the package surface/.test(w)),
"shadowing must be named — silently replacing a surface is a silent replacement gives an operator no way to learn something was displaced");
});
test("serving is REGISTRATION-aware: an unregistered consumer file never wins", async (t) => {
// Regression: The overlay was resolved path-first, which made it a
// public static root: any file under it answered /surfaces/<name> whether or
// not a row registered it. Three consequences, all reproduced and all
// asserted here.
const { dir, consumer } = scaffold();
fs.writeFileSync(path.join(consumer, "floor.html"), "CONSUMER-FLOOR-OVERRIDE"); // unregistered
fs.writeFileSync(path.join(consumer, "secret.txt"), "SECRET"); // unregistered
const srv = await start(dir, ["--surfaces", consumer]);
t.after(() => srv.stop());
// 1. an UNREGISTERED consumer file must not outrank a registered package page
const floor = await srv.text("/surfaces/floor.html");
assert.doesNotMatch(floor, /CONSUMER-FLOOR-OVERRIDE/,
"an unregistered consumer file silently overrode the registered package page");
// 2. an unregistered file in the overlay must not be published at all
assert.equal(await srv.status("/surfaces/secret.txt"), 404, "the overlay is acting as a public static root");
assert.doesNotMatch(await srv.text("/surfaces/secret.txt"), /SECRET/);
// the registered consumer surface still works
assert.equal(await srv.text("/surfaces/mine.html"), CONSUMER_PAGE);
});
test("a registered consumer row with a MISSING page fails as itself, with no cross-owner fallback", async (t) => {
// The third of QA's reproductions. Quietly serving the package's bytes under
// the consumer's row would make row ownership and page ownership disagree —
// a lie about what is being served, and precisely what the id-based, warned
// shadow semantics exist to prevent.
const { dir, consumer } = scaffold();
fs.writeFileSync(path.join(consumer, "surfaces.json"), JSON.stringify({
surfaces: [{ id: "floor", name: "MY FLOOR", path: "/surfaces/floor.html" }], // page NOT created
}, null, 2));
const srv = await start(dir, ["--surfaces", consumer]);
t.after(() => srv.stop());
assert.equal(await srv.status("/surfaces/floor.html"), 404,
"the package's FLOOR page was substituted under the consumer's row");
const body = await srv.text("/surfaces/floor.html");
assert.match(body, /registered for this path but its page is missing/);
assert.doesNotMatch(body, /<!doctype html>/i, "package page bytes were served");
});
test("the overlay serving root is contained — traversal is refused", async (t) => {
const { dir, consumer } = scaffold();
fs.writeFileSync(path.join(path.dirname(consumer), "secret.txt"), "SECRET");
const srv = await start(dir, ["--surfaces", consumer]);
t.after(() => srv.stop());
for (const attempt of ["/surfaces/../secret.txt", "/surfaces/..%2fsecret.txt", "/surfaces/../../etc/hosts"]) {
const body = await srv.text(attempt);
assert.doesNotMatch(body, /SECRET/, `traversal escaped the overlay root: ${attempt}`);
}
});
// ------------------------------------------- redeploy survival, for the CONSUMER
test("the CONSUMER manifest survives delete -> recreate with no restart", async (t) => {
// The redeploy-survival guarantees have to hold for the consumer manifest too.
// Consumer surfaces are what a real deployment actually redeploys, so this is
// where the class does the most damage if it returns.
const { dir, consumer } = scaffold();
const srv = await start(dir, ["--surfaces", consumer]);
t.after(() => srv.stop());
const manifest = path.join(consumer, "surfaces.json");
const saved = fs.readFileSync(manifest, "utf8");
assert.equal((await srv.contract()).surfaces, 2);
fs.unlinkSync(manifest);
let m = await srv.until((x) => x.consumer.state === "unreadable");
assert.equal(m.consumer.state, "unreadable");
assert.equal(m.surfaces, 1, "the rail falls back to FLOOR alone");
fs.writeFileSync(manifest, saved);
m = await srv.until((x) => x.consumer.state === "ok");
assert.equal(m.surfaces, 2, "the consumer manifest did not recover");
assert.equal(m.consumer.recoveries, 1, "recovery must be reported positively for the consumer source");
assert.match(srv.log(), /manifest RECOVERED:/);
});
test("a CONSUMER directory swap is detected and served fresh", async (t) => {
const { dir, consumer } = scaffold();
const srv = await start(dir, ["--surfaces", consumer]);
t.after(() => srv.stop());
assert.deepEqual((await srv.rows()).map((r) => r.id), ["floor", "mine"]);
const incoming = path.join(dir, "consumer-next");
fs.mkdirSync(incoming);
fs.writeFileSync(path.join(incoming, "surfaces.json"), JSON.stringify({
surfaces: [{ id: "swapped", name: "SWAPPED", path: "/surfaces/swapped.html" }],
}, null, 2));
fs.writeFileSync(path.join(incoming, "swapped.html"), "<html>swapped</html>");
fs.renameSync(consumer, path.join(dir, "consumer-old"));
fs.renameSync(incoming, consumer);
const deadline = Date.now() + 8000;
let ids;
do {
ids = (await srv.rows()).map((r) => r.id);
if (ids.includes("swapped")) break;
await new Promise((r) => setTimeout(r, 100));
} while (Date.now() < deadline);
assert.deepEqual(ids, ["floor", "swapped"], "served stale consumer content after a directory swap");
const m = await srv.contract();
assert.ok(m.consumer.integrityReloads >= 1,
"a consumer directory swap must be reported as an integrity reload, not corrected silently");
assert.match(srv.log(), /manifest REPLACED:/);
});
test("an idle overlay does not churn", async (t) => {
const { dir, consumer } = scaffold();
const srv = await start(dir, ["--surfaces", consumer]);
t.after(() => srv.stop());
const before = (await srv.contract()).consumer.reloads;
await new Promise((r) => setTimeout(r, 3500));
const after = (await srv.contract()).consumer;
assert.equal(after.reloads, before, "the integrity guard reloaded an unchanged consumer manifest");
assert.equal(after.integrityReloads, 0);
});
test("positive control: this suite is capable of failing", () => {
assert.throws(() => assert.equal(1, 2));
});
// -------------------------------------------------- the authority root
test("the SDK's declared ROWS deep-equal what the package actually ships", () => {
// DRIFT GUARD. the earlier id-only version was worthless: it
// passed while FLOOR's name, glyph and path were all changed, because only
// the id was compared. An authority that checks the id is bypassed by reusing
// the id, so the guard has to check what the authority checks — the whole row.
const src = fs.readFileSync(path.join(REPO, "app", "serve-studio.mjs"), "utf8");
const block = src.match(/const SDK_DECLARED_SURFACES = \[([\s\S]*?)\n\];/)[1];
const declared = JSON.parse("[" + block.replace(/(\w+):/g, '"$1":').replace(/,\s*$/, "") + "]");
const shipped = JSON.parse(fs.readFileSync(path.join(REPO, "app", "surfaces.json"), "utf8")).surfaces;
const canon = (rows) => rows
.map((r) => JSON.stringify(Object.keys(r).sort().map((k) => [k, r[k]])))
.sort();
assert.deepEqual(canon(declared), canon(shipped),
"the SDK's in-code row declaration and its shipped manifest disagree. Declare SDK surfaces " +
"EXACTLY as shipped — an id-level match is not enough, because the authority compares whole rows");
});
test("reusing a DECLARED id with different content does not make a row authoritative", async (t) => {
// With an id-only authority, an injected row reusing the
// declared id served attacker content under a trusted name.
const { dir, consumer, appDir } = scaffold();
const doc = JSON.parse(fs.readFileSync(path.join(appDir, "surfaces.json"), "utf8"));
doc.surfaces.unshift({ id: "floor", name: "HIJACKED FLOOR", glyph: "X", path: "/surfaces/hijack.html" });
fs.writeFileSync(path.join(appDir, "surfaces.json"), JSON.stringify(doc, null, 2));
fs.writeFileSync(path.join(appDir, "surfaces", "hijack.html"), "HIJACK-PAGE");
const srv = await start(dir, ["--surfaces", consumer]);
t.after(() => srv.stop());
// BOTH halves, because asserting only rejection is what let the next defect
// through: a PREPENDED forged row got the genuine FLOOR
// rejected as its duplicate before the authority filter ran, so the forged
// bytes were refused AND the authoritative surface vanished. The guarantee is
// that the served set IS the declared set — a declared surface must SURVIVE
// contamination, not merely fail to be replaced by it.
assert.equal(await srv.status("/surfaces/hijack.html"), 404, "an injected row reusing a declared id was served");
const rows = await srv.rows();
assert.ok(!rows.some((r) => r.name === "HIJACKED FLOOR"), "the hijacked row reached the rail");
assert.ok(rows.some((r) => r.id === "floor" && r.name === "FLOOR"),
"the GENUINE FLOOR row was suppressed by the forged one — contamination must not remove a declared surface");
const floor = await srv.text("/surfaces/floor.html");
assert.match(floor, /<!doctype html>/i, "the genuine FLOOR page must still be served");
const m = await srv.contract();
assert.ok(m.warnings.some((w) => /NOT declared by this SDK/.test(w)), "the injected row was not warned");
assert.ok(!m.warnings.some((w) => /floor\.html: present but NOT declared/.test(w)),
"the genuine FLOOR file was misclassified as undeclared");
});
test("a consumer row may not steal a package-owned PATH with a different id", async (t) => {
// Replacement is an ID act that warns; allowing a
// different-id row to claim a package path made it a filename act that was
// silent, and made both rail rows load consumer bytes.
const { dir, consumer } = scaffold();
fs.writeFileSync(path.join(consumer, "surfaces.json"), JSON.stringify({
surfaces: [{ id: "other", name: "OTHER", path: "/surfaces/floor.html" }],
}, null, 2));
fs.writeFileSync(path.join(consumer, "floor.html"), "CONSUMER-BYTES");
const srv = await start(dir, ["--surfaces", consumer]);
t.after(() => srv.stop());
const body = await srv.text("/surfaces/floor.html");
assert.doesNotMatch(body, /CONSUMER-BYTES/, "a different-id consumer row hijacked the package path");
assert.match(body, /<!doctype html>/i, "the package FLOOR page must still be served");
const m = await srv.contract();
assert.ok(m.errors.some((e) => /already owned by the package surface/.test(e)),
"the colliding consumer row must be EXCLUDED with a named error");
assert.ok(!m.warnings.some((w) => /floor\.html: present but NOT declared/.test(w)),
"the genuine package FLOOR file must not be falsely reported as undeclared");
});
test("with NO overlay, undeclared package content is served exactly as before", async (t) => {
// The enforcement is opt-in with the seam. Applying it unconditionally would
// change zero-config serving semantics — a vendored SDK that legitimately
// added a row to its own manifest would stop serving it — and contract-meta
// calls changing URL-resolution semantics breaking.
const { dir, appDir } = scaffold({ withConsumer: false });
fs.writeFileSync(path.join(appDir, "surfaces", "extra.html"), "<html>extra</html>");
const doc = JSON.parse(fs.readFileSync(path.join(appDir, "surfaces.json"), "utf8"));
doc.surfaces.push({ id: "extra", name: "EXTRA", path: "/surfaces/extra.html" });
fs.writeFileSync(path.join(appDir, "surfaces.json"), JSON.stringify(doc, null, 2));
const srv = await start(dir);
t.after(() => srv.stop());
const m = await srv.contract();
assert.equal(m.surfaces, 2, "zero-config must still serve whatever the package manifest declares");
assert.deepEqual((await srv.rows()).map((r) => r.id), ["floor", "extra"]);
assert.equal(await srv.status("/surfaces/extra.html"), 200);
assert.equal(m.warnings.length, 0, "no contamination warnings without an overlay");
});
+506
View File
@@ -0,0 +1,506 @@
// Regression suite for redeploy-survival: the manifest watcher must survive the
// manifest being DELETED AND RECREATED, and the recovery must be OBSERVABLE.
//
// node --test 'test/*.test.mjs'
//
// The defect: surfaces.json was watched BY PATH, so the watch bound to that
// file's inode. Anything that REPLACES rather than edits the file — npm ci,
// write-tmp+rename, a redeploy over an existing install, a save-by-rename
// editor — produced a new inode, and the old watch never fired again. The
// runtime sat on ENOENT indefinitely, serving an empty rail with HTTP 200,
// until someone restarted the process.
//
// Why the assertions look the way they do: warn-first output (empty rail, named
// error, 200) is honest AND is exactly what the stale-watcher bug looked like.
// So "no error" proves nothing here. Every recovery assertion checks a POSITIVE
// signal — the recoveries counter, lastRecoveryAt, and the log line — and every
// one is paired with a negative control that must NOT report a recovery. A
// counter that only ever increments would pass a one-sided test.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const REPO = path.resolve(HERE, "..");
const SERVER = path.join("app", "serve-studio.mjs");
const ONE = [{ id: "floor", name: "FLOOR", glyph: "●", path: "/surfaces/floor.html", hint: "one" }];
const TWO = [...ONE, { id: "two", name: "TWO", glyph: "◆", path: "/surfaces/two.html", hint: "two" }];
const freePort = () => new Promise((resolve, reject) => {
const s = net.createServer();
s.once("error", reject);
s.listen(0, "127.0.0.1", () => { const { port } = s.address(); s.close(() => resolve(port)); });
});
// A throwaway copy of the runtime — never the repo's own app/, never a live
// instance. serve-studio derives its paths from its own location, so a copy is
// fully isolated.
function instance(surfaces = ONE) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "redeploy-test-"));
fs.cpSync(path.join(REPO, "app"), path.join(dir, "app"), { recursive: true });
fs.cpSync(path.join(REPO, "fixtures"), path.join(dir, "fixtures"), { recursive: true });
const manifestPath = path.join(dir, "app", "surfaces.json");
const doc = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
doc.surfaces = surfaces;
fs.writeFileSync(manifestPath, JSON.stringify(doc, null, 2));
return { dir, manifestPath, doc };
}
function writeManifest(manifestPath, surfaces) {
const doc = JSON.parse(fs.readFileSync(manifestPath, "utf8").trim() || "{}");
doc.surfaces = surfaces;
fs.writeFileSync(manifestPath, JSON.stringify(doc, null, 2));
}
async function start(dir) {
const port = await freePort();
const proc = spawn(process.execPath, [path.join(dir, SERVER), "--port", String(port)],
{ cwd: dir, stdio: ["ignore", "pipe", "pipe"] });
let log = "";
proc.stdout.on("data", (d) => { log += d; });
proc.stderr.on("data", (d) => { log += d; });
const contract = async () => {
const r = await fetch(`http://127.0.0.1:${port}/api/contract`, { cache: "no-store" });
return (await r.json()).manifest;
};
const deadline = Date.now() + 10_000;
for (;;) {
try { await contract(); break; } catch {
if (Date.now() > deadline) { proc.kill(); throw new Error(`server never came up:\n${log}`); }
await new Promise((r) => setTimeout(r, 100));
}
}
// Bounded wait on CONTENT, not a fixed sleep — a sleep long enough to be
// reliable is long enough to hide a slow regression.
const until = async (predicate, ms = 8000) => {
const end = Date.now() + ms;
let last;
for (;;) {
last = await contract();
if (predicate(last)) return last;
if (Date.now() > end) return last;
await new Promise((r) => setTimeout(r, 100));
}
};
return {
port, contract, until,
log: () => log,
text: async (p) => (await fetch(`http://127.0.0.1:${port}${p}`, { cache: "no-store" })).text(),
status: async (p) => (await fetch(`http://127.0.0.1:${port}${p}`, { cache: "no-store" })).status,
stop: () => proc.kill(),
};
}
// ----------------------------------------------------- the A-G sequence
test("A-G: manifest survives delete -> recreate with no process restart", async (t) => {
const { dir, manifestPath } = instance(ONE);
const srv = await start(dir);
t.after(() => srv.stop());
// A — baseline. NEGATIVE CONTROL: booting healthy is not "recovering".
let m = await srv.contract();
assert.equal(m.ok, true);
assert.equal(m.surfaces, 1);
assert.equal(m.state, "ok");
assert.equal(m.recoveries, 0, "a process that started healthy must report 0 recoveries");
assert.equal(m.lastRecoveryAt, null);
// B — modify in place. Still not a recovery: it never broke.
writeManifest(manifestPath, TWO);
m = await srv.until((x) => x.surfaces === 2);
assert.equal(m.surfaces, 2, "in-place edit was not picked up");
assert.equal(m.recoveries, 0, "an ordinary edit must not be reported as a recovery");
// C — delete. Warn-first: named error, empty rail, still serving.
const saved = fs.readFileSync(manifestPath, "utf8");
fs.unlinkSync(manifestPath);
m = await srv.until((x) => x.state === "unreadable");
assert.equal(m.state, "unreadable");
assert.equal(m.surfaces, 0);
assert.match(m.errors[0], /cannot read surfaces\.json/);
assert.equal(await srv.status("/"), 200, "runtime must keep serving through a missing manifest");
// D — recreate at the same path. THIS is the defect: pre-fix it stayed
// ENOENT forever because the watch was bound to the deleted inode.
fs.writeFileSync(manifestPath, saved);
m = await srv.until((x) => x.state === "ok");
assert.equal(m.state, "ok", "did not recover after delete -> recreate");
assert.equal(m.surfaces, 2);
// The recovery must be ASSERTED, not inferred from the absence of an error.
assert.equal(m.recoveries, 1, "recovery must be reported positively");
assert.ok(m.lastRecoveryAt, "lastRecoveryAt must be set on recovery");
assert.match(srv.log(), /manifest RECOVERED:/, "recovery must emit a log line");
// E — settle. Recovery is stable, not a flap.
await new Promise((r) => setTimeout(r, 1500));
m = await srv.contract();
assert.equal(m.state, "ok");
assert.equal(m.surfaces, 2);
assert.equal(m.recoveries, 1, "a stable manifest must not keep incrementing recoveries");
// F — edit the RECREATED file. Proves the watch is genuinely live again
// rather than having fired once.
writeManifest(manifestPath, [...TWO.slice(0, 1), { ...TWO[1], name: "TWO-EDITED" }]);
// Wait on the SERVED CONTENT, not on a count that is already satisfied — the
// surface count is 2 both before and after this edit, so polling it would
// return instantly and assert against a stale read.
const deadline = Date.now() + 8000;
let served;
do {
served = JSON.parse(await srv.text("/surfaces.json"));
if (served.surfaces[1]?.name === "TWO-EDITED") break;
await new Promise((r) => setTimeout(r, 100));
} while (Date.now() < deadline);
assert.equal(served.surfaces[1].name, "TWO-EDITED", "watch is not live after recreate");
});
test("G control: a fresh process reads the same file fine", async (t) => {
// The control that makes the whole sequence trustworthy — it distinguishes
// "the watcher was stale" from "the data on disk was broken". Kept after the
// fix for exactly the same reason.
const { dir, manifestPath } = instance(ONE);
writeManifest(manifestPath, TWO);
const srv = await start(dir);
t.after(() => srv.stop());
const m = await srv.contract();
assert.equal(m.ok, true);
assert.equal(m.surfaces, 2);
assert.equal(m.recoveries, 0);
});
// ------------------------------------------------- replacement mechanisms
test("atomic write-tmp+rename is picked up", async (t) => {
const { dir, manifestPath } = instance(ONE);
const srv = await start(dir);
t.after(() => srv.stop());
const doc = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
doc.surfaces = TWO;
const tmp = manifestPath + ".tmp";
fs.writeFileSync(tmp, JSON.stringify(doc, null, 2));
fs.renameSync(tmp, manifestPath); // atomic replace: new inode, same path
const m = await srv.until((x) => x.surfaces === 2);
assert.equal(m.surfaces, 2, "tmp+rename replace was not picked up");
assert.equal(m.state, "ok");
});
test("recovers when the manifest's parent directory is itself replaced", async (t) => {
// The npm-ci shape: the whole install tree goes away, so the manifest AND its
// parent directory are gone and there is briefly nothing to watch. Recovery
// here depends on the bounded probe, not on a filesystem event.
const { dir, manifestPath } = instance(ONE);
const srv = await start(dir);
t.after(() => srv.stop());
const appDir = path.dirname(manifestPath);
const stash = path.join(dir, "app-stash");
fs.renameSync(appDir, stash);
const m1 = await srv.until((x) => x.state === "unreadable");
assert.equal(m1.state, "unreadable");
fs.renameSync(stash, appDir); // tree comes back, new directory inode
const m2 = await srv.until((x) => x.state === "ok", 10_000);
assert.equal(m2.state, "ok", "did not recover after the parent directory was replaced");
assert.equal(m2.recoveries, 1);
assert.match(srv.log(), /manifest RECOVERED:/);
});
test("a directory SWAP is picked up, not served stale", async (t) => {
// The nastiest shape, and the one no arrangement of watches can catch:
// `mv current current.old && mv new current`. The watch keeps following the
// OLD directory inode, which still exists under its new name, so no event
// ever fires and no error is raised — the runtime would serve the PREVIOUS
// manifest indefinitely while reporting state "ok". That is worse than the
// ENOENT this slice started on, because it looks healthy.
const { dir, manifestPath } = instance([{ id: "old", name: "OLD", glyph: "●", path: "/surfaces/floor.html" }]);
const appDir = path.dirname(manifestPath);
// a complete replacement tree, as a deploy would stage
const incoming = path.join(dir, "app-incoming");
fs.cpSync(appDir, incoming, { recursive: true });
writeManifest(path.join(incoming, "surfaces.json"),
[{ id: "new", name: "NEW", glyph: "▲", path: "/surfaces/floor.html" },
{ id: "new2", name: "NEW2", glyph: "◆", path: "/surfaces/floor.html" }]);
const srv = await start(dir);
t.after(() => srv.stop());
assert.deepEqual(JSON.parse(await srv.text("/surfaces.json")).surfaces.map((r) => r.id), ["old"]);
fs.renameSync(appDir, path.join(dir, "app-old"));
fs.renameSync(incoming, appDir);
const deadline = Date.now() + 8000;
let ids;
do {
ids = JSON.parse(await srv.text("/surfaces.json")).surfaces.map((r) => r.id);
if (ids.join() === "new,new2") break;
await new Promise((r) => setTimeout(r, 100));
} while (Date.now() < deadline);
assert.deepEqual(ids, ["new", "new2"], "served stale content after a directory swap");
const m = await srv.contract();
assert.equal(m.state, "ok");
// Being corrected is not the same as being observable. A clean swap goes
// ok -> ok, so no RECOVERY is reported — and if that were the only signal,
// nothing would distinguish "the watch saw it" from "the watch missed it and
// the stat floor caught it". The stale-but-healthy-looking lie has to be
// observably DETECTED, not merely fixed.
assert.equal(m.recoveries, 0, "a clean swap never broke, so it is not a recovery");
assert.ok(m.integrityReloads >= 1,
"a directory swap must be reported as an integrity reload — the watch cannot see it");
assert.ok(m.lastIntegrityReloadAt, "lastIntegrityReloadAt must be set when the guard fires");
assert.match(srv.log(), /manifest REPLACED:/,
"the integrity guard must emit a positive log line, not correct silently");
});
test("an idle runtime does not churn", async (t) => {
// NEGATIVE CONTROL for the integrity guard: it stats every second, and it
// must NOT reload a file that has not changed. Without this, "it reloads
// eventually" could be passing for the wrong reason.
const { dir } = instance(ONE);
const srv = await start(dir);
t.after(() => srv.stop());
const before = (await srv.contract()).reloads;
await new Promise((r) => setTimeout(r, 3500));
const after = await srv.contract();
assert.equal(after.reloads, before, "the integrity guard reloaded an unchanged manifest");
assert.equal(after.recoveries, 0);
assert.equal(after.state, "ok");
});
// ------------------------------------------------------ startup robustness
// The gap: every test above starts from a HEALTHY manifest, so an
// entire class of failure — the runtime dying during boot — was invisible to
// the suite. A redeploy is exactly when the manifest is most likely to be
// missing as the process starts, so booting into that state has to work.
test("starts and warn-firsts when the manifest is ABSENT at boot", async (t) => {
const { dir, manifestPath } = instance(ONE);
fs.unlinkSync(manifestPath);
const srv = await start(dir); // start() throws if the process never serves
t.after(() => srv.stop());
const m = await srv.contract();
assert.equal(m.state, "unreadable");
assert.match(m.errors[0], /cannot read surfaces\.json/);
assert.equal(await srv.status("/"), 200, "the shell must be available with no manifest");
// and it must still recover once the file appears — a cold start into the
// broken state must not disable the recovery path
writeManifest2(manifestPath, TWO);
const after = await srv.until((x) => x.state === "ok");
assert.equal(after.state, "ok");
assert.equal(after.surfaces, 2);
assert.equal(after.recoveries, 1, "recovering from a boot-time failure is still a recovery");
});
test("starts and warn-firsts when the manifest is INVALID at boot", async (t) => {
const { dir, manifestPath } = instance(ONE);
fs.writeFileSync(manifestPath, "{ not json at all");
const srv = await start(dir);
t.after(() => srv.stop());
const m = await srv.contract();
assert.equal(m.state, "invalid");
assert.match(m.errors[0], /not valid JSON/);
assert.equal(await srv.status("/"), 200);
assert.equal(m.recoveries, 0, "booting broken is not a recovery");
});
test("starts when the manifest's whole directory is missing at boot", async (t) => {
// The most hostile boot: mid-redeploy, the tree is not there yet.
const { dir, manifestPath } = instance(ONE);
fs.rmSync(path.dirname(manifestPath), { recursive: true, force: true });
fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
fs.cpSync(path.join(REPO, "app", "serve-studio.mjs"), path.join(dir, SERVER));
const srv = await start(dir);
t.after(() => srv.stop());
const m = await srv.contract();
assert.equal(m.state, "unreadable");
assert.equal(await srv.status("/api/contract"), 200);
});
// helper used by the boot tests: the manifest may not exist yet, so this one
// does not try to read the previous document first
function writeManifest2(manifestPath, surfaces) {
fs.writeFileSync(manifestPath, JSON.stringify({ surfaces }, null, 2));
}
// ------------------------------------------------ warn-first non-regression
test("a genuinely invalid manifest still warn-firsts and is NOT reported as recovered", async (t) => {
// The discriminator that matters most: a fix which made everything look
// recovered would be worse than the bug it replaced.
const { dir, manifestPath } = instance(ONE);
const srv = await start(dir);
t.after(() => srv.stop());
fs.writeFileSync(manifestPath, JSON.stringify({ surfaces: [{ name: "NO ID" }] }, null, 2));
const m = await srv.until((x) => x.state === "invalid");
assert.equal(m.state, "invalid");
assert.equal(m.ok, false);
assert.equal(m.surfaces, 0, "an invalid row must be excluded from the served rail");
assert.match(m.errors[0], /id is required/, "the error must be NAMED, not generic");
assert.equal(m.recoveries, 0, "an invalid manifest must never be reported as a recovery");
assert.equal(await srv.status("/"), 200, "the shell must stay available");
});
test("malformed JSON is named and the runtime keeps serving", async (t) => {
const { dir, manifestPath } = instance(ONE);
const srv = await start(dir);
t.after(() => srv.stop());
fs.writeFileSync(manifestPath, "{ not json");
const m = await srv.until((x) => x.state === "invalid");
assert.match(m.errors[0], /not valid JSON/);
assert.equal(await srv.status("/"), 200);
});
test("unknown fields still warn rather than error (forward-compat preserved)", async (t) => {
const { dir, manifestPath } = instance(ONE);
const srv = await start(dir);
t.after(() => srv.stop());
writeManifest(manifestPath, [{ ...ONE[0], somethingNew: "from a later version" }]);
const m = await srv.until((x) => x.warnings.length > 0);
assert.equal(m.ok, true, "an unknown field must not be an error");
assert.equal(m.surfaces, 1, "the row must still be served");
assert.match(m.warnings[0], /unknown field/);
});
// ------------------------------------------------------- contract v0.1
test("contract v0.1 is unchanged: same version, same existing fields, additive only", async (t) => {
const { dir } = instance(ONE);
const srv = await start(dir);
t.after(() => srv.stop());
const r = await fetch(`http://127.0.0.1:${srv.port}/api/contract`, { cache: "no-store" });
const body = await r.json();
assert.equal(body.contractVersion, "0.1", "adding fields must not move the contract version");
assert.deepEqual(body.capabilities,
["contract.meta", "observe.factory-state", "stream.events", "files.read", "shell.protocol"]);
for (const key of ["ok", "errors", "warnings", "surfaces"]) {
assert.ok(key in body.manifest, `pre-existing manifest field ${key} disappeared`);
}
for (const key of ["state", "lastLoadedAt", "reloads", "recoveries", "lastRecoveryAt"]) {
assert.ok(key in body.manifest, `additive observability field ${key} missing`);
}
});
test("the SSE change-signal still fires on a manifest change", async (t) => {
const { dir, manifestPath } = instance(ONE);
const srv = await start(dir);
t.after(() => srv.stop());
const controller = new AbortController();
t.after(() => controller.abort());
const res = await fetch(`http://127.0.0.1:${srv.port}/api/events`, { signal: controller.signal });
const reader = res.body.getReader();
const got = (async () => {
// The deadline has to be ENFORCED, not merely stated. Awaiting read()
// bare never returns if the event never arrives, so the loop could not
// re-check its own deadline — the test would hang until the runner killed
// it instead of failing with the reason it asserts. A stated timeout that
// cannot fire is a check whose failure does not look like its failure.
//
const deadline = Date.now() + 8000;
let seen = "";
while (Date.now() < deadline) {
// The losing timer must be CLEARED. An uncleared referenced timer keeps
// the event loop alive for its full duration after the race resolves —
// the suite still passes, it just silently gets slower, which is a cost
// nobody attributes to the test that caused it.
let timer;
const timeout = new Promise((r) => { timer = setTimeout(() => r("__timeout__"), Math.max(0, deadline - Date.now())); });
const next = await Promise.race([reader.read(), timeout]);
clearTimeout(timer);
if (next === "__timeout__") return false;
if (next.done) break;
seen += new TextDecoder().decode(next.value);
if (seen.includes("data:")) return true;
}
return false;
})();
await new Promise((r) => setTimeout(r, 300));
writeManifest(manifestPath, TWO);
assert.equal(await got, true, "no SSE change-signal after a manifest change");
});
test("the documented /api/contract example lists exactly the fields the runtime serves", async (t) => {
// Drift-proofing. Shipping a field while the canonical example still shows
// its absence manufactures a lie in the document a cold agent reads FIRST —
// and that is a defect nobody notices, because docs do not fail loudly. This
// asserts the example against the live response so the two cannot separate
// silently: add a field without documenting it and this test fails.
const { dir } = instance(ONE);
const srv = await start(dir);
t.after(() => srv.stop());
const doc = fs.readFileSync(path.join(REPO, "contract", "contract-meta.md"), "utf8");
const block = doc.match(/`GET \/api\/contract` returns:\n\n```json\n([\s\S]*?)\n```/);
assert.ok(block, "contract-meta.md no longer contains a /api/contract example");
const documented = JSON.parse(block[1]);
const live = await srv.contract();
assert.deepEqual(
Object.keys(documented.manifest).sort(),
Object.keys(live).sort(),
"the documented manifest example and the served manifest have diverged"
);
});
test("startup runs after EVERY module binding, so the TDZ class cannot return", () => {
// This asserts the STRUCTURE the source comment claims, rather than trusting
// the comment. The earlier arrangement put init partway down the module and
// was "safe" only because the current call graph happened not to reach the
// later bindings — a property that silently expires the moment loadManifest
// or watchForChanges grows a new dependency. Two TDZ crashes came out of that
// file before this was made structural, the second after the first was
// "fixed" by hoisting the single binding that tripped.
const src = fs.readFileSync(path.join(REPO, "app", "serve-studio.mjs"), "utf8").split("\n");
const declarations = src
.map((line, i) => (/^(let|const)\s/.test(line) ? i + 1 : null))
.filter(Boolean);
const startup = src
.map((line, i) => (/^(watchForChanges|loadManifest)\(\);\s*$/.test(line) ? i + 1 : null))
.filter(Boolean);
assert.ok(startup.length >= 2, "expected the startup calls at module level");
assert.ok(
Math.min(...startup) > Math.max(...declarations),
`startup runs at line ${Math.min(...startup)} but a module binding is declared at line ` +
`${Math.max(...declarations)} — startup must follow EVERY declaration, not merely the ones ` +
`it currently happens to touch`
);
});
test("positive control: this suite is capable of failing", () => {
assert.throws(() => assert.equal(1, 2));
});