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,234 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ // O6-style ambiguity rejection (mirrors java-spring's detectBasePackage / python-fastapi's
5
+ // detectImportRoot): TS/Node has no __init__.py-style package marker, so the project root is the
6
+ // nearest ancestor directory containing BOTH package.json and tsconfig.json among this module's
7
+ // own files. More than one distinct candidate is refused with named candidates, never silently
8
+ // picked. Source root is `<root>/src` if it exists (matches the real oracle's own layout), else
9
+ // the project root itself.
10
+ function projectRootFor(file) {
11
+ let dir = path.dirname(file);
12
+ for (;;) {
13
+ if (fs.existsSync(path.join(dir, 'package.json')) && fs.existsSync(path.join(dir, 'tsconfig.json'))) return dir;
14
+ const parent = path.dirname(dir);
15
+ if (parent === dir) return null;
16
+ dir = parent;
17
+ }
18
+ }
19
+
20
+ function detectProjectRoot(moduleFiles, repoRoot) {
21
+ const roots = new Set();
22
+ for (const file of moduleFiles) {
23
+ const r = projectRootFor(file);
24
+ if (r) roots.add(r);
25
+ }
26
+ if (roots.size === 0) {
27
+ throw new Error('could not detect a TypeScript project root (no ancestor directory with both package.json and tsconfig.json found above any scanned file for this module).');
28
+ }
29
+ if (roots.size > 1) {
30
+ throw new Error(`ambiguous TypeScript project 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-project-root repos yet.`);
31
+ }
32
+ const projectRoot = [...roots][0];
33
+ const srcRoot = fs.existsSync(path.join(projectRoot, 'src')) ? path.join(projectRoot, 'src') : projectRoot;
34
+ return { projectRoot, srcRoot };
35
+ }
36
+
37
+ function listTypeScriptFilesUnder(dir) {
38
+ const out = [];
39
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
40
+ if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'dist' || entry.name === 'build') continue;
41
+ const full = path.join(dir, entry.name);
42
+ if (entry.isDirectory()) out.push(...listTypeScriptFilesUnder(full));
43
+ else if (entry.name.endsWith('.ts')) out.push(full);
44
+ }
45
+ return out.sort();
46
+ }
47
+
48
+ // Same "canonical fetch" concept as java-spring's findFetchOperation / python-fastapi's
49
+ // findFetchRoute: a GET endpoint whose path is exactly `${basePath}/:param` (Express's own
50
+ // path-param syntax, optionally regex-constrained -- `:id([0-9]+)`, confirmed in the real oracle)
51
+ // on a controller whose class-name affinity-matches the entity.
52
+ function findFetchRoute(controllers, entityClassName) {
53
+ const needle = entityClassName.toLowerCase();
54
+ for (const controller of controllers) {
55
+ if (!controller.className.toLowerCase().includes(needle)) continue;
56
+ for (const ep of controller.endpoints) {
57
+ if (ep.verb !== 'GET') continue;
58
+ const suffix = ep.path.slice(controller.basePath.length);
59
+ if (/^\/:[^/(]+(\([^)]*\))?$/.test(suffix)) {
60
+ return { method: ep.method, path: ep.path, file: controller.file, line: ep.line, controllerClassName: controller.className };
61
+ }
62
+ }
63
+ }
64
+ return null;
65
+ }
66
+
67
+ // Resolves an import specifier to a real file on disk, extension-probed the same way Node's own
68
+ // TS resolver would. Relative specifiers (`./x`, `../x`) resolve against `fromFile`'s own
69
+ // directory; bare specifiers (`controllers/users`) resolve against `srcRoot` (this project's own
70
+ // `baseUrl`, confirmed against the real oracle's own tsconfig.json: `"baseUrl": "src/"`).
71
+ function resolveImportSpecifier(fromFile, specifier, srcRoot) {
72
+ const base = specifier.startsWith('.') ? path.resolve(path.dirname(fromFile), specifier) : path.join(srcRoot, specifier);
73
+ for (const candidate of [`${base}.ts`, path.join(base, 'index.ts')]) {
74
+ if (fs.existsSync(candidate)) return candidate;
75
+ }
76
+ return null;
77
+ }
78
+
79
+ // `router.get('/:id(...)', [...], show)` -- `show` is only ever an imported identifier (inline
80
+ // arrow-function handlers are already excluded by the scanner). Resolves the router file's own
81
+ // `import { ..., show, ... } from '<specifier>'`, then follows ONE level of barrel re-export
82
+ // (`export * from './show'`, confirmed in the real oracle's own controllers/users/index.ts) if
83
+ // the resolved file doesn't define `show` itself -- no deeper, matching the existing "narrow, not
84
+ // general" discipline every other cross-file resolution in this codebase already follows.
85
+ function resolveHandlerFile(routerFile, handlerName, srcRoot) {
86
+ const routerText = fs.readFileSync(routerFile, 'utf8');
87
+ const importRe = new RegExp(`import\\s*\\{[^}]*\\b${handlerName}\\b[^}]*\\}\\s*from\\s*["']([^"']+)["']`);
88
+ const importMatch = routerText.match(importRe);
89
+ if (!importMatch) return null;
90
+ const directFile = resolveImportSpecifier(routerFile, importMatch[1], srcRoot);
91
+ if (!directFile) return null;
92
+
93
+ const directText = fs.readFileSync(directFile, 'utf8');
94
+ if (new RegExp(`export\\s+const\\s+${handlerName}\\b`).test(directText)) return directFile;
95
+
96
+ // One barrel hop: `export * from './show'` inside an index.ts that doesn't define the handler
97
+ // itself.
98
+ for (const m of directText.matchAll(/export\s*\*\s*from\s*["']([^"']+)["']/g)) {
99
+ const barrelTarget = resolveImportSpecifier(directFile, m[1], srcRoot);
100
+ if (barrelTarget && new RegExp(`export\\s+const\\s+${handlerName}\\b`).test(fs.readFileSync(barrelTarget, 'utf8'))) {
101
+ return barrelTarget;
102
+ }
103
+ }
104
+ return null;
105
+ }
106
+
107
+ // The oracle's own only real protection against leaking a column the app never otherwise exposes
108
+ // (e.g. a password hash): a hand-written `select: [...]` allow-list literal in the fetch handler's
109
+ // own `findOne(...)`/`find(...)` call -- TypeORM's real `{ select: false }` column option exists
110
+ // but the oracle doesn't use it, so requiring a Java/Python-style `<Entity>Public` convention this
111
+ // ecosystem's own real code doesn't demonstrate would be inventing a requirement, not grounding
112
+ // one. Required, not decorative -- mirrors D-fastapi-adapter's own `<Entity>Public` precondition
113
+ // in spirit exactly.
114
+ function findSelectAllowList(handlerFile) {
115
+ if (!handlerFile) return null;
116
+ const text = fs.readFileSync(handlerFile, 'utf8');
117
+ const m = text.match(/select\s*:\s*\[([^\]]*)\]/);
118
+ if (!m) return null;
119
+ const fields = m[1].split(',').map((s) => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
120
+ return fields.length > 0 ? fields : null;
121
+ }
122
+
123
+ // `export const AppDataSource = new DataSource({...})` -- or whatever this app names its own
124
+ // DataSource instance. TypeORM 0.3.x's real, current app-owned-instance API (see
125
+ // D-typescript-express-provider in DECISIONS.md for why this targets DataSource and not the
126
+ // oracle's own stale `getRepository()` global-connection-manager pattern). Same
127
+ // shallowest-then-name deterministic tie-break as python-fastapi's own findSessionDep.
128
+ const DATA_SOURCE_RE = /export\s+const\s+(\w+)\s*=\s*new\s+DataSource\s*\(/;
129
+
130
+ function findDataSource(files) {
131
+ const candidates = [];
132
+ for (const file of files) {
133
+ const m = fs.readFileSync(file, 'utf8').match(DATA_SOURCE_RE);
134
+ if (m) candidates.push({ file, name: m[1] });
135
+ }
136
+ if (candidates.length === 0) return null;
137
+ candidates.sort((a, b) => {
138
+ const depthA = a.file.split(path.sep).length;
139
+ const depthB = b.file.split(path.sep).length;
140
+ return depthA !== depthB ? depthA - depthB : a.file.localeCompare(b.file);
141
+ });
142
+ return candidates[0];
143
+ }
144
+
145
+ function dottedModulePath(file, srcRoot) {
146
+ const rel = path.relative(srcRoot, file).replace(/\.ts$/, '');
147
+ return rel.split(path.sep).join('/');
148
+ }
149
+
150
+ // The descriptor-facing entry point (handles/providers/typescript-express.mjs's provider.plan).
151
+ // See schemas/handles-plan.schema.json for the sbf.handles-plan/1 envelope this returns.
152
+ export function plan({ repoRoot, scanReport, module: moduleName, resourceFilter }) {
153
+ const targetModule = moduleName
154
+ ? scanReport.related_modules.find((m) => m.module === moduleName)
155
+ : scanReport.related_modules[0];
156
+
157
+ if (!targetModule) {
158
+ return {
159
+ schema: 'sbf.handles-plan/1', provider: 'typescript-express', module: null, resources: [],
160
+ notes: ['no related module in the scan report -- run `bskel scan` first, or pass --module explicitly'],
161
+ };
162
+ }
163
+
164
+ const moduleFiles = [...targetModule.controllers.map((c) => c.file), ...targetModule.entities.map((e) => e.file)].filter(Boolean);
165
+ const { projectRoot, srcRoot } = detectProjectRoot(moduleFiles, repoRoot);
166
+ const allProjectFiles = listTypeScriptFilesUnder(srcRoot);
167
+ const dataSource = findDataSource(allProjectFiles);
168
+
169
+ const resources = [];
170
+ const notes = [];
171
+
172
+ for (const entity of targetModule.entities) {
173
+ if (resourceFilter && !resourceFilter.includes(entity.className)) continue;
174
+ const fetchRoute = findFetchRoute(targetModule.controllers, entity.className);
175
+ const handlerFile = fetchRoute ? resolveHandlerFile(fetchRoute.file, fetchRoute.method, srcRoot) : null;
176
+ const selectFields = handlerFile ? findSelectAllowList(handlerFile) : null;
177
+
178
+ if (!fetchRoute) {
179
+ 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`);
180
+ } else if (!handlerFile) {
181
+ notes.push(`${entity.className}: could not resolve ${fetchRoute.method}'s own defining file (import, or one barrel hop, from ${path.relative(repoRoot, fetchRoute.file)}) -- resolver NOT generated.`);
182
+ } else if (!selectFields) {
183
+ notes.push(`${entity.className}: no literal select: [...] allow-list found in ${path.relative(repoRoot, handlerFile)} -- resolver NOT generated (a generic handle-fetch route serializing the raw entity could leak a column the app never otherwise exposes, e.g. a password hash). Add one and re-run.`);
184
+ }
185
+ if (fetchRoute && !entity.idField) {
186
+ notes.push(`${entity.className}: no primary-key field detected on the entity -- resolver NOT generated.`);
187
+ } else if (fetchRoute && entity.idField && !entity.idFieldIsUuid) {
188
+ // This handle system's own token format (kind:type:UUID[:pointer]) can only ever address a
189
+ // UUID-shaped resource identifier -- an entity whose primary key is the TypeORM default
190
+ // (an auto-incrementing integer, @PrimaryGeneratedColumn() with no argument) genuinely
191
+ // cannot be reached through a handle at all, a structural fact about this whole project's
192
+ // handle scheme, not a TypeScript-specific limitation. Found live via a real `tsc --noEmit`
193
+ // type error (the real oracle's own User entity uses the integer form) before this
194
+ // distinction was tracked.
195
+ notes.push(`${entity.className}: primary key "${entity.idField}" is not UUID-typed (@PrimaryGeneratedColumn('uuid')) -- resolver NOT generated. This project's handle format can only address UUID-shaped resource identifiers.`);
196
+ }
197
+ if (fetchRoute && handlerFile && selectFields && entity.idField && entity.idFieldIsUuid && !dataSource) {
198
+ notes.push(`${entity.className}: fetch route and select allow-list found, but no "export const X = new DataSource(...)" was found anywhere under ${path.relative(repoRoot, srcRoot)}/ -- resolver NOT generated. (This provider targets TypeORM's current DataSource API, not the older global getRepository() pattern.)`);
199
+ }
200
+ if (fetchRoute) {
201
+ 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 checkAccess() always denies until hand-wired.`);
202
+ }
203
+
204
+ const willGenerateResolver = Boolean(fetchRoute && handlerFile && selectFields && entity.idField && entity.idFieldIsUuid && dataSource);
205
+
206
+ resources.push({
207
+ type: entity.className,
208
+ table: entity.table,
209
+ idField: entity.idField,
210
+ readPath: fetchRoute ? `dataSource.getRepository(${entity.className}).findOne({ where: { ${entity.idField}: resourceUid } })` : null,
211
+ requiredAuthority: 'TODO_ACCESS_CHECK',
212
+ willGenerateResolver,
213
+ // provider-specific extras (additionalProperties: true in schemas/handles-plan.schema.json)
214
+ fetchRoute,
215
+ selectFields,
216
+ modelImport: dottedModulePath(entity.file, srcRoot),
217
+ dataSource,
218
+ });
219
+ }
220
+
221
+ if (resources.length === 0) {
222
+ notes.push(`no entities found for module "${targetModule.module}" ${resourceFilter ? `matching --resource filter [${resourceFilter.join(', ')}]` : ''} -- nothing to plan.`);
223
+ }
224
+
225
+ return {
226
+ schema: 'sbf.handles-plan/1',
227
+ provider: 'typescript-express',
228
+ projectRoot,
229
+ srcRoot,
230
+ module: targetModule.module,
231
+ resources,
232
+ notes,
233
+ };
234
+ }
@@ -0,0 +1,116 @@
1
+ // Generated by backend-skeleton. Do not hand-edit -- change the source template and regenerate,
2
+ // or the JS/Java/Python/TypeScript implementations will silently diverge.
3
+ //
4
+ // Encodes/decodes backend-skeleton "handles" (kind:type:uuid[:pointer], base64url with an
5
+ // "sbf1_" prefix). Must stay behavior-identical to handles/codec.mjs (the JS reference
6
+ // implementation) -- verified by an executed round-trip test (test/handles-typescript-codec.
7
+ // test.mjs), not just inspection. See D-typescript-express-provider in DECISIONS.md.
8
+ //
9
+ // A SELF-CONTAINED port, not an `import` of this CLI's own handles/codec.mjs -- importing this
10
+ // whole scaffolding tool into a generated file would make it a runtime dependency of the target
11
+ // application, architecturally wrong regardless of same-ecosystem convenience. Zero imports
12
+ // beyond node:crypto. Zero non-erasable TypeScript syntax (no decorators, no enums with values)
13
+ // so this file runs directly via `node --experimental-strip-types` with no tsc/devDependency
14
+ // needed for behavioral verification -- real type-checking is proven separately (a real
15
+ // `tsc --noEmit`), not by this file's own runtime test.
16
+ import { createHash } from 'node:crypto';
17
+
18
+ export const NS_SBF_FIELD = 'a3f1c2e0-8b4d-4f1a-9c3e-1d2b3a4c5d6e';
19
+
20
+ const UUID_RE = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
21
+ const HANDLE_RE = new RegExp(`^([rfo]):([^:]+):(${UUID_RE})(?::(.*))?$`, 'i');
22
+ // D-security-10 parity: Node's own `Buffer.from(str, 'base64')` silently discards characters
23
+ // outside the base64 alphabet instead of rejecting them (the same runtime this whole CLI already
24
+ // runs on -- not a new investigation, confirmed by the JS reference's own identical guard).
25
+ const BASE64URL_CHARSET_RE = /^[A-Za-z0-9_-]*$/;
26
+
27
+ const MAX_HANDLE_TOKEN_LENGTH = 2048;
28
+
29
+ export interface DecodedHandle {
30
+ kind: string;
31
+ type: string;
32
+ uuid: string;
33
+ pointer: string | null;
34
+ }
35
+
36
+ function base64url(buf: Buffer): string {
37
+ return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
38
+ }
39
+
40
+ function base64urlDecode(str: string): Buffer {
41
+ if (!BASE64URL_CHARSET_RE.test(str)) {
42
+ throw new Error('not valid base64url after the sbf1_ prefix');
43
+ }
44
+ const pad = (4 - (str.length % 4)) % 4;
45
+ const padded = str.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat(pad);
46
+ return Buffer.from(padded, 'base64');
47
+ }
48
+
49
+ export function encodeHandle(kind: string, type: string, uuid: string, pointer: string | null = null): string {
50
+ if (!['r', 'f', 'o'].includes(kind)) throw new Error(`invalid handle kind "${kind}" (expected r, f, or o)`);
51
+ if (!type || !uuid) throw new Error('encodeHandle requires both type and uuid');
52
+ if (kind === 'f' && !pointer) throw new Error('field handles (kind=f) require a JSON Pointer');
53
+ if (kind !== 'f' && pointer) throw new Error(`handle kind "${kind}" must not carry a JSON Pointer (only kind=f field handles do)`);
54
+ const raw = `${kind}:${type}:${uuid}${pointer ? `:${pointer}` : ''}`;
55
+ return `sbf1_${base64url(Buffer.from(raw, 'utf8'))}`;
56
+ }
57
+
58
+ export function decodeHandle(token: string): DecodedHandle {
59
+ if (typeof token !== 'string' || !token.startsWith('sbf1_')) {
60
+ throw new Error('not an sbf1 handle (missing "sbf1_" prefix)');
61
+ }
62
+ if (token.length > MAX_HANDLE_TOKEN_LENGTH) {
63
+ throw new Error(`handle token exceeds the maximum length of ${MAX_HANDLE_TOKEN_LENGTH} characters`);
64
+ }
65
+ const raw = base64urlDecode(token.slice('sbf1_'.length)).toString('utf8');
66
+ const match = raw.match(HANDLE_RE);
67
+ if (!match) throw new Error(`malformed handle payload after decoding: "${raw}"`);
68
+ const [, kind, type, uuid, pointer] = match;
69
+ return { kind: kind.toLowerCase(), type, uuid: uuid.toLowerCase(), pointer: pointer ?? null };
70
+ }
71
+
72
+ function uuidToBytes(uuid: string): Buffer {
73
+ return Buffer.from(uuid.replace(/-/g, ''), 'hex');
74
+ }
75
+
76
+ function bytesToUuid(bytes: Buffer): string {
77
+ const hex = bytes.toString('hex');
78
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
79
+ }
80
+
81
+ /** RFC 4122 UUIDv5 (name-based, SHA-1). */
82
+ export function uuidv5(namespaceUuid: string, name: string): string {
83
+ const hash = createHash('sha1')
84
+ .update(Buffer.concat([uuidToBytes(namespaceUuid), Buffer.from(name, 'utf8')]))
85
+ .digest();
86
+ const bytes = Buffer.from(hash.subarray(0, 16));
87
+ bytes[6] = (bytes[6] & 0x0f) | 0x50; // version 5
88
+ bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant RFC 4122
89
+ return bytesToUuid(bytes);
90
+ }
91
+
92
+ export function deriveHandleUid(kind: string, type: string, uuid: string, pointer: string | null): string {
93
+ if (kind === 'r') return uuid;
94
+ if (kind === 'f') {
95
+ if (!pointer) throw new Error('field handles require a pointer to derive handle_uid');
96
+ return uuidv5(NS_SBF_FIELD, `${type}:${uuid}:${pointer}`);
97
+ }
98
+ if (kind === 'o') return uuidv5(NS_SBF_FIELD, `${type}:${uuid}:o`);
99
+ throw new Error(`invalid handle kind "${kind}"`);
100
+ }
101
+
102
+ // RFC 6901 JSON Pointer resolution. Unlike the Python port (which needed a `_MISSING` sentinel
103
+ // because Python's plain `dict.get()` conflates "absent" with a genuine `None`), TypeScript/
104
+ // JavaScript already has the exact distinction the JS reference relies on -- `undefined` (absent)
105
+ // vs `null` (present, empty) -- so this is a direct, unmodified port.
106
+ export function resolveJsonPointer(obj: unknown, pointer: string | null): unknown {
107
+ if (pointer == null || pointer === '') return obj;
108
+ if (!pointer.startsWith('/')) throw new Error(`invalid JSON Pointer "${pointer}" -- must start with "/"`);
109
+ const parts = pointer.split('/').slice(1).map((p) => p.replace(/~1/g, '/').replace(/~0/g, '~'));
110
+ let current: unknown = obj;
111
+ for (const part of parts) {
112
+ if (current == null) return undefined;
113
+ current = Array.isArray(current) ? (current as unknown[])[Number(part)] : (current as Record<string, unknown>)[part];
114
+ }
115
+ return current;
116
+ }
@@ -0,0 +1,39 @@
1
+ // Generated by backend-skeleton. Do not hand-edit -- change the source template and regenerate.
2
+ //
3
+ // In-process registry mapping a handle `type` string to its resolver instance -- deliberately NOT
4
+ // a database table. This 1st-slice scope carries no recover()/snapshot lifecycle equivalent (no
5
+ // sbf_handle/sbf_handle_snapshot table) -- mirrors java-spring/python-fastapi's own pre-O4/
6
+ // pre-follow-up state, not a gap specific to this provider. See D-typescript-express-provider in
7
+ // DECISIONS.md.
8
+
9
+ // Unlike the Python provider's ResourceResolver (which threads a per-request SQLAlchemy `session`
10
+ // through every method, matching FastAPI's own dependency-injection convention), this interface
11
+ // takes NO dataSource/session parameter at all -- TypeORM's `DataSource` is an app-wide singleton
12
+ // instantiated once at startup (`export const AppDataSource = new DataSource(...)`), not a
13
+ // per-request-injected object the way SQLAlchemy's `Session` is. Each generated resolver imports
14
+ // its own DataSource reference directly (see resolver.ts.tmpl) -- a genuine simplification from
15
+ // this ecosystem's own real pattern, not an arbitrary deviation from the Python provider's shape.
16
+ export interface ResourceResolver {
17
+ type: string;
18
+ fetch(resourceUid: string): Promise<unknown>;
19
+ checkAccess(obj: unknown): void;
20
+ patchField(obj: unknown, pointer: string, value: unknown): void;
21
+ toPublic(obj: unknown): unknown;
22
+ }
23
+
24
+ // Small, self-contained error classes -- router.ts.tmpl maps these to real HTTP status codes
25
+ // explicitly (403/501), never relying on Express's own default error handler (Express 4, the
26
+ // real oracle's own pinned version, does not auto-catch a rejected Promise inside an async route
27
+ // handler the way Express 5 does -- cannot be assumed of an arbitrary target app either way).
28
+ export class HandleAccessDeniedError extends Error {}
29
+ export class HandleNotImplementedError extends Error {}
30
+
31
+ const RESOLVERS = new Map<string, ResourceResolver>();
32
+
33
+ export function register(resolver: ResourceResolver): void {
34
+ RESOLVERS.set(resolver.type, resolver);
35
+ }
36
+
37
+ export function resolverFor(type: string): ResourceResolver | undefined {
38
+ return RESOLVERS.get(type);
39
+ }
@@ -0,0 +1,55 @@
1
+ // Generated by backend-skeleton (bskel handles emit) for feature {{FEATURE_ID}}.
2
+ // type = "{{RESOURCE_TYPE}}"
3
+ //
4
+ // `fetch`/`toPublic` are wired to real, existing code: `fetch` uses TypeORM's own DataSource-
5
+ // based `getRepository(...).findOne(...)`, this stack's current (not the deprecated global
6
+ // `getRepository()`) canonical read path, and `toPublic` projects only through the field
7
+ // allow-list found in the real fetch handler's own `select: [...]` literal -- required so a
8
+ // generic handle-fetch route can never accidentally serialize a column the app does not
9
+ // otherwise expose (a real example this provider was built against: a table entity carrying a
10
+ // password hash column with no `{ select: false }` protection of its own). See the fetch route
11
+ // this was planned from: {{FETCH_ROUTE_FILE}}:{{FETCH_ROUTE_LINE}}.
12
+ //
13
+ // `checkAccess` is DELIBERATELY a fail-closed stub, not auto-generated business logic. Express has
14
+ // no imperatively-readable global security context, and this stack's own real authorization logic
15
+ // lives inside route handler bodies (per-row checks) or project-specific middleware (e.g. a
16
+ // `checkRole(...)` convention) -- neither of which a static source scan can safely extract or
17
+ // assume is a stable, framework-level signal. Wire it to whatever this app's own current-user/
18
+ // permission pattern is before relying on it -- until then, every request is denied.
19
+ //
20
+ // `patchField` is DELIBERATELY a stub for the same reason Java's ResourceResolverStub.java.tmpl's
21
+ // patchField() is: this stack's own update conventions must be checked by a human before writing
22
+ // to them (see D-resolver-scope in DECISIONS.md) -- do not write directly against the ORM, that
23
+ // bypasses this app's existing validation and business rules.
24
+ import { {{MODEL}} } from '{{MODEL_IMPORT_PATH}}';
25
+ import { {{DATA_SOURCE_NAME}} } from '{{DATA_SOURCE_IMPORT_PATH}}';
26
+ import { register, HandleAccessDeniedError, HandleNotImplementedError, type ResourceResolver } from '../registry';
27
+
28
+ const {{RESOURCE_TYPE}}Resolver: ResourceResolver = {
29
+ type: '{{RESOURCE_TYPE}}',
30
+
31
+ async fetch(resourceUid: string): Promise<unknown> {
32
+ return {{DATA_SOURCE_NAME}}.getRepository({{MODEL}}).findOne({ where: { {{ID_FIELD}}: resourceUid } });
33
+ },
34
+
35
+ checkAccess(_obj: unknown): void {
36
+ // TODO: wire this app's own current-user/permission check before relying on this resolver.
37
+ // Fails closed until then -- every request is denied, not silently permitted.
38
+ throw new HandleAccessDeniedError('access check not yet implemented for {{RESOURCE_TYPE}}');
39
+ },
40
+
41
+ patchField(_obj: unknown, pointer: string, _value: unknown): void {
42
+ // TODO: route through this app's real update path for {{MODEL}}, matching whichever
43
+ // partial-update convention its own real update handler actually uses. Do not write directly
44
+ // to the entity's own fields and save -- that bypasses this app's existing validation and
45
+ // business rules.
46
+ throw new HandleNotImplementedError(`patchField not yet implemented for {{RESOURCE_TYPE}}${pointer}`);
47
+ },
48
+
49
+ toPublic(obj: unknown): unknown {
50
+ const row = obj as Record<string, unknown>;
51
+ return { {{SELECT_PROJECTION}} };
52
+ },
53
+ };
54
+
55
+ register({{RESOURCE_TYPE}}Resolver);
@@ -0,0 +1,11 @@
1
+ // Generated by backend-skeleton. Do not hand-edit -- change the source template and regenerate.
2
+ //
3
+ // Imports every resolver module under this directory for its `register(...)` side effect. Unlike
4
+ // the Python provider's own resolvers_init.py.tmpl (which uses `pkgutil.iter_modules` at RUNTIME
5
+ // and is therefore truly feature-independent content), Node ESM has no direct equivalent without
6
+ // extra tooling -- this file's own import list is regenerated by `bskel handles emit` from the
7
+ // resolvers directory's real current contents every run (including any orphaned resolver a
8
+ // feature no longer plans but that O2's own "never delete, only report" policy leaves on disk),
9
+ // so it stays correct without a human ever hand-editing it, even though its content is not
10
+ // literally static the way the Python file's is.
11
+ {{IMPORTS}}
@@ -0,0 +1,122 @@
1
+ // Generated by backend-skeleton.
2
+ //
3
+ // D-handles (DECISIONS.md): exposed in production. Per-resolver `checkAccess()` is the entire
4
+ // defense for this generic-object-accessor security surface -- see the docstring on each
5
+ // generated resolver for why it is ALWAYS a fail-closed stub in this provider.
6
+ //
7
+ // Every async handler body is wrapped in an explicit try/catch, never relying on Express's own
8
+ // default error handling -- Express 4 (the real community boilerplate this provider was verified
9
+ // against still pins this version) does NOT auto-catch a rejected Promise inside an async route
10
+ // handler the way Express 5 does, and that can't be assumed of an arbitrary target app either way.
11
+ import { Router, type Request, type Response } from 'express';
12
+ import { decodeHandle, resolveJsonPointer } from './codec';
13
+ import { resolverFor, HandleAccessDeniedError, HandleNotImplementedError } from './registry';
14
+
15
+ export const router = Router();
16
+
17
+ router.get('/handles/:handle', async (req: Request, res: Response) => {
18
+ // Express's own types allow a route param to be `string | string[]` (repeated wildcard
19
+ // segments can produce an array) -- a single named `:handle` segment never actually does in
20
+ // practice, but `decodeHandle` only accepts a real string, so this is checked explicitly
21
+ // rather than silently asserted.
22
+ if (typeof req.params.handle !== 'string') {
23
+ res.status(400).json({ detail: 'malformed handle path segment' });
24
+ return;
25
+ }
26
+ let decoded;
27
+ try {
28
+ decoded = decodeHandle(req.params.handle);
29
+ } catch (exc) {
30
+ res.status(400).json({ detail: (exc as Error).message });
31
+ return;
32
+ }
33
+
34
+ const resolver = resolverFor(decoded.type);
35
+ if (!resolver) {
36
+ res.status(404).json({ detail: `no resolver registered for handle type "${decoded.type}"` });
37
+ return;
38
+ }
39
+
40
+ try {
41
+ const obj = await resolver.fetch(decoded.uuid);
42
+ if (obj == null) {
43
+ res.status(404).json({ detail: 'resource not found' });
44
+ return;
45
+ }
46
+ resolver.checkAccess(obj);
47
+ const publicObj = resolver.toPublic(obj);
48
+ if (decoded.pointer == null) {
49
+ res.status(200).json(publicObj);
50
+ return;
51
+ }
52
+ // Walks the PUBLIC projection, not the raw fetch() row -- a deliberate, security-preserving
53
+ // departure, not a shortcut: this provider's own fetch() returns the raw entity, and
54
+ // toPublic() is a SEPARATE, required projection specifically because a real entity can carry
55
+ // a column (e.g. a password hash) with no protection besides the handler's own select
56
+ // allow-list. Walking the raw row here would silently reopen exactly that leak vector.
57
+ const target = resolveJsonPointer(publicObj, decoded.pointer);
58
+ if (target === undefined) {
59
+ res.status(404).json({ detail: `pointer "${decoded.pointer}" does not resolve on ${decoded.type}` });
60
+ return;
61
+ }
62
+ res.status(200).json(target as object);
63
+ } catch (exc) {
64
+ if (exc instanceof HandleAccessDeniedError) {
65
+ res.status(403).json({ detail: exc.message });
66
+ return;
67
+ }
68
+ throw exc;
69
+ }
70
+ });
71
+
72
+ router.patch('/handles/:handle', async (req: Request, res: Response) => {
73
+ // Express's own types allow a route param to be `string | string[]` (repeated wildcard
74
+ // segments can produce an array) -- a single named `:handle` segment never actually does in
75
+ // practice, but `decodeHandle` only accepts a real string, so this is checked explicitly
76
+ // rather than silently asserted.
77
+ if (typeof req.params.handle !== 'string') {
78
+ res.status(400).json({ detail: 'malformed handle path segment' });
79
+ return;
80
+ }
81
+ let decoded;
82
+ try {
83
+ decoded = decodeHandle(req.params.handle);
84
+ } catch (exc) {
85
+ res.status(400).json({ detail: (exc as Error).message });
86
+ return;
87
+ }
88
+
89
+ // D-security-10 parity: checks kind explicitly, not just pointer-presence -- mirrors
90
+ // HandleController.java.tmpl's/router.py.tmpl's identical check.
91
+ if (decoded.kind !== 'f' || decoded.pointer == null) {
92
+ res.status(400).json({ detail: 'cannot PATCH a resource-level handle (kind=r) -- only field handles (kind=f) support PATCH' });
93
+ return;
94
+ }
95
+
96
+ const resolver = resolverFor(decoded.type);
97
+ if (!resolver) {
98
+ res.status(404).json({ detail: `no resolver registered for handle type "${decoded.type}"` });
99
+ return;
100
+ }
101
+
102
+ try {
103
+ const obj = await resolver.fetch(decoded.uuid);
104
+ if (obj == null) {
105
+ res.status(404).json({ detail: 'resource not found' });
106
+ return;
107
+ }
108
+ resolver.checkAccess(obj);
109
+ resolver.patchField(obj, decoded.pointer, req.body);
110
+ res.status(204).send();
111
+ } catch (exc) {
112
+ if (exc instanceof HandleAccessDeniedError) {
113
+ res.status(403).json({ detail: exc.message });
114
+ return;
115
+ }
116
+ if (exc instanceof HandleNotImplementedError) {
117
+ res.status(501).json({ detail: exc.message });
118
+ return;
119
+ }
120
+ throw exc;
121
+ }
122
+ });
@@ -0,0 +1,20 @@
1
+ import { plan } from './typescript-express/plan.mjs';
2
+ import { emitTypeScriptExpress } from './typescript-express/emit.mjs';
3
+
4
+ // G5 (D-typescript-express-provider). Zero-registration descriptor loaded by handles/registry.mjs
5
+ // -- see schemas/handles-provider.schema.json for the contract this object's JSON-shaped fields
6
+ // must match (plan/emit are functions, checked separately).
7
+ export const provider = {
8
+ contract: 'sbf.handles-provider/1',
9
+ id: 'typescript-express',
10
+ title: 'TypeScript / Express / TypeORM',
11
+ requiresCapabilities: ['resource.fetch'],
12
+ // No migration.sql -- this 1st-slice provider generates no schema-owning artifact (no
13
+ // recover(), no sbf_handle table), matching java-spring/python-fastapi's own pre-O4/
14
+ // pre-follow-up state, not a gap specific to this provider.
15
+ outputs: { spec: [] },
16
+ plan,
17
+ emit(args) {
18
+ return emitTypeScriptExpress(args);
19
+ },
20
+ };