@suluk/cockpit 0.1.3 → 0.1.5

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.3",
3
+ "version": "0.1.5",
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
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The cross-cut (M1) — the moat. The cockpit already projects all 9 layers for ONE viewer (buildCycle's
3
+ * principal filter). This compares ACROSS viewers: one contract, refracted through every principal, so you can
4
+ * see exactly which operations are gated and who can reach them. No other tool can do this, because none owns
5
+ * the derivation. Pure (no host) → unit-tested; the extension renders the matrix.
6
+ */
7
+ import { buildAda } from "@suluk/core";
8
+ import type { OpenAPIv4Document, Request, SecurityRequirement } from "@suluk/core";
9
+
10
+ /** A viewer to project for. `scopes: undefined` ⇒ the full/operator view; `[]` ⇒ no scopes.
11
+ * `authenticated` distinguishes a logged-in viewer from a truly anonymous one — an auth-only operation
12
+ * (`security: [{ bearer: [] }]`, a requirement with zero scopes) is reachable by any AUTHENTICATED viewer but
13
+ * NOT by anonymous. (This is more precise than the cockpit's single-principal "View as", which keys on scopes
14
+ * alone; the cross-cut is the purpose-built security view.) */
15
+ export interface Viewer {
16
+ label: string;
17
+ scopes: string[] | undefined;
18
+ /** does this viewer hold a credential? defaults to "holds at least one scope". */
19
+ authenticated?: boolean;
20
+ }
21
+ export interface ViewerView {
22
+ label: string;
23
+ scopes: string[] | null;
24
+ visible: string[];
25
+ hidden: string[];
26
+ }
27
+ export interface GatedOp {
28
+ operation: string;
29
+ detail: string;
30
+ /** the scope requirements (OR of AND-groups); empty ⇒ public */
31
+ requiredScopes: string[][];
32
+ /** the labels of the viewers who CAN see it */
33
+ visibleTo: string[];
34
+ }
35
+ export interface CrossCut {
36
+ operations: { name: string; detail: string }[];
37
+ viewers: ViewerView[];
38
+ /** operations not visible to every viewer — the scope-gated surface */
39
+ gated: GatedOp[];
40
+ }
41
+
42
+ function requiredScopes(req: Request): string[][] {
43
+ const reqs = (req.security as SecurityRequirement[] | undefined) ?? [];
44
+ return reqs.map((r) => Object.values(r).flat());
45
+ }
46
+ function visibleTo(req: Request, viewer: Viewer): boolean {
47
+ if (viewer.scopes === undefined) return true; // full operator view sees all
48
+ const reqs = requiredScopes(req);
49
+ if (reqs.length === 0) return true; // genuinely public op
50
+ const held = new Set(viewer.scopes);
51
+ const authed = viewer.authenticated ?? viewer.scopes.length > 0; // holding a scope implies a credential
52
+ // a requirement of [] (auth-only, no scope) is satisfied iff authenticated; a scoped requirement needs its scopes
53
+ return reqs.some((needed) => (needed.length === 0 ? authed : needed.every((s) => held.has(s))));
54
+ }
55
+
56
+ /** Every scope referenced by any operation's security requirements (sorted, deduped). */
57
+ export function documentScopes(doc: OpenAPIv4Document): string[] {
58
+ return [...new Set(buildAda(doc).operations.flatMap((o) => requiredScopes(o.request).flat()))].sort();
59
+ }
60
+
61
+ /**
62
+ * Sensible default viewers for a document: anonymous, one per declared scope, and the full operator view —
63
+ * so a single command shows the whole gated surface without the user hand-entering scope sets.
64
+ */
65
+ export function defaultViewers(doc: OpenAPIv4Document): Viewer[] {
66
+ const ops = buildAda(doc).operations;
67
+ const scopes = [...new Set(ops.flatMap((o) => requiredScopes(o.request).flat()))].sort();
68
+ // only show a distinct "authenticated" viewer when some operation requires auth WITHOUT a scope (else its
69
+ // column would duplicate "anonymous" — a logged-in user with no role sees the same public + scoped surface).
70
+ const hasAuthOnly = ops.some((o) => requiredScopes(o.request).some((r) => r.length === 0));
71
+ return [
72
+ { label: "anonymous", scopes: [], authenticated: false },
73
+ ...(hasAuthOnly ? [{ label: "authenticated", scopes: [] as string[], authenticated: true }] : []),
74
+ ...scopes.map((s) => ({ label: s, scopes: [s], authenticated: true })),
75
+ { label: "full", scopes: undefined },
76
+ ];
77
+ }
78
+
79
+ /** Project the contract through every viewer and surface the gated operations. */
80
+ export function crossCut(doc: OpenAPIv4Document, viewers: Viewer[]): CrossCut {
81
+ const ops = buildAda(doc).operations.map((o) => ({ name: o.name, detail: `${o.request.method.toUpperCase()} ${o.pathTemplate}`, req: o.request }));
82
+ const views: ViewerView[] = viewers.map((v) => {
83
+ const visible: string[] = [];
84
+ const hidden: string[] = [];
85
+ for (const o of ops) (visibleTo(o.req, v) ? visible : hidden).push(o.name);
86
+ return { label: v.label, scopes: v.scopes ?? null, visible, hidden };
87
+ });
88
+ const gated: GatedOp[] = [];
89
+ for (const o of ops) {
90
+ const seers = views.filter((v) => v.visible.includes(o.name)).map((v) => v.label);
91
+ if (seers.length !== views.length) {
92
+ gated.push({ operation: o.name, detail: o.detail, requiredScopes: requiredScopes(o.req), visibleTo: seers });
93
+ }
94
+ }
95
+ return { operations: ops.map((o) => ({ name: o.name, detail: o.detail })), viewers: views, gated };
96
+ }
package/src/cycle.ts CHANGED
@@ -14,6 +14,7 @@ import type { OpenAPIv4Document, Request, SchemaOrRef, SecurityRequirement } fro
14
14
  import { audit, coverage } from "@suluk/hono";
15
15
  import { formSpec, tableSpec } from "@suluk/shadcn";
16
16
  import { costAudit, costTable, formatMicroUsd } from "@suluk/cost";
17
+ import { readProviders } from "@suluk/builder";
17
18
 
18
19
  export type LayerStatus = "ok" | "warn" | "error" | "info";
19
20
 
@@ -26,7 +27,7 @@ export interface CycleItem {
26
27
  }
27
28
 
28
29
  export interface CycleLayer {
29
- id: "data" | "contract" | "auth" | "document" | "cost" | "docs" | "state" | "ui" | "tests";
30
+ id: "data" | "contract" | "auth" | "document" | "cost" | "docs" | "state" | "ui" | "providers" | "tests";
30
31
  title: string;
31
32
  status: LayerStatus;
32
33
  summary: string;
@@ -140,6 +141,14 @@ export function buildCycle(doc: OpenAPIv4Document, opts: { principal?: Principal
140
141
  const undeclared = costFindings.filter((f) => f.code === "no-cost-model").length;
141
142
  const costItems: CycleItem[] = declaredCosts.map((d) => ({ label: d.operation, ref: d.operation, detail: `${formatMicroUsd(d.estimateMicroUsd)} · ${d.sources.join(", ")}` }));
142
143
 
144
+ // ── providers: the swappable facet bindings recorded on the document (M3)
145
+ const providerBindings = readProviders(doc);
146
+ const providerItems: CycleItem[] = providerBindings.map((b) => ({
147
+ label: b.facet, ref: b.facet,
148
+ detail: `${b.title}${b.alternatives.length ? ` · ${b.alternatives.length} alternatives` : ""}`,
149
+ status: b.known ? undefined : "warn",
150
+ }));
151
+
143
152
  // ── tests: doc-level contract checks
144
153
  const checks = docChecks(doc);
145
154
  const testsItems: CycleItem[] = checks.map((c) => ({ label: c.name, status: c.pass ? "ok" : "error", detail: c.pass ? "pass" : c.message }));
@@ -168,6 +177,12 @@ export function buildCycle(doc: OpenAPIv4Document, opts: { principal?: Principal
168
177
  { id: "docs", title: "Docs", status: "ok", summary: "Scalar · Swagger", items: docsItems },
169
178
  { id: "state", title: "State (Nano Stores)", status: "ok", summary: `${stateItems.length} stores`, items: stateItems },
170
179
  { id: "ui", title: "UI (shadcn)", status: uiItems.length ? "ok" : "info", summary: `${uiItems.length} forms/tables`, items: uiItems },
180
+ {
181
+ id: "providers", title: "Providers",
182
+ status: providerItems.length ? "ok" : "info",
183
+ summary: providerItems.length ? providerBindings.map((b) => `${b.facet}→${b.impl}`).join(" · ") : "none",
184
+ items: providerItems,
185
+ },
171
186
  {
172
187
  id: "tests", title: "Tests (contract checks)",
173
188
  status: checks.every((c) => c.pass) ? "ok" : "error",
package/src/drift.ts CHANGED
Binary file
package/src/index.ts CHANGED
@@ -11,12 +11,16 @@ export { entityNames, generateForm, generateTable, generateStoresModule, exportV
11
11
  export { deployPlan, deployMarkdown } 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
- export { diffContracts, canonical, type ContractDiff, type ChangedOp, type OpRef } from "./drift";
14
+ export { diffContracts, canonical, type ContractDiff, type ChangedOp, type OpRef, type ProviderDelta, type ProviderChange } from "./drift";
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";
15
17
  // cost formatting, re-exported so the extension shell can render a live /cost ledger without a direct @suluk/cost dep.
16
18
  export { formatMicroUsd, summarize, type CostSummary } from "@suluk/cost";
17
19
  // modules (C021): install a contract fragment into the hub doc — the cockpit then re-projects it for free.
18
20
  export {
19
21
  installModule, namespaceModule, previewInstall, gradeModule,
20
22
  ECOMMERCE, CRM, BILLING, FIRST_PARTY_REGISTRY,
23
+ PROVIDER_CATALOG, providerFacets, readProviders, swapProvider,
21
24
  type SulukModule, type InstallResult, type ModuleEntry, type ModuleRegistry, type ModuleGrade, type InstallPreview,
25
+ type ProviderImpl, type ProviderBinding,
22
26
  } from "@suluk/builder";
@@ -0,0 +1,81 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { parseDocument } from "@suluk/core";
3
+ import { crossCut, documentScopes, defaultViewers } from "../src/crosscut";
4
+
5
+ // A petstore-ish contract: listPets/getPet are public; createPet/deletePet need write:pets; a read:secret op.
6
+ const DOC = parseDocument(`openapi: 4.0.0-candidate
7
+ info: { title: Pets, version: 1.0.0 }
8
+ paths:
9
+ "pet":
10
+ requests:
11
+ listPets: { method: get, responses: { ok: { status: 200 } } }
12
+ createPet: { method: post, security: [ { petstore_auth: [ "write:pets" ] } ], responses: { created: { status: 201 } } }
13
+ "pet/{id}":
14
+ requests:
15
+ getPet: { method: get, responses: { ok: { status: 200 } } }
16
+ deletePet: { method: delete, security: [ { petstore_auth: [ "write:pets" ] } ], responses: { deleted: { status: 204 } } }
17
+ auditPet: { method: get, security: [ { petstore_auth: [ "read:secret" ] } ], responses: { ok: { status: 200 } } }
18
+ components:
19
+ securitySchemes:
20
+ petstore_auth: { type: oauth2, flows: { implicit: { authorizationUrl: "https://x/o", scopes: { "write:pets": "w", "read:secret": "r" } } } }
21
+ `);
22
+
23
+ describe("documentScopes / defaultViewers", () => {
24
+ test("collects every scope referenced by an operation", () => {
25
+ expect(documentScopes(DOC)).toEqual(["read:secret", "write:pets"]);
26
+ });
27
+ test("default viewers = anonymous + one per scope + full", () => {
28
+ expect(defaultViewers(DOC).map((v) => v.label)).toEqual(["anonymous", "read:secret", "write:pets", "full"]);
29
+ });
30
+ });
31
+
32
+ describe("crossCut — one contract through every viewer", () => {
33
+ const cc = crossCut(DOC, defaultViewers(DOC));
34
+ const view = (label: string) => cc.viewers.find((v) => v.label === label)!;
35
+
36
+ test("anonymous sees only the public operations", () => {
37
+ expect(view("anonymous").visible.sort()).toEqual(["getPet", "listPets"]);
38
+ expect(view("anonymous").hidden.sort()).toEqual(["auditPet", "createPet", "deletePet"]);
39
+ });
40
+ test("a write:pets viewer gains create/delete but NOT the read:secret op", () => {
41
+ expect(view("write:pets").visible.sort()).toEqual(["createPet", "deletePet", "getPet", "listPets"]);
42
+ expect(view("write:pets").visible).not.toContain("auditPet");
43
+ });
44
+ test("the full operator view sees everything", () => {
45
+ expect(view("full").hidden).toHaveLength(0);
46
+ expect(view("full").visible).toHaveLength(5);
47
+ });
48
+ test("the gated surface lists exactly the scope-gated operations + who can reach them", () => {
49
+ const gatedOps = cc.gated.map((g) => g.operation).sort();
50
+ expect(gatedOps).toEqual(["auditPet", "createPet", "deletePet"]);
51
+ const createPet = cc.gated.find((g) => g.operation === "createPet")!;
52
+ expect(createPet.visibleTo).toEqual(["write:pets", "full"]); // anonymous + read:secret cannot
53
+ expect(createPet.requiredScopes).toEqual([["write:pets"]]);
54
+ });
55
+ test("an auth-only operation (security with no scopes) is hidden from anonymous, visible to authenticated", () => {
56
+ const authed = parseDocument(`openapi: 4.0.0-candidate
57
+ info: { title: A, version: 1.0.0 }
58
+ paths:
59
+ "me":
60
+ requests:
61
+ getMe: { method: get, security: [ { bearerAuth: [] } ], responses: { ok: { status: 200 } } }
62
+ ping: { method: get, responses: { ok: { status: 200 } } }
63
+ components: { securitySchemes: { bearerAuth: { type: http, scheme: bearer } } }
64
+ `);
65
+ const viewers = defaultViewers(authed);
66
+ expect(viewers.map((v) => v.label)).toContain("authenticated"); // surfaced because of the auth-only op
67
+ const cc = crossCut(authed, viewers);
68
+ const v = (l: string) => cc.viewers.find((x) => x.label === l)!;
69
+ expect(v("anonymous").visible).toEqual(["ping"]); // NOT getMe
70
+ expect(v("authenticated").visible.sort()).toEqual(["getMe", "ping"]);
71
+ expect(cc.gated.map((g) => g.operation)).toEqual(["getMe"]);
72
+ expect(cc.gated[0].visibleTo).toEqual(["authenticated", "full"]);
73
+ });
74
+ test("a fully-public contract has an empty gated surface", () => {
75
+ const open = parseDocument(`openapi: 4.0.0-candidate
76
+ info: { title: Open, version: 1.0.0 }
77
+ paths: { "a": { requests: { listA: { method: get, responses: { ok: { status: 200 } } } } } }
78
+ `);
79
+ expect(crossCut(open, defaultViewers(open)).gated).toHaveLength(0);
80
+ });
81
+ });
@@ -14,8 +14,8 @@ const petstore = parseDocument(petstoreSrc);
14
14
  describe("buildCycle — the cockpit spine (every layer from one hub)", () => {
15
15
  const model = buildCycle(petstore);
16
16
 
17
- test("models all nine layers (incl. cost)", () => {
18
- expect(model.layers.map((l) => l.id)).toEqual(["data", "contract", "auth", "document", "cost", "docs", "state", "ui", "tests"]);
17
+ test("models all ten layers (incl. cost + providers)", () => {
18
+ expect(model.layers.map((l) => l.id)).toEqual(["data", "contract", "auth", "document", "cost", "docs", "state", "ui", "providers", "tests"]);
19
19
  });
20
20
  test("the cost layer reflects declared costs (x-suluk-cost) — none on the bare petstore", () => {
21
21
  expect(model.layers.find((l) => l.id === "cost")!.summary).toBe("no costs declared");
@@ -110,6 +110,6 @@ describe("codegen actions land real artifacts", () => {
110
110
 
111
111
  describe("cycleSummary — flat status line", () => {
112
112
  test("returns one entry per layer", () => {
113
- expect(cycleSummary(buildCycle(petstore)).length).toBe(9);
113
+ expect(cycleSummary(buildCycle(petstore)).length).toBe(10);
114
114
  });
115
115
  });
@@ -139,6 +139,15 @@ paths: { "p": { requests: { g: { method: get, responses: { ok: { status: 200 } }
139
139
  expect(c.changes.some((x) => x.includes("shape"))).toBe(true); // the residual is not suppressed
140
140
  });
141
141
 
142
+ test("provider-slot drift (x-suluk-providers) is reported", () => {
143
+ const local = { openapi: "4.0.0-candidate", info: { title: "T", version: "1.0.0" }, paths: {}, "x-suluk-providers": { payments: "paddle", email: "resend" } } as unknown as Parameters<typeof diffContracts>[0];
144
+ const deployed = { openapi: "4.0.0-candidate", info: { title: "T", version: "1.0.0" }, paths: {}, "x-suluk-providers": { payments: "stripe" } } as unknown as Parameters<typeof diffContracts>[0];
145
+ const d = diffContracts(local, deployed);
146
+ expect(d.identical).toBe(false);
147
+ expect(d.providers.changed).toEqual([{ facet: "payments", from: "stripe", to: "paddle" }]);
148
+ expect(d.providers.added).toEqual([{ facet: "email", impl: "resend" }]);
149
+ expect(d.summary).toContain("providers");
150
+ });
142
151
  test("canonical() is cycle-safe (does not overflow the stack)", () => {
143
152
  const a: Record<string, unknown> = { x: 1 };
144
153
  a.self = a;
@@ -43,4 +43,10 @@ describe("installModule → the cockpit cycle lights up", () => {
43
43
  test("the document still validates against the v4 meta-schema after the merge", () => {
44
44
  expect(after.valid).toBe(true);
45
45
  });
46
+ test("the Providers layer lights up with the module's provider slot (payments→stripe)", () => {
47
+ const providers = layer(after, "providers");
48
+ expect(providers.summary).toContain("payments→stripe");
49
+ expect(providers.items.map((i) => i.label)).toContain("payments");
50
+ expect(layer(before, "providers").summary).toBe("none"); // the host had no provider slots
51
+ });
46
52
  });