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.
Files changed (119) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +284 -0
  3. package/bin/bskel.mjs +2384 -0
  4. package/contracts/completeness.mjs +176 -0
  5. package/contracts/emit.mjs +287 -0
  6. package/contracts/export.mjs +325 -0
  7. package/contracts/openapi.mjs +869 -0
  8. package/contracts/validate.mjs +147 -0
  9. package/handles/_engine.mjs +281 -0
  10. package/handles/codec.mjs +119 -0
  11. package/handles/conformance.mjs +74 -0
  12. package/handles/providers/java-spring/ast-bridge.mjs +59 -0
  13. package/handles/providers/java-spring/ast-helper/build.gradle +34 -0
  14. package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.jar +0 -0
  15. package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.properties +9 -0
  16. package/handles/providers/java-spring/ast-helper/gradlew +248 -0
  17. package/handles/providers/java-spring/ast-helper/gradlew.bat +82 -0
  18. package/handles/providers/java-spring/ast-helper/settings.gradle +1 -0
  19. package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +178 -0
  20. package/handles/providers/java-spring/emit.mjs +232 -0
  21. package/handles/providers/java-spring/patch-strategy.mjs +229 -0
  22. package/handles/providers/java-spring/plan.mjs +377 -0
  23. package/handles/providers/java-spring/templates/HandleAspect.java.tmpl +125 -0
  24. package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +150 -0
  25. package/handles/providers/java-spring/templates/HandleController.java.tmpl +177 -0
  26. package/handles/providers/java-spring/templates/HandleRegistry.java.tmpl +107 -0
  27. package/handles/providers/java-spring/templates/HandleRegistryRepository.java.tmpl +8 -0
  28. package/handles/providers/java-spring/templates/HandleService.java.tmpl +95 -0
  29. package/handles/providers/java-spring/templates/HandleSnapshot.java.tmpl +75 -0
  30. package/handles/providers/java-spring/templates/HandleSnapshotRepository.java.tmpl +20 -0
  31. package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +50 -0
  32. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +50 -0
  33. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +77 -0
  34. package/handles/providers/java-spring/templates/migration.sql.tmpl +34 -0
  35. package/handles/providers/java-spring.mjs +21 -0
  36. package/handles/providers/python-fastapi/emit.mjs +171 -0
  37. package/handles/providers/python-fastapi/plan.mjs +186 -0
  38. package/handles/providers/python-fastapi/templates/__init__.py.tmpl +1 -0
  39. package/handles/providers/python-fastapi/templates/codec.py.tmpl +122 -0
  40. package/handles/providers/python-fastapi/templates/handle_service.py.tmpl +96 -0
  41. package/handles/providers/python-fastapi/templates/migration.sql.tmpl +35 -0
  42. package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +155 -0
  43. package/handles/providers/python-fastapi/templates/registry.py.tmpl +37 -0
  44. package/handles/providers/python-fastapi/templates/resolver.py.tmpl +59 -0
  45. package/handles/providers/python-fastapi/templates/resolvers_init.py.tmpl +13 -0
  46. package/handles/providers/python-fastapi/templates/router.py.tmpl +140 -0
  47. package/handles/providers/python-fastapi/templates/tables.py.tmpl +66 -0
  48. package/handles/providers/python-fastapi.mjs +22 -0
  49. package/handles/providers/typescript-express/emit.mjs +128 -0
  50. package/handles/providers/typescript-express/plan.mjs +234 -0
  51. package/handles/providers/typescript-express/templates/codec.ts.tmpl +116 -0
  52. package/handles/providers/typescript-express/templates/registry.ts.tmpl +39 -0
  53. package/handles/providers/typescript-express/templates/resolver.ts.tmpl +55 -0
  54. package/handles/providers/typescript-express/templates/resolvers_index.ts.tmpl +11 -0
  55. package/handles/providers/typescript-express/templates/router.ts.tmpl +122 -0
  56. package/handles/providers/typescript-express.mjs +20 -0
  57. package/handles/registry.mjs +90 -0
  58. package/lib/cli.mjs +430 -0
  59. package/lib/doctor.mjs +200 -0
  60. package/lib/exit-codes.mjs +67 -0
  61. package/lib/featureid.mjs +55 -0
  62. package/lib/featurelifecycle.mjs +205 -0
  63. package/lib/fsutil.mjs +50 -0
  64. package/lib/gate-definitions.mjs +293 -0
  65. package/lib/gates.mjs +263 -0
  66. package/lib/handles-manifest.mjs +92 -0
  67. package/lib/lock.mjs +68 -0
  68. package/lib/patch-approvals.mjs +56 -0
  69. package/lib/paths.mjs +21 -0
  70. package/lib/repo.mjs +44 -0
  71. package/lib/schema-validate.mjs +56 -0
  72. package/lib/state.mjs +124 -0
  73. package/lib/template.mjs +35 -0
  74. package/lib/verify.mjs +206 -0
  75. package/lib/workflow.mjs +142 -0
  76. package/new/fastapi.mjs +165 -0
  77. package/new/index.mjs +62 -0
  78. package/new/params.mjs +233 -0
  79. package/new/spring.mjs +198 -0
  80. package/new/templates/fastapi/README.md +26 -0
  81. package/new/templates/fastapi/app/__init__.py +0 -0
  82. package/new/templates/fastapi/app/main.py +8 -0
  83. package/new/templates/fastapi/gitignore +6 -0
  84. package/new/templates/fastapi/pyproject.toml +14 -0
  85. package/package.json +50 -0
  86. package/scanners/adapters/_express-shared.mjs +238 -0
  87. package/scanners/adapters/_java-spring-analyzer.mjs +273 -0
  88. package/scanners/adapters/generic-grep.mjs +128 -0
  89. package/scanners/adapters/java-spring.mjs +301 -0
  90. package/scanners/adapters/javascript-express.mjs +422 -0
  91. package/scanners/adapters/python-fastapi.mjs +348 -0
  92. package/scanners/adapters/typescript-express.mjs +299 -0
  93. package/scanners/capabilities.mjs +90 -0
  94. package/scanners/conformance.mjs +59 -0
  95. package/scanners/db/introspect.mjs +109 -0
  96. package/scanners/db/migrations.mjs +126 -0
  97. package/scanners/index.mjs +281 -0
  98. package/scanners/registry.mjs +130 -0
  99. package/scanners/render.mjs +136 -0
  100. package/scanners/text-util.mjs +8 -0
  101. package/schemas/adapter.schema.json +23 -0
  102. package/schemas/agent-envelope.schema.json +21 -0
  103. package/schemas/contract-resolution.schema.json +28 -0
  104. package/schemas/feature-contract.schema.json +78 -0
  105. package/schemas/feature-index.schema.json +25 -0
  106. package/schemas/feature.schema.json +17 -0
  107. package/schemas/gate-event.schema.json +19 -0
  108. package/schemas/handles-plan.schema.json +31 -0
  109. package/schemas/handles-provider.schema.json +26 -0
  110. package/schemas/patch-approvals.schema.json +28 -0
  111. package/schemas/scan-report.schema.json +102 -0
  112. package/schemas/stack-choice.schema.json +89 -0
  113. package/schemas/stack-record.schema.json +20 -0
  114. package/schemas/state.schema.json +43 -0
  115. package/scripts/preflight-base-ref.sh +226 -0
  116. package/stack/apply.mjs +159 -0
  117. package/stack/bootstrap/_lib.sh +73 -0
  118. package/stack/bootstrap/ngrok.sh +90 -0
  119. 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} -&gt; {@code /}, {@code ~0}
107
+ * -&gt; {@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" -&gt; 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
+ }