@suluk/cockpit 0.1.13 → 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,6 +1,6 @@
1
1
  {
2
2
  "name": "@suluk/cockpit",
3
- "version": "0.1.13",
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
5
  "publishConfig": {
6
6
  "access": "public"
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.
package/src/lifecycle.ts CHANGED
@@ -53,6 +53,18 @@ export function contractGates(doc: OpenAPIv4Document, baseline: Baseline): Gate[
53
53
  action: errs ? "suluk.convergeContract" : undefined,
54
54
  });
55
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
+
56
68
  // 3. pixel-confident components
57
69
  const cr = componentReport(doc, baseline);
58
70
  const pending = cr.confidence.missing.length + cr.confidence.drifted.length;
@@ -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
+ });