@suluk/cockpit 0.1.2 → 0.1.4

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.2",
3
+ "version": "0.1.4",
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/index.ts CHANGED
@@ -12,7 +12,13 @@ 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
14
  export { diffContracts, canonical, type ContractDiff, type ChangedOp, type OpRef } 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
- export { installModule, namespaceModule, ECOMMERCE, type SulukModule, type InstallResult } from "@suluk/builder";
20
+ export {
21
+ installModule, namespaceModule, previewInstall, gradeModule,
22
+ ECOMMERCE, CRM, BILLING, FIRST_PARTY_REGISTRY,
23
+ type SulukModule, type InstallResult, type ModuleEntry, type ModuleRegistry, type ModuleGrade, type InstallPreview,
24
+ } 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
+ });