backend-skeleton 1.0.0 → 1.1.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/README.md +66 -4
- package/bin/bskel.mjs +125 -18
- package/contracts/export.mjs +39 -4
- package/contracts/openapi.mjs +292 -27
- package/contracts/validate.mjs +23 -4
- package/handles/_engine.mjs +75 -32
- package/handles/capability-codec.mjs +94 -0
- package/handles/codec.mjs +13 -3
- package/handles/providers/java-spring/emit.mjs +78 -33
- package/handles/providers/java-spring/observe.mjs +4 -3
- package/handles/providers/java-spring/plan.mjs +51 -7
- package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +19 -1
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +13 -7
- package/handles/providers/java-spring/templates/HandleService.java.tmpl +21 -2
- package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +1 -1
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +24 -3
- package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +8 -2
- package/handles/providers/java-spring.mjs +8 -0
- package/handles/providers/python-fastapi/emit.mjs +21 -26
- package/handles/providers/python-fastapi/observe.mjs +6 -5
- package/handles/providers/python-fastapi/templates/codec.py.tmpl +18 -3
- package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +32 -4
- package/handles/providers/python-fastapi.mjs +3 -3
- package/handles/providers/typescript-express/emit.mjs +135 -46
- package/handles/providers/typescript-express/observe.mjs +7 -6
- package/handles/providers/typescript-express/plan.mjs +8 -1
- package/handles/providers/typescript-express/templates/codec.ts.tmpl +13 -3
- package/handles/providers/typescript-express/templates/handleEntities.ts.tmpl +89 -0
- package/handles/providers/typescript-express/templates/handleService.ts.tmpl +81 -0
- package/handles/providers/typescript-express/templates/migration.sql.tmpl +36 -0
- package/handles/providers/typescript-express/templates/recordSnapshotWrapper.ts.tmpl +123 -0
- package/handles/providers/typescript-express/templates/registry.ts.tmpl +19 -10
- package/handles/providers/typescript-express/templates/resolver.ts.tmpl +13 -0
- package/handles/providers/typescript-express/templates/resolverPolicy.ts.tmpl +20 -0
- package/handles/providers/typescript-express/templates/router.ts.tmpl +113 -2
- package/handles/providers/typescript-express.mjs +7 -4
- package/lib/cli.mjs +11 -2
- package/lib/exit-codes.mjs +21 -0
- package/lib/verify.mjs +23 -6
- package/package.json +5 -2
- package/scanners/adapters/_java-spring-analyzer.mjs +25 -2
- package/scanners/adapters/java-spring.mjs +108 -10
- package/scanners/adapters/javascript-express.mjs +46 -13
- package/scanners/adapters/python-fastapi.mjs +63 -18
- package/scanners/adapters/typescript-express.mjs +33 -7
- package/schemas/feature-contract.schema.json +3 -3
- package/schemas/handles-plan.schema.json +2 -0
- package/schemas/oracle-manifest.schema.json +58 -0
- package/schemas/stack-record.schema.json +6 -1
- package/stack/apply.mjs +47 -6
package/handles/_engine.mjs
CHANGED
|
@@ -31,7 +31,9 @@ const DIFFABLE_ACTIONS = new Set(['update', 'conflict', 'adopt-update']);
|
|
|
31
31
|
// overwrite is only ever reversible if the content it destroys is already committed. Fails
|
|
32
32
|
// closed (treats git errors, or a repo where the path can't be resolved, as "dirty") since the
|
|
33
33
|
// whole point is to never make an irreversible action look safe by default.
|
|
34
|
-
|
|
34
|
+
// D-write-safety-phase0 (item 2): exported so stack/apply.mjs can apply the identical
|
|
35
|
+
// git-recoverability check before a --force overwrite -- previously private to this file.
|
|
36
|
+
export function isDirtyOrUntracked(repoRoot, absPath) {
|
|
35
37
|
try {
|
|
36
38
|
const out = execFileSync('git', ['status', '--porcelain', '--', absPath], { cwd: repoRoot, encoding: 'utf8' });
|
|
37
39
|
return out.trim().length > 0;
|
|
@@ -58,16 +60,22 @@ function isDirtyOrUntracked(repoRoot, absPath) {
|
|
|
58
60
|
// 'update'/'conflict'/'adopt-update' action -- the only 3 where content actually
|
|
59
61
|
// differs. Off by default since it shells out to git per diffable file.
|
|
60
62
|
// postResolverUnit: D-patch-transactions (Continued) -- an OPTIONAL single unit, { id,
|
|
61
|
-
// templatePath, targetAbs, render() => string
|
|
62
|
-
// be computed AFTER resolverUnits above have been
|
|
63
|
-
// express's resolvers_index.ts barrel -- its import list
|
|
64
|
-
// resolvers directory's REAL final on-disk listing, including
|
|
65
|
-
// just-written resolver files, not just this run's own
|
|
66
|
-
// infraUnits entry can't do this, since infra is processed
|
|
67
|
-
// Reuses the exact same classify/conflict/force/manifest logic
|
|
68
|
-
// above already implements
|
|
69
|
-
//
|
|
70
|
-
|
|
63
|
+
// templatePath, targetAbs, render() => string, kind?, ownership?, owner? },
|
|
64
|
+
// whose correct content can only be computed AFTER resolverUnits above have been
|
|
65
|
+
// written (e.g. typescript-express's resolvers_index.ts barrel -- its import list
|
|
66
|
+
// must reflect the resolvers directory's REAL final on-disk listing, including
|
|
67
|
+
// this run's own just-written resolver files, not just this run's own
|
|
68
|
+
// resolverUnits -- a 4th infraUnits entry can't do this, since infra is processed
|
|
69
|
+
// BEFORE resolvers). Reuses the exact same classify/conflict/force/manifest logic
|
|
70
|
+
// the infra loop above already implements. `kind`/`ownership`/`owner` default to
|
|
71
|
+
// `'infra'`/`'repo'`/`'_repo'` (the barrel's own shape, unchanged);
|
|
72
|
+
// D-write-safety-phase0 (item 1) is the first caller to override them, for a
|
|
73
|
+
// feature-owned unit (java-spring/python-fastapi's migration.sql,
|
|
74
|
+
// kind: 'migration', ownership: 'feature') whose content does NOT actually depend
|
|
75
|
+
// on the resolver loop's post-write state -- it just reuses this slot rather than
|
|
76
|
+
// adding a third one.
|
|
77
|
+
// null (the default) is a true no-op.
|
|
78
|
+
export function emitUnits({ repoRoot, featureId, provider, force = false, reason = '', infraUnits, resolverUnits, orphanScan, dryRun = false, computeDiff = false, postResolverUnits = [] }) {
|
|
71
79
|
const manifest = loadManifest(repoRoot);
|
|
72
80
|
const nowIso = new Date().toISOString();
|
|
73
81
|
|
|
@@ -125,6 +133,10 @@ export function emitUnits({ repoRoot, featureId, provider, force = false, reason
|
|
|
125
133
|
};
|
|
126
134
|
manifestChanged = true;
|
|
127
135
|
writeUnit(u.targetAbs, u.rendered);
|
|
136
|
+
// D-write-safety-phase0 (item 3): persist per-unit, not once at the end of the whole
|
|
137
|
+
// loop -- a crash after this write but before a later unit's own write must not
|
|
138
|
+
// leave THIS file's real provenance unrecorded.
|
|
139
|
+
saveManifest(repoRoot, manifest);
|
|
128
140
|
}
|
|
129
141
|
written.push(u.relPath);
|
|
130
142
|
forced.push(u.relPath);
|
|
@@ -150,6 +162,8 @@ export function emitUnits({ repoRoot, featureId, provider, force = false, reason
|
|
|
150
162
|
updated_at: nowIso,
|
|
151
163
|
};
|
|
152
164
|
manifestChanged = true;
|
|
165
|
+
// D-write-safety-phase0 (item 3): see the comment at this loop's first saveManifest() call.
|
|
166
|
+
saveManifest(repoRoot, manifest);
|
|
153
167
|
}
|
|
154
168
|
recordAction({ relPath: u.relPath, kind: 'infra', action: u.action, diskContent: u.diskContent, rendered: u.rendered });
|
|
155
169
|
}
|
|
@@ -197,6 +211,11 @@ export function emitUnits({ repoRoot, featureId, provider, force = false, reason
|
|
|
197
211
|
updated_at: nowIso, last_force: { reason, at: nowIso, overwritten_hash: diskHash },
|
|
198
212
|
};
|
|
199
213
|
manifestChanged = true;
|
|
214
|
+
// D-write-safety-phase0 (item 3): persist per-unit -- the resolver loop is where this
|
|
215
|
+
// matters most (potentially many resolvers per feature, one saveManifest() call at
|
|
216
|
+
// the end previously meant a crash partway through left every already-written
|
|
217
|
+
// resolver this run unrecorded, not just the interrupted one).
|
|
218
|
+
saveManifest(repoRoot, manifest);
|
|
200
219
|
}
|
|
201
220
|
written.push(relPath);
|
|
202
221
|
forced.push(relPath);
|
|
@@ -228,23 +247,37 @@ export function emitUnits({ repoRoot, featureId, provider, force = false, reason
|
|
|
228
247
|
updated_at: nowIso,
|
|
229
248
|
};
|
|
230
249
|
manifestChanged = true;
|
|
250
|
+
// D-write-safety-phase0 (item 3): see the comment at this loop's first saveManifest() call.
|
|
251
|
+
saveManifest(repoRoot, manifest);
|
|
231
252
|
}
|
|
232
253
|
}
|
|
233
254
|
recordAction({ relPath, kind: 'resolver', action, resourceType: u.resourceType, diskContent, rendered: u.rendered });
|
|
234
255
|
}
|
|
235
256
|
|
|
236
|
-
// ---- post-resolver
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
// earlier, precisely so it observes this run's own writes
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
|
|
247
|
-
|
|
257
|
+
// ---- post-resolver units: an array of units whose correct content can only be computed AFTER
|
|
258
|
+
// the resolver loop above has finished writing (e.g. typescript-express's resolvers_index.ts
|
|
259
|
+
// barrel -- its import list must reflect the resolvers directory's REAL on-disk listing,
|
|
260
|
+
// including THIS run's own just-written resolver files, not just this run's own
|
|
261
|
+
// resolverUnits). Reuses the EXACT SAME classify/conflict/force/manifest logic the infra loop
|
|
262
|
+
// above already implements, applied to each unit independently. `render()` is called HERE, not
|
|
263
|
+
// earlier, precisely so it observes this run's own writes (still true for a unit whose content
|
|
264
|
+
// doesn't actually need that timing, like migration.sql -- it just reuses this array rather
|
|
265
|
+
// than adding a third mechanism). Empty array (the default) is a true no-op.
|
|
266
|
+
// D-typescript-express-registry-parity: was a single optional `postResolverUnit` object
|
|
267
|
+
// (D-write-safety-phase0's own kind/ownership/owner generalization) until typescript-express
|
|
268
|
+
// needed TWO units here at once (resolvers_index.ts, repo-owned, AND migration.sql,
|
|
269
|
+
// feature-owned, once this item gave it one) -- widened to an array, same per-unit logic,
|
|
270
|
+
// nothing else about the mechanism changed. See D-patch-transactions (Continued) in
|
|
271
|
+
// DECISIONS.md for why this couldn't just be infraUnits/resolverUnits entries. ----
|
|
272
|
+
for (const u of postResolverUnits) {
|
|
273
|
+
// D-write-safety-phase0 (item 1): generalized from a hardcoded infra/repo/_repo triple so a
|
|
274
|
+
// feature-owned single unit (java-spring/python-fastapi's migration.sql) can reuse this exact
|
|
275
|
+
// classify/conflict/force/manifest cycle too, not just typescript-express's repo-owned
|
|
276
|
+
// resolvers_index.ts barrel. Defaults preserve the original hardcoded values byte-for-byte, so
|
|
277
|
+
// the barrel caller (which never sets these) is unaffected.
|
|
278
|
+
const unitKind = u.kind ?? 'infra';
|
|
279
|
+
const unitOwnership = u.ownership ?? 'repo';
|
|
280
|
+
const unitOwner = u.owner ?? '_repo';
|
|
248
281
|
const rendered = u.render();
|
|
249
282
|
const relPath = path.relative(repoRoot, u.targetAbs);
|
|
250
283
|
const diskContent = readIfExists(u.targetAbs);
|
|
@@ -256,28 +289,30 @@ export function emitUnits({ repoRoot, featureId, provider, force = false, reason
|
|
|
256
289
|
const action = classifyFile({ exists, diskHash, manifestEntryHash: entry?.generated_hash ?? null, freshRenderHash, matchesPristineRender });
|
|
257
290
|
|
|
258
291
|
if (action === 'conflict' && !force) {
|
|
259
|
-
conflicts.push({ path: relPath, kind:
|
|
260
|
-
recordAction({ relPath, kind:
|
|
292
|
+
conflicts.push({ path: relPath, kind: unitKind, reason: 'diverged from the last content backend-skeleton generated -- see notes for remediation' });
|
|
293
|
+
recordAction({ relPath, kind: unitKind, action, diskContent, rendered });
|
|
261
294
|
} else if (action === 'conflict') {
|
|
262
295
|
if (isDirtyOrUntracked(repoRoot, u.targetAbs)) {
|
|
263
|
-
conflicts.push({ path: relPath, kind:
|
|
264
|
-
recordAction({ relPath, kind:
|
|
296
|
+
conflicts.push({ path: relPath, kind: unitKind, reason: 'refusing --force: this file has uncommitted/untracked changes -- commit or stash it first so the overwrite is recoverable' });
|
|
297
|
+
recordAction({ relPath, kind: unitKind, action, diskContent, rendered });
|
|
265
298
|
} else {
|
|
266
299
|
if (!dryRun) {
|
|
267
300
|
manifest.files[relPath] = {
|
|
268
|
-
kind:
|
|
301
|
+
kind: unitKind, ownership: unitOwnership, owner: unitOwner, provider, template: u.id,
|
|
269
302
|
template_hash: sha256File(u.templatePath), generated_hash: freshRenderHash,
|
|
270
303
|
updated_at: nowIso, last_force: { reason, at: nowIso },
|
|
271
304
|
};
|
|
272
305
|
manifestChanged = true;
|
|
273
306
|
writeUnit(u.targetAbs, rendered);
|
|
307
|
+
// D-write-safety-phase0 (item 3): see the infra loop's first saveManifest() call above.
|
|
308
|
+
saveManifest(repoRoot, manifest);
|
|
274
309
|
}
|
|
275
310
|
written.push(relPath);
|
|
276
311
|
forced.push(relPath);
|
|
277
|
-
recordAction({ relPath, kind:
|
|
312
|
+
recordAction({ relPath, kind: unitKind, action, diskContent, rendered });
|
|
278
313
|
}
|
|
279
314
|
} else if (action === 'unchanged') {
|
|
280
|
-
recordAction({ relPath, kind:
|
|
315
|
+
recordAction({ relPath, kind: unitKind, action });
|
|
281
316
|
} else {
|
|
282
317
|
if (action !== 'adopt-unchanged') {
|
|
283
318
|
if (!dryRun) writeUnit(u.targetAbs, rendered);
|
|
@@ -285,13 +320,15 @@ export function emitUnits({ repoRoot, featureId, provider, force = false, reason
|
|
|
285
320
|
}
|
|
286
321
|
if (!dryRun) {
|
|
287
322
|
manifest.files[relPath] = {
|
|
288
|
-
kind:
|
|
323
|
+
kind: unitKind, ownership: unitOwnership, owner: unitOwner, provider, template: u.id,
|
|
289
324
|
template_hash: sha256File(u.templatePath), generated_hash: freshRenderHash,
|
|
290
325
|
updated_at: nowIso,
|
|
291
326
|
};
|
|
292
327
|
manifestChanged = true;
|
|
328
|
+
// D-write-safety-phase0 (item 3): see the infra loop's first saveManifest() call above.
|
|
329
|
+
saveManifest(repoRoot, manifest);
|
|
293
330
|
}
|
|
294
|
-
recordAction({ relPath, kind:
|
|
331
|
+
recordAction({ relPath, kind: unitKind, action, diskContent, rendered });
|
|
295
332
|
}
|
|
296
333
|
}
|
|
297
334
|
|
|
@@ -325,6 +362,12 @@ export function emitUnits({ repoRoot, featureId, provider, force = false, reason
|
|
|
325
362
|
}
|
|
326
363
|
}
|
|
327
364
|
|
|
365
|
+
// D-write-safety-phase0 (item 3): every mutation site above already calls saveManifest()
|
|
366
|
+
// itself, incrementally, right after its own write -- this is now a defensive backstop, not
|
|
367
|
+
// the primary persistence mechanism (a no-op resave of already-current content in the normal
|
|
368
|
+
// case), kept so a future mutation site added without its own incremental save still ends up
|
|
369
|
+
// correct at the end of a run that completes normally. It is NOT what makes this item's own
|
|
370
|
+
// crash-safety property hold -- that comes entirely from the per-unit calls above.
|
|
328
371
|
if (!dryRun && manifestChanged) saveManifest(repoRoot, manifest);
|
|
329
372
|
|
|
330
373
|
// D-resolver-policy-split: two units (Resolver + Policy) now share one resourceType, so
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// ROADMAP.md Phase 6, item 1 (D-sbf2-capability-codec): the `sbf2_` token FORMAT and its
|
|
2
|
+
// Ed25519 sign/verify mechanics -- deliberately INFRASTRUCTURE ONLY. Not wired into
|
|
3
|
+
// `handles/codec.mjs`'s `HANDLE_DECODERS` dispatch table, not called from `HandleController`/
|
|
4
|
+
// `ResourceResolver`, no CLI command issues or consumes one. ROADMAP's own Phase 6 text is explicit
|
|
5
|
+
// about why: `sbf1_` conveys no authority by design (all authority comes from the target app's own
|
|
6
|
+
// `@PreAuthorize`-derived check) -- a capability token is a genuinely DIFFERENT design, and this
|
|
7
|
+
// project's own Phase 4 pilot (D-handles-pilot-cohort) never exercised a single real cross-tenant
|
|
8
|
+
// or delegated-access use case (one resource, one org, straightforward CRUD). Building the actual
|
|
9
|
+
// delegation MODEL -- what "scope" grants, how a capability token interacts with the real
|
|
10
|
+
// authorization flow, key rotation/multiple-signers/revocation -- against zero real delegation
|
|
11
|
+
// requirements is exactly what this project has refused to do everywhere else (D-security-8's own
|
|
12
|
+
// "never guess, always explicit" boundary, D-write-safety-phase1's non-UUID refusal, this whole
|
|
13
|
+
// project's Data-First Numerics discipline). This module exists so that work, once real
|
|
14
|
+
// requirements exist, has real, tested crypto/envelope plumbing to start from rather than a blank
|
|
15
|
+
// page -- not because the delegation model itself is considered designed.
|
|
16
|
+
//
|
|
17
|
+
// Reuses lib/attest.mjs's Ed25519 primitives directly (Node built-in `crypto`, zero new
|
|
18
|
+
// dependencies) -- the same canonicalize/sign/verify this project's own gate attestations already
|
|
19
|
+
// use, not a second, independently-written crypto path.
|
|
20
|
+
import { signPayload, verifyPayload } from '../lib/attest.mjs';
|
|
21
|
+
|
|
22
|
+
// D-security-10 precedent (handles/codec.mjs's own MAX_HANDLE_TOKEN_LENGTH): a defense-in-depth
|
|
23
|
+
// cap before attempting to decode, not a functional requirement. Larger than sbf1's 2048 -- this
|
|
24
|
+
// envelope carries a JSON payload plus a base64 Ed25519 signature (~88 bytes), not a bare
|
|
25
|
+
// kind:type:uuid:pointer address.
|
|
26
|
+
const MAX_CAPABILITY_TOKEN_LENGTH = 4096;
|
|
27
|
+
const PREFIX = 'sbf2_';
|
|
28
|
+
const BASE64URL_CHARSET_RE = /^[A-Za-z0-9_-]*$/;
|
|
29
|
+
|
|
30
|
+
// Byte-for-byte the same base64url encode/decode handles/codec.mjs's own encodeHandle/
|
|
31
|
+
// decodeSbf1Handle use (duplicated rather than imported -- these are ~3-line helpers with no
|
|
32
|
+
// handle-specific meaning, and importing them would wire this file into handles/codec.mjs's own
|
|
33
|
+
// module for no real reason beyond avoiding a duplicate).
|
|
34
|
+
function base64url(buf) {
|
|
35
|
+
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function base64urlDecode(str) {
|
|
39
|
+
if (!BASE64URL_CHARSET_RE.test(str)) throw new Error('not valid base64url after the sbf2_ prefix');
|
|
40
|
+
const pad = (4 - (str.length % 4)) % 4;
|
|
41
|
+
const padded = str.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat(pad);
|
|
42
|
+
return Buffer.from(padded, 'base64');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The payload shape ROADMAP.md Phase 6 itself names: issuer, audience, expiry, and a scope.
|
|
46
|
+
// `scope`'s internal shape is deliberately UNINTERPRETED by this module -- it's carried through
|
|
47
|
+
// verbatim, JSON-serializable, nothing more. This module has no opinion about what a "scope"
|
|
48
|
+
// value means or how it should be checked against a real request; that's exactly the delegation-
|
|
49
|
+
// model design this module deliberately does not make.
|
|
50
|
+
export function encodeCapabilityToken({ iss, aud, exp, scope }, privateKeyPem) {
|
|
51
|
+
if (typeof iss !== 'string' || !iss) throw new Error('encodeCapabilityToken requires a non-empty "iss" (issuer)');
|
|
52
|
+
if (typeof aud !== 'string' || !aud) throw new Error('encodeCapabilityToken requires a non-empty "aud" (audience)');
|
|
53
|
+
if (typeof exp !== 'number' || !Number.isFinite(exp)) throw new Error('encodeCapabilityToken requires a numeric "exp" (Unix seconds)');
|
|
54
|
+
if (scope === undefined) throw new Error('encodeCapabilityToken requires a "scope" (any JSON-serializable value, uninterpreted by this codec)');
|
|
55
|
+
|
|
56
|
+
const payload = { iss, aud, exp, scope };
|
|
57
|
+
const sig = signPayload(payload, privateKeyPem);
|
|
58
|
+
const envelope = { payload, sig };
|
|
59
|
+
return `${PREFIX}${base64url(Buffer.from(JSON.stringify(envelope), 'utf8'))}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Returns { ok: true, payload } or { ok: false, reason } -- never throws on a malformed/tampered/
|
|
63
|
+
// expired token. Matches this project's own established convention for routine, expected
|
|
64
|
+
// verification failures (contracts/openapi.mjs's loadOpenApiDocument/inlineSchema,
|
|
65
|
+
// lib/schema-validate.mjs's validateAgainstSchema), not handles/codec.mjs's own throw-based
|
|
66
|
+
// decodeHandle() -- an invalid capability token is an ordinary, expected outcome a caller needs
|
|
67
|
+
// to branch on cleanly, not an exceptional program error.
|
|
68
|
+
export function decodeCapabilityToken(token, publicKeyPem, { now = Math.floor(Date.now() / 1000) } = {}) {
|
|
69
|
+
if (typeof token !== 'string' || !token.startsWith(PREFIX)) return { ok: false, reason: 'missing sbf2_ prefix' };
|
|
70
|
+
if (token.length > MAX_CAPABILITY_TOKEN_LENGTH) return { ok: false, reason: `token exceeds the maximum length of ${MAX_CAPABILITY_TOKEN_LENGTH} characters` };
|
|
71
|
+
|
|
72
|
+
let envelope;
|
|
73
|
+
try {
|
|
74
|
+
const raw = base64urlDecode(token.slice(PREFIX.length)).toString('utf8');
|
|
75
|
+
envelope = JSON.parse(raw);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
return { ok: false, reason: `malformed token: ${err.message}` };
|
|
78
|
+
}
|
|
79
|
+
if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) return { ok: false, reason: 'malformed token: not an object' };
|
|
80
|
+
const { payload, sig } = envelope;
|
|
81
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return { ok: false, reason: 'malformed token: missing payload' };
|
|
82
|
+
if (typeof sig !== 'string' || !sig) return { ok: false, reason: 'malformed token: missing signature' };
|
|
83
|
+
|
|
84
|
+
if (!verifyPayload(payload, sig, publicKeyPem)) return { ok: false, reason: 'invalid signature' };
|
|
85
|
+
|
|
86
|
+
const { iss, aud, exp, scope } = payload;
|
|
87
|
+
if (typeof iss !== 'string' || !iss) return { ok: false, reason: 'malformed payload: missing iss' };
|
|
88
|
+
if (typeof aud !== 'string' || !aud) return { ok: false, reason: 'malformed payload: missing aud' };
|
|
89
|
+
if (typeof exp !== 'number' || !Number.isFinite(exp)) return { ok: false, reason: 'malformed payload: missing exp' };
|
|
90
|
+
if (scope === undefined) return { ok: false, reason: 'malformed payload: missing scope' };
|
|
91
|
+
if (exp <= now) return { ok: false, reason: 'expired' };
|
|
92
|
+
|
|
93
|
+
return { ok: true, payload: { iss, aud, exp, scope } };
|
|
94
|
+
}
|
package/handles/codec.mjs
CHANGED
|
@@ -53,10 +53,20 @@ export function encodeHandle({ kind, type, uuid, pointer = null }) {
|
|
|
53
53
|
return `sbf1_${base64url(Buffer.from(raw, 'utf8'))}`;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
// D-handle-identity-contract-freeze (Phase 3): scheme-dispatch table, not a hardcoded 'sbf1_'
|
|
57
|
+
// check -- reserves the discriminant for a future `sbf2_` scheme (Phase 6, capability-scoped
|
|
58
|
+
// handles) to register itself here additively, without ever changing how an existing 'sbf1_'
|
|
59
|
+
// token decodes. encodeHandle() is untouched -- it still only ever emits 'sbf1_' tokens.
|
|
60
|
+
const HANDLE_DECODERS = { sbf1: decodeSbf1Handle };
|
|
61
|
+
|
|
56
62
|
export function decodeHandle(token) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
63
|
+
const scheme = typeof token === 'string' && token.includes('_') ? token.slice(0, token.indexOf('_')) : token;
|
|
64
|
+
const decoder = typeof scheme === 'string' ? HANDLE_DECODERS[scheme] : undefined;
|
|
65
|
+
if (!decoder) throw new Error('not an sbf1 handle (missing "sbf1_" prefix)');
|
|
66
|
+
return decoder(token);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function decodeSbf1Handle(token) {
|
|
60
70
|
if (token.length > MAX_HANDLE_TOKEN_LENGTH) {
|
|
61
71
|
throw new Error(`handle token exceeds the maximum length of ${MAX_HANDLE_TOKEN_LENGTH} characters`);
|
|
62
72
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import { emitUnits
|
|
4
|
+
import { emitUnits } from '../../_engine.mjs';
|
|
5
5
|
import { loadPatchApprovals, approvedStrategyFor } from '../../../lib/patch-approvals.mjs';
|
|
6
6
|
import { computeCodegenNeeds, renderPatchFieldBody } from './patch-strategy.mjs';
|
|
7
7
|
import { sha256File } from '../../../lib/fsutil.mjs';
|
|
@@ -56,12 +56,54 @@ function lowerFirst(s) {
|
|
|
56
56
|
// the primary `ObjectMapper` bean, which is what `@RequiredArgsConstructor` injection needs.
|
|
57
57
|
// Defaults to the classic package when build.gradle/the plugin version can't be found -- covers
|
|
58
58
|
// the far more common Spring Boot <=3.x case, and matches this project's own CI fixture corpus.
|
|
59
|
-
|
|
59
|
+
// D-write-safety-phase1 (item 1): a disk-verified grep for `spring-boot-starter-aop`, the ONE
|
|
60
|
+
// dependency `HandleAspect.java` needs to actually intercept anything (Spring AOP is not enabled
|
|
61
|
+
// by any other starter) -- `emit.mjs`'s own postEmitNotes has said so since O4, but only AFTER
|
|
62
|
+
// writing code that silently can't work without it. Checks the same 3 build-file names
|
|
63
|
+
// `scanners/adapters/java-spring.mjs`'s own `JAVA_BUILD_FILE_GLOBS` uses (not re-imported --
|
|
64
|
+
// that constant is scanner-internal; duplicating 3 literal filenames here is simpler than adding
|
|
65
|
+
// a cross-module export for them), since a Kotlin DSL or Maven repo is just as real a target as a
|
|
66
|
+
// Groovy one. A simple substring check, not a real dependency-graph parse -- matches this
|
|
67
|
+
// codebase's own "good-enough regex, not a real parser" convention throughout, and a
|
|
68
|
+
// transitively-pulled-in `spring-boot-starter-aop` (via some other starter) is not a real shape
|
|
69
|
+
// this project's own oracle or fixtures have ever exhibited.
|
|
70
|
+
// D-handles-pilot-cohort follow-up: shared by detectJacksonPackage (Jackson 3 vs 2) and
|
|
71
|
+
// springAopArtifactName (spring-boot-starter-aspectj vs -aop) -- both decisions hinge on the same
|
|
72
|
+
// single signal, the Spring Boot Gradle plugin's own declared major version. Returns null (not a
|
|
73
|
+
// guessed default) when build.gradle is absent or the plugin version can't be found -- callers
|
|
74
|
+
// each decide their own "can't tell, assume the more common case" default, since that default
|
|
75
|
+
// differs per caller (Jackson 2 vs the pre-4.x -aop artifact name).
|
|
76
|
+
function detectSpringBootMajorVersion(repoRoot) {
|
|
60
77
|
const buildGradlePath = path.join(repoRoot, 'build.gradle');
|
|
61
|
-
if (!fs.existsSync(buildGradlePath)) return
|
|
78
|
+
if (!fs.existsSync(buildGradlePath)) return null;
|
|
62
79
|
const text = fs.readFileSync(buildGradlePath, 'utf8');
|
|
63
80
|
const match = text.match(/id\s+['"]org\.springframework\.boot['"]\s+version\s+['"](\d+)\./);
|
|
64
|
-
|
|
81
|
+
return match ? Number(match[1]) : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// D-handles-pilot-cohort: found live, deploying against a real Spring Boot 4.1.0 target --
|
|
85
|
+
// `spring-boot-starter-aop` does not exist as an artifact for Spring Boot 4.x (confirmed against
|
|
86
|
+
// the real Maven Central BOM POM for spring-boot-dependencies:4.1.0, a 404 for the old artifact);
|
|
87
|
+
// it was renamed to `spring-boot-starter-aspectj`. `hasSpringAopDependency()`'s old hardcoded
|
|
88
|
+
// check would have "passed" a target repo that added the WRONG (nonexistent, for its own Boot
|
|
89
|
+
// version) artifact name, only ever caught by a real compile failure.
|
|
90
|
+
export function springAopArtifactName(repoRoot) {
|
|
91
|
+
const majorVersion = detectSpringBootMajorVersion(repoRoot);
|
|
92
|
+
return majorVersion !== null && majorVersion >= 4 ? 'spring-boot-starter-aspectj' : 'spring-boot-starter-aop';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function hasSpringAopDependency(repoRoot) {
|
|
96
|
+
const artifactName = springAopArtifactName(repoRoot);
|
|
97
|
+
for (const name of ['build.gradle', 'build.gradle.kts', 'pom.xml']) {
|
|
98
|
+
const buildFilePath = path.join(repoRoot, name);
|
|
99
|
+
if (!fs.existsSync(buildFilePath)) continue;
|
|
100
|
+
if (fs.readFileSync(buildFilePath, 'utf8').includes(artifactName)) return true;
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function detectJacksonPackage(repoRoot) {
|
|
106
|
+
const majorVersion = detectSpringBootMajorVersion(repoRoot);
|
|
65
107
|
return majorVersion !== null && majorVersion >= 4 ? 'tools.jackson.databind' : 'com.fasterxml.jackson.databind';
|
|
66
108
|
}
|
|
67
109
|
|
|
@@ -99,11 +141,6 @@ function currentlyApprovedFields(approvals, resourceType, patchable) {
|
|
|
99
141
|
return approved;
|
|
100
142
|
}
|
|
101
143
|
|
|
102
|
-
function writeUnit(target, content) {
|
|
103
|
-
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
104
|
-
fs.writeFileSync(target, content);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
144
|
// O3 follow-up (D-handle-registry-enforcement, "Continued"): a coarse, per-resource, emit-time,
|
|
108
145
|
// source-only proxy for "has this resource's own create-flow ever been wired to register a
|
|
109
146
|
// HandleRegistry row" -- see DECISIONS.md for why this stays static and never queries a live
|
|
@@ -136,6 +173,7 @@ export function emitJavaSpring({ repoRoot, featureId, plan, basePackage, resourc
|
|
|
136
173
|
// now needs jacksonPackage too, for the same reason patch codegen already did).
|
|
137
174
|
const patchApprovals = loadPatchApprovals(repoRoot, featureId);
|
|
138
175
|
const jacksonPackage = detectJacksonPackage(repoRoot);
|
|
176
|
+
const aopArtifactName = springAopArtifactName(repoRoot);
|
|
139
177
|
|
|
140
178
|
const infraUnits = INFRA_FILES.map((f) => ({
|
|
141
179
|
id: f.template,
|
|
@@ -145,7 +183,9 @@ export function emitJavaSpring({ repoRoot, featureId, plan, basePackage, resourc
|
|
|
145
183
|
// HandleController.java.tmpl -- applied to every infra file uniformly anyway, matching
|
|
146
184
|
// how BASE_PACKAGE/JACKSON_PACKAGE already do (render()'s replaceAll is a harmless no-op
|
|
147
185
|
// for a token absent from a given template, e.g. JACKSON_PACKAGE inside HandleCodec.java.tmpl).
|
|
148
|
-
|
|
186
|
+
// AOP_ARTIFACT_NAME (D-handles-pilot-cohort): same no-op-when-absent reasoning, only
|
|
187
|
+
// referenced by RecordHandleSnapshot.java.tmpl's own javadoc today.
|
|
188
|
+
rendered: render(path.join(TEMPLATES_DIR, f.template), { BASE_PACKAGE: basePackage, JACKSON_PACKAGE: jacksonPackage, AOP_ARTIFACT_NAME: aopArtifactName, ENFORCE_REGISTRY: String(enforceRegistry) }),
|
|
149
189
|
}));
|
|
150
190
|
|
|
151
191
|
// O4 (D-handle-lifecycle): every resource in THIS feature shares the same contract file and
|
|
@@ -245,25 +285,25 @@ export function emitJavaSpring({ repoRoot, featureId, plan, basePackage, resourc
|
|
|
245
285
|
resourceTypeOf: (file, _content) => file.replace(/ResolverPolicy\.java$|Resolver\.java$/, ''),
|
|
246
286
|
} : null;
|
|
247
287
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
|
|
288
|
+
// D-write-safety-phase0 (item 1): migration.sql used to be regenerated fresh every run,
|
|
289
|
+
// unconditionally, with no manifest tracking and no conflict detection at all -- a hand-edited
|
|
290
|
+
// migration (an added index, a NOT NULL, a tenant column) was silently overwritten on the next
|
|
291
|
+
// `handles emit`. It's feature-scoped (specs/<featureId>/handles/migration.sql), not a
|
|
292
|
+
// repo-wide singleton, but its content depends only on featureId (not on the resolver loop's
|
|
293
|
+
// post-write state) -- so it reuses emitUnits()'s postResolverUnits slot (previously
|
|
294
|
+
// typescript-express-only, for resolvers_index.ts; widened to an array in
|
|
295
|
+
// D-typescript-express-registry-parity once that provider needed two units here at once)
|
|
296
|
+
// rather than adding a third write path, with kind/ownership/owner overridden to reflect real
|
|
297
|
+
// feature ownership.
|
|
258
298
|
const migrationPath = path.join(repoRoot, 'specs', featureId, 'handles', 'migration.sql');
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
299
|
+
const result = emitUnits({
|
|
300
|
+
repoRoot, featureId, provider: 'java-spring', force, reason, infraUnits, resolverUnits, orphanScan, dryRun, computeDiff,
|
|
301
|
+
postResolverUnits: [{
|
|
302
|
+
id: 'migration.sql.tmpl', templatePath: MIGRATION_TEMPLATE, targetAbs: migrationPath,
|
|
303
|
+
render: () => render(MIGRATION_TEMPLATE, { FEATURE_ID: featureId }),
|
|
304
|
+
kind: 'migration', ownership: 'feature', owner: featureId,
|
|
305
|
+
}],
|
|
306
|
+
});
|
|
267
307
|
|
|
268
308
|
const postEmitNotes = [
|
|
269
309
|
'NOT done automatically: applying specs/<id>/handles/migration.sql to any database. Review it and apply yourself.',
|
|
@@ -271,20 +311,25 @@ export function emitJavaSpring({ repoRoot, featureId, plan, basePackage, resourc
|
|
|
271
311
|
// human applies @RecordHandleSnapshot to a real service method AND the target repo has
|
|
272
312
|
// this dependency -- never auto-added to build.gradle, same "review and apply yourself"
|
|
273
313
|
// boundary as the migration note above.
|
|
274
|
-
|
|
314
|
+
`NOT done automatically: HandleAspect.java requires ${aopArtifactName} on your own build.gradle classpath (Spring AOP is not enabled by any other starter; the artifact is named spring-boot-starter-aop before Spring Boot 4, spring-boot-starter-aspectj from Spring Boot 4 on -- ${aopArtifactName} matches what this repo's own detected Spring Boot version needs). Add it yourself before applying @RecordHandleSnapshot to any service method.`,
|
|
275
315
|
];
|
|
276
316
|
// O3 follow-up (D-handle-registry-enforcement, "Continued"): per-resource, conditional on
|
|
277
317
|
// enforceRegistry actually being on -- see hasRecordHandleSnapshot() above.
|
|
318
|
+
// D-write-safety-phase1 (item 2): now ALSO collected into registrationGaps (a real, structured
|
|
319
|
+
// array), not just prose -- the postEmitNote stays unconditional (still visible even when
|
|
320
|
+
// --force acknowledges it) so the CLI layer (bin/bskel.mjs) can decide whether an unacknowledged
|
|
321
|
+
// gap should block the command, without this function knowing anything about --force itself.
|
|
322
|
+
const registrationGaps = [];
|
|
278
323
|
if (enforceRegistry) {
|
|
279
324
|
for (const resource of plan.resources) {
|
|
280
325
|
if (!resource.willGenerateResolver) continue;
|
|
281
326
|
if (hasRecordHandleSnapshot(resource.service.file)) continue;
|
|
282
327
|
const relServiceFile = path.relative(repoRoot, resource.service.file);
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
);
|
|
328
|
+
const note = `${resource.type}: --enforce-registry is on, but no @RecordHandleSnapshot(...) was found anywhere in ${relServiceFile} -- this resource may never get its first HandleRegistry row, and every fetch()/patch() call against it will 404 until something registers it. Apply @RecordHandleSnapshot to ${resource.service.serviceType}'s own create-flow method (or call HandleService.register() by hand at least once per resource), then re-emit. See D-handle-registry-enforcement in DECISIONS.md for the full bootstrapping explanation.`;
|
|
329
|
+
postEmitNotes.push(note);
|
|
330
|
+
registrationGaps.push({ resourceType: resource.type, file: relServiceFile, note });
|
|
286
331
|
}
|
|
287
332
|
}
|
|
288
333
|
|
|
289
|
-
return { ...result, postEmitNotes };
|
|
334
|
+
return { ...result, postEmitNotes, registrationGaps };
|
|
290
335
|
}
|
|
@@ -62,9 +62,10 @@ export function emitObserveJavaSpring({ repoRoot, featureId, contract, basePacka
|
|
|
62
62
|
const result = emitUnits({ repoRoot, featureId, provider: 'java-spring', force, reason, infraUnits, resolverUnits: [], orphanScan: null, dryRun, computeDiff });
|
|
63
63
|
|
|
64
64
|
// The projected observed-schema.json classpath resource -- regenerated unconditionally every
|
|
65
|
-
// run
|
|
66
|
-
//
|
|
67
|
-
//
|
|
65
|
+
// run (unlike handles' own migration.sql, which moved to manifest tracking in
|
|
66
|
+
// D-write-safety-phase0 -- this file stays unconditional: nobody hand-finishes a generated
|
|
67
|
+
// data file the way they hand-finish a resolver stub, so O2-style conflict tracking buys
|
|
68
|
+
// nothing here). `kind: 'spec'` still means "always regenerated, not conflict-tracked".
|
|
68
69
|
const operations = {};
|
|
69
70
|
for (const [opId, opContract] of Object.entries(contract.operations)) {
|
|
70
71
|
operations[opId] = projectOperation(opContract);
|
|
@@ -77,9 +77,18 @@ function findRequestBodyTypeName(controllerFilePath, methodName) {
|
|
|
77
77
|
// during this item's grounding). A DTO living somewhere else is a documented gap: patchable stays
|
|
78
78
|
// empty for that resource, exactly like findServiceFile()'s own "resolver NOT generated" fallback
|
|
79
79
|
// for a service that can't be found.
|
|
80
|
+
// D-write-safety-phase1 (item 4b): a real, bounded fallback -- after the domain/<module>/
|
|
81
|
+
// presentation/dto/ convention (Team-IZ-Backend's own shape) fails, also try the DTO directly
|
|
82
|
+
// under <module>/ with no domain/presentation/dto middle segments, the natural flat-package
|
|
83
|
+
// variant of the same convention (javaSrcRoot is already base-package-anchored -- see
|
|
84
|
+
// detectBasePackage() -- so this is `<basePackage>/<module>/<Type>.java`, not a second guessed
|
|
85
|
+
// base). Does not attempt any other shape: no second real oracle has ever validated one, and
|
|
86
|
+
// guessing further risks W9-style overfitting to an imagined repo rather than a confirmed one.
|
|
80
87
|
function findUpdateDtoFile(javaSrcRoot, module, dtoTypeName) {
|
|
81
|
-
const
|
|
82
|
-
|
|
88
|
+
const conventional = path.join(javaSrcRoot, 'domain', module, 'presentation', 'dto', `${dtoTypeName}.java`);
|
|
89
|
+
if (fs.existsSync(conventional)) return conventional;
|
|
90
|
+
const flat = path.join(javaSrcRoot, module, `${dtoTypeName}.java`);
|
|
91
|
+
return fs.existsSync(flat) ? flat : null;
|
|
83
92
|
}
|
|
84
93
|
|
|
85
94
|
// The full patchable-field pipeline for one entity: find its update endpoint -> find the
|
|
@@ -194,10 +203,20 @@ function findRequiredAuthority(controllerFilePath, methodName) {
|
|
|
194
203
|
// guaranteed for every entity): <Entity>Service under domain/<module>/application/. Only
|
|
195
204
|
// trusted if the file actually exists -- see D-resolver-scope in DECISIONS.md for why a
|
|
196
205
|
// resolver is only generated when this resolves to a real file, not a guessed import.
|
|
206
|
+
// D-write-safety-phase1 (item 4b): falls back to <module>/<Entity>Service.java directly under
|
|
207
|
+
// javaSrcRoot (no domain/application segments) when the conventional path doesn't exist -- the
|
|
208
|
+
// flat-package variant of the same convention. Confirmed this does NOT close the real-world case
|
|
209
|
+
// that motivated it (spring-projects/spring-petclinic): petclinic has no *Service.java at all
|
|
210
|
+
// (controllers call a Spring Data repository directly), and its entities are Integer-keyed, not
|
|
211
|
+
// UUID (see idFieldIsUuid's own gate in planHandles() below, which fires first regardless). This
|
|
212
|
+
// is a real, independent improvement for a different, plausible shape -- a UUID-keyed entity with
|
|
213
|
+
// a Service layer, just not nested under domain/ -- not a claim that it closes the petclinic gap.
|
|
197
214
|
function findServiceFile(javaSrcRoot, module, entityClassName) {
|
|
198
215
|
const guessedType = `${entityClassName}Service`;
|
|
199
|
-
const
|
|
200
|
-
|
|
216
|
+
const conventional = path.join(javaSrcRoot, 'domain', module, 'application', `${guessedType}.java`);
|
|
217
|
+
if (fs.existsSync(conventional)) return { serviceType: guessedType, file: conventional };
|
|
218
|
+
const flat = path.join(javaSrcRoot, module, `${guessedType}.java`);
|
|
219
|
+
return fs.existsSync(flat) ? { serviceType: guessedType, file: flat } : null;
|
|
201
220
|
}
|
|
202
221
|
|
|
203
222
|
// Counts top-level commas in a captured argument list, treating `<...>` (generics) as non-
|
|
@@ -246,10 +265,26 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
|
|
|
246
265
|
|
|
247
266
|
for (const entity of targetModule.entities) {
|
|
248
267
|
if (resourceFilter && !resourceFilter.includes(entity.className)) continue;
|
|
268
|
+
|
|
269
|
+
// D-write-safety-phase1 (item 4a): a non-UUID primary key disqualifies resolver generation
|
|
270
|
+
// entirely, independent of whether a Service class can be found -- fetch(UUID resourceUid)
|
|
271
|
+
// and the sbf1_ handle token format both hard-assume a UUID identity. Checked BEFORE the
|
|
272
|
+
// service lookup so the note names the real, decisive reason instead of the generic "no
|
|
273
|
+
// XService found" note below, which would be true but misleading here (implies the fix is
|
|
274
|
+
// finding/writing a service, when no service could ever make this entity addressable).
|
|
275
|
+
// `=== false` (not just falsy) deliberately excludes `null` (idField itself was never
|
|
276
|
+
// found, e.g. inherited from an unindexed superclass) -- that stays the existing, separate
|
|
277
|
+
// "no XService found" path unchanged, since this scanner genuinely doesn't know the type
|
|
278
|
+
// there, not that it's confirmed non-UUID.
|
|
279
|
+
const pkIsNonUuid = entity.idFieldIsUuid === false;
|
|
280
|
+
if (pkIsNonUuid) {
|
|
281
|
+
notes.push(`${entity.className}: primary key is declared \`${entity.idFieldType}\`, not UUID -- the handles subsystem only generates UUID-addressable resolvers (fetch(UUID resourceUid); the sbf1_ handle token format encodes a UUID). Resolver NOT generated for this entity, and cannot be regardless of where its service file lives. See D-handle-uid-type-binding in DECISIONS.md.`);
|
|
282
|
+
}
|
|
283
|
+
|
|
249
284
|
const fetchOp = findFetchOperation(targetModule.controllers, entity.className);
|
|
250
285
|
const authorityResult = findRequiredAuthority(fetchOp?.controllerFile ?? null, fetchOp?.method ?? null);
|
|
251
286
|
const requiredAuthority = authorityResult.authority;
|
|
252
|
-
const service = findServiceFile(javaSrcRoot, targetModule.module, entity.className);
|
|
287
|
+
const service = pkIsNonUuid ? null : findServiceFile(javaSrcRoot, targetModule.module, entity.className);
|
|
253
288
|
const serviceParamCount = (service && fetchOp) ? countServiceMethodParams(service.file, fetchOp.method) : null;
|
|
254
289
|
|
|
255
290
|
// O5 (D-resolver-authorization-action-aware): the SAME extraction, run again against the
|
|
@@ -276,12 +311,16 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
|
|
|
276
311
|
notes.push(`${entity.className}: no method-level or class-level @PreAuthorize(hasRole(...)/hasAuthority(...)) found for ${updateOpForAuthority.controllerClassName}.${updateOpForAuthority.method} -- requiredAuthorityForPatch() defaults to "TODO_ROLE", fix before relying on it`);
|
|
277
312
|
}
|
|
278
313
|
if (!service) {
|
|
279
|
-
|
|
314
|
+
// pkIsNonUuid already explained the real reason above -- this note would be true but
|
|
315
|
+
// redundant (and misleading: it implies finding a service would fix it).
|
|
316
|
+
if (!pkIsNonUuid) {
|
|
317
|
+
notes.push(`${entity.className}: no ${entity.className}Service found under domain/${targetModule.module}/application/ or ${targetModule.module}/ -- resolver NOT generated for this entity (would produce a broken import). Emit it by hand once the right service is identified.`);
|
|
318
|
+
}
|
|
280
319
|
} else if (fetchOp && serviceParamCount !== 1) {
|
|
281
320
|
const reason = serviceParamCount === null
|
|
282
321
|
? `could not find a ${fetchOp.method}(...) method on ${service.serviceType} to confirm its argument count`
|
|
283
322
|
: `${service.serviceType}.${fetchOp.method} takes ${serviceParamCount} argument(s), not the single resource UUID the generated resolver always passes`;
|
|
284
|
-
notes.push(`${entity.className}: ${reason} -- resolver NOT generated (would either fail to compile or silently call the wrong overload and drop a required scoping argument, e.g. an organization/cohort id). Wire it by hand.`);
|
|
323
|
+
notes.push(`${entity.className}: ${reason} -- resolver NOT generated (would either fail to compile or silently call the wrong overload and drop a required scoping argument, e.g. an organization/cohort id). Wire it by hand -- ResourceResolver#fetch/#patchField receive the request's Authentication (D-resolver-authentication-context) for exactly this case, e.g. deriving a tenant/org id the same way the resource's own controller already does.`);
|
|
285
324
|
}
|
|
286
325
|
|
|
287
326
|
// A3 (D-patch-strategy): only worth computing once fetch()/the resolver itself is actually
|
|
@@ -319,6 +358,11 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
|
|
|
319
358
|
type: entity.className,
|
|
320
359
|
table: entity.table,
|
|
321
360
|
idField: entity.idField,
|
|
361
|
+
// D-write-safety-phase1 (item 4a): surfaced in --json output too, not just the note --
|
|
362
|
+
// null/null when the type genuinely couldn't be determined (not the same as confirmed
|
|
363
|
+
// non-UUID; see pkIsNonUuid's own `=== false` check above).
|
|
364
|
+
idFieldType: entity.idFieldType,
|
|
365
|
+
idFieldIsUuid: entity.idFieldIsUuid,
|
|
322
366
|
fetchOperation: fetchOp,
|
|
323
367
|
updateOperation: patchResult.updateOperation,
|
|
324
368
|
patchable: patchResult.patchable,
|