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.
- package/LICENSE +21 -0
- package/README.md +284 -0
- package/bin/bskel.mjs +2384 -0
- package/contracts/completeness.mjs +176 -0
- package/contracts/emit.mjs +287 -0
- package/contracts/export.mjs +325 -0
- package/contracts/openapi.mjs +869 -0
- package/contracts/validate.mjs +147 -0
- package/handles/_engine.mjs +281 -0
- package/handles/codec.mjs +119 -0
- package/handles/conformance.mjs +74 -0
- package/handles/providers/java-spring/ast-bridge.mjs +59 -0
- package/handles/providers/java-spring/ast-helper/build.gradle +34 -0
- package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.properties +9 -0
- package/handles/providers/java-spring/ast-helper/gradlew +248 -0
- package/handles/providers/java-spring/ast-helper/gradlew.bat +82 -0
- package/handles/providers/java-spring/ast-helper/settings.gradle +1 -0
- package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +178 -0
- package/handles/providers/java-spring/emit.mjs +232 -0
- package/handles/providers/java-spring/patch-strategy.mjs +229 -0
- package/handles/providers/java-spring/plan.mjs +377 -0
- package/handles/providers/java-spring/templates/HandleAspect.java.tmpl +125 -0
- package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +150 -0
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +177 -0
- package/handles/providers/java-spring/templates/HandleRegistry.java.tmpl +107 -0
- package/handles/providers/java-spring/templates/HandleRegistryRepository.java.tmpl +8 -0
- package/handles/providers/java-spring/templates/HandleService.java.tmpl +95 -0
- package/handles/providers/java-spring/templates/HandleSnapshot.java.tmpl +75 -0
- package/handles/providers/java-spring/templates/HandleSnapshotRepository.java.tmpl +20 -0
- package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +50 -0
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +50 -0
- package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +77 -0
- package/handles/providers/java-spring/templates/migration.sql.tmpl +34 -0
- package/handles/providers/java-spring.mjs +21 -0
- package/handles/providers/python-fastapi/emit.mjs +171 -0
- package/handles/providers/python-fastapi/plan.mjs +186 -0
- package/handles/providers/python-fastapi/templates/__init__.py.tmpl +1 -0
- package/handles/providers/python-fastapi/templates/codec.py.tmpl +122 -0
- package/handles/providers/python-fastapi/templates/handle_service.py.tmpl +96 -0
- package/handles/providers/python-fastapi/templates/migration.sql.tmpl +35 -0
- package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +155 -0
- package/handles/providers/python-fastapi/templates/registry.py.tmpl +37 -0
- package/handles/providers/python-fastapi/templates/resolver.py.tmpl +59 -0
- package/handles/providers/python-fastapi/templates/resolvers_init.py.tmpl +13 -0
- package/handles/providers/python-fastapi/templates/router.py.tmpl +140 -0
- package/handles/providers/python-fastapi/templates/tables.py.tmpl +66 -0
- package/handles/providers/python-fastapi.mjs +22 -0
- package/handles/providers/typescript-express/emit.mjs +128 -0
- package/handles/providers/typescript-express/plan.mjs +234 -0
- package/handles/providers/typescript-express/templates/codec.ts.tmpl +116 -0
- package/handles/providers/typescript-express/templates/registry.ts.tmpl +39 -0
- package/handles/providers/typescript-express/templates/resolver.ts.tmpl +55 -0
- package/handles/providers/typescript-express/templates/resolvers_index.ts.tmpl +11 -0
- package/handles/providers/typescript-express/templates/router.ts.tmpl +122 -0
- package/handles/providers/typescript-express.mjs +20 -0
- package/handles/registry.mjs +90 -0
- package/lib/cli.mjs +430 -0
- package/lib/doctor.mjs +200 -0
- package/lib/exit-codes.mjs +67 -0
- package/lib/featureid.mjs +55 -0
- package/lib/featurelifecycle.mjs +205 -0
- package/lib/fsutil.mjs +50 -0
- package/lib/gate-definitions.mjs +293 -0
- package/lib/gates.mjs +263 -0
- package/lib/handles-manifest.mjs +92 -0
- package/lib/lock.mjs +68 -0
- package/lib/patch-approvals.mjs +56 -0
- package/lib/paths.mjs +21 -0
- package/lib/repo.mjs +44 -0
- package/lib/schema-validate.mjs +56 -0
- package/lib/state.mjs +124 -0
- package/lib/template.mjs +35 -0
- package/lib/verify.mjs +206 -0
- package/lib/workflow.mjs +142 -0
- package/new/fastapi.mjs +165 -0
- package/new/index.mjs +62 -0
- package/new/params.mjs +233 -0
- package/new/spring.mjs +198 -0
- package/new/templates/fastapi/README.md +26 -0
- package/new/templates/fastapi/app/__init__.py +0 -0
- package/new/templates/fastapi/app/main.py +8 -0
- package/new/templates/fastapi/gitignore +6 -0
- package/new/templates/fastapi/pyproject.toml +14 -0
- package/package.json +50 -0
- package/scanners/adapters/_express-shared.mjs +238 -0
- package/scanners/adapters/_java-spring-analyzer.mjs +273 -0
- package/scanners/adapters/generic-grep.mjs +128 -0
- package/scanners/adapters/java-spring.mjs +301 -0
- package/scanners/adapters/javascript-express.mjs +422 -0
- package/scanners/adapters/python-fastapi.mjs +348 -0
- package/scanners/adapters/typescript-express.mjs +299 -0
- package/scanners/capabilities.mjs +90 -0
- package/scanners/conformance.mjs +59 -0
- package/scanners/db/introspect.mjs +109 -0
- package/scanners/db/migrations.mjs +126 -0
- package/scanners/index.mjs +281 -0
- package/scanners/registry.mjs +130 -0
- package/scanners/render.mjs +136 -0
- package/scanners/text-util.mjs +8 -0
- package/schemas/adapter.schema.json +23 -0
- package/schemas/agent-envelope.schema.json +21 -0
- package/schemas/contract-resolution.schema.json +28 -0
- package/schemas/feature-contract.schema.json +78 -0
- package/schemas/feature-index.schema.json +25 -0
- package/schemas/feature.schema.json +17 -0
- package/schemas/gate-event.schema.json +19 -0
- package/schemas/handles-plan.schema.json +31 -0
- package/schemas/handles-provider.schema.json +26 -0
- package/schemas/patch-approvals.schema.json +28 -0
- package/schemas/scan-report.schema.json +102 -0
- package/schemas/stack-choice.schema.json +89 -0
- package/schemas/stack-record.schema.json +20 -0
- package/schemas/state.schema.json +43 -0
- package/scripts/preflight-base-ref.sh +226 -0
- package/stack/apply.mjs +159 -0
- package/stack/bootstrap/_lib.sh +73 -0
- package/stack/bootstrap/ngrok.sh +90 -0
- 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
|
+
};
|