@suluk/cockpit 0.1.8 → 0.1.9
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/converge.ts +96 -0
- package/src/index.ts +2 -0
- package/test/converge.test.ts +131 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suluk/cockpit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
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/converge.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract converge — a coherence audit over a WHOLE contract (the burhan-converge spirit). installModule
|
|
3
|
+
* refuses NAME collisions at merge time; converge catches the cross-cutting CONTRADICTIONS that survive a clean
|
|
4
|
+
* merge: an operation requiring a scope no security scheme declares, a security requirement naming a scheme
|
|
5
|
+
* that isn't defined, a $ref to a schema nothing provides, a path with no operations, an entity referenced by
|
|
6
|
+
* nothing. Run it after composing modules to know the merged platform is self-consistent, not just collision-free.
|
|
7
|
+
* Pure (no host) → unit-tested.
|
|
8
|
+
*/
|
|
9
|
+
import { buildAda } from "@suluk/core";
|
|
10
|
+
import type { OpenAPIv4Document, Request, SchemaOrRef, SecurityRequirement } from "@suluk/core";
|
|
11
|
+
import { schemaRefName } from "@suluk/builder";
|
|
12
|
+
|
|
13
|
+
export type ConvergeCode = "dangling-ref" | "undeclared-scheme" | "orphan-scope" | "empty-path" | "unreferenced-entity";
|
|
14
|
+
|
|
15
|
+
export interface ConvergeFinding {
|
|
16
|
+
code: ConvergeCode;
|
|
17
|
+
severity: "error" | "warn" | "info";
|
|
18
|
+
message: string;
|
|
19
|
+
where?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface ConvergeReport {
|
|
22
|
+
findings: ConvergeFinding[];
|
|
23
|
+
/** true ⇒ no error-severity findings — the contract is self-consistent */
|
|
24
|
+
clean: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const hasOwn = (o: object, k: string) => Object.prototype.hasOwnProperty.call(o, k);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The scope universe a scheme allows, for orphan-scope checking — or NULL when the scopes are not LOCALLY
|
|
31
|
+
* knowable (openIdConnect defines its scopes in a remote discovery document), so converge must not flag them.
|
|
32
|
+
* oauth2 → the union of its flows' declared scopes; apiKey/http/mutualTLS → the empty set (they declare none).
|
|
33
|
+
*/
|
|
34
|
+
function scopeUniverse(scheme: unknown): Set<string> | null {
|
|
35
|
+
const s = scheme as { type?: string; flows?: Record<string, { scopes?: Record<string, string> }> };
|
|
36
|
+
if (s?.type === "openIdConnect") return null; // scopes live in the OIDC discovery doc — not locally checkable
|
|
37
|
+
if (s?.type !== "oauth2") return new Set();
|
|
38
|
+
const out = new Set<string>();
|
|
39
|
+
for (const flow of Object.values(s.flows ?? {})) for (const name of Object.keys(flow?.scopes ?? {})) out.add(name);
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Every schema name targeted by a `#/components/schemas/X` $ref anywhere in the document (deep/escaped-safe). */
|
|
44
|
+
function referencedSchemas(doc: OpenAPIv4Document): Set<string> {
|
|
45
|
+
const names = new Set<string>();
|
|
46
|
+
const walk = (v: unknown): void => {
|
|
47
|
+
if (Array.isArray(v)) { v.forEach(walk); return; }
|
|
48
|
+
if (v && typeof v === "object") {
|
|
49
|
+
const o = v as Record<string, unknown>;
|
|
50
|
+
if (typeof o.$ref === "string") { const name = schemaRefName(o.$ref); if (name) names.add(name); }
|
|
51
|
+
for (const val of Object.values(o)) walk(val);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
walk(doc);
|
|
55
|
+
return names;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Audit a contract for coherence contradictions a clean merge can still leave behind. */
|
|
59
|
+
export function convergeContract(doc: OpenAPIv4Document): ConvergeReport {
|
|
60
|
+
const findings: ConvergeFinding[] = [];
|
|
61
|
+
const schemas = (doc.components?.schemas ?? {}) as Record<string, SchemaOrRef>;
|
|
62
|
+
const schemes = (doc.components?.securitySchemes ?? {}) as Record<string, unknown>;
|
|
63
|
+
|
|
64
|
+
// dangling $refs — a reference to a schema nothing provides
|
|
65
|
+
const referenced = referencedSchemas(doc);
|
|
66
|
+
for (const name of referenced) if (!hasOwn(schemas, name)) findings.push({ code: "dangling-ref", severity: "error", message: `$ref to schema "${name}" — nothing provides it`, where: name });
|
|
67
|
+
|
|
68
|
+
// security coherence — every required scheme is declared, and every required scope is declared by that scheme.
|
|
69
|
+
// Covers BOTH path operations AND webhooks (both carry security; the dangling-ref walk already covers webhooks).
|
|
70
|
+
const secured = [
|
|
71
|
+
...buildAda(doc).operations.map((o) => ({ name: o.name, security: o.request.security as SecurityRequirement[] | undefined })),
|
|
72
|
+
...Object.entries((doc as { webhooks?: Record<string, Request> }).webhooks ?? {}).map(([name, req]) => ({ name, security: req.security as SecurityRequirement[] | undefined })),
|
|
73
|
+
];
|
|
74
|
+
for (const op of secured) {
|
|
75
|
+
for (const req of op.security ?? []) {
|
|
76
|
+
for (const [schemeName, scopes] of Object.entries(req)) {
|
|
77
|
+
if (!hasOwn(schemes, schemeName)) {
|
|
78
|
+
findings.push({ code: "undeclared-scheme", severity: "error", message: `operation "${op.name}" requires security scheme "${schemeName}", which is not declared in components.securitySchemes`, where: op.name });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const universe = scopeUniverse(schemes[schemeName]);
|
|
82
|
+
if (universe && Array.isArray(scopes)) for (const s of scopes) if (!universe.has(s)) findings.push({ code: "orphan-scope", severity: "error", message: `operation "${op.name}" requires scope "${s}", which scheme "${schemeName}" does not declare`, where: op.name });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// empty path items — a path that declares no operations
|
|
88
|
+
for (const [p, pi] of Object.entries(doc.paths ?? {})) {
|
|
89
|
+
if (!Object.keys((pi as { requests?: object }).requests ?? {}).length) findings.push({ code: "empty-path", severity: "error", message: `path "${p}" declares no operations`, where: p });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// unreferenced entities — a schema nothing $refs (dead weight; info, not an error)
|
|
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
|
+
|
|
95
|
+
return { findings, clean: !findings.some((f) => f.severity === "error") };
|
|
96
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -14,6 +14,8 @@ export type { DeployPlan, DeployStep, DeployProvider } from "@suluk/deploy";
|
|
|
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
16
|
export { crossCut, documentScopes, defaultViewers, type Viewer, type ViewerView, type GatedOp, type CrossCut } from "./crosscut";
|
|
17
|
+
// converge: a coherence audit over a whole contract — the cross-cutting contradictions a clean merge leaves behind.
|
|
18
|
+
export { convergeContract, type ConvergeReport, type ConvergeFinding, type ConvergeCode } from "./converge";
|
|
17
19
|
// cost formatting, re-exported so the extension shell can render a live /cost ledger without a direct @suluk/cost dep.
|
|
18
20
|
export { formatMicroUsd, summarize, type CostSummary } from "@suluk/cost";
|
|
19
21
|
// modules (C021): install a contract fragment into the hub doc — the cockpit then re-projects it for free.
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { parseDocument } from "@suluk/core";
|
|
3
|
+
import { installModule, ECOMMERCE, composeModules, resolveTemplate, STACK_TEMPLATES } from "@suluk/builder";
|
|
4
|
+
import { convergeContract } from "../src/converge";
|
|
5
|
+
|
|
6
|
+
const doc = (s: string) => parseDocument(s);
|
|
7
|
+
|
|
8
|
+
describe("convergeContract — coherence over a whole contract", () => {
|
|
9
|
+
test("a coherent contract is clean", () => {
|
|
10
|
+
const d = doc(`openapi: 4.0.0-candidate
|
|
11
|
+
info: { title: T, version: 1.0.0 }
|
|
12
|
+
paths:
|
|
13
|
+
"pet":
|
|
14
|
+
requests:
|
|
15
|
+
listPets: { method: get, responses: { ok: { status: 200, contentSchema: { $ref: "#/components/schemas/Pet" } } } }
|
|
16
|
+
createPet: { method: post, security: [ { auth: [ "write:pets" ] } ], responses: { created: { status: 201 } } }
|
|
17
|
+
components:
|
|
18
|
+
schemas: { Pet: { type: object, properties: { name: { type: string } } } }
|
|
19
|
+
securitySchemes: { auth: { type: oauth2, flows: { implicit: { authorizationUrl: "https://x", scopes: { "write:pets": "w" } } } } }
|
|
20
|
+
`);
|
|
21
|
+
const r = convergeContract(d);
|
|
22
|
+
expect(r.clean).toBe(true);
|
|
23
|
+
expect(r.findings.filter((f) => f.severity === "error")).toHaveLength(0);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("an operation requiring an UNDECLARED scope is an error", () => {
|
|
27
|
+
const d = doc(`openapi: 4.0.0-candidate
|
|
28
|
+
info: { title: T, version: 1.0.0 }
|
|
29
|
+
paths: { "pet": { requests: { createPet: { method: post, security: [ { auth: [ "delete:everything" ] } ], responses: { ok: { status: 201 } } } } } }
|
|
30
|
+
components: { securitySchemes: { auth: { type: oauth2, flows: { implicit: { authorizationUrl: "https://x", scopes: { "write:pets": "w" } } } } } }
|
|
31
|
+
`);
|
|
32
|
+
const r = convergeContract(d);
|
|
33
|
+
expect(r.clean).toBe(false);
|
|
34
|
+
expect(r.findings.some((f) => f.code === "orphan-scope" && f.message.includes("delete:everything"))).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("an operation requiring an UNDECLARED scheme is an error", () => {
|
|
38
|
+
const d = doc(`openapi: 4.0.0-candidate
|
|
39
|
+
info: { title: T, version: 1.0.0 }
|
|
40
|
+
paths: { "pet": { requests: { createPet: { method: post, security: [ { ghostAuth: [] } ], responses: { ok: { status: 201 } } } } } }
|
|
41
|
+
components: { securitySchemes: {} }
|
|
42
|
+
`);
|
|
43
|
+
const r = convergeContract(d);
|
|
44
|
+
expect(r.findings.some((f) => f.code === "undeclared-scheme" && f.message.includes("ghostAuth"))).toBe(true);
|
|
45
|
+
expect(r.clean).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("a $ref to a missing schema is a dangling-ref error", () => {
|
|
49
|
+
const d = doc(`openapi: 4.0.0-candidate
|
|
50
|
+
info: { title: T, version: 1.0.0 }
|
|
51
|
+
paths: { "pet": { requests: { getPet: { method: get, responses: { ok: { status: 200, contentSchema: { $ref: "#/components/schemas/Ghost" } } } } } } }
|
|
52
|
+
components: { schemas: {} }
|
|
53
|
+
`);
|
|
54
|
+
const r = convergeContract(d);
|
|
55
|
+
expect(r.findings.some((f) => f.code === "dangling-ref" && f.where === "Ghost")).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("an unreferenced entity is reported as info (not an error)", () => {
|
|
59
|
+
const d = doc(`openapi: 4.0.0-candidate
|
|
60
|
+
info: { title: T, version: 1.0.0 }
|
|
61
|
+
paths: { "pet": { requests: { ping: { method: get, responses: { ok: { status: 200 } } } } } }
|
|
62
|
+
components: { schemas: { Orphan: { type: object } } }
|
|
63
|
+
`);
|
|
64
|
+
const r = convergeContract(d);
|
|
65
|
+
expect(r.clean).toBe(true); // info, not error
|
|
66
|
+
expect(r.findings.some((f) => f.code === "unreferenced-entity" && f.where === "Orphan")).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("an openIdConnect scheme's scopes are NOT locally checkable — no false orphan-scope", () => {
|
|
70
|
+
const d = doc(`openapi: 4.0.0-candidate
|
|
71
|
+
info: { title: T, version: 1.0.0 }
|
|
72
|
+
paths: { "x": { requests: { getX: { method: get, security: [ { oidc: [ "profile", "email" ] } ], responses: { ok: { status: 200 } } } } } }
|
|
73
|
+
components: { securitySchemes: { oidc: { type: openIdConnect, openIdConnectUrl: "https://idp/.well-known/openid-configuration" } } }
|
|
74
|
+
`);
|
|
75
|
+
const r = convergeContract(d);
|
|
76
|
+
expect(r.findings.filter((f) => f.code === "orphan-scope")).toHaveLength(0);
|
|
77
|
+
expect(r.clean).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("a DEEP $ref (.../Order/properties/id) resolves to its root — not a false dangling-ref", () => {
|
|
81
|
+
const d = doc(`openapi: 4.0.0-candidate
|
|
82
|
+
info: { title: T, version: 1.0.0 }
|
|
83
|
+
paths: { "o": { requests: { getId: { method: get, responses: { ok: { status: 200, contentSchema: { $ref: "#/components/schemas/Order/properties/id" } } } } } } }
|
|
84
|
+
components: { schemas: { Order: { type: object, properties: { id: { type: integer } } } } }
|
|
85
|
+
`);
|
|
86
|
+
const r = convergeContract(d);
|
|
87
|
+
expect(r.findings.some((f) => f.code === "dangling-ref")).toBe(false);
|
|
88
|
+
expect(r.findings.some((f) => f.code === "unreferenced-entity" && f.where === "Order")).toBe(false); // Order IS referenced
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("a security issue inside a WEBHOOK is caught (not just path operations)", () => {
|
|
92
|
+
const d = doc(`openapi: 4.0.0-candidate
|
|
93
|
+
info: { title: T, version: 1.0.0 }
|
|
94
|
+
paths: {}
|
|
95
|
+
webhooks: { onEvent: { method: post, security: [ { ghostHook: [] } ], responses: { ok: { status: 200 } } } }
|
|
96
|
+
components: { securitySchemes: {} }
|
|
97
|
+
`);
|
|
98
|
+
expect(convergeContract(d).findings.some((f) => f.code === "undeclared-scheme" && f.message.includes("ghostHook"))).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("a malformed (non-array) scope value never iterates character-by-character", () => {
|
|
102
|
+
const d = doc(`openapi: 4.0.0-candidate
|
|
103
|
+
info: { title: T, version: 1.0.0 }
|
|
104
|
+
paths: { "x": { requests: { getX: { method: get, security: [ { auth: "write" } ], responses: { ok: { status: 200 } } } } } }
|
|
105
|
+
components: { securitySchemes: { auth: { type: oauth2, flows: { implicit: { authorizationUrl: "https://x", scopes: { write: "w" } } } } } }
|
|
106
|
+
`);
|
|
107
|
+
const r = convergeContract(d);
|
|
108
|
+
expect(r.findings.filter((f) => f.code === "orphan-scope")).toHaveLength(0); // not w,r,i,t,e
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("a freshly composed platform (auth + ecommerce + crm) converges clean", () => {
|
|
112
|
+
const empty = doc(`openapi: 4.0.0-candidate
|
|
113
|
+
info: { title: P, version: 1.0.0 }
|
|
114
|
+
paths: {}
|
|
115
|
+
components: { schemas: {} }`);
|
|
116
|
+
const composed = composeModules(empty, resolveTemplate(STACK_TEMPLATES.find((t) => t.name === "Everything")!).modules);
|
|
117
|
+
expect(composed.ok).toBe(true);
|
|
118
|
+
expect(convergeContract(composed.doc).clean).toBe(true); // no dangling refs, no orphan scopes across modules
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("a module installed WITHOUT its required entity would dangle — but install refuses it; converge catches a hand-built dangle", () => {
|
|
122
|
+
// installModule refuses ecommerce without User; convergeContract independently flags a doc someone hand-merged wrong
|
|
123
|
+
const broken = installModule(doc(`openapi: 4.0.0-candidate
|
|
124
|
+
info: { title: H, version: 1.0.0 }
|
|
125
|
+
paths: {}
|
|
126
|
+
components: { schemas: { User: { type: object } } }`), ECOMMERCE).doc;
|
|
127
|
+
// remove User after the fact (simulating a bad hand-edit) → Order.customer now dangles
|
|
128
|
+
delete (broken.components!.schemas as Record<string, unknown>).User;
|
|
129
|
+
expect(convergeContract(broken).findings.some((f) => f.code === "dangling-ref" && f.where === "User")).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
});
|