backend-skeleton 1.0.0-beta.9 → 1.0.0

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.
Files changed (45) hide show
  1. package/README.md +122 -12
  2. package/bin/bskel.mjs +564 -15
  3. package/contracts/emit.mjs +5 -1
  4. package/contracts/export.mjs +26 -3
  5. package/contracts/openapi.mjs +29 -3
  6. package/handles/_engine.mjs +79 -29
  7. package/handles/providers/java-spring/plan.mjs +22 -9
  8. package/handles/providers/java-spring/templates/HandleController.java.tmpl +7 -3
  9. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +12 -6
  10. package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +101 -39
  11. package/handles/providers/typescript-express/emit.mjs +23 -23
  12. package/handles/providers/typescript-express/observe.mjs +101 -0
  13. package/handles/providers/typescript-express/templates/contractCheck.ts.tmpl +136 -0
  14. package/handles/providers/typescript-express/templates/observeContract.ts.tmpl +146 -0
  15. package/handles/providers/typescript-express/templates/observedSchema.ts.tmpl +116 -0
  16. package/lib/attest.mjs +40 -0
  17. package/lib/cli.mjs +125 -3
  18. package/lib/cross-feature-collisions.mjs +286 -0
  19. package/lib/diff.mjs +35 -0
  20. package/lib/fsutil.mjs +7 -2
  21. package/lib/gate-definitions.mjs +85 -1
  22. package/lib/gates.mjs +5 -1
  23. package/lib/http-server.mjs +192 -6
  24. package/lib/lock.mjs +68 -15
  25. package/lib/patch-kinds.mjs +52 -0
  26. package/lib/patch-transactions.mjs +206 -0
  27. package/lib/serve-ui.html +211 -0
  28. package/lib/workflow.mjs +31 -3
  29. package/package.json +5 -2
  30. package/scanners/adapters/java-spring.mjs +6 -0
  31. package/scanners/adapters/python-fastapi.mjs +9 -1
  32. package/scanners/adapters/typescript-express.mjs +6 -0
  33. package/scanners/db/ddl-apply.mjs +253 -0
  34. package/scanners/db/introspect.mjs +61 -32
  35. package/scanners/db/migrations.mjs +73 -18
  36. package/schemas/cross-feature-report.schema.json +66 -0
  37. package/schemas/cross-feature-resolution.schema.json +28 -0
  38. package/schemas/gate-attestation.schema.json +22 -0
  39. package/schemas/gate-export.schema.json +58 -0
  40. package/schemas/patch-transaction.schema.json +182 -0
  41. package/schemas/scan-report.schema.json +6 -4
  42. package/schemas/stack-choice.schema.json +12 -1
  43. package/stack/apply.mjs +4 -1
  44. package/stack/catalog/ngrok.yml +8 -2
  45. package/stack/config-apply.mjs +168 -0
@@ -0,0 +1,101 @@
1
+ // D-runtime-conformance-receipts: the emit-side half of opt-in runtime contract-conformance
2
+ // checking for typescript-express. Mirrors handles/providers/python-fastapi/observe.mjs's own
3
+ // shape -- observe and handles are orthogonal capabilities that happen to share the same repo-wide
4
+ // "generated infra" pattern, not the same feature. See DECISIONS.md for the full WHY, including the
5
+ // TS-specific response-body-capture note (res.json patch + res.on('finish', ...), never a wrapped
6
+ // return value the way java's @Around/python's `await fn(...)` are) the generated
7
+ // observeContract.ts itself implements.
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { emitUnits, unifiedDiff } from '../../_engine.mjs';
12
+ import { sha256File } from '../../../lib/fsutil.mjs';
13
+ import { specPath } from '../../../lib/paths.mjs';
14
+ import { projectOperation } from '../../observe-schema-projection.mjs';
15
+
16
+ const PROVIDER_ROOT = path.dirname(fileURLToPath(import.meta.url));
17
+ const TEMPLATES_DIR = path.join(PROVIDER_ROOT, 'templates');
18
+
19
+ // Repo-wide, shared across every feature that ever runs `bskel observe emit` -- observedSchema.ts
20
+ // discovers every schemas/*.observed-schema.json file at MODULE LOAD time rather than being
21
+ // regenerated per feature, so these three files are true infra (create-once-per-repo, all-or-nothing
22
+ // conflict unit), same treatment INFRA_FILES gives handles' own handlesDir infra files (codec.ts/
23
+ // registry.ts/router.ts). Three files, not python's four -- TS/Node has no __init__.py-equivalent
24
+ // package marker a plain relative-import directory needs to be importable, so there is no
25
+ // __init__.ts-shaped file to carry over. None of the three need any {{VAR}} substitution -- observe
26
+ // stays decoupled from handles/, cross-imports between them are relative `from './contractCheck'`.
27
+ const INFRA_FILES = [
28
+ { template: 'contractCheck.ts.tmpl', target: 'contractCheck.ts' },
29
+ { template: 'observedSchema.ts.tmpl', target: 'observedSchema.ts' },
30
+ { template: 'observeContract.ts.tmpl', target: 'observeContract.ts' },
31
+ ];
32
+
33
+ function render(templatePath, vars) {
34
+ let content = fs.readFileSync(templatePath, 'utf8');
35
+ for (const [key, value] of Object.entries(vars)) {
36
+ content = content.replaceAll(`{{${key}}}`, String(value));
37
+ }
38
+ return content;
39
+ }
40
+
41
+ function writeUnit(target, content) {
42
+ fs.mkdirSync(path.dirname(target), { recursive: true });
43
+ fs.writeFileSync(target, content);
44
+ }
45
+
46
+ // See DECISIONS.md D-runtime-conformance-receipts. `contract` is the already-loaded, already
47
+ // schema-validated feature contract (bin/bskel.mjs's loadContract) -- this function does not read
48
+ // specs/ itself. `plan` is the already-computed typescript-express resource plan (bin/bskel.mjs
49
+ // calls planTypeScriptExpress() before this, the same --module dependency python-fastapi's own
50
+ // observe emit already established) -- only `plan.srcRoot` is used here, `plan.resources`/
51
+ // `plan.notes` are computed and simply unused, same tolerance emitUnits() already extends to
52
+ // java's/python's own `resolverUnits: []`. There is no per-resource generated file here (a human
53
+ // inserts observeContract('...') directly into an existing route's own middleware array), so
54
+ // emitUnits()'s resolver/orphan machinery has nothing to do.
55
+ export function emitObserveTypeScriptExpress({ repoRoot, featureId, contract, plan, force = false, reason = '', dryRun = false, computeDiff = false }) {
56
+ const observeDir = path.join(plan.srcRoot, 'observe');
57
+
58
+ const infraUnits = INFRA_FILES.map((f) => ({
59
+ id: f.template,
60
+ templatePath: path.join(TEMPLATES_DIR, f.template),
61
+ targetAbs: path.join(observeDir, f.target),
62
+ rendered: render(path.join(TEMPLATES_DIR, f.template), {}),
63
+ }));
64
+
65
+ const result = emitUnits({ repoRoot, featureId, provider: 'typescript-express', force, reason, infraUnits, resolverUnits: [], orphanScan: null, dryRun, computeDiff });
66
+
67
+ // The projected observed-schema.json resource -- regenerated unconditionally every run, like
68
+ // handles' own migration.sql (and both other providers' own observe schema resource), for the
69
+ // identical reason: nobody hand-finishes a generated data file, so O2-style conflict tracking
70
+ // buys nothing here. `kind: 'spec'` matches migration.sql's own action-reporting convention --
71
+ // NOT the `kind: 'infra'` A13 gave resolvers_index.ts, which was a genuinely hand-editable
72
+ // barrel; this is a generated data file, same class as migration.sql, not that one.
73
+ const operations = {};
74
+ for (const [opId, opContract] of Object.entries(contract.operations)) {
75
+ operations[opId] = projectOperation(opContract);
76
+ }
77
+ const contractRef = sha256File(specPath(repoRoot, featureId, 'contracts', `${featureId}.schema.json`));
78
+ const schemaContent = `${JSON.stringify({ sbf_observed_schema: '1', feature_id: featureId, feature_uid: contract.feature_uid, contract_ref: contractRef, operations }, null, '\t')}\n`;
79
+ const schemaPath = path.join(observeDir, 'schemas', `${featureId}.observed-schema.json`);
80
+ const schemaRelPath = path.relative(repoRoot, schemaPath);
81
+ const schemaDiskContent = fs.existsSync(schemaPath) ? fs.readFileSync(schemaPath, 'utf8') : null;
82
+ const schemaAction = schemaDiskContent === null ? 'create' : (schemaDiskContent === schemaContent ? 'unchanged' : 'update');
83
+ if (!dryRun) writeUnit(schemaPath, schemaContent);
84
+ result.written.push(schemaRelPath);
85
+ const schemaActionEntry = { path: schemaRelPath, kind: 'spec', action: schemaAction };
86
+ if (computeDiff && schemaAction === 'update') schemaActionEntry.diff = unifiedDiff(schemaRelPath, schemaDiskContent, schemaContent);
87
+ result.actions.push(schemaActionEntry);
88
+
89
+ return {
90
+ ...result,
91
+ postEmitNotes: [
92
+ 'NOT done automatically: route observeContract\'s own receipt sink (defaults to one JSON line per receipt via process.stdout.write) to wherever you want receipt lines collected -- bskel never edits your logging/process-supervisor config. Override it at app startup ("import { setReceiptSink } from \'./observe/observeContract\';") to point it at your own log pipeline, then point `bskel observe import --receipts <path>` at whatever that ends up as.',
93
+ 'If your build compiles TypeScript to a separate output directory (`tsc --outDir dist`), make sure observe/schemas/*.json is copied alongside the compiled observedSchema.js -- tsc only compiles .ts files, it does not copy plain data files into the output tree, and observedSchema.ts discovers its schemas relative to its OWN compiled location at runtime (__dirname). A target app that only ever runs from src/ (ts-node, tsx) needs no extra step here.',
94
+ `Contract-conformance checking only covers path params always, plus a bounded slice of request/response/error body shape -- and only when this contract was emitted with --openapi-file. See the emitted ${path.relative(repoRoot, schemaPath)}'s own "unsupported" markers for exactly what is skipped for this feature.`,
95
+ 'NOT done automatically: insert observeContract(\'<operationId>\') into whichever existing route\'s own middleware array/argument list you want observed (e.g. `router.get(path, [checkJwt, observeContract(\'op-id\')], handler)`) -- nothing is inserted for you (D-resolver-scope: never guess which route implements which operation).',
96
+ 'error_class is never populated in this provider\'s receipts (always omitted) -- Express middleware runs BEFORE the route handler and is structurally unable to observe a thrown error the way java\'s @Around/python\'s except block can (by the time a handler throws or calls next(err), this middleware\'s own call frame has already returned). See DECISIONS.md D-runtime-conformance-receipts.',
97
+ 'Response-body checking only covers a handler that calls res.json(...) or res.send(<object>) (Express\'s own res.send delegates to res.json for a plain-object body) -- a handler that calls res.send(<string>)/res.end(...) directly, or whose response is produced by Express\'s own default/generic error handler, has its response check silently skipped, never guessed.',
98
+ 'OpenAPI reconciliation for this adapter matches scanned Express route strings EXACTLY against the OpenAPI document\'s own path keys (contracts/openapi.mjs has no ":id" <-> "{id}" translation) -- a real, standards-compliant OpenAPI document (which must use "{id}") will not match a scanned ":id"/":id([0-9]+)" route unless the document\'s own path key happens to already read that way. Unlike python-fastapi, this is not "for free."',
99
+ ],
100
+ };
101
+ }
@@ -0,0 +1,136 @@
1
+ // Generated by backend-skeleton (bskel observe emit). Do not hand-edit -- change the source
2
+ // template and regenerate.
3
+ //
4
+ // D-runtime-conformance-receipts: pure, dependency-free -- the only place a real observed value is
5
+ // ever looked at. `Violation.message` MUST NEVER interpolate the observed value itself, only the
6
+ // JSON Pointer and constraint kind ("required"/"type"/"pattern"/"unsupported") -- a real payload
7
+ // value must never leave this process, structurally, not by convention (see DECISIONS.md
8
+ // D-runtime-conformance-receipts, Decision A).
9
+ //
10
+ // Every check here matches exactly what handles/observe-schema-projection.mjs's own projection
11
+ // produces (shared with java-spring/python-fastapi, nothing TS-specific about the shape) --
12
+ // required-field presence, scalar (string/number/integer/boolean) type, and a pre-compiled regex
13
+ // pattern (compiled once by observedSchema.ts, not here). Nothing deeper: a property this checker
14
+ // cannot evaluate is marked `unsupported` at its own pointer by the projection already, never
15
+ // guessed at here.
16
+
17
+ export interface ObservedProperty {
18
+ type: string | null;
19
+ pattern: RegExp | null;
20
+ }
21
+
22
+ export interface ObservedObject {
23
+ required: string[];
24
+ properties: Record<string, ObservedProperty>;
25
+ unsupported: string[];
26
+ }
27
+
28
+ export interface Violation {
29
+ pointer: string;
30
+ keyword: 'required' | 'type' | 'pattern' | 'status' | 'unsupported';
31
+ message: string;
32
+ }
33
+
34
+ function matchesType(type: string, value: unknown): boolean {
35
+ switch (type) {
36
+ case 'string':
37
+ return typeof value === 'string';
38
+ case 'boolean':
39
+ return typeof value === 'boolean';
40
+ case 'integer':
41
+ return typeof value === 'number' && Number.isInteger(value);
42
+ case 'number':
43
+ return typeof value === 'number';
44
+ default:
45
+ return true; // an unrecognized type name is not this checker's job to enforce
46
+ }
47
+ }
48
+
49
+ function checkScalar(prop: ObservedProperty, value: unknown, pointer: string): Violation[] {
50
+ const violations: Violation[] = [];
51
+ if (prop.type !== null && !matchesType(prop.type, value)) {
52
+ violations.push({ pointer, keyword: 'type', message: `expected type "${prop.type}"` });
53
+ return violations;
54
+ }
55
+ if (prop.pattern !== null && typeof value === 'string' && !prop.pattern.test(value)) {
56
+ violations.push({ pointer, keyword: 'pattern', message: 'does not match the required pattern' });
57
+ }
58
+ return violations;
59
+ }
60
+
61
+ /**
62
+ * Checks `actual` (a request/response/error JSON body) against `schema`, an already-compiled
63
+ * entry from observedSchema.get(...)'s own request/response/error field. `basePointer` is
64
+ * prefixed onto every violation's own pointer ("" for the root, "/body" for a wrapped body).
65
+ */
66
+ export function checkObject(schema: ObservedObject | null, actual: unknown, basePointer = ''): Violation[] {
67
+ const violations: Violation[] = [];
68
+ if (schema === null) return violations;
69
+ for (const pointer of schema.unsupported) {
70
+ violations.push({ pointer: `${basePointer}${pointer}`, keyword: 'unsupported', message: 'schema at this pointer is deeper than this checker supports for now -- not validated' });
71
+ }
72
+ if (actual === null || actual === undefined) {
73
+ if (schema.required.length > 0 || Object.keys(schema.properties).length > 0) {
74
+ violations.push({ pointer: basePointer, keyword: 'type', message: 'expected an object, got none' });
75
+ }
76
+ return violations;
77
+ }
78
+ if (typeof actual !== 'object' || Array.isArray(actual)) {
79
+ violations.push({ pointer: basePointer, keyword: 'type', message: 'expected an object' });
80
+ return violations;
81
+ }
82
+ const record = actual as Record<string, unknown>;
83
+ for (const field of schema.required) {
84
+ if (!(field in record)) {
85
+ violations.push({ pointer: `${basePointer}/${field}`, keyword: 'required', message: 'missing required field' });
86
+ }
87
+ }
88
+ for (const [field, prop] of Object.entries(schema.properties)) {
89
+ if (!(field in record)) continue; // required-ness already checked above; an absent optional field is not a violation
90
+ violations.push(...checkScalar(prop, record[field], `${basePointer}/${field}`));
91
+ }
92
+ return violations;
93
+ }
94
+
95
+ /**
96
+ * `actual` is Express's own `req.params`-shaped name->value map. Values are coerced to string
97
+ * before a pattern check (Express's own router always produces string param values for a named
98
+ * segment; a defensive String() coercion here matches java's/python's own equivalent).
99
+ */
100
+ export function checkPathParams(schema: ObservedObject | null, actual: Record<string, unknown>): Violation[] {
101
+ const violations: Violation[] = [];
102
+ if (schema === null) return violations;
103
+ for (const pointer of schema.unsupported) {
104
+ violations.push({ pointer: `/pathParams${pointer}`, keyword: 'unsupported', message: 'schema at this pointer is deeper than this checker supports for now -- not validated' });
105
+ }
106
+ for (const field of schema.required) {
107
+ const value = actual[field];
108
+ if (value === undefined || value === null) {
109
+ violations.push({ pointer: `/pathParams/${field}`, keyword: 'required', message: 'missing required path parameter' });
110
+ continue;
111
+ }
112
+ const prop = schema.properties[field];
113
+ if (prop?.pattern != null && !prop.pattern.test(String(value))) {
114
+ violations.push({ pointer: `/pathParams/${field}`, keyword: 'pattern', message: 'does not match the required pattern' });
115
+ }
116
+ }
117
+ return violations;
118
+ }
119
+
120
+ /**
121
+ * A8: `statusKey` is a literal source-document status key ("200", a range like "4XX", or
122
+ * "default") -- contracts/emit.mjs's own `sourceResponses` vocabulary
123
+ * (schemas/feature-contract.schema.json's `^(?:[1-5](?:[0-9]{2}|XX)|default)$` pattern). Matching
124
+ * a REAL observed status against this key is a bounded, mechanical string comparison, not
125
+ * JSON-Schema interpretation.
126
+ */
127
+ export function statusMatches(statusKey: string, actualStatus: number): boolean {
128
+ if (statusKey === 'default') return true;
129
+ const actual = String(actualStatus);
130
+ if (statusKey.length !== 3 || actual.length !== 3) return false;
131
+ for (let i = 0; i < 3; i++) {
132
+ const k = statusKey[i];
133
+ if (k !== 'X' && k !== actual[i]) return false;
134
+ }
135
+ return true;
136
+ }
@@ -0,0 +1,146 @@
1
+ // Generated by backend-skeleton (bskel observe emit). Do not hand-edit -- change the source
2
+ // template and regenerate.
3
+ //
4
+ // D-runtime-conformance-receipts: the opt-in AUTOMATIC half of runtime contract-conformance
5
+ // checking -- exists only for a route a human has chosen to insert observeContract('...') into.
6
+ // Never activates on anything else, and a failure here is ALWAYS logged and swallowed, never
7
+ // allowed to affect the real request/response (best-effort observability, same posture java's
8
+ // ContractObservationAspect / python's observe_contract already establish).
9
+ //
10
+ // Combines java's separate @ObserveContract annotation + ContractObservationAspect interceptor,
11
+ // and mirrors python's single-decorator choice: Express middleware IS this ecosystem's own
12
+ // interception mechanism, so there is no separate "declare a marker" vs. "implement the
13
+ // interceptor" split worth preserving here either.
14
+ //
15
+ // STRUCTURAL DIFFERENCE FROM JAVA/PYTHON, not a deferred gap: Express middleware runs BEFORE the
16
+ // route handler and never receives its return value -- there is no `joinPoint.proceed()`/
17
+ // `await fn(...)` to intercept. Response-body capture instead patches `res.json` on the per-request
18
+ // `res` instance (confirmed against the real express@4.18.2 source: `res.send(<plainObject>)`
19
+ // itself delegates to `this.json(...)`, so this single patch point covers both call styles) and
20
+ // reads the FINAL, real `res.statusCode` inside `res.on('finish', ...)` -- which fires exactly
21
+ // once regardless of whether the handler succeeded, threw, or Express's own default/custom error
22
+ // handling ultimately produced the response. A handler that calls `res.send(<string>)`/
23
+ // `res.end(...)` directly, or whose response is produced by Express's own generic error handler,
24
+ // never touches the patched `res.json` -- the response check is silently SKIPPED for that request,
25
+ // never guessed (same "unsupported, not guessed" discipline as everywhere else in this feature).
26
+ //
27
+ // error_class is NEVER populated (always omitted) -- by the time a handler throws or calls
28
+ // next(err), this middleware's own call frame has already returned; there is no catch-block
29
+ // equivalent available the way java's @Around/python's except block have. See DECISIONS.md
30
+ // D-runtime-conformance-receipts.
31
+ //
32
+ // Path-parameter checking needs no config (Express's own req.params is already keyed by the real
33
+ // route's own :name segments). Request-body checking is AUTOMATIC whenever op.body !== 'false'
34
+ // (req.body is Express's own unambiguous body once body-parsing middleware has run -- no
35
+ // python-style explicit body_param argument needed here).
36
+ //
37
+ // Example:
38
+ // router.get('/users/:id', [checkJwt, observeContract('users-show')], showUser);
39
+
40
+ import type { RequestHandler, Request, Response } from 'express';
41
+ import * as contractCheck from './contractCheck';
42
+ import * as observedSchema from './observedSchema';
43
+ import type { ObservedOperation } from './observedSchema';
44
+ import type { Violation } from './contractCheck';
45
+
46
+ /**
47
+ * Where a receipt JSON line is delivered -- defaults to stdout (one line per receipt), matching
48
+ * java's/python's own "a dedicated, independently-routable channel a human points `bskel observe
49
+ * import` at" property without inventing a logging framework dependency. Override at app startup
50
+ * (`import { setReceiptSink } from './observe/observeContract'; setReceiptSink((line) => ...);`)
51
+ * to route it wherever you want -- bskel never chooses your delivery path (D-config-patch's own
52
+ * boundary).
53
+ */
54
+ let receiptSink: (line: string) => void = (line) => {
55
+ process.stdout.write(`${line}\n`);
56
+ };
57
+
58
+ export function setReceiptSink(sink: (line: string) => void): void {
59
+ receiptSink = sink;
60
+ }
61
+
62
+ function checkRequest(op: ObservedOperation, req: Request): Violation[] {
63
+ const violations = contractCheck.checkPathParams(op.pathParams, req.params as Record<string, unknown>);
64
+ if (op.body !== 'false' && req.body !== undefined) {
65
+ violations.push(...contractCheck.checkObject(op.request, req.body, '/body'));
66
+ }
67
+ return violations;
68
+ }
69
+
70
+ function checkResponse(op: ObservedOperation, body: unknown): Violation[] {
71
+ return contractCheck.checkObject(op.response, body, '/body');
72
+ }
73
+
74
+ function emitReceipt(op: ObservedOperation, operationId: string, requestViolations: Violation[], responseViolations: Violation[], status: number): void {
75
+ try {
76
+ const allViolations = [...requestViolations, ...responseViolations];
77
+ if (op.statuses) {
78
+ const matched = op.statuses.some((key) => contractCheck.statusMatches(key, status));
79
+ if (!matched) {
80
+ allViolations.push({ pointer: '/status', keyword: 'status', message: `observed status ${status} is not among the documented status keys` });
81
+ }
82
+ }
83
+ const receipt: Record<string, unknown> = {
84
+ feature_id: op.featureId,
85
+ feature_uid: op.featureUid,
86
+ operation_id: operationId,
87
+ contract_ref: op.contractRef,
88
+ verb: op.verb,
89
+ status,
90
+ recorded_at: new Date().toISOString(),
91
+ violations: allViolations,
92
+ };
93
+ receiptSink(JSON.stringify(receipt));
94
+ } catch (err) {
95
+ console.warn(`observeContract: could not emit a receipt for "${operationId}"`, err);
96
+ }
97
+ }
98
+
99
+ export function observeContract(operationId: string): RequestHandler {
100
+ return (req: Request, res: Response, next) => {
101
+ let op: ObservedOperation | undefined;
102
+ try {
103
+ op = observedSchema.get(operationId);
104
+ } catch {
105
+ op = undefined;
106
+ }
107
+ if (!op) {
108
+ console.warn(`observeContract: no observed schema loaded for operationId "${operationId}" -- skipping, the request proceeds unaffected`);
109
+ next();
110
+ return;
111
+ }
112
+ const activeOp = op;
113
+
114
+ let requestViolations: Violation[] = [];
115
+ try {
116
+ requestViolations = checkRequest(activeOp, req);
117
+ } catch (err) {
118
+ console.warn(`observeContract: could not complete the request check for "${operationId}" -- the request proceeds unaffected`, err);
119
+ requestViolations = [];
120
+ }
121
+
122
+ try {
123
+ let capturedBody: unknown;
124
+ let bodyCaptured = false;
125
+ const originalJson = res.json.bind(res);
126
+ res.json = ((body?: any): Response => {
127
+ capturedBody = body;
128
+ bodyCaptured = true;
129
+ return originalJson(body);
130
+ }) as typeof res.json;
131
+
132
+ res.on('finish', () => {
133
+ try {
134
+ const responseViolations = bodyCaptured ? checkResponse(activeOp, capturedBody) : [];
135
+ emitReceipt(activeOp, operationId, requestViolations, responseViolations, res.statusCode);
136
+ } catch (err) {
137
+ console.warn(`observeContract: could not emit a receipt for "${operationId}" -- the response was already sent unaffected`, err);
138
+ }
139
+ });
140
+ } catch (err) {
141
+ console.warn(`observeContract: could not attach response observation for "${operationId}" -- the request proceeds unaffected`, err);
142
+ }
143
+
144
+ next();
145
+ };
146
+ }
@@ -0,0 +1,116 @@
1
+ // Generated by backend-skeleton (bskel observe emit). Do not hand-edit -- change the source
2
+ // template and regenerate.
3
+ //
4
+ // D-runtime-conformance-receipts: loads every `<feature-id>.observed-schema.json` file under this
5
+ // package's own `schemas/` directory (one per feature `bskel observe emit --module ...` has been
6
+ // run against) at MODULE LOAD time and merges them into one flat map keyed by operationId -- a
7
+ // deployed app has no access to specs/ at runtime, same reasoning java's ResourceResolver /
8
+ // python's observed_schema.py already state, just a plain data file here instead of a baked string
9
+ // constant. Node's own CommonJS module cache gives this the same "loaded once, shared everywhere"
10
+ // property java's @Component / python's module-level `_OPERATIONS` get.
11
+ //
12
+ // `schemas/` is a plain directory discovered via `fs.readdirSync`, not a bundler-aware asset
13
+ // pipeline -- this ecosystem's own target apps only ever run compiled output sitting next to this
14
+ // same compiled file (a plain `tsc` deployment), so `__dirname` (CommonJS -- this generated tree's
15
+ // own tsconfig.json pins `module: "commonjs"`) is a real, valid runtime anchor; `import.meta.url`
16
+ // is deliberately NOT used here -- it is not legal syntax under CommonJS module output and would
17
+ // fail `tsc` outright.
18
+ //
19
+ // Every `pattern` keyword is compiled exactly ONCE here, not per-request -- an invalid regex
20
+ // string at this point marks that one field unsupported rather than throwing at module load.
21
+
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import type { ObservedObject, ObservedProperty } from './contractCheck';
25
+
26
+ export interface ObservedOperation {
27
+ featureId: string | null;
28
+ featureUid: string | null;
29
+ contractRef: string | null;
30
+ verb: string | null;
31
+ path: string | null;
32
+ pathParams: ObservedObject;
33
+ body: string;
34
+ request: ObservedObject | null;
35
+ response: ObservedObject;
36
+ error: ObservedObject;
37
+ statuses: string[] | null;
38
+ }
39
+
40
+ const SCHEMAS_DIR = path.join(__dirname, 'schemas');
41
+
42
+ function compileProperty(prop: any): ObservedProperty | null {
43
+ const patternText = prop?.pattern;
44
+ if (patternText === undefined) return { type: prop?.type ?? null, pattern: null };
45
+ try {
46
+ return { type: prop?.type ?? null, pattern: new RegExp(patternText) };
47
+ } catch {
48
+ return null; // caller treats this as unsupported
49
+ }
50
+ }
51
+
52
+ function compileObject(node: any): ObservedObject {
53
+ if (!node) return { required: [], properties: {}, unsupported: [] };
54
+ const unsupported: string[] = [...(node.unsupported ?? [])];
55
+ const properties: Record<string, ObservedProperty> = {};
56
+ for (const [name, prop] of Object.entries<any>(node.properties ?? {})) {
57
+ const compiled = compileProperty(prop);
58
+ if (compiled === null) {
59
+ unsupported.push(`/${name}`);
60
+ continue;
61
+ }
62
+ properties[name] = compiled;
63
+ }
64
+ return { required: [...(node.required ?? [])], properties, unsupported };
65
+ }
66
+
67
+ function loadOne(filePath: string): Record<string, ObservedOperation> {
68
+ let raw: any;
69
+ try {
70
+ raw = JSON.parse(fs.readFileSync(filePath, 'utf8'));
71
+ } catch (err) {
72
+ console.warn(`observedSchema: could not read/parse ${filePath} -- skipping this feature's observed schema`, err);
73
+ return {};
74
+ }
75
+ const featureId = raw.feature_id ?? null;
76
+ const featureUid = raw.feature_uid ?? null;
77
+ const contractRef = raw.contract_ref ?? null;
78
+ const operations: Record<string, ObservedOperation> = {};
79
+ for (const [operationId, op] of Object.entries<any>(raw.operations ?? {})) {
80
+ const body = op.body ?? 'unknown';
81
+ operations[operationId] = {
82
+ featureId, featureUid, contractRef,
83
+ verb: op.verb ?? null,
84
+ path: op.path ?? null,
85
+ pathParams: compileObject(op.pathParams),
86
+ body,
87
+ request: body === 'false' ? null : compileObject(op.request),
88
+ response: compileObject(op.response),
89
+ error: compileObject(op.error),
90
+ statuses: op.statuses ?? null,
91
+ };
92
+ }
93
+ return operations;
94
+ }
95
+
96
+ function loadAll(): Map<string, ObservedOperation> {
97
+ const merged = new Map<string, ObservedOperation>();
98
+ if (!fs.existsSync(SCHEMAS_DIR)) return merged;
99
+ const files = fs.readdirSync(SCHEMAS_DIR).filter((f) => f.endsWith('.observed-schema.json')).sort();
100
+ for (const file of files) {
101
+ for (const [operationId, op] of Object.entries(loadOne(path.join(SCHEMAS_DIR, file)))) {
102
+ if (merged.has(operationId)) {
103
+ console.warn(`observedSchema: operationId "${operationId}" is declared by more than one *.observed-schema.json on disk -- the last one loaded wins`);
104
+ }
105
+ merged.set(operationId, op);
106
+ }
107
+ }
108
+ return merged;
109
+ }
110
+
111
+ const OPERATIONS = loadAll();
112
+
113
+ /** undefined when no loaded *.observed-schema.json declares this operationId. */
114
+ export function get(operationId: string): ObservedOperation | undefined {
115
+ return OPERATIONS.get(operationId);
116
+ }
package/lib/attest.mjs ADDED
@@ -0,0 +1,40 @@
1
+ // D-gate-attestation-signing: cryptographic primitives for signed gate attestations -- Node's
2
+ // built-in `crypto` module only (Ed25519, available since Node 12, well within this project's
3
+ // >=18 floor), zero new dependencies. Deliberately minimal: this module knows how to canonicalize,
4
+ // sign, and verify a JSON payload -- it has no opinion about WHERE a key lives (bin/bskel.mjs's
5
+ // `--key`/`--pubkey` flags are the only interface, per the user's own explicit choice to reject a
6
+ // new home-directory key-storage convention for this slice -- see DECISIONS.md).
7
+ import { generateKeyPairSync, sign as cryptoSign, verify as cryptoVerify } from 'node:crypto';
8
+ import { sortKeysDeep } from './gates.mjs';
9
+
10
+ export function generateKeypair() {
11
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519');
12
+ return {
13
+ publicKeyPem: publicKey.export({ type: 'spki', format: 'pem' }),
14
+ privateKeyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }),
15
+ };
16
+ }
17
+
18
+ // Deep-sorted, whitespace-free JSON -- the ONLY thing that's ever actually signed/verified.
19
+ // Reusing lib/gates.mjs's own sortKeysDeep() (already proven correct via every gate's `inputs`
20
+ // field) rather than a second, possibly-subtly-different implementation.
21
+ export function canonicalize(value) {
22
+ return JSON.stringify(sortKeysDeep(value));
23
+ }
24
+
25
+ export function signPayload(payload, privateKeyPem) {
26
+ const canonical = canonicalize(payload);
27
+ return cryptoSign(null, Buffer.from(canonical), privateKeyPem).toString('base64');
28
+ }
29
+
30
+ // Returns a plain boolean, never throws on a malformed signature/key -- a corrupt or wrong-format
31
+ // signature is exactly as "not valid" as a mismatched one, not a distinct error class a caller
32
+ // needs to handle differently.
33
+ export function verifyPayload(payload, signatureB64, publicKeyPem) {
34
+ const canonical = canonicalize(payload);
35
+ try {
36
+ return cryptoVerify(null, Buffer.from(canonical), publicKeyPem, Buffer.from(signatureB64, 'base64'));
37
+ } catch {
38
+ return false;
39
+ }
40
+ }