@xemahq/create-biome 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -5
- package/dist/bin/create-biome.js +11 -5
- package/dist/bin/create-biome.js.map +1 -1
- package/dist/lib/kind.d.ts +2 -1
- package/dist/lib/kind.d.ts.map +1 -1
- package/dist/lib/kind.js +6 -0
- package/dist/lib/kind.js.map +1 -1
- package/dist/lib/manifest-builder.d.ts +48 -6
- package/dist/lib/manifest-builder.d.ts.map +1 -1
- package/dist/lib/manifest-builder.js +48 -4
- package/dist/lib/manifest-builder.js.map +1 -1
- package/dist/lib/scaffolder.d.ts.map +1 -1
- package/dist/lib/scaffolder.js +26 -2
- package/dist/lib/scaffolder.js.map +1 -1
- package/dist/lib/template-files.d.ts +1 -0
- package/dist/lib/template-files.d.ts.map +1 -1
- package/dist/lib/template-files.js +987 -68
- package/dist/lib/template-files.js.map +1 -1
- package/package.json +4 -2
|
@@ -9,6 +9,13 @@ function pascalize(kebab) {
|
|
|
9
9
|
function constCase(kebab) {
|
|
10
10
|
return kebab.replaceAll('-', '_').toUpperCase();
|
|
11
11
|
}
|
|
12
|
+
function snakeCase(kebab) {
|
|
13
|
+
return kebab.replaceAll('-', '_');
|
|
14
|
+
}
|
|
15
|
+
const SAMPLE_RESOURCE = 'item';
|
|
16
|
+
const SAMPLE_RESOURCE_PLURAL = 'items';
|
|
17
|
+
const FEATURE_DIR = SAMPLE_RESOURCE_PLURAL;
|
|
18
|
+
const FEATURE_MODULE_CLASS = 'ItemsModule';
|
|
12
19
|
function serverBiomeRootFiles(ctx) {
|
|
13
20
|
return {
|
|
14
21
|
'README.md': `# ${ctx.displayName}
|
|
@@ -17,6 +24,38 @@ ${ctx.description}
|
|
|
17
24
|
|
|
18
25
|
A first-party Xema **server biome** scaffolded by \`@xemahq/create-biome\`.
|
|
19
26
|
|
|
27
|
+
## What you already have, without authoring anything
|
|
28
|
+
|
|
29
|
+
| You wrote | You already have |
|
|
30
|
+
|---|---|
|
|
31
|
+
| \`@XemaResource('${SAMPLE_RESOURCE}')\` + four \`@XemaRoute\`s | four authorized, auditable, **agent-callable capabilities** — \`${ctx.biomeId}:${SAMPLE_RESOURCE}.{list,read,create,delete}@1\` — in the capability graph, with input/output schemas |
|
|
32
|
+
| \`xema.capabilityDomain\` | the \`<domain>\` those refs are filed under |
|
|
33
|
+
| a \`search-type\` contribution | a renderable search result-type with working deep links |
|
|
34
|
+
| \`renderHints.routeTemplate\` | a real URL from chat and search into the detail page |
|
|
35
|
+
|
|
36
|
+
**No capability descriptor is written anywhere in this biome, and none should
|
|
37
|
+
be.** Rename the \`${SAMPLE_RESOURCE}\` resource and the refs follow; add a route
|
|
38
|
+
and it projects; delete one and the projector prunes it. Reach for
|
|
39
|
+
\`@XemaCapabilityHint\` to correct a derived value, and for a hand-authored
|
|
40
|
+
\`@XemaCapability\` only when an operation genuinely needs a curated ref.
|
|
41
|
+
|
|
42
|
+
Run \`pnpm test\` in \`api/${apiNameForBiome(ctx.biomeId)}/\` to see the exact
|
|
43
|
+
capability set this biome projects.
|
|
44
|
+
|
|
45
|
+
## First steps
|
|
46
|
+
|
|
47
|
+
\`\`\`sh
|
|
48
|
+
# from this biome directory
|
|
49
|
+
xema dev
|
|
50
|
+
\`\`\`
|
|
51
|
+
|
|
52
|
+
\`xema dev\` boots the biome against a local platform (the workspace source
|
|
53
|
+
always wins — no publish, no token). When it does what you want:
|
|
54
|
+
|
|
55
|
+
\`\`\`sh
|
|
56
|
+
xema biome publish
|
|
57
|
+
\`\`\`
|
|
58
|
+
|
|
20
59
|
## Layout
|
|
21
60
|
|
|
22
61
|
- \`xema-biome.json\` — the biome manifest (validated by \`BiomeManifestSchema\` at boot).
|
|
@@ -24,15 +63,20 @@ A first-party Xema **server biome** scaffolded by \`@xemahq/create-biome\`.
|
|
|
24
63
|
bootstrapped by \`XemaServiceModule.forBiome(...)\`.
|
|
25
64
|
- \`skills/\` — Skill folder bundles (\`<slug>/SKILL.md\`) seeded into the agent runtime.
|
|
26
65
|
- \`agents/\` — Agent definitions (\`<slug>.md\`); declare each in \`xema.agents[]\`.
|
|
27
|
-
- \`contributions/\` — \`*.contribution.json\` envelopes (capabilities,
|
|
66
|
+
- \`contributions/\` — \`*.contribution.json\` envelopes (capabilities, search
|
|
67
|
+
result-types, …). Ships a working \`${ctx.biomeId}.search-type.contribution.json\`
|
|
68
|
+
so this biome's content is renderable AND deep-linkable from search on day one.
|
|
28
69
|
- \`workspace-manifests/\` — \`<slug>.workspace.yaml\` agent workspace manifests.
|
|
29
70
|
|
|
30
|
-
##
|
|
71
|
+
## In-tree monorepo development
|
|
72
|
+
|
|
73
|
+
When this biome lives inside the Xema monorepo, wire it into the workspace
|
|
74
|
+
toolchain after scaffolding:
|
|
31
75
|
|
|
32
76
|
\`\`\`sh
|
|
33
77
|
# from the monorepo root
|
|
34
78
|
pnpm install
|
|
35
|
-
node tooling/codegen/generate-service-bootstrap.mjs #
|
|
79
|
+
node tooling/codegen/generate-service-bootstrap.mjs # sync the bootstrap descriptor
|
|
36
80
|
pnpm refresh # openapi -> client -> build
|
|
37
81
|
\`\`\`
|
|
38
82
|
`,
|
|
@@ -52,11 +96,102 @@ are server-resolved.
|
|
|
52
96
|
'agents/README.md': `# Agents
|
|
53
97
|
|
|
54
98
|
Drop agent definitions here, one \`<slug>.md\` per agent. Each agent MUST also
|
|
55
|
-
be declared in \`xema-biome.json\` under \`xema.agents[]\` (\`slug\` + \`mode\`)
|
|
56
|
-
|
|
57
|
-
|
|
99
|
+
be declared in \`xema-biome.json\` under \`xema.agents[]\` (\`slug\` + \`mode\`) — the
|
|
100
|
+
boot-time cross-validator enforces parity between the manifest roster and the
|
|
101
|
+
on-disk files. The \`agents/\` directory itself is discovered by on-disk presence.
|
|
102
|
+
`,
|
|
103
|
+
'contributions/README.md': `# Contributions
|
|
104
|
+
|
|
105
|
+
Drop contribution envelopes here, one \`<slug>.<kind>.contribution.json\` per
|
|
106
|
+
declaration. Every file has the SAME two-key envelope shape:
|
|
107
|
+
|
|
108
|
+
\`\`\`json
|
|
109
|
+
{ "kind": "<contribution-kind>", "manifest": { /* kind-specific manifest */ } }
|
|
110
|
+
\`\`\`
|
|
111
|
+
|
|
112
|
+
The file-name suffix and the \`kind\` field MUST agree — a mismatch fails the
|
|
113
|
+
biome's contribution sync at boot with the offending path. Provenance
|
|
114
|
+
(\`biome.id\` / \`biome.version\`) is NEVER declared inline: it is stamped from
|
|
115
|
+
\`xema-biome.json\` by the discovering host.
|
|
116
|
+
|
|
117
|
+
Every manifest is validated fail-fast against its kernel Zod schema at
|
|
118
|
+
biome-discovery time, so a malformed contribution never reaches a registry.
|
|
119
|
+
|
|
120
|
+
## What ships here already
|
|
121
|
+
|
|
122
|
+
- \`${ctx.biomeId}.search-type.contribution.json\` — the search RESULT-TYPE for
|
|
123
|
+
this biome's primary entity. It declares the \`(objectKind, docType)\` facet,
|
|
124
|
+
the render/route hints the generic search UI reads, the searchable-field set,
|
|
125
|
+
and the result-level authz mapping. Edit it to match your real entity; delete
|
|
126
|
+
it only if this biome indexes nothing.
|
|
127
|
+
|
|
128
|
+
## \`renderHints.routeTemplate\`
|
|
129
|
+
|
|
130
|
+
The route template turns a search hit into a real URL. Its placeholder
|
|
131
|
+
vocabulary is the CLOSED \`SearchRouteTemplateVariable\` set, split by WHO
|
|
132
|
+
supplies each value:
|
|
133
|
+
|
|
134
|
+
\`\`\`
|
|
135
|
+
platform-derived (always available, never declared):
|
|
136
|
+
orgId · projectId · docType · objectKind · sourceId · title
|
|
137
|
+
projected (YOU supply: declare in renderHints.routeParams AND stamp on
|
|
138
|
+
IndexableDocument.routeParams):
|
|
139
|
+
slug · containerSlug
|
|
140
|
+
\`\`\`
|
|
141
|
+
|
|
142
|
+
\`slug\` is your entity's own URL slug; \`containerSlug\` is the slug of the
|
|
143
|
+
collection a nested route puts it under (e.g. a knowledge space). Use them when
|
|
144
|
+
your frontend route is slug-addressed rather than id-addressed:
|
|
145
|
+
|
|
146
|
+
\`\`\`json
|
|
147
|
+
"renderHints": {
|
|
148
|
+
"routeTemplate": "/spaces/projects/{projectId}/docs/{containerSlug}/{slug}",
|
|
149
|
+
"routeParams": ["containerSlug", "slug"]
|
|
150
|
+
}
|
|
151
|
+
\`\`\`
|
|
152
|
+
|
|
153
|
+
A template must be a site-relative path (leading \`/\`, no whitespace, no \`//\`)
|
|
154
|
+
and may reference nothing else. Validation is fail-fast at parse time: an
|
|
155
|
+
unknown placeholder is rejected, a PROJECTED placeholder you did not declare in
|
|
156
|
+
\`routeParams\` is rejected (it could never expand), and a \`routeParams\` entry
|
|
157
|
+
the template never references is rejected as a dead declaration. At expansion
|
|
158
|
+
time a placeholder with no value yields NO url rather than a half-substituted,
|
|
159
|
+
broken link.
|
|
160
|
+
|
|
161
|
+
Point \`routeTemplate\` at the per-INSTANCE detail route whenever your biome has
|
|
162
|
+
one. When it genuinely has none, the biome's own registered surface (its list
|
|
163
|
+
page) is a better link than none — but NEVER invent a query parameter your UI
|
|
164
|
+
does not actually read: that produces a live-looking URL that silently ignores
|
|
165
|
+
the result the user clicked.
|
|
58
166
|
`,
|
|
59
|
-
|
|
167
|
+
[`contributions/${ctx.biomeId}.search-type.contribution.json`]: `${JSON.stringify({
|
|
168
|
+
$comment: `Search RESULT-TYPE for this biome's primary entity — replace the placeholder ` +
|
|
169
|
+
`values with your real ones. \`objectKind\` must be a XemaObjectKind member; use ` +
|
|
170
|
+
`the kind your entity IS, or the closest one when it has no kernel kind (docType ` +
|
|
171
|
+
`is the real discriminator, and the natural key is the (objectKind, docType) pair). ` +
|
|
172
|
+
`\`searchableFields\` must match the fields your producer actually projects onto the ` +
|
|
173
|
+
`IndexableDocument. \`routeTemplate\` resolves to the deeplink-only detail page the ` +
|
|
174
|
+
`paired web biome ships (\`navHidden: true\`, slug '${ctx.biomeId}/:id'), so the link ` +
|
|
175
|
+
`works on day one — if you scaffolded WITHOUT a web surface, add that route before ` +
|
|
176
|
+
`keeping this field, and drop it entirely rather than pointing it at a list page. ` +
|
|
177
|
+
`\`embeddingEligibleDefault\` is false: semantic embedding is opt-in, never inferred.`,
|
|
178
|
+
kind: 'search-type',
|
|
179
|
+
manifest: {
|
|
180
|
+
objectKind: 'artifact',
|
|
181
|
+
docType: `${snakeCase(ctx.biomeId)}_item`,
|
|
182
|
+
renderHints: {
|
|
183
|
+
label: ctx.displayName,
|
|
184
|
+
icon: 'layout-grid',
|
|
185
|
+
routeTemplate: `/spaces/projects/{projectId}/${ctx.biomeId}/{sourceId}`,
|
|
186
|
+
},
|
|
187
|
+
searchableFields: ['title', 'content', 'tags'],
|
|
188
|
+
embeddingEligibleDefault: false,
|
|
189
|
+
authz: {
|
|
190
|
+
resourceType: `${snakeCase(ctx.biomeId)}_item`,
|
|
191
|
+
defaultVisibility: 'org-shared',
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
}, null, 2)}\n`,
|
|
60
195
|
'workspace-manifests/README.md': `# Workspace manifests
|
|
61
196
|
|
|
62
197
|
Drop agent workspace manifests here, one \`<slug>.workspace.yaml\` per agent
|
|
@@ -68,10 +203,6 @@ function serverApiFiles(ctx) {
|
|
|
68
203
|
const apiName = apiNameForBiome(ctx.biomeId);
|
|
69
204
|
const apiDir = `api/${apiName}`;
|
|
70
205
|
const constName = `${constCase(apiName)}_BOOTSTRAP`;
|
|
71
|
-
const pascal = pascalize(ctx.biomeId);
|
|
72
|
-
const featureModule = `${pascal}Module`;
|
|
73
|
-
const featureController = `${pascal}Controller`;
|
|
74
|
-
const featureService = `${pascal}Service`;
|
|
75
206
|
return {
|
|
76
207
|
[`${apiDir}/package.json`]: `${JSON.stringify({
|
|
77
208
|
name: apiName,
|
|
@@ -86,6 +217,7 @@ function serverApiFiles(ctx) {
|
|
|
86
217
|
lint: 'eslint "src/**/*.ts"',
|
|
87
218
|
'lint:fix': 'eslint "src/**/*.ts" --fix',
|
|
88
219
|
typecheck: 'tsc --noEmit',
|
|
220
|
+
test: 'nest build && node --test "test/*.test.mjs"',
|
|
89
221
|
openapi: 'NODE_PATH=node_modules xema-openapi',
|
|
90
222
|
'client:generate': 'xema-client-generate',
|
|
91
223
|
},
|
|
@@ -159,20 +291,24 @@ function serverApiFiles(ctx) {
|
|
|
159
291
|
],
|
|
160
292
|
},
|
|
161
293
|
}, null, 2)}\n`,
|
|
294
|
+
[`${apiDir}/eslint.config.mjs`]: ESLINT_CONFIG,
|
|
162
295
|
[`${apiDir}/.gitignore`]: `node_modules
|
|
163
296
|
dist
|
|
164
297
|
.turbo
|
|
165
298
|
openapi.*.json
|
|
166
299
|
`,
|
|
300
|
+
[`${apiDir}/test/manifest.test.mjs`]: renderManifestTest(ctx),
|
|
301
|
+
[`${apiDir}/test/capabilities.test.mjs`]: renderCapabilitiesTest(ctx, apiName),
|
|
167
302
|
[`${apiDir}/src/generated/${apiName}.bootstrap.generated.ts`]: renderBootstrapPlaceholder(apiName, constName, `${ctx.displayName} API`, ctx.biomeId),
|
|
168
303
|
[`${apiDir}/src/main.ts`]: renderMainTs(apiName, ctx),
|
|
169
|
-
[`${apiDir}/src/app.module.ts`]: renderAppModule(apiName, constName
|
|
304
|
+
[`${apiDir}/src/app.module.ts`]: renderAppModule(apiName, constName),
|
|
170
305
|
[`${apiDir}/src/config/index.ts`]: renderConfigIndex(),
|
|
171
306
|
[`${apiDir}/src/health/health.module.ts`]: HEALTH_MODULE,
|
|
172
307
|
[`${apiDir}/src/health/health.controller.ts`]: HEALTH_CONTROLLER,
|
|
173
|
-
[`${apiDir}/src/${
|
|
174
|
-
[`${apiDir}/src/${
|
|
175
|
-
[`${apiDir}/src/${
|
|
308
|
+
[`${apiDir}/src/${FEATURE_DIR}/${FEATURE_DIR}.module.ts`]: renderFeatureModule(),
|
|
309
|
+
[`${apiDir}/src/${FEATURE_DIR}/${FEATURE_DIR}.controller.ts`]: renderFeatureController(ctx),
|
|
310
|
+
[`${apiDir}/src/${FEATURE_DIR}/${FEATURE_DIR}.service.ts`]: renderFeatureService(),
|
|
311
|
+
[`${apiDir}/src/${FEATURE_DIR}/dto/${SAMPLE_RESOURCE}.dto.ts`]: renderItemDto(),
|
|
176
312
|
};
|
|
177
313
|
}
|
|
178
314
|
function renderBootstrapPlaceholder(apiName, constName, displayName, biomeId) {
|
|
@@ -196,6 +332,9 @@ export const ${constName}: BiomeServiceDescriptor = {
|
|
|
196
332
|
displayName: '${displayName}',
|
|
197
333
|
requiresServices: ['identity-api'],
|
|
198
334
|
exposesCapabilities: [],
|
|
335
|
+
biomeId: '${biomeId}',
|
|
336
|
+
biomeVersion: '0.1.0',
|
|
337
|
+
capabilityDomain: '${biomeId}',
|
|
199
338
|
};
|
|
200
339
|
`;
|
|
201
340
|
}
|
|
@@ -218,11 +357,9 @@ void bootstrapXemaService({
|
|
|
218
357
|
});
|
|
219
358
|
`;
|
|
220
359
|
}
|
|
221
|
-
function renderAppModule(apiName, constName
|
|
222
|
-
const featureFile =
|
|
223
|
-
|
|
224
|
-
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
225
|
-
.toLowerCase();
|
|
360
|
+
function renderAppModule(apiName, constName) {
|
|
361
|
+
const featureFile = FEATURE_DIR;
|
|
362
|
+
const featureModule = FEATURE_MODULE_CLASS;
|
|
226
363
|
return `import { Module } from '@nestjs/common';
|
|
227
364
|
import { ConfigModule } from '@nestjs/config';
|
|
228
365
|
import { APP_INTERCEPTOR } from '@nestjs/core';
|
|
@@ -252,6 +389,44 @@ import { ${featureModule} } from './${featureFile}/${featureFile}.module';
|
|
|
252
389
|
export class AppModule {}
|
|
253
390
|
`;
|
|
254
391
|
}
|
|
392
|
+
const ESLINT_CONFIG = `import eslint from '@eslint/js';
|
|
393
|
+
import prettierConfig from 'eslint-config-prettier';
|
|
394
|
+
import tseslint from 'typescript-eslint';
|
|
395
|
+
|
|
396
|
+
export default tseslint.config(
|
|
397
|
+
{ ignores: ['dist/', 'node_modules/', 'src/generated/', 'test/', 'eslint.config.mjs'] },
|
|
398
|
+
eslint.configs.recommended,
|
|
399
|
+
...tseslint.configs.recommended,
|
|
400
|
+
prettierConfig,
|
|
401
|
+
{
|
|
402
|
+
files: ['src/**/*.ts'],
|
|
403
|
+
languageOptions: {
|
|
404
|
+
parserOptions: {
|
|
405
|
+
projectService: true,
|
|
406
|
+
tsconfigRootDir: import.meta.dirname,
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
rules: {
|
|
410
|
+
'@typescript-eslint/no-floating-promises': 'error',
|
|
411
|
+
'@typescript-eslint/no-misused-promises': 'error',
|
|
412
|
+
'@typescript-eslint/await-thenable': 'error',
|
|
413
|
+
'@typescript-eslint/no-explicit-any': 'error',
|
|
414
|
+
'@typescript-eslint/consistent-type-imports': [
|
|
415
|
+
'error',
|
|
416
|
+
{ prefer: 'type-imports', fixStyle: 'separate-type-imports' },
|
|
417
|
+
],
|
|
418
|
+
'@typescript-eslint/no-unused-vars': [
|
|
419
|
+
'error',
|
|
420
|
+
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrors: 'all' },
|
|
421
|
+
],
|
|
422
|
+
eqeqeq: ['error', 'always'],
|
|
423
|
+
curly: ['error', 'all'],
|
|
424
|
+
'no-console': ['error', { allow: ['warn', 'error'] }],
|
|
425
|
+
'no-duplicate-imports': 'error',
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
);
|
|
429
|
+
`;
|
|
255
430
|
function renderConfigIndex() {
|
|
256
431
|
return `import { registerAs } from '@nestjs/config';
|
|
257
432
|
|
|
@@ -275,88 +450,358 @@ import { HealthController } from './health.controller';
|
|
|
275
450
|
export class HealthModule {}
|
|
276
451
|
`;
|
|
277
452
|
const HEALTH_CONTROLLER = `import { Controller, Get } from '@nestjs/common';
|
|
278
|
-
import { XemaPublicRoute } from '@xemahq/xema-decorators';
|
|
453
|
+
import { PublicRouteReason, XemaPublicRoute } from '@xemahq/xema-decorators';
|
|
279
454
|
|
|
455
|
+
/**
|
|
456
|
+
* Probe endpoints. \`@XemaPublicRoute\` requires a closed-domain \`reason\` —
|
|
457
|
+
* public-by-default is not expressible, so every unauthenticated route says
|
|
458
|
+
* why it is one. These are also the only routes in the service that project no
|
|
459
|
+
* capability: a public probe is not an agent-callable operation.
|
|
460
|
+
*/
|
|
280
461
|
@Controller('health')
|
|
281
462
|
export class HealthController {
|
|
282
463
|
@Get('live')
|
|
283
|
-
@XemaPublicRoute()
|
|
464
|
+
@XemaPublicRoute({ reason: PublicRouteReason.K8sProbe })
|
|
284
465
|
live(): { status: 'ok' } {
|
|
285
466
|
return { status: 'ok' };
|
|
286
467
|
}
|
|
287
468
|
|
|
288
469
|
@Get('ready')
|
|
289
|
-
@XemaPublicRoute()
|
|
470
|
+
@XemaPublicRoute({ reason: PublicRouteReason.K8sProbe })
|
|
290
471
|
ready(): { status: 'ok' } {
|
|
291
472
|
return { status: 'ok' };
|
|
292
473
|
}
|
|
293
474
|
}
|
|
294
475
|
`;
|
|
295
|
-
function renderFeatureModule(
|
|
296
|
-
const file = biomeId;
|
|
476
|
+
function renderFeatureModule() {
|
|
297
477
|
return `import { Module } from '@nestjs/common';
|
|
298
478
|
|
|
299
|
-
import {
|
|
300
|
-
import {
|
|
479
|
+
import { ItemsController } from './items.controller';
|
|
480
|
+
import { ItemsService } from './items.service';
|
|
301
481
|
|
|
302
482
|
@Module({
|
|
303
|
-
controllers: [
|
|
304
|
-
providers: [
|
|
483
|
+
controllers: [ItemsController],
|
|
484
|
+
providers: [ItemsService],
|
|
305
485
|
})
|
|
306
|
-
export class ${
|
|
486
|
+
export class ${FEATURE_MODULE_CLASS} {}
|
|
307
487
|
`;
|
|
308
488
|
}
|
|
309
|
-
function renderFeatureController(
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
import {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
489
|
+
function renderFeatureController(ctx) {
|
|
490
|
+
return `import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common';
|
|
491
|
+
import {
|
|
492
|
+
ApiCreatedResponse,
|
|
493
|
+
ApiNoContentResponse,
|
|
494
|
+
ApiOkResponse,
|
|
495
|
+
ApiTags,
|
|
496
|
+
} from '@nestjs/swagger';
|
|
497
|
+
import {
|
|
498
|
+
ApiSurface,
|
|
499
|
+
XemaApiSurface,
|
|
500
|
+
XemaCapabilityHint,
|
|
501
|
+
XemaResource,
|
|
502
|
+
XemaRoute,
|
|
503
|
+
} from '@xemahq/xema-decorators';
|
|
504
|
+
|
|
505
|
+
import { CreateItemDto, ItemDto } from './dto/item.dto';
|
|
506
|
+
import { ItemsService } from './items.service';
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Resource controller for the \`${SAMPLE_RESOURCE}\` noun this biome owns.
|
|
510
|
+
*
|
|
511
|
+
* \`@XemaResource\` + \`@XemaRoute\` are the ONLY declarations needed to get
|
|
512
|
+
* capabilities: at boot, \`XemaServiceModule.forBiome\` stamps every public
|
|
513
|
+
* operation with \`x-xema-resource\` / \`x-xema-action\`, and the HTTP capability
|
|
514
|
+
* projector derives one capability per operation —
|
|
515
|
+
* \`<capabilityDomain>:<resource>.<verb>@1\` — registers it, and prunes any it
|
|
516
|
+
* no longer produces. The refs this file yields (domain \`${ctx.biomeId}\`, from
|
|
517
|
+
* \`xema.capabilityDomain\`):
|
|
518
|
+
*
|
|
519
|
+
* ${ctx.biomeId}:${SAMPLE_RESOURCE}.list@1 risk low
|
|
520
|
+
* ${ctx.biomeId}:${SAMPLE_RESOURCE}.read@1 risk low
|
|
521
|
+
* ${ctx.biomeId}:${SAMPLE_RESOURCE}.create@1 risk medium
|
|
522
|
+
* ${ctx.biomeId}:${SAMPLE_RESOURCE}.delete@1 risk high, requires approval
|
|
523
|
+
*
|
|
524
|
+
* Risk tiers come from the DECLARED verb→risk table, not a heuristic; an
|
|
525
|
+
* unrecognised verb resolves to \`high\` (never low), which over-gates visibly
|
|
526
|
+
* instead of silently un-gating. \`test/capabilities.test.mjs\` asserts this
|
|
527
|
+
* exact set, so adding or renaming a route without updating the expectation
|
|
528
|
+
* fails the build rather than quietly changing the biome's public surface.
|
|
529
|
+
*/
|
|
530
|
+
@ApiTags('${SAMPLE_RESOURCE_PLURAL}')
|
|
531
|
+
// The surface is the "which generated client, and is this agent-reachable?"
|
|
532
|
+
// axis, and it is REQUIRED: a route with no surface is unclassified, and the
|
|
533
|
+
// projector only ever derives from \`ApiSurface.Public\`. There is no default —
|
|
534
|
+
// a silently-private route that an author believed was public would be the
|
|
535
|
+
// worst kind of quiet failure.
|
|
536
|
+
@XemaApiSurface(ApiSurface.Public)
|
|
537
|
+
@XemaResource('${SAMPLE_RESOURCE}')
|
|
538
|
+
@Controller('${SAMPLE_RESOURCE_PLURAL}')
|
|
539
|
+
export class ItemsController {
|
|
540
|
+
constructor(private readonly itemsService: ItemsService) {}
|
|
541
|
+
|
|
542
|
+
@Get()
|
|
543
|
+
@XemaRoute({ action: 'list' })
|
|
544
|
+
@ApiOkResponse({ type: ItemDto, isArray: true })
|
|
545
|
+
list(): ItemDto[] {
|
|
546
|
+
return this.itemsService.list();
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
@Get(':itemId')
|
|
550
|
+
@XemaRoute({ action: 'read' })
|
|
551
|
+
@ApiOkResponse({ type: ItemDto })
|
|
552
|
+
read(@Param('itemId') itemId: string): ItemDto {
|
|
553
|
+
return this.itemsService.read(itemId);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
@Post()
|
|
557
|
+
@XemaRoute({ action: 'create' })
|
|
558
|
+
// The ONE knob for correcting a derived value. \`riskTier\` and
|
|
559
|
+
// \`requiresApproval\` are the other two; each defaults from the verb table,
|
|
560
|
+
// and the summary defaults to the OpenAPI description. Override only what is
|
|
561
|
+
// actually wrong — a hand-authored \`@XemaCapability\` always beats both, and
|
|
562
|
+
// is reserved for the rare operation that needs a curated v2 ref.
|
|
563
|
+
@XemaCapabilityHint({
|
|
564
|
+
summary:
|
|
565
|
+
'Create one ${SAMPLE_RESOURCE} in this biome. Prefer this over editing storage directly: it is the audited, authorized write path.',
|
|
566
|
+
})
|
|
567
|
+
@ApiCreatedResponse({ type: ItemDto })
|
|
568
|
+
create(@Body() dto: CreateItemDto): ItemDto {
|
|
569
|
+
return this.itemsService.create(dto);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
@Delete(':itemId')
|
|
573
|
+
@XemaRoute({ action: 'delete' })
|
|
574
|
+
@ApiNoContentResponse()
|
|
575
|
+
remove(@Param('itemId') itemId: string): void {
|
|
576
|
+
this.itemsService.remove(itemId);
|
|
329
577
|
}
|
|
330
578
|
}
|
|
331
579
|
`;
|
|
332
580
|
}
|
|
333
|
-
function
|
|
334
|
-
return
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
581
|
+
function renderItemDto() {
|
|
582
|
+
return `import { ApiProperty } from '@nestjs/swagger';
|
|
583
|
+
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Sample domain type — replace with the biome's real one. The DTO is not
|
|
587
|
+
* decoration: the capability projector derives each capability's input and
|
|
588
|
+
* output JSON Schema from the OpenAPI operation, so an untyped body produces a
|
|
589
|
+
* capability an agent cannot call correctly.
|
|
590
|
+
*/
|
|
591
|
+
export class ItemDto {
|
|
592
|
+
@ApiProperty({ description: 'Stable identifier of the ${SAMPLE_RESOURCE}.' })
|
|
593
|
+
id!: string;
|
|
340
594
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
biome!: string;
|
|
595
|
+
@ApiProperty({ description: 'Human-readable title.' })
|
|
596
|
+
title!: string;
|
|
597
|
+
}
|
|
345
598
|
|
|
346
|
-
|
|
347
|
-
|
|
599
|
+
export class CreateItemDto {
|
|
600
|
+
@ApiProperty({ description: 'Human-readable title.', maxLength: 200 })
|
|
601
|
+
@IsString()
|
|
602
|
+
@IsNotEmpty()
|
|
603
|
+
@MaxLength(200)
|
|
604
|
+
title!: string;
|
|
605
|
+
}
|
|
606
|
+
`;
|
|
348
607
|
}
|
|
608
|
+
function renderFeatureService() {
|
|
609
|
+
return `import { Injectable, NotFoundException } from '@nestjs/common';
|
|
349
610
|
|
|
611
|
+
import { CreateItemDto, ItemDto } from './dto/item.dto';
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Sample in-memory store — replace with the biome's real persistence. Kept
|
|
615
|
+
* deliberately trivial so a freshly-scaffolded biome builds and tests with no
|
|
616
|
+
* database: the scaffold's job is to prove the capability/render/deep-link
|
|
617
|
+
* wiring, not to pick a storage engine for you.
|
|
618
|
+
*/
|
|
350
619
|
@Injectable()
|
|
351
|
-
export class
|
|
352
|
-
|
|
353
|
-
|
|
620
|
+
export class ItemsService {
|
|
621
|
+
private readonly items = new Map<string, ItemDto>();
|
|
622
|
+
private nextId = 1;
|
|
623
|
+
|
|
624
|
+
list(): ItemDto[] {
|
|
625
|
+
return [...this.items.values()];
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
read(itemId: string): ItemDto {
|
|
629
|
+
const found = this.items.get(itemId);
|
|
630
|
+
if (found === undefined) {
|
|
631
|
+
throw new NotFoundException(\`${SAMPLE_RESOURCE} "\${itemId}" not found\`);
|
|
632
|
+
}
|
|
633
|
+
return found;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
create(dto: CreateItemDto): ItemDto {
|
|
637
|
+
const id = String(this.nextId);
|
|
638
|
+
this.nextId += 1;
|
|
639
|
+
const created: ItemDto = { id, title: dto.title };
|
|
640
|
+
this.items.set(id, created);
|
|
641
|
+
return created;
|
|
354
642
|
}
|
|
643
|
+
|
|
644
|
+
remove(itemId: string): void {
|
|
645
|
+
if (!this.items.delete(itemId)) {
|
|
646
|
+
throw new NotFoundException(\`${SAMPLE_RESOURCE} "\${itemId}" not found\`);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
`;
|
|
355
651
|
}
|
|
652
|
+
function renderManifestTest(ctx) {
|
|
653
|
+
return `import assert from 'node:assert/strict';
|
|
654
|
+
import { readFileSync } from 'node:fs';
|
|
655
|
+
import { dirname, join } from 'node:path';
|
|
656
|
+
import { test } from 'node:test';
|
|
657
|
+
import { fileURLToPath } from 'node:url';
|
|
658
|
+
|
|
659
|
+
import { BiomeManifestSchema } from '@xemahq/kernel-contracts/biome';
|
|
660
|
+
import {
|
|
661
|
+
SearchTypeContributionManifestSchema,
|
|
662
|
+
assertValidSearchRouteTemplate,
|
|
663
|
+
} from '@xemahq/kernel-contracts/search';
|
|
664
|
+
|
|
665
|
+
// test/ -> api/<api>/ -> api/ -> biome root
|
|
666
|
+
const BIOME_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
|
|
667
|
+
|
|
668
|
+
const readJson = (rel) => JSON.parse(readFileSync(join(BIOME_ROOT, rel), 'utf8'));
|
|
669
|
+
|
|
670
|
+
test('xema-biome.json validates against the kernel BiomeManifestSchema', () => {
|
|
671
|
+
const result = BiomeManifestSchema.safeParse(readJson('xema-biome.json'));
|
|
672
|
+
assert.equal(
|
|
673
|
+
result.success,
|
|
674
|
+
true,
|
|
675
|
+
result.success ? '' : JSON.stringify(result.error.issues, null, 2),
|
|
676
|
+
);
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
test('the manifest declares the capability domain its routes project into', () => {
|
|
680
|
+
const manifest = readJson('xema-biome.json');
|
|
681
|
+
// Without a declared domain the projector falls back to xema.id, which is
|
|
682
|
+
// correct but invisible. Declaring it keeps the capability vocabulary a
|
|
683
|
+
// one-line edit instead of a documentation hunt.
|
|
684
|
+
assert.equal(manifest.xema.capabilityDomain, '${ctx.biomeId}');
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
test('the search-type contribution validates and its routeTemplate is well-formed', () => {
|
|
688
|
+
const envelope = readJson('contributions/${ctx.biomeId}.search-type.contribution.json');
|
|
689
|
+
assert.equal(envelope.kind, 'search-type');
|
|
690
|
+
|
|
691
|
+
const result = SearchTypeContributionManifestSchema.safeParse(envelope.manifest);
|
|
692
|
+
assert.equal(
|
|
693
|
+
result.success,
|
|
694
|
+
true,
|
|
695
|
+
result.success ? '' : JSON.stringify(result.error.issues, null, 2),
|
|
696
|
+
);
|
|
697
|
+
|
|
698
|
+
// A deep link that only LOOKS clickable is worse than none: every
|
|
699
|
+
// placeholder must come from the closed SearchRouteTemplateVariable set.
|
|
700
|
+
assertValidSearchRouteTemplate(envelope.manifest.renderHints.routeTemplate);
|
|
701
|
+
});
|
|
702
|
+
`;
|
|
703
|
+
}
|
|
704
|
+
function renderCapabilitiesTest(ctx, apiName) {
|
|
705
|
+
return `import 'reflect-metadata';
|
|
706
|
+
|
|
707
|
+
import assert from 'node:assert/strict';
|
|
708
|
+
import { createRequire } from 'node:module';
|
|
709
|
+
import { after, before, test } from 'node:test';
|
|
710
|
+
|
|
711
|
+
// Loaded through \`createRequire\`, not \`import\`: \`nest build\` emits CommonJS,
|
|
712
|
+
// and a decorated class's exports are not statically analysable, so an ESM
|
|
713
|
+
// named import of the compiled module fails. Requiring the framework packages
|
|
714
|
+
// the same way also guarantees the test and the compiled controller share ONE
|
|
715
|
+
// instance of Nest — two would mean two metadata registries and an empty scan.
|
|
716
|
+
const require = createRequire(import.meta.url);
|
|
717
|
+
|
|
718
|
+
const { DiscoveryService, MetadataScanner, ModulesContainer, NestFactory } = require('@nestjs/core');
|
|
719
|
+
const { DocumentBuilder, SwaggerModule } = require('@nestjs/swagger');
|
|
720
|
+
const { scanApiSurfaces, stampRouteExtensions } = require('@xemahq/xema-decorators');
|
|
721
|
+
const { deriveHttpCapabilities } = require('@xemahq/xema-service-nest');
|
|
722
|
+
|
|
723
|
+
const { ItemsModule } = require('../dist/${FEATURE_DIR}/${FEATURE_DIR}.module.js');
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* The capabilities this biome MUST project, and the risk tier each derives.
|
|
727
|
+
* Hand-maintained on purpose: this is the assertion, not the derivation. Change
|
|
728
|
+
* a route and this list must change with it — deliberately, and in review.
|
|
729
|
+
*/
|
|
730
|
+
const EXPECTED = {
|
|
731
|
+
'${ctx.biomeId}:${SAMPLE_RESOURCE}.list@1': { riskTier: 'low', requiresApproval: false },
|
|
732
|
+
'${ctx.biomeId}:${SAMPLE_RESOURCE}.read@1': { riskTier: 'low', requiresApproval: false },
|
|
733
|
+
'${ctx.biomeId}:${SAMPLE_RESOURCE}.create@1': { riskTier: 'medium', requiresApproval: false },
|
|
734
|
+
'${ctx.biomeId}:${SAMPLE_RESOURCE}.delete@1': { riskTier: 'high', requiresApproval: true },
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
let derivation;
|
|
738
|
+
let app;
|
|
739
|
+
|
|
740
|
+
before(async () => {
|
|
741
|
+
// The feature module ALONE — no XemaServiceModule, so no registry, no
|
|
742
|
+
// identity bootstrap, no network. Capability derivation reads decorator
|
|
743
|
+
// metadata and the OpenAPI document; neither needs a booted platform.
|
|
744
|
+
app = await NestFactory.create(ItemsModule, { logger: false });
|
|
745
|
+
const doc = SwaggerModule.createDocument(
|
|
746
|
+
app,
|
|
747
|
+
new DocumentBuilder().setTitle('${apiName}').setVersion('0.1.0').build(),
|
|
748
|
+
{ operationIdFactory: (controllerKey, methodKey) => \`\${controllerKey}_\${methodKey}\` },
|
|
749
|
+
);
|
|
750
|
+
const scan = scanApiSurfaces(
|
|
751
|
+
new DiscoveryService(app.get(ModulesContainer, { strict: false })),
|
|
752
|
+
new MetadataScanner(),
|
|
753
|
+
);
|
|
754
|
+
stampRouteExtensions(doc, scan.projections);
|
|
755
|
+
|
|
756
|
+
derivation = deriveHttpCapabilities({
|
|
757
|
+
doc,
|
|
758
|
+
serviceName: '${apiName}',
|
|
759
|
+
domain: '${ctx.biomeId}',
|
|
760
|
+
biomeId: '${ctx.biomeId}',
|
|
761
|
+
biomeVersion: '0.1.0',
|
|
762
|
+
sourceKey: 'http-operations',
|
|
763
|
+
authoredRefs: new Set(),
|
|
764
|
+
});
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
after(async () => {
|
|
768
|
+
await app?.close();
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
test('every route projects a capability — none authored by hand', () => {
|
|
772
|
+
const derived = [...derivation.capabilities.keys()].sort();
|
|
773
|
+
assert.deepEqual(derived, Object.keys(EXPECTED).sort());
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
test('risk tier and approval gating are derived from the declared verb table', () => {
|
|
777
|
+
for (const [ref, expected] of Object.entries(EXPECTED)) {
|
|
778
|
+
const capability = derivation.capabilities.get(ref);
|
|
779
|
+
assert.ok(capability, \`missing capability \${ref}\`);
|
|
780
|
+
assert.equal(capability.riskTier, expected.riskTier, ref);
|
|
781
|
+
assert.equal(capability.requiresApproval, expected.requiresApproval, ref);
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
test('each capability carries an invocation binding and IO schemas', () => {
|
|
786
|
+
for (const ref of Object.keys(EXPECTED)) {
|
|
787
|
+
const capability = derivation.capabilities.get(ref);
|
|
788
|
+
// Without a binding the ref is registered but unroutable — an agent could
|
|
789
|
+
// discover it and never call it.
|
|
790
|
+
assert.ok(capability.invocation, \`\${ref} has no invocation binding\`);
|
|
791
|
+
assert.equal(capability.provenance.biomeId, '${ctx.biomeId}');
|
|
792
|
+
assert.ok(capability.inputSchema, \`\${ref} has no input schema\`);
|
|
793
|
+
}
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
test('the @XemaCapabilityHint summary wins over the derived one', () => {
|
|
797
|
+
const created = derivation.capabilities.get('${ctx.biomeId}:${SAMPLE_RESOURCE}.create@1');
|
|
798
|
+
assert.match(created.summary, /audited, authorized write path/);
|
|
799
|
+
});
|
|
356
800
|
`;
|
|
357
801
|
}
|
|
358
802
|
export function webBiomeFiles(ctx) {
|
|
359
803
|
const pageComponent = `${pascalize(ctx.biomeId)}Page`;
|
|
804
|
+
const detailComponent = `${pascalize(ctx.biomeId)}DetailPage`;
|
|
360
805
|
const navId = ctx.biomeId;
|
|
361
806
|
return {
|
|
362
807
|
'package.json': `${JSON.stringify({
|
|
@@ -391,8 +836,9 @@ export function webBiomeFiles(ctx) {
|
|
|
391
836
|
},
|
|
392
837
|
}, null, 2)}\n`,
|
|
393
838
|
'tsconfig.json': WEB_TSCONFIG,
|
|
394
|
-
'src/index.tsx': renderWebIndex(ctx, pageComponent, navId),
|
|
839
|
+
'src/index.tsx': renderWebIndex(ctx, pageComponent, detailComponent, navId),
|
|
395
840
|
[`src/pages/${pageComponent}.tsx`]: renderWebPage(ctx, pageComponent),
|
|
841
|
+
[`src/pages/${detailComponent}.tsx`]: renderWebDetailPage(ctx, detailComponent),
|
|
396
842
|
};
|
|
397
843
|
}
|
|
398
844
|
const WEB_TSCONFIG = `{
|
|
@@ -423,7 +869,7 @@ const WEB_TSCONFIG = `{
|
|
|
423
869
|
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
|
424
870
|
}
|
|
425
871
|
`;
|
|
426
|
-
function renderWebIndex(ctx, pageComponent, navId) {
|
|
872
|
+
function renderWebIndex(ctx, pageComponent, detailComponent, navId) {
|
|
427
873
|
return `import { defineWebBiome } from '@xemahq/ui-kernel';
|
|
428
874
|
import { LayoutGrid } from 'lucide-react';
|
|
429
875
|
|
|
@@ -455,12 +901,66 @@ const ${camelize(ctx.webBiomeId)}Biome = defineWebBiome({
|
|
|
455
901
|
scope: 'project',
|
|
456
902
|
load: () => import('./pages/${pageComponent}'),
|
|
457
903
|
},
|
|
904
|
+
{
|
|
905
|
+
// Deeplink-only detail route. This is the LANDING TARGET of the server
|
|
906
|
+
// biome's search-type \`renderHints.routeTemplate\`
|
|
907
|
+
// (\`/spaces/projects/{projectId}/${navId}/{sourceId}\`) — without it the
|
|
908
|
+
// template would expand to a real-looking URL that 404s, which is worse
|
|
909
|
+
// than shipping no deep link at all. \`navHidden\` keeps it out of the
|
|
910
|
+
// menu: it is reachable from search and from chat, never browsed to.
|
|
911
|
+
slug: '${navId}/:id',
|
|
912
|
+
label: '${ctx.navLabel} detail',
|
|
913
|
+
icon: LayoutGrid,
|
|
914
|
+
category: 'build',
|
|
915
|
+
scope: 'project',
|
|
916
|
+
navHidden: true,
|
|
917
|
+
load: () => import('./pages/${detailComponent}'),
|
|
918
|
+
},
|
|
458
919
|
],
|
|
459
920
|
});
|
|
460
921
|
|
|
461
922
|
export default ${camelize(ctx.webBiomeId)}Biome;
|
|
462
923
|
`;
|
|
463
924
|
}
|
|
925
|
+
function renderWebDetailPage(ctx, detailComponent) {
|
|
926
|
+
return `import { useBiomeRouteParams } from '@/lib/biomes/biome-host-next';
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* Deeplink-only detail page for one \`${ctx.biomeId}\` record.
|
|
930
|
+
*
|
|
931
|
+
* Reached by expanding the search-type contribution's \`routeTemplate\`, whose
|
|
932
|
+
* \`{sourceId}\` placeholder binds to the \`:id\` segment declared on this page's
|
|
933
|
+
* slug. Both sides name the same closed vocabulary, so a search hit, a chat
|
|
934
|
+
* citation and a manual URL all land here.
|
|
935
|
+
*/
|
|
936
|
+
export default function ${detailComponent}(): JSX.Element {
|
|
937
|
+
const { projectId, id } = useBiomeRouteParams<{
|
|
938
|
+
projectId?: string;
|
|
939
|
+
id?: string;
|
|
940
|
+
}>();
|
|
941
|
+
|
|
942
|
+
return (
|
|
943
|
+
<div className="p-6">
|
|
944
|
+
<h1 className="text-xl font-semibold">${ctx.navLabel} detail</h1>
|
|
945
|
+
<p className="mt-2 text-sm text-muted-foreground">
|
|
946
|
+
Replace this with the real record view — fetch by id from the biome's
|
|
947
|
+
API and render it.
|
|
948
|
+
</p>
|
|
949
|
+
<dl className="mt-4 text-sm">
|
|
950
|
+
<dt className="font-medium">Record id</dt>
|
|
951
|
+
<dd>
|
|
952
|
+
<code>{id ?? '(missing — this route requires an id)'}</code>
|
|
953
|
+
</dd>
|
|
954
|
+
<dt className="mt-2 font-medium">Project scope</dt>
|
|
955
|
+
<dd>
|
|
956
|
+
<code>{projectId ?? '(none — open from a project)'}</code>
|
|
957
|
+
</dd>
|
|
958
|
+
</dl>
|
|
959
|
+
</div>
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
`;
|
|
963
|
+
}
|
|
464
964
|
function renderWebPage(ctx, pageComponent) {
|
|
465
965
|
return `import { useBiomeRouteParams } from '@/lib/biomes/biome-host-next';
|
|
466
966
|
|
|
@@ -488,6 +988,425 @@ export default function ${pageComponent}(): JSX.Element {
|
|
|
488
988
|
}
|
|
489
989
|
`;
|
|
490
990
|
}
|
|
991
|
+
export function connectorBiomeFiles(ctx) {
|
|
992
|
+
const providerKey = ctx.biomeId.replace(/^connector-/, '');
|
|
993
|
+
const connectorKey = providerKey.replaceAll('-', '_').toUpperCase();
|
|
994
|
+
const camel = camelize(providerKey);
|
|
995
|
+
return {
|
|
996
|
+
'README.md': renderConnectorReadme(ctx, providerKey, connectorKey),
|
|
997
|
+
'package.json': `${JSON.stringify({
|
|
998
|
+
name: `@xemahq/biomes-${ctx.biomeId}`,
|
|
999
|
+
version: '0.1.0',
|
|
1000
|
+
description: ctx.description,
|
|
1001
|
+
license: 'LicenseRef-Xema-BSL-1.1',
|
|
1002
|
+
private: true,
|
|
1003
|
+
type: 'module',
|
|
1004
|
+
main: 'dist/connector-adapters/index.js',
|
|
1005
|
+
files: ['dist', 'xema-biome.json'],
|
|
1006
|
+
exports: {
|
|
1007
|
+
'./connector-adapters': {
|
|
1008
|
+
types: './dist/connector-adapters/index.d.ts',
|
|
1009
|
+
default: './dist/connector-adapters/index.js',
|
|
1010
|
+
},
|
|
1011
|
+
'./package.json': './package.json',
|
|
1012
|
+
},
|
|
1013
|
+
scripts: {
|
|
1014
|
+
clean: 'rm -rf dist',
|
|
1015
|
+
build: 'tsc -p tsconfig.json',
|
|
1016
|
+
typecheck: 'tsc --noEmit',
|
|
1017
|
+
format: 'prettier --write "src/**/*.ts"',
|
|
1018
|
+
test: 'tsc -p tsconfig.json && node --test "tests/*.test.mjs"',
|
|
1019
|
+
},
|
|
1020
|
+
dependencies: {
|
|
1021
|
+
'@xemahq/biome-sdk': 'catalog:',
|
|
1022
|
+
'@xemahq/kernel-contracts': 'catalog:',
|
|
1023
|
+
},
|
|
1024
|
+
devDependencies: {
|
|
1025
|
+
'@types/node': 'catalog:',
|
|
1026
|
+
prettier: 'catalog:',
|
|
1027
|
+
typescript: 'catalog:',
|
|
1028
|
+
},
|
|
1029
|
+
}, null, 2)}\n`,
|
|
1030
|
+
'tsconfig.json': `${JSON.stringify({
|
|
1031
|
+
extends: '../../tsconfig.base.json',
|
|
1032
|
+
compilerOptions: {
|
|
1033
|
+
outDir: 'dist',
|
|
1034
|
+
rootDir: 'src',
|
|
1035
|
+
module: 'ESNext',
|
|
1036
|
+
moduleResolution: 'Bundler',
|
|
1037
|
+
},
|
|
1038
|
+
include: ['src/**/*.ts'],
|
|
1039
|
+
exclude: ['node_modules', 'dist'],
|
|
1040
|
+
}, null, 2)}\n`,
|
|
1041
|
+
'.gitignore': `node_modules
|
|
1042
|
+
dist
|
|
1043
|
+
.turbo
|
|
1044
|
+
`,
|
|
1045
|
+
'src/connector-adapters/index.ts': renderConnectorAdapter(ctx, providerKey, connectorKey, camel),
|
|
1046
|
+
'tests/provider.test.mjs': renderConnectorTest(ctx, providerKey, connectorKey, camel),
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
function renderConnectorReadme(ctx, providerKey, connectorKey) {
|
|
1050
|
+
return `# ${ctx.displayName}
|
|
1051
|
+
|
|
1052
|
+
${ctx.description}
|
|
1053
|
+
|
|
1054
|
+
A Xema **connector biome** scaffolded by \`@xemahq/create-biome\`.
|
|
1055
|
+
|
|
1056
|
+
## What you already have, without authoring anything
|
|
1057
|
+
|
|
1058
|
+
One \`ConnectorAdapterModule\` — the descriptors, the webhook verifier, the event
|
|
1059
|
+
mapper, the deterministic idempotency extractor and the onboarding manifest — is
|
|
1060
|
+
the ONLY thing this biome declares. From it the platform derives:
|
|
1061
|
+
|
|
1062
|
+
- **connector capabilities**, filed under the \`${ctx.biomeId}\` domain;
|
|
1063
|
+
- the **connection-setup flow**, rendered from \`onboarding.fields\` (no bespoke UI);
|
|
1064
|
+
- **credential custody** — the secret is sealed by the credential broker and
|
|
1065
|
+
resolved at use; this biome never reads or stores it;
|
|
1066
|
+
- **OAuth**, when the provider declares \`appAuthKind\` other than \`none\` plus its
|
|
1067
|
+
authorize/token endpoints;
|
|
1068
|
+
- **preflight**, from the declared credential kind + scopes.
|
|
1069
|
+
|
|
1070
|
+
You never write a capability descriptor, a connect dialog, or a token store.
|
|
1071
|
+
|
|
1072
|
+
## Constraints (CI-enforced)
|
|
1073
|
+
|
|
1074
|
+
\`tooling/boundaries/check-connector-biome.mjs\` requires a \`kind: "connector"\`
|
|
1075
|
+
biome to contribute ≥1 \`connector-adapter\`, to ship **no** product surface (no
|
|
1076
|
+
web target, no pages, no BFF), and to depend on or extend **no** other biome —
|
|
1077
|
+
so any app can reuse it. The scaffold satisfies all three; keep it that way.
|
|
1078
|
+
|
|
1079
|
+
## Make it real
|
|
1080
|
+
|
|
1081
|
+
1. Replace the \`${providerKey}\` provider/connector descriptors with the real
|
|
1082
|
+
provider's identity, category, auth kind and scopes.
|
|
1083
|
+
2. Replace the verifier with the provider's ACTUAL signature scheme, and compare
|
|
1084
|
+
in constant time. Never accept an unverified delivery.
|
|
1085
|
+
3. Replace the event mapper: return \`ok(null)\` for events you deliberately drop,
|
|
1086
|
+
and \`err(adapterError('malformed-payload', …))\` for ones that are broken.
|
|
1087
|
+
Never forward a fabricated envelope.
|
|
1088
|
+
4. Keep the idempotency key DETERMINISTIC — the provider's delivery id, or a
|
|
1089
|
+
hash of the canonical body. Never time-based, never random.
|
|
1090
|
+
5. Declare every credential field the provider needs in \`onboarding.fields\`;
|
|
1091
|
+
that list IS the connect form.
|
|
1092
|
+
|
|
1093
|
+
\`\`\`sh
|
|
1094
|
+
pnpm build && pnpm test
|
|
1095
|
+
\`\`\`
|
|
1096
|
+
|
|
1097
|
+
The connector wire key is \`${connectorKey}\`.
|
|
1098
|
+
`;
|
|
1099
|
+
}
|
|
1100
|
+
function renderConnectorAdapter(ctx, providerKey, connectorKey, camel) {
|
|
1101
|
+
const constPrefix = constCase(providerKey);
|
|
1102
|
+
return `import { createHash, timingSafeEqual } from 'node:crypto';
|
|
1103
|
+
|
|
1104
|
+
import {
|
|
1105
|
+
adapterError,
|
|
1106
|
+
defineConnectorAdapter,
|
|
1107
|
+
err,
|
|
1108
|
+
ok,
|
|
1109
|
+
type ConnectorAdapterModule,
|
|
1110
|
+
type EventMapper,
|
|
1111
|
+
type IdempotencyKeyExtractor,
|
|
1112
|
+
type MappedEnvelope,
|
|
1113
|
+
type WebhookVerifier,
|
|
1114
|
+
} from '@xemahq/biome-sdk/adapter';
|
|
1115
|
+
import {
|
|
1116
|
+
ConnectorOnboardingKind,
|
|
1117
|
+
CredentialFieldTransform,
|
|
1118
|
+
CredentialFieldType,
|
|
1119
|
+
CredentialKind,
|
|
1120
|
+
ProviderAppAuthKind,
|
|
1121
|
+
ProviderOrigin,
|
|
1122
|
+
type ConnectorDescriptor,
|
|
1123
|
+
type ProviderDescriptor,
|
|
1124
|
+
type ProviderOnboardingManifest,
|
|
1125
|
+
} from '@xemahq/kernel-contracts/connector';
|
|
1126
|
+
|
|
1127
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1128
|
+
// ${ctx.displayName} — ConnectorAdapterModule
|
|
1129
|
+
//
|
|
1130
|
+
// This module is the WHOLE biome. Everything a connector gets for free —
|
|
1131
|
+
// capabilities, the connect flow, credential custody, OAuth, preflight — is
|
|
1132
|
+
// derived from the declarations below. There is no capability descriptor to
|
|
1133
|
+
// write and no webhook route to mount: inbound deliveries terminate at the
|
|
1134
|
+
// single connector-gateway ingress edge, which routes them here by
|
|
1135
|
+
// \`provider\`.
|
|
1136
|
+
//
|
|
1137
|
+
// Replace every placeholder marked TODO with the real provider's behaviour.
|
|
1138
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1139
|
+
|
|
1140
|
+
/** Provider slug — the key the ingress edge routes deliveries by. */
|
|
1141
|
+
export const ${constPrefix}_PROVIDER_KEY = '${providerKey}';
|
|
1142
|
+
|
|
1143
|
+
/** Human-readable provider name, shared by onboarding + both descriptors. */
|
|
1144
|
+
const ${constPrefix}_DISPLAY_NAME = '${ctx.displayName}';
|
|
1145
|
+
|
|
1146
|
+
/** Lucide-react icon name, shared by onboarding + both descriptors. */
|
|
1147
|
+
const ${constPrefix}_ICON_NAME = 'Plug';
|
|
1148
|
+
|
|
1149
|
+
const ${constPrefix}_DESCRIPTION = '${ctx.description.replaceAll("'", "\\'")}';
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* Biome-contributed adapter kind. NOT a member of the kernel's built-in set —
|
|
1153
|
+
* it is the canonical string this connector declares, and the value the
|
|
1154
|
+
* gateway routes on.
|
|
1155
|
+
*/
|
|
1156
|
+
export const ${constPrefix}_ADAPTER_KIND = '${providerKey}';
|
|
1157
|
+
|
|
1158
|
+
/** Header the provider stamps its signature on. TODO: use the real one. */
|
|
1159
|
+
const SIGNATURE_HEADER = 'x-${providerKey}-signature';
|
|
1160
|
+
|
|
1161
|
+
// ── Webhook verifier — constant-time, fail-closed ───────────────────────────
|
|
1162
|
+
|
|
1163
|
+
const ${camel}Verifier: WebhookVerifier = {
|
|
1164
|
+
signatureHeader: SIGNATURE_HEADER,
|
|
1165
|
+
// TODO: switch to the provider's real algorithm (e.g. 'hmac-sha256') and
|
|
1166
|
+
// verify over the RAW body bytes, not a re-serialized object.
|
|
1167
|
+
algorithm: 'none',
|
|
1168
|
+
secretSource: 'org-connection-secret',
|
|
1169
|
+
verify({ headers, secret }) {
|
|
1170
|
+
const provided = headers[SIGNATURE_HEADER];
|
|
1171
|
+
if (typeof provided !== 'string' || provided.length === 0) {
|
|
1172
|
+
return err(
|
|
1173
|
+
adapterError('verification-failed', \`missing \${SIGNATURE_HEADER} header\`),
|
|
1174
|
+
);
|
|
1175
|
+
}
|
|
1176
|
+
if (provided.length !== secret.length) {
|
|
1177
|
+
return err(adapterError('verification-failed', 'signature length mismatch'));
|
|
1178
|
+
}
|
|
1179
|
+
let equal: boolean;
|
|
1180
|
+
try {
|
|
1181
|
+
equal = timingSafeEqual(
|
|
1182
|
+
Buffer.from(provided, 'utf8'),
|
|
1183
|
+
Buffer.from(secret, 'utf8'),
|
|
1184
|
+
);
|
|
1185
|
+
} catch {
|
|
1186
|
+
return err(adapterError('verification-failed', 'signature decode failed'));
|
|
1187
|
+
}
|
|
1188
|
+
return equal
|
|
1189
|
+
? ok(undefined)
|
|
1190
|
+
: err(adapterError('verification-failed', 'signature mismatch'));
|
|
1191
|
+
},
|
|
1192
|
+
};
|
|
1193
|
+
|
|
1194
|
+
// ── Idempotency — deterministic, never time-based ───────────────────────────
|
|
1195
|
+
|
|
1196
|
+
/**
|
|
1197
|
+
* A retried delivery MUST produce the same key as its original, or the
|
|
1198
|
+
* platform will process it twice. Prefer the provider's own delivery id; fall
|
|
1199
|
+
* back to a hash of the canonical body so identical bytes dedup. An
|
|
1200
|
+
* underivable key is a hard error — never fabricate a unique one.
|
|
1201
|
+
*/
|
|
1202
|
+
const ${camel}IdempotencyKeyExtractor: IdempotencyKeyExtractor = ({
|
|
1203
|
+
rawBody,
|
|
1204
|
+
}) => {
|
|
1205
|
+
if (rawBody && typeof rawBody === 'object') {
|
|
1206
|
+
// TODO: read the provider's real delivery id field.
|
|
1207
|
+
const deliveryId = (rawBody as Record<string, unknown>)['id'];
|
|
1208
|
+
if (typeof deliveryId === 'string' && deliveryId.length > 0) {
|
|
1209
|
+
return \`${providerKey}:\${deliveryId}\`;
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
const canonical =
|
|
1213
|
+
typeof rawBody === 'string'
|
|
1214
|
+
? rawBody
|
|
1215
|
+
: rawBody instanceof Uint8Array
|
|
1216
|
+
? Buffer.from(rawBody).toString('utf8')
|
|
1217
|
+
: rawBody && typeof rawBody === 'object'
|
|
1218
|
+
? JSON.stringify(rawBody)
|
|
1219
|
+
: '';
|
|
1220
|
+
if (canonical.length === 0) {
|
|
1221
|
+
throw new Error(
|
|
1222
|
+
'${providerKey} webhook: cannot derive a deterministic idempotency key (no delivery id and empty body)',
|
|
1223
|
+
);
|
|
1224
|
+
}
|
|
1225
|
+
return \`${providerKey}:\${createHash('sha256').update(canonical, 'utf8').digest('hex')}\`;
|
|
1226
|
+
};
|
|
1227
|
+
|
|
1228
|
+
// ── Event mapper — provider payload → canonical envelope ────────────────────
|
|
1229
|
+
|
|
1230
|
+
const ${camel}EventMapper: EventMapper = {
|
|
1231
|
+
map({ rawEvent }) {
|
|
1232
|
+
if (!rawEvent || typeof rawEvent !== 'object') {
|
|
1233
|
+
return err(
|
|
1234
|
+
adapterError('malformed-payload', '${providerKey} body is not an object'),
|
|
1235
|
+
);
|
|
1236
|
+
}
|
|
1237
|
+
const payload = rawEvent as Record<string, unknown>;
|
|
1238
|
+
const externalId = payload['id'];
|
|
1239
|
+
if (typeof externalId !== 'string' || externalId.length === 0) {
|
|
1240
|
+
// TODO: decide per event type. Return ok(null) to record-and-drop an
|
|
1241
|
+
// event this connector deliberately does not model; return err(...) only
|
|
1242
|
+
// when the payload is genuinely malformed.
|
|
1243
|
+
return err(
|
|
1244
|
+
adapterError('malformed-payload', '${providerKey} event: string \`id\` required'),
|
|
1245
|
+
);
|
|
1246
|
+
}
|
|
1247
|
+
const envelope: MappedEnvelope = {
|
|
1248
|
+
// TODO: the canonical entity kind + event this connector produces.
|
|
1249
|
+
entityKind: 'record',
|
|
1250
|
+
event: '${providerKey}.record.received',
|
|
1251
|
+
payload,
|
|
1252
|
+
externalId,
|
|
1253
|
+
};
|
|
1254
|
+
return ok(envelope);
|
|
1255
|
+
},
|
|
1256
|
+
};
|
|
1257
|
+
|
|
1258
|
+
// ── Onboarding manifest — this list IS the connect form ─────────────────────
|
|
1259
|
+
|
|
1260
|
+
const ${camel}Onboarding: ProviderOnboardingManifest = {
|
|
1261
|
+
provider: ${constPrefix}_PROVIDER_KEY,
|
|
1262
|
+
displayName: ${constPrefix}_DISPLAY_NAME,
|
|
1263
|
+
description: ${constPrefix}_DESCRIPTION,
|
|
1264
|
+
iconName: ${constPrefix}_ICON_NAME,
|
|
1265
|
+
kind: ConnectorOnboardingKind.CREDENTIALS,
|
|
1266
|
+
supportedProjectBindingAdapterKinds: [],
|
|
1267
|
+
fields: [
|
|
1268
|
+
{
|
|
1269
|
+
key: 'apiKey',
|
|
1270
|
+
label: 'API key',
|
|
1271
|
+
type: CredentialFieldType.PASSWORD,
|
|
1272
|
+
required: true,
|
|
1273
|
+
isSecret: true,
|
|
1274
|
+
hint: 'Held by the credential broker and resolved at use — this biome never reads it.',
|
|
1275
|
+
transform: CredentialFieldTransform.TRIM,
|
|
1276
|
+
},
|
|
1277
|
+
],
|
|
1278
|
+
hint: 'TODO: tell the operator where to obtain the credential.',
|
|
1279
|
+
};
|
|
1280
|
+
|
|
1281
|
+
// ── Provider + connector descriptors ────────────────────────────────────────
|
|
1282
|
+
|
|
1283
|
+
export const providerDescriptor: ProviderDescriptor = {
|
|
1284
|
+
providerKey: ${constPrefix}_PROVIDER_KEY,
|
|
1285
|
+
displayName: ${constPrefix}_DISPLAY_NAME,
|
|
1286
|
+
description: ${constPrefix}_DESCRIPTION,
|
|
1287
|
+
iconName: ${constPrefix}_ICON_NAME,
|
|
1288
|
+
category: 'Other',
|
|
1289
|
+
// No shared platform app ⇒ every connection carries its own complete secret,
|
|
1290
|
+
// so the connector is usable with zero org-level setup. Switch to OAuth2 and
|
|
1291
|
+
// declare the authorize/token endpoints when the provider has a shared app.
|
|
1292
|
+
appAuthKind: ProviderAppAuthKind.None,
|
|
1293
|
+
origin: ProviderOrigin.Biome,
|
|
1294
|
+
allowOrgOverride: false,
|
|
1295
|
+
};
|
|
1296
|
+
|
|
1297
|
+
export const connectorDescriptors: readonly ConnectorDescriptor[] = [
|
|
1298
|
+
{
|
|
1299
|
+
connectorKey: '${connectorKey}',
|
|
1300
|
+
displayName: ${constPrefix}_DISPLAY_NAME,
|
|
1301
|
+
description: ${constPrefix}_DESCRIPTION,
|
|
1302
|
+
iconName: ${constPrefix}_ICON_NAME,
|
|
1303
|
+
providerKey: ${constPrefix}_PROVIDER_KEY,
|
|
1304
|
+
scopes: [],
|
|
1305
|
+
connectionCredentialKind: CredentialKind.ApiKey,
|
|
1306
|
+
adapterKind: ${constPrefix}_ADAPTER_KIND,
|
|
1307
|
+
},
|
|
1308
|
+
];
|
|
1309
|
+
|
|
1310
|
+
/**
|
|
1311
|
+
* The provider module. The gateway's loader imports \`default\` (or the named
|
|
1312
|
+
* \`connectorAdapters\` array) and registers every entry.
|
|
1313
|
+
*/
|
|
1314
|
+
export const ${camel}Provider: ConnectorAdapterModule = defineConnectorAdapter({
|
|
1315
|
+
adapterKind: ${constPrefix}_ADAPTER_KIND,
|
|
1316
|
+
provider: ${constPrefix}_PROVIDER_KEY,
|
|
1317
|
+
displayName: ${constPrefix}_DISPLAY_NAME,
|
|
1318
|
+
webhook: {
|
|
1319
|
+
verifier: ${camel}Verifier,
|
|
1320
|
+
eventMapper: ${camel}EventMapper,
|
|
1321
|
+
idempotencyKeyExtractor: ${camel}IdempotencyKeyExtractor,
|
|
1322
|
+
},
|
|
1323
|
+
credentialKind: CredentialKind.ApiKey,
|
|
1324
|
+
resources: {},
|
|
1325
|
+
actions: {},
|
|
1326
|
+
onboarding: ${camel}Onboarding,
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
const connectorAdapters: readonly ConnectorAdapterModule[] = [${camel}Provider];
|
|
1330
|
+
export default connectorAdapters;
|
|
1331
|
+
`;
|
|
1332
|
+
}
|
|
1333
|
+
function renderConnectorTest(ctx, providerKey, connectorKey, camel) {
|
|
1334
|
+
const constPrefix = constCase(providerKey);
|
|
1335
|
+
return `// ${ctx.displayName} — connector-adapter conformance tests.
|
|
1336
|
+
//
|
|
1337
|
+
// Run against the BUILT module (\`pnpm test\` builds first): these assert the
|
|
1338
|
+
// invariants the ingress edge relies on — fail-closed verification and a
|
|
1339
|
+
// deterministic idempotency key — so a change that breaks either is caught
|
|
1340
|
+
// here rather than as duplicate processing in production.
|
|
1341
|
+
|
|
1342
|
+
import assert from 'node:assert/strict';
|
|
1343
|
+
import { test } from 'node:test';
|
|
1344
|
+
|
|
1345
|
+
import connectorAdapters, {
|
|
1346
|
+
${constPrefix}_ADAPTER_KIND,
|
|
1347
|
+
${constPrefix}_PROVIDER_KEY,
|
|
1348
|
+
connectorDescriptors,
|
|
1349
|
+
providerDescriptor,
|
|
1350
|
+
${camel}Provider,
|
|
1351
|
+
} from '../dist/connector-adapters/index.js';
|
|
1352
|
+
|
|
1353
|
+
test('the biome contributes exactly one ConnectorAdapterModule', () => {
|
|
1354
|
+
assert.equal(connectorAdapters.length, 1);
|
|
1355
|
+
assert.equal(connectorAdapters[0], ${camel}Provider);
|
|
1356
|
+
assert.equal(${camel}Provider.provider, ${constPrefix}_PROVIDER_KEY);
|
|
1357
|
+
assert.equal(${camel}Provider.adapterKind, ${constPrefix}_ADAPTER_KIND);
|
|
1358
|
+
});
|
|
1359
|
+
|
|
1360
|
+
test('the descriptors that drive connect + custody are complete', () => {
|
|
1361
|
+
assert.equal(providerDescriptor.providerKey, ${constPrefix}_PROVIDER_KEY);
|
|
1362
|
+
assert.equal(connectorDescriptors.length, 1);
|
|
1363
|
+
assert.equal(connectorDescriptors[0].connectorKey, '${connectorKey}');
|
|
1364
|
+
assert.equal(connectorDescriptors[0].providerKey, ${constPrefix}_PROVIDER_KEY);
|
|
1365
|
+
// The connect form is rendered from these fields; an empty list means the
|
|
1366
|
+
// operator is shown a form that collects nothing.
|
|
1367
|
+
assert.ok(${camel}Provider.onboarding.fields.length > 0);
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1370
|
+
test('verification fails closed on a missing or mismatched signature', () => {
|
|
1371
|
+
const { verifier } = ${camel}Provider.webhook;
|
|
1372
|
+
assert.equal(
|
|
1373
|
+
verifier.verify({ rawBody: new Uint8Array(), headers: {}, secret: 's3cret' }).ok,
|
|
1374
|
+
false,
|
|
1375
|
+
);
|
|
1376
|
+
assert.equal(
|
|
1377
|
+
verifier.verify({
|
|
1378
|
+
rawBody: new Uint8Array(),
|
|
1379
|
+
headers: { [verifier.signatureHeader]: 'wrong!' },
|
|
1380
|
+
secret: 's3cret',
|
|
1381
|
+
}).ok,
|
|
1382
|
+
false,
|
|
1383
|
+
);
|
|
1384
|
+
assert.equal(
|
|
1385
|
+
verifier.verify({
|
|
1386
|
+
rawBody: new Uint8Array(),
|
|
1387
|
+
headers: { [verifier.signatureHeader]: 's3cret' },
|
|
1388
|
+
secret: 's3cret',
|
|
1389
|
+
}).ok,
|
|
1390
|
+
true,
|
|
1391
|
+
);
|
|
1392
|
+
});
|
|
1393
|
+
|
|
1394
|
+
test('the idempotency key is deterministic across redeliveries', () => {
|
|
1395
|
+
const extract = ${camel}Provider.webhook.idempotencyKeyExtractor;
|
|
1396
|
+
const body = { id: 'evt_1' };
|
|
1397
|
+
assert.equal(extract({ rawBody: body, headers: {} }), extract({ rawBody: body, headers: {} }));
|
|
1398
|
+
assert.notEqual(
|
|
1399
|
+
extract({ rawBody: body, headers: {} }),
|
|
1400
|
+
extract({ rawBody: { id: 'evt_2' }, headers: {} }),
|
|
1401
|
+
);
|
|
1402
|
+
});
|
|
1403
|
+
|
|
1404
|
+
test('an underivable idempotency key throws rather than fabricating one', () => {
|
|
1405
|
+
const extract = ${camel}Provider.webhook.idempotencyKeyExtractor;
|
|
1406
|
+
assert.throws(() => extract({ rawBody: '', headers: {} }));
|
|
1407
|
+
});
|
|
1408
|
+
`;
|
|
1409
|
+
}
|
|
491
1410
|
export function renderServerBiomeFiles(ctx) {
|
|
492
1411
|
return {
|
|
493
1412
|
...serverBiomeRootFiles(ctx),
|