backend-skeleton 1.0.0-beta.1
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/LICENSE +21 -0
- package/README.md +284 -0
- package/bin/bskel.mjs +2384 -0
- package/contracts/completeness.mjs +176 -0
- package/contracts/emit.mjs +287 -0
- package/contracts/export.mjs +325 -0
- package/contracts/openapi.mjs +869 -0
- package/contracts/validate.mjs +147 -0
- package/handles/_engine.mjs +281 -0
- package/handles/codec.mjs +119 -0
- package/handles/conformance.mjs +74 -0
- package/handles/providers/java-spring/ast-bridge.mjs +59 -0
- package/handles/providers/java-spring/ast-helper/build.gradle +34 -0
- package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.properties +9 -0
- package/handles/providers/java-spring/ast-helper/gradlew +248 -0
- package/handles/providers/java-spring/ast-helper/gradlew.bat +82 -0
- package/handles/providers/java-spring/ast-helper/settings.gradle +1 -0
- package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +178 -0
- package/handles/providers/java-spring/emit.mjs +232 -0
- package/handles/providers/java-spring/patch-strategy.mjs +229 -0
- package/handles/providers/java-spring/plan.mjs +377 -0
- package/handles/providers/java-spring/templates/HandleAspect.java.tmpl +125 -0
- package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +150 -0
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +177 -0
- package/handles/providers/java-spring/templates/HandleRegistry.java.tmpl +107 -0
- package/handles/providers/java-spring/templates/HandleRegistryRepository.java.tmpl +8 -0
- package/handles/providers/java-spring/templates/HandleService.java.tmpl +95 -0
- package/handles/providers/java-spring/templates/HandleSnapshot.java.tmpl +75 -0
- package/handles/providers/java-spring/templates/HandleSnapshotRepository.java.tmpl +20 -0
- package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +50 -0
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +50 -0
- package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +77 -0
- package/handles/providers/java-spring/templates/migration.sql.tmpl +34 -0
- package/handles/providers/java-spring.mjs +21 -0
- package/handles/providers/python-fastapi/emit.mjs +171 -0
- package/handles/providers/python-fastapi/plan.mjs +186 -0
- package/handles/providers/python-fastapi/templates/__init__.py.tmpl +1 -0
- package/handles/providers/python-fastapi/templates/codec.py.tmpl +122 -0
- package/handles/providers/python-fastapi/templates/handle_service.py.tmpl +96 -0
- package/handles/providers/python-fastapi/templates/migration.sql.tmpl +35 -0
- package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +155 -0
- package/handles/providers/python-fastapi/templates/registry.py.tmpl +37 -0
- package/handles/providers/python-fastapi/templates/resolver.py.tmpl +59 -0
- package/handles/providers/python-fastapi/templates/resolvers_init.py.tmpl +13 -0
- package/handles/providers/python-fastapi/templates/router.py.tmpl +140 -0
- package/handles/providers/python-fastapi/templates/tables.py.tmpl +66 -0
- package/handles/providers/python-fastapi.mjs +22 -0
- package/handles/providers/typescript-express/emit.mjs +128 -0
- package/handles/providers/typescript-express/plan.mjs +234 -0
- package/handles/providers/typescript-express/templates/codec.ts.tmpl +116 -0
- package/handles/providers/typescript-express/templates/registry.ts.tmpl +39 -0
- package/handles/providers/typescript-express/templates/resolver.ts.tmpl +55 -0
- package/handles/providers/typescript-express/templates/resolvers_index.ts.tmpl +11 -0
- package/handles/providers/typescript-express/templates/router.ts.tmpl +122 -0
- package/handles/providers/typescript-express.mjs +20 -0
- package/handles/registry.mjs +90 -0
- package/lib/cli.mjs +430 -0
- package/lib/doctor.mjs +200 -0
- package/lib/exit-codes.mjs +67 -0
- package/lib/featureid.mjs +55 -0
- package/lib/featurelifecycle.mjs +205 -0
- package/lib/fsutil.mjs +50 -0
- package/lib/gate-definitions.mjs +293 -0
- package/lib/gates.mjs +263 -0
- package/lib/handles-manifest.mjs +92 -0
- package/lib/lock.mjs +68 -0
- package/lib/patch-approvals.mjs +56 -0
- package/lib/paths.mjs +21 -0
- package/lib/repo.mjs +44 -0
- package/lib/schema-validate.mjs +56 -0
- package/lib/state.mjs +124 -0
- package/lib/template.mjs +35 -0
- package/lib/verify.mjs +206 -0
- package/lib/workflow.mjs +142 -0
- package/new/fastapi.mjs +165 -0
- package/new/index.mjs +62 -0
- package/new/params.mjs +233 -0
- package/new/spring.mjs +198 -0
- package/new/templates/fastapi/README.md +26 -0
- package/new/templates/fastapi/app/__init__.py +0 -0
- package/new/templates/fastapi/app/main.py +8 -0
- package/new/templates/fastapi/gitignore +6 -0
- package/new/templates/fastapi/pyproject.toml +14 -0
- package/package.json +50 -0
- package/scanners/adapters/_express-shared.mjs +238 -0
- package/scanners/adapters/_java-spring-analyzer.mjs +273 -0
- package/scanners/adapters/generic-grep.mjs +128 -0
- package/scanners/adapters/java-spring.mjs +301 -0
- package/scanners/adapters/javascript-express.mjs +422 -0
- package/scanners/adapters/python-fastapi.mjs +348 -0
- package/scanners/adapters/typescript-express.mjs +299 -0
- package/scanners/capabilities.mjs +90 -0
- package/scanners/conformance.mjs +59 -0
- package/scanners/db/introspect.mjs +109 -0
- package/scanners/db/migrations.mjs +126 -0
- package/scanners/index.mjs +281 -0
- package/scanners/registry.mjs +130 -0
- package/scanners/render.mjs +136 -0
- package/scanners/text-util.mjs +8 -0
- package/schemas/adapter.schema.json +23 -0
- package/schemas/agent-envelope.schema.json +21 -0
- package/schemas/contract-resolution.schema.json +28 -0
- package/schemas/feature-contract.schema.json +78 -0
- package/schemas/feature-index.schema.json +25 -0
- package/schemas/feature.schema.json +17 -0
- package/schemas/gate-event.schema.json +19 -0
- package/schemas/handles-plan.schema.json +31 -0
- package/schemas/handles-provider.schema.json +26 -0
- package/schemas/patch-approvals.schema.json +28 -0
- package/schemas/scan-report.schema.json +102 -0
- package/schemas/stack-choice.schema.json +89 -0
- package/schemas/stack-record.schema.json +20 -0
- package/schemas/state.schema.json +43 -0
- package/scripts/preflight-base-ref.sh +226 -0
- package/stack/apply.mjs +159 -0
- package/stack/bootstrap/_lib.sh +73 -0
- package/stack/bootstrap/ngrok.sh +90 -0
- package/stack/catalog/ngrok.yml +63 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { findMappingAnnotations, findMethodParams, maskNonCode, skipAnnotationsAndWhitespace } from '../../../scanners/adapters/_java-spring-analyzer.mjs';
|
|
5
|
+
import { classifyDtoFields, splitTopLevelParams, extractTypeAndName } from './patch-strategy.mjs';
|
|
6
|
+
|
|
7
|
+
// The "canonical fetch" for an entity: a GET endpoint whose path is exactly
|
|
8
|
+
// `${controller.basePath}/{id}` (one trailing path param, nothing after it) on a controller
|
|
9
|
+
// whose CLASS NAME contains the entity's name -- e.g. `GET /organizations/{organizationId}` on
|
|
10
|
+
// `OrganizationController` for entity `Organization`, not `OperatorController`'s endpoints
|
|
11
|
+
// (`OperatorController` doesn't contain "Organization", so it's never considered even though
|
|
12
|
+
// it lives in the same module and its base path also starts with `/organizations/...`).
|
|
13
|
+
//
|
|
14
|
+
// Bug this fixes (found while testing against the real module, which has BOTH
|
|
15
|
+
// OrganizationController and OperatorController): using controllers[0]'s basePath for every
|
|
16
|
+
// entity, instead of each candidate controller's own basePath + a name-affinity check, matched
|
|
17
|
+
// Organization's fetch operation against OperatorController's basePath and silently found
|
|
18
|
+
// nothing (or worse, could have matched the wrong controller's endpoint in a module shaped
|
|
19
|
+
// differently).
|
|
20
|
+
function findFetchOperation(controllers, entityClassName) {
|
|
21
|
+
const needle = entityClassName.toLowerCase();
|
|
22
|
+
for (const controller of controllers) {
|
|
23
|
+
if (!controller.className.toLowerCase().includes(needle)) continue;
|
|
24
|
+
for (const ep of controller.endpoints) {
|
|
25
|
+
if (ep.verb !== 'GET' || !ep.operationId) continue;
|
|
26
|
+
const suffix = ep.path.slice(controller.basePath.length);
|
|
27
|
+
if (/^\/\{[^/]+\}$/.test(suffix)) {
|
|
28
|
+
return { operationId: ep.operationId, method: ep.method, path: ep.path, controllerFile: controller.file, controllerClassName: controller.className };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// A3 (D-patch-strategy): the update-endpoint counterpart to findFetchOperation() above -- same
|
|
36
|
+
// name-affinity-gated controller search (a controller must contain the entity's name), same
|
|
37
|
+
// single-path-param shape (`${controller.basePath}/{id}`, nothing after it) confirmed against
|
|
38
|
+
// every real update endpoint in the oracle repo's grounding (all `@PatchMapping`, PUT accepted
|
|
39
|
+
// too since nothing in this codebase's own conventions rules it out). Deliberately does not
|
|
40
|
+
// require an operationId -- unlike fetch(), patch codegen never needs one, only the controller
|
|
41
|
+
// file + Java method name to locate the @RequestBody DTO parameter.
|
|
42
|
+
function findUpdateOperation(controllers, entityClassName) {
|
|
43
|
+
const needle = entityClassName.toLowerCase();
|
|
44
|
+
for (const controller of controllers) {
|
|
45
|
+
if (!controller.className.toLowerCase().includes(needle)) continue;
|
|
46
|
+
for (const ep of controller.endpoints) {
|
|
47
|
+
if (ep.verb !== 'PATCH' && ep.verb !== 'PUT') continue;
|
|
48
|
+
const suffix = ep.path.slice(controller.basePath.length);
|
|
49
|
+
if (/^\/\{[^/]+\}$/.test(suffix)) {
|
|
50
|
+
return { method: ep.method, path: ep.path, controllerFile: controller.file, controllerClassName: controller.className };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// From a controller method's own parameter-list text, finds the @RequestBody-annotated
|
|
58
|
+
// parameter's declared type name (e.g. "UpdateOrganizationRequest") -- reuses the same
|
|
59
|
+
// annotation-skipping/top-level-split primitives patch-strategy.mjs already exports for
|
|
60
|
+
// classifying a DTO's own fields, so a request-body param with several annotations in any order
|
|
61
|
+
// (`@Valid @RequestBody X x` or `@RequestBody @Valid X x`) is found the same way either way.
|
|
62
|
+
// Returns null if no @RequestBody parameter is found (a GET-shaped or bodyless update method,
|
|
63
|
+
// which for PATCH/PUT would be unusual but is not assumed impossible).
|
|
64
|
+
function findRequestBodyTypeName(controllerFilePath, methodName) {
|
|
65
|
+
const params = findMethodParams(fs.readFileSync(controllerFilePath, 'utf8'), methodName);
|
|
66
|
+
if (params === null) return null;
|
|
67
|
+
const maskedParams = maskNonCode(params);
|
|
68
|
+
const segment = splitTopLevelParams(maskedParams).find((s) => /@RequestBody\b/.test(s));
|
|
69
|
+
if (!segment) return null;
|
|
70
|
+
const parsed = extractTypeAndName(segment);
|
|
71
|
+
return parsed ? parsed.baseType : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Resolves the update DTO's own .java file the same way findServiceFile() resolves a service --
|
|
75
|
+
// only trusted if the file actually exists at the convention this codebase's real DTOs all use
|
|
76
|
+
// (domain/<module>/presentation/dto/<TypeName>.java, confirmed against all 17 real update DTOs
|
|
77
|
+
// during this item's grounding). A DTO living somewhere else is a documented gap: patchable stays
|
|
78
|
+
// empty for that resource, exactly like findServiceFile()'s own "resolver NOT generated" fallback
|
|
79
|
+
// for a service that can't be found.
|
|
80
|
+
function findUpdateDtoFile(javaSrcRoot, module, dtoTypeName) {
|
|
81
|
+
const guessedPath = path.join(javaSrcRoot, 'domain', module, 'presentation', 'dto', `${dtoTypeName}.java`);
|
|
82
|
+
return fs.existsSync(guessedPath) ? guessedPath : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The full patchable-field pipeline for one entity: find its update endpoint -> find the
|
|
86
|
+
// @RequestBody DTO type -> find that DTO's file -> classify its fields. Returns `{ patchable:
|
|
87
|
+
// [...], updateOperation, updateDtoFile, notes: [...] }` -- notes explain exactly which step
|
|
88
|
+
// failed when patchable ends up empty, mirroring willGenerateResolver's own note-per-reason
|
|
89
|
+
// convention rather than a silent empty array.
|
|
90
|
+
function planPatchable({ javaSrcRoot, module: moduleName, controllers, entityClassName }) {
|
|
91
|
+
const notes = [];
|
|
92
|
+
const updateOperation = findUpdateOperation(controllers, entityClassName);
|
|
93
|
+
if (!updateOperation) {
|
|
94
|
+
return { patchable: [], updateOperation: null, updateDtoFile: null, notes: [`${entityClassName}: no PATCH/PUT single-resource endpoint found -- patchField() stays a blanket stub`] };
|
|
95
|
+
}
|
|
96
|
+
const dtoTypeName = findRequestBodyTypeName(updateOperation.controllerFile, updateOperation.method);
|
|
97
|
+
if (!dtoTypeName) {
|
|
98
|
+
notes.push(`${entityClassName}: found ${updateOperation.controllerClassName}.${updateOperation.method} but couldn't determine its @RequestBody DTO type -- patchField() stays a blanket stub`);
|
|
99
|
+
return { patchable: [], updateOperation, updateDtoFile: null, notes };
|
|
100
|
+
}
|
|
101
|
+
const updateDtoFile = findUpdateDtoFile(javaSrcRoot, moduleName, dtoTypeName);
|
|
102
|
+
if (!updateDtoFile) {
|
|
103
|
+
notes.push(`${entityClassName}: request body type "${dtoTypeName}" not found under domain/${moduleName}/presentation/dto/ -- patchField() stays a blanket stub`);
|
|
104
|
+
return { patchable: [], updateOperation, updateDtoFile: null, notes };
|
|
105
|
+
}
|
|
106
|
+
const classified = classifyDtoFields(fs.readFileSync(updateDtoFile, 'utf8'));
|
|
107
|
+
if (!classified) {
|
|
108
|
+
notes.push(`${entityClassName}: ${dtoTypeName} is not a record (or has no canonical constructor) -- patchField() stays a blanket stub`);
|
|
109
|
+
return { patchable: [], updateOperation, updateDtoFile, notes };
|
|
110
|
+
}
|
|
111
|
+
return { patchable: classified.fields, updateOperation, updateDtoFile, dtoTypeName, notes };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// A2 Phase 1 (D-java-analyzer): this used to duplicate scanners/adapters/java-spring.mjs's own
|
|
115
|
+
// (then-brittle) mapping regex, kept separate only because THIS function needs each match's
|
|
116
|
+
// source *position* (to locate the region immediately above one specific method), not just the
|
|
117
|
+
// endpoint list plan.mjs already has -- the earlier comment here explicitly earmarked "a
|
|
118
|
+
// different catalog item's territory" for whoever eventually fixed the regex itself. That's this
|
|
119
|
+
// item: findMappingAnnotations() (shared with the scanner) now owns the actual matching, this
|
|
120
|
+
// file only maps its richer records down to the {index, methodName} shape findRequiredAuthority()
|
|
121
|
+
// below already consumes -- findRequiredAuthority()/extractPreAuthorize()/classBodyStart() are
|
|
122
|
+
// completely unchanged, D-security-7's own region-carving logic untouched.
|
|
123
|
+
const HAS_ROLE_RE = /@PreAuthorize\(\s*"hasRole\('([^']+)'\)"\s*\)/;
|
|
124
|
+
const PRE_AUTH_RE = /@PreAuthorize\(/;
|
|
125
|
+
|
|
126
|
+
function methodMappingBoundaries(text) {
|
|
127
|
+
return findMappingAnnotations(text).map((m) => ({ index: m.index, methodName: m.methodName }));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Index just after the class body's opening brace -- the lower bound for a method-level search
|
|
131
|
+
// when the target is the FIRST method in the file (no prior method boundary to anchor to).
|
|
132
|
+
// Without this, that search's region would fall back to 0 and swallow the class-level
|
|
133
|
+
// annotations (@PreAuthorize included) that sit BEFORE `class X {`, which is exactly the
|
|
134
|
+
// class-vs-method conflation this fix exists to prevent.
|
|
135
|
+
function classBodyStart(text) {
|
|
136
|
+
const m = text.match(/\bclass\s+\w+[^{]*\{/);
|
|
137
|
+
return m ? m.index + m[0].length : 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 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 simple
|
|
142
|
+
// hasRole('X') shape this regex-based scanner understands (hasAnyRole, SpEL, etc.) -- the caller
|
|
143
|
+
// must fail closed (TODO_ROLE) rather than silently treating it as "no authority found" and
|
|
144
|
+
// falling back to a weaker/wrong source.
|
|
145
|
+
function extractPreAuthorize(region) {
|
|
146
|
+
if (!PRE_AUTH_RE.test(region)) return null;
|
|
147
|
+
const hasRoleMatch = region.match(HAS_ROLE_RE);
|
|
148
|
+
return hasRoleMatch ? { authority: hasRoleMatch[1], unsupported: false } : { authority: null, unsupported: true };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// D-security-7: a controller with more than one method can require DIFFERENT roles per method --
|
|
152
|
+
// the previous version always used the file's FIRST @PreAuthorize match, which for a controller
|
|
153
|
+
// whose first-declared method happens to carry a weaker role than the actual fetch method being
|
|
154
|
+
// planned would silently generate a resolver enforcing that weaker role instead. Found by the
|
|
155
|
+
// Codex security review. Now searches method-level first: the region from the previous method's
|
|
156
|
+
// mapping annotation (exclusive) up to this method's mapping annotation (exclusive) is exactly
|
|
157
|
+
// the span that can only contain this method's own annotations, never the previous method's (its
|
|
158
|
+
// own @PreAuthorize, if any, sits before that boundary). Only falls back to a genuine class-level
|
|
159
|
+
// @PreAuthorize -- the region before the FIRST method mapping in the file -- when the method
|
|
160
|
+
// level has nothing at all.
|
|
161
|
+
function findRequiredAuthority(controllerFilePath, methodName) {
|
|
162
|
+
if (!controllerFilePath || !methodName || !fs.existsSync(controllerFilePath)) {
|
|
163
|
+
return { authority: null, unsupported: false };
|
|
164
|
+
}
|
|
165
|
+
const text = fs.readFileSync(controllerFilePath, 'utf8');
|
|
166
|
+
const boundaries = methodMappingBoundaries(text);
|
|
167
|
+
const target = boundaries.find((b) => b.methodName === methodName);
|
|
168
|
+
if (!target) return { authority: null, unsupported: false };
|
|
169
|
+
|
|
170
|
+
const priorBoundaries = boundaries.filter((b) => b.index < target.index);
|
|
171
|
+
const methodRegionStart = priorBoundaries.length > 0 ? priorBoundaries[priorBoundaries.length - 1].index : classBodyStart(text);
|
|
172
|
+
const methodLevel = extractPreAuthorize(text.slice(methodRegionStart, target.index));
|
|
173
|
+
if (methodLevel) return methodLevel;
|
|
174
|
+
|
|
175
|
+
const classRegion = text.slice(0, boundaries[0].index);
|
|
176
|
+
const classLevel = extractPreAuthorize(classRegion);
|
|
177
|
+
return classLevel ?? { authority: null, unsupported: false };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Heuristic (this codebase's convention, verified for Organization -> OrganizationService, not
|
|
181
|
+
// guaranteed for every entity): <Entity>Service under domain/<module>/application/. Only
|
|
182
|
+
// trusted if the file actually exists -- see D-resolver-scope in DECISIONS.md for why a
|
|
183
|
+
// resolver is only generated when this resolves to a real file, not a guessed import.
|
|
184
|
+
function findServiceFile(javaSrcRoot, module, entityClassName) {
|
|
185
|
+
const guessedType = `${entityClassName}Service`;
|
|
186
|
+
const guessedPath = path.join(javaSrcRoot, 'domain', module, 'application', `${guessedType}.java`);
|
|
187
|
+
return fs.existsSync(guessedPath) ? { serviceType: guessedType, file: guessedPath } : null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Counts top-level commas in a captured argument list, treating `<...>` (generics) as non-
|
|
191
|
+
// splitting -- good enough for interface method signatures, which is all this reads.
|
|
192
|
+
function countTopLevelCommas(argsText) {
|
|
193
|
+
let depth = 0;
|
|
194
|
+
let count = 0;
|
|
195
|
+
for (const ch of argsText) {
|
|
196
|
+
if (ch === '<') depth++;
|
|
197
|
+
else if (ch === '>') depth = Math.max(0, depth - 1);
|
|
198
|
+
else if (ch === ',' && depth === 0) count++;
|
|
199
|
+
}
|
|
200
|
+
return count;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// D-security-8: ResourceResolverStub.java.tmpl always generates `fetch(UUID resourceUid)` as
|
|
204
|
+
// `{{SERVICE_FIELD}}.{{FETCH_METHOD}}(resourceUid)` -- exactly one argument, by construction. If
|
|
205
|
+
// the real service method actually requires more (a common shape for anything scoped under an
|
|
206
|
+
// org/cohort, e.g. `find(UUID organizationId, UUID cohortId)`), that's not just a compile error:
|
|
207
|
+
// a method with the SAME NAME but a different single-UUID-arg overload could exist and get called
|
|
208
|
+
// instead, silently dropping the scoping argument (an IDOR-shaped bug, not just a build failure).
|
|
209
|
+
// Found by the Codex security review. Returns null if the method signature can't be found at all
|
|
210
|
+
// (fails closed the same as a param-count mismatch -- caller must not assume 1).
|
|
211
|
+
function countServiceMethodParams(serviceFilePath, methodName) {
|
|
212
|
+
if (!serviceFilePath || !methodName || !fs.existsSync(serviceFilePath)) return null;
|
|
213
|
+
const text = fs.readFileSync(serviceFilePath, 'utf8');
|
|
214
|
+
const escaped = methodName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
215
|
+
const sigRe = new RegExp(`\\S+(?:<[^;{}]*?>)?\\s+${escaped}\\s*\\(([^)]*)\\)`);
|
|
216
|
+
const match = text.match(sigRe);
|
|
217
|
+
if (!match) return null;
|
|
218
|
+
const argsText = match[1].trim();
|
|
219
|
+
return argsText === '' ? 0 : countTopLevelCommas(argsText) + 1;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resourceFilter }) {
|
|
223
|
+
const targetModule = moduleName
|
|
224
|
+
? scanReport.related_modules.find((m) => m.module === moduleName)
|
|
225
|
+
: scanReport.related_modules[0];
|
|
226
|
+
|
|
227
|
+
if (!targetModule) {
|
|
228
|
+
return { module: null, resources: [], notes: ['no related module in the scan report -- run `bskel scan` first, or pass --module explicitly'] };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const resources = [];
|
|
232
|
+
const notes = [];
|
|
233
|
+
|
|
234
|
+
for (const entity of targetModule.entities) {
|
|
235
|
+
if (resourceFilter && !resourceFilter.includes(entity.className)) continue;
|
|
236
|
+
const fetchOp = findFetchOperation(targetModule.controllers, entity.className);
|
|
237
|
+
const authorityResult = findRequiredAuthority(fetchOp?.controllerFile ?? null, fetchOp?.method ?? null);
|
|
238
|
+
const requiredAuthority = authorityResult.authority;
|
|
239
|
+
const service = findServiceFile(javaSrcRoot, targetModule.module, entity.className);
|
|
240
|
+
const serviceParamCount = (service && fetchOp) ? countServiceMethodParams(service.file, fetchOp.method) : null;
|
|
241
|
+
|
|
242
|
+
if (!fetchOp) {
|
|
243
|
+
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
|
+
} else if (authorityResult.unsupported) {
|
|
245
|
+
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`);
|
|
246
|
+
} else if (!requiredAuthority) {
|
|
247
|
+
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
|
+
}
|
|
249
|
+
if (!service) {
|
|
250
|
+
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
|
+
} else if (fetchOp && serviceParamCount !== 1) {
|
|
252
|
+
const reason = serviceParamCount === null
|
|
253
|
+
? `could not find a ${fetchOp.method}(...) method on ${service.serviceType} to confirm its argument count`
|
|
254
|
+
: `${service.serviceType}.${fetchOp.method} takes ${serviceParamCount} argument(s), not the single resource UUID the generated resolver always passes`;
|
|
255
|
+
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.`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// A3 (D-patch-strategy): only worth computing once fetch()/the resolver itself is actually
|
|
259
|
+
// going to be generated -- an entity with no resolver has nowhere for patchField() codegen
|
|
260
|
+
// to land anyway. Reuses countServiceMethodParams() (D-security-8) against the UPDATE
|
|
261
|
+
// method, expecting exactly 2 args (resource id + the DTO) -- the same IDOR-shaped-bug
|
|
262
|
+
// concern fetch()'s own param-count check exists for: a real update method scoped under an
|
|
263
|
+
// org/cohort (e.g. `update(UUID orgId, UUID cohortId, UpdateXRequest req)`) must never be
|
|
264
|
+
// silently called with the wrong overload or a missing scoping argument.
|
|
265
|
+
let patchResult = { patchable: [], updateOperation: null, updateDtoFile: null, updateServiceBlockedReason: null, notes: [] };
|
|
266
|
+
if (fetchOp && service && serviceParamCount === 1) {
|
|
267
|
+
patchResult = { ...planPatchable({ javaSrcRoot, module: targetModule.module, controllers: targetModule.controllers, entityClassName: entity.className }), updateServiceBlockedReason: null };
|
|
268
|
+
if (patchResult.updateOperation) {
|
|
269
|
+
const updateServiceParamCount = countServiceMethodParams(service.file, patchResult.updateOperation.method);
|
|
270
|
+
if (updateServiceParamCount !== 2) {
|
|
271
|
+
// Classification itself (patchable) stays intact and is still surfaced -- only
|
|
272
|
+
// CODEGEN is blocked. A field's bucket is a fact about the DTO, independent of
|
|
273
|
+
// whether the update service method happens to be safely callable with the (id,
|
|
274
|
+
// dto) shape generated code always assumes; losing that classification here would
|
|
275
|
+
// silently give up on this item's own "precise per-field reason" value the moment
|
|
276
|
+
// a real service signature doesn't match (found live: none of the 3 real update
|
|
277
|
+
// service methods checked during this item's grounding -- Organization/Classroom/
|
|
278
|
+
// Cohort -- actually have the plain 2-arg shape, every one carries an extra
|
|
279
|
+
// scoping/auditing argument -- so this is the COMMON case, not an edge case).
|
|
280
|
+
const reason = updateServiceParamCount === null
|
|
281
|
+
? `could not find a ${patchResult.updateOperation.method}(...) method on ${service.serviceType} to confirm its argument count`
|
|
282
|
+
: `${service.serviceType}.${patchResult.updateOperation.method} takes ${updateServiceParamCount} argument(s), not the (resource id, request DTO) pair generated patch code always passes`;
|
|
283
|
+
patchResult = { ...patchResult, updateServiceBlockedReason: reason, notes: [...patchResult.notes, `${entity.className}: ${reason} -- patchField() fields are classified but none are auto-generated`] };
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
notes.push(...patchResult.notes);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
resources.push({
|
|
290
|
+
type: entity.className,
|
|
291
|
+
table: entity.table,
|
|
292
|
+
idField: entity.idField,
|
|
293
|
+
fetchOperation: fetchOp,
|
|
294
|
+
updateOperation: patchResult.updateOperation,
|
|
295
|
+
patchable: patchResult.patchable,
|
|
296
|
+
dtoTypeName: patchResult.dtoTypeName ?? null,
|
|
297
|
+
// A2 Phase 2 (D-java-ast-helper): the one piece of data `bskel handles plan --ast`
|
|
298
|
+
// needs that wasn't previously surfaced past this function's own internal patchResult.
|
|
299
|
+
updateDtoFile: patchResult.updateDtoFile ?? null,
|
|
300
|
+
updateServiceBlockedReason: patchResult.updateServiceBlockedReason,
|
|
301
|
+
requiredAuthority: requiredAuthority ?? 'TODO_ROLE',
|
|
302
|
+
service,
|
|
303
|
+
willGenerateResolver: Boolean(fetchOp && service && serviceParamCount === 1),
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (resources.length === 0) {
|
|
308
|
+
notes.push(`no entities found for module "${targetModule.module}" ${resourceFilter ? `matching --resource filter [${resourceFilter.join(', ')}]` : ''} -- nothing to plan.`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return { module: targetModule.module, resources, notes };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Detected from the Spring Boot `*Application.java` file's own package declaration, rather
|
|
315
|
+
// than assumed/configured -- works for any Spring Boot project following the standard
|
|
316
|
+
// convention, not just Team-IZ-Backend's specific `com.bigproject.backend`. Moved here from the
|
|
317
|
+
// pre-G4 handles/emit.mjs -- G1's original `bin/bskel.mjs`-level `detectBasePackageOrExit` no
|
|
318
|
+
// longer exists; the CLI has no Java-specific knowledge left, this provider owns it entirely.
|
|
319
|
+
//
|
|
320
|
+
// O6: previously used files[0] unconditionally when the glob matched more than one
|
|
321
|
+
// *Application.java -- silently picking whichever one `rg --files`'s (unordered, see the .sort()
|
|
322
|
+
// below) traversal happened to return first. Multiple candidates that all declare the SAME
|
|
323
|
+
// package (a common multi-module-monorepo shape) aren't actually ambiguous, so that case still
|
|
324
|
+
// resolves quietly; only genuinely DIFFERENT packages throw, naming every candidate so the caller
|
|
325
|
+
// can see why. There is no existing repo in this project's real-world testing with more than one
|
|
326
|
+
// application root, so this is unverified against a real multi-app case -- see
|
|
327
|
+
// D-artifact-determinism's EXIT in DECISIONS.md for why no override flag was added speculatively.
|
|
328
|
+
export function detectBasePackage(repoRoot) {
|
|
329
|
+
const srcRoot = path.join(repoRoot, 'src', 'main', 'java');
|
|
330
|
+
if (!fs.existsSync(srcRoot)) return null;
|
|
331
|
+
let files;
|
|
332
|
+
try {
|
|
333
|
+
files = execFileSync('rg', ['--files', '-g', '*Application.java', srcRoot], { encoding: 'utf8' }).split('\n').filter(Boolean).sort();
|
|
334
|
+
} catch {
|
|
335
|
+
files = [];
|
|
336
|
+
}
|
|
337
|
+
if (files.length === 0) return null;
|
|
338
|
+
const packages = new Set(
|
|
339
|
+
files.map((f) => fs.readFileSync(f, 'utf8').match(/^package\s+([\w.]+);/m)?.[1]).filter(Boolean),
|
|
340
|
+
);
|
|
341
|
+
if (packages.size > 1) {
|
|
342
|
+
throw new Error(
|
|
343
|
+
`ambiguous base package -- found ${files.length} *Application.java file(s) declaring ${packages.size} different packages: ` +
|
|
344
|
+
`${files.map((f) => path.relative(repoRoot, f)).join(', ')}. This tool doesn't support multi-application-root repos yet.`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
return packages.size === 1 ? [...packages][0] : null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// The descriptor-facing entry point (handles/providers/java-spring.mjs's provider.plan). Wraps
|
|
351
|
+
// planHandles() above with base-package detection and the framework-neutral sbf.handles-plan/1
|
|
352
|
+
// envelope -- see schemas/handles-plan.schema.json. `basePackage` rides along as a provider-
|
|
353
|
+
// specific extra field (additionalProperties: true) so emit() below can reuse the SAME detected
|
|
354
|
+
// value instead of re-detecting it (each is a separate `bskel handles plan`/`bskel handles emit`
|
|
355
|
+
// process invocation, but within one process this plan object is computed once and threaded
|
|
356
|
+
// through -- a small improvement over the pre-G4 code, which detected it independently in each
|
|
357
|
+
// command's own function body; detectBasePackage is deterministic so this changes no observable
|
|
358
|
+
// behavior).
|
|
359
|
+
export function plan({ repoRoot, scanReport, module: moduleName, resourceFilter }) {
|
|
360
|
+
const basePackage = detectBasePackage(repoRoot);
|
|
361
|
+
if (!basePackage) {
|
|
362
|
+
throw new Error('could not detect the base package (no *Application.java found under src/main/java) -- is this a Spring Boot project?');
|
|
363
|
+
}
|
|
364
|
+
const javaSrcRoot = path.join(repoRoot, 'src', 'main', 'java', ...basePackage.split('.'));
|
|
365
|
+
const inner = planHandles({ javaSrcRoot, scanReport, module: moduleName, resourceFilter });
|
|
366
|
+
return {
|
|
367
|
+
schema: 'sbf.handles-plan/1',
|
|
368
|
+
provider: 'java-spring',
|
|
369
|
+
basePackage,
|
|
370
|
+
module: inner.module,
|
|
371
|
+
resources: inner.resources.map((r) => ({
|
|
372
|
+
...r,
|
|
373
|
+
readPath: (r.service && r.fetchOperation) ? `${r.service.serviceType}.${r.fetchOperation.method}()` : null,
|
|
374
|
+
})),
|
|
375
|
+
notes: inner.notes,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
package {{BASE_PACKAGE}}.global.handle;
|
|
2
|
+
|
|
3
|
+
import {{JACKSON_PACKAGE}}.JsonNode;
|
|
4
|
+
import {{JACKSON_PACKAGE}}.ObjectMapper;
|
|
5
|
+
import {{JACKSON_PACKAGE}}.node.ObjectNode;
|
|
6
|
+
import lombok.RequiredArgsConstructor;
|
|
7
|
+
import lombok.extern.slf4j.Slf4j;
|
|
8
|
+
import org.aspectj.lang.ProceedingJoinPoint;
|
|
9
|
+
import org.aspectj.lang.annotation.Around;
|
|
10
|
+
import org.aspectj.lang.annotation.Aspect;
|
|
11
|
+
import org.springframework.stereotype.Component;
|
|
12
|
+
|
|
13
|
+
import java.util.ArrayList;
|
|
14
|
+
import java.util.List;
|
|
15
|
+
import java.util.Map;
|
|
16
|
+
import java.util.UUID;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* O4 (D-handle-lifecycle): the opt-in AUTOMATIC half of the handle lifecycle -- {@link
|
|
20
|
+
* HandleService} is the explicit API a human can call directly; this aspect exists only for
|
|
21
|
+
* methods a human has chosen to mark with {@link RecordHandleSnapshot}. Never activates on
|
|
22
|
+
* anything else, and a failure recording a snapshot is ALWAYS logged and swallowed, never allowed
|
|
23
|
+
* to fail the real business call it wraps -- snapshot recording is best-effort observability, not
|
|
24
|
+
* a new way for an unrelated write path to start failing.
|
|
25
|
+
*
|
|
26
|
+
* <p>Generated by backend-skeleton ({@code bskel handles emit}). Requires {@code
|
|
27
|
+
* spring-boot-starter-aop} on the classpath -- see {@link RecordHandleSnapshot}'s own javadoc.
|
|
28
|
+
*/
|
|
29
|
+
@Aspect
|
|
30
|
+
@Component
|
|
31
|
+
@RequiredArgsConstructor
|
|
32
|
+
@Slf4j
|
|
33
|
+
public class HandleAspect {
|
|
34
|
+
|
|
35
|
+
private final Map<String, ResourceResolver> resolversByBeanName;
|
|
36
|
+
private final HandleService handleService;
|
|
37
|
+
private final ObjectMapper objectMapper;
|
|
38
|
+
|
|
39
|
+
@Around("@annotation(recordHandleSnapshot)")
|
|
40
|
+
public Object record(ProceedingJoinPoint joinPoint, RecordHandleSnapshot recordHandleSnapshot) throws Throwable {
|
|
41
|
+
ResourceResolver resolver = resolverFor(recordHandleSnapshot.resourceType());
|
|
42
|
+
if (resolver == null) {
|
|
43
|
+
log.warn("HandleAspect: no resolver registered for resourceType \"{}\" on {} -- skipping snapshot recording, the wrapped call proceeds unaffected", recordHandleSnapshot.resourceType(), joinPoint.getSignature());
|
|
44
|
+
return joinPoint.proceed();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
Object[] args = joinPoint.getArgs();
|
|
48
|
+
int uidParam = recordHandleSnapshot.resourceUidParam();
|
|
49
|
+
if (uidParam < 0 || uidParam >= args.length || !(args[uidParam] instanceof UUID resourceUid)) {
|
|
50
|
+
log.warn("HandleAspect: resourceUidParam {} on {} does not resolve to a UUID argument -- skipping snapshot recording, the wrapped call proceeds unaffected", uidParam, joinPoint.getSignature());
|
|
51
|
+
return joinPoint.proceed();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
UUID handleUid = HandleCodec.deriveHandleUid("r", recordHandleSnapshot.resourceType(), resourceUid, null);
|
|
55
|
+
String contractRef = resolver.contractRef();
|
|
56
|
+
safely(() -> {
|
|
57
|
+
handleService.register("r", recordHandleSnapshot.resourceType(), resourceUid, null, resolver.featureUid(), recordHandleSnapshot.operationId(), contractRef);
|
|
58
|
+
recordEnvelope(handleUid, "request", recordHandleSnapshot.operationId(), contractRef, requestPayload(args, uidParam), recordHandleSnapshot.redact());
|
|
59
|
+
}, handleUid, "request");
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
Object result = joinPoint.proceed();
|
|
63
|
+
safely(() -> recordEnvelope(handleUid, "response", recordHandleSnapshot.operationId(), contractRef, result, recordHandleSnapshot.redact()), handleUid, "response");
|
|
64
|
+
return result;
|
|
65
|
+
} catch (Throwable t) {
|
|
66
|
+
safely(() -> recordEnvelope(handleUid, "error", recordHandleSnapshot.operationId(), contractRef, Map.of("message", String.valueOf(t.getMessage())), recordHandleSnapshot.redact()), handleUid, "error");
|
|
67
|
+
throw t;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The remaining args once the resource-uid one is excluded -- the sole survivor unwrapped (the common case: one DTO), otherwise a list. */
|
|
72
|
+
private static Object requestPayload(Object[] args, int uidParam) {
|
|
73
|
+
List<Object> rest = new ArrayList<>();
|
|
74
|
+
for (int i = 0; i < args.length; i++) {
|
|
75
|
+
if (i != uidParam) rest.add(args[i]);
|
|
76
|
+
}
|
|
77
|
+
return rest.size() == 1 ? rest.get(0) : rest;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private void recordEnvelope(UUID handleUid, String envelopeDir, String operationId, String contractRef, Object payload, String[] redactPointers) {
|
|
81
|
+
JsonNode node = objectMapper.valueToTree(payload);
|
|
82
|
+
if (node instanceof ObjectNode objectNode) {
|
|
83
|
+
for (String pointer : redactPointers) {
|
|
84
|
+
redact(objectNode, pointer);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
handleService.recordSnapshot(handleUid, envelopeDir, operationId, contractRef, node);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Best-effort: any failure here is logged, never propagated -- see this class's own javadoc for why. */
|
|
91
|
+
private void safely(Runnable action, UUID handleUid, String envelopeDir) {
|
|
92
|
+
try {
|
|
93
|
+
action.run();
|
|
94
|
+
} catch (Exception e) {
|
|
95
|
+
log.warn("HandleAspect: could not record {} snapshot for handle {} -- the wrapped call proceeds unaffected", envelopeDir, handleUid, e);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private ResourceResolver resolverFor(String type) {
|
|
100
|
+
return resolversByBeanName.values().stream().filter(r -> r.type().equals(type)).findFirst().orElse(null);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Walks to `pointer`'s parent node and blanks the leaf value -- Jackson's {@code JsonNode} has
|
|
105
|
+
* no built-in "set/remove at pointer" (unlike {@code JsonNode#at} for READING one), so this is
|
|
106
|
+
* a small, purpose-built navigator. RFC 6901 escaping ({@code ~1} -> {@code /}, {@code ~0}
|
|
107
|
+
* -> {@code ~}) mirrors the same decoding {@code handles/codec.mjs}'s own
|
|
108
|
+
* {@code resolveJsonPointer} JS reference implementation applies.
|
|
109
|
+
*/
|
|
110
|
+
private static void redact(ObjectNode root, String pointer) {
|
|
111
|
+
if (pointer == null || pointer.isEmpty() || !pointer.startsWith("/")) return;
|
|
112
|
+
String[] parts = pointer.substring(1).split("/");
|
|
113
|
+
for (int i = 0; i < parts.length; i++) {
|
|
114
|
+
parts[i] = parts[i].replace("~1", "/").replace("~0", "~");
|
|
115
|
+
}
|
|
116
|
+
JsonNode current = root;
|
|
117
|
+
for (int i = 0; i < parts.length - 1; i++) {
|
|
118
|
+
if (current == null || !current.isObject()) return;
|
|
119
|
+
current = current.get(parts[i]);
|
|
120
|
+
}
|
|
121
|
+
if (current instanceof ObjectNode parent && parent.has(parts[parts.length - 1])) {
|
|
122
|
+
parent.put(parts[parts.length - 1], "***REDACTED***");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
package {{BASE_PACKAGE}}.global.handle;
|
|
2
|
+
|
|
3
|
+
import java.nio.charset.StandardCharsets;
|
|
4
|
+
import java.security.MessageDigest;
|
|
5
|
+
import java.security.NoSuchAlgorithmException;
|
|
6
|
+
import java.util.Base64;
|
|
7
|
+
import java.util.UUID;
|
|
8
|
+
import java.util.regex.Matcher;
|
|
9
|
+
import java.util.regex.Pattern;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Encodes/decodes backend-skeleton "handles" -- {@code kind:type:uuid[:pointer]}, base64url
|
|
13
|
+
* with an {@code sbf1_} prefix. Extends Relay's {@code base64(Type:id)} global-ID pattern with
|
|
14
|
+
* an RFC 6901 JSON Pointer for field-level addressing.
|
|
15
|
+
*
|
|
16
|
+
* <p>Must stay behavior-identical to {@code handles/codec.mjs} (the JS reference
|
|
17
|
+
* implementation this was generated from) -- cross-checked by EXECUTION, not just assertion, in
|
|
18
|
+
* {@code test/handles-java-codec.test.mjs} (both directions, positive and negative parity), for
|
|
19
|
+
* the exact same inputs producing the exact same tokens/handle_uids, and against the standard
|
|
20
|
+
* UUIDv5 test vector (NAMESPACE_DNS + "example.com" -> cfbff0d1-9375-5685-968c-48ce8b15ae17).
|
|
21
|
+
*
|
|
22
|
+
* <p>Generated by backend-skeleton ({@code bskel handles emit}). Do not hand-edit -- change the
|
|
23
|
+
* source template and regenerate, or the JS/Java implementations will silently diverge.
|
|
24
|
+
*/
|
|
25
|
+
public final class HandleCodec {
|
|
26
|
+
|
|
27
|
+
private static final Pattern HANDLE_PATTERN = Pattern.compile(
|
|
28
|
+
"^([rfo]):([^:]+):([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})(?::(.*))?$");
|
|
29
|
+
|
|
30
|
+
// D-security-10: defense-in-depth cap, not a functional requirement -- real handles are well
|
|
31
|
+
// under this. Must match handles/codec.mjs's MAX_HANDLE_TOKEN_LENGTH. Found by the Codex
|
|
32
|
+
// security review.
|
|
33
|
+
private static final int MAX_HANDLE_TOKEN_LENGTH = 2048;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Fixed namespace UUID for field-handle derivation. Must match {@code NS_SBF_FIELD} in
|
|
37
|
+
* {@code handles/codec.mjs} exactly -- changing it silently re-derives every existing
|
|
38
|
+
* field_uid to a different value.
|
|
39
|
+
*/
|
|
40
|
+
public static final UUID NS_SBF_FIELD = UUID.fromString("a3f1c2e0-8b4d-4f1a-9c3e-1d2b3a4c5d6e");
|
|
41
|
+
|
|
42
|
+
private HandleCodec() {
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
public record Decoded(String kind, String type, UUID uuid, String pointer) {
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
public static String encode(String kind, String type, UUID uuid, String pointer) {
|
|
49
|
+
if (!kind.equals("r") && !kind.equals("f") && !kind.equals("o")) {
|
|
50
|
+
throw new IllegalArgumentException("invalid handle kind \"" + kind + "\" (expected r, f, or o)");
|
|
51
|
+
}
|
|
52
|
+
if (type == null || uuid == null) {
|
|
53
|
+
throw new IllegalArgumentException("encode requires both type and uuid");
|
|
54
|
+
}
|
|
55
|
+
if (kind.equals("f") && (pointer == null || pointer.isEmpty())) {
|
|
56
|
+
throw new IllegalArgumentException("field handles (kind=f) require a JSON Pointer");
|
|
57
|
+
}
|
|
58
|
+
// D-security-10: symmetric case -- a non-field handle must not carry a pointer either
|
|
59
|
+
// (only kind=f addresses a field). Found by the Codex security review.
|
|
60
|
+
if (!kind.equals("f") && pointer != null && !pointer.isEmpty()) {
|
|
61
|
+
throw new IllegalArgumentException("handle kind \"" + kind + "\" must not carry a JSON Pointer (only kind=f field handles do)");
|
|
62
|
+
}
|
|
63
|
+
String raw = kind + ":" + type + ":" + uuid + (pointer != null && !pointer.isEmpty() ? ":" + pointer : "");
|
|
64
|
+
return "sbf1_" + Base64.getUrlEncoder().withoutPadding().encodeToString(raw.getBytes(StandardCharsets.UTF_8));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
public static Decoded decode(String token) {
|
|
68
|
+
if (token == null || !token.startsWith("sbf1_")) {
|
|
69
|
+
throw new IllegalArgumentException("not an sbf1 handle (missing \"sbf1_\" prefix)");
|
|
70
|
+
}
|
|
71
|
+
if (token.length() > MAX_HANDLE_TOKEN_LENGTH) {
|
|
72
|
+
throw new IllegalArgumentException("handle token exceeds the maximum length of " + MAX_HANDLE_TOKEN_LENGTH + " characters");
|
|
73
|
+
}
|
|
74
|
+
String raw;
|
|
75
|
+
try {
|
|
76
|
+
raw = new String(Base64.getUrlDecoder().decode(token.substring(5)), StandardCharsets.UTF_8);
|
|
77
|
+
} catch (IllegalArgumentException e) {
|
|
78
|
+
throw new IllegalArgumentException("not valid base64url after the sbf1_ prefix", e);
|
|
79
|
+
}
|
|
80
|
+
Matcher matcher = HANDLE_PATTERN.matcher(raw);
|
|
81
|
+
if (!matcher.matches()) {
|
|
82
|
+
throw new IllegalArgumentException("malformed handle payload after decoding: \"" + raw + "\"");
|
|
83
|
+
}
|
|
84
|
+
String kind = matcher.group(1).toLowerCase();
|
|
85
|
+
String type = matcher.group(2);
|
|
86
|
+
UUID uuid = UUID.fromString(matcher.group(3).toLowerCase());
|
|
87
|
+
String pointer = matcher.group(4);
|
|
88
|
+
return new Decoded(kind, type, uuid, pointer);
|
|
89
|
+
}
|
|
90
|
+
|
|
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.
|
|
96
|
+
*/
|
|
97
|
+
public static UUID deriveHandleUid(String kind, String type, UUID uuid, String pointer) {
|
|
98
|
+
return switch (kind) {
|
|
99
|
+
case "r" -> uuid;
|
|
100
|
+
case "f" -> {
|
|
101
|
+
if (pointer == null || pointer.isEmpty()) {
|
|
102
|
+
throw new IllegalArgumentException("field handles require a pointer to derive handle_uid");
|
|
103
|
+
}
|
|
104
|
+
yield uuidv5(NS_SBF_FIELD, type + ":" + uuid + ":" + pointer);
|
|
105
|
+
}
|
|
106
|
+
case "o" -> uuidv5(NS_SBF_FIELD, type + ":" + uuid + ":o");
|
|
107
|
+
default -> throw new IllegalArgumentException("invalid handle kind \"" + kind + "\"");
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** RFC 4122 UUIDv5 (name-based, SHA-1). */
|
|
112
|
+
public static UUID uuidv5(UUID namespace, String name) {
|
|
113
|
+
try {
|
|
114
|
+
MessageDigest sha1 = MessageDigest.getInstance("SHA-1");
|
|
115
|
+
sha1.update(uuidToBytes(namespace));
|
|
116
|
+
sha1.update(name.getBytes(StandardCharsets.UTF_8));
|
|
117
|
+
byte[] hash = sha1.digest();
|
|
118
|
+
byte[] bytes = new byte[16];
|
|
119
|
+
System.arraycopy(hash, 0, bytes, 0, 16);
|
|
120
|
+
bytes[6] = (byte) ((bytes[6] & 0x0f) | 0x50); // version 5
|
|
121
|
+
bytes[8] = (byte) ((bytes[8] & 0x3f) | 0x80); // variant RFC 4122
|
|
122
|
+
return bytesToUuid(bytes);
|
|
123
|
+
} catch (NoSuchAlgorithmException e) {
|
|
124
|
+
throw new IllegalStateException("SHA-1 unavailable", e);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private static byte[] uuidToBytes(UUID uuid) {
|
|
129
|
+
byte[] bytes = new byte[16];
|
|
130
|
+
long msb = uuid.getMostSignificantBits();
|
|
131
|
+
long lsb = uuid.getLeastSignificantBits();
|
|
132
|
+
for (int i = 0; i < 8; i++) {
|
|
133
|
+
bytes[i] = (byte) (msb >>> (8 * (7 - i)));
|
|
134
|
+
bytes[8 + i] = (byte) (lsb >>> (8 * (7 - i)));
|
|
135
|
+
}
|
|
136
|
+
return bytes;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private static UUID bytesToUuid(byte[] bytes) {
|
|
140
|
+
long msb = 0;
|
|
141
|
+
long lsb = 0;
|
|
142
|
+
for (int i = 0; i < 8; i++) {
|
|
143
|
+
msb = (msb << 8) | (bytes[i] & 0xff);
|
|
144
|
+
}
|
|
145
|
+
for (int i = 8; i < 16; i++) {
|
|
146
|
+
lsb = (lsb << 8) | (bytes[i] & 0xff);
|
|
147
|
+
}
|
|
148
|
+
return new UUID(msb, lsb);
|
|
149
|
+
}
|
|
150
|
+
}
|