backend-skeleton 1.0.0-beta.9 → 1.1.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 +185 -13
- package/bin/bskel.mjs +689 -33
- package/contracts/emit.mjs +5 -1
- package/contracts/export.mjs +65 -7
- package/contracts/openapi.mjs +321 -30
- package/contracts/validate.mjs +23 -4
- package/handles/_engine.mjs +123 -30
- 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 +73 -16
- package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +19 -1
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +19 -9
- 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 +36 -9
- 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 +129 -39
- package/handles/providers/python-fastapi.mjs +3 -3
- package/handles/providers/typescript-express/emit.mjs +144 -55
- package/handles/providers/typescript-express/observe.mjs +102 -0
- package/handles/providers/typescript-express/templates/codec.ts.tmpl +13 -3
- package/handles/providers/typescript-express/templates/contractCheck.ts.tmpl +136 -0
- 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/observeContract.ts.tmpl +146 -0
- package/handles/providers/typescript-express/templates/observedSchema.ts.tmpl +116 -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/attest.mjs +40 -0
- package/lib/cli.mjs +136 -5
- package/lib/cross-feature-collisions.mjs +286 -0
- package/lib/diff.mjs +35 -0
- package/lib/exit-codes.mjs +21 -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/verify.mjs +23 -6
- package/lib/workflow.mjs +31 -3
- package/package.json +8 -2
- package/scanners/adapters/_java-spring-analyzer.mjs +9 -1
- package/scanners/adapters/java-spring.mjs +114 -10
- package/scanners/adapters/javascript-express.mjs +46 -13
- package/scanners/adapters/python-fastapi.mjs +9 -1
- package/scanners/adapters/typescript-express.mjs +19 -2
- 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/feature-contract.schema.json +3 -3
- package/schemas/gate-attestation.schema.json +22 -0
- package/schemas/gate-export.schema.json +58 -0
- package/schemas/handles-plan.schema.json +2 -0
- package/schemas/oracle-manifest.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/schemas/stack-record.schema.json +6 -1
- package/stack/apply.mjs +51 -7
- package/stack/catalog/ngrok.yml +8 -2
- package/stack/config-apply.mjs +168 -0
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,40 +25,15 @@ 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
|
|
53
31
|
// overwrite is only ever reversible if the content it destroys is already committed. Fails
|
|
54
32
|
// closed (treats git errors, or a repo where the path can't be resolved, as "dirty") since the
|
|
55
33
|
// whole point is to never make an irreversible action look safe by default.
|
|
56
|
-
|
|
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) {
|
|
57
37
|
try {
|
|
58
38
|
const out = execFileSync('git', ['status', '--porcelain', '--', absPath], { cwd: repoRoot, encoding: 'utf8' });
|
|
59
39
|
return out.trim().length > 0;
|
|
@@ -79,7 +59,23 @@ function isDirtyOrUntracked(repoRoot, absPath) {
|
|
|
79
59
|
// computeDiff: D4 -- when true, attaches a real unified diff (git diff --no-index) to every
|
|
80
60
|
// 'update'/'conflict'/'adopt-update' action -- the only 3 where content actually
|
|
81
61
|
// differs. Off by default since it shells out to git per diffable file.
|
|
82
|
-
|
|
62
|
+
// postResolverUnit: D-patch-transactions (Continued) -- an OPTIONAL single unit, { id,
|
|
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 = [] }) {
|
|
83
79
|
const manifest = loadManifest(repoRoot);
|
|
84
80
|
const nowIso = new Date().toISOString();
|
|
85
81
|
|
|
@@ -137,6 +133,10 @@ export function emitUnits({ repoRoot, featureId, provider, force = false, reason
|
|
|
137
133
|
};
|
|
138
134
|
manifestChanged = true;
|
|
139
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);
|
|
140
140
|
}
|
|
141
141
|
written.push(u.relPath);
|
|
142
142
|
forced.push(u.relPath);
|
|
@@ -162,6 +162,8 @@ export function emitUnits({ repoRoot, featureId, provider, force = false, reason
|
|
|
162
162
|
updated_at: nowIso,
|
|
163
163
|
};
|
|
164
164
|
manifestChanged = true;
|
|
165
|
+
// D-write-safety-phase0 (item 3): see the comment at this loop's first saveManifest() call.
|
|
166
|
+
saveManifest(repoRoot, manifest);
|
|
165
167
|
}
|
|
166
168
|
recordAction({ relPath: u.relPath, kind: 'infra', action: u.action, diskContent: u.diskContent, rendered: u.rendered });
|
|
167
169
|
}
|
|
@@ -209,6 +211,11 @@ export function emitUnits({ repoRoot, featureId, provider, force = false, reason
|
|
|
209
211
|
updated_at: nowIso, last_force: { reason, at: nowIso, overwritten_hash: diskHash },
|
|
210
212
|
};
|
|
211
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);
|
|
212
219
|
}
|
|
213
220
|
written.push(relPath);
|
|
214
221
|
forced.push(relPath);
|
|
@@ -240,11 +247,91 @@ export function emitUnits({ repoRoot, featureId, provider, force = false, reason
|
|
|
240
247
|
updated_at: nowIso,
|
|
241
248
|
};
|
|
242
249
|
manifestChanged = true;
|
|
250
|
+
// D-write-safety-phase0 (item 3): see the comment at this loop's first saveManifest() call.
|
|
251
|
+
saveManifest(repoRoot, manifest);
|
|
243
252
|
}
|
|
244
253
|
}
|
|
245
254
|
recordAction({ relPath, kind: 'resolver', action, resourceType: u.resourceType, diskContent, rendered: u.rendered });
|
|
246
255
|
}
|
|
247
256
|
|
|
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';
|
|
281
|
+
const rendered = u.render();
|
|
282
|
+
const relPath = path.relative(repoRoot, u.targetAbs);
|
|
283
|
+
const diskContent = readIfExists(u.targetAbs);
|
|
284
|
+
const exists = diskContent !== null;
|
|
285
|
+
const diskHash = exists ? sha256String(diskContent) : null;
|
|
286
|
+
const freshRenderHash = sha256String(rendered);
|
|
287
|
+
const entry = manifest.files[relPath];
|
|
288
|
+
const matchesPristineRender = exists && diskContent === rendered;
|
|
289
|
+
const action = classifyFile({ exists, diskHash, manifestEntryHash: entry?.generated_hash ?? null, freshRenderHash, matchesPristineRender });
|
|
290
|
+
|
|
291
|
+
if (action === 'conflict' && !force) {
|
|
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 });
|
|
294
|
+
} else if (action === 'conflict') {
|
|
295
|
+
if (isDirtyOrUntracked(repoRoot, u.targetAbs)) {
|
|
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 });
|
|
298
|
+
} else {
|
|
299
|
+
if (!dryRun) {
|
|
300
|
+
manifest.files[relPath] = {
|
|
301
|
+
kind: unitKind, ownership: unitOwnership, owner: unitOwner, provider, template: u.id,
|
|
302
|
+
template_hash: sha256File(u.templatePath), generated_hash: freshRenderHash,
|
|
303
|
+
updated_at: nowIso, last_force: { reason, at: nowIso },
|
|
304
|
+
};
|
|
305
|
+
manifestChanged = true;
|
|
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);
|
|
309
|
+
}
|
|
310
|
+
written.push(relPath);
|
|
311
|
+
forced.push(relPath);
|
|
312
|
+
recordAction({ relPath, kind: unitKind, action, diskContent, rendered });
|
|
313
|
+
}
|
|
314
|
+
} else if (action === 'unchanged') {
|
|
315
|
+
recordAction({ relPath, kind: unitKind, action });
|
|
316
|
+
} else {
|
|
317
|
+
if (action !== 'adopt-unchanged') {
|
|
318
|
+
if (!dryRun) writeUnit(u.targetAbs, rendered);
|
|
319
|
+
written.push(relPath);
|
|
320
|
+
}
|
|
321
|
+
if (!dryRun) {
|
|
322
|
+
manifest.files[relPath] = {
|
|
323
|
+
kind: unitKind, ownership: unitOwnership, owner: unitOwner, provider, template: u.id,
|
|
324
|
+
template_hash: sha256File(u.templatePath), generated_hash: freshRenderHash,
|
|
325
|
+
updated_at: nowIso,
|
|
326
|
+
};
|
|
327
|
+
manifestChanged = true;
|
|
328
|
+
// D-write-safety-phase0 (item 3): see the infra loop's first saveManifest() call above.
|
|
329
|
+
saveManifest(repoRoot, manifest);
|
|
330
|
+
}
|
|
331
|
+
recordAction({ relPath, kind: unitKind, action, diskContent, rendered });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
248
335
|
// ---- orphan detection: a resolver this feature's CURRENT plan no longer generates, left
|
|
249
336
|
// untouched and never deleted -- same conservative bias as D-migration-scope/D-config-patch.
|
|
250
337
|
// Suppressed entirely under --resource (orphanScan === null), since every resource outside the
|
|
@@ -275,6 +362,12 @@ export function emitUnits({ repoRoot, featureId, provider, force = false, reason
|
|
|
275
362
|
}
|
|
276
363
|
}
|
|
277
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.
|
|
278
371
|
if (!dryRun && manifestChanged) saveManifest(repoRoot, manifest);
|
|
279
372
|
|
|
280
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);
|