@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 +1 -1
- package/src/crosscut.ts +96 -0
- package/src/cycle.ts +16 -1
- package/src/drift.ts +0 -0
- package/src/index.ts +5 -1
- package/test/crosscut.test.ts +81 -0
- package/test/cycle.test.ts +3 -3
- package/test/drift.test.ts +9 -0
- package/test/module-cycle.test.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suluk/cockpit",
|
|
3
|
-
"version": "0.1.
|
|
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",
|
package/src/crosscut.ts
ADDED
|
@@ -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
|
+
});
|
package/test/cycle.test.ts
CHANGED
|
@@ -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
|
|
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(
|
|
113
|
+
expect(cycleSummary(buildCycle(petstore)).length).toBe(10);
|
|
114
114
|
});
|
|
115
115
|
});
|
package/test/drift.test.ts
CHANGED
|
@@ -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
|
});
|