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.
- package/README.md +185 -13
- package/bin/bskel.mjs +689 -33
- package/contracts/emit.mjs +5 -1
- package/contracts/export.mjs +65 -7
- package/contracts/openapi.mjs +321 -30
- package/contracts/validate.mjs +23 -4
- package/handles/_engine.mjs +123 -30
- package/handles/capability-codec.mjs +94 -0
- package/handles/codec.mjs +13 -3
- package/handles/providers/java-spring/emit.mjs +78 -33
- package/handles/providers/java-spring/observe.mjs +4 -3
- package/handles/providers/java-spring/plan.mjs +73 -16
- package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +19 -1
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +19 -9
- package/handles/providers/java-spring/templates/HandleService.java.tmpl +21 -2
- package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +1 -1
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +36 -9
- package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +8 -2
- package/handles/providers/java-spring.mjs +8 -0
- package/handles/providers/python-fastapi/emit.mjs +21 -26
- package/handles/providers/python-fastapi/observe.mjs +6 -5
- package/handles/providers/python-fastapi/templates/codec.py.tmpl +18 -3
- package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +129 -39
- package/handles/providers/python-fastapi.mjs +3 -3
- package/handles/providers/typescript-express/emit.mjs +144 -55
- package/handles/providers/typescript-express/observe.mjs +102 -0
- package/handles/providers/typescript-express/templates/codec.ts.tmpl +13 -3
- package/handles/providers/typescript-express/templates/contractCheck.ts.tmpl +136 -0
- package/handles/providers/typescript-express/templates/handleEntities.ts.tmpl +89 -0
- package/handles/providers/typescript-express/templates/handleService.ts.tmpl +81 -0
- package/handles/providers/typescript-express/templates/migration.sql.tmpl +36 -0
- package/handles/providers/typescript-express/templates/observeContract.ts.tmpl +146 -0
- package/handles/providers/typescript-express/templates/observedSchema.ts.tmpl +116 -0
- package/handles/providers/typescript-express/templates/recordSnapshotWrapper.ts.tmpl +123 -0
- package/handles/providers/typescript-express/templates/registry.ts.tmpl +19 -10
- package/handles/providers/typescript-express/templates/resolver.ts.tmpl +13 -0
- package/handles/providers/typescript-express/templates/resolverPolicy.ts.tmpl +20 -0
- package/handles/providers/typescript-express/templates/router.ts.tmpl +113 -2
- package/handles/providers/typescript-express.mjs +7 -4
- package/lib/attest.mjs +40 -0
- package/lib/cli.mjs +136 -5
- package/lib/cross-feature-collisions.mjs +286 -0
- package/lib/diff.mjs +35 -0
- package/lib/exit-codes.mjs +21 -0
- package/lib/fsutil.mjs +7 -2
- package/lib/gate-definitions.mjs +85 -1
- package/lib/gates.mjs +5 -1
- package/lib/http-server.mjs +192 -6
- package/lib/lock.mjs +68 -15
- package/lib/patch-kinds.mjs +52 -0
- package/lib/patch-transactions.mjs +206 -0
- package/lib/serve-ui.html +211 -0
- package/lib/verify.mjs +23 -6
- package/lib/workflow.mjs +31 -3
- package/package.json +8 -2
- package/scanners/adapters/_java-spring-analyzer.mjs +9 -1
- package/scanners/adapters/java-spring.mjs +114 -10
- package/scanners/adapters/javascript-express.mjs +46 -13
- package/scanners/adapters/python-fastapi.mjs +9 -1
- package/scanners/adapters/typescript-express.mjs +19 -2
- package/scanners/db/ddl-apply.mjs +253 -0
- package/scanners/db/introspect.mjs +61 -32
- package/scanners/db/migrations.mjs +73 -18
- package/schemas/cross-feature-report.schema.json +66 -0
- package/schemas/cross-feature-resolution.schema.json +28 -0
- package/schemas/feature-contract.schema.json +3 -3
- package/schemas/gate-attestation.schema.json +22 -0
- package/schemas/gate-export.schema.json +58 -0
- package/schemas/handles-plan.schema.json +2 -0
- package/schemas/oracle-manifest.schema.json +58 -0
- package/schemas/patch-transaction.schema.json +182 -0
- package/schemas/scan-report.schema.json +6 -4
- package/schemas/stack-choice.schema.json +12 -1
- package/schemas/stack-record.schema.json +6 -1
- package/stack/apply.mjs +51 -7
- package/stack/catalog/ngrok.yml +8 -2
- package/stack/config-apply.mjs +168 -0
|
@@ -77,9 +77,18 @@ function findRequestBodyTypeName(controllerFilePath, methodName) {
|
|
|
77
77
|
// during this item's grounding). A DTO living somewhere else is a documented gap: patchable stays
|
|
78
78
|
// empty for that resource, exactly like findServiceFile()'s own "resolver NOT generated" fallback
|
|
79
79
|
// for a service that can't be found.
|
|
80
|
+
// D-write-safety-phase1 (item 4b): a real, bounded fallback -- after the domain/<module>/
|
|
81
|
+
// presentation/dto/ convention (Team-IZ-Backend's own shape) fails, also try the DTO directly
|
|
82
|
+
// under <module>/ with no domain/presentation/dto middle segments, the natural flat-package
|
|
83
|
+
// variant of the same convention (javaSrcRoot is already base-package-anchored -- see
|
|
84
|
+
// detectBasePackage() -- so this is `<basePackage>/<module>/<Type>.java`, not a second guessed
|
|
85
|
+
// base). Does not attempt any other shape: no second real oracle has ever validated one, and
|
|
86
|
+
// guessing further risks W9-style overfitting to an imagined repo rather than a confirmed one.
|
|
80
87
|
function findUpdateDtoFile(javaSrcRoot, module, dtoTypeName) {
|
|
81
|
-
const
|
|
82
|
-
|
|
88
|
+
const conventional = path.join(javaSrcRoot, 'domain', module, 'presentation', 'dto', `${dtoTypeName}.java`);
|
|
89
|
+
if (fs.existsSync(conventional)) return conventional;
|
|
90
|
+
const flat = path.join(javaSrcRoot, module, `${dtoTypeName}.java`);
|
|
91
|
+
return fs.existsSync(flat) ? flat : null;
|
|
83
92
|
}
|
|
84
93
|
|
|
85
94
|
// The full patchable-field pipeline for one entity: find its update endpoint -> find the
|
|
@@ -121,6 +130,7 @@ function planPatchable({ javaSrcRoot, module: moduleName, controllers, entityCla
|
|
|
121
130
|
// below already consumes -- findRequiredAuthority()/extractPreAuthorize()/classBodyStart() are
|
|
122
131
|
// completely unchanged, D-security-7's own region-carving logic untouched.
|
|
123
132
|
const HAS_ROLE_RE = /@PreAuthorize\(\s*"hasRole\('([^']+)'\)"\s*\)/;
|
|
133
|
+
const HAS_AUTHORITY_RE = /@PreAuthorize\(\s*"hasAuthority\('([^']+)'\)"\s*\)/;
|
|
124
134
|
const PRE_AUTH_RE = /@PreAuthorize\(/;
|
|
125
135
|
|
|
126
136
|
function methodMappingBoundaries(text) {
|
|
@@ -138,14 +148,26 @@ function classBodyStart(text) {
|
|
|
138
148
|
}
|
|
139
149
|
|
|
140
150
|
// Returns { authority, unsupported } for an @PreAuthorize search over one region of source text.
|
|
141
|
-
// `unsupported: true` means an @PreAuthorize annotation IS present but isn't the
|
|
142
|
-
//
|
|
143
|
-
// must fail closed (TODO_ROLE) rather than silently treating it as "no authority
|
|
144
|
-
// falling back to a weaker/wrong source.
|
|
151
|
+
// `unsupported: true` means an @PreAuthorize annotation IS present but isn't one of the two
|
|
152
|
+
// simple shapes this regex-based scanner understands (hasAnyRole, hasAnyAuthority, SpEL, etc.) --
|
|
153
|
+
// the caller must fail closed (TODO_ROLE) rather than silently treating it as "no authority
|
|
154
|
+
// found" and falling back to a weaker/wrong source.
|
|
155
|
+
//
|
|
156
|
+
// O5 (D-resolver-authorization-action-aware, hasAuthority follow-up): hasRole('X') and
|
|
157
|
+
// hasAuthority('X') are NOT interchangeable at the Spring Security level -- hasRole('X') checks
|
|
158
|
+
// for the granted authority "ROLE_X" (an implicit prefix Spring itself applies), hasAuthority('X')
|
|
159
|
+
// checks for "X" verbatim. The returned `authority` string is the LITERAL granted-authority value
|
|
160
|
+
// the generated code must match, decided HERE (plan time), not left for the template to re-derive
|
|
161
|
+
// -- HandleController.java.tmpl's requireAuthority() does one plain equality check regardless of
|
|
162
|
+
// which shape produced the value. This is the real discriminant this item's own EXIT note named:
|
|
163
|
+
// widening the regex alone, without this prefix decision, would generate an incorrect check.
|
|
145
164
|
function extractPreAuthorize(region) {
|
|
146
165
|
if (!PRE_AUTH_RE.test(region)) return null;
|
|
147
166
|
const hasRoleMatch = region.match(HAS_ROLE_RE);
|
|
148
|
-
|
|
167
|
+
if (hasRoleMatch) return { authority: `ROLE_${hasRoleMatch[1]}`, unsupported: false };
|
|
168
|
+
const hasAuthorityMatch = region.match(HAS_AUTHORITY_RE);
|
|
169
|
+
if (hasAuthorityMatch) return { authority: hasAuthorityMatch[1], unsupported: false };
|
|
170
|
+
return { authority: null, unsupported: true };
|
|
149
171
|
}
|
|
150
172
|
|
|
151
173
|
// D-security-7: a controller with more than one method can require DIFFERENT roles per method --
|
|
@@ -181,10 +203,20 @@ function findRequiredAuthority(controllerFilePath, methodName) {
|
|
|
181
203
|
// guaranteed for every entity): <Entity>Service under domain/<module>/application/. Only
|
|
182
204
|
// trusted if the file actually exists -- see D-resolver-scope in DECISIONS.md for why a
|
|
183
205
|
// resolver is only generated when this resolves to a real file, not a guessed import.
|
|
206
|
+
// D-write-safety-phase1 (item 4b): falls back to <module>/<Entity>Service.java directly under
|
|
207
|
+
// javaSrcRoot (no domain/application segments) when the conventional path doesn't exist -- the
|
|
208
|
+
// flat-package variant of the same convention. Confirmed this does NOT close the real-world case
|
|
209
|
+
// that motivated it (spring-projects/spring-petclinic): petclinic has no *Service.java at all
|
|
210
|
+
// (controllers call a Spring Data repository directly), and its entities are Integer-keyed, not
|
|
211
|
+
// UUID (see idFieldIsUuid's own gate in planHandles() below, which fires first regardless). This
|
|
212
|
+
// is a real, independent improvement for a different, plausible shape -- a UUID-keyed entity with
|
|
213
|
+
// a Service layer, just not nested under domain/ -- not a claim that it closes the petclinic gap.
|
|
184
214
|
function findServiceFile(javaSrcRoot, module, entityClassName) {
|
|
185
215
|
const guessedType = `${entityClassName}Service`;
|
|
186
|
-
const
|
|
187
|
-
|
|
216
|
+
const conventional = path.join(javaSrcRoot, 'domain', module, 'application', `${guessedType}.java`);
|
|
217
|
+
if (fs.existsSync(conventional)) return { serviceType: guessedType, file: conventional };
|
|
218
|
+
const flat = path.join(javaSrcRoot, module, `${guessedType}.java`);
|
|
219
|
+
return fs.existsSync(flat) ? { serviceType: guessedType, file: flat } : null;
|
|
188
220
|
}
|
|
189
221
|
|
|
190
222
|
// Counts top-level commas in a captured argument list, treating `<...>` (generics) as non-
|
|
@@ -233,10 +265,26 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
|
|
|
233
265
|
|
|
234
266
|
for (const entity of targetModule.entities) {
|
|
235
267
|
if (resourceFilter && !resourceFilter.includes(entity.className)) continue;
|
|
268
|
+
|
|
269
|
+
// D-write-safety-phase1 (item 4a): a non-UUID primary key disqualifies resolver generation
|
|
270
|
+
// entirely, independent of whether a Service class can be found -- fetch(UUID resourceUid)
|
|
271
|
+
// and the sbf1_ handle token format both hard-assume a UUID identity. Checked BEFORE the
|
|
272
|
+
// service lookup so the note names the real, decisive reason instead of the generic "no
|
|
273
|
+
// XService found" note below, which would be true but misleading here (implies the fix is
|
|
274
|
+
// finding/writing a service, when no service could ever make this entity addressable).
|
|
275
|
+
// `=== false` (not just falsy) deliberately excludes `null` (idField itself was never
|
|
276
|
+
// found, e.g. inherited from an unindexed superclass) -- that stays the existing, separate
|
|
277
|
+
// "no XService found" path unchanged, since this scanner genuinely doesn't know the type
|
|
278
|
+
// there, not that it's confirmed non-UUID.
|
|
279
|
+
const pkIsNonUuid = entity.idFieldIsUuid === false;
|
|
280
|
+
if (pkIsNonUuid) {
|
|
281
|
+
notes.push(`${entity.className}: primary key is declared \`${entity.idFieldType}\`, not UUID -- the handles subsystem only generates UUID-addressable resolvers (fetch(UUID resourceUid); the sbf1_ handle token format encodes a UUID). Resolver NOT generated for this entity, and cannot be regardless of where its service file lives. See D-handle-uid-type-binding in DECISIONS.md.`);
|
|
282
|
+
}
|
|
283
|
+
|
|
236
284
|
const fetchOp = findFetchOperation(targetModule.controllers, entity.className);
|
|
237
285
|
const authorityResult = findRequiredAuthority(fetchOp?.controllerFile ?? null, fetchOp?.method ?? null);
|
|
238
286
|
const requiredAuthority = authorityResult.authority;
|
|
239
|
-
const service = findServiceFile(javaSrcRoot, targetModule.module, entity.className);
|
|
287
|
+
const service = pkIsNonUuid ? null : findServiceFile(javaSrcRoot, targetModule.module, entity.className);
|
|
240
288
|
const serviceParamCount = (service && fetchOp) ? countServiceMethodParams(service.file, fetchOp.method) : null;
|
|
241
289
|
|
|
242
290
|
// O5 (D-resolver-authorization-action-aware): the SAME extraction, run again against the
|
|
@@ -253,22 +301,26 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
|
|
|
253
301
|
if (!fetchOp) {
|
|
254
302
|
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`);
|
|
255
303
|
} else if (authorityResult.unsupported) {
|
|
256
|
-
notes.push(`${entity.className}: @PreAuthorize found on ${fetchOp.controllerClassName}.${fetchOp.method} (or its class) but not in the simple hasRole('X') shape this scanner understands (e.g. hasAnyRole/SpEL) -- requiredAuthority() defaults to "TODO_ROLE" (fails closed) until a human fixes it`);
|
|
304
|
+
notes.push(`${entity.className}: @PreAuthorize found on ${fetchOp.controllerClassName}.${fetchOp.method} (or its class) but not in the simple hasRole('X')/hasAuthority('X') shape this scanner understands (e.g. hasAnyRole/SpEL) -- requiredAuthority() defaults to "TODO_ROLE" (fails closed) until a human fixes it`);
|
|
257
305
|
} else if (!requiredAuthority) {
|
|
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`);
|
|
306
|
+
notes.push(`${entity.className}: no method-level or class-level @PreAuthorize(hasRole(...)/hasAuthority(...)) found for ${fetchOp.controllerClassName}.${fetchOp.method} -- requiredAuthority() defaults to "TODO_ROLE", fix before relying on it`);
|
|
259
307
|
}
|
|
260
308
|
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`);
|
|
309
|
+
notes.push(`${entity.className}: @PreAuthorize found on ${updateOpForAuthority.controllerClassName}.${updateOpForAuthority.method} (or its class) but not in the simple hasRole('X')/hasAuthority('X') shape this scanner understands -- requiredAuthorityForPatch() defaults to "TODO_ROLE" (fails closed) until a human fixes it`);
|
|
262
310
|
} 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`);
|
|
311
|
+
notes.push(`${entity.className}: no method-level or class-level @PreAuthorize(hasRole(...)/hasAuthority(...)) found for ${updateOpForAuthority.controllerClassName}.${updateOpForAuthority.method} -- requiredAuthorityForPatch() defaults to "TODO_ROLE", fix before relying on it`);
|
|
264
312
|
}
|
|
265
313
|
if (!service) {
|
|
266
|
-
|
|
314
|
+
// pkIsNonUuid already explained the real reason above -- this note would be true but
|
|
315
|
+
// redundant (and misleading: it implies finding a service would fix it).
|
|
316
|
+
if (!pkIsNonUuid) {
|
|
317
|
+
notes.push(`${entity.className}: no ${entity.className}Service found under domain/${targetModule.module}/application/ or ${targetModule.module}/ -- resolver NOT generated for this entity (would produce a broken import). Emit it by hand once the right service is identified.`);
|
|
318
|
+
}
|
|
267
319
|
} else if (fetchOp && serviceParamCount !== 1) {
|
|
268
320
|
const reason = serviceParamCount === null
|
|
269
321
|
? `could not find a ${fetchOp.method}(...) method on ${service.serviceType} to confirm its argument count`
|
|
270
322
|
: `${service.serviceType}.${fetchOp.method} takes ${serviceParamCount} argument(s), not the single resource UUID the generated resolver always passes`;
|
|
271
|
-
notes.push(`${entity.className}: ${reason} -- resolver NOT generated (would either fail to compile or silently call the wrong overload and drop a required scoping argument, e.g. an organization/cohort id). Wire it by hand.`);
|
|
323
|
+
notes.push(`${entity.className}: ${reason} -- resolver NOT generated (would either fail to compile or silently call the wrong overload and drop a required scoping argument, e.g. an organization/cohort id). Wire it by hand -- ResourceResolver#fetch/#patchField receive the request's Authentication (D-resolver-authentication-context) for exactly this case, e.g. deriving a tenant/org id the same way the resource's own controller already does.`);
|
|
272
324
|
}
|
|
273
325
|
|
|
274
326
|
// A3 (D-patch-strategy): only worth computing once fetch()/the resolver itself is actually
|
|
@@ -306,6 +358,11 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
|
|
|
306
358
|
type: entity.className,
|
|
307
359
|
table: entity.table,
|
|
308
360
|
idField: entity.idField,
|
|
361
|
+
// D-write-safety-phase1 (item 4a): surfaced in --json output too, not just the note --
|
|
362
|
+
// null/null when the type genuinely couldn't be determined (not the same as confirmed
|
|
363
|
+
// non-UUID; see pkIsNonUuid's own `=== false` check above).
|
|
364
|
+
idFieldType: entity.idFieldType,
|
|
365
|
+
idFieldIsUuid: entity.idFieldIsUuid,
|
|
309
366
|
fetchOperation: fetchOp,
|
|
310
367
|
updateOperation: patchResult.updateOperation,
|
|
311
368
|
patchable: patchResult.patchable,
|
|
@@ -64,10 +64,28 @@ public final class HandleCodec {
|
|
|
64
64
|
return "sbf1_" + Base64.getUrlEncoder().withoutPadding().encodeToString(raw.getBytes(StandardCharsets.UTF_8));
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* D-handle-identity-contract-freeze (Phase 3): scheme-dispatch, not a hardcoded {@code sbf1_}
|
|
69
|
+
* check -- reserves the discriminant for a future {@code sbf2_} scheme (Phase 6,
|
|
70
|
+
* capability-scoped handles) to register itself here additively, without ever changing how an
|
|
71
|
+
* existing {@code sbf1_} token decodes. {@link #encode} is untouched -- it still only ever
|
|
72
|
+
* emits {@code sbf1_} tokens.
|
|
73
|
+
*/
|
|
67
74
|
public static Decoded decode(String token) {
|
|
68
|
-
|
|
75
|
+
String scheme = scheme(token);
|
|
76
|
+
if (!"sbf1".equals(scheme)) {
|
|
69
77
|
throw new IllegalArgumentException("not an sbf1 handle (missing \"sbf1_\" prefix)");
|
|
70
78
|
}
|
|
79
|
+
return decodeSbf1(token);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private static String scheme(String token) {
|
|
83
|
+
if (token == null) return null;
|
|
84
|
+
int sep = token.indexOf('_');
|
|
85
|
+
return sep == -1 ? token : token.substring(0, sep);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private static Decoded decodeSbf1(String token) {
|
|
71
89
|
if (token.length() > MAX_HANDLE_TOKEN_LENGTH) {
|
|
72
90
|
throw new IllegalArgumentException("handle token exceeds the maximum length of " + MAX_HANDLE_TOKEN_LENGTH + " characters");
|
|
73
91
|
}
|
|
@@ -4,6 +4,7 @@ import {{JACKSON_PACKAGE}}.JsonNode;
|
|
|
4
4
|
import {{JACKSON_PACKAGE}}.ObjectMapper;
|
|
5
5
|
import lombok.RequiredArgsConstructor;
|
|
6
6
|
import org.springframework.http.ResponseEntity;
|
|
7
|
+
import org.springframework.security.core.Authentication;
|
|
7
8
|
import org.springframework.security.core.GrantedAuthority;
|
|
8
9
|
import org.springframework.security.core.context.SecurityContextHolder;
|
|
9
10
|
import org.springframework.http.HttpStatus;
|
|
@@ -58,12 +59,13 @@ public class HandleController {
|
|
|
58
59
|
public ResponseEntity<Object> fetch(@PathVariable String handle) {
|
|
59
60
|
HandleCodec.Decoded decoded = decodeOrThrow(handle);
|
|
60
61
|
ResourceResolver resolver = resolverFor(decoded.type());
|
|
61
|
-
|
|
62
|
+
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
|
63
|
+
requireAuthority(authentication, resolver.requiredAuthority());
|
|
62
64
|
if (ENFORCE_REGISTRY) {
|
|
63
65
|
requireRegisteredOrThrow(decoded);
|
|
64
66
|
}
|
|
65
67
|
|
|
66
|
-
Object resource = resolver.fetch(decoded.uuid());
|
|
68
|
+
Object resource = resolver.fetch(decoded.uuid(), authentication);
|
|
67
69
|
if (decoded.pointer() == null) {
|
|
68
70
|
return ResponseEntity.ok(resource);
|
|
69
71
|
}
|
|
@@ -95,15 +97,16 @@ public class HandleController {
|
|
|
95
97
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "cannot PATCH a resource-level handle (kind=r) -- only field handles (kind=f) support PATCH");
|
|
96
98
|
}
|
|
97
99
|
ResourceResolver resolver = resolverFor(decoded.type());
|
|
100
|
+
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
|
98
101
|
// O5 (D-resolver-authorization-action-aware): the PATCH-specific authority, independently
|
|
99
102
|
// derived from the entity's own UPDATE endpoint -- NOT resolver.requiredAuthority(), which
|
|
100
103
|
// is fetch()/recover()'s own value and may legitimately require a different role.
|
|
101
|
-
requireAuthority(resolver.requiredAuthorityForPatch());
|
|
104
|
+
requireAuthority(authentication, resolver.requiredAuthorityForPatch());
|
|
102
105
|
if (ENFORCE_REGISTRY) {
|
|
103
106
|
requireRegisteredOrThrow(decoded);
|
|
104
107
|
}
|
|
105
108
|
|
|
106
|
-
resolver.patchField(decoded.uuid(), decoded.pointer(), value);
|
|
109
|
+
resolver.patchField(decoded.uuid(), decoded.pointer(), value, authentication);
|
|
107
110
|
return ResponseEntity.noContent().build();
|
|
108
111
|
}
|
|
109
112
|
|
|
@@ -111,7 +114,7 @@ public class HandleController {
|
|
|
111
114
|
public ResponseEntity<?> recover(@PathVariable String handle, @RequestParam(required = false) Instant at) {
|
|
112
115
|
HandleCodec.Decoded decoded = decodeOrThrow(handle);
|
|
113
116
|
ResourceResolver resolver = resolverFor(decoded.type());
|
|
114
|
-
requireAuthority(resolver.requiredAuthority());
|
|
117
|
+
requireAuthority(SecurityContextHolder.getContext().getAuthentication(), resolver.requiredAuthority());
|
|
115
118
|
|
|
116
119
|
// D-security-9 / O3 (D-handle-registry-enforcement): recover() structurally REQUIRES a
|
|
117
120
|
// registry row to find a snapshot's own primary key -- there is no "unenforced" mode for
|
|
@@ -200,12 +203,19 @@ public class HandleController {
|
|
|
200
203
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "no active registration for this handle"));
|
|
201
204
|
}
|
|
202
205
|
|
|
203
|
-
|
|
204
|
-
|
|
206
|
+
// O5 (D-resolver-authorization-action-aware, hasAuthority follow-up): a plain equality check --
|
|
207
|
+
// the ROLE_ prefix Spring Security implicitly applies for hasRole('X') (but not hasAuthority('X'))
|
|
208
|
+
// is already baked into requiredAuthority by plan.mjs's extractPreAuthorize() at handles-plan
|
|
209
|
+
// time, so this method never needs to know which source annotation shape produced the value.
|
|
210
|
+
// D-resolver-authentication-context: takes the already-fetched Authentication (callers now need
|
|
211
|
+
// it themselves, to thread into resolver.fetch()/patchField() too) rather than re-reading
|
|
212
|
+
// SecurityContextHolder a second time per request.
|
|
213
|
+
private void requireAuthority(Authentication authentication, String requiredAuthority) {
|
|
214
|
+
boolean granted = authentication.getAuthorities().stream()
|
|
205
215
|
.map(GrantedAuthority::getAuthority)
|
|
206
|
-
.anyMatch(authority -> authority.equals(
|
|
216
|
+
.anyMatch(authority -> authority.equals(requiredAuthority));
|
|
207
217
|
if (!granted) {
|
|
208
|
-
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "requires
|
|
218
|
+
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "requires authority " + requiredAuthority);
|
|
209
219
|
}
|
|
210
220
|
}
|
|
211
221
|
}
|
|
@@ -3,6 +3,7 @@ package {{BASE_PACKAGE}}.global.handle;
|
|
|
3
3
|
import {{JACKSON_PACKAGE}}.ObjectMapper;
|
|
4
4
|
import lombok.RequiredArgsConstructor;
|
|
5
5
|
import org.springframework.stereotype.Service;
|
|
6
|
+
import org.springframework.transaction.annotation.Propagation;
|
|
6
7
|
import org.springframework.transaction.annotation.Transactional;
|
|
7
8
|
|
|
8
9
|
import java.time.Instant;
|
|
@@ -38,8 +39,23 @@ public class HandleService {
|
|
|
38
39
|
* unique-constraint check. Returns the encoded handle token ({@link HandleCodec#encode},
|
|
39
40
|
* already implemented -- "minting" a token was never the missing piece, persisting the row
|
|
40
41
|
* backing it was).
|
|
42
|
+
*
|
|
43
|
+
* <p>D-handles-pilot-cohort: {@code REQUIRES_NEW}, not the default {@code REQUIRED} --
|
|
44
|
+
* {@link HandleAspect} calls this from WITHIN the wrapped business method's own transaction
|
|
45
|
+
* (e.g. a {@code @Transactional(readOnly = true)} read path, a real, unremarkable shape found
|
|
46
|
+
* live against {@code CohortService}). Under the default propagation this PARTICIPATES in that
|
|
47
|
+
* transaction rather than starting a new one; if it fails (readOnly rejects the INSERT, or any
|
|
48
|
+
* other real constraint), the whole shared transaction/connection is marked aborted by the
|
|
49
|
+
* database, and {@link HandleAspect#safely}'s {@code catch (Exception e)} genuinely swallows
|
|
50
|
+
* THIS method's own exception -- but the WRAPPED business call still fails when it next touches
|
|
51
|
+
* that same aborted connection, directly contradicting {@code safely()}'s own documented
|
|
52
|
+
* promise ("the wrapped call proceeds unaffected"). Confirmed live: a real disposable Postgres
|
|
53
|
+
* reproduced exactly this with {@code CohortService.findCohort} (readOnly) before this fix.
|
|
54
|
+
* {@code REQUIRES_NEW} makes this a genuinely separate transaction/connection, so a failure
|
|
55
|
+
* here can never propagate into the caller's own transaction -- matching what "best-effort,
|
|
56
|
+
* never propagated" already claimed to guarantee.
|
|
41
57
|
*/
|
|
42
|
-
@Transactional
|
|
58
|
+
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
|
43
59
|
public String register(String kind, String type, UUID resourceUid, String pointer, UUID featureUid, String operationId, String contractRef) {
|
|
44
60
|
String token = HandleCodec.encode(kind, type, resourceUid, pointer);
|
|
45
61
|
UUID handleUid = HandleCodec.deriveHandleUid(kind, type, resourceUid, pointer);
|
|
@@ -58,8 +74,11 @@ public class HandleService {
|
|
|
58
74
|
* object (a DTO, a request body, an error shape), never a pre-serialized string, so this is
|
|
59
75
|
* the one place that decides how it becomes the JSON text {@code sbf_handle_snapshot.payload}
|
|
60
76
|
* stores.
|
|
77
|
+
*
|
|
78
|
+
* <p>{@code REQUIRES_NEW} for the same reason {@link #register} now uses it -- see that
|
|
79
|
+
* method's own javadoc.
|
|
61
80
|
*/
|
|
62
|
-
@Transactional
|
|
81
|
+
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
|
63
82
|
public void recordSnapshot(UUID handleUid, String envelopeDir, String operationId, String contractHash, Object payload) {
|
|
64
83
|
// A generic `catch (Exception e)` (not the Jackson-2-specific checked
|
|
65
84
|
// JsonProcessingException, nor assuming Jackson 3's unchecked JacksonException) compiles
|
|
@@ -29,7 +29,7 @@ import java.lang.annotation.Target;
|
|
|
29
29
|
* }</pre>
|
|
30
30
|
*
|
|
31
31
|
* <p>Generated by backend-skeleton ({@code bskel handles emit}). Requires {@code
|
|
32
|
-
*
|
|
32
|
+
* {{AOP_ARTIFACT_NAME}}} on the target repo's own classpath -- NOT added automatically (see
|
|
33
33
|
* {@code handles emit}'s own post-emit notes), matching this project's established boundary of
|
|
34
34
|
* never auto-editing a target's build file.
|
|
35
35
|
*/
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
package {{BASE_PACKAGE}}.global.handle;
|
|
2
2
|
|
|
3
|
+
import org.springframework.security.core.Authentication;
|
|
4
|
+
|
|
3
5
|
import java.util.UUID;
|
|
4
6
|
|
|
5
7
|
/**
|
|
@@ -19,8 +21,24 @@ public interface ResourceResolver {
|
|
|
19
21
|
/** Must match the {@code type} segment used when this resource's handles were minted (e.g. "Organization"). */
|
|
20
22
|
String type();
|
|
21
23
|
|
|
22
|
-
/**
|
|
23
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Fetches the whole resource, serialized the same shape its normal API response uses.
|
|
26
|
+
*
|
|
27
|
+
* <p>{@code authentication} is the SAME object {@link HandleController} already reads via
|
|
28
|
+
* {@code SecurityContextHolder.getContext().getAuthentication()} for its own {@code
|
|
29
|
+
* requireAuthority()} check, passed through verbatim -- for the common case (role-only
|
|
30
|
+
* authorization, already fully covered by {@link #requiredAuthority()}) implementations
|
|
31
|
+
* ignore this parameter entirely. It exists for resources whose real read path is scoped by
|
|
32
|
+
* something {@code requiredAuthority()}'s single role string cannot express -- tenant/
|
|
33
|
+
* organization/ownership scoping derived from the caller's own identity (e.g. an org id
|
|
34
|
+
* carried in {@code authentication.getDetails()}), the same information the resource's real,
|
|
35
|
+
* hand-written controller already reads from {@code Authentication} to enforce that scoping
|
|
36
|
+
* itself. The framework interprets nothing here and never will -- same "never guess-modify
|
|
37
|
+
* hand-written code" boundary {@link #patchField} already follows; a hand-written resolver
|
|
38
|
+
* derives whatever it needs from this object using the target app's own existing helper (e.g.
|
|
39
|
+
* mirroring the source controller's own extraction logic), never a guess this generator makes.
|
|
40
|
+
*/
|
|
41
|
+
Object fetch(UUID resourceUid, Authentication authentication);
|
|
24
42
|
|
|
25
43
|
/**
|
|
26
44
|
* Applies a single field-level patch. Implementations route through the resource's EXISTING
|
|
@@ -29,16 +47,25 @@ public interface ResourceResolver {
|
|
|
29
47
|
* this feature's target module for the specific pattern to follow (some DTOs use {@code
|
|
30
48
|
* PatchField<T>} for null-has-meaning fields, most just treat null-vs-absent as "unchanged",
|
|
31
49
|
* and some require re-submitting the whole object; check the target DTO before assuming).
|
|
50
|
+
*
|
|
51
|
+
* <p>{@code authentication}: see {@link #fetch}'s own javadoc -- same object, same purpose,
|
|
52
|
+
* ignored by implementations that don't need it.
|
|
32
53
|
*/
|
|
33
|
-
void patchField(UUID resourceUid, String pointer, Object value);
|
|
54
|
+
void patchField(UUID resourceUid, String pointer, Object value, Authentication authentication);
|
|
34
55
|
|
|
35
56
|
/**
|
|
36
|
-
* Spring Security
|
|
37
|
-
* this resource type via a handle.
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
57
|
+
* The literal Spring Security granted-authority string required to {@code fetch}/{@code
|
|
58
|
+
* recover} this resource type via a handle -- e.g. {@code "ROLE_ADMIN"} when the source
|
|
59
|
+
* {@code @PreAuthorize} was {@code hasRole('ADMIN')} (Spring implicitly applies the
|
|
60
|
+
* {@code ROLE_} prefix), or a bare value like {@code "ADMIN"} when the source was
|
|
61
|
+
* {@code hasAuthority('ADMIN')} (no prefix). This distinction is decided once, at
|
|
62
|
+
* {@code bskel handles plan} time, by {@code extractPreAuthorize()} -- callers of this method
|
|
63
|
+
* never need to know which source annotation shape produced the value; they compare it
|
|
64
|
+
* verbatim against a granted authority. O5 (D-resolver-authorization-action-aware): derived
|
|
65
|
+
* from the entity's FETCH (GET) endpoint's own {@code @PreAuthorize} -- deliberately NOT
|
|
66
|
+
* reused for {@link #patchField}, which has its own {@link #requiredAuthorityForPatch()}
|
|
67
|
+
* derived from the entity's real UPDATE endpoint instead, since a real app's GET and PATCH
|
|
68
|
+
* endpoints can (and often do) require genuinely different roles.
|
|
42
69
|
*/
|
|
43
70
|
String requiredAuthority();
|
|
44
71
|
|
|
@@ -3,6 +3,7 @@ package {{BASE_PACKAGE}}.domain.{{MODULE}}.infrastructure;
|
|
|
3
3
|
import {{BASE_PACKAGE}}.global.handle.ResourceResolver;
|
|
4
4
|
import {{SERVICE_IMPORT}};
|
|
5
5
|
{{PATCH_IMPORTS}}import lombok.RequiredArgsConstructor;
|
|
6
|
+
import org.springframework.security.core.Authentication;
|
|
6
7
|
import org.springframework.stereotype.Component;
|
|
7
8
|
|
|
8
9
|
import java.util.UUID;
|
|
@@ -48,13 +49,18 @@ public class {{RESOURCE_TYPE}}Resolver implements ResourceResolver {
|
|
|
48
49
|
return {{RESOURCE_TYPE}}ResolverPolicy.type();
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
// D-resolver-authentication-context: `authentication` is unused here -- this stub is only ever
|
|
53
|
+
// generated for the single-resource-UUID-arg case (D-security-8), where the real service
|
|
54
|
+
// method needs nothing beyond resourceUid. It's still part of the signature (ResourceResolver
|
|
55
|
+
// requires it) so a hand-edit that later needs tenant/ownership scoping has it available
|
|
56
|
+
// without changing the interface again.
|
|
51
57
|
@Override
|
|
52
|
-
public Object fetch(UUID resourceUid) {
|
|
58
|
+
public Object fetch(UUID resourceUid, Authentication authentication) {
|
|
53
59
|
return {{SERVICE_FIELD}}.{{FETCH_METHOD}}(resourceUid);
|
|
54
60
|
}
|
|
55
61
|
|
|
56
62
|
@Override
|
|
57
|
-
public void patchField(UUID resourceUid, String pointer, Object value) {
|
|
63
|
+
public void patchField(UUID resourceUid, String pointer, Object value, Authentication authentication) {
|
|
58
64
|
{{PATCH_FIELD_BODY}}
|
|
59
65
|
}
|
|
60
66
|
|
|
@@ -13,6 +13,14 @@ export const provider = {
|
|
|
13
13
|
id: 'java-spring',
|
|
14
14
|
title: 'Java / Spring Boot',
|
|
15
15
|
requiresCapabilities: ['resource.fetch'],
|
|
16
|
+
// D-write-safety-phase0 (item 1): migration.sql is manifest-tracked now (classifyFile()-
|
|
17
|
+
// classified, conflict-blocked, --force --reason-audited), so it no longer needs
|
|
18
|
+
// handles/conformance.mjs's idempotence-check exclusion for correctness -- but this field is
|
|
19
|
+
// ALSO lib/verify.mjs's S6 safety net (a `handles ran` check that fires even with no manifest
|
|
20
|
+
// entry at all, e.g. a `gate force`d handles gate that never really emitted anything), which is
|
|
21
|
+
// still real and still needed. Left unchanged rather than emptied -- checkArtifacts() dedupes
|
|
22
|
+
// against the manifest-based check by path, so this doesn't produce a duplicate when a real
|
|
23
|
+
// manifest entry exists; it only fires as the fallback when one doesn't.
|
|
16
24
|
outputs: { spec: ['handles/migration.sql'] },
|
|
17
25
|
plan,
|
|
18
26
|
emit({ repoRoot, featureId, plan: handlesPlan, resourceFilter = null, force = false, reason = '', dryRun = false, computeDiff = false, enforceRegistry = false }) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import { emitUnits
|
|
4
|
+
import { emitUnits } from '../../_engine.mjs';
|
|
5
5
|
import { sha256File } from '../../../lib/fsutil.mjs';
|
|
6
6
|
import { specPath } from '../../../lib/paths.mjs';
|
|
7
7
|
import { loadFeatureFile } from '../../../lib/featurelifecycle.mjs';
|
|
@@ -16,11 +16,6 @@ const RESOLVER_TEMPLATE = path.join(TEMPLATES_DIR, 'resolver.py.tmpl');
|
|
|
16
16
|
const POLICY_TEMPLATE = path.join(TEMPLATES_DIR, 'resolver_policy.py.tmpl');
|
|
17
17
|
const MIGRATION_TEMPLATE = path.join(TEMPLATES_DIR, 'migration.sql.tmpl');
|
|
18
18
|
|
|
19
|
-
function writeUnit(target, content) {
|
|
20
|
-
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
21
|
-
fs.writeFileSync(target, content);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
19
|
function render(templatePath, vars) {
|
|
25
20
|
let content = fs.readFileSync(templatePath, 'utf8');
|
|
26
21
|
for (const [key, value] of Object.entries(vars)) {
|
|
@@ -175,23 +170,20 @@ export function emitPythonFastApi({ repoRoot, featureId, plan, resourceFilter =
|
|
|
175
170
|
},
|
|
176
171
|
} : null;
|
|
177
172
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
// manifest-tracked infra/resolver kinds, same D4/outputs.spec category P4's conformance
|
|
184
|
-
// harness already special-cases.
|
|
185
|
-
const migrationContent = render(MIGRATION_TEMPLATE, { FEATURE_ID: featureId });
|
|
173
|
+
// D-write-safety-phase0 (item 1): mirrors java-spring/emit.mjs's own fix exactly -- migration.sql
|
|
174
|
+
// used to be regenerated fresh every run, unconditionally, never manifest-tracked. Reuses
|
|
175
|
+
// emitUnits()'s postResolverUnits slot (kind/ownership/owner overridden for feature ownership;
|
|
176
|
+
// widened to an array in D-typescript-express-registry-parity) instead of a separate,
|
|
177
|
+
// untracked write path.
|
|
186
178
|
const migrationPath = path.join(repoRoot, 'specs', featureId, 'handles', 'migration.sql');
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
179
|
+
const result = emitUnits({
|
|
180
|
+
repoRoot, featureId, provider: 'python-fastapi', force, reason, infraUnits, resolverUnits, orphanScan, dryRun, computeDiff,
|
|
181
|
+
postResolverUnits: [{
|
|
182
|
+
id: 'migration.sql.tmpl', templatePath: MIGRATION_TEMPLATE, targetAbs: migrationPath,
|
|
183
|
+
render: () => render(MIGRATION_TEMPLATE, { FEATURE_ID: featureId }),
|
|
184
|
+
kind: 'migration', ownership: 'feature', owner: featureId,
|
|
185
|
+
}],
|
|
186
|
+
});
|
|
195
187
|
|
|
196
188
|
const postEmitNotes = [
|
|
197
189
|
'NOT done automatically: applying specs/<id>/handles/migration.sql to any database. Review it and apply yourself.',
|
|
@@ -209,16 +201,19 @@ export function emitPythonFastApi({ repoRoot, featureId, plan, resourceFilter =
|
|
|
209
201
|
|
|
210
202
|
// O3 follow-up (D-handle-registry-enforcement, "Continued"): per-resource, conditional on
|
|
211
203
|
// enforceRegistry.
|
|
204
|
+
// D-write-safety-phase1 (item 2): mirrors java-spring/emit.mjs's own registrationGaps exactly --
|
|
205
|
+
// see that file's comment for the full reasoning.
|
|
206
|
+
const registrationGaps = [];
|
|
212
207
|
if (enforceRegistry) {
|
|
213
208
|
for (const resource of plan.resources) {
|
|
214
209
|
if (!resource.willGenerateResolver) continue;
|
|
215
210
|
if (hasRecordSnapshot(resource.fetchRoute.file)) continue;
|
|
216
211
|
const relRouteFile = path.relative(repoRoot, resource.fetchRoute.file);
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
);
|
|
212
|
+
const note = `${resource.type}: --enforce-registry is on, but no @record_snapshot(...) was found anywhere in ${relRouteFile} -- this resource may never get its first registry row, and every fetch/patch call against it will 404 until something registers it. Apply @record_snapshot to its own create-flow route function (or call handle_service.register() by hand at least once per resource), then re-emit. See D-handle-registry-enforcement in DECISIONS.md for the full bootstrapping explanation.`;
|
|
213
|
+
postEmitNotes.push(note);
|
|
214
|
+
registrationGaps.push({ resourceType: resource.type, file: relRouteFile, note });
|
|
220
215
|
}
|
|
221
216
|
}
|
|
222
217
|
|
|
223
|
-
return { ...result, postEmitNotes };
|
|
218
|
+
return { ...result, postEmitNotes, registrationGaps };
|
|
224
219
|
}
|
|
@@ -62,11 +62,12 @@ export function emitObservePythonFastApi({ repoRoot, featureId, contract, plan,
|
|
|
62
62
|
|
|
63
63
|
const result = emitUnits({ repoRoot, featureId, provider: 'python-fastapi', force, reason, infraUnits, resolverUnits: [], orphanScan: null, dryRun, computeDiff });
|
|
64
64
|
|
|
65
|
-
// The projected observed-schema.json resource -- regenerated unconditionally every run
|
|
66
|
-
// handles' own migration.sql
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
65
|
+
// The projected observed-schema.json resource -- regenerated unconditionally every run (unlike
|
|
66
|
+
// handles' own migration.sql, which moved to manifest tracking in D-write-safety-phase0; this
|
|
67
|
+
// file, and java observe's own schema resource, stay unconditional: nobody hand-finishes a
|
|
68
|
+
// generated data file the way they hand-finish a resolver stub, so O2-style conflict tracking
|
|
69
|
+
// buys nothing here). `kind: 'spec'` still means "always regenerated, not conflict-tracked".
|
|
70
|
+
// Discovered at runtime via a plain glob under observe/schemas/
|
|
70
71
|
// (see observed_schema.py.tmpl's own docstring for why -- no importlib.resources needed since
|
|
71
72
|
// this ecosystem only ever runs from source).
|
|
72
73
|
const operations = {};
|
|
@@ -63,9 +63,7 @@ class DecodedHandle:
|
|
|
63
63
|
self.pointer = pointer
|
|
64
64
|
|
|
65
65
|
|
|
66
|
-
def
|
|
67
|
-
if not isinstance(token, str) or not token.startswith("sbf1_"):
|
|
68
|
-
raise ValueError('not an sbf1 handle (missing "sbf1_" prefix)')
|
|
66
|
+
def _decode_sbf1(token: str) -> DecodedHandle:
|
|
69
67
|
if len(token) > MAX_HANDLE_TOKEN_LENGTH:
|
|
70
68
|
raise ValueError(f"handle token exceeds the maximum length of {MAX_HANDLE_TOKEN_LENGTH} characters")
|
|
71
69
|
raw = _base64url_decode(token[len("sbf1_"):]).decode("utf-8")
|
|
@@ -76,6 +74,23 @@ def decode_handle(token: str) -> DecodedHandle:
|
|
|
76
74
|
return DecodedHandle(kind.lower(), type_, resource_uuid.lower(), pointer)
|
|
77
75
|
|
|
78
76
|
|
|
77
|
+
# D-handle-identity-contract-freeze (Phase 3): scheme-dispatch table, not a hardcoded "sbf1_"
|
|
78
|
+
# check -- reserves the discriminant for a future "sbf2_" scheme (Phase 6, capability-scoped
|
|
79
|
+
# handles) to register itself here additively, without ever changing how an existing "sbf1_"
|
|
80
|
+
# token decodes. encode_handle() is untouched -- it still only ever emits "sbf1_" tokens.
|
|
81
|
+
_HANDLE_DECODERS = {"sbf1": _decode_sbf1}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def decode_handle(token: str) -> DecodedHandle:
|
|
85
|
+
if not isinstance(token, str):
|
|
86
|
+
raise ValueError('not an sbf1 handle (missing "sbf1_" prefix)')
|
|
87
|
+
scheme = token.split("_", 1)[0]
|
|
88
|
+
decoder = _HANDLE_DECODERS.get(scheme)
|
|
89
|
+
if decoder is None:
|
|
90
|
+
raise ValueError('not an sbf1 handle (missing "sbf1_" prefix)')
|
|
91
|
+
return decoder(token)
|
|
92
|
+
|
|
93
|
+
|
|
79
94
|
# O3 (D-handle-uid-type-binding): ALL three kinds hash a "type:uuid[:...]" discriminant through
|
|
80
95
|
# UUIDv5 -- kind="r" used to return resource_uuid verbatim (no type binding at all), so two
|
|
81
96
|
# different resource TYPES sharing the same resourceUid derived the SAME handle_uid and collided
|