backend-skeleton 1.0.0-beta.2 → 1.0.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +12 -2
  2. package/bin/bskel.mjs +260 -11
  3. package/contracts/completeness.mjs +27 -2
  4. package/contracts/emit.mjs +68 -9
  5. package/contracts/export.mjs +94 -25
  6. package/contracts/openapi.mjs +307 -40
  7. package/handles/audit.mjs +83 -0
  8. package/handles/codec.mjs +11 -5
  9. package/handles/providers/java-spring/emit.mjs +9 -2
  10. package/handles/providers/java-spring/plan.mjs +20 -0
  11. package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +13 -5
  12. package/handles/providers/java-spring/templates/HandleController.java.tmpl +54 -20
  13. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +17 -1
  14. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +5 -0
  15. package/handles/providers/java-spring.mjs +2 -2
  16. package/handles/providers/python-fastapi/emit.mjs +5 -1
  17. package/handles/providers/python-fastapi/templates/codec.py.tmpl +9 -1
  18. package/handles/providers/python-fastapi/templates/router.py.tmpl +43 -19
  19. package/handles/providers/typescript-express/templates/codec.ts.tmpl +10 -1
  20. package/lib/cli.mjs +51 -3
  21. package/lib/handles-manifest.mjs +10 -4
  22. package/lib/repo.mjs +56 -0
  23. package/package.json +1 -1
  24. package/scanners/adapters/_express-shared.mjs +17 -12
  25. package/scanners/adapters/generic-grep.mjs +5 -1
  26. package/scanners/adapters/java-spring.mjs +60 -14
  27. package/scanners/adapters/javascript-express.mjs +5 -1
  28. package/scanners/adapters/python-fastapi.mjs +17 -14
  29. package/scanners/adapters/typescript-express.mjs +6 -1
  30. package/scanners/registry.mjs +5 -2
  31. package/scanners/text-util.mjs +25 -0
  32. package/schemas/adapter.schema.json +6 -2
  33. package/schemas/contract-resolution.schema.json +6 -1
  34. package/schemas/feature-contract.schema.json +10 -1
  35. package/schemas/handles-plan.schema.json +1 -0
  36. package/stack/bootstrap/db-up.sh +52 -0
  37. package/stack/bootstrap/docker-compose.postgres.yml +18 -0
  38. package/stack/catalog/postgres-dev-db.yml +55 -0
@@ -0,0 +1,83 @@
1
+ // O7/B2 (D-handle-audit-report): live query over `sbf_handle`/`sbf_handle_snapshot` -- the tables
2
+ // O4's `HandleService.recordSnapshot`/`@RecordHandleSnapshot` (java-spring, python-fastapi) already
3
+ // write to, when a target app opts into recording. Both providers' migration.sql.tmpl/tables.py.tmpl
4
+ // generate the SAME table names and columns (confirmed by reading both templates directly, not
5
+ // assumed) -- one query works regardless of which provider backed a given feature.
6
+ // Reuses A4/D-db-schema-plane's own `pg`/connection conventions unchanged: `Client`, one connection,
7
+ // sequential queries, `BEGIN TRANSACTION READ ONLY` as structural defense-in-depth (this is a
8
+ // read-only report; the transaction means the DB itself refuses any write attempt, not just "we
9
+ // didn't write any queries that would").
10
+ import pg from 'pg';
11
+
12
+ const { Client } = pg;
13
+
14
+ const HANDLES_SQL_BASE = `
15
+ SELECT
16
+ h.handle_uid, h.kind, h.resource_type, h.resource_uid, h.pointer,
17
+ h.operation_id, h.contract_ref, h.created_at, h.revoked_at, h.revoked_reason,
18
+ count(s.snapshot_id)::int AS snapshot_count,
19
+ max(s.recorded_at) AS last_recorded_at
20
+ FROM sbf_handle h
21
+ LEFT JOIN sbf_handle_snapshot s ON s.handle_uid = h.handle_uid
22
+ WHERE h.feature_uid = $1`;
23
+
24
+ // `= ANY($2)` -- an array bind, not string-interpolated -- for the multi-value --resource filter,
25
+ // matching `handles plan`/`handles emit`'s own `--resource type1,type2` convention exactly (see
26
+ // bin/bskel.mjs's existing `flags.resource.split(',')...` idiom) rather than inventing a
27
+ // singular `--resource-type` flag.
28
+ const RESOURCE_FILTER_SQL = ' AND h.resource_type = ANY($2)';
29
+ const GROUP_ORDER_SQL = ' GROUP BY h.handle_uid ORDER BY h.created_at DESC';
30
+
31
+ // Postgres error code for "relation does not exist" -- the real, expected shape when a target
32
+ // app's migration.sql was never applied (D-migration-scope: bskel never applies it automatically).
33
+ const UNDEFINED_TABLE = '42P01';
34
+
35
+ export function isMissingHandleTables(err) {
36
+ return err?.code === UNDEFINED_TABLE;
37
+ }
38
+
39
+ // `GROUP BY h.handle_uid` then selecting other `h.*` columns is valid Postgres (functional
40
+ // dependency on the grouped table's own primary key) -- verified live against a real Postgres
41
+ // (see D-handle-audit-report's verification note), not assumed from the SQL standard alone, since
42
+ // this specific form is a Postgres extension MySQL/older engines don't share.
43
+ export async function auditHandles({ connectionString, featureUid, resourceTypes }) {
44
+ const client = new Client({ connectionString });
45
+ await client.connect();
46
+ try {
47
+ await client.query('BEGIN TRANSACTION READ ONLY');
48
+ const params = [featureUid];
49
+ let sql = HANDLES_SQL_BASE;
50
+ if (resourceTypes && resourceTypes.length > 0) {
51
+ params.push(resourceTypes);
52
+ sql += RESOURCE_FILTER_SQL;
53
+ }
54
+ sql += GROUP_ORDER_SQL;
55
+ const res = await client.query(sql, params);
56
+ await client.query('COMMIT');
57
+ return res.rows.map((row) => ({
58
+ handle_uid: row.handle_uid,
59
+ kind: row.kind,
60
+ resource_type: row.resource_type,
61
+ resource_uid: row.resource_uid,
62
+ pointer: row.pointer,
63
+ operation_id: row.operation_id,
64
+ contract_ref: row.contract_ref,
65
+ created_at: row.created_at,
66
+ revoked_at: row.revoked_at,
67
+ revoked_reason: row.revoked_reason,
68
+ snapshot_count: row.snapshot_count,
69
+ last_recorded_at: row.last_recorded_at,
70
+ }));
71
+ } finally {
72
+ await client.end();
73
+ }
74
+ }
75
+
76
+ export function summarizeAudit(rows) {
77
+ return {
78
+ total_handles: rows.length,
79
+ revoked_handles: rows.filter((r) => r.revoked_at !== null).length,
80
+ never_snapshotted: rows.filter((r) => r.snapshot_count === 0).length,
81
+ total_snapshots: rows.reduce((sum, r) => sum + r.snapshot_count, 0),
82
+ };
83
+ }
package/handles/codec.mjs CHANGED
@@ -90,12 +90,18 @@ export function uuidv5(namespaceUuid, name) {
90
90
  }
91
91
 
92
92
  // The plain-UUID identity of a handle (for use as a DB primary key / foreign key), derivable
93
- // offline without a DB round-trip: kind=r handles ARE the resource's own uuid (no derivation
94
- // needed -- a resource handle and the entity's own PK are the same identity); kind=f handles
95
- // derive a UUIDv5 from type+uuid+pointer, so the same field always gets the same handle_uid
96
- // without ever needing to look it up first.
93
+ // offline without a DB round-trip. O3 (D-handle-uid-type-binding): ALL three kinds now hash a
94
+ // `type:uuid[:...]` discriminant through UUIDv5 -- kind=r used to return `uuid` verbatim (no type
95
+ // binding at all), so two different resource TYPES sharing the same resourceUid derived the SAME
96
+ // handle_uid and collided on the same `sbf_handle` primary key (silently blending their
97
+ // featureUid/operationId/contractRef, and letting HandleService.revoke() on one type's handle
98
+ // also revoke the other's -- the literal same row). Provably collision-free across kinds without
99
+ // a token-format change: kind=f's discriminant always has a pointer segment starting with "/"
100
+ // (RFC 6901), kind=o's always ends in the literal ":o" with no leading slash, and kind=r's never
101
+ // has a third segment at all -- no two different (kind, type, uuid, pointer) tuples can ever
102
+ // produce the same discriminant string.
97
103
  export function deriveHandleUid({ kind, type, uuid, pointer }) {
98
- if (kind === 'r') return uuid;
104
+ if (kind === 'r') return uuidv5(NS_SBF_FIELD, `${type}:${uuid}`);
99
105
  if (kind === 'f') {
100
106
  if (!pointer) throw new Error('field handles require a pointer to derive handle_uid');
101
107
  return uuidv5(NS_SBF_FIELD, `${type}:${uuid}:${pointer}`);
@@ -106,7 +106,7 @@ function writeUnit(target, content) {
106
106
  // featureId/module/resourceFilter) -- never a blanket, unscoped force. `resourceFilter` (the same
107
107
  // array plan() was called with, or null) turns off orphan detection when non-null, since a scoped
108
108
  // run would otherwise report every OTHER resource's resolver as orphaned.
109
- export function emitJavaSpring({ repoRoot, featureId, plan, basePackage, resourceFilter = null, force = false, reason = '', dryRun = false, computeDiff = false }) {
109
+ export function emitJavaSpring({ repoRoot, featureId, plan, basePackage, resourceFilter = null, force = false, reason = '', dryRun = false, computeDiff = false, enforceRegistry = false }) {
110
110
  const javaSrcRoot = path.join(repoRoot, 'src', 'main', 'java', ...basePackage.split('.'));
111
111
 
112
112
  // A3 (D-patch-strategy): loaded/detected once per emit call -- approvals are feature-scoped
@@ -120,7 +120,11 @@ export function emitJavaSpring({ repoRoot, featureId, plan, basePackage, resourc
120
120
  id: f.template,
121
121
  templatePath: path.join(TEMPLATES_DIR, f.template),
122
122
  targetAbs: path.join(javaSrcRoot, f.target),
123
- rendered: render(path.join(TEMPLATES_DIR, f.template), { BASE_PACKAGE: basePackage, JACKSON_PACKAGE: jacksonPackage }),
123
+ // O3 (D-handle-registry-enforcement): ENFORCE_REGISTRY only appears in
124
+ // HandleController.java.tmpl -- applied to every infra file uniformly anyway, matching
125
+ // how BASE_PACKAGE/JACKSON_PACKAGE already do (render()'s replaceAll is a harmless no-op
126
+ // for a token absent from a given template, e.g. JACKSON_PACKAGE inside HandleCodec.java.tmpl).
127
+ rendered: render(path.join(TEMPLATES_DIR, f.template), { BASE_PACKAGE: basePackage, JACKSON_PACKAGE: jacksonPackage, ENFORCE_REGISTRY: String(enforceRegistry) }),
124
128
  }));
125
129
 
126
130
  // O4 (D-handle-lifecycle): every resource in THIS feature shares the same contract file and
@@ -160,6 +164,9 @@ export function emitJavaSpring({ repoRoot, featureId, plan, basePackage, resourc
160
164
  SERVICE_FIELD: serviceField,
161
165
  FETCH_METHOD: resource.fetchOperation.method,
162
166
  REQUIRED_AUTHORITY: resource.requiredAuthority,
167
+ // O5 (D-resolver-authorization-action-aware): independently derived from the UPDATE
168
+ // endpoint's own @PreAuthorize -- see plan.mjs's own computation.
169
+ REQUIRED_AUTHORITY_PATCH: resource.requiredAuthorityForPatch,
163
170
  FEATURE_ID: featureId,
164
171
  CONTRACT_REF: contractRef,
165
172
  FEATURE_UID: featureUid,
@@ -239,6 +239,17 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
239
239
  const service = findServiceFile(javaSrcRoot, targetModule.module, entity.className);
240
240
  const serviceParamCount = (service && fetchOp) ? countServiceMethodParams(service.file, fetchOp.method) : null;
241
241
 
242
+ // O5 (D-resolver-authorization-action-aware): the SAME extraction, run again against the
243
+ // UPDATE (PATCH/PUT) endpoint instead of the fetch (GET) one -- previously nothing ever
244
+ // looked at the update endpoint's own @PreAuthorize at all, so patch() silently reused
245
+ // whichever role fetch() happened to require. Computed independently of planPatchable()'s
246
+ // own (conditional, gated on serviceParamCount===1) call to findUpdateOperation() below --
247
+ // requiredAuthorityForPatch is meaningful even when resolver codegen itself ends up
248
+ // blocked, exactly like requiredAuthority already is unconditional above.
249
+ const updateOpForAuthority = findUpdateOperation(targetModule.controllers, entity.className);
250
+ const patchAuthorityResult = findRequiredAuthority(updateOpForAuthority?.controllerFile ?? null, updateOpForAuthority?.method ?? null);
251
+ const requiredAuthorityForPatch = patchAuthorityResult.authority;
252
+
242
253
  if (!fetchOp) {
243
254
  notes.push(`${entity.className}: no single-resource GET endpoint found on a controller whose name contains "${entity.className}" -- fetch() will need to be hand-written`);
244
255
  } else if (authorityResult.unsupported) {
@@ -246,6 +257,11 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
246
257
  } else if (!requiredAuthority) {
247
258
  notes.push(`${entity.className}: no method-level or class-level @PreAuthorize(hasRole(...)) found for ${fetchOp.controllerClassName}.${fetchOp.method} -- requiredAuthority() defaults to "TODO_ROLE", fix before relying on it`);
248
259
  }
260
+ if (updateOpForAuthority && patchAuthorityResult.unsupported) {
261
+ notes.push(`${entity.className}: @PreAuthorize found on ${updateOpForAuthority.controllerClassName}.${updateOpForAuthority.method} (or its class) but not in the simple hasRole('X') shape this scanner understands -- requiredAuthorityForPatch() defaults to "TODO_ROLE" (fails closed) until a human fixes it`);
262
+ } else if (updateOpForAuthority && !requiredAuthorityForPatch) {
263
+ notes.push(`${entity.className}: no method-level or class-level @PreAuthorize(hasRole(...)) found for ${updateOpForAuthority.controllerClassName}.${updateOpForAuthority.method} -- requiredAuthorityForPatch() defaults to "TODO_ROLE", fix before relying on it`);
264
+ }
249
265
  if (!service) {
250
266
  notes.push(`${entity.className}: no ${entity.className}Service found under domain/${targetModule.module}/application/ -- resolver NOT generated for this entity (would produce a broken import). Emit it by hand once the right service is identified.`);
251
267
  } else if (fetchOp && serviceParamCount !== 1) {
@@ -299,6 +315,10 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
299
315
  updateDtoFile: patchResult.updateDtoFile ?? null,
300
316
  updateServiceBlockedReason: patchResult.updateServiceBlockedReason,
301
317
  requiredAuthority: requiredAuthority ?? 'TODO_ROLE',
318
+ // O5 (D-resolver-authorization-action-aware): independently derived from the UPDATE
319
+ // endpoint's own @PreAuthorize, not copied from requiredAuthority above -- see the
320
+ // computation and its own notes earlier in this loop.
321
+ requiredAuthorityForPatch: requiredAuthorityForPatch ?? 'TODO_ROLE',
302
322
  service,
303
323
  willGenerateResolver: Boolean(fetchOp && service && serviceParamCount === 1),
304
324
  });
@@ -89,14 +89,22 @@ public final class HandleCodec {
89
89
  }
90
90
 
91
91
  /**
92
- * The plain-UUID identity of a handle, for use as a DB primary/foreign key. {@code kind=r}
93
- * handles ARE the resource's own uuid; {@code kind=f} handles derive a UUIDv5 from
94
- * type+uuid+pointer, so the same field always derives the same handle_uid without a DB
95
- * round-trip.
92
+ * The plain-UUID identity of a handle, for use as a DB primary/foreign key. O3
93
+ * ({@code D-handle-uid-type-binding}): ALL three kinds hash a {@code type:uuid[:...]}
94
+ * discriminant through UUIDv5 -- {@code kind=r} used to return {@code uuid} verbatim (no type
95
+ * binding at all), so two different resource TYPES sharing the same {@code resourceUid}
96
+ * derived the SAME {@code handle_uid} and collided on the same {@code sbf_handle} primary key
97
+ * (silently blending their featureUid/operationId/contractRef, and letting
98
+ * {@link HandleService#revoke} on one type's handle also revoke the other's -- the literal
99
+ * same row). Provably collision-free across kinds without a token-format change: {@code
100
+ * kind=f}'s discriminant always has a pointer segment starting with "/" (RFC 6901), {@code
101
+ * kind=o}'s always ends in the literal ":o" with no leading slash, and {@code kind=r}'s never
102
+ * has a third segment at all -- no two different (kind, type, uuid, pointer) tuples can ever
103
+ * produce the same discriminant string.
96
104
  */
97
105
  public static UUID deriveHandleUid(String kind, String type, UUID uuid, String pointer) {
98
106
  return switch (kind) {
99
- case "r" -> uuid;
107
+ case "r" -> uuidv5(NS_SBF_FIELD, type + ":" + uuid);
100
108
  case "f" -> {
101
109
  if (pointer == null || pointer.isEmpty()) {
102
110
  throw new IllegalArgumentException("field handles require a pointer to derive handle_uid");
@@ -19,7 +19,6 @@ import org.springframework.web.server.ResponseStatusException;
19
19
  import java.time.Instant;
20
20
  import java.util.List;
21
21
  import java.util.Map;
22
- import java.util.Objects;
23
22
  import java.util.Optional;
24
23
  import java.util.UUID;
25
24
 
@@ -45,11 +44,24 @@ public class HandleController {
45
44
  private final HandleSnapshotRepository handleSnapshotRepository;
46
45
  private final ObjectMapper objectMapper;
47
46
 
47
+ // O3 (D-handle-registry-enforcement, part 2 of 2): opt-in, defaults false -- baked in at
48
+ // `bskel handles emit --enforce-registry on` time, matching every other per-target-repo
49
+ // codegen constant this project bakes in (e.g. ResourceResolver#contractRef()/featureUid()).
50
+ // `HandleService.register()` is only ever called by generated code from HandleAspect (an app
51
+ // that opted into @RecordHandleSnapshot) or a target app's own hand-written calls -- an app
52
+ // that mints handles through neither path gets fully working fetch()/patch() with zero
53
+ // registry involvement today; flipping this on unconditionally would 404 every one of their
54
+ // existing handles. Stays a deliberate, informed choice a target app's own maintainer makes.
55
+ private static final boolean ENFORCE_REGISTRY = {{ENFORCE_REGISTRY}};
56
+
48
57
  @GetMapping("/{handle}")
49
58
  public ResponseEntity<Object> fetch(@PathVariable String handle) {
50
59
  HandleCodec.Decoded decoded = decodeOrThrow(handle);
51
60
  ResourceResolver resolver = resolverFor(decoded.type());
52
61
  requireAuthority(resolver.requiredAuthority());
62
+ if (ENFORCE_REGISTRY) {
63
+ requireRegisteredOrThrow(decoded);
64
+ }
53
65
 
54
66
  Object resource = resolver.fetch(decoded.uuid());
55
67
  if (decoded.pointer() == null) {
@@ -83,7 +95,13 @@ public class HandleController {
83
95
  throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "cannot PATCH a resource-level handle (kind=r) -- only field handles (kind=f) support PATCH");
84
96
  }
85
97
  ResourceResolver resolver = resolverFor(decoded.type());
86
- requireAuthority(resolver.requiredAuthority());
98
+ // O5 (D-resolver-authorization-action-aware): the PATCH-specific authority, independently
99
+ // derived from the entity's own UPDATE endpoint -- NOT resolver.requiredAuthority(), which
100
+ // is fetch()/recover()'s own value and may legitimately require a different role.
101
+ requireAuthority(resolver.requiredAuthorityForPatch());
102
+ if (ENFORCE_REGISTRY) {
103
+ requireRegisteredOrThrow(decoded);
104
+ }
87
105
 
88
106
  resolver.patchField(decoded.uuid(), decoded.pointer(), value);
89
107
  return ResponseEntity.noContent().build();
@@ -95,24 +113,12 @@ public class HandleController {
95
113
  ResourceResolver resolver = resolverFor(decoded.type());
96
114
  requireAuthority(resolver.requiredAuthority());
97
115
 
98
- UUID handleUid = HandleCodec.deriveHandleUid(decoded.kind(), decoded.type(), decoded.uuid(), decoded.pointer());
99
-
100
- // D-security-9: handleUid alone does not prove WHAT is being recovered -- kind=r's
101
- // derivation returns the resource UUID verbatim (no type binding baked into the hash), so
102
- // an attacker who controls `type` in the handle token (as long as it names a real,
103
- // registered resolver whose requiredAuthority() they can pass) can request the snapshot
104
- // history of a DIFFERENT, more sensitive resource type that happens to share the same
105
- // UUID. Found by the Codex security review. Fix: cross-check the decoded type/kind/pointer
106
- // against the registry row this handleUid was actually registered under, and 404 on ANY
107
- // disagreement -- without saying which field mismatched, so the error can't be used to
108
- // probe which part was wrong. A revoked handle is rejected the same way; it must not be
109
- // recoverable at all.
110
- HandleRegistry registry = handleRegistryRepository.findById(handleUid)
111
- .filter(r -> r.getResourceType().equals(decoded.type()))
112
- .filter(r -> r.getKind().equals(decoded.kind()))
113
- .filter(r -> Objects.equals(r.getPointer(), decoded.pointer()))
114
- .filter(r -> !r.isRevoked())
115
- .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "no snapshot recorded for this handle"));
116
+ // D-security-9 / O3 (D-handle-registry-enforcement): recover() structurally REQUIRES a
117
+ // registry row to find a snapshot's own primary key -- there is no "unenforced" mode for
118
+ // it, unlike fetch()/patch() above. Always calls the same shared cross-check those two now
119
+ // optionally call, so the two paths can never silently drift apart.
120
+ HandleRegistry registry = requireRegisteredOrThrow(decoded);
121
+ UUID handleUid = registry.getHandleUid();
116
122
 
117
123
  List<HandleSnapshot> snapshots = handleSnapshotRepository.findByHandleUidOrderByRecordedAtDesc(handleUid);
118
124
  Optional<HandleSnapshot> match = at == null
@@ -166,6 +172,34 @@ public class HandleController {
166
172
  .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "no resolver registered for handle type \"" + type + "\""));
167
173
  }
168
174
 
175
+ // D-security-9 / O3 (D-handle-registry-enforcement): always looks up the PARENT RESOURCE's own
176
+ // registry row (kind=r, pointer=null) -- deliberately NOT the exact (kind, pointer) of the
177
+ // requested handle. Found live, not assumed: HandleAspect (the ONLY thing that ever registers
178
+ // automatically) always calls HandleService.register("r", ..., null, ...) regardless of which
179
+ // handle actually triggered it -- a kind=f field handle is NEVER auto-registered under its OWN
180
+ // derived UID, only the whole-resource kind=r one is. Requiring an exact (kind, pointer) match
181
+ // (this check's original shape) made a fresh app's very FIRST field-level PATCH structurally
182
+ // impossible under enforcement: the check runs BEFORE resolver.patchField() is ever called, so
183
+ // the very call that would (via the aspect) register something never gets the chance to. This
184
+ // is also the semantically correct model, not merely a workaround -- revocation is a
185
+ // RESOURCE-level concept (HandleService.revoke() takes one handleUid representing the whole
186
+ // resource), so a field-level handle's access is properly gated by "is the resource itself
187
+ // registered and non-revoked", the same way `fetch()`'s own kind=f path already fetches the
188
+ // WHOLE resource and narrows client-side with a pointer, never treating a field as its own
189
+ // separately-trusted object. `resourceType` is still exactly cross-checked (the real
190
+ // protection D-security-9 exists for -- an attacker-controlled `type` in the token can't claim
191
+ // a different, more sensitive resource's registration by UUID coincidence); the `kind`/
192
+ // `pointer` cross-check served no purpose beyond that once every real registration is
193
+ // confirmed to be resource-level, so removing it is not a narrowed security posture, just a
194
+ // closer match to how registration actually works in this system.
195
+ private HandleRegistry requireRegisteredOrThrow(HandleCodec.Decoded decoded) {
196
+ UUID resourceHandleUid = HandleCodec.deriveHandleUid("r", decoded.type(), decoded.uuid(), null);
197
+ return handleRegistryRepository.findById(resourceHandleUid)
198
+ .filter(r -> r.getResourceType().equals(decoded.type()))
199
+ .filter(r -> !r.isRevoked())
200
+ .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "no active registration for this handle"));
201
+ }
202
+
169
203
  private void requireAuthority(String requiredRole) {
170
204
  boolean granted = SecurityContextHolder.getContext().getAuthentication().getAuthorities().stream()
171
205
  .map(GrantedAuthority::getAuthority)
@@ -32,9 +32,25 @@ public interface ResourceResolver {
32
32
  */
33
33
  void patchField(UUID resourceUid, String pointer, Object value);
34
34
 
35
- /** Spring Security role name (no "ROLE_" prefix) required to fetch/patch this resource type via a handle. */
35
+ /**
36
+ * Spring Security role name (no "ROLE_" prefix) required to {@code fetch}/{@code recover}
37
+ * this resource type via a handle. O5 (D-resolver-authorization-action-aware): derived from
38
+ * the entity's FETCH (GET) endpoint's own {@code @PreAuthorize} -- deliberately NOT reused
39
+ * for {@link #patchField}, which has its own {@link #requiredAuthorityForPatch()} derived
40
+ * from the entity's real UPDATE endpoint instead, since a real app's GET and PATCH endpoints
41
+ * can (and often do) require genuinely different roles.
42
+ */
36
43
  String requiredAuthority();
37
44
 
45
+ /**
46
+ * O5 (D-resolver-authorization-action-aware): the {@code patchField} counterpart to {@link
47
+ * #requiredAuthority()} -- derived independently from the entity's UPDATE (PATCH/PUT)
48
+ * endpoint's own {@code @PreAuthorize}, not copied from the fetch endpoint's role. Before this
49
+ * existed, {@link HandleController#patch} silently reused {@link #requiredAuthority()},
50
+ * enforcing the wrong role whenever a real app's GET and PATCH endpoints genuinely differed.
51
+ */
52
+ String requiredAuthorityForPatch();
53
+
38
54
  /**
39
55
  * O4 (D-handle-lifecycle): the content hash of the feature contract this resolver was
40
56
  * generated from -- baked in at {@code bskel handles emit} time (regenerated every run, so a
@@ -65,6 +65,11 @@ public class {{RESOURCE_TYPE}}Resolver implements ResourceResolver {
65
65
  return "{{REQUIRED_AUTHORITY}}";
66
66
  }
67
67
 
68
+ @Override
69
+ public String requiredAuthorityForPatch() {
70
+ return "{{REQUIRED_AUTHORITY_PATCH}}";
71
+ }
72
+
68
73
  @Override
69
74
  public String contractRef() {
70
75
  return CONTRACT_REF;
@@ -15,7 +15,7 @@ export const provider = {
15
15
  requiresCapabilities: ['resource.fetch'],
16
16
  outputs: { spec: ['handles/migration.sql'] },
17
17
  plan,
18
- emit({ repoRoot, featureId, plan: handlesPlan, resourceFilter = null, force = false, reason = '', dryRun = false, computeDiff = false }) {
19
- return emitJavaSpring({ repoRoot, featureId, plan: handlesPlan, basePackage: handlesPlan.basePackage, resourceFilter, force, reason, dryRun, computeDiff });
18
+ emit({ repoRoot, featureId, plan: handlesPlan, resourceFilter = null, force = false, reason = '', dryRun = false, computeDiff = false, enforceRegistry = false }) {
19
+ return emitJavaSpring({ repoRoot, featureId, plan: handlesPlan, basePackage: handlesPlan.basePackage, resourceFilter, force, reason, dryRun, computeDiff, enforceRegistry });
20
20
  },
21
21
  };
@@ -43,7 +43,7 @@ function dottedModulePath(file, importRoot) {
43
43
  // but the router itself is infra -- generated once, independent of which resolvers exist -- so it
44
44
  // needs its own guard for the "found zero resolvers, and specifically because no SessionDep
45
45
  // exists" case).
46
- export function emitPythonFastApi({ repoRoot, featureId, plan, resourceFilter = null, force = false, reason = '', dryRun = false, computeDiff = false }) {
46
+ export function emitPythonFastApi({ repoRoot, featureId, plan, resourceFilter = null, force = false, reason = '', dryRun = false, computeDiff = false, enforceRegistry = false }) {
47
47
  const handlesDir = path.join(plan.importRoot, plan.topPackage, 'handles');
48
48
  const resolversDir = path.join(handlesDir, 'resolvers');
49
49
 
@@ -70,6 +70,10 @@ export function emitPythonFastApi({ repoRoot, featureId, plan, resourceFilter =
70
70
  PKG: plan.topPackage,
71
71
  SESSION_DEP_MODULE: dottedModulePath(sessionDep.file, plan.importRoot),
72
72
  SESSION_DEP_NAME: sessionDep.name,
73
+ // O3 (D-handle-registry-enforcement): a Python boolean LITERAL ("True"/"False",
74
+ // capitalized) -- render()'s plain String(value) would produce JS-style lowercase
75
+ // "true"/"false", invalid Python syntax.
76
+ ENFORCE_REGISTRY: enforceRegistry ? 'True' : 'False',
73
77
  }),
74
78
  });
75
79
  }
@@ -76,9 +76,17 @@ def decode_handle(token: str) -> DecodedHandle:
76
76
  return DecodedHandle(kind.lower(), type_, resource_uuid.lower(), pointer)
77
77
 
78
78
 
79
+ # O3 (D-handle-uid-type-binding): ALL three kinds hash a "type:uuid[:...]" discriminant through
80
+ # UUIDv5 -- kind="r" used to return resource_uuid verbatim (no type binding at all), so two
81
+ # different resource TYPES sharing the same resourceUid derived the SAME handle_uid and collided
82
+ # on the same sbf_handle primary key. Provably collision-free across kinds without a token-format
83
+ # change: kind="f"'s discriminant always has a pointer segment starting with "/" (RFC 6901),
84
+ # kind="o"'s always ends in the literal ":o" with no leading slash, and kind="r"'s never has a
85
+ # third segment at all -- no two different (kind, type, uuid, pointer) tuples can ever produce the
86
+ # same discriminant string.
79
87
  def derive_handle_uid(kind: str, type_: str, resource_uuid: str, pointer: str | None) -> str:
80
88
  if kind == "r":
81
- return resource_uuid
89
+ return str(uuid.uuid5(NS_SBF_FIELD, f"{type_}:{resource_uuid}"))
82
90
  if kind == "f":
83
91
  if not pointer:
84
92
  raise ValueError("field handles require a pointer to derive handle_uid")
@@ -20,6 +20,38 @@ from {{SESSION_DEP_MODULE}} import {{SESSION_DEP_NAME}}
20
20
 
21
21
  router = APIRouter(prefix="/handles", tags=["handles"])
22
22
 
23
+ # O3 (D-handle-registry-enforcement, part 2 of 2): opt-in, defaults False -- baked in at
24
+ # `bskel handles emit --enforce-registry on` time, matching every other per-target-repo codegen
25
+ # constant this project bakes in. HandleService.register()'s Python equivalent is only ever
26
+ # called from record_snapshot.py.tmpl's opt-in @record_snapshot decorator or a target app's own
27
+ # hand-written calls -- an app that mints handles through neither path gets fully working
28
+ # fetch_handle/patch_handle with zero registry involvement today; flipping this on
29
+ # unconditionally would 404 every one of their existing handles.
30
+ ENFORCE_REGISTRY = {{ENFORCE_REGISTRY}}
31
+
32
+
33
+ # D-security-9 parity / O3 (D-handle-registry-enforcement): always looks up the PARENT RESOURCE's
34
+ # own registry row (kind="r", pointer=None) -- deliberately NOT the exact (kind, pointer) of the
35
+ # requested handle. Found live, not assumed: the record_snapshot decorator (the ONLY thing that
36
+ # ever registers automatically) always calls register("r", ..., None, ...) regardless of which
37
+ # handle actually triggered it -- a kind="f" field handle is NEVER auto-registered under its OWN
38
+ # derived UID, only the whole-resource kind="r" one is. Requiring an exact (kind, pointer) match
39
+ # (this check's original shape) made a fresh app's very FIRST field-level PATCH structurally
40
+ # impossible under enforcement: the check ran BEFORE patch_field() was ever called, so the very
41
+ # call that would (via the decorator) register something never got the chance to. This is also
42
+ # the semantically correct model, not merely a workaround -- revocation is a RESOURCE-level
43
+ # concept, so a field-level handle's access is properly gated by "is the resource itself
44
+ # registered and non-revoked", the same way fetch_handle()'s own kind="f" path already fetches
45
+ # the WHOLE resource and narrows client-side with a pointer. resource_type is still exactly
46
+ # cross-checked (the real protection D-security-9 exists for); the kind/pointer cross-check
47
+ # served no purpose beyond that once every real registration is confirmed to be resource-level.
48
+ def _require_registered_or_404(session, decoded) -> HandleRegistry:
49
+ resource_handle_uid = uuid.UUID(derive_handle_uid("r", decoded.type, decoded.uuid, None))
50
+ registry = session.get(HandleRegistry, resource_handle_uid)
51
+ if registry is None or registry.resource_type != decoded.type or registry.is_revoked:
52
+ raise HTTPException(status_code=404, detail="no active registration for this handle")
53
+ return registry
54
+
23
55
 
24
56
  @router.get("/{handle}")
25
57
  def fetch_handle(handle: str, session: {{SESSION_DEP_NAME}}):
@@ -36,6 +68,8 @@ def fetch_handle(handle: str, session: {{SESSION_DEP_NAME}}):
36
68
  if obj is None:
37
69
  raise HTTPException(status_code=404, detail="resource not found")
38
70
  resolver.check_access(session, obj)
71
+ if ENFORCE_REGISTRY:
72
+ _require_registered_or_404(session, decoded)
39
73
  public = resolver.to_public(obj)
40
74
  if decoded.pointer is None:
41
75
  return public
@@ -75,18 +109,16 @@ def patch_handle(handle: str, session: {{SESSION_DEP_NAME}}, value: dict):
75
109
  if obj is None:
76
110
  raise HTTPException(status_code=404, detail="resource not found")
77
111
  resolver.check_access(session, obj)
112
+ if ENFORCE_REGISTRY:
113
+ _require_registered_or_404(session, decoded)
78
114
  resolver.patch_field(session, obj, decoded.pointer, value)
79
115
 
80
116
 
81
- # G4 follow-up (D-handles-providers): mirrors HandleController.recover() including its full
82
- # D-security-9 cross-check -- never weakened. handle_uid alone does not prove WHAT is being
83
- # recovered (kind="r"'s derivation returns the resource UUID verbatim, no type binding baked into
84
- # the hash), so an attacker who controls `type` in the token (as long as it names a real,
85
- # registered resolver whose check_access() they can pass) could otherwise request the snapshot
86
- # history of a DIFFERENT, more sensitive resource type sharing the same UUID. Fix: cross-check the
87
- # decoded type/kind/pointer against the registry row this handle_uid was actually registered
88
- # under, and 404 on ANY disagreement -- without saying which field mismatched, so the error can't
89
- # be used to probe which part was wrong. A revoked handle is rejected the same way.
117
+ # G4 follow-up (D-handles-providers) / O3 (D-handle-registry-enforcement): mirrors
118
+ # HandleController.recover() -- structurally REQUIRES a registry row to find a snapshot's own
119
+ # primary key, so there is no "unenforced" mode for it, unlike fetch_handle/patch_handle above.
120
+ # Always calls the same shared _require_registered_or_404() those two now optionally call, so the
121
+ # paths can never silently drift apart.
90
122
  #
91
123
  # Ecosystem-honest gap, not silently resolved: Java's role check is resource-existence-independent
92
124
  # (recovering history of a since-deleted resource is legitimate), so it never calls fetch() at
@@ -109,16 +141,8 @@ def recover_handle(handle: str, session: {{SESSION_DEP_NAME}}, at: datetime | No
109
141
  obj = resolver.fetch(session, decoded.uuid)
110
142
  resolver.check_access(session, obj)
111
143
 
112
- handle_uid = uuid.UUID(derive_handle_uid(decoded.kind, decoded.type, decoded.uuid, decoded.pointer))
113
- registry = session.get(HandleRegistry, handle_uid)
114
- if (
115
- registry is None
116
- or registry.resource_type != decoded.type
117
- or registry.kind != decoded.kind
118
- or registry.pointer != decoded.pointer
119
- or registry.is_revoked
120
- ):
121
- raise HTTPException(status_code=404, detail="no snapshot recorded for this handle")
144
+ registry = _require_registered_or_404(session, decoded)
145
+ handle_uid = registry.handle_uid
122
146
 
123
147
  query = select(HandleSnapshot).where(HandleSnapshot.handle_uid == handle_uid)
124
148
  if at is not None:
@@ -89,8 +89,17 @@ export function uuidv5(namespaceUuid: string, name: string): string {
89
89
  return bytesToUuid(bytes);
90
90
  }
91
91
 
92
+ // O3 (D-handle-uid-type-binding): ALL three kinds hash a "type:uuid[:...]" discriminant through
93
+ // UUIDv5 -- kind='r' used to return uuid verbatim (no type binding at all), so two different
94
+ // resource TYPES sharing the same uuid derived the SAME handle_uid and would collide on the same
95
+ // registry primary key, were this provider to ever gain one (parity fix only -- this provider has
96
+ // no HandleRegistry table today, see D-typescript-express-provider). Provably collision-free
97
+ // across kinds without a token-format change: kind='f''s discriminant always has a pointer
98
+ // segment starting with "/" (RFC 6901), kind='o''s always ends in the literal ":o" with no
99
+ // leading slash, and kind='r''s never has a third segment at all -- no two different (kind, type,
100
+ // uuid, pointer) tuples can ever produce the same discriminant string.
92
101
  export function deriveHandleUid(kind: string, type: string, uuid: string, pointer: string | null): string {
93
- if (kind === 'r') return uuid;
102
+ if (kind === 'r') return uuidv5(NS_SBF_FIELD, `${type}:${uuid}`);
94
103
  if (kind === 'f') {
95
104
  if (!pointer) throw new Error('field handles require a pointer to derive handle_uid');
96
105
  return uuidv5(NS_SBF_FIELD, `${type}:${uuid}:${pointer}`);
package/lib/cli.mjs CHANGED
@@ -82,6 +82,16 @@ export const COMMANDS = {
82
82
  options: { feature: { type: 'string', default: REPO_GATE_ID } },
83
83
  allowPositionals: true,
84
84
  },
85
+ // D-gate-export: unlike `gate show`, always feature-scoped -- the whole point is one feature's
86
+ // own evidence trail across all 5 gates, not a single gate/repo-level snapshot.
87
+ 'gate export': {
88
+ usage: 'bskel gate export --feature <id> [--out <path>] [--json]',
89
+ options: {
90
+ feature: { type: 'string', default: null, required: true },
91
+ out: { type: 'string', default: null },
92
+ json: { type: 'boolean', default: false },
93
+ },
94
+ },
85
95
  scan: {
86
96
  usage: 'bskel scan [--feature <id>] [--terms a,b,c] [--json] [--accept-low-confidence] [--db [--database-url-env <NAME>] [--schema public]]',
87
97
  options: {
@@ -152,13 +162,18 @@ export const COMMANDS = {
152
162
  allowPositionals: true,
153
163
  },
154
164
  'contract emit': {
155
- usage: 'bskel contract emit --feature <id> [--module <name>] [--json] [--openapi-file <path>] [--path-prefix /api/v0]',
165
+ usage: 'bskel contract emit --feature <id> [--module <name>] [--json] [--openapi-file <path>] [--path-prefix /api/v0] [--descriptions]',
156
166
  options: {
157
167
  feature: { type: 'string', default: null, required: true },
158
168
  module: { type: 'string', default: null },
159
169
  json: { type: 'boolean', default: false },
160
170
  'openapi-file': { type: 'string', default: null },
161
171
  'path-prefix': { type: 'string', default: null },
172
+ // A10 (D-openapi-description): the one source-backed field that is NOT default-on --
173
+ // measured real cost (2,442.7 bytes/operation average) is larger than every other field
174
+ // this whole passthrough effort copies combined, so it stays opt-in rather than joining
175
+ // A7/A8/A9's default-on behavior.
176
+ descriptions: { type: 'boolean', default: false },
162
177
  },
163
178
  },
164
179
  // A6 (D-openapi-export): the export direction. `--allow-unprefixed` is deliberately NOT a
@@ -176,14 +191,26 @@ export const COMMANDS = {
176
191
  json: { type: 'boolean', default: false },
177
192
  },
178
193
  },
194
+ // D-contract-history: read-only, so no --module/--openapi-file/etc -- it only ever reads what
195
+ // git already recorded for this feature's own contract file.
196
+ 'contract history': {
197
+ usage: 'bskel contract history --feature <id> [--json]',
198
+ options: {
199
+ feature: { type: 'string', default: null, required: true },
200
+ json: { type: 'boolean', default: false },
201
+ },
202
+ },
179
203
  'contract waive': {
180
- usage: 'bskel contract waive --feature <id> --code <CODE> (--subject "VERB /path" | --all) --reason "..."',
204
+ usage: 'bskel contract waive --feature <id> --code <CODE> (--subject "VERB /path" | --all) --reason "..." [--expires <Nd>]',
181
205
  options: {
182
206
  feature: { type: 'string', default: null, required: true },
183
207
  code: { type: 'string', default: null, required: true },
184
208
  subject: { type: 'string', default: null },
185
209
  all: { type: 'boolean', default: false },
186
210
  reason: { type: 'string', default: '' },
211
+ // D-waiver-expiry: no default -- an un-timed waiver never expires, same "opt-in only,
212
+ // never silently starts a clock" posture --max-age-minutes already takes elsewhere.
213
+ expires: { type: 'string', default: null },
187
214
  json: { type: 'boolean', default: false },
188
215
  },
189
216
  },
@@ -230,7 +257,7 @@ export const COMMANDS = {
230
257
  },
231
258
  },
232
259
  'handles emit': {
233
- usage: 'bskel handles emit --feature <id> [--module <name>] [--resource type1,type2] [--force --reason "..."] [--check] [--diff]',
260
+ usage: 'bskel handles emit --feature <id> [--module <name>] [--resource type1,type2] [--force --reason "..."] [--check] [--diff] [--enforce-registry on|off [--reason "..."]]',
234
261
  options: {
235
262
  feature: { type: 'string', default: null, required: true },
236
263
  module: { type: 'string', default: null },
@@ -239,6 +266,27 @@ export const COMMANDS = {
239
266
  reason: { type: 'string', default: '' },
240
267
  check: { type: 'boolean', default: false },
241
268
  diff: { type: 'boolean', default: false },
269
+ // O3 (D-handle-registry-enforcement): repo-wide, singleton state (java-spring/
270
+ // python-fastapi's shared global/handle infra, not per-feature) -- omitted entirely
271
+ // (default null) reuses whatever `.sbf/handles-manifest.json` last recorded, so a
272
+ // re-emit that doesn't mention this flag never silently reverts a previously-enabled
273
+ // protection. An explicit `on`->`off` transition requires the SAME `--reason` flag
274
+ // `--force` already uses, not a new one -- mirrors that convention rather than adding
275
+ // a second audited-override mechanism.
276
+ 'enforce-registry': { type: 'string', default: null },
277
+ json: { type: 'boolean', default: false },
278
+ },
279
+ },
280
+ // O7 (D-handle-audit-report): read-only, requires --database-url-env (unlike `scan --db`,
281
+ // there is no meaningful "run without a live connection" mode for this command -- its entire
282
+ // purpose IS the live query). Reuses `--resource type1,type2`'s exact multi-value convention
283
+ // from `handles plan`/`handles emit` rather than a new singular flag name.
284
+ 'handles audit': {
285
+ usage: 'bskel handles audit --feature <id> --database-url-env <NAME> [--resource type1,type2] [--json]',
286
+ options: {
287
+ feature: { type: 'string', default: null, required: true },
288
+ 'database-url-env': { type: 'string', default: null, required: true },
289
+ resource: { type: 'string', default: '' },
242
290
  json: { type: 'boolean', default: false },
243
291
  },
244
292
  },