backend-skeleton 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +284 -0
  3. package/bin/bskel.mjs +2384 -0
  4. package/contracts/completeness.mjs +176 -0
  5. package/contracts/emit.mjs +287 -0
  6. package/contracts/export.mjs +325 -0
  7. package/contracts/openapi.mjs +869 -0
  8. package/contracts/validate.mjs +147 -0
  9. package/handles/_engine.mjs +281 -0
  10. package/handles/codec.mjs +119 -0
  11. package/handles/conformance.mjs +74 -0
  12. package/handles/providers/java-spring/ast-bridge.mjs +59 -0
  13. package/handles/providers/java-spring/ast-helper/build.gradle +34 -0
  14. package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.jar +0 -0
  15. package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.properties +9 -0
  16. package/handles/providers/java-spring/ast-helper/gradlew +248 -0
  17. package/handles/providers/java-spring/ast-helper/gradlew.bat +82 -0
  18. package/handles/providers/java-spring/ast-helper/settings.gradle +1 -0
  19. package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +178 -0
  20. package/handles/providers/java-spring/emit.mjs +232 -0
  21. package/handles/providers/java-spring/patch-strategy.mjs +229 -0
  22. package/handles/providers/java-spring/plan.mjs +377 -0
  23. package/handles/providers/java-spring/templates/HandleAspect.java.tmpl +125 -0
  24. package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +150 -0
  25. package/handles/providers/java-spring/templates/HandleController.java.tmpl +177 -0
  26. package/handles/providers/java-spring/templates/HandleRegistry.java.tmpl +107 -0
  27. package/handles/providers/java-spring/templates/HandleRegistryRepository.java.tmpl +8 -0
  28. package/handles/providers/java-spring/templates/HandleService.java.tmpl +95 -0
  29. package/handles/providers/java-spring/templates/HandleSnapshot.java.tmpl +75 -0
  30. package/handles/providers/java-spring/templates/HandleSnapshotRepository.java.tmpl +20 -0
  31. package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +50 -0
  32. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +50 -0
  33. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +77 -0
  34. package/handles/providers/java-spring/templates/migration.sql.tmpl +34 -0
  35. package/handles/providers/java-spring.mjs +21 -0
  36. package/handles/providers/python-fastapi/emit.mjs +171 -0
  37. package/handles/providers/python-fastapi/plan.mjs +186 -0
  38. package/handles/providers/python-fastapi/templates/__init__.py.tmpl +1 -0
  39. package/handles/providers/python-fastapi/templates/codec.py.tmpl +122 -0
  40. package/handles/providers/python-fastapi/templates/handle_service.py.tmpl +96 -0
  41. package/handles/providers/python-fastapi/templates/migration.sql.tmpl +35 -0
  42. package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +155 -0
  43. package/handles/providers/python-fastapi/templates/registry.py.tmpl +37 -0
  44. package/handles/providers/python-fastapi/templates/resolver.py.tmpl +59 -0
  45. package/handles/providers/python-fastapi/templates/resolvers_init.py.tmpl +13 -0
  46. package/handles/providers/python-fastapi/templates/router.py.tmpl +140 -0
  47. package/handles/providers/python-fastapi/templates/tables.py.tmpl +66 -0
  48. package/handles/providers/python-fastapi.mjs +22 -0
  49. package/handles/providers/typescript-express/emit.mjs +128 -0
  50. package/handles/providers/typescript-express/plan.mjs +234 -0
  51. package/handles/providers/typescript-express/templates/codec.ts.tmpl +116 -0
  52. package/handles/providers/typescript-express/templates/registry.ts.tmpl +39 -0
  53. package/handles/providers/typescript-express/templates/resolver.ts.tmpl +55 -0
  54. package/handles/providers/typescript-express/templates/resolvers_index.ts.tmpl +11 -0
  55. package/handles/providers/typescript-express/templates/router.ts.tmpl +122 -0
  56. package/handles/providers/typescript-express.mjs +20 -0
  57. package/handles/registry.mjs +90 -0
  58. package/lib/cli.mjs +430 -0
  59. package/lib/doctor.mjs +200 -0
  60. package/lib/exit-codes.mjs +67 -0
  61. package/lib/featureid.mjs +55 -0
  62. package/lib/featurelifecycle.mjs +205 -0
  63. package/lib/fsutil.mjs +50 -0
  64. package/lib/gate-definitions.mjs +293 -0
  65. package/lib/gates.mjs +263 -0
  66. package/lib/handles-manifest.mjs +92 -0
  67. package/lib/lock.mjs +68 -0
  68. package/lib/patch-approvals.mjs +56 -0
  69. package/lib/paths.mjs +21 -0
  70. package/lib/repo.mjs +44 -0
  71. package/lib/schema-validate.mjs +56 -0
  72. package/lib/state.mjs +124 -0
  73. package/lib/template.mjs +35 -0
  74. package/lib/verify.mjs +206 -0
  75. package/lib/workflow.mjs +142 -0
  76. package/new/fastapi.mjs +165 -0
  77. package/new/index.mjs +62 -0
  78. package/new/params.mjs +233 -0
  79. package/new/spring.mjs +198 -0
  80. package/new/templates/fastapi/README.md +26 -0
  81. package/new/templates/fastapi/app/__init__.py +0 -0
  82. package/new/templates/fastapi/app/main.py +8 -0
  83. package/new/templates/fastapi/gitignore +6 -0
  84. package/new/templates/fastapi/pyproject.toml +14 -0
  85. package/package.json +50 -0
  86. package/scanners/adapters/_express-shared.mjs +238 -0
  87. package/scanners/adapters/_java-spring-analyzer.mjs +273 -0
  88. package/scanners/adapters/generic-grep.mjs +128 -0
  89. package/scanners/adapters/java-spring.mjs +301 -0
  90. package/scanners/adapters/javascript-express.mjs +422 -0
  91. package/scanners/adapters/python-fastapi.mjs +348 -0
  92. package/scanners/adapters/typescript-express.mjs +299 -0
  93. package/scanners/capabilities.mjs +90 -0
  94. package/scanners/conformance.mjs +59 -0
  95. package/scanners/db/introspect.mjs +109 -0
  96. package/scanners/db/migrations.mjs +126 -0
  97. package/scanners/index.mjs +281 -0
  98. package/scanners/registry.mjs +130 -0
  99. package/scanners/render.mjs +136 -0
  100. package/scanners/text-util.mjs +8 -0
  101. package/schemas/adapter.schema.json +23 -0
  102. package/schemas/agent-envelope.schema.json +21 -0
  103. package/schemas/contract-resolution.schema.json +28 -0
  104. package/schemas/feature-contract.schema.json +78 -0
  105. package/schemas/feature-index.schema.json +25 -0
  106. package/schemas/feature.schema.json +17 -0
  107. package/schemas/gate-event.schema.json +19 -0
  108. package/schemas/handles-plan.schema.json +31 -0
  109. package/schemas/handles-provider.schema.json +26 -0
  110. package/schemas/patch-approvals.schema.json +28 -0
  111. package/schemas/scan-report.schema.json +102 -0
  112. package/schemas/stack-choice.schema.json +89 -0
  113. package/schemas/stack-record.schema.json +20 -0
  114. package/schemas/state.schema.json +43 -0
  115. package/scripts/preflight-base-ref.sh +226 -0
  116. package/stack/apply.mjs +159 -0
  117. package/stack/bootstrap/_lib.sh +73 -0
  118. package/stack/bootstrap/ngrok.sh +90 -0
  119. package/stack/catalog/ngrok.yml +63 -0
@@ -0,0 +1,34 @@
1
+ -- Generated by backend-skeleton (bskel handles emit) for feature {{FEATURE_ID}}.
2
+ -- NOT applied automatically -- this repo has no Flyway/Liquibase (confirmed by `bskel scan`'s
3
+ -- Plane A/B during Phase 2), so apply this yourself against Supabase (e.g. via the SQL editor
4
+ -- or `psql`) once you've reviewed it. See D-config-patch / D-migration-scope in DECISIONS.md
5
+ -- for why backend-skeleton never applies schema changes on its own.
6
+
7
+ create table if not exists sbf_handle (
8
+ handle_uid uuid primary key,
9
+ kind text not null check (kind in ('r', 'f', 'o')),
10
+ resource_type text not null,
11
+ resource_uid uuid not null,
12
+ pointer text,
13
+ feature_uid uuid not null,
14
+ operation_id text,
15
+ contract_ref text not null,
16
+ created_at timestamptz not null default now(),
17
+ revoked_at timestamptz,
18
+ revoked_reason text,
19
+ unique (resource_type, resource_uid, pointer)
20
+ );
21
+
22
+ create index if not exists ix_sbf_handle_resource on sbf_handle (resource_type, resource_uid);
23
+
24
+ create table if not exists sbf_handle_snapshot (
25
+ snapshot_id bigserial primary key,
26
+ handle_uid uuid not null references sbf_handle (handle_uid),
27
+ envelope_dir text not null check (envelope_dir in ('request', 'response', 'error')),
28
+ operation_id text not null,
29
+ contract_hash text not null,
30
+ payload jsonb not null,
31
+ recorded_at timestamptz not null default now()
32
+ );
33
+
34
+ create index if not exists ix_sbf_handle_snapshot_handle_uid on sbf_handle_snapshot (handle_uid, recorded_at desc);
@@ -0,0 +1,21 @@
1
+ import { plan } from './java-spring/plan.mjs';
2
+ import { emitJavaSpring } from './java-spring/emit.mjs';
3
+
4
+ // D-handles-providers (G4). Zero-registration descriptor loaded by handles/registry.mjs -- see
5
+ // schemas/handles-provider.schema.json for the contract this object's JSON-shaped fields must
6
+ // match (plan/emit are functions, checked separately). This is the ORIGINAL handles codegen
7
+ // (pre-G4: handles/plan.mjs + handles/emit.mjs, un-abstracted) extracted behind the same
8
+ // interface handles/providers/python-fastapi.mjs implements -- see D-handles-providers for why
9
+ // this extraction only happened once a real second provider existed to factor a boundary
10
+ // against.
11
+ export const provider = {
12
+ contract: 'sbf.handles-provider/1',
13
+ id: 'java-spring',
14
+ title: 'Java / Spring Boot',
15
+ requiresCapabilities: ['resource.fetch'],
16
+ outputs: { spec: ['handles/migration.sql'] },
17
+ plan,
18
+ emit({ repoRoot, featureId, plan: handlesPlan, resourceFilter = null, force = false, reason = '', dryRun = false, computeDiff = false }) {
19
+ return emitJavaSpring({ repoRoot, featureId, plan: handlesPlan, basePackage: handlesPlan.basePackage, resourceFilter, force, reason, dryRun, computeDiff });
20
+ },
21
+ };
@@ -0,0 +1,171 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { emitUnits, unifiedDiff } from '../../_engine.mjs';
5
+ import { sha256File } from '../../../lib/fsutil.mjs';
6
+ import { specPath } from '../../../lib/paths.mjs';
7
+ import { loadFeatureFile } from '../../../lib/featurelifecycle.mjs';
8
+
9
+ const PROVIDER_ROOT = path.dirname(fileURLToPath(import.meta.url));
10
+ const TEMPLATES_DIR = path.join(PROVIDER_ROOT, 'templates');
11
+ const RESOLVER_TEMPLATE = path.join(TEMPLATES_DIR, 'resolver.py.tmpl');
12
+ const MIGRATION_TEMPLATE = path.join(TEMPLATES_DIR, 'migration.sql.tmpl');
13
+
14
+ function writeUnit(target, content) {
15
+ fs.mkdirSync(path.dirname(target), { recursive: true });
16
+ fs.writeFileSync(target, content);
17
+ }
18
+
19
+ function render(templatePath, vars) {
20
+ let content = fs.readFileSync(templatePath, 'utf8');
21
+ for (const [key, value] of Object.entries(vars)) {
22
+ content = content.replaceAll(`{{${key}}}`, String(value));
23
+ }
24
+ return content;
25
+ }
26
+
27
+ // PascalCase -> snake_case, good enough for the class names this scanner actually extracts
28
+ // (ASCII identifiers only, same assumption java-spring's own naming makes).
29
+ function snakeCase(s) {
30
+ return s.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
31
+ }
32
+
33
+ function dottedModulePath(file, importRoot) {
34
+ const rel = path.relative(importRoot, file).replace(/\.py$/, '');
35
+ return rel.split(path.sep).join('.');
36
+ }
37
+
38
+ // See DECISIONS.md D-handles-providers. G4 follow-up: migration.sql + a real recover() lifecycle
39
+ // (tables.py/handle_service.py/record_snapshot.py) are now generated, mirroring java-spring's own
40
+ // O4 work -- the EXCLUDED section's original "even Java hasn't got O4" reasoning is stale, see
41
+ // that entry's own follow-up paragraph. `router.py` is only emitted when a SessionDep-shaped alias
42
+ // was actually found (plan()'s willGenerateResolver gate already implies this for every resolver,
43
+ // but the router itself is infra -- generated once, independent of which resolvers exist -- so it
44
+ // needs its own guard for the "found zero resolvers, and specifically because no SessionDep
45
+ // exists" case).
46
+ export function emitPythonFastApi({ repoRoot, featureId, plan, resourceFilter = null, force = false, reason = '', dryRun = false, computeDiff = false }) {
47
+ const handlesDir = path.join(plan.importRoot, plan.topPackage, 'handles');
48
+ const resolversDir = path.join(handlesDir, 'resolvers');
49
+
50
+ const infraUnits = [
51
+ { id: '__init__.py.tmpl', templatePath: path.join(TEMPLATES_DIR, '__init__.py.tmpl'), targetAbs: path.join(handlesDir, '__init__.py'), rendered: render(path.join(TEMPLATES_DIR, '__init__.py.tmpl'), {}) },
52
+ { id: 'codec.py.tmpl', templatePath: path.join(TEMPLATES_DIR, 'codec.py.tmpl'), targetAbs: path.join(handlesDir, 'codec.py'), rendered: render(path.join(TEMPLATES_DIR, 'codec.py.tmpl'), {}) },
53
+ { id: 'registry.py.tmpl', templatePath: path.join(TEMPLATES_DIR, 'registry.py.tmpl'), targetAbs: path.join(handlesDir, 'registry.py'), rendered: render(path.join(TEMPLATES_DIR, 'registry.py.tmpl'), {}) },
54
+ { id: 'resolvers_init.py.tmpl', templatePath: path.join(TEMPLATES_DIR, 'resolvers_init.py.tmpl'), targetAbs: path.join(resolversDir, '__init__.py'), rendered: render(path.join(TEMPLATES_DIR, 'resolvers_init.py.tmpl'), {}) },
55
+ // G4 follow-up (D-handles-providers): tables.py has zero {{VAR}} substitutions (same class
56
+ // as codec.py.tmpl above -- fixed schema, not per-feature), handle_service.py/
57
+ // record_snapshot.py each need only {{PKG}} to resolve their own sibling-module imports.
58
+ { id: 'tables.py.tmpl', templatePath: path.join(TEMPLATES_DIR, 'tables.py.tmpl'), targetAbs: path.join(handlesDir, 'tables.py'), rendered: render(path.join(TEMPLATES_DIR, 'tables.py.tmpl'), {}) },
59
+ { id: 'handle_service.py.tmpl', templatePath: path.join(TEMPLATES_DIR, 'handle_service.py.tmpl'), targetAbs: path.join(handlesDir, 'handle_service.py'), rendered: render(path.join(TEMPLATES_DIR, 'handle_service.py.tmpl'), { PKG: plan.topPackage }) },
60
+ { id: 'record_snapshot.py.tmpl', templatePath: path.join(TEMPLATES_DIR, 'record_snapshot.py.tmpl'), targetAbs: path.join(handlesDir, 'record_snapshot.py'), rendered: render(path.join(TEMPLATES_DIR, 'record_snapshot.py.tmpl'), { PKG: plan.topPackage }) },
61
+ ];
62
+
63
+ const sessionDep = plan.resources.find((r) => r.sessionDep)?.sessionDep ?? null;
64
+ if (sessionDep) {
65
+ infraUnits.push({
66
+ id: 'router.py.tmpl',
67
+ templatePath: path.join(TEMPLATES_DIR, 'router.py.tmpl'),
68
+ targetAbs: path.join(handlesDir, 'router.py'),
69
+ rendered: render(path.join(TEMPLATES_DIR, 'router.py.tmpl'), {
70
+ PKG: plan.topPackage,
71
+ SESSION_DEP_MODULE: dottedModulePath(sessionDep.file, plan.importRoot),
72
+ SESSION_DEP_NAME: sessionDep.name,
73
+ }),
74
+ });
75
+ }
76
+
77
+ // G4 follow-up (D-handles-providers): mirrors java-spring/emit.mjs's own contractRefFor/
78
+ // featureUidFor exactly, including the cross-feature adoption-safety fix that item's own Java
79
+ // work found the hard way -- proactively included here rather than rediscovered in Python
80
+ // later. requireNamedGate(root, 'contract', ...) already ran before emitPythonFastApi() is
81
+ // ever reached (cmdHandlesEmit's own precondition), so this feature's own contract file is
82
+ // guaranteed to exist here.
83
+ const contractRefFor = (id) => sha256File(specPath(repoRoot, id, 'contracts', `${id}.schema.json`));
84
+ const featureUidFor = (id) => loadFeatureFile(repoRoot, id)?.feature_uid ?? '00000000-0000-0000-0000-000000000000';
85
+ const contractRef = contractRefFor(featureId);
86
+ const featureUid = featureUidFor(featureId);
87
+
88
+ const resolverUnits = plan.resources
89
+ .filter((r) => r.willGenerateResolver)
90
+ .map((resource) => {
91
+ const vars = {
92
+ FEATURE_ID: featureId,
93
+ RESOURCE_TYPE: resource.type,
94
+ MODEL: resource.type,
95
+ PUBLIC_MODEL: resource.publicModel,
96
+ MODEL_IMPORT: resource.modelImport,
97
+ PKG: plan.topPackage,
98
+ FETCH_ROUTE_FILE: resource.fetchRoute ? path.relative(repoRoot, resource.fetchRoute.file) : '(unknown)',
99
+ FETCH_ROUTE_LINE: resource.fetchRoute ? resource.fetchRoute.line : '',
100
+ CONTRACT_REF: contractRef,
101
+ FEATURE_UID: featureUid,
102
+ };
103
+ return {
104
+ id: 'resolver.py.tmpl',
105
+ resourceType: resource.type,
106
+ module: plan.module,
107
+ templatePath: RESOLVER_TEMPLATE,
108
+ targetAbs: path.join(resolversDir, `${snakeCase(resource.type)}.py`),
109
+ rendered: render(RESOLVER_TEMPLATE, vars),
110
+ // FEATURE_ID/CONTRACT_REF/FEATURE_UID all change between features -- deliberately NOT
111
+ // reused verbatim for a DIFFERENT owner (see java-spring/emit.mjs's own identical
112
+ // comment): O2's cross-feature adoption check re-renders using the ORIGINAL owner's
113
+ // feature_id specifically, so baking in the CURRENT run's own contract_ref/feature_uid
114
+ // there would compare disk content against the wrong feature's values and manufacture a
115
+ // false conflict for an untouched file.
116
+ pristineRenderFor: (ownerId) => render(RESOLVER_TEMPLATE, {
117
+ ...vars,
118
+ FEATURE_ID: ownerId,
119
+ CONTRACT_REF: ownerId === featureId ? contractRef : contractRefFor(ownerId),
120
+ FEATURE_UID: ownerId === featureId ? featureUid : featureUidFor(ownerId),
121
+ }),
122
+ };
123
+ });
124
+
125
+ const orphanScan = (!resourceFilter && plan.module) ? {
126
+ dir: resolversDir,
127
+ module: plan.module,
128
+ matchesFile: (file) => file.endsWith('.py') && file !== '__init__.py',
129
+ // Filename can't reliably recover the type (organization_policy.py could be OrganizationPolicy
130
+ // or Organizationpolicy) -- read the `type = "X"` class attribute the resolver template
131
+ // itself carries instead of guessing from the filename, unlike java-spring's orphan scan.
132
+ resourceTypeOf: (_file, content) => {
133
+ const m = content.match(/^\s*type\s*=\s*"([^"]+)"/m);
134
+ return m ? m[1] : null;
135
+ },
136
+ } : null;
137
+
138
+ const result = emitUnits({ repoRoot, featureId, provider: 'python-fastapi', force, reason, infraUnits, resolverUnits, orphanScan, dryRun, computeDiff });
139
+
140
+ // G4 follow-up (D-handles-providers): mirrors java-spring/emit.mjs's own migration.sql
141
+ // handling exactly -- regenerated fresh every run, unconditionally, never manifest-tracked
142
+ // (no conflict detection for it at all). `kind: 'spec'` tags it distinctly from the
143
+ // manifest-tracked infra/resolver kinds, same D4/outputs.spec category P4's conformance
144
+ // harness already special-cases.
145
+ const migrationContent = render(MIGRATION_TEMPLATE, { FEATURE_ID: featureId });
146
+ const migrationPath = path.join(repoRoot, 'specs', featureId, 'handles', 'migration.sql');
147
+ const migrationRelPath = path.relative(repoRoot, migrationPath);
148
+ const migrationDiskContent = fs.existsSync(migrationPath) ? fs.readFileSync(migrationPath, 'utf8') : null;
149
+ const migrationAction = migrationDiskContent === null ? 'create' : (migrationDiskContent === migrationContent ? 'unchanged' : 'update');
150
+ if (!dryRun) writeUnit(migrationPath, migrationContent);
151
+ result.written.push(migrationRelPath);
152
+ const migrationActionEntry = { path: migrationRelPath, kind: 'spec', action: migrationAction };
153
+ if (computeDiff && migrationAction === 'update') migrationActionEntry.diff = unifiedDiff(migrationRelPath, migrationDiskContent, migrationContent);
154
+ result.actions.push(migrationActionEntry);
155
+
156
+ const postEmitNotes = [
157
+ 'NOT done automatically: applying specs/<id>/handles/migration.sql to any database. Review it and apply yourself.',
158
+ ];
159
+ if (!sessionDep) {
160
+ postEmitNotes.push('router.py was NOT generated -- no SessionDep-shaped dependency alias was found under the detected package, see plan notes.');
161
+ } else {
162
+ postEmitNotes.push(`NOT done automatically: wiring the generated router into your app -- add "from ${plan.topPackage}.handles.router import router as handles_router" and include it via your app's own router-composition file (e.g. api_router.include_router(handles_router)) by hand.`);
163
+ }
164
+ // G4 follow-up (D-handles-providers): record_snapshot.py's decorator needs nothing extra
165
+ // installed (unlike Java's spring-boot-starter-aop requirement) -- Python decorators need no
166
+ // framework support -- but still requires a human to apply it to their own code, same "review
167
+ // and apply yourself" boundary as the migration note above.
168
+ postEmitNotes.push('NOT done automatically: applying @record_snapshot (handles/record_snapshot.py) to any of your own service functions. Codegen never touches existing business logic files.');
169
+
170
+ return { ...result, postEmitNotes };
171
+ }
@@ -0,0 +1,186 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ // Walks up from a file's own directory while `__init__.py` exists, returning the topmost such
5
+ // directory (the "top package" dir), or null if the file's own directory has none at all. Not
6
+ // named-conditioned on any particular directory name -- a standard PyPA src-layout
7
+ // (`src/<package>/__init__.py`, a real `__init__.py`, just under a `src/` dir) walks up correctly
8
+ // like any other layout (see D-typescript-express-provider's slice-4 correction in DECISIONS.md,
9
+ // and the positive regression test in test/python-fastapi-handles.test.mjs). Only PEP 420
10
+ // *implicit namespace packages* (omitting `__init__.py` entirely) hit the null case below --
11
+ // unsupported, see COST in DECISIONS.md, exit 2.
12
+ function packageRootFor(file) {
13
+ let dir = path.dirname(file);
14
+ if (!fs.existsSync(path.join(dir, '__init__.py'))) return null;
15
+ let top = dir;
16
+ for (;;) {
17
+ const parent = path.dirname(top);
18
+ if (parent === top || !fs.existsSync(path.join(parent, '__init__.py'))) break;
19
+ top = parent;
20
+ }
21
+ return top;
22
+ }
23
+
24
+ // O6-style ambiguity rejection (see java-spring's detectBasePackage): more than one DIFFERENT
25
+ // package root among this module's own files is refused with named candidates rather than
26
+ // silently picking one.
27
+ function detectImportRoot(moduleFiles, repoRoot) {
28
+ const roots = new Set();
29
+ for (const file of moduleFiles) {
30
+ const r = packageRootFor(file);
31
+ if (r) roots.add(r);
32
+ }
33
+ if (roots.size === 0) {
34
+ throw new Error('could not detect a Python package root (no __init__.py found above any scanned file for this module) -- this provider does not support PEP 420 implicit namespace packages (omitting __init__.py entirely) yet. A standard src-layout WITH real __init__.py files works fine.');
35
+ }
36
+ if (roots.size > 1) {
37
+ throw new Error(`ambiguous Python package root -- found ${roots.size} different candidates among this module's own files: ${[...roots].map((r) => path.relative(repoRoot, r)).join(', ')}. This provider doesn't support multi-package-root repos yet.`);
38
+ }
39
+ const topPackageDir = [...roots][0];
40
+ return { importRoot: path.dirname(topPackageDir), topPackage: path.basename(topPackageDir) };
41
+ }
42
+
43
+ function listPythonFilesUnder(dir) {
44
+ const out = [];
45
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
46
+ if (entry.name.startsWith('.') || entry.name === '__pycache__') continue;
47
+ const full = path.join(dir, entry.name);
48
+ if (entry.isDirectory()) out.push(...listPythonFilesUnder(full));
49
+ else if (entry.name.endsWith('.py')) out.push(full);
50
+ }
51
+ return out.sort();
52
+ }
53
+
54
+ // Same "canonical fetch" concept as java-spring's findFetchOperation, keyed on `ep.method` (the
55
+ // Python function name) instead of `ep.operationId` -- python-fastapi's scan output always sets
56
+ // operationId: null (see D-fastapi-adapter), so that logic cannot be shared as-is. A GET endpoint
57
+ // whose path is exactly `${controller.basePath}/{param}` (one trailing path segment) on a
58
+ // controller whose class-name affinity-matches the entity, mirroring java-spring's own check.
59
+ function findFetchRoute(controllers, entityClassName) {
60
+ const needle = entityClassName.toLowerCase();
61
+ for (const controller of controllers) {
62
+ if (!controller.className.toLowerCase().includes(needle)) continue;
63
+ for (const ep of controller.endpoints) {
64
+ if (ep.verb !== 'GET') continue;
65
+ const suffix = ep.path.slice(controller.basePath.length);
66
+ if (/^\/\{[^/]+\}$/.test(suffix)) {
67
+ return { method: ep.method, path: ep.path, file: controller.file, line: ep.line, controllerClassName: controller.className };
68
+ }
69
+ }
70
+ }
71
+ return null;
72
+ }
73
+
74
+ // `class <Entity>Public(...)` -- scoped to the entity's own file. Required, not decorative: the
75
+ // real oracle's User table carries hashed_password with no protection besides each individual
76
+ // route's own `response_model=UserPublic` declaration -- a generic handle-fetch route serving
77
+ // multiple types can't rely on that, so a resolver is refused entirely when this class is absent
78
+ // rather than risk leaking a raw table row.
79
+ function findPublicModel(entityFile, entityClassName) {
80
+ if (!entityFile || !fs.existsSync(entityFile)) return null;
81
+ const text = fs.readFileSync(entityFile, 'utf8');
82
+ const re = new RegExp(`^class\\s+${entityClassName}Public\\s*\\(`, 'm');
83
+ return re.test(text) ? `${entityClassName}Public` : null;
84
+ }
85
+
86
+ // `SessionDep = Annotated[Session, Depends(get_db)]` -- or whatever this app names its own
87
+ // session-dependency alias. Searches the whole detected package (not just this module's files,
88
+ // since the alias is typically declared once in a shared deps module), deterministic
89
+ // shallowest-then-name tie-break if more than one file declares one -- same convention
90
+ // scanners/adapters/python-fastapi.mjs's own byShallowestThenName helper uses.
91
+ const SESSION_DEP_RE = /^(\w+)\s*=\s*Annotated\[\s*Session\s*,\s*Depends\(/m;
92
+
93
+ function findSessionDep(files) {
94
+ const candidates = [];
95
+ for (const file of files) {
96
+ const text = fs.readFileSync(file, 'utf8');
97
+ const m = text.match(SESSION_DEP_RE);
98
+ if (m) candidates.push({ file, name: m[1] });
99
+ }
100
+ if (candidates.length === 0) return null;
101
+ candidates.sort((a, b) => {
102
+ const depthA = a.file.split(path.sep).length;
103
+ const depthB = b.file.split(path.sep).length;
104
+ return depthA !== depthB ? depthA - depthB : a.file.localeCompare(b.file);
105
+ });
106
+ return candidates[0];
107
+ }
108
+
109
+ function dottedModulePath(file, importRoot) {
110
+ const rel = path.relative(importRoot, file).replace(/\.py$/, '');
111
+ return rel.split(path.sep).join('.');
112
+ }
113
+
114
+ // The descriptor-facing entry point (handles/providers/python-fastapi.mjs's provider.plan). See
115
+ // schemas/handles-plan.schema.json for the sbf.handles-plan/1 envelope this returns.
116
+ export function plan({ repoRoot, scanReport, module: moduleName, resourceFilter }) {
117
+ const targetModule = moduleName
118
+ ? scanReport.related_modules.find((m) => m.module === moduleName)
119
+ : scanReport.related_modules[0];
120
+
121
+ if (!targetModule) {
122
+ return {
123
+ schema: 'sbf.handles-plan/1', provider: 'python-fastapi', module: null, resources: [],
124
+ notes: ['no related module in the scan report -- run `bskel scan` first, or pass --module explicitly'],
125
+ };
126
+ }
127
+
128
+ const moduleFiles = [...targetModule.controllers.map((c) => c.file), ...targetModule.entities.map((e) => e.file)].filter(Boolean);
129
+ const { importRoot, topPackage } = detectImportRoot(moduleFiles, repoRoot);
130
+ const allProjectFiles = listPythonFilesUnder(path.join(importRoot, topPackage));
131
+ const sessionDep = findSessionDep(allProjectFiles);
132
+
133
+ const resources = [];
134
+ const notes = [];
135
+
136
+ for (const entity of targetModule.entities) {
137
+ if (resourceFilter && !resourceFilter.includes(entity.className)) continue;
138
+ const fetchRoute = findFetchRoute(targetModule.controllers, entity.className);
139
+ const publicModel = fetchRoute ? findPublicModel(entity.file, entity.className) : null;
140
+
141
+ if (!fetchRoute) {
142
+ notes.push(`${entity.className}: no single-resource GET route found on a router whose name contains "${entity.className}" -- fetch() will need to be hand-written`);
143
+ } else if (!publicModel) {
144
+ notes.push(`${entity.className}: no ${entity.className}Public class found in ${path.relative(repoRoot, entity.file)} -- resolver NOT generated (a generic handle-fetch route serializing the raw table model could leak a column the app never otherwise exposes, e.g. a password hash). Add a Public projection class and re-run.`);
145
+ }
146
+ if (fetchRoute && !entity.idField) {
147
+ notes.push(`${entity.className}: no primary-key field detected on the table model -- resolver NOT generated.`);
148
+ }
149
+ if (fetchRoute && publicModel && entity.idField && !sessionDep) {
150
+ notes.push(`${entity.className}: fetch route and Public model found, but no SessionDep-shaped dependency alias (Annotated[Session, Depends(...)]) was found anywhere under ${topPackage}/ -- resolver NOT generated.`);
151
+ }
152
+ if (fetchRoute) {
153
+ notes.push(`${entity.className}: static scanning cannot safely determine this route's real authorization logic (see ${path.relative(repoRoot, fetchRoute.file)}:${fetchRoute.line}) -- the generated resolver's check_access() always denies until hand-wired.`);
154
+ }
155
+
156
+ const willGenerateResolver = Boolean(fetchRoute && publicModel && entity.idField && sessionDep);
157
+
158
+ resources.push({
159
+ type: entity.className,
160
+ table: entity.table,
161
+ idField: entity.idField,
162
+ readPath: fetchRoute ? `session.get(${entity.className}, ${entity.idField})` : null,
163
+ requiredAuthority: 'TODO_ACCESS_CHECK',
164
+ willGenerateResolver,
165
+ // provider-specific extras (additionalProperties: true in schemas/handles-plan.schema.json)
166
+ fetchRoute,
167
+ publicModel,
168
+ modelImport: dottedModulePath(entity.file, importRoot),
169
+ sessionDep,
170
+ });
171
+ }
172
+
173
+ if (resources.length === 0) {
174
+ notes.push(`no entities found for module "${targetModule.module}" ${resourceFilter ? `matching --resource filter [${resourceFilter.join(', ')}]` : ''} -- nothing to plan.`);
175
+ }
176
+
177
+ return {
178
+ schema: 'sbf.handles-plan/1',
179
+ provider: 'python-fastapi',
180
+ importRoot,
181
+ topPackage,
182
+ module: targetModule.module,
183
+ resources,
184
+ notes,
185
+ };
186
+ }
@@ -0,0 +1 @@
1
+ # Generated by backend-skeleton. Do not hand-edit -- change the source template and regenerate.
@@ -0,0 +1,122 @@
1
+ """Generated by backend-skeleton. Do not hand-edit -- change the source template and regenerate,
2
+ or the JS/Java/Python implementations will silently diverge.
3
+
4
+ Encodes/decodes backend-skeleton "handles" (kind:type:uuid[:pointer], base64url with an "sbf1_"
5
+ prefix). Must stay behavior-identical to handles/codec.mjs (the JS reference implementation) --
6
+ verified by an executed round-trip test (test/handles-python-codec.test.mjs), not just by
7
+ inspection. See D-handles-providers in DECISIONS.md.
8
+ """
9
+ import base64
10
+ import re
11
+ import uuid
12
+
13
+ NS_SBF_FIELD = uuid.UUID("a3f1c2e0-8b4d-4f1a-9c3e-1d2b3a4c5d6e")
14
+
15
+ _UUID_PATTERN = r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
16
+ _HANDLE_RE = re.compile(rf"^([rfo]):([^:]+):({_UUID_PATTERN})(?::(.*))?$", re.IGNORECASE)
17
+ _BASE64URL_CHARSET_RE = re.compile(r"^[A-Za-z0-9_-]*$")
18
+
19
+ MAX_HANDLE_TOKEN_LENGTH = 2048
20
+
21
+
22
+ def _base64url_encode(raw: bytes) -> str:
23
+ return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
24
+
25
+
26
+ # D-security-10 parity, confirmed against this Python runtime directly: `base64.urlsafe_b64decode`
27
+ # silently discards characters outside the base64 alphabet instead of rejecting them -- the exact
28
+ # same defect handles/codec.mjs fixed for Node's `Buffer.from(str, 'base64')`. Without this
29
+ # explicit charset check first, a corrupted/tampered handle token could decode to a DIFFERENT
30
+ # valid-looking payload instead of raising.
31
+ def _base64url_decode(s: str) -> bytes:
32
+ if not _BASE64URL_CHARSET_RE.match(s):
33
+ raise ValueError("not valid base64url after the sbf1_ prefix")
34
+ # Padding restoration must match handles/codec.mjs's own formula exactly:
35
+ # `(4 - (str.length % 4)) % 4` -- arithmetically identical to `-len(s) % 4` in Python.
36
+ padded = s + "=" * (-len(s) % 4)
37
+ try:
38
+ return base64.urlsafe_b64decode(padded)
39
+ except Exception as exc: # binascii.Error (a ValueError subclass) on malformed input
40
+ raise ValueError("not valid base64url after the sbf1_ prefix") from exc
41
+
42
+
43
+ def encode_handle(kind: str, type_: str, resource_uuid: str, pointer: str | None = None) -> str:
44
+ if kind not in ("r", "f", "o"):
45
+ raise ValueError(f'invalid handle kind "{kind}" (expected r, f, or o)')
46
+ if not type_ or not resource_uuid:
47
+ raise ValueError("encode_handle requires both type_ and resource_uuid")
48
+ if kind == "f" and not pointer:
49
+ raise ValueError("field handles (kind=f) require a JSON Pointer")
50
+ if kind != "f" and pointer:
51
+ raise ValueError(f'handle kind "{kind}" must not carry a JSON Pointer (only kind=f field handles do)')
52
+ raw = f"{kind}:{type_}:{resource_uuid}" + (f":{pointer}" if pointer else "")
53
+ return "sbf1_" + _base64url_encode(raw.encode("utf-8"))
54
+
55
+
56
+ class DecodedHandle:
57
+ __slots__ = ("kind", "type", "uuid", "pointer")
58
+
59
+ def __init__(self, kind: str, type_: str, resource_uuid: str, pointer: str | None):
60
+ self.kind = kind
61
+ self.type = type_
62
+ self.uuid = resource_uuid
63
+ self.pointer = pointer
64
+
65
+
66
+ def decode_handle(token: str) -> DecodedHandle:
67
+ if not isinstance(token, str) or not token.startswith("sbf1_"):
68
+ raise ValueError('not an sbf1 handle (missing "sbf1_" prefix)')
69
+ if len(token) > MAX_HANDLE_TOKEN_LENGTH:
70
+ raise ValueError(f"handle token exceeds the maximum length of {MAX_HANDLE_TOKEN_LENGTH} characters")
71
+ raw = _base64url_decode(token[len("sbf1_"):]).decode("utf-8")
72
+ match = _HANDLE_RE.match(raw)
73
+ if not match:
74
+ raise ValueError(f'malformed handle payload after decoding: "{raw}"')
75
+ kind, type_, resource_uuid, pointer = match.groups()
76
+ return DecodedHandle(kind.lower(), type_, resource_uuid.lower(), pointer)
77
+
78
+
79
+ def derive_handle_uid(kind: str, type_: str, resource_uuid: str, pointer: str | None) -> str:
80
+ if kind == "r":
81
+ return resource_uuid
82
+ if kind == "f":
83
+ if not pointer:
84
+ raise ValueError("field handles require a pointer to derive handle_uid")
85
+ return str(uuid.uuid5(NS_SBF_FIELD, f"{type_}:{resource_uuid}:{pointer}"))
86
+ if kind == "o":
87
+ return str(uuid.uuid5(NS_SBF_FIELD, f"{type_}:{resource_uuid}:o"))
88
+ raise ValueError(f'invalid handle kind "{kind}"')
89
+
90
+
91
+ # G4 follow-up (D-handles-providers): a sentinel, not `None`, for "path does not resolve" -- a
92
+ # literal port of handles/codec.mjs's resolveJsonPointer (which relies on JS's `undefined` being
93
+ # distinct from `null` at every step) would conflate "field is genuinely JSON null" with "field
94
+ # doesn't exist" under Python's plain `dict.get()`/`None`, turning a present-but-null field into a
95
+ # 404 instead of a 200 with `null` -- a real correctness regression, not a stylistic difference.
96
+ MISSING = object()
97
+
98
+
99
+ def resolve_json_pointer(obj, pointer: str | None):
100
+ if pointer is None or pointer == "":
101
+ return obj
102
+ if not pointer.startswith("/"):
103
+ raise ValueError(f'invalid JSON Pointer "{pointer}" -- must start with "/"')
104
+ parts = [p.replace("~1", "/").replace("~0", "~") for p in pointer.split("/")[1:]]
105
+ current = obj
106
+ for part in parts:
107
+ if current is None:
108
+ return MISSING
109
+ if isinstance(current, list):
110
+ if not re.fullmatch(r"-?\d+", part):
111
+ return MISSING
112
+ idx = int(part)
113
+ if idx < 0 or idx >= len(current):
114
+ return MISSING
115
+ current = current[idx]
116
+ elif isinstance(current, dict):
117
+ if part not in current:
118
+ return MISSING
119
+ current = current[part]
120
+ else:
121
+ return MISSING
122
+ return current
@@ -0,0 +1,96 @@
1
+ """Generated by backend-skeleton. Do not hand-edit -- change the source template and regenerate.
2
+
3
+ G4 follow-up (D-handles-providers): mirrors Java's HandleService.java.tmpl -- the explicit API
4
+ that makes a real GET /handles/{handle}/recover reachable. Deliberately never auto-invoked by
5
+ anything generated elsewhere and never wired into any EXISTING business logic file -- call these
6
+ functions explicitly from your own service code at the point a resource is actually
7
+ created/updated, or apply @record_snapshot (see record_snapshot.py) to an existing service
8
+ function to have it called for you.
9
+
10
+ Plain module-level functions taking `session` explicitly, not a class with injected dependencies
11
+ -- matches this provider's own established convention (every resolver method already takes
12
+ `session` as an explicit call-time argument; there is no DI container here the way Spring's
13
+ @Service beans have).
14
+ """
15
+ import uuid
16
+ from datetime import datetime, timezone
17
+
18
+ from sqlmodel import Session, select
19
+
20
+ from {{PKG}}.handles.codec import encode_handle, derive_handle_uid
21
+ from {{PKG}}.handles.tables import HandleRegistry, HandleSnapshot
22
+
23
+
24
+ def register(
25
+ session: Session,
26
+ kind: str,
27
+ type_: str,
28
+ resource_uid: uuid.UUID,
29
+ pointer: str | None,
30
+ feature_uid: uuid.UUID,
31
+ operation_id: str | None,
32
+ contract_ref: str,
33
+ ) -> str:
34
+ """Derives handle_uid and UPSERTS the registry row -- the schema's own
35
+ unique(resource_type, resource_uid, pointer) constraint means the SAME (kind, type,
36
+ resource_uid, pointer) triple always derives the SAME handle_uid, so re-registering it is
37
+ expected, not an error: an existing row has its feature_uid/operation_id/contract_ref
38
+ refreshed, but revoked_at/revoked_reason are NEVER touched here -- re-registering a revoked
39
+ handle must never silently un-revoke it. Returns the encoded handle token.
40
+ """
41
+ token = encode_handle(kind, type_, str(resource_uid), pointer)
42
+ handle_uid = uuid.UUID(derive_handle_uid(kind, type_, str(resource_uid), pointer))
43
+ existing = session.get(HandleRegistry, handle_uid)
44
+ if existing is None:
45
+ session.add(HandleRegistry(
46
+ handle_uid=handle_uid, kind=kind, resource_type=type_, resource_uid=resource_uid,
47
+ pointer=pointer, feature_uid=feature_uid, operation_id=operation_id, contract_ref=contract_ref,
48
+ ))
49
+ else:
50
+ existing.feature_uid = feature_uid
51
+ existing.operation_id = operation_id
52
+ existing.contract_ref = contract_ref
53
+ session.add(existing)
54
+ session.commit()
55
+ return token
56
+
57
+
58
+ def record_snapshot(
59
+ session: Session,
60
+ handle_uid: uuid.UUID,
61
+ envelope_dir: str,
62
+ operation_id: str,
63
+ contract_hash: str,
64
+ payload,
65
+ ) -> None:
66
+ """Records one envelope for an already-registered handle. `payload` is a plain
67
+ dict/list/scalar (already JSON-serializable), stored directly into the native JSONB column --
68
+ no manual json.dumps/json.loads round-trip exists here for `recover` to get wrong later.
69
+ """
70
+ session.add(HandleSnapshot(
71
+ handle_uid=handle_uid, envelope_dir=envelope_dir, operation_id=operation_id,
72
+ contract_hash=contract_hash, payload=payload,
73
+ ))
74
+ session.commit()
75
+
76
+
77
+ def revoke(session: Session, handle_uid: uuid.UUID, reason: str) -> None:
78
+ registry = session.get(HandleRegistry, handle_uid)
79
+ if registry is not None:
80
+ registry.revoked_at = datetime.now(timezone.utc)
81
+ registry.revoked_reason = reason
82
+ session.add(registry)
83
+ session.commit()
84
+
85
+
86
+ def prune_snapshots_older_than(session: Session, cutoff: datetime) -> int:
87
+ """Retention is EXPOSED, not auto-scheduled -- nothing generated here calls this
88
+ automatically. Deciding how long snapshots should live, and whether a background job is even
89
+ appropriate for this application, is left to a human -- the same boundary this provider's own
90
+ migration.sql already draws around never applying itself.
91
+ """
92
+ rows = session.exec(select(HandleSnapshot).where(HandleSnapshot.recorded_at < cutoff)).all()
93
+ for row in rows:
94
+ session.delete(row)
95
+ session.commit()
96
+ return len(rows)