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,123 @@
1
+ // Generated by backend-skeleton. Do not hand-edit -- change the source template and regenerate.
2
+ //
3
+ // D-typescript-express-registry-parity: the opt-in AUTOMATIC half of the handle lifecycle --
4
+ // apply `recordSnapshot(...)` to wrap an EXISTING service function (never generated onto one --
5
+ // a human decides which functions are worth handle-tracking) to have it automatically register
6
+ // the resource-level handle and record the request/response/error envelope around every call.
7
+ //
8
+ // Java splits this into a marker annotation (@RecordHandleSnapshot) + a Spring AOP interceptor
9
+ // (HandleAspect) because Java has no other way to intercept a method call. Python collapses both
10
+ // into one decorator, because Python decorators natively ARE that ecosystem's interception
11
+ // mechanism. TypeScript has NEITHER available here: TS decorators are an experimental,
12
+ // `experimentalDecorators`-gated feature that targets class methods, not the standalone async
13
+ // functions this provider's own resolvers/routes are written as -- and this provider's own
14
+ // codec.ts already establishes "zero non-erasable TypeScript syntax (no decorators...)" as a
15
+ // real, load-bearing constraint. The equivalent here is a HIGHER-ORDER FUNCTION: one mechanism,
16
+ // same "no marker/interceptor split" property Python's decorator has, without decorator syntax.
17
+ //
18
+ // Only an async wrapper exists (no sync variant) -- unlike Python, where a decorator can wrap
19
+ // either a sync or async function and must detect which at decoration time, every Express
20
+ // handler and TypeORM repository call in this ecosystem is already async by convention, so
21
+ // there's no second code path to maintain.
22
+ //
23
+ // `resourceUidArg` is an argument INDEX (not a parameter NAME) -- TypeScript/JavaScript has no
24
+ // runtime reflection equivalent to Python's `inspect.signature(fn).bind(...)`, so this cannot
25
+ // resolve a named parameter the way the Python port does; pass the zero-based position of the
26
+ // resource-uid argument in the wrapped function's own parameter list. `redact` is an explicit
27
+ // array of JSON Pointers, never a guessed heuristic. A failure recording a snapshot is ALWAYS
28
+ // logged and swallowed, never allowed to fail the real business call it wraps -- snapshot
29
+ // recording is best-effort observability, not a new way for an unrelated call to start failing.
30
+ //
31
+ // Requires nothing extra installed (unlike Java's spring-boot-starter-aop) -- a plain function
32
+ // needs no framework support -- but still requires a human to apply it to their own code;
33
+ // codegen never touches an existing business logic file.
34
+ //
35
+ // Example:
36
+ // export const updateOrganization = recordSnapshot(
37
+ // { resourceType: 'Organization', operationId: 'updateOrganization', resourceUidArg: 0, redact: ['/internalNote'] },
38
+ // async (organizationId: string, req: UpdateOrganizationRequest): Promise<OrganizationResponse> => { ... },
39
+ // );
40
+ import type { DataSource } from 'typeorm';
41
+ import { registerHandle, recordSnapshot as persistSnapshot } from './handleService';
42
+ import { deriveHandleUid } from './codec';
43
+ import { resolverFor } from './registry';
44
+
45
+ export interface RecordSnapshotOptions {
46
+ resourceType: string;
47
+ operationId: string;
48
+ resourceUidArg: number;
49
+ redact?: string[];
50
+ }
51
+
52
+ function redactInPlace(obj: unknown, pointer: string): void {
53
+ if (!pointer.startsWith('/')) return;
54
+ const parts = pointer.slice(1).split('/').map((p) => p.replace(/~1/g, '/').replace(/~0/g, '~'));
55
+ let current: unknown = obj;
56
+ for (const part of parts.slice(0, -1)) {
57
+ if (current == null) return;
58
+ current = Array.isArray(current) ? current[Number(part)] : (current as Record<string, unknown>)[part];
59
+ }
60
+ const leaf = parts[parts.length - 1];
61
+ if (Array.isArray(current)) {
62
+ const idx = Number(leaf);
63
+ if (!Number.isNaN(idx) && idx < current.length) current[idx] = '***REDACTED***';
64
+ } else if (current != null && typeof current === 'object') {
65
+ if (leaf in (current as Record<string, unknown>)) (current as Record<string, unknown>)[leaf] = '***REDACTED***';
66
+ }
67
+ }
68
+
69
+ function safely(action: () => Promise<void>, handleUid: string, envelopeDir: string): void {
70
+ action().catch((err) => {
71
+ // eslint-disable-next-line no-console
72
+ console.warn(`recordSnapshot: could not record ${envelopeDir} snapshot for handle ${handleUid} -- the wrapped call proceeds unaffected`, err);
73
+ });
74
+ }
75
+
76
+ export function recordSnapshot<Args extends unknown[], R>(
77
+ options: RecordSnapshotOptions,
78
+ dataSource: DataSource,
79
+ fn: (...args: Args) => Promise<R>,
80
+ ): (...args: Args) => Promise<R> {
81
+ const { resourceType, operationId, resourceUidArg, redact = [] } = options;
82
+ return async (...args: Args): Promise<R> => {
83
+ const resolver = resolverFor(resourceType);
84
+ if (resolver == null) {
85
+ // eslint-disable-next-line no-console
86
+ console.warn(`recordSnapshot: no resolver registered for resourceType "${resourceType}" -- skipping snapshot recording, the wrapped call proceeds unaffected`);
87
+ return fn(...args);
88
+ }
89
+ const resourceUid = args[resourceUidArg];
90
+ if (typeof resourceUid !== 'string') {
91
+ // eslint-disable-next-line no-console
92
+ console.warn(`recordSnapshot: resourceUidArg ${resourceUidArg} on a call to a wrapped "${resourceType}" function did not resolve to a string argument -- skipping snapshot recording, the wrapped call proceeds unaffected`);
93
+ return fn(...args);
94
+ }
95
+ const handleUid = deriveHandleUid('r', resourceType, resourceUid, null);
96
+
97
+ const record = (envelopeDir: string, payload: unknown) => {
98
+ if (payload != null && typeof payload === 'object') {
99
+ for (const pointer of redact) redactInPlace(payload, pointer);
100
+ }
101
+ safely(async () => {
102
+ await registerHandle(dataSource, { kind: 'r', type: resourceType, resourceUid, pointer: null, featureUid: resolver.featureUid, operationId, contractRef: resolver.contractRef });
103
+ await persistSnapshot(dataSource, handleUid, envelopeDir, operationId, resolver.contractRef, payload);
104
+ }, handleUid, envelopeDir);
105
+ };
106
+
107
+ // The remaining arguments once resourceUidArg is excluded -- the sole survivor UNWRAPPED (the
108
+ // common case: one DTO), matching Java's/Python's own request-payload projection exactly, so
109
+ // a `redact` pointer written against the DTO's own fields resolves correctly.
110
+ const rest = args.filter((_, i) => i !== resourceUidArg);
111
+ record('request', rest.length === 1 ? rest[0] : rest);
112
+
113
+ let result: R;
114
+ try {
115
+ result = await fn(...args);
116
+ } catch (err) {
117
+ record('error', { message: err instanceof Error ? err.message : String(err) });
118
+ throw err;
119
+ }
120
+ record('response', result);
121
+ return result;
122
+ };
123
+ }
@@ -1,20 +1,29 @@
1
1
  // Generated by backend-skeleton. Do not hand-edit -- change the source template and regenerate.
2
2
  //
3
3
  // In-process registry mapping a handle `type` string to its resolver instance -- deliberately NOT
4
- // a database table. This 1st-slice scope carries no recover()/snapshot lifecycle equivalent (no
5
- // sbf_handle/sbf_handle_snapshot table) -- mirrors java-spring/python-fastapi's own pre-O4/
6
- // pre-follow-up state, not a gap specific to this provider. See D-typescript-express-provider in
7
- // DECISIONS.md.
4
+ // a database table (that's HandleRegistry in handleEntities.ts, an unrelated concept that only
5
+ // shares the word "registry" -- see that file's own header). See D-typescript-express-provider /
6
+ // D-typescript-express-registry-parity in DECISIONS.md.
8
7
 
9
8
  // Unlike the Python provider's ResourceResolver (which threads a per-request SQLAlchemy `session`
10
- // through every method, matching FastAPI's own dependency-injection convention), this interface
11
- // takes NO dataSource/session parameter at all -- TypeORM's `DataSource` is an app-wide singleton
12
- // instantiated once at startup (`export const AppDataSource = new DataSource(...)`), not a
13
- // per-request-injected object the way SQLAlchemy's `Session` is. Each generated resolver imports
14
- // its own DataSource reference directly (see resolver.ts.tmpl) -- a genuine simplification from
15
- // this ecosystem's own real pattern, not an arbitrary deviation from the Python provider's shape.
9
+ // argument through every METHOD CALL, matching FastAPI's own dependency-injection convention),
10
+ // none of fetch()/checkAccess()/patchField()/toPublic() below take a dataSource/session parameter
11
+ // -- TypeORM's `DataSource` is an app-wide singleton instantiated once at startup (`export const
12
+ // AppDataSource = new DataSource(...)`), not a per-request-injected object the way SQLAlchemy's
13
+ // `Session` is. Each generated resolver still imports its own DataSource reference directly (see
14
+ // resolver.ts.tmpl) and exposes it as the `dataSource` FIELD below (not a method parameter) --
15
+ // D-typescript-express-registry-parity's own infrastructure (recordSnapshotWrapper.ts, router.ts's
16
+ // recover()/enforcement logic) reads it from there rather than needing a separate reference of
17
+ // its own. `featureUid`/`contractRef` are the other two live-derived values
18
+ // D-resolver-policy-split exists for (see <Type>Policy.ts.tmpl). All three fields are read by
19
+ // that infrastructure only, never by fetch()/checkAccess()/patchField()/toPublic().
20
+ import type { DataSource } from 'typeorm';
21
+
16
22
  export interface ResourceResolver {
17
23
  type: string;
24
+ featureUid: string;
25
+ contractRef: string;
26
+ dataSource: DataSource;
18
27
  fetch(resourceUid: string): Promise<unknown>;
19
28
  checkAccess(obj: unknown): void;
20
29
  patchField(obj: unknown, pointer: string, value: unknown): void;
@@ -24,9 +24,22 @@
24
24
  import { {{MODEL}} } from '{{MODEL_IMPORT_PATH}}';
25
25
  import { {{DATA_SOURCE_NAME}} } from '{{DATA_SOURCE_IMPORT_PATH}}';
26
26
  import { register, HandleAccessDeniedError, HandleNotImplementedError, type ResourceResolver } from '../registry';
27
+ import { {{RESOURCE_TYPE}}Policy } from './{{RESOURCE_TYPE_CAMEL}}Policy';
27
28
 
28
29
  const {{RESOURCE_TYPE}}Resolver: ResourceResolver = {
30
+ ...{{RESOURCE_TYPE}}Policy,
31
+ // Redundant with the spread above (Policy already carries `type`) -- restated explicitly so
32
+ // this file's own orphan-scan (emit.mjs's resourceTypeOf(), a content-read, not a filename
33
+ // guess) can still find a real `type: 'X'` field without needing to open the sibling Policy
34
+ // file too. A literal, not derived from anything -- if this and the Policy value ever
35
+ // disagreed, the object-literal spread order means this line always wins at runtime, matching
36
+ // what the orphan scan would read.
29
37
  type: '{{RESOURCE_TYPE}}',
38
+ // D-typescript-express-registry-parity: exposed here, not in the policy file -- this is an
39
+ // OBJECT REFERENCE (the same app-wide DataSource singleton fetch() already imports below), not
40
+ // a plain regenerable string value the way contractRef/featureUid are. Read by recordSnapshotWrapper.ts
41
+ // and router.ts's recover()/enforcement logic so they never need their own separate DataSource.
42
+ dataSource: {{DATA_SOURCE_NAME}},
30
43
 
31
44
  async fetch(resourceUid: string): Promise<unknown> {
32
45
  return {{DATA_SOURCE_NAME}}.getRepository({{MODEL}}).findOne({ where: { {{ID_FIELD}}: resourceUid } });
@@ -0,0 +1,20 @@
1
+ // Generated by backend-skeleton (bskel handles emit) for feature {{FEATURE_ID}}.
2
+ //
3
+ // D-typescript-express-registry-parity (mirrors java-spring's ResourceResolverPolicyStub.java.tmpl
4
+ // / D-resolver-policy-split): machine-owned companion to {{RESOURCE_TYPE}}Resolver -- every value
5
+ // here is derived directly from the current contract/scan and is safe to regenerate on every
6
+ // `bskel handles emit` run, even when the sibling resolver file has diverged (e.g. a hand-finished
7
+ // patchField()) and is refusing to regenerate. This is a SEPARATE file, not a section of
8
+ // resolver.ts, so a hand-edit to checkAccess()/patchField() there never blocks a live-derived
9
+ // value here (contractRef/featureUid) from updating -- both used to live in one file for this
10
+ // provider's own 1st slice, and classifyFile()'s conflict detection is file-level.
11
+ //
12
+ // Nothing here is ever meant to be hand-edited -- to change one of these values, change the
13
+ // SOURCE it was derived from (the feature contract, etc.) and re-run `bskel handles emit`. A
14
+ // hand-edit here will be silently overwritten on the next run, by design (see the "provably
15
+ // untouched -> safe to regenerate" path in lib/handles-manifest.mjs's classifyFile()).
16
+ export const {{RESOURCE_TYPE}}Policy = {
17
+ type: '{{RESOURCE_TYPE}}',
18
+ contractRef: '{{CONTRACT_REF}}',
19
+ featureUid: '{{FEATURE_UID}}',
20
+ } as const;
@@ -9,8 +9,38 @@
9
9
  // against still pins this version) does NOT auto-catch a rejected Promise inside an async route
10
10
  // handler the way Express 5 does, and that can't be assumed of an arbitrary target app either way.
11
11
  import { Router, type Request, type Response } from 'express';
12
- import { decodeHandle, resolveJsonPointer } from './codec';
13
- import { resolverFor, HandleAccessDeniedError, HandleNotImplementedError } from './registry';
12
+ import { decodeHandle, resolveJsonPointer, deriveHandleUid, type DecodedHandle } from './codec';
13
+ import { resolverFor, HandleAccessDeniedError, HandleNotImplementedError, type ResourceResolver } from './registry';
14
+ import { HandleRegistry, HandleSnapshot } from './handleEntities';
15
+
16
+ // O3 (D-handle-registry-enforcement, part 2 of 2), D-typescript-express-registry-parity: opt-in,
17
+ // defaults false -- baked in at `bskel handles emit --enforce-registry on` time, matching every
18
+ // other per-target-repo codegen constant this project bakes in (e.g. featureUid/contractRef on
19
+ // each resolver). `registerHandle()` is only ever called by generated code wrapped with
20
+ // recordSnapshot(...) (an app that opted in) or a target app's own hand-written calls -- an app
21
+ // that mints handles through neither path gets fully working fetch()/patch() with zero registry
22
+ // involvement today; flipping this on unconditionally would 404 every one of their existing
23
+ // handles. Stays a deliberate, informed choice a target app's own maintainer makes.
24
+ const ENFORCE_REGISTRY = {{ENFORCE_REGISTRY}};
25
+
26
+ export class HandleNotRegisteredError extends Error {}
27
+
28
+ // D-handle-registry-enforcement's own bootstrapping-trap fix, ported here from day one (not
29
+ // rediscovered): always derives and looks up the PARENT RESOURCE's own row (kind='r',
30
+ // pointer=null), regardless of the requested handle's own kind/pointer -- `resourceType` is still
31
+ // exactly cross-checked (the real D-security-9 protection: an attacker-controlled `type` can't
32
+ // claim a different, more sensitive resource's registration by UUID coincidence), but kind/pointer
33
+ // are not, since every real registration in this system is resource-level. Structurally correct,
34
+ // not a workaround: revocation is inherently a resource-level concept, matching how fetch()'s own
35
+ // kind=f path already fetches the WHOLE resource and narrows client-side with a pointer.
36
+ async function requireRegisteredOrThrow(resolver: ResourceResolver, decoded: DecodedHandle): Promise<HandleRegistry> {
37
+ const resourceHandleUid = deriveHandleUid('r', decoded.type, decoded.uuid, null);
38
+ const registry = await resolver.dataSource.getRepository(HandleRegistry).findOne({ where: { handleUid: resourceHandleUid } });
39
+ if (registry == null || registry.resourceType !== decoded.type || registry.revokedAt != null) {
40
+ throw new HandleNotRegisteredError('no active registration for this handle');
41
+ }
42
+ return registry;
43
+ }
14
44
 
15
45
  export const router = Router();
16
46
 
@@ -38,6 +68,9 @@ router.get('/handles/:handle', async (req: Request, res: Response) => {
38
68
  }
39
69
 
40
70
  try {
71
+ if (ENFORCE_REGISTRY) {
72
+ await requireRegisteredOrThrow(resolver, decoded);
73
+ }
41
74
  const obj = await resolver.fetch(decoded.uuid);
42
75
  if (obj == null) {
43
76
  res.status(404).json({ detail: 'resource not found' });
@@ -65,6 +98,10 @@ router.get('/handles/:handle', async (req: Request, res: Response) => {
65
98
  res.status(403).json({ detail: exc.message });
66
99
  return;
67
100
  }
101
+ if (exc instanceof HandleNotRegisteredError) {
102
+ res.status(404).json({ detail: exc.message });
103
+ return;
104
+ }
68
105
  throw exc;
69
106
  }
70
107
  });
@@ -100,6 +137,9 @@ router.patch('/handles/:handle', async (req: Request, res: Response) => {
100
137
  }
101
138
 
102
139
  try {
140
+ if (ENFORCE_REGISTRY) {
141
+ await requireRegisteredOrThrow(resolver, decoded);
142
+ }
103
143
  const obj = await resolver.fetch(decoded.uuid);
104
144
  if (obj == null) {
105
145
  res.status(404).json({ detail: 'resource not found' });
@@ -117,6 +157,77 @@ router.patch('/handles/:handle', async (req: Request, res: Response) => {
117
157
  res.status(501).json({ detail: exc.message });
118
158
  return;
119
159
  }
160
+ if (exc instanceof HandleNotRegisteredError) {
161
+ res.status(404).json({ detail: exc.message });
162
+ return;
163
+ }
164
+ throw exc;
165
+ }
166
+ });
167
+
168
+ // D-handle-lifecycle (O4), D-typescript-express-registry-parity: recover() structurally REQUIRES
169
+ // a registry row to find a snapshot's own primary key -- there is no "unenforced" mode for it,
170
+ // unlike fetch()/patch() above. Always calls the same shared cross-check those two now optionally
171
+ // call, so the two paths can never silently drift apart. Unlike fetch()/patch(), this does NOT
172
+ // call resolver.fetch() first -- recovering history of a since-deleted resource is legitimate, so
173
+ // this never requires the live resource to still exist.
174
+ router.get('/handles/:handle/recover', async (req: Request, res: Response) => {
175
+ if (typeof req.params.handle !== 'string') {
176
+ res.status(400).json({ detail: 'malformed handle path segment' });
177
+ return;
178
+ }
179
+ let decoded;
180
+ try {
181
+ decoded = decodeHandle(req.params.handle);
182
+ } catch (exc) {
183
+ res.status(400).json({ detail: (exc as Error).message });
184
+ return;
185
+ }
186
+
187
+ const resolver = resolverFor(decoded.type);
188
+ if (!resolver) {
189
+ res.status(404).json({ detail: `no resolver registered for handle type "${decoded.type}"` });
190
+ return;
191
+ }
192
+
193
+ let registry: HandleRegistry;
194
+ try {
195
+ registry = await requireRegisteredOrThrow(resolver, decoded);
196
+ } catch (exc) {
197
+ if (exc instanceof HandleNotRegisteredError) {
198
+ res.status(404).json({ detail: exc.message });
199
+ return;
200
+ }
120
201
  throw exc;
121
202
  }
203
+
204
+ const atParam = typeof req.query.at === 'string' ? req.query.at : null;
205
+ const at = atParam != null ? new Date(atParam) : null;
206
+ if (at != null && Number.isNaN(at.getTime())) {
207
+ res.status(400).json({ detail: `"at" query parameter is not a valid date: "${atParam}"` });
208
+ return;
209
+ }
210
+
211
+ const qb = resolver.dataSource.getRepository(HandleSnapshot)
212
+ .createQueryBuilder('snapshot')
213
+ .where('snapshot.handle_uid = :handleUid', { handleUid: registry.handleUid })
214
+ .orderBy('snapshot.recorded_at', 'DESC');
215
+ if (at != null) qb.andWhere('snapshot.recorded_at <= :at', { at });
216
+ const snapshot = await qb.getOne();
217
+
218
+ if (snapshot == null) {
219
+ res.status(404).json({ detail: `no snapshot recorded for this handle${at != null ? ` at or before ${at.toISOString()}` : ''}` });
220
+ return;
221
+ }
222
+
223
+ res.status(200).json({
224
+ handle: req.params.handle,
225
+ recordedAt: snapshot.recordedAt,
226
+ operationId: snapshot.operationId,
227
+ // snapshot.payload is already a native object/array/scalar (a real jsonb column) -- no
228
+ // manual re-parse step exists here for the double-encoding bug Java once had to be possible
229
+ // in the first place.
230
+ payload: snapshot.payload,
231
+ schemaDrift: registry.contractRef !== snapshot.contractHash,
232
+ });
122
233
  });
@@ -9,10 +9,13 @@ export const provider = {
9
9
  id: 'typescript-express',
10
10
  title: 'TypeScript / Express / TypeORM',
11
11
  requiresCapabilities: ['resource.fetch'],
12
- // No migration.sql -- this 1st-slice provider generates no schema-owning artifact (no
13
- // recover(), no sbf_handle table), matching java-spring/python-fastapi's own pre-O4/
14
- // pre-follow-up state, not a gap specific to this provider.
15
- outputs: { spec: [] },
12
+ // D-typescript-express-registry-parity: migration.sql is manifest-tracked now (like every
13
+ // other provider's, per D-write-safety-phase0), so this declaration is NOT for idempotence
14
+ // exclusion -- it's lib/verify.mjs's S6 safety net (a `handles ran` check that fires even with
15
+ // no manifest entry at all, e.g. a `gate force`d handles gate). See java-spring.mjs's own
16
+ // identical comment for the full reasoning; checkArtifacts() dedupes against the manifest-
17
+ // based check by path, so this never produces a duplicate once a real manifest entry exists.
18
+ outputs: { spec: ['handles/migration.sql'] },
16
19
  plan,
17
20
  emit(args) {
18
21
  return emitTypeScriptExpress(args);
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
+ }
package/lib/cli.mjs CHANGED
@@ -85,10 +85,29 @@ export const COMMANDS = {
85
85
  // D-gate-export: unlike `gate show`, always feature-scoped -- the whole point is one feature's
86
86
  // own evidence trail across all 5 gates, not a single gate/repo-level snapshot.
87
87
  'gate export': {
88
- usage: 'bskel gate export --feature <id> [--out <path>] [--json]',
88
+ usage: 'bskel gate export --feature <id> [--out <path>] [--sign --key <privateKeyPath>] [--json]',
89
89
  options: {
90
90
  feature: { type: 'string', default: null, required: true },
91
91
  out: { type: 'string', default: null },
92
+ sign: { type: 'boolean', default: false },
93
+ key: { type: 'string', default: null },
94
+ json: { type: 'boolean', default: false },
95
+ },
96
+ },
97
+ // D-gate-attestation-signing.
98
+ 'attest keygen': {
99
+ usage: 'bskel attest keygen --out <dir> [--force] [--json]',
100
+ options: {
101
+ out: { type: 'string', default: null, required: true },
102
+ force: { type: 'boolean', default: false },
103
+ json: { type: 'boolean', default: false },
104
+ },
105
+ },
106
+ 'attest verify': {
107
+ usage: 'bskel attest verify --file <path> --pubkey <path> [--json]',
108
+ options: {
109
+ file: { type: 'string', default: null, required: true },
110
+ pubkey: { type: 'string', default: null, required: true },
92
111
  json: { type: 'boolean', default: false },
93
112
  },
94
113
  },
@@ -129,6 +148,30 @@ export const COMMANDS = {
129
148
  },
130
149
  allowPositionals: true,
131
150
  },
151
+ 'scan cross-feature-check': {
152
+ usage: 'bskel scan cross-feature-check --feature <id> [--db [--database-url-env <NAME>] [--schema public]] [--json]',
153
+ options: {
154
+ feature: { type: 'string', default: null, required: true },
155
+ // D-cross-feature-fk-inference: byte-identical shape to `scan`'s own --db/--database-url-env/
156
+ // --schema (resolved via the SAME resolveDbSchemaOrExit() helper) -- omitting them entirely
157
+ // is the exact prior behavior, unchanged.
158
+ db: { type: 'boolean', default: false },
159
+ 'database-url-env': { type: 'string', default: null },
160
+ schema: { type: 'string', default: 'public' },
161
+ json: { type: 'boolean', default: false },
162
+ },
163
+ },
164
+ 'scan cross-feature-waive': {
165
+ usage: 'bskel scan cross-feature-waive --feature <id> --signal resource_type|table|operation_id|db_foreign_key --identifier <name> --other-feature <id> --reason "..." [--json]',
166
+ options: {
167
+ feature: { type: 'string', default: null, required: true },
168
+ signal: { type: 'string', default: null, required: true },
169
+ identifier: { type: 'string', default: null, required: true },
170
+ 'other-feature': { type: 'string', default: null, required: true },
171
+ reason: { type: 'string', default: '' },
172
+ json: { type: 'boolean', default: false },
173
+ },
174
+ },
132
175
  'feature init': {
133
176
  usage: 'bskel feature init --slug <name>',
134
177
  options: { slug: { type: 'string', default: null, required: true } },
@@ -263,11 +306,15 @@ export const COMMANDS = {
263
306
  },
264
307
  },
265
308
  'stack apply': {
266
- usage: 'bskel stack apply --choice <id> [--apply] [--port N] [--json]',
309
+ usage: 'bskel stack apply --choice <id> [--apply] [--port N] [--force --reason "..."] [--json]',
267
310
  options: {
268
311
  choice: { type: 'string', default: null },
269
312
  apply: { type: 'boolean', default: false },
270
313
  port: { type: 'string', default: '8080', numeric: { min: 1, max: 65535 } },
314
+ // D-write-safety-phase0 (item 2): mirrors handles emit's own --force/--reason exactly --
315
+ // a file that diverged from what `stack apply` itself last wrote is refused without this.
316
+ force: { type: 'boolean', default: false },
317
+ reason: { type: 'string', default: '' },
271
318
  json: { type: 'boolean', default: false },
272
319
  },
273
320
  },
@@ -316,11 +363,16 @@ export const COMMANDS = {
316
363
  // purpose IS the live query). Reuses `--resource type1,type2`'s exact multi-value convention
317
364
  // from `handles plan`/`handles emit` rather than a new singular flag name.
318
365
  'handles audit': {
319
- usage: 'bskel handles audit --feature <id> --database-url-env <NAME> [--resource type1,type2] [--json]',
366
+ usage: 'bskel handles audit --feature <id> --database-url-env <NAME> [--resource type1,type2] [--module <name>] [--check-registry-coverage] [--json]',
320
367
  options: {
321
368
  feature: { type: 'string', default: null, required: true },
322
369
  'database-url-env': { type: 'string', default: null, required: true },
323
370
  resource: { type: 'string', default: '' },
371
+ // D-write-safety-phase1 (item 3): both opt-in, only consulted when --check-registry-coverage
372
+ // is set -- the base command stays gate-independent and scan-report-independent otherwise
373
+ // (see this command's own doc comment in bin/bskel.mjs for why that matters).
374
+ module: { type: 'string', default: null },
375
+ 'check-registry-coverage': { type: 'boolean', default: false },
324
376
  json: { type: 'boolean', default: false },
325
377
  },
326
378
  },
@@ -343,10 +395,11 @@ export const COMMANDS = {
343
395
  },
344
396
  },
345
397
  'observe import': {
346
- usage: 'bskel observe import --feature <id> --receipts <path> [--json]',
398
+ usage: 'bskel observe import --feature <id> --receipts <path> [--fail-on-violation] [--json]',
347
399
  options: {
348
400
  feature: { type: 'string', default: null, required: true },
349
401
  receipts: { type: 'string', default: null, required: true },
402
+ 'fail-on-violation': { type: 'boolean', default: false },
350
403
  json: { type: 'boolean', default: false },
351
404
  },
352
405
  },
@@ -410,6 +463,70 @@ export const COMMANDS = {
410
463
  json: { type: 'boolean', default: false },
411
464
  },
412
465
  },
466
+ // D-patch-transactions: content-addressed patch transactions, Slice 1 (config_check ->
467
+ // config_apply). `propose`/`approve` only touch specs/, so no --force escape exists on either --
468
+ // re-propose is the only remediation for a stale target. `rollback` alone gets --force (reverting
469
+ // to a known-good, git-recoverable prior state is materially lower-risk than forcing a forward
470
+ // edit whose collateral effects were never re-verified).
471
+ 'patch propose': {
472
+ usage: 'bskel patch propose --feature <id> [--kind config-apply --choice <stackChoiceId> --target <config_check target path> | --kind ddl-apply --database-url-env <NAME> --sql-file <path> [--schema public]] [--json]',
473
+ options: {
474
+ feature: { type: 'string', default: null, required: true },
475
+ // D-ddl-apply: default 'config-apply' -- omitting --kind entirely is byte-identical to
476
+ // this project's prior behavior. choice/target/database-url-env/sql-file are validated
477
+ // by hand inside cmdPatchPropose (kind-conditional requirements aren't expressible via
478
+ // this table's own unconditional `required: true`), matching this file's existing
479
+ // convention for kind-conditional flags (e.g. --reason on approve/rollback).
480
+ kind: { type: 'string', default: 'config-apply' },
481
+ choice: { type: 'string', default: null },
482
+ target: { type: 'string', default: null },
483
+ 'database-url-env': { type: 'string', default: null },
484
+ schema: { type: 'string', default: 'public' },
485
+ 'sql-file': { type: 'string', default: null },
486
+ json: { type: 'boolean', default: false },
487
+ },
488
+ },
489
+ 'patch approve': {
490
+ usage: 'bskel patch approve --feature <id> --transaction <id> --reason "..." [--json]',
491
+ options: {
492
+ feature: { type: 'string', default: null, required: true },
493
+ transaction: { type: 'string', default: null, required: true },
494
+ reason: { type: 'string', default: '' },
495
+ json: { type: 'boolean', default: false },
496
+ },
497
+ },
498
+ 'patch apply': {
499
+ usage: 'bskel patch apply --feature <id> --transaction <id> [--confirm <id-or-dropped-table-name>] [--json]',
500
+ options: {
501
+ feature: { type: 'string', default: null, required: true },
502
+ transaction: { type: 'string', default: null, required: true },
503
+ // D-ddl-apply: required for any kind other than config-apply -- what value it must
504
+ // exactly equal is kind- AND transaction-specific (getPatchKind(kind).requiredConfirmValue(txn)):
505
+ // the transaction id for a non-drop ddl-apply transaction, or the sorted, comma-joined
506
+ // dropped-table name(s) for one that drops a table. Checked by hand inside cmdPatchApply
507
+ // once the transaction's own kind/shape is known, not declaratively here. Ignored for
508
+ // config-apply.
509
+ confirm: { type: 'string', default: null },
510
+ json: { type: 'boolean', default: false },
511
+ },
512
+ },
513
+ 'patch rollback': {
514
+ usage: 'bskel patch rollback --feature <id> --transaction <id> --reason "..." [--force] [--json]',
515
+ options: {
516
+ feature: { type: 'string', default: null, required: true },
517
+ transaction: { type: 'string', default: null, required: true },
518
+ reason: { type: 'string', default: '' },
519
+ force: { type: 'boolean', default: false },
520
+ json: { type: 'boolean', default: false },
521
+ },
522
+ },
523
+ 'patch list': {
524
+ usage: 'bskel patch list --feature <id> [--json]',
525
+ options: {
526
+ feature: { type: 'string', default: null, required: true },
527
+ json: { type: 'boolean', default: false },
528
+ },
529
+ },
413
530
  verify: {
414
531
  usage: 'bskel verify --feature <id> [--build [--allow-skip-build]] [--json]',
415
532
  options: {
@@ -444,13 +561,27 @@ export const COMMANDS = {
444
561
  },
445
562
  },
446
563
  serve: {
447
- usage: 'bskel serve [--port N] [--host <addr>] [--json]',
564
+ usage: 'bskel serve [--port N] [--host <addr>] [--database-url-env <NAME> [--schema public] [--sign-key <path>] [--require-sign-key]] [--json]',
448
565
  options: {
449
566
  // min:0 (unlike stack apply --port's min:1) -- 0 is the standard "let the OS pick a free
450
567
  // ephemeral port" sentinel, genuinely useful both for tests and for a user who doesn't care
451
568
  // which port they get, not just a testing convenience.
452
569
  port: { type: 'string', default: '4747', numeric: { min: 0, max: 65535 } },
453
570
  host: { type: 'string', default: '127.0.0.1' },
571
+ // D-ddl-apply: every new DB-schema/patch-transaction route is gated behind this one flag
572
+ // being present -- a plain `bskel serve` (no --database-url-env) stays byte-identical to
573
+ // today, those paths simply don't exist (404, not 403), mirroring --host's own "safe
574
+ // default, explicit override" convention. --sign-key is optional even when the DB surface
575
+ // is enabled (see D-ddl-apply's "detect and warn, never hard-require" signing posture).
576
+ 'database-url-env': { type: 'string', default: null },
577
+ schema: { type: 'string', default: 'public' },
578
+ 'sign-key': { type: 'string', default: null },
579
+ // D-ddl-apply: opt-in-to-MORE-strictness -- refuses to even start the DDL surface without
580
+ // --sign-key also given, closing this feature's own named "mandatory signing... cheap,
581
+ // well-justified near-term addition" EXIT item. A no-op when --database-url-env wasn't
582
+ // given at all (nothing to enforce on a surface that isn't running), same as --schema/
583
+ // --sign-key themselves already being inert outside that case.
584
+ 'require-sign-key': { type: 'boolean', default: false },
454
585
  json: { type: 'boolean', default: false },
455
586
  },
456
587
  },