backend-skeleton 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +113 -8
  2. package/bin/bskel.mjs +331 -53
  3. package/contracts/openapi.mjs +125 -18
  4. package/handles/providers/java-spring/ast-bridge.mjs +85 -1
  5. package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +407 -0
  6. package/handles/providers/java-spring/emit.mjs +126 -6
  7. package/handles/providers/java-spring/plan.mjs +220 -74
  8. package/handles/providers/java-spring/source-splice.mjs +477 -0
  9. package/handles/providers/java-spring/templates/AuthorizationPolicyStub.java.tmpl +30 -0
  10. package/handles/providers/java-spring/templates/HandleController.java.tmpl +21 -3
  11. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +26 -0
  12. package/handles/providers/java-spring/templates/ResourceResolverPolicyStub.java.tmpl +9 -0
  13. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +3 -3
  14. package/lib/attest.mjs +59 -1
  15. package/lib/cli.mjs +79 -7
  16. package/lib/doctor.mjs +23 -0
  17. package/lib/exit-codes.mjs +17 -0
  18. package/lib/gate-definitions.mjs +65 -2
  19. package/lib/gate-export.mjs +199 -0
  20. package/lib/impact-export-graphify.mjs +145 -0
  21. package/lib/impact-graph.mjs +194 -0
  22. package/lib/impact-surface.mjs +158 -0
  23. package/lib/impact.mjs +286 -0
  24. package/lib/patch-kinds.mjs +24 -0
  25. package/lib/repo.mjs +46 -0
  26. package/lib/workflow.mjs +16 -0
  27. package/package.json +1 -1
  28. package/scanners/adapters/_java-spring-analyzer.mjs +6 -0
  29. package/schemas/gate-attestation.schema.json +6 -1
  30. package/schemas/gate-export.schema.json +530 -22
  31. package/schemas/handles-plan.schema.json +32 -0
  32. package/schemas/impact-baseline.schema.json +59 -0
  33. package/schemas/impact-graph.schema.json +53 -0
  34. package/schemas/impact-report.schema.json +86 -0
  35. package/schemas/impact-resolution.schema.json +33 -0
  36. package/schemas/java-source-splice.schema.json +84 -0
  37. package/schemas/patch-transaction.schema.json +87 -2
@@ -1,7 +1,8 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { execFileSync } from 'node:child_process';
4
- import { findMappingAnnotations, findMethodParams, maskNonCode, skipAnnotationsAndWhitespace } from '../../../scanners/adapters/_java-spring-analyzer.mjs';
4
+ import { findMappingAnnotations, findMethodParams, maskNonCode, matchBalanced } from '../../../scanners/adapters/_java-spring-analyzer.mjs';
5
+ import { lineNumberAt } from '../../../scanners/text-util.mjs';
5
6
  import { classifyDtoFields, splitTopLevelParams, extractTypeAndName } from './patch-strategy.mjs';
6
7
 
7
8
  // The "canonical fetch" for an entity: a GET endpoint whose path is exactly
@@ -126,21 +127,62 @@ function planPatchable({ javaSrcRoot, module: moduleName, controllers, entityCla
126
127
  return { patchable: classified.fields, updateOperation, updateDtoFile, dtoTypeName, notes };
127
128
  }
128
129
 
129
- // A2 Phase 1 (D-java-analyzer): this used to duplicate scanners/adapters/java-spring.mjs's own
130
- // (then-brittle) mapping regex, kept separate only because THIS function needs each match's
131
- // source *position* (to locate the region immediately above one specific method), not just the
132
- // endpoint list plan.mjs already has -- the earlier comment here explicitly earmarked "a
133
- // different catalog item's territory" for whoever eventually fixed the regex itself. That's this
134
- // item: findMappingAnnotations() (shared with the scanner) now owns the actual matching, this
135
- // file only maps its richer records down to the {index, methodName} shape findRequiredAuthority()
136
- // below already consumes -- findRequiredAuthority()/extractPreAuthorize()/classBodyStart() are
137
- // completely unchanged, D-security-7's own region-carving logic untouched.
130
+ // D-resolver-policy-contract (PC1/PC2/PC3): replaces the old extractPreAuthorize()/
131
+ // findRequiredAuthority()/methodMappingBoundaries() with an explicit refusal ladder producing a
132
+ // full policy RECORD (not just a scalar authority string) -- see DECISIONS.md's
133
+ // D-resolver-policy-contract for the WHY: a @PreAuthorize("hasRole('USER')") sitting next to a
134
+ // @PostAuthorize("returnObject.ownerId == authentication.name") ownership check used to
135
+ // auto-materialize ROLE_USER and silently ignore the ownership check entirely -- a real IDOR bskel
136
+ // itself would introduce. The provably-safe set (hasRole('X') / hasAuthority('X') alone, nothing
137
+ // else in the region) is UNCHANGED from before (O5) -- this item only adds refusal rungs in FRONT
138
+ // of it, so every case that materialized correctly before still does (frozen, not widened).
139
+ //
140
+ // O5 (D-resolver-authorization-action-aware, hasAuthority follow-up): hasRole('X') and
141
+ // hasAuthority('X') are NOT interchangeable at the Spring Security level -- hasRole('X') checks
142
+ // for the granted authority "ROLE_X" (an implicit prefix Spring itself applies), hasAuthority('X')
143
+ // checks for "X" verbatim. The returned `authority` string is the LITERAL granted-authority value
144
+ // the generated code must match, decided HERE (plan time), not left for the template to re-derive.
138
145
  const HAS_ROLE_RE = /@PreAuthorize\(\s*"hasRole\('([^']+)'\)"\s*\)/;
139
146
  const HAS_AUTHORITY_RE = /@PreAuthorize\(\s*"hasAuthority\('([^']+)'\)"\s*\)/;
140
- const PRE_AUTH_RE = /@PreAuthorize\(/;
141
147
 
142
- function methodMappingBoundaries(text) {
143
- return findMappingAnnotations(text).map((m) => ({ index: m.index, methodName: m.methodName }));
148
+ // D-resolver-policy-contract (PC3): companion annotations that can carry authorization logic
149
+ // @PreAuthorize's own simple-shape check can never see (ownership/SpEL/etc.) -- their mere
150
+ // PRESENCE in a method's or class's authorization region refuses auto-materialization outright,
151
+ // regardless of whether a perfectly-shaped @PreAuthorize ALSO sits in the same region. Verified
152
+ // live: none of these five appear anywhere in this repository today (grep, zero hits) -- so this
153
+ // rung changes nothing for any case this codebase currently exercises; it exists for target repos
154
+ // that DO use them.
155
+ const COMPANION_AUTHZ_RE = /@(PostAuthorize|Secured|RolesAllowed|PreFilter|PostFilter)\s*\(/g;
156
+ const PRE_AUTHORIZE_SCAN_RE = /@PreAuthorize\s*\(/g;
157
+ const EVIDENCE_TEXT_MAX = 200;
158
+
159
+ // D-resolver-policy-contract (PC2): the 8 evidence.kind values the ladder below can produce, in
160
+ // the exact order DECISIONS.md documents them. Exported so schemas/handles-plan.schema.json's own
161
+ // enum and test/handles-policy-contract.test.mjs can be asserted to never drift apart silently.
162
+ export const POLICY_EVIDENCE_KINDS = Object.freeze([
163
+ 'endpoint-absent',
164
+ 'endpoint-method-absent',
165
+ 'companion-annotation-present',
166
+ 'pre-authorize-ambiguous',
167
+ 'pre-authorize-unrecognized',
168
+ 'authorization-annotation-absent',
169
+ 'pre-authorize-has-role',
170
+ 'pre-authorize-has-authority',
171
+ ]);
172
+
173
+ // D-resolver-policy-contract (PC2 follow-up, found live): an unresolved record whose
174
+ // evidence.kind is 'endpoint-absent'/'endpoint-method-absent' means the underlying route
175
+ // STRUCTURALLY DOES NOT EXIST for this action -- e.g. a read-only resource with no PATCH/PUT
176
+ // endpoint at all. That is the SAME "nothing to protect" case this codebase has silently accepted
177
+ // via TODO_ROLE forever (planHandles()'s own `requiredAuthorityForPatch ?? 'TODO_ROLE'`, unnoted
178
+ // when no update endpoint exists) -- NOT the gap this item exists to close (an endpoint that DOES
179
+ // exist but couldn't be safely verified). Found live: without this exclusion, willGenerateResolver
180
+ // (which never depends on the UPDATE endpoint existing at all -- a read-only resource is a normal,
181
+ // common shape) would delegate/block emit for every read-only resource, a real regression this
182
+ // project's own real fixtures caught immediately. Exported so emit.mjs's unresolvedPolicies gate
183
+ // uses the exact same rule -- one place decides "does this unresolved record need a human."
184
+ export function policyRequiresResolution(p) {
185
+ return p.status === 'unresolved' && p.evidence.kind !== 'endpoint-absent' && p.evidence.kind !== 'endpoint-method-absent';
144
186
  }
145
187
 
146
188
  // Index just after the class body's opening brace -- the lower bound for a method-level search
@@ -153,56 +195,146 @@ function classBodyStart(text) {
153
195
  return m ? m.index + m[0].length : 0;
154
196
  }
155
197
 
156
- // Returns { authority, unsupported } for an @PreAuthorize search over one region of source text.
157
- // `unsupported: true` means an @PreAuthorize annotation IS present but isn't one of the two
158
- // simple shapes this regex-based scanner understands (hasAnyRole, hasAnyAuthority, SpEL, etc.) --
159
- // the caller must fail closed (TODO_ROLE) rather than silently treating it as "no authority
160
- // found" and falling back to a weaker/wrong source.
161
- //
162
- // O5 (D-resolver-authorization-action-aware, hasAuthority follow-up): hasRole('X') and
163
- // hasAuthority('X') are NOT interchangeable at the Spring Security level -- hasRole('X') checks
164
- // for the granted authority "ROLE_X" (an implicit prefix Spring itself applies), hasAuthority('X')
165
- // checks for "X" verbatim. The returned `authority` string is the LITERAL granted-authority value
166
- // the generated code must match, decided HERE (plan time), not left for the template to re-derive
167
- // -- HandleController.java.tmpl's requireAuthority() does one plain equality check regardless of
168
- // which shape produced the value. This is the real discriminant this item's own EXIT note named:
169
- // widening the regex alone, without this prefix decision, would generate an incorrect check.
170
- function extractPreAuthorize(region) {
171
- if (!PRE_AUTH_RE.test(region)) return null;
172
- const hasRoleMatch = region.match(HAS_ROLE_RE);
173
- if (hasRoleMatch) return { authority: `ROLE_${hasRoleMatch[1]}`, unsupported: false };
174
- const hasAuthorityMatch = region.match(HAS_AUTHORITY_RE);
175
- if (hasAuthorityMatch) return { authority: hasAuthorityMatch[1], unsupported: false };
176
- return { authority: null, unsupported: true };
198
+ function normalizeEvidenceText(s) {
199
+ const collapsed = s.replace(/\s+/g, ' ').trim();
200
+ return collapsed.length > EVIDENCE_TEXT_MAX ? `${collapsed.slice(0, EVIDENCE_TEXT_MAX)}…` : collapsed;
201
+ }
202
+
203
+ // Finds every match of a global annotation-start regex (`re`, must end in a literal `\(` so its
204
+ // own match always ends exactly on the opening paren) whose `@` sits within [start, end) of
205
+ // `masked`, resolving each one's balanced `(...)` via matchBalanced() and slicing the ORIGINAL
206
+ // (unmasked) `text` for `.text` -- values are never read off masked/blanked content, matching
207
+ // this analyzer's own established convention.
208
+ function collectAnnotations(masked, text, re, start, end) {
209
+ const found = [];
210
+ re.lastIndex = start;
211
+ let m;
212
+ while ((m = re.exec(masked))) {
213
+ if (m.index >= end) break;
214
+ const openParen = m.index + m[0].length - 1;
215
+ const close = matchBalanced(masked, openParen, '(', ')');
216
+ if (close === -1) continue; // malformed -- skip, don't misattribute
217
+ found.push({ index: m.index, text: normalizeEvidenceText(text.slice(m.index, close + 1)) });
218
+ }
219
+ return found;
220
+ }
221
+
222
+ function evidenceRecord({ kind, scope, file, line, symbol, text }) {
223
+ return { kind, scope: scope ?? null, file: file ?? null, line: line ?? null, symbol: symbol ?? null, text: text ?? null };
224
+ }
225
+
226
+ function policyRecord({ action, mode, status, authority, evidence, reason }) {
227
+ return { action, mode, status, authority: authority ?? null, evidence, reason: reason ?? null };
228
+ }
229
+
230
+ // Runs the companion-annotation / @PreAuthorize-count / exact-shape rungs (D3's rungs 2-4) over
231
+ // one region ([start, end) of `masked`/`text`). Returns null (not a refusal, just "nothing here")
232
+ // when the region has neither a companion annotation nor any @PreAuthorize at all -- the caller
233
+ // tries the next scope (method -> class) before finally refusing with
234
+ // 'authorization-annotation-absent'.
235
+ function derivePolicyFromRegion({ masked, text, start, end, scope, action, controllerFile, controllerFileRel, symbol }) {
236
+ const companions = collectAnnotations(masked, text, COMPANION_AUTHZ_RE, start, end);
237
+ if (companions.length > 0) {
238
+ const line = lineNumberAt(text, companions[0].index);
239
+ const names = companions.map((c) => c.text).join(', ');
240
+ return policyRecord({
241
+ action, mode: 'delegated', status: 'unresolved',
242
+ evidence: evidenceRecord({ kind: 'companion-annotation-present', scope, file: controllerFileRel, line, symbol, text: companions.map((c) => c.text).join(' | ') }),
243
+ reason: `${controllerFileRel}:${line}: ${symbol} carries ${names} -- this scanner cannot safely verify what a companion authorization annotation enforces (ownership/tenant checks are common here), so authorization is NOT auto-materialized even though a @PreAuthorize may also be present. Implement the generated AuthorizationPolicy interface by hand, using the annotation text above as the specification.`,
244
+ });
245
+ }
246
+ const preAuths = collectAnnotations(masked, text, PRE_AUTHORIZE_SCAN_RE, start, end);
247
+ if (preAuths.length > 1) {
248
+ const line = lineNumberAt(text, preAuths[0].index);
249
+ return policyRecord({
250
+ action, mode: 'delegated', status: 'unresolved',
251
+ evidence: evidenceRecord({ kind: 'pre-authorize-ambiguous', scope, file: controllerFileRel, line, symbol, text: preAuths.map((p) => p.text).join(' | ') }),
252
+ reason: `${controllerFileRel}:${line}: ${symbol} carries ${preAuths.length} @PreAuthorize annotations in the same ${scope}-level region -- ambiguous which one governs ${action}. Write authorize() by hand.`,
253
+ });
254
+ }
255
+ if (preAuths.length === 1) {
256
+ const p = preAuths[0];
257
+ const line = lineNumberAt(text, p.index);
258
+ const hasRoleMatch = p.text.match(HAS_ROLE_RE);
259
+ if (hasRoleMatch) {
260
+ return policyRecord({
261
+ action, mode: 'role', status: 'materialized', authority: `ROLE_${hasRoleMatch[1]}`,
262
+ evidence: evidenceRecord({ kind: 'pre-authorize-has-role', scope, file: controllerFileRel, line, symbol, text: p.text }),
263
+ });
264
+ }
265
+ const hasAuthorityMatch = p.text.match(HAS_AUTHORITY_RE);
266
+ if (hasAuthorityMatch) {
267
+ return policyRecord({
268
+ action, mode: 'role', status: 'materialized', authority: hasAuthorityMatch[1],
269
+ evidence: evidenceRecord({ kind: 'pre-authorize-has-authority', scope, file: controllerFileRel, line, symbol, text: p.text }),
270
+ });
271
+ }
272
+ return policyRecord({
273
+ action, mode: 'delegated', status: 'unresolved',
274
+ evidence: evidenceRecord({ kind: 'pre-authorize-unrecognized', scope, file: controllerFileRel, line, symbol, text: p.text }),
275
+ reason: `${controllerFileRel}:${line}: ${symbol}'s @PreAuthorize (${p.text}) is not exactly hasRole('X') or hasAuthority('X') -- this scanner does not evaluate SpEL (hasAnyRole/hasAnyAuthority/compound expressions included). Write authorize() by hand.`,
276
+ });
277
+ }
278
+ return null;
177
279
  }
178
280
 
179
- // D-security-7: a controller with more than one method can require DIFFERENT roles per method --
180
- // the previous version always used the file's FIRST @PreAuthorize match, which for a controller
181
- // whose first-declared method happens to carry a weaker role than the actual fetch method being
182
- // planned would silently generate a resolver enforcing that weaker role instead. Found by the
183
- // Codex security review. Now searches method-level first: the region from the previous method's
184
- // mapping annotation (exclusive) up to this method's mapping annotation (exclusive) is exactly
185
- // the span that can only contain this method's own annotations, never the previous method's (its
186
- // own @PreAuthorize, if any, sits before that boundary). Only falls back to a genuine class-level
187
- // @PreAuthorize -- the region before the FIRST method mapping in the file -- when the method
188
- // level has nothing at all.
189
- function findRequiredAuthority(controllerFilePath, methodName) {
190
- if (!controllerFilePath || !methodName || !fs.existsSync(controllerFilePath)) {
191
- return { authority: null, unsupported: false };
281
+ // D-resolver-policy-contract (PC3): the refusal ladder's entry point. `operation` is whatever
282
+ // findFetchOperation()/findUpdateOperation() returned (or null). Modeled directly on
283
+ // scanners/adapters/java-spring.mjs's extractRepositoryResource() -- an ordered sequence of
284
+ // checks, each non-matching rung returning an explicit, reasoned refusal rather than a best-effort
285
+ // guess. The companion-annotation rung runs BEFORE any @PreAuthorize matching, on purpose: this is
286
+ // the rung that closes the @PostAuthorize-ownership-check gap named in DECISIONS.md's WHY.
287
+ function derivePolicy({ action, operation, repoRoot }) {
288
+ const rel = (p) => (repoRoot && p ? path.relative(repoRoot, p) : p);
289
+
290
+ if (!operation) {
291
+ return policyRecord({
292
+ action, mode: 'delegated', status: 'unresolved',
293
+ evidence: evidenceRecord({ kind: 'endpoint-absent' }),
294
+ reason: `no ${action === 'fetch' ? 'single-resource GET' : 'single-resource PATCH/PUT'} endpoint found for this action -- write authorize() by hand once one exists`,
295
+ });
296
+ }
297
+ if (!operation.method || !operation.controllerFile) {
298
+ const declNote = operation.declaration
299
+ ? ` -- it was expanded from ${operation.declaration.label ?? operation.declaration.rule}, a framework-synthesized route with no literal per-action source method to correlate to`
300
+ : ' -- no literal per-action source method exists to correlate to';
301
+ return policyRecord({
302
+ action, mode: 'delegated', status: 'unresolved',
303
+ evidence: evidenceRecord({ kind: 'endpoint-method-absent', file: rel(operation.controllerFile) }),
304
+ reason: `matched endpoint (${action} ${operation.path ?? ''})${declNote}. Write authorize() by hand.`,
305
+ });
306
+ }
307
+
308
+ const controllerFile = operation.controllerFile;
309
+ const controllerFileRel = rel(controllerFile);
310
+ const symbol = `${operation.controllerClassName}.${operation.method}`;
311
+ const text = fs.readFileSync(controllerFile, 'utf8');
312
+ const masked = maskNonCode(text);
313
+ const mappings = findMappingAnnotations(text);
314
+ const target = mappings.find((m) => m.methodName === operation.method);
315
+ if (!target) {
316
+ return policyRecord({
317
+ action, mode: 'delegated', status: 'unresolved',
318
+ evidence: evidenceRecord({ kind: 'endpoint-method-absent', file: controllerFileRel, symbol }),
319
+ reason: `${controllerFileRel}: could not re-locate ${symbol} via its own mapping annotation -- write authorize() by hand.`,
320
+ });
192
321
  }
193
- const text = fs.readFileSync(controllerFilePath, 'utf8');
194
- const boundaries = methodMappingBoundaries(text);
195
- const target = boundaries.find((b) => b.methodName === methodName);
196
- if (!target) return { authority: null, unsupported: false };
197
-
198
- const priorBoundaries = boundaries.filter((b) => b.index < target.index);
199
- const methodRegionStart = priorBoundaries.length > 0 ? priorBoundaries[priorBoundaries.length - 1].index : classBodyStart(text);
200
- const methodLevel = extractPreAuthorize(text.slice(methodRegionStart, target.index));
201
- if (methodLevel) return methodLevel;
202
-
203
- const classRegion = text.slice(0, boundaries[0].index);
204
- const classLevel = extractPreAuthorize(classRegion);
205
- return classLevel ?? { authority: null, unsupported: false };
322
+ const priorSameFile = mappings.filter((m) => m.index < target.index);
323
+ const methodStart = priorSameFile.length > 0 ? priorSameFile[priorSameFile.length - 1].signatureIndex : classBodyStart(text);
324
+ const methodEnd = target.signatureIndex;
325
+ const classRegionEnd = mappings[0].index;
326
+
327
+ const methodResult = derivePolicyFromRegion({ masked, text, start: methodStart, end: methodEnd, scope: 'method', action, controllerFile, controllerFileRel, symbol });
328
+ if (methodResult) return methodResult;
329
+
330
+ const classResult = derivePolicyFromRegion({ masked, text, start: 0, end: classRegionEnd, scope: 'class', action, controllerFile, controllerFileRel, symbol });
331
+ if (classResult) return classResult;
332
+
333
+ return policyRecord({
334
+ action, mode: 'delegated', status: 'unresolved',
335
+ evidence: evidenceRecord({ kind: 'authorization-annotation-absent', file: controllerFileRel, symbol }),
336
+ reason: `no @PreAuthorize (method- or class-level) found for ${symbol} -- authorization may be enforced elsewhere (service layer, a SecurityFilterChain) that this scanner cannot see. Write authorize() by hand.`,
337
+ });
206
338
  }
207
339
 
208
340
  // Heuristic (this codebase's convention, verified for Organization -> OrganizationService, not
@@ -257,7 +389,11 @@ function countServiceMethodParams(serviceFilePath, methodName) {
257
389
  return argsText === '' ? 0 : countTopLevelCommas(argsText) + 1;
258
390
  }
259
391
 
260
- export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resourceFilter }) {
392
+ // D-resolver-policy-contract (PC2): `repoRoot` defaults to `javaSrcRoot` so existing direct
393
+ // callers (test/handles-plan.test.mjs among them) keep working unmodified -- derivePolicy()'s
394
+ // evidence.file is only genuinely repo-relative when the real repoRoot is threaded through by
395
+ // plan() below; a caller that omits it still gets a (less pretty, still correct) path.
396
+ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resourceFilter, repoRoot = javaSrcRoot }) {
261
397
  const targetModule = moduleName
262
398
  ? scanReport.related_modules.find((m) => m.module === moduleName)
263
399
  : scanReport.related_modules[0];
@@ -294,8 +430,8 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
294
430
  // without this check the notes below would read literally "...found for X.null" / "could not
295
431
  // find a null(...) method", which is confusing, not honest. See D-resolver-scope.
296
432
  const fetchOpMissingMethod = Boolean(fetchOp && !fetchOp.method);
297
- const authorityResult = findRequiredAuthority(fetchOp?.controllerFile ?? null, fetchOp?.method ?? null);
298
- const requiredAuthority = authorityResult.authority;
433
+ const fetchPolicy = derivePolicy({ action: 'fetch', operation: fetchOp, repoRoot });
434
+ const requiredAuthority = fetchPolicy.authority;
299
435
  const service = pkIsNonUuid ? null : findServiceFile(javaSrcRoot, targetModule.module, entity.className);
300
436
  const serviceParamCount = (service && fetchOp) ? countServiceMethodParams(service.file, fetchOp.method) : null;
301
437
 
@@ -307,8 +443,8 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
307
443
  // requiredAuthorityForPatch is meaningful even when resolver codegen itself ends up
308
444
  // blocked, exactly like requiredAuthority already is unconditional above.
309
445
  const updateOpForAuthority = findUpdateOperation(targetModule.controllers, entity.className);
310
- const patchAuthorityResult = findRequiredAuthority(updateOpForAuthority?.controllerFile ?? null, updateOpForAuthority?.method ?? null);
311
- const requiredAuthorityForPatch = patchAuthorityResult.authority;
446
+ const patchPolicy = derivePolicy({ action: 'patch', operation: updateOpForAuthority, repoRoot });
447
+ const requiredAuthorityForPatch = patchPolicy.authority;
312
448
 
313
449
  if (!fetchOp) {
314
450
  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`);
@@ -318,15 +454,15 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
318
454
  ? ` -- it was expanded from ${declaration.label ?? declaration.rule} at ${path.relative(javaSrcRoot, fetchOp.controllerFile)}:${declaration.line} (rule: ${declaration.rule}); the framework generates this handler at runtime, so no literal per-action source method exists to correlate to`
319
455
  : ' -- no literal per-action source method exists to correlate to';
320
456
  notes.push(`${entity.className}: the matched endpoint (GET ${fetchOp.path})${declNote}. Resolver NOT generated -- this is a structural boundary of static-scan-based handles codegen, not a bug. See D-resolver-scope.`);
321
- } else if (authorityResult.unsupported) {
322
- notes.push(`${entity.className}: @PreAuthorize found on ${fetchOp.controllerClassName}.${fetchOp.method} (or its class) but not in the simple hasRole('X')/hasAuthority('X') shape this scanner understands (e.g. hasAnyRole/SpEL) -- requiredAuthority() defaults to "TODO_ROLE" (fails closed) until a human fixes it`);
323
- } else if (!requiredAuthority) {
324
- notes.push(`${entity.className}: no method-level or class-level @PreAuthorize(hasRole(...)/hasAuthority(...)) found for ${fetchOp.controllerClassName}.${fetchOp.method} -- requiredAuthority() defaults to "TODO_ROLE", fix before relying on it`);
457
+ } else if (fetchPolicy.reason) {
458
+ // D-resolver-policy-contract (PC2): the ladder's own reason IS the note now -- see
459
+ // derivePolicy()'s per-rung wording (companion-annotation-present, pre-authorize-
460
+ // ambiguous/unrecognized, authorization-annotation-absent all produce their own precise
461
+ // explanation, replacing the old two-case unsupported/absent note here).
462
+ notes.push(fetchPolicy.reason);
325
463
  }
326
- if (updateOpForAuthority && patchAuthorityResult.unsupported) {
327
- notes.push(`${entity.className}: @PreAuthorize found on ${updateOpForAuthority.controllerClassName}.${updateOpForAuthority.method} (or its class) but not in the simple hasRole('X')/hasAuthority('X') shape this scanner understands -- requiredAuthorityForPatch() defaults to "TODO_ROLE" (fails closed) until a human fixes it`);
328
- } else if (updateOpForAuthority && !requiredAuthorityForPatch) {
329
- notes.push(`${entity.className}: no method-level or class-level @PreAuthorize(hasRole(...)/hasAuthority(...)) found for ${updateOpForAuthority.controllerClassName}.${updateOpForAuthority.method} -- requiredAuthorityForPatch() defaults to "TODO_ROLE", fix before relying on it`);
464
+ if (updateOpForAuthority && patchPolicy.reason) {
465
+ notes.push(patchPolicy.reason);
330
466
  }
331
467
  if (!service) {
332
468
  // pkIsNonUuid already explained the real reason above -- this note would be true but
@@ -397,6 +533,16 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
397
533
  // endpoint's own @PreAuthorize, not copied from requiredAuthority above -- see the
398
534
  // computation and its own notes earlier in this loop.
399
535
  requiredAuthorityForPatch: requiredAuthorityForPatch ?? 'TODO_ROLE',
536
+ // D-resolver-policy-contract (PC2): the source of truth -- requiredAuthority/
537
+ // requiredAuthorityForPatch above are its legacy scalar PROJECTION, kept for every
538
+ // existing consumer (test files, templates) to keep working unmodified. Exactly two
539
+ // records, action 'fetch' then 'patch' -- 'recover' has no record of its own (it is
540
+ // governed by the 'fetch' record; HandleController#recover uses requiredAuthority()).
541
+ policies: [fetchPolicy, patchPolicy],
542
+ // D-resolver-policy-contract (PC2): one resource = one resolver bean = one mode --
543
+ // if EITHER action is unresolved, the whole resolver is delegated (fail-closed by
544
+ // construction: a resource can't be "half-delegated").
545
+ authorizationMode: (policyRequiresResolution(fetchPolicy) || policyRequiresResolution(patchPolicy)) ? 'delegated' : 'role',
400
546
  service,
401
547
  willGenerateResolver: Boolean(fetchOp && service && serviceParamCount === 1),
402
548
  });
@@ -460,7 +606,7 @@ export function plan({ repoRoot, scanReport, module: moduleName, resourceFilter
460
606
  throw new Error('could not detect the base package (no *Application.java found under src/main/java) -- is this a Spring Boot project?');
461
607
  }
462
608
  const javaSrcRoot = path.join(repoRoot, 'src', 'main', 'java', ...basePackage.split('.'));
463
- const inner = planHandles({ javaSrcRoot, scanReport, module: moduleName, resourceFilter });
609
+ const inner = planHandles({ javaSrcRoot, scanReport, module: moduleName, resourceFilter, repoRoot });
464
610
  return {
465
611
  schema: 'sbf.handles-plan/1',
466
612
  provider: 'java-spring',