backend-skeleton 1.0.0-beta.9 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +122 -12
- package/bin/bskel.mjs +564 -15
- package/contracts/emit.mjs +5 -1
- package/contracts/export.mjs +26 -3
- package/contracts/openapi.mjs +29 -3
- package/handles/_engine.mjs +79 -29
- package/handles/providers/java-spring/plan.mjs +22 -9
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +7 -3
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +12 -6
- package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +101 -39
- package/handles/providers/typescript-express/emit.mjs +23 -23
- package/handles/providers/typescript-express/observe.mjs +101 -0
- package/handles/providers/typescript-express/templates/contractCheck.ts.tmpl +136 -0
- package/handles/providers/typescript-express/templates/observeContract.ts.tmpl +146 -0
- package/handles/providers/typescript-express/templates/observedSchema.ts.tmpl +116 -0
- package/lib/attest.mjs +40 -0
- package/lib/cli.mjs +125 -3
- package/lib/cross-feature-collisions.mjs +286 -0
- package/lib/diff.mjs +35 -0
- package/lib/fsutil.mjs +7 -2
- package/lib/gate-definitions.mjs +85 -1
- package/lib/gates.mjs +5 -1
- package/lib/http-server.mjs +192 -6
- package/lib/lock.mjs +68 -15
- package/lib/patch-kinds.mjs +52 -0
- package/lib/patch-transactions.mjs +206 -0
- package/lib/serve-ui.html +211 -0
- package/lib/workflow.mjs +31 -3
- package/package.json +5 -2
- package/scanners/adapters/java-spring.mjs +6 -0
- package/scanners/adapters/python-fastapi.mjs +9 -1
- package/scanners/adapters/typescript-express.mjs +6 -0
- package/scanners/db/ddl-apply.mjs +253 -0
- package/scanners/db/introspect.mjs +61 -32
- package/scanners/db/migrations.mjs +73 -18
- package/schemas/cross-feature-report.schema.json +66 -0
- package/schemas/cross-feature-resolution.schema.json +28 -0
- package/schemas/gate-attestation.schema.json +22 -0
- package/schemas/gate-export.schema.json +58 -0
- package/schemas/patch-transaction.schema.json +182 -0
- package/schemas/scan-report.schema.json +6 -4
- package/schemas/stack-choice.schema.json +12 -1
- package/stack/apply.mjs +4 -1
- package/stack/catalog/ngrok.yml +8 -2
- package/stack/config-apply.mjs +168 -0
package/contracts/emit.mjs
CHANGED
|
@@ -31,8 +31,12 @@ export const CONTRACT_SCHEMA_VERSION = '8';
|
|
|
31
31
|
// item. `pathParamsHeuristic` names every segment that still fell back, so a downstream consumer
|
|
32
32
|
// (contracts/export.mjs's collectOmissions()) can tell, per operation, whether ANY segment is still
|
|
33
33
|
// a guess -- `null` (never `[]`) when every segment was source-resolved or the route has none.
|
|
34
|
+
// Update (D-openapi-path-params, closing the O8 typescript-express port's own Finding 2): the
|
|
35
|
+
// regex also recognizes Express's own `:name`/`:name(...)` segment syntax now, not just OpenAPI/
|
|
36
|
+
// Spring/FastAPI-style `{name}` -- additive-only for java-spring/python-fastapi (their own route
|
|
37
|
+
// strings never contain a colon in this position).
|
|
34
38
|
function pathParamsSchema(routePath, sourcePathParamSchemas = null) {
|
|
35
|
-
const params = [...routePath.matchAll(/\{(\w+)\}
|
|
39
|
+
const params = [...routePath.matchAll(/\{(\w+)\}|:(\w+)(?:\([^)]*\))?/g)].map((m) => m[1] ?? m[2]);
|
|
36
40
|
const properties = {};
|
|
37
41
|
const heuristicNames = [];
|
|
38
42
|
for (const p of params) {
|
package/contracts/export.mjs
CHANGED
|
@@ -239,20 +239,37 @@ function renderDescription(contract, omissions, statusCodes) {
|
|
|
239
239
|
return lines.join('\n');
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
// Update (D-openapi-export, closing the gap named in D-openapi-reconciliation's own Update note):
|
|
243
|
+
// rewrites Express's own `:name`/`:name(...)` segments into OpenAPI's `{name}` templating -- an
|
|
244
|
+
// OpenAPI Paths Object key must use `{name}` (verified against the real 3.1 meta-schema), but
|
|
245
|
+
// `op.path` for an unmatched/document-less typescript-express operation is still the scan's own
|
|
246
|
+
// colon syntax. A true no-op for java-spring/python-fastapi (their own paths never contain a colon
|
|
247
|
+
// in this position, confirmed live -- no behavior change).
|
|
248
|
+
function colonPathToBraceSyntax(routePath) {
|
|
249
|
+
return routePath.replace(/:(\w+)(?:\([^)]*\))?/g, '{$1}');
|
|
250
|
+
}
|
|
251
|
+
|
|
242
252
|
// Every `{name}` in the path template, in order, deduplicated (OpenAPI forbids two parameters
|
|
243
253
|
// sharing name+location). The schema comes from the contract's own `pathParams.properties`; the
|
|
244
254
|
// `{}` fallback for a name the contract has no property for is "unconstrained", which is both
|
|
245
255
|
// honest and the minimum the 3.1 meta-schema accepts (`$defs.parameter`'s
|
|
246
256
|
// `oneOf: [{required:["schema"]}, {required:["content"]}]` means a parameter MUST carry one or the
|
|
247
257
|
// other -- confirmed by executing the real schema).
|
|
258
|
+
//
|
|
259
|
+
// Update (D-openapi-export, closing the gap named in D-openapi-reconciliation's own Update note):
|
|
260
|
+
// also recognizes Express's own `:name`/`:name(...)` syntax -- `op.path` for a typescript-express
|
|
261
|
+
// operation that never matched/adopted against an OpenAPI document (drift/missing/unresolved/
|
|
262
|
+
// ambiguous, or a document-less scan-only contract) is still the scan's own colon syntax, and this
|
|
263
|
+
// function would otherwise silently list zero path parameters for it. The existing `{name}`
|
|
264
|
+
// branch's own character class (`[^{}/]+`, more permissive than `\w+`) stays untouched.
|
|
248
265
|
function buildPathParameters(op) {
|
|
249
266
|
const props = op.pathParams && typeof op.pathParams === 'object' && !Array.isArray(op.pathParams)
|
|
250
267
|
? (op.pathParams.properties ?? {})
|
|
251
268
|
: {};
|
|
252
269
|
const seen = new Set();
|
|
253
270
|
const params = [];
|
|
254
|
-
for (const match of String(op.path).matchAll(/\{([^{}/]+)\}
|
|
255
|
-
const name = match[1];
|
|
271
|
+
for (const match of String(op.path).matchAll(/\{([^{}/]+)\}|:(\w+)(?:\([^)]*\))?/g)) {
|
|
272
|
+
const name = match[1] ?? match[2];
|
|
256
273
|
if (seen.has(name)) continue;
|
|
257
274
|
seen.add(name);
|
|
258
275
|
params.push({
|
|
@@ -500,7 +517,13 @@ export function buildOpenApiDocument({ contract, snapshot = null, options = {} }
|
|
|
500
517
|
|
|
501
518
|
for (const operationId of operationIds) {
|
|
502
519
|
const op = contract.operations[operationId];
|
|
503
|
-
|
|
520
|
+
// Update (D-openapi-export): op.path can still be Express's own colon syntax (a
|
|
521
|
+
// typescript-express operation that never matched/adopted against an OpenAPI document) --
|
|
522
|
+
// an OpenAPI Paths Object key must use {name} templating (verified against the real 3.1
|
|
523
|
+
// meta-schema), so this is rewritten unconditionally here, once, before it becomes both the
|
|
524
|
+
// document's own path key AND every downstream use of `route` in this loop. A no-op for
|
|
525
|
+
// java-spring/python-fastapi (their own paths never contain a colon in this position).
|
|
526
|
+
const route = colonPathToBraceSyntax(String(op.path));
|
|
504
527
|
if (!route.startsWith('/')) {
|
|
505
528
|
return { ok: false, error: `operation "${operationId}" has path "${route}", which does not start with "/" -- an OpenAPI Paths Object key must (verified against the official 3.1 meta-schema)` };
|
|
506
529
|
}
|
package/contracts/openapi.mjs
CHANGED
|
@@ -329,6 +329,32 @@ export function normalizeRoute(routePath) {
|
|
|
329
329
|
return normalized;
|
|
330
330
|
}
|
|
331
331
|
|
|
332
|
+
// A1/Finding-1 fix (D-openapi-reconciliation, closing D-runtime-conformance-receipts' own
|
|
333
|
+
// Finding 1, 0047e85): a route MATCH KEY collapses every parameter segment -- both OpenAPI/
|
|
334
|
+
// Spring/FastAPI-style `{name}` and Express's own `:name`/`:name(...)` -- to the identical
|
|
335
|
+
// placeholder, reusing the EXACT SAME two-alternative regex `contracts/emit.mjs`'s
|
|
336
|
+
// pathParamsSchema() already added for the sibling problem (A9, D-openapi-path-params: PARAM-NAME
|
|
337
|
+
// extraction from the scan's own route string). This is for MATCHING two route STRINGS as the same
|
|
338
|
+
// shape, not for naming a parameter -- the name is always discarded (":param" regardless of which
|
|
339
|
+
// name), unlike pathParamsSchema()'s own capture-group use of the same regex. Only ever used to
|
|
340
|
+
// build/read a `byRoute` MAP KEY -- the document's own raw path string (its own `{name}`/`:name`
|
|
341
|
+
// syntax, verbatim) is always what's stored/returned as `entry.path`/`docEntry.path`; no
|
|
342
|
+
// downstream consumer ever sees a literal ":param" segment.
|
|
343
|
+
//
|
|
344
|
+
// Additive-only for java-spring/python-fastapi's existing {name}-only matching: two `{name}`-syntax
|
|
345
|
+
// paths that were string-equal under the old normalizeRoute()-only key stay equal here (every
|
|
346
|
+
// {anyName} collapses to the same placeholder regardless of name); two that were string-UNEQUAL
|
|
347
|
+
// (different LITERAL segments) stay unequal (only parameter segments are touched). The one new
|
|
348
|
+
// theoretical collision -- two really-different real params at the exact same position/verb --
|
|
349
|
+
// was already an inherent REST-design conflict before this fix (no two real routes can coexist
|
|
350
|
+
// that way), and the existing `hits.length > 1 -> kind: 'ambiguous'` handling (reconcileModule,
|
|
351
|
+
// below) already covers a same-key collision safely, never silently picking one -- confirmed no
|
|
352
|
+
// existing fixture in test/contract-openapi.test.mjs or test/contract-cli.test.mjs relies on the
|
|
353
|
+
// opposite.
|
|
354
|
+
export function canonicalRouteShape(routePath) {
|
|
355
|
+
return normalizeRoute(routePath.replace(/\{(\w+)\}|:(\w+)(?:\([^)]*\))?/g, ':param'));
|
|
356
|
+
}
|
|
357
|
+
|
|
332
358
|
// Reads and parses exactly once. Every failure mode returns {ok:false, error}, never throws --
|
|
333
359
|
// this is the one function in the module that touches the filesystem, so it's the one place that
|
|
334
360
|
// has to be defensive about a file that's huge, unreadable, not JSON, or JSON-but-not-an-object.
|
|
@@ -446,7 +472,7 @@ export function indexOpenApiDocument(doc) {
|
|
|
446
472
|
continue;
|
|
447
473
|
}
|
|
448
474
|
stats.path_count++;
|
|
449
|
-
const
|
|
475
|
+
const routeShape = canonicalRouteShape(routeKey);
|
|
450
476
|
|
|
451
477
|
for (const methodKey of Object.keys(pathItem)) {
|
|
452
478
|
const verbLower = methodKey.toLowerCase();
|
|
@@ -498,7 +524,7 @@ export function indexOpenApiDocument(doc) {
|
|
|
498
524
|
const description = typeof operation.description === 'string' ? operation.description : null;
|
|
499
525
|
const entry = { verb, path: routeKey, operationId, requestBody, responses, parameters, security, summary, tags, description };
|
|
500
526
|
|
|
501
|
-
const routeMatchKey = `${verb} ${
|
|
527
|
+
const routeMatchKey = `${verb} ${routeShape}`;
|
|
502
528
|
const existingRoute = byRoute.get(routeMatchKey);
|
|
503
529
|
if (existingRoute) existingRoute.push(entry); else byRoute.set(routeMatchKey, [entry]);
|
|
504
530
|
|
|
@@ -1475,7 +1501,7 @@ export function reconcileModule({ index, module, pathPrefix = null, includeDescr
|
|
|
1475
1501
|
stats.unresolved++;
|
|
1476
1502
|
} else {
|
|
1477
1503
|
const candidates = prefix.value === '' ? [ep.path] : [...new Set([prefix.value + ep.path, ep.path])];
|
|
1478
|
-
const hits = candidates.flatMap((c) => index.byRoute.get(`${ep.verb} ${
|
|
1504
|
+
const hits = candidates.flatMap((c) => index.byRoute.get(`${ep.verb} ${canonicalRouteShape(c)}`) ?? []);
|
|
1479
1505
|
if (hits.length === 0) {
|
|
1480
1506
|
result = { kind: 'unresolved', reason: 'no-candidate', scanVerb: ep.verb, scanPath: ep.path };
|
|
1481
1507
|
stats.unresolved++;
|
package/handles/_engine.mjs
CHANGED
|
@@ -5,11 +5,16 @@
|
|
|
5
5
|
// D-handles-providers (G4) in DECISIONS.md. `handles/providers/java-spring/emit.mjs` is the
|
|
6
6
|
// reference caller to compare against if this file's behavior is ever in question.
|
|
7
7
|
import fs from 'node:fs';
|
|
8
|
-
import os from 'node:os';
|
|
9
8
|
import path from 'node:path';
|
|
10
9
|
import { execFileSync } from 'node:child_process';
|
|
11
10
|
import { sha256File, sha256String } from '../lib/fsutil.mjs';
|
|
12
11
|
import { loadManifest, saveManifest, classifyFile, extractResolverOwnerFeatureId, BSKEL_GENERATED_MARKER } from '../lib/handles-manifest.mjs';
|
|
12
|
+
// D-patch-transactions: unifiedDiff() moved to lib/diff.mjs (stack/config-apply.mjs needs the
|
|
13
|
+
// identical mechanism for its own collateral-diff safety gate) -- re-exported here so every
|
|
14
|
+
// existing caller in this file keeps working unchanged.
|
|
15
|
+
import { unifiedDiff } from '../lib/diff.mjs';
|
|
16
|
+
|
|
17
|
+
export { unifiedDiff };
|
|
13
18
|
|
|
14
19
|
function readIfExists(target) {
|
|
15
20
|
return fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null;
|
|
@@ -20,33 +25,6 @@ function writeUnit(target, content) {
|
|
|
20
25
|
fs.writeFileSync(target, content);
|
|
21
26
|
}
|
|
22
27
|
|
|
23
|
-
// D4 (D-handles-dryrun): a real unified diff via `git diff --no-index`, not a hand-rolled diff
|
|
24
|
-
// algorithm -- `git` is already a hard dependency (isDirtyOrUntracked below already shells out to
|
|
25
|
-
// it on every emit), so this adds zero new dependencies. `cwd: tmpDir` + relative `a/<relPath>`/
|
|
26
|
-
// `b/<relPath>` paths (rather than absolute temp paths) keep the diff header clean and
|
|
27
|
-
// reproducible -- the random tmpdir name never leaks into the output. `git diff --no-index` exits
|
|
28
|
-
// 1 when the two sides differ (the expected, common case here, not a failure) -- only a status
|
|
29
|
-
// other than 0/1 is a genuine error worth throwing.
|
|
30
|
-
export function unifiedDiff(relPath, before, after) {
|
|
31
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bskel-handles-diff-'));
|
|
32
|
-
try {
|
|
33
|
-
const beforeAbs = path.join(tmpDir, 'a', relPath);
|
|
34
|
-
const afterAbs = path.join(tmpDir, 'b', relPath);
|
|
35
|
-
fs.mkdirSync(path.dirname(beforeAbs), { recursive: true });
|
|
36
|
-
fs.mkdirSync(path.dirname(afterAbs), { recursive: true });
|
|
37
|
-
fs.writeFileSync(beforeAbs, before ?? '');
|
|
38
|
-
fs.writeFileSync(afterAbs, after ?? '');
|
|
39
|
-
try {
|
|
40
|
-
return execFileSync('git', ['diff', '--no-index', '--no-color', '--', `a/${relPath}`, `b/${relPath}`], { cwd: tmpDir, encoding: 'utf8' });
|
|
41
|
-
} catch (err) {
|
|
42
|
-
if (err.status === 1 && typeof err.stdout === 'string') return err.stdout;
|
|
43
|
-
throw err;
|
|
44
|
-
}
|
|
45
|
-
} finally {
|
|
46
|
-
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
28
|
const DIFFABLE_ACTIONS = new Set(['update', 'conflict', 'adopt-update']);
|
|
51
29
|
|
|
52
30
|
// O2: refuses --force on a target that isn't safely recoverable from git history -- a --force
|
|
@@ -79,7 +57,17 @@ function isDirtyOrUntracked(repoRoot, absPath) {
|
|
|
79
57
|
// computeDiff: D4 -- when true, attaches a real unified diff (git diff --no-index) to every
|
|
80
58
|
// 'update'/'conflict'/'adopt-update' action -- the only 3 where content actually
|
|
81
59
|
// differs. Off by default since it shells out to git per diffable file.
|
|
82
|
-
|
|
60
|
+
// postResolverUnit: D-patch-transactions (Continued) -- an OPTIONAL single unit, { id,
|
|
61
|
+
// templatePath, targetAbs, render() => string }, whose correct content can only
|
|
62
|
+
// be computed AFTER resolverUnits above have been written (e.g. typescript-
|
|
63
|
+
// express's resolvers_index.ts barrel -- its import list must reflect the
|
|
64
|
+
// resolvers directory's REAL final on-disk listing, including this run's own
|
|
65
|
+
// just-written resolver files, not just this run's own resolverUnits -- a 4th
|
|
66
|
+
// infraUnits entry can't do this, since infra is processed BEFORE resolvers).
|
|
67
|
+
// Reuses the exact same classify/conflict/force/manifest logic the infra loop
|
|
68
|
+
// above already implements, applied to exactly one repo-owned (kind: 'infra')
|
|
69
|
+
// unit. null (the default) is a true no-op.
|
|
70
|
+
export function emitUnits({ repoRoot, featureId, provider, force = false, reason = '', infraUnits, resolverUnits, orphanScan, dryRun = false, computeDiff = false, postResolverUnit = null }) {
|
|
83
71
|
const manifest = loadManifest(repoRoot);
|
|
84
72
|
const nowIso = new Date().toISOString();
|
|
85
73
|
|
|
@@ -245,6 +233,68 @@ export function emitUnits({ repoRoot, featureId, provider, force = false, reason
|
|
|
245
233
|
recordAction({ relPath, kind: 'resolver', action, resourceType: u.resourceType, diskContent, rendered: u.rendered });
|
|
246
234
|
}
|
|
247
235
|
|
|
236
|
+
// ---- post-resolver unit: an OPTIONAL single unit whose correct content can only be computed
|
|
237
|
+
// AFTER the resolver loop above has finished writing (e.g. typescript-express's
|
|
238
|
+
// resolvers_index.ts barrel -- its import list must reflect the resolvers directory's REAL
|
|
239
|
+
// on-disk listing, including THIS run's own just-written resolver files, not just this run's
|
|
240
|
+
// own resolverUnits). Reuses the EXACT SAME classify/conflict/force/manifest logic the infra
|
|
241
|
+
// loop above already implements, applied to exactly one unit. `render()` is called HERE, not
|
|
242
|
+
// earlier, precisely so it observes this run's own writes. null (the default) is a true no-op
|
|
243
|
+
// -- java-spring/python-fastapi never pass this, so this block never executes for them. See
|
|
244
|
+
// D-patch-transactions (Continued) in DECISIONS.md for why this couldn't just be a 4th
|
|
245
|
+
// infraUnits entry. ----
|
|
246
|
+
if (postResolverUnit) {
|
|
247
|
+
const u = postResolverUnit;
|
|
248
|
+
const rendered = u.render();
|
|
249
|
+
const relPath = path.relative(repoRoot, u.targetAbs);
|
|
250
|
+
const diskContent = readIfExists(u.targetAbs);
|
|
251
|
+
const exists = diskContent !== null;
|
|
252
|
+
const diskHash = exists ? sha256String(diskContent) : null;
|
|
253
|
+
const freshRenderHash = sha256String(rendered);
|
|
254
|
+
const entry = manifest.files[relPath];
|
|
255
|
+
const matchesPristineRender = exists && diskContent === rendered;
|
|
256
|
+
const action = classifyFile({ exists, diskHash, manifestEntryHash: entry?.generated_hash ?? null, freshRenderHash, matchesPristineRender });
|
|
257
|
+
|
|
258
|
+
if (action === 'conflict' && !force) {
|
|
259
|
+
conflicts.push({ path: relPath, kind: 'infra', reason: 'diverged from the last content backend-skeleton generated -- see notes for remediation' });
|
|
260
|
+
recordAction({ relPath, kind: 'infra', action, diskContent, rendered });
|
|
261
|
+
} else if (action === 'conflict') {
|
|
262
|
+
if (isDirtyOrUntracked(repoRoot, u.targetAbs)) {
|
|
263
|
+
conflicts.push({ path: relPath, kind: 'infra', reason: 'refusing --force: this file has uncommitted/untracked changes -- commit or stash it first so the overwrite is recoverable' });
|
|
264
|
+
recordAction({ relPath, kind: 'infra', action, diskContent, rendered });
|
|
265
|
+
} else {
|
|
266
|
+
if (!dryRun) {
|
|
267
|
+
manifest.files[relPath] = {
|
|
268
|
+
kind: 'infra', ownership: 'repo', owner: '_repo', provider, template: u.id,
|
|
269
|
+
template_hash: sha256File(u.templatePath), generated_hash: freshRenderHash,
|
|
270
|
+
updated_at: nowIso, last_force: { reason, at: nowIso },
|
|
271
|
+
};
|
|
272
|
+
manifestChanged = true;
|
|
273
|
+
writeUnit(u.targetAbs, rendered);
|
|
274
|
+
}
|
|
275
|
+
written.push(relPath);
|
|
276
|
+
forced.push(relPath);
|
|
277
|
+
recordAction({ relPath, kind: 'infra', action, diskContent, rendered });
|
|
278
|
+
}
|
|
279
|
+
} else if (action === 'unchanged') {
|
|
280
|
+
recordAction({ relPath, kind: 'infra', action });
|
|
281
|
+
} else {
|
|
282
|
+
if (action !== 'adopt-unchanged') {
|
|
283
|
+
if (!dryRun) writeUnit(u.targetAbs, rendered);
|
|
284
|
+
written.push(relPath);
|
|
285
|
+
}
|
|
286
|
+
if (!dryRun) {
|
|
287
|
+
manifest.files[relPath] = {
|
|
288
|
+
kind: 'infra', ownership: 'repo', owner: '_repo', provider, template: u.id,
|
|
289
|
+
template_hash: sha256File(u.templatePath), generated_hash: freshRenderHash,
|
|
290
|
+
updated_at: nowIso,
|
|
291
|
+
};
|
|
292
|
+
manifestChanged = true;
|
|
293
|
+
}
|
|
294
|
+
recordAction({ relPath, kind: 'infra', action, diskContent, rendered });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
248
298
|
// ---- orphan detection: a resolver this feature's CURRENT plan no longer generates, left
|
|
249
299
|
// untouched and never deleted -- same conservative bias as D-migration-scope/D-config-patch.
|
|
250
300
|
// Suppressed entirely under --resource (orphanScan === null), since every resource outside the
|
|
@@ -121,6 +121,7 @@ function planPatchable({ javaSrcRoot, module: moduleName, controllers, entityCla
|
|
|
121
121
|
// below already consumes -- findRequiredAuthority()/extractPreAuthorize()/classBodyStart() are
|
|
122
122
|
// completely unchanged, D-security-7's own region-carving logic untouched.
|
|
123
123
|
const HAS_ROLE_RE = /@PreAuthorize\(\s*"hasRole\('([^']+)'\)"\s*\)/;
|
|
124
|
+
const HAS_AUTHORITY_RE = /@PreAuthorize\(\s*"hasAuthority\('([^']+)'\)"\s*\)/;
|
|
124
125
|
const PRE_AUTH_RE = /@PreAuthorize\(/;
|
|
125
126
|
|
|
126
127
|
function methodMappingBoundaries(text) {
|
|
@@ -138,14 +139,26 @@ function classBodyStart(text) {
|
|
|
138
139
|
}
|
|
139
140
|
|
|
140
141
|
// Returns { authority, unsupported } for an @PreAuthorize search over one region of source text.
|
|
141
|
-
// `unsupported: true` means an @PreAuthorize annotation IS present but isn't the
|
|
142
|
-
//
|
|
143
|
-
// must fail closed (TODO_ROLE) rather than silently treating it as "no authority
|
|
144
|
-
// falling back to a weaker/wrong source.
|
|
142
|
+
// `unsupported: true` means an @PreAuthorize annotation IS present but isn't one of the two
|
|
143
|
+
// simple shapes this regex-based scanner understands (hasAnyRole, hasAnyAuthority, SpEL, etc.) --
|
|
144
|
+
// the caller must fail closed (TODO_ROLE) rather than silently treating it as "no authority
|
|
145
|
+
// found" and falling back to a weaker/wrong source.
|
|
146
|
+
//
|
|
147
|
+
// O5 (D-resolver-authorization-action-aware, hasAuthority follow-up): hasRole('X') and
|
|
148
|
+
// hasAuthority('X') are NOT interchangeable at the Spring Security level -- hasRole('X') checks
|
|
149
|
+
// for the granted authority "ROLE_X" (an implicit prefix Spring itself applies), hasAuthority('X')
|
|
150
|
+
// checks for "X" verbatim. The returned `authority` string is the LITERAL granted-authority value
|
|
151
|
+
// the generated code must match, decided HERE (plan time), not left for the template to re-derive
|
|
152
|
+
// -- HandleController.java.tmpl's requireAuthority() does one plain equality check regardless of
|
|
153
|
+
// which shape produced the value. This is the real discriminant this item's own EXIT note named:
|
|
154
|
+
// widening the regex alone, without this prefix decision, would generate an incorrect check.
|
|
145
155
|
function extractPreAuthorize(region) {
|
|
146
156
|
if (!PRE_AUTH_RE.test(region)) return null;
|
|
147
157
|
const hasRoleMatch = region.match(HAS_ROLE_RE);
|
|
148
|
-
|
|
158
|
+
if (hasRoleMatch) return { authority: `ROLE_${hasRoleMatch[1]}`, unsupported: false };
|
|
159
|
+
const hasAuthorityMatch = region.match(HAS_AUTHORITY_RE);
|
|
160
|
+
if (hasAuthorityMatch) return { authority: hasAuthorityMatch[1], unsupported: false };
|
|
161
|
+
return { authority: null, unsupported: true };
|
|
149
162
|
}
|
|
150
163
|
|
|
151
164
|
// D-security-7: a controller with more than one method can require DIFFERENT roles per method --
|
|
@@ -253,14 +266,14 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
|
|
|
253
266
|
if (!fetchOp) {
|
|
254
267
|
notes.push(`${entity.className}: no single-resource GET endpoint found on a controller whose name contains "${entity.className}" -- fetch() will need to be hand-written`);
|
|
255
268
|
} else if (authorityResult.unsupported) {
|
|
256
|
-
notes.push(`${entity.className}: @PreAuthorize found on ${fetchOp.controllerClassName}.${fetchOp.method} (or its class) but not in the simple hasRole('X') shape this scanner understands (e.g. hasAnyRole/SpEL) -- requiredAuthority() defaults to "TODO_ROLE" (fails closed) until a human fixes it`);
|
|
269
|
+
notes.push(`${entity.className}: @PreAuthorize found on ${fetchOp.controllerClassName}.${fetchOp.method} (or its class) but not in the simple hasRole('X')/hasAuthority('X') shape this scanner understands (e.g. hasAnyRole/SpEL) -- requiredAuthority() defaults to "TODO_ROLE" (fails closed) until a human fixes it`);
|
|
257
270
|
} else if (!requiredAuthority) {
|
|
258
|
-
notes.push(`${entity.className}: no method-level or class-level @PreAuthorize(hasRole(...)) found for ${fetchOp.controllerClassName}.${fetchOp.method} -- requiredAuthority() defaults to "TODO_ROLE", fix before relying on it`);
|
|
271
|
+
notes.push(`${entity.className}: no method-level or class-level @PreAuthorize(hasRole(...)/hasAuthority(...)) found for ${fetchOp.controllerClassName}.${fetchOp.method} -- requiredAuthority() defaults to "TODO_ROLE", fix before relying on it`);
|
|
259
272
|
}
|
|
260
273
|
if (updateOpForAuthority && patchAuthorityResult.unsupported) {
|
|
261
|
-
notes.push(`${entity.className}: @PreAuthorize found on ${updateOpForAuthority.controllerClassName}.${updateOpForAuthority.method} (or its class) but not in the simple hasRole('X') shape this scanner understands -- requiredAuthorityForPatch() defaults to "TODO_ROLE" (fails closed) until a human fixes it`);
|
|
274
|
+
notes.push(`${entity.className}: @PreAuthorize found on ${updateOpForAuthority.controllerClassName}.${updateOpForAuthority.method} (or its class) but not in the simple hasRole('X')/hasAuthority('X') shape this scanner understands -- requiredAuthorityForPatch() defaults to "TODO_ROLE" (fails closed) until a human fixes it`);
|
|
262
275
|
} else if (updateOpForAuthority && !requiredAuthorityForPatch) {
|
|
263
|
-
notes.push(`${entity.className}: no method-level or class-level @PreAuthorize(hasRole(...)) found for ${updateOpForAuthority.controllerClassName}.${updateOpForAuthority.method} -- requiredAuthorityForPatch() defaults to "TODO_ROLE", fix before relying on it`);
|
|
276
|
+
notes.push(`${entity.className}: no method-level or class-level @PreAuthorize(hasRole(...)/hasAuthority(...)) found for ${updateOpForAuthority.controllerClassName}.${updateOpForAuthority.method} -- requiredAuthorityForPatch() defaults to "TODO_ROLE", fix before relying on it`);
|
|
264
277
|
}
|
|
265
278
|
if (!service) {
|
|
266
279
|
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.`);
|
|
@@ -200,12 +200,16 @@ public class HandleController {
|
|
|
200
200
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "no active registration for this handle"));
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
-
|
|
203
|
+
// O5 (D-resolver-authorization-action-aware, hasAuthority follow-up): a plain equality check --
|
|
204
|
+
// the ROLE_ prefix Spring Security implicitly applies for hasRole('X') (but not hasAuthority('X'))
|
|
205
|
+
// is already baked into requiredAuthority by plan.mjs's extractPreAuthorize() at handles-plan
|
|
206
|
+
// time, so this method never needs to know which source annotation shape produced the value.
|
|
207
|
+
private void requireAuthority(String requiredAuthority) {
|
|
204
208
|
boolean granted = SecurityContextHolder.getContext().getAuthentication().getAuthorities().stream()
|
|
205
209
|
.map(GrantedAuthority::getAuthority)
|
|
206
|
-
.anyMatch(authority -> authority.equals(
|
|
210
|
+
.anyMatch(authority -> authority.equals(requiredAuthority));
|
|
207
211
|
if (!granted) {
|
|
208
|
-
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "requires
|
|
212
|
+
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "requires authority " + requiredAuthority);
|
|
209
213
|
}
|
|
210
214
|
}
|
|
211
215
|
}
|
|
@@ -33,12 +33,18 @@ public interface ResourceResolver {
|
|
|
33
33
|
void patchField(UUID resourceUid, String pointer, Object value);
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* Spring Security
|
|
37
|
-
* this resource type via a handle.
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
36
|
+
* The literal Spring Security granted-authority string required to {@code fetch}/{@code
|
|
37
|
+
* recover} this resource type via a handle -- e.g. {@code "ROLE_ADMIN"} when the source
|
|
38
|
+
* {@code @PreAuthorize} was {@code hasRole('ADMIN')} (Spring implicitly applies the
|
|
39
|
+
* {@code ROLE_} prefix), or a bare value like {@code "ADMIN"} when the source was
|
|
40
|
+
* {@code hasAuthority('ADMIN')} (no prefix). This distinction is decided once, at
|
|
41
|
+
* {@code bskel handles plan} time, by {@code extractPreAuthorize()} -- callers of this method
|
|
42
|
+
* never need to know which source annotation shape produced the value; they compare it
|
|
43
|
+
* verbatim against a granted authority. O5 (D-resolver-authorization-action-aware): derived
|
|
44
|
+
* from the entity's FETCH (GET) endpoint's own {@code @PreAuthorize} -- deliberately NOT
|
|
45
|
+
* reused for {@link #patchField}, which has its own {@link #requiredAuthorityForPatch()}
|
|
46
|
+
* derived from the entity's real UPDATE endpoint instead, since a real app's GET and PATCH
|
|
47
|
+
* endpoints can (and often do) require genuinely different roles.
|
|
42
48
|
*/
|
|
43
49
|
String requiredAuthority();
|
|
44
50
|
|
|
@@ -23,6 +23,16 @@ Requires nothing extra installed (unlike Java's spring-boot-starter-aop) -- Pyth
|
|
|
23
23
|
no framework support -- but still requires a human to apply it to their own code; codegen never
|
|
24
24
|
touches an existing business logic file.
|
|
25
25
|
|
|
26
|
+
CRITICAL, python-specific correctness requirement (see DECISIONS.md D-runtime-conformance-receipts's
|
|
27
|
+
own async-wrapper note, and observe_contract.py's identical dual-wrapper shape in this same
|
|
28
|
+
directory): detects whether the wrapped function is a coroutine function
|
|
29
|
+
(`inspect.iscoroutinefunction`) at DECORATION time and dispatches to a genuinely separate async
|
|
30
|
+
wrapper (awaiting the wrapped call) vs. sync wrapper (calling it directly). A single wrapper naively
|
|
31
|
+
calling an async function without awaiting it would return an unawaited coroutine object as the
|
|
32
|
+
"result" -- the "response" snapshot would then record that coroutine object instead of the real
|
|
33
|
+
result, and the "error" snapshot path would never fire even when the real async call later raises
|
|
34
|
+
(calling an async function does not raise synchronously; only awaiting it does).
|
|
35
|
+
|
|
26
36
|
Example:
|
|
27
37
|
@record_snapshot(resource_type="Organization", operation_id="update_organization",
|
|
28
38
|
resource_uid_param="organization_id", session_param="session",
|
|
@@ -110,45 +120,97 @@ def record_snapshot(*, resource_type: str, operation_id: str, resource_uid_param
|
|
|
110
120
|
def decorator(fn):
|
|
111
121
|
signature = inspect.signature(fn)
|
|
112
122
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
_record(
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
123
|
+
# CRITICAL, python-specific correctness requirement (see DECISIONS.md
|
|
124
|
+
# D-runtime-conformance-receipts's own async-wrapper note, ported here for the same reason):
|
|
125
|
+
# detects whether the wrapped function is a coroutine function (inspect.iscoroutinefunction)
|
|
126
|
+
# at DECORATION time and dispatches to a genuinely separate async wrapper (awaiting the
|
|
127
|
+
# wrapped call) vs. sync wrapper (calling it directly). A single wrapper naively calling an
|
|
128
|
+
# async function without awaiting it returns an unawaited coroutine object as "result" --
|
|
129
|
+
# the "response" snapshot then records that coroutine object instead of the real result, and
|
|
130
|
+
# the "error" snapshot path never fires even when the real async call later raises (calling
|
|
131
|
+
# an async function does not raise synchronously; only awaiting it does). This mirrors
|
|
132
|
+
# observe_contract.py's own dual-wrapper shape exactly, deliberately, so both decorators in
|
|
133
|
+
# this file handle async the same way.
|
|
134
|
+
if inspect.iscoroutinefunction(fn):
|
|
135
|
+
@functools.wraps(fn)
|
|
136
|
+
async def wrapper(*args, **kwargs):
|
|
137
|
+
bound = signature.bind(*args, **kwargs)
|
|
138
|
+
bound.apply_defaults()
|
|
139
|
+
|
|
140
|
+
resolver = resolver_for(resource_type)
|
|
141
|
+
if resolver is None:
|
|
142
|
+
logger.warning('record_snapshot: no resolver registered for resource_type "%s" on %s -- skipping snapshot recording, the wrapped call proceeds unaffected', resource_type, fn.__qualname__)
|
|
143
|
+
return await fn(*args, **kwargs)
|
|
144
|
+
|
|
145
|
+
resource_uid = bound.arguments.get(resource_uid_param)
|
|
146
|
+
if not isinstance(resource_uid, uuid.UUID):
|
|
147
|
+
logger.warning('record_snapshot: resource_uid_param "%s" on %s does not resolve to a UUID argument -- skipping snapshot recording, the wrapped call proceeds unaffected', resource_uid_param, fn.__qualname__)
|
|
148
|
+
return await fn(*args, **kwargs)
|
|
149
|
+
|
|
150
|
+
session = bound.arguments.get(session_param)
|
|
151
|
+
handle_uid = uuid.UUID(derive_handle_uid("r", resource_type, str(resource_uid), None))
|
|
152
|
+
contract_ref = resolver.contract_ref
|
|
153
|
+
|
|
154
|
+
def _register_and_record(envelope_dir, payload):
|
|
155
|
+
handle_service.register(session, "r", resource_type, resource_uid, None, resolver.feature_uid, operation_id, contract_ref)
|
|
156
|
+
handle_service.record_snapshot(session, handle_uid, envelope_dir, operation_id, contract_ref, payload)
|
|
157
|
+
|
|
158
|
+
def _record(envelope_dir, payload):
|
|
159
|
+
if isinstance(payload, (dict, list)):
|
|
160
|
+
for pointer in redact:
|
|
161
|
+
_redact(payload, pointer)
|
|
162
|
+
_safely(lambda: _register_and_record(envelope_dir, payload), handle_uid, envelope_dir)
|
|
163
|
+
|
|
164
|
+
request_payload = _request_payload(bound, resource_uid_param, session_param)
|
|
165
|
+
_record("request", request_payload)
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
result = await fn(*args, **kwargs)
|
|
169
|
+
except Exception as exc:
|
|
170
|
+
_record("error", {"message": str(exc)})
|
|
171
|
+
raise
|
|
172
|
+
_record("response", _to_jsonable(result))
|
|
173
|
+
return result
|
|
174
|
+
else:
|
|
175
|
+
@functools.wraps(fn)
|
|
176
|
+
def wrapper(*args, **kwargs):
|
|
177
|
+
bound = signature.bind(*args, **kwargs)
|
|
178
|
+
bound.apply_defaults()
|
|
179
|
+
|
|
180
|
+
resolver = resolver_for(resource_type)
|
|
181
|
+
if resolver is None:
|
|
182
|
+
logger.warning('record_snapshot: no resolver registered for resource_type "%s" on %s -- skipping snapshot recording, the wrapped call proceeds unaffected', resource_type, fn.__qualname__)
|
|
183
|
+
return fn(*args, **kwargs)
|
|
184
|
+
|
|
185
|
+
resource_uid = bound.arguments.get(resource_uid_param)
|
|
186
|
+
if not isinstance(resource_uid, uuid.UUID):
|
|
187
|
+
logger.warning('record_snapshot: resource_uid_param "%s" on %s does not resolve to a UUID argument -- skipping snapshot recording, the wrapped call proceeds unaffected', resource_uid_param, fn.__qualname__)
|
|
188
|
+
return fn(*args, **kwargs)
|
|
189
|
+
|
|
190
|
+
session = bound.arguments.get(session_param)
|
|
191
|
+
handle_uid = uuid.UUID(derive_handle_uid("r", resource_type, str(resource_uid), None))
|
|
192
|
+
contract_ref = resolver.contract_ref
|
|
193
|
+
|
|
194
|
+
def _register_and_record(envelope_dir, payload):
|
|
195
|
+
handle_service.register(session, "r", resource_type, resource_uid, None, resolver.feature_uid, operation_id, contract_ref)
|
|
196
|
+
handle_service.record_snapshot(session, handle_uid, envelope_dir, operation_id, contract_ref, payload)
|
|
197
|
+
|
|
198
|
+
def _record(envelope_dir, payload):
|
|
199
|
+
if isinstance(payload, (dict, list)):
|
|
200
|
+
for pointer in redact:
|
|
201
|
+
_redact(payload, pointer)
|
|
202
|
+
_safely(lambda: _register_and_record(envelope_dir, payload), handle_uid, envelope_dir)
|
|
203
|
+
|
|
204
|
+
request_payload = _request_payload(bound, resource_uid_param, session_param)
|
|
205
|
+
_record("request", request_payload)
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
result = fn(*args, **kwargs)
|
|
209
|
+
except Exception as exc:
|
|
210
|
+
_record("error", {"message": str(exc)})
|
|
211
|
+
raise
|
|
212
|
+
_record("response", _to_jsonable(result))
|
|
213
|
+
return result
|
|
152
214
|
|
|
153
215
|
return wrapper
|
|
154
216
|
|
|
@@ -95,29 +95,29 @@ export function emitTypeScriptExpress({ repoRoot, featureId, plan, resourceFilte
|
|
|
95
95
|
},
|
|
96
96
|
} : null;
|
|
97
97
|
|
|
98
|
-
const result = emitUnits({
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
98
|
+
const result = emitUnits({
|
|
99
|
+
repoRoot, featureId, provider: 'typescript-express', force, reason, infraUnits, resolverUnits, orphanScan, dryRun, computeDiff,
|
|
100
|
+
// D-patch-transactions (Continued): the resolvers barrel's own import list is regenerated
|
|
101
|
+
// from the resolvers directory's REAL current contents (not just this run's own
|
|
102
|
+
// resolverUnits) -- an orphaned resolver from a different feature/module (O2's "never
|
|
103
|
+
// delete, only report" policy leaves it on disk) still needs its own `register(...)` call
|
|
104
|
+
// imported, or that resource type silently stops being servable. `render()` is called by
|
|
105
|
+
// emitUnits() itself AFTER its resolver loop writes this run's own files, so this always
|
|
106
|
+
// sees the final on-disk listing -- now conflict-safe/manifest-tracked like every other
|
|
107
|
+
// generated file, no longer unconditional (unlike migration.sql, which stays that way).
|
|
108
|
+
postResolverUnit: {
|
|
109
|
+
id: 'resolvers_index.ts.tmpl',
|
|
110
|
+
templatePath: RESOLVERS_INDEX_TEMPLATE,
|
|
111
|
+
targetAbs: resolversIndexPath,
|
|
112
|
+
render: () => {
|
|
113
|
+
const currentResolverFiles = fs.existsSync(resolversDir)
|
|
114
|
+
? fs.readdirSync(resolversDir).filter((f) => f.endsWith('.ts') && f !== 'resolvers_index.ts').sort()
|
|
115
|
+
: [];
|
|
116
|
+
const imports = currentResolverFiles.map((f) => `import './${f.replace(/\.ts$/, '')}';`).join('\n');
|
|
117
|
+
return render(RESOLVERS_INDEX_TEMPLATE, { IMPORTS: imports });
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
121
|
|
|
122
122
|
return {
|
|
123
123
|
...result,
|