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.
- package/README.md +12 -2
- package/bin/bskel.mjs +260 -11
- package/contracts/completeness.mjs +27 -2
- package/contracts/emit.mjs +68 -9
- package/contracts/export.mjs +94 -25
- package/contracts/openapi.mjs +307 -40
- package/handles/audit.mjs +83 -0
- package/handles/codec.mjs +11 -5
- package/handles/providers/java-spring/emit.mjs +9 -2
- package/handles/providers/java-spring/plan.mjs +20 -0
- package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +13 -5
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +54 -20
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +17 -1
- package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +5 -0
- package/handles/providers/java-spring.mjs +2 -2
- package/handles/providers/python-fastapi/emit.mjs +5 -1
- package/handles/providers/python-fastapi/templates/codec.py.tmpl +9 -1
- package/handles/providers/python-fastapi/templates/router.py.tmpl +43 -19
- package/handles/providers/typescript-express/templates/codec.ts.tmpl +10 -1
- package/lib/cli.mjs +51 -3
- package/lib/handles-manifest.mjs +10 -4
- package/lib/repo.mjs +56 -0
- package/package.json +1 -1
- package/scanners/adapters/_express-shared.mjs +17 -12
- package/scanners/adapters/generic-grep.mjs +5 -1
- package/scanners/adapters/java-spring.mjs +60 -14
- package/scanners/adapters/javascript-express.mjs +5 -1
- package/scanners/adapters/python-fastapi.mjs +17 -14
- package/scanners/adapters/typescript-express.mjs +6 -1
- package/scanners/registry.mjs +5 -2
- package/scanners/text-util.mjs +25 -0
- package/schemas/adapter.schema.json +6 -2
- package/schemas/contract-resolution.schema.json +6 -1
- package/schemas/feature-contract.schema.json +10 -1
- package/schemas/handles-plan.schema.json +1 -0
- package/stack/bootstrap/db-up.sh +52 -0
- package/stack/bootstrap/docker-compose.postgres.yml +18 -0
- 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
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
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
|
-
|
|
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.
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
/**
|
|
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)
|
|
82
|
-
#
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
#
|
|
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
|
-
|
|
113
|
-
|
|
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
|
},
|