backend-skeleton 1.0.0-beta.9 → 1.1.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 (77) hide show
  1. package/README.md +185 -13
  2. package/bin/bskel.mjs +689 -33
  3. package/contracts/emit.mjs +5 -1
  4. package/contracts/export.mjs +65 -7
  5. package/contracts/openapi.mjs +321 -30
  6. package/contracts/validate.mjs +23 -4
  7. package/handles/_engine.mjs +123 -30
  8. package/handles/capability-codec.mjs +94 -0
  9. package/handles/codec.mjs +13 -3
  10. package/handles/providers/java-spring/emit.mjs +78 -33
  11. package/handles/providers/java-spring/observe.mjs +4 -3
  12. package/handles/providers/java-spring/plan.mjs +73 -16
  13. package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +19 -1
  14. package/handles/providers/java-spring/templates/HandleController.java.tmpl +19 -9
  15. package/handles/providers/java-spring/templates/HandleService.java.tmpl +21 -2
  16. package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +1 -1
  17. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +36 -9
  18. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +8 -2
  19. package/handles/providers/java-spring.mjs +8 -0
  20. package/handles/providers/python-fastapi/emit.mjs +21 -26
  21. package/handles/providers/python-fastapi/observe.mjs +6 -5
  22. package/handles/providers/python-fastapi/templates/codec.py.tmpl +18 -3
  23. package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +129 -39
  24. package/handles/providers/python-fastapi.mjs +3 -3
  25. package/handles/providers/typescript-express/emit.mjs +144 -55
  26. package/handles/providers/typescript-express/observe.mjs +102 -0
  27. package/handles/providers/typescript-express/templates/codec.ts.tmpl +13 -3
  28. package/handles/providers/typescript-express/templates/contractCheck.ts.tmpl +136 -0
  29. package/handles/providers/typescript-express/templates/handleEntities.ts.tmpl +89 -0
  30. package/handles/providers/typescript-express/templates/handleService.ts.tmpl +81 -0
  31. package/handles/providers/typescript-express/templates/migration.sql.tmpl +36 -0
  32. package/handles/providers/typescript-express/templates/observeContract.ts.tmpl +146 -0
  33. package/handles/providers/typescript-express/templates/observedSchema.ts.tmpl +116 -0
  34. package/handles/providers/typescript-express/templates/recordSnapshotWrapper.ts.tmpl +123 -0
  35. package/handles/providers/typescript-express/templates/registry.ts.tmpl +19 -10
  36. package/handles/providers/typescript-express/templates/resolver.ts.tmpl +13 -0
  37. package/handles/providers/typescript-express/templates/resolverPolicy.ts.tmpl +20 -0
  38. package/handles/providers/typescript-express/templates/router.ts.tmpl +113 -2
  39. package/handles/providers/typescript-express.mjs +7 -4
  40. package/lib/attest.mjs +40 -0
  41. package/lib/cli.mjs +136 -5
  42. package/lib/cross-feature-collisions.mjs +286 -0
  43. package/lib/diff.mjs +35 -0
  44. package/lib/exit-codes.mjs +21 -0
  45. package/lib/fsutil.mjs +7 -2
  46. package/lib/gate-definitions.mjs +85 -1
  47. package/lib/gates.mjs +5 -1
  48. package/lib/http-server.mjs +192 -6
  49. package/lib/lock.mjs +68 -15
  50. package/lib/patch-kinds.mjs +52 -0
  51. package/lib/patch-transactions.mjs +206 -0
  52. package/lib/serve-ui.html +211 -0
  53. package/lib/verify.mjs +23 -6
  54. package/lib/workflow.mjs +31 -3
  55. package/package.json +8 -2
  56. package/scanners/adapters/_java-spring-analyzer.mjs +9 -1
  57. package/scanners/adapters/java-spring.mjs +114 -10
  58. package/scanners/adapters/javascript-express.mjs +46 -13
  59. package/scanners/adapters/python-fastapi.mjs +9 -1
  60. package/scanners/adapters/typescript-express.mjs +19 -2
  61. package/scanners/db/ddl-apply.mjs +253 -0
  62. package/scanners/db/introspect.mjs +61 -32
  63. package/scanners/db/migrations.mjs +73 -18
  64. package/schemas/cross-feature-report.schema.json +66 -0
  65. package/schemas/cross-feature-resolution.schema.json +28 -0
  66. package/schemas/feature-contract.schema.json +3 -3
  67. package/schemas/gate-attestation.schema.json +22 -0
  68. package/schemas/gate-export.schema.json +58 -0
  69. package/schemas/handles-plan.schema.json +2 -0
  70. package/schemas/oracle-manifest.schema.json +58 -0
  71. package/schemas/patch-transaction.schema.json +182 -0
  72. package/schemas/scan-report.schema.json +6 -4
  73. package/schemas/stack-choice.schema.json +12 -1
  74. package/schemas/stack-record.schema.json +6 -1
  75. package/stack/apply.mjs +51 -7
  76. package/stack/catalog/ngrok.yml +8 -2
  77. package/stack/config-apply.mjs +168 -0
@@ -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,89 @@
1
+ // Generated by backend-skeleton. Do not hand-edit -- change the source template and regenerate.
2
+ //
3
+ // G5 follow-up (D-typescript-express-registry-parity): mirrors java-spring's HandleRegistry.java.tmpl/
4
+ // HandleSnapshot.java.tmpl and python-fastapi's tables.py.tmpl -- same table names, columns, and
5
+ // unique(resource_type, resource_uid, pointer) constraint, so all three providers' generated
6
+ // schemas agree byte-for-byte at the DDL level (see the emitted migration.sql). NOT the same
7
+ // concept as this package's own registry.ts -- that file is an in-process type->resolver
8
+ // dispatch map; HandleRegistry here is a real database table. The names collide only because
9
+ // Java's own naming does; treat them as unrelated.
10
+ //
11
+ // `payload` is a native `jsonb` column -- TypeORM (de)serializes at the DB boundary automatically,
12
+ // the same no-manual-round-trip safety python-fastapi's own tables.py already has (Java's original
13
+ // `payload` column was a manually (de)serialized String, the exact bug class this structurally
14
+ // cannot reproduce).
15
+ //
16
+ // Requires `experimentalDecorators`/`emitDecoratorMetadata` in the target app's own tsconfig --
17
+ // already required by this provider's own real oracle (any TypeORM app already needs both for its
18
+ // OWN `@Entity()` classes; this is not a new requirement this file introduces). Unlike codec.ts
19
+ // (deliberately decorator-free so it can run standalone via `node --experimental-strip-types`),
20
+ // this file is inherently coupled to a real TypeORM build the way every entity in this ecosystem
21
+ // already is.
22
+ import { Entity, PrimaryColumn, Column, CreateDateColumn, PrimaryGeneratedColumn } from 'typeorm';
23
+
24
+ @Entity({ name: 'sbf_handle' })
25
+ export class HandleRegistry {
26
+ @PrimaryColumn('uuid')
27
+ handleUid!: string;
28
+
29
+ // 'r' (resource), 'f' (field), or 'o' (operation instance -- reserved, unused).
30
+ @Column('text')
31
+ kind!: string;
32
+
33
+ @Column('text')
34
+ resourceType!: string;
35
+
36
+ @Column('uuid')
37
+ resourceUid!: string;
38
+
39
+ // RFC 6901 JSON Pointer, null for kind='r'.
40
+ @Column('text', { nullable: true })
41
+ pointer!: string | null;
42
+
43
+ @Column('uuid')
44
+ featureUid!: string;
45
+
46
+ @Column('text', { nullable: true })
47
+ operationId!: string | null;
48
+
49
+ @Column('text')
50
+ contractRef!: string;
51
+
52
+ @CreateDateColumn({ type: 'timestamptz' })
53
+ createdAt!: Date;
54
+
55
+ @Column('timestamptz', { nullable: true })
56
+ revokedAt!: Date | null;
57
+
58
+ @Column('text', { nullable: true })
59
+ revokedReason!: string | null;
60
+ }
61
+
62
+ @Entity({ name: 'sbf_handle_snapshot' })
63
+ export class HandleSnapshot {
64
+ @PrimaryGeneratedColumn({ type: 'bigint' })
65
+ snapshotId!: string;
66
+
67
+ @Column('uuid')
68
+ handleUid!: string;
69
+
70
+ // 'request', 'response', or 'error' -- what kind of payload this envelope holds.
71
+ @Column('text')
72
+ envelopeDir!: string;
73
+
74
+ @Column('text')
75
+ operationId!: string;
76
+
77
+ // Hash of the feature contract this snapshot was recorded against. `recover` compares this to
78
+ // the CURRENT contract's hash -- a mismatch means the contract has since changed shape, and
79
+ // `recover` must say so explicitly (a schemaDrift marker) rather than silently returning a
80
+ // payload the current contract no longer describes.
81
+ @Column('text')
82
+ contractHash!: string;
83
+
84
+ @Column('jsonb')
85
+ payload!: unknown;
86
+
87
+ @CreateDateColumn({ type: 'timestamptz' })
88
+ recordedAt!: Date;
89
+ }
@@ -0,0 +1,81 @@
1
+ // Generated by backend-skeleton. Do not hand-edit -- change the source template and regenerate.
2
+ //
3
+ // G5 follow-up (D-typescript-express-registry-parity): mirrors Java's HandleService.java.tmpl /
4
+ // python-fastapi's handle_service.py.tmpl -- the explicit API that makes a real GET
5
+ // /handles/:handle/recover reachable. Deliberately never auto-invoked by anything generated
6
+ // elsewhere and never wired into any EXISTING business logic file -- call these functions
7
+ // explicitly from your own service code at the point a resource is actually created/updated, or
8
+ // wrap an existing service function with `recordSnapshot(...)` (see recordSnapshotWrapper.ts) to
9
+ // have it called for you.
10
+ //
11
+ // Named `registerHandle`/`revokeHandle` (not `register`/`revoke`) -- `registry.ts`'s own
12
+ // `register()` is a completely unrelated function (in-process resolver-type dispatch, not a
13
+ // database write); the names collide only because both files use the word "register" for
14
+ // different things. Plain exported functions taking `dataSource` explicitly, not a class with
15
+ // injected dependencies -- matches this provider's own established convention (every resolver
16
+ // already imports its own app-wide DataSource singleton directly; there is no DI container here
17
+ // the way Spring's @Service beans have, or even FastAPI's own Depends()).
18
+ import type { DataSource } from 'typeorm';
19
+ import { HandleRegistry, HandleSnapshot } from './handleEntities';
20
+ import { deriveHandleUid, encodeHandle } from './codec';
21
+
22
+ export interface RegisterHandleArgs {
23
+ kind: string;
24
+ type: string;
25
+ resourceUid: string;
26
+ pointer: string | null;
27
+ featureUid: string;
28
+ operationId: string | null;
29
+ contractRef: string;
30
+ }
31
+
32
+ // Derives handleUid and UPSERTS the registry row -- the schema's own
33
+ // unique(resource_type, resource_uid, pointer) constraint means the SAME (kind, type,
34
+ // resource_uid, pointer) tuple always derives the SAME handleUid, so re-registering it is
35
+ // expected, not an error: an existing row has its featureUid/operationId/contractRef refreshed,
36
+ // but revokedAt/revokedReason are NEVER touched here -- re-registering a revoked handle must
37
+ // never silently un-revoke it. Returns the encoded handle token.
38
+ export async function registerHandle(dataSource: DataSource, args: RegisterHandleArgs): Promise<string> {
39
+ const { kind, type, resourceUid, pointer, featureUid, operationId, contractRef } = args;
40
+ const token = encodeHandle(kind, type, resourceUid, pointer);
41
+ const handleUid = deriveHandleUid(kind, type, resourceUid, pointer);
42
+ const repo = dataSource.getRepository(HandleRegistry);
43
+ const existing = await repo.findOne({ where: { handleUid } });
44
+ if (existing == null) {
45
+ await repo.save(repo.create({ handleUid, kind, resourceType: type, resourceUid, pointer, featureUid, operationId, contractRef, revokedAt: null, revokedReason: null }));
46
+ } else {
47
+ existing.featureUid = featureUid;
48
+ existing.operationId = operationId;
49
+ existing.contractRef = contractRef;
50
+ await repo.save(existing);
51
+ }
52
+ return token;
53
+ }
54
+
55
+ // Records one envelope for an already-registered handle. `payload` is a plain
56
+ // object/array/scalar (already JSON-shaped), stored directly into the native jsonb column -- no
57
+ // manual JSON.stringify/JSON.parse round-trip exists here for `recover` to get wrong later.
58
+ export async function recordSnapshot(dataSource: DataSource, handleUid: string, envelopeDir: string, operationId: string, contractHash: string, payload: unknown): Promise<void> {
59
+ const repo = dataSource.getRepository(HandleSnapshot);
60
+ await repo.save(repo.create({ handleUid, envelopeDir, operationId, contractHash, payload }));
61
+ }
62
+
63
+ export async function revokeHandle(dataSource: DataSource, handleUid: string, reason: string): Promise<void> {
64
+ const repo = dataSource.getRepository(HandleRegistry);
65
+ const existing = await repo.findOne({ where: { handleUid } });
66
+ if (existing != null) {
67
+ existing.revokedAt = new Date();
68
+ existing.revokedReason = reason;
69
+ await repo.save(existing);
70
+ }
71
+ }
72
+
73
+ // Retention is EXPOSED, not auto-scheduled -- nothing generated here calls this automatically.
74
+ // Deciding how long snapshots should live, and whether a background job is even appropriate for
75
+ // this application, is left to a human -- the same boundary this provider's own migration.sql
76
+ // already draws around never applying itself.
77
+ export async function pruneSnapshotsOlderThan(dataSource: DataSource, cutoff: Date): Promise<number> {
78
+ const repo = dataSource.getRepository(HandleSnapshot);
79
+ const result = await repo.createQueryBuilder().delete().where('recorded_at < :cutoff', { cutoff }).execute();
80
+ return result.affected ?? 0;
81
+ }
@@ -0,0 +1,36 @@
1
+ -- Generated by backend-skeleton (bskel handles emit) for feature {{FEATURE_ID}}.
2
+ -- NOT applied automatically -- review it and apply yourself (e.g. via `psql` or your own
3
+ -- TypeORM migration, wrapped in a `queryRunner.query(...)` migration file if you use one). See
4
+ -- D-config-patch / D-migration-scope in DECISIONS.md for why backend-skeleton never applies
5
+ -- schema changes on its own. Same table names/columns as the java-spring/python-fastapi
6
+ -- providers' own migration.sql.tmpl, so all three providers' generated schemas agree at the DDL
7
+ -- level.
8
+
9
+ create table if not exists sbf_handle (
10
+ handle_uid uuid primary key,
11
+ kind text not null check (kind in ('r', 'f', 'o')),
12
+ resource_type text not null,
13
+ resource_uid uuid not null,
14
+ pointer text,
15
+ feature_uid uuid not null,
16
+ operation_id text,
17
+ contract_ref text not null,
18
+ created_at timestamptz not null default now(),
19
+ revoked_at timestamptz,
20
+ revoked_reason text,
21
+ unique (resource_type, resource_uid, pointer)
22
+ );
23
+
24
+ create index if not exists ix_sbf_handle_resource on sbf_handle (resource_type, resource_uid);
25
+
26
+ create table if not exists sbf_handle_snapshot (
27
+ snapshot_id bigserial primary key,
28
+ handle_uid uuid not null references sbf_handle (handle_uid),
29
+ envelope_dir text not null check (envelope_dir in ('request', 'response', 'error')),
30
+ operation_id text not null,
31
+ contract_hash text not null,
32
+ payload jsonb not null,
33
+ recorded_at timestamptz not null default now()
34
+ );
35
+
36
+ create index if not exists ix_sbf_handle_snapshot_handle_uid on sbf_handle_snapshot (handle_uid, recorded_at desc);
@@ -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
+ }