@suluk/cockpit 0.1.12 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,36 +1,39 @@
1
1
  {
2
2
  "name": "@suluk/cockpit",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "The pure cockpit core (cycle model · builder model · codegen · deploy planning · validate/audit/preview) shared by the vscode extension and the /superadmin web admin panel. CANDIDATE tooling.",
5
- "license": "Apache-2.0",
6
- "repository": {
7
- "type": "git",
8
- "url": "git+https://github.com/MahmoodKhalil57/suluk.git",
9
- "directory": "tooling/ts/packages/cockpit"
10
- },
11
- "homepage": "https://github.com/MahmoodKhalil57/suluk#readme",
12
- "bugs": "https://github.com/MahmoodKhalil57/suluk/issues",
13
- "type": "module",
14
- "main": "src/index.ts",
15
- "exports": {
16
- ".": "./src/index.ts"
17
- },
18
- "dependencies": {
19
- "@suluk/core": "0.1.6",
20
- "@suluk/hono": "0.1.1",
21
- "@suluk/scalar": "0.1.1",
22
- "@suluk/swagger": "0.1.1",
23
- "@suluk/shadcn": "0.1.1",
24
- "@suluk/builder": "0.1.9",
25
- "@suluk/deploy": "0.1.1",
26
- "@suluk/cost": "0.1.1",
27
- "@suluk/visual": "0.1.1"
28
- },
29
- "devDependencies": {
30
- "@types/bun": "latest"
31
- },
32
- "scripts": {
33
- "test": "bun test",
34
- "typecheck": "tsc --noEmit -p ."
35
- }
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "license": "Apache-2.0",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/MahmoodKhalil57/suluk.git",
12
+ "directory": "tooling/ts/packages/cockpit"
13
+ },
14
+ "homepage": "https://github.com/MahmoodKhalil57/suluk#readme",
15
+ "bugs": "https://github.com/MahmoodKhalil57/suluk/issues",
16
+ "type": "module",
17
+ "main": "src/index.ts",
18
+ "exports": {
19
+ ".": "./src/index.ts"
20
+ },
21
+ "dependencies": {
22
+ "@suluk/core": "0.1.6",
23
+ "@suluk/hono": "0.1.1",
24
+ "@suluk/scalar": "0.1.1",
25
+ "@suluk/swagger": "0.1.1",
26
+ "@suluk/shadcn": "0.1.1",
27
+ "@suluk/builder": "0.1.9",
28
+ "@suluk/deploy": "0.1.1",
29
+ "@suluk/cost": "0.1.1",
30
+ "@suluk/visual": "0.1.1"
31
+ },
32
+ "devDependencies": {
33
+ "@types/bun": "latest"
34
+ },
35
+ "scripts": {
36
+ "test": "bun test",
37
+ "typecheck": "tsc --noEmit -p ."
38
+ }
36
39
  }
package/src/converge.ts CHANGED
@@ -8,9 +8,9 @@
8
8
  */
9
9
  import { buildAda } from "@suluk/core";
10
10
  import type { OpenAPIv4Document, Request, SchemaOrRef, SecurityRequirement } from "@suluk/core";
11
- import { schemaRefName } from "@suluk/builder";
11
+ import { schemaRefName, PREVIEW_ONLY_MARKER } from "@suluk/builder";
12
12
 
13
- export type ConvergeCode = "dangling-ref" | "undeclared-scheme" | "orphan-scope" | "empty-path" | "unreferenced-entity";
13
+ export type ConvergeCode = "dangling-ref" | "undeclared-scheme" | "orphan-scope" | "empty-path" | "unreferenced-entity" | "preview-op-exposed";
14
14
 
15
15
  export interface ConvergeFinding {
16
16
  code: ConvergeCode;
@@ -92,5 +92,21 @@ export function convergeContract(doc: OpenAPIv4Document): ConvergeReport {
92
92
  // unreferenced entities — a schema nothing $refs (dead weight; info, not an error)
93
93
  for (const name of Object.keys(schemas)) if (!referenced.has(name)) findings.push({ code: "unreferenced-entity", severity: "info", message: `entity "${name}" is referenced by no $ref`, where: name });
94
94
 
95
+ // preview-only operations — a `x-suluk-preview-only` op (the /preview/login backdoor) must NEVER silently sit
96
+ // in a contract that gets deployed to prod. WARN (not error) so its presence is always surfaced: it is only
97
+ // safe behind the deploy-time SULUK_PREVIEW gate; confirm this projection is a preview, not production. Walk
98
+ // BOTH path operations AND webhooks — a marker hidden on a webhook is exactly the same backdoor surface.
99
+ const previewMarked = (req: unknown): boolean => (req as Record<string, unknown> | null | undefined)?.[PREVIEW_ONLY_MARKER] === true;
100
+ for (const o of buildAda(doc).operations) {
101
+ if (previewMarked(o.request as unknown)) {
102
+ findings.push({ code: "preview-op-exposed", severity: "warn", message: `operation "${o.name}" is preview-only (a role-login backdoor) — it must reach prod ONLY behind the SULUK_PREVIEW gate; confirm this is a preview deployment, not production`, where: o.name });
103
+ }
104
+ }
105
+ for (const [name, req] of Object.entries((doc as { webhooks?: Record<string, Request> }).webhooks ?? {})) {
106
+ if (previewMarked(req as unknown)) {
107
+ findings.push({ code: "preview-op-exposed", severity: "warn", message: `webhook "${name}" is preview-only (a role-login backdoor) — it must reach prod ONLY behind the SULUK_PREVIEW gate; confirm this is a preview deployment, not production`, where: name });
108
+ }
109
+ }
110
+
95
111
  return { findings, clean: !findings.some((f) => f.severity === "error") };
96
112
  }
package/src/crosscut.ts CHANGED
@@ -94,3 +94,64 @@ export function crossCut(doc: OpenAPIv4Document, viewers: Viewer[]): CrossCut {
94
94
  }
95
95
  return { operations: ops.map((o) => ({ name: o.name, detail: o.detail })), viewers: views, gated };
96
96
  }
97
+
98
+ /** A principal you can preview the running app AS — derived from the contract, never hardcoded. */
99
+ export interface PreviewRole {
100
+ label: string;
101
+ /** the role token passed to the preview deploy's /preview/login?role=… (or "anonymous"). */
102
+ role: string;
103
+ /** the scopes this role implies in the cross-cut (here, just the role itself; the runtime maps role→scopes). */
104
+ scopes: string[];
105
+ authenticated: boolean;
106
+ }
107
+
108
+ /**
109
+ * The previewable principals for live role-preview (the LAST roadmap slice): anonymous, then ONE per role the
110
+ * contract's `User.role` enum declares (the auth module ships ["user","admin","superadmin"]). Pure; degrades
111
+ * HONESTLY to anonymous-only when there is no User.role enum — never a hardcoded role list. The extension turns
112
+ * each into a deep-link to the preview deploy's own login; the cockpit never mints or holds a session.
113
+ */
114
+ // the safe identifier charset a previewable role name must match — it flows into a URL, into seed.sql, AND into
115
+ // the deployed gate's allow-list, so all three agree on exactly this set. Anything else is not previewable.
116
+ const SAFE_ROLE = /^[A-Za-z0-9_-]{1,40}$/;
117
+
118
+ export function previewRoles(doc: OpenAPIv4Document): PreviewRole[] {
119
+ const anonymous: PreviewRole = { label: "anonymous", role: "anonymous", scopes: [], authenticated: false };
120
+ const user = (doc.components?.schemas as Record<string, unknown> | undefined)?.["User"] as
121
+ | { properties?: { role?: { enum?: unknown } } }
122
+ | undefined;
123
+ const raw = user?.properties?.role?.enum;
124
+ const seen = new Set<string>();
125
+ const roles = (Array.isArray(raw) ? raw : [])
126
+ .filter((r): r is string => typeof r === "string" && r.length > 0)
127
+ // exclude the RESERVED "anonymous" (it is never a mintable session — login-less by definition), apply the safe
128
+ // charset (URL + SQL + gate safety), and DEDUP — so the previewable set == the seeded set == the gate allow-list.
129
+ .filter((r) => r !== "anonymous" && SAFE_ROLE.test(r))
130
+ .filter((r) => (seen.has(r) ? false : (seen.add(r), true)));
131
+ return [anonymous, ...roles.map((r) => ({ label: r, role: r, scopes: [r], authenticated: true }))];
132
+ }
133
+
134
+ /**
135
+ * The roles a preview may be minted AS — the authenticated principals, EXCLUDING the login-less `anonymous`. This
136
+ * is the ONE source for the deployed gate's allow-list AND for which demo users seed.sql seeds; keeping them equal
137
+ * means a role can be previewed iff it is seeded iff the gate allows it (no allow-but-unseedable divergence).
138
+ */
139
+ export function previewAllowedRoles(doc: OpenAPIv4Document): string[] {
140
+ return previewRoles(doc).filter((r) => r.authenticated).map((r) => r.role);
141
+ }
142
+
143
+ /**
144
+ * Resolve the browser deep-link for previewing AS a role — the security-critical guard, made PURE so it is
145
+ * unit-testable (the extension package has no test harness). Hard-REFUSES any non-preview env BEFORE producing
146
+ * a URL (INV-08: role-preview can never target prod/local). anonymous ⇒ just the app; a role ⇒ the preview
147
+ * deploy's own gated /preview/login. The extension calls this, then openExternal — it never builds the URL itself.
148
+ */
149
+ export function previewLaunchUrl(
150
+ env: { baseUrl: string; isPreview: boolean },
151
+ role: string,
152
+ ): { refused: true; reason: string } | { refused: false; url: string } {
153
+ if (!env.isPreview) return { refused: true, reason: "not a preview deployment — role-preview is refused on prod/local" };
154
+ const base = env.baseUrl.replace(/\/+$/, "");
155
+ const url = role === "anonymous" ? base : `${base}/preview/login?role=${encodeURIComponent(role)}`;
156
+ return { refused: false, url };
157
+ }
package/src/deploy.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  import type { OpenAPIv4Document } from "@suluk/core";
8
8
  import { cloudflare, type DeployPlan } from "@suluk/deploy";
9
9
  import { entitiesFromDoc } from "./builder";
10
+ import { previewAllowedRoles } from "./crosscut";
10
11
 
11
12
  /** Build the Cloudflare deploy plan from a v4 document (its schemas → entities). */
12
13
  export function deployPlan(doc: OpenAPIv4Document): DeployPlan {
@@ -18,6 +19,22 @@ export function deployPlan(doc: OpenAPIv4Document): DeployPlan {
18
19
  });
19
20
  }
20
21
 
22
+ /**
23
+ * Build the PREVIEW deploy plan (charter-bounded role-preview): a `${slug}-preview` Worker with the two
24
+ * fail-closed locks + a seed.sql for the contract's roles. Terminal-gated identically to prod — Suluk holds no
25
+ * infra token; the USER runs wrangler. The seeded roles come from the contract (previewRoles), never hardcoded.
26
+ */
27
+ export function previewDeployPlan(doc: OpenAPIv4Document): DeployPlan {
28
+ return cloudflare.generate({
29
+ name: doc.info?.title ?? "suluk-app",
30
+ entities: entitiesFromDoc(doc),
31
+ appModule: "./src/app",
32
+ assetsDir: "./dist/client",
33
+ preview: true,
34
+ previewRoles: previewAllowedRoles(doc), // the seedable, non-anonymous set — equals the gate's allow-list
35
+ });
36
+ }
37
+
21
38
  /** Render the deploy plan as a DEPLOY.md the user can follow step by step. */
22
39
  export function deployMarkdown(plan: DeployPlan): string {
23
40
  const steps = plan.steps.map((s, i) => `${i + 1}. \`${s.cmd}\`\n - ${s.note}`).join("\n");
@@ -42,3 +59,16 @@ ${notes}
42
59
  > consequential. The deployment target is swappable: this is the \`cloudflare\` provider.
43
60
  `;
44
61
  }
62
+
63
+ /** Render a PREVIEW deploy plan as a PREVIEW-DEPLOY.md — same steps, but headed with the role-preview safety. */
64
+ export function previewDeployMarkdown(plan: DeployPlan): string {
65
+ const body = deployMarkdown(plan).replace(
66
+ "# Deploy to Cloudflare — CANDIDATE (generated by Suluk)",
67
+ "# Deploy a PREVIEW (role-preview) — CANDIDATE (generated by Suluk)\n\n" +
68
+ "> This is an EPHEMERAL preview deployment. It mounts a `/preview/login?role=…` backdoor that logs you in\n" +
69
+ "> as a seeded demo user — gated, fail-closed, behind TWO independent locks (the `SULUK_PREVIEW` var AND the\n" +
70
+ "> `PREVIEW_DB` binding). A production deploy sets neither, so the backdoor is inert there. **Tear the preview\n" +
71
+ "> down when finished** (`wrangler delete`) — a standing preview is a live credentialed surface.",
72
+ );
73
+ return body;
74
+ }
package/src/index.ts CHANGED
@@ -8,12 +8,12 @@ export { validateSource, auditSource, previewHtml, looksLikeV4, type Diagnostic
8
8
  export { buildCycle, docChecks, cycleSummary, type CycleModel, type CycleLayer, type CycleItem, type LayerStatus, type Principal, type DocCheck } from "./cycle";
9
9
  export { buildBuilderModel, builderTree, entitiesFromDoc, generateAppFiles, generateRegistryJson, type BuilderModel, type BuilderNode, type GeneratedFile } from "./builder";
10
10
  export { entityNames, generateForm, generateTable, generateStoresModule, exportV4Json } from "./codegen";
11
- export { deployPlan, deployMarkdown } from "./deploy";
11
+ export { deployPlan, deployMarkdown, previewDeployPlan, previewDeployMarkdown } from "./deploy";
12
12
  export type { DeployPlan, DeployStep, DeployProvider } from "@suluk/deploy";
13
13
  // drift (OBSERVE): compare a LOCAL contract against a DEPLOYED one — the "what's drifted in prod" view (C020).
14
14
  export { diffContracts, canonical, type ContractDiff, type ChangedOp, type OpRef, type ProviderDelta, type ProviderChange } from "./drift";
15
15
  // cross-cut (M1): one contract refracted through every viewer — the scope-gated surface, the moat.
16
- export { crossCut, documentScopes, defaultViewers, type Viewer, type ViewerView, type GatedOp, type CrossCut } from "./crosscut";
16
+ export { crossCut, documentScopes, defaultViewers, previewRoles, previewAllowedRoles, previewLaunchUrl, type Viewer, type ViewerView, type GatedOp, type CrossCut, type PreviewRole } from "./crosscut";
17
17
  // converge: a coherence audit over a whole contract — the cross-cutting contradictions a clean merge leaves behind.
18
18
  export { convergeContract, type ConvergeReport, type ConvergeFinding, type ConvergeCode } from "./converge";
19
19
  // diagrams: D2 source for views of the contract (ERD / the declarative cycle / the operation surface) — another projection.
@@ -21,6 +21,8 @@ export { contractToD2, diagramViews, type DiagramView } from "./diagram";
21
21
  // component preview + pixel-confidence (surfaces @suluk/visual): decompose generated UI into primitives, check vs a baseline.
22
22
  export { componentReport, approveComponents, type ComponentReport } from "./visual";
23
23
  export { type Baseline, primitiveCss } from "@suluk/visual";
24
+ // lifecycle / ship-readiness (L3): the round-trip loop as one checklist — authored → coherent → confident → generated → deployed.
25
+ export { contractGates, shipSummary, type Gate, type GateStatus } from "./lifecycle";
24
26
  // cost formatting, re-exported so the extension shell can render a live /cost ledger without a direct @suluk/cost dep.
25
27
  export { formatMicroUsd, summarize, type CostSummary } from "@suluk/cost";
26
28
  // modules (C021): install a contract fragment into the hub doc — the cockpit then re-projects it for free.
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Lifecycle / ship-readiness (L3) — the round-trip loop made legible. One contract walks: authored → coherent →
3
+ * pixel-confident → generated → deployed. contractGates computes the CONTRACT-level gates here (pure: valid /
4
+ * coherent / confident), each with a status + the cheapest next action; the extension adds the host gates
5
+ * (generated-in-sync, deployed-in-sync, health) it can only see with fs + network. The whole thing is one
6
+ * "are you ready to ship?" checklist that aggregates the cockpit's own audits. Pure (no host) → unit-tested.
7
+ */
8
+ import { validateDocument, buildAda, type OpenAPIv4Document } from "@suluk/core";
9
+ import { convergeContract } from "./converge";
10
+ import { componentReport } from "./visual";
11
+ import type { Baseline } from "@suluk/visual";
12
+
13
+ // "info" is the non-blocking status: a gate that is honestly n/a (no env configured, no workspace open) — it
14
+ // is shown for transparency but NEVER counts against readiness. Distinct from "warn"/"todo", which DO block.
15
+ export type GateStatus = "ok" | "warn" | "error" | "todo" | "info";
16
+ export interface Gate {
17
+ id: string;
18
+ title: string;
19
+ status: GateStatus;
20
+ detail: string;
21
+ /** the command to run to advance this gate (undefined ⇒ nothing to do) */
22
+ action?: string;
23
+ }
24
+
25
+ /** The CONTRACT-level ship gates — everything decidable from the document itself (no host needed). */
26
+ export function contractGates(doc: OpenAPIv4Document, baseline: Baseline): Gate[] {
27
+ const gates: Gate[] = [];
28
+
29
+ // 0. non-empty — a contract with zero operations has nothing to ship, however valid/coherent it is in isolation.
30
+ const ops = buildAda(doc).operations.length;
31
+ gates.push({
32
+ id: "operations", title: "Has operations",
33
+ status: ops ? "ok" : "todo",
34
+ detail: ops ? `${ops} operation${ops === 1 ? "" : "s"}` : "no operations — nothing to ship",
35
+ });
36
+
37
+ // 1. valid v4
38
+ const v = validateDocument(doc);
39
+ gates.push({
40
+ id: "valid", title: "Valid v4 contract",
41
+ status: v.valid ? "ok" : "error",
42
+ detail: v.valid ? "passes the meta-schema" : `${v.errors.length} schema error${v.errors.length === 1 ? "" : "s"}`,
43
+ action: v.valid ? undefined : "suluk.validate",
44
+ });
45
+
46
+ // 2. coherent (converge)
47
+ const conv = convergeContract(doc);
48
+ const errs = conv.findings.filter((f) => f.severity === "error").length;
49
+ gates.push({
50
+ id: "coherent", title: "Coherent (no contradictions)",
51
+ status: errs ? "error" : "ok",
52
+ detail: errs ? `${errs} contradiction${errs === 1 ? "" : "s"} (dangling ref / orphan scope / …)` : "converges clean",
53
+ action: errs ? "suluk.convergeContract" : undefined,
54
+ });
55
+
56
+ // 2b. no preview backdoor — a contract carrying an x-suluk-preview-only op (a role-login backdoor) must be
57
+ // deployed ONLY as a preview, never prod. WARN-status so it counts against ready (shipSummary blocks on warn):
58
+ // a clean contract reads ready; one with the backdoor reads NOT ready until the user confirms it is a preview.
59
+ // (Fixes the supply-chain hole where a smuggled preview op was hard-filtered out of the error-only coherent gate.)
60
+ const previewOps = conv.findings.filter((f) => f.code === "preview-op-exposed");
61
+ gates.push({
62
+ id: "noBackdoor", title: "No preview backdoor in prod",
63
+ status: previewOps.length ? "warn" : "ok",
64
+ detail: previewOps.length ? `${previewOps.length} preview-only op${previewOps.length === 1 ? "" : "s"} (${previewOps.map((f) => f.where).filter(Boolean).join(", ")}) — deploy ONLY as a preview, never to production` : "no preview-only operations",
65
+ action: previewOps.length ? "suluk.convergeContract" : undefined,
66
+ });
67
+
68
+ // 3. pixel-confident components
69
+ const cr = componentReport(doc, baseline);
70
+ const pending = cr.confidence.missing.length + cr.confidence.drifted.length;
71
+ gates.push({
72
+ id: "confident", title: "Components pixel-confident",
73
+ status: cr.used.length === 0 ? "ok" : pending ? "todo" : "ok",
74
+ detail: cr.used.length === 0 ? "no generated components" : pending ? `${pending} primitive${pending === 1 ? "" : "s"} to verify once` : "every primitive approved + unchanged",
75
+ action: pending ? "suluk.previewComponents" : undefined,
76
+ });
77
+
78
+ return gates;
79
+ }
80
+
81
+ /** A one-line readiness summary over a set of gates (contract + host). "info" gates never count against ready. */
82
+ export function shipSummary(gates: Gate[]): { ready: boolean; line: string } {
83
+ const errors = gates.filter((g) => g.status === "error").length;
84
+ const todos = gates.filter((g) => g.status === "todo" || g.status === "warn").length;
85
+ const ok = gates.filter((g) => g.status === "ok").length;
86
+ const info = gates.filter((g) => g.status === "info").length;
87
+ const ready = errors === 0 && todos === 0;
88
+ return {
89
+ ready,
90
+ line: ready
91
+ ? `ready to ship — ${ok} gate${ok === 1 ? "" : "s"} pass${info ? ` · ${info} n/a` : ""}`
92
+ : `${errors} blocker${errors === 1 ? "" : "s"} · ${todos} to do · ${ok}/${gates.length} pass`,
93
+ };
94
+ }
@@ -0,0 +1,67 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { parseDocument } from "@suluk/core";
3
+ import type { Baseline } from "@suluk/visual";
4
+ import { componentReport, approveComponents } from "../src/visual";
5
+ import { contractGates, shipSummary } from "../src/lifecycle";
6
+
7
+ const good = () => parseDocument(`openapi: 4.0.0-candidate
8
+ info: { title: Shop, version: 1.0.0 }
9
+ paths:
10
+ "pet":
11
+ requests:
12
+ listPets: { method: get, responses: { ok: { status: 200, contentSchema: { $ref: "#/components/schemas/Pet" } } } }
13
+ components: { schemas: { Pet: { type: object, properties: { name: { type: string } } } } }`);
14
+
15
+ const gate = (gates: ReturnType<typeof contractGates>, id: string) => gates.find((g) => g.id === id)!;
16
+
17
+ describe("contractGates — the contract-level ship gates", () => {
18
+ test("a valid, coherent contract passes valid + coherent; confidence is 'todo' against an empty baseline", () => {
19
+ const gates = contractGates(good(), {});
20
+ expect(gate(gates, "valid").status).toBe("ok");
21
+ expect(gate(gates, "coherent").status).toBe("ok");
22
+ expect(gate(gates, "confident").status).toBe("todo"); // never verified
23
+ expect(gate(gates, "confident").action).toBe("suluk.previewComponents");
24
+ });
25
+ test("a contradiction (a dangling $ref) makes the coherent gate an error with a fix action", () => {
26
+ const broken = parseDocument(`openapi: 4.0.0-candidate
27
+ info: { title: T, version: 1.0.0 }
28
+ paths: { "p": { requests: { g: { method: get, responses: { ok: { status: 200, contentSchema: { $ref: "#/components/schemas/Ghost" } } } } } } }
29
+ components: { schemas: {} }`);
30
+ const g = gate(contractGates(broken, {}), "coherent");
31
+ expect(g.status).toBe("error");
32
+ expect(g.action).toBe("suluk.convergeContract");
33
+ });
34
+ test("after approving the components, the confidence gate passes", () => {
35
+ const doc = good();
36
+ const baseline: Baseline = approveComponents(componentReport(doc, {}), {}, 1);
37
+ expect(gate(contractGates(doc, baseline), "confident").status).toBe("ok");
38
+ });
39
+ test("an empty contract (zero operations) is NOT ship-ready — the operations gate is 'todo'", () => {
40
+ const empty = parseDocument(`openapi: 4.0.0-candidate
41
+ info: { title: Empty, version: 1.0.0 }
42
+ paths: {}`);
43
+ const gates = contractGates(empty, {});
44
+ expect(gate(gates, "operations").status).toBe("todo"); // nothing to ship
45
+ expect(shipSummary(gates).ready).toBe(false); // even though valid + coherent are clean
46
+ });
47
+ });
48
+
49
+ describe("shipSummary", () => {
50
+ test("all-ok gates ⇒ ready", () => {
51
+ const r = shipSummary([{ id: "a", title: "A", status: "ok", detail: "" }, { id: "b", title: "B", status: "ok", detail: "" }]);
52
+ expect(r.ready).toBe(true);
53
+ expect(r.line).toContain("ready to ship");
54
+ });
55
+ test("an error gate ⇒ NOT ready, counted as a blocker", () => {
56
+ const r = shipSummary([{ id: "a", title: "A", status: "ok", detail: "" }, { id: "b", title: "B", status: "error", detail: "" }, { id: "c", title: "C", status: "todo", detail: "" }]);
57
+ expect(r.ready).toBe(false);
58
+ expect(r.line).toContain("1 blocker");
59
+ expect(r.line).toContain("1 to do");
60
+ });
61
+ test("an 'info' gate is non-blocking — it never makes a clean contract read as not-ready", () => {
62
+ const r = shipSummary([{ id: "a", title: "A", status: "ok", detail: "" }, { id: "b", title: "B", status: "info", detail: "n/a — no environment configured" }]);
63
+ expect(r.ready).toBe(true);
64
+ expect(r.line).toContain("ready to ship");
65
+ expect(r.line).toContain("1 n/a"); // surfaced for transparency, but not counted against ready
66
+ });
67
+ });
@@ -0,0 +1,135 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import type { OpenAPIv4Document } from "@suluk/core";
3
+ import { installModule, PREVIEW } from "@suluk/builder";
4
+ import { previewRoles, previewAllowedRoles, previewLaunchUrl } from "../src/crosscut";
5
+ import { convergeContract } from "../src/converge";
6
+ import { contractGates, shipSummary } from "../src/lifecycle";
7
+ import { previewDeployPlan, deployPlan } from "../src/deploy";
8
+
9
+ const docWithRoles = (): OpenAPIv4Document => ({
10
+ openapi: "4.0.0-candidate",
11
+ info: { title: "Shop", version: "1.0.0" },
12
+ paths: { user: { requests: { listUser: { method: "get", responses: { ok: { status: 200, contentType: "application/json", contentSchema: { $ref: "#/components/schemas/User" } } } } } } },
13
+ components: { schemas: { User: { type: "object", properties: { id: { type: "integer" }, role: { type: "string", enum: ["user", "admin", "superadmin"] } } } } },
14
+ });
15
+
16
+ describe("previewRoles — contract-sourced principals (never hardcoded)", () => {
17
+ test("derives anonymous + one per User.role enum value", () => {
18
+ const roles = previewRoles(docWithRoles());
19
+ expect(roles.map((r) => r.role)).toEqual(["anonymous", "user", "admin", "superadmin"]);
20
+ expect(roles[0]).toMatchObject({ role: "anonymous", authenticated: false });
21
+ expect(roles[2]).toMatchObject({ role: "admin", authenticated: true, scopes: ["admin"] });
22
+ });
23
+ test("degrades HONESTLY to anonymous-only when there is no User.role enum", () => {
24
+ const noRole: OpenAPIv4Document = { openapi: "4.0.0-candidate", info: { title: "X", version: "1.0.0" }, paths: {}, components: { schemas: {} } };
25
+ expect(previewRoles(noRole).map((r) => r.role)).toEqual(["anonymous"]);
26
+ // and when there's no User at all it does not throw
27
+ const bare: OpenAPIv4Document = { openapi: "4.0.0-candidate", info: { title: "X", version: "1.0.0" }, paths: {} };
28
+ expect(previewRoles(bare).map((r) => r.role)).toEqual(["anonymous"]);
29
+ });
30
+ test("a reserved/unsafe/duplicate enum is reconciled — anonymous appears exactly once, unsafe roles dropped", () => {
31
+ const messy: OpenAPIv4Document = {
32
+ openapi: "4.0.0-candidate", info: { title: "X", version: "1.0.0" }, paths: {},
33
+ components: { schemas: { User: { type: "object", properties: { role: { type: "string", enum: ["admin", "anonymous", "admin", "ev:il", "x".repeat(50), 42 as unknown as string] } } } } },
34
+ };
35
+ expect(previewRoles(messy).map((r) => r.role)).toEqual(["anonymous", "admin"]); // dedup + reserved + unsafe/long/non-string all dropped
36
+ expect(previewRoles(messy).filter((r) => r.role === "anonymous")).toHaveLength(1);
37
+ });
38
+ test("previewAllowedRoles is the seedable set — authenticated only, NEVER anonymous (the gate's allow-list)", () => {
39
+ expect(previewAllowedRoles(docWithRoles())).toEqual(["user", "admin", "superadmin"]);
40
+ expect(previewAllowedRoles(docWithRoles())).not.toContain("anonymous");
41
+ });
42
+ });
43
+
44
+ describe("previewLaunchUrl — the preview-only guard + deep-link (INV-08, pure & testable)", () => {
45
+ test("REFUSES a non-preview env before producing any URL", () => {
46
+ const r = previewLaunchUrl({ baseUrl: "https://prod.example.com", isPreview: false }, "admin");
47
+ expect(r.refused).toBe(true);
48
+ if (r.refused) expect(r.reason).toContain("refused");
49
+ });
50
+ test("a preview env + a role ⇒ the deploy's own gated /preview/login, role URL-encoded", () => {
51
+ const r = previewLaunchUrl({ baseUrl: "https://app-preview.example.com/", isPreview: true }, "super admin");
52
+ expect(r.refused).toBe(false);
53
+ if (!r.refused) expect(r.url).toBe("https://app-preview.example.com/preview/login?role=super%20admin");
54
+ });
55
+ test("anonymous ⇒ just the app, no login route, no query", () => {
56
+ const r = previewLaunchUrl({ baseUrl: "https://app-preview.example.com", isPreview: true }, "anonymous");
57
+ expect(r.refused).toBe(false);
58
+ if (!r.refused) expect(r.url).toBe("https://app-preview.example.com");
59
+ });
60
+ });
61
+
62
+ describe("converge — surfaces the preview-only backdoor (it must never sit silently in a projection)", () => {
63
+ test("a contract carrying the preview op gets a preview-op-exposed WARN", () => {
64
+ const installed = installModule(docWithRoles(), PREVIEW);
65
+ expect(installed.installed).toBe(true);
66
+ const report = convergeContract(installed.doc);
67
+ const f = report.findings.find((x) => x.code === "preview-op-exposed");
68
+ expect(f).toBeDefined();
69
+ expect(f!.severity).toBe("warn");
70
+ expect(f!.where).toBe("previewLogin");
71
+ expect(report.clean).toBe(true); // a WARN is not an error — it does not fail coherence, only flags
72
+ });
73
+ test("a contract WITHOUT the preview op has no such finding", () => {
74
+ expect(convergeContract(docWithRoles()).findings.some((x) => x.code === "preview-op-exposed")).toBe(false);
75
+ });
76
+ test("a marker hidden on a WEBHOOK is ALSO surfaced (no false negative)", () => {
77
+ const wh: OpenAPIv4Document = {
78
+ openapi: "4.0.0-candidate", info: { title: "X", version: "1.0.0" }, paths: {},
79
+ webhooks: { sneaky: { method: "post", responses: { ok: { status: 200, description: "x" } }, "x-suluk-preview-only": true } },
80
+ } as unknown as OpenAPIv4Document;
81
+ const f = convergeContract(wh).findings.find((x) => x.code === "preview-op-exposed");
82
+ expect(f).toBeDefined();
83
+ expect(f!.where).toBe("sneaky");
84
+ });
85
+ });
86
+
87
+ describe("ship gate — a contract carrying the backdoor is NOT 'ready to ship' (supply-chain fix)", () => {
88
+ test("contractGates adds a 'noBackdoor' warn that blocks readiness when a preview op is present", () => {
89
+ const installed = installModule(docWithRoles(), PREVIEW).doc;
90
+ const gates = contractGates(installed, {});
91
+ const g = gates.find((x) => x.id === "noBackdoor")!;
92
+ expect(g.status).toBe("warn");
93
+ expect(g.detail).toContain("previewLogin");
94
+ expect(shipSummary(gates).ready).toBe(false); // the smuggled-backdoor contract can no longer read 'ready'
95
+ });
96
+ test("a clean contract has noBackdoor 'ok'", () => {
97
+ const gates = contractGates(docWithRoles(), {});
98
+ expect(gates.find((x) => x.id === "noBackdoor")!.status).toBe("ok");
99
+ });
100
+ });
101
+
102
+ describe("previewDeployPlan — the two locks + the seed, terminal-gated", () => {
103
+ const plan = previewDeployPlan(docWithRoles());
104
+ const wrangler = plan.files.find((f) => f.path === "wrangler.jsonc")!.content;
105
+ test("names a -preview Worker with BOTH locks: the SULUK_PREVIEW var and a PREVIEW_DB binding", () => {
106
+ expect(wrangler).toContain("-preview");
107
+ expect(wrangler).toContain('"SULUK_PREVIEW": "1"');
108
+ expect(wrangler).toContain('"binding": "PREVIEW_DB"');
109
+ });
110
+ test("seeds one throwaway demo user per non-anonymous role", () => {
111
+ const seed = plan.files.find((f) => f.path === "seed.sql")!.content;
112
+ expect(seed).toContain("preview-admin");
113
+ expect(seed).toContain("preview-superadmin");
114
+ expect(seed).not.toContain("preview-anonymous"); // anonymous is never seeded
115
+ });
116
+ test("includes a teardown step (a standing preview is a live credentialed surface)", () => {
117
+ expect(plan.steps.some((s) => s.cmd.includes("wrangler delete"))).toBe(true);
118
+ });
119
+ test("a hostile role enum value never reaches seed.sql (filtered at previewAllowedRoles — no SQL injection)", () => {
120
+ const evil: OpenAPIv4Document = {
121
+ openapi: "4.0.0-candidate", info: { title: "Evil", version: "1.0.0" }, paths: {},
122
+ components: { schemas: { User: { type: "object", properties: { role: { type: "string", enum: ["admin", "x'); DROP TABLE user;--"] } } } } },
123
+ };
124
+ const seed = previewDeployPlan(evil).files.find((f) => f.path === "seed.sql")!.content;
125
+ expect(seed).toContain("preview-admin"); // the safe role is seeded
126
+ expect(seed).not.toContain("DROP TABLE"); // the hostile role is filtered out upstream, never emitted
127
+ });
128
+ test("the PROD plan sets NO preview flag — the backdoor is inert there", () => {
129
+ const prod = deployPlan(docWithRoles());
130
+ const prodWrangler = prod.files.find((f) => f.path === "wrangler.jsonc")!.content;
131
+ expect(prodWrangler).not.toContain("SULUK_PREVIEW");
132
+ expect(prodWrangler).not.toContain("PREVIEW_DB");
133
+ expect(prod.files.some((f) => f.path === "seed.sql")).toBe(false);
134
+ });
135
+ });