@xemahq/create-biome 0.3.0 → 0.3.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.
@@ -0,0 +1,1701 @@
1
+ /**
2
+ * In-source template bodies for the in-tree biome scaffold. Embedded as plain
3
+ * TS strings (rather than an out-of-tree `templates/` folder copied at
4
+ * runtime) so the scaffolder's behavior is fully testable without filesystem
5
+ * fixtures, and so `pnpm dlx @xemahq/create-biome` ships in one tarball.
6
+ *
7
+ * Each renderer returns the set of files to write, keyed by biome-root-
8
+ * relative path. The scaffolder writes them under `<target>/<key>`.
9
+ *
10
+ * The SERVER renderers mirror a real first-party biome-api (e.g.
11
+ * `repos/xema-base/biomes/agent-runtime/api/memory-api`): a minimal NestJS
12
+ * service bootstrapped by `XemaServiceModule.forBiome(...)` reading a generated
13
+ * `<api>.bootstrap.generated.ts` descriptor, pnpm `catalog:` deps, a flat
14
+ * ESLint config, `nest-cli.json` with the Swagger plugin, and a
15
+ * `@XemaResource`-annotated controller whose routes PROJECT into real
16
+ * capabilities with no descriptor authored anywhere.
17
+ *
18
+ * The WEB renderer mirrors a real web biome (`biomes/platform/documents-web`):
19
+ * a `defineWebBiome` default export contributing a nav item, a project-scoped
20
+ * lazy list page, and a `navHidden` detail page that is the landing target of
21
+ * the search-type contribution's `routeTemplate` — so the scaffolded deep link
22
+ * resolves instead of 404-ing.
23
+ *
24
+ * The CONNECTOR renderer mirrors a real connector biome
25
+ * (`repos/xema-community/biomes/connector-telegram`): one
26
+ * `ConnectorAdapterModule` built with `defineConnectorAdapter`, no NestJS API
27
+ * and no web surface, which is exactly what
28
+ * `tooling/boundaries/check-connector-biome.mjs` requires.
29
+ */
30
+
31
+ import { apiNameForBiome } from './manifest-builder.js';
32
+
33
+ export interface TemplateContext {
34
+ readonly biomeId: string;
35
+ readonly displayName: string;
36
+ readonly description: string;
37
+ }
38
+
39
+ export type TemplateFiles = Readonly<Record<string, string>>;
40
+
41
+ // ── Shared helpers ──────────────────────────────────────────────────
42
+
43
+ function camelize(kebab: string): string {
44
+ return kebab.replaceAll(/-([a-z0-9])/g, (_, ch: string) => ch.toUpperCase());
45
+ }
46
+
47
+ function pascalize(kebab: string): string {
48
+ const camel = camelize(kebab);
49
+ return camel.charAt(0).toUpperCase() + camel.slice(1);
50
+ }
51
+
52
+ function constCase(kebab: string): string {
53
+ return kebab.replaceAll('-', '_').toUpperCase();
54
+ }
55
+
56
+ function snakeCase(kebab: string): string {
57
+ return kebab.replaceAll('-', '_');
58
+ }
59
+
60
+ /**
61
+ * The sample RESOURCE noun the scaffolded controller owns.
62
+ *
63
+ * Deliberately `item` and not the biome id: the biome id is already the
64
+ * capability `<domain>`, and `@XemaResource` names the `<resource>` — using the
65
+ * id for both would emit `<id>:<id>.list@1` and teach the author that the two
66
+ * are the same axis. `item` also keeps the whole scaffold coherent: it is the
67
+ * `authz.resourceType` and the `<snake_id>_item` docType the seeded search-type
68
+ * contribution declares, and the `{sourceId}` its `routeTemplate` resolves.
69
+ *
70
+ * Rename it and every derived capability ref follows automatically — that is
71
+ * the entire point of deriving rather than declaring.
72
+ */
73
+ const SAMPLE_RESOURCE = 'item';
74
+
75
+ /** URL collection segment for {@link SAMPLE_RESOURCE}. */
76
+ const SAMPLE_RESOURCE_PLURAL = 'items';
77
+
78
+ /**
79
+ * Source directory of the sample resource module. A CONSTANT, not a value
80
+ * derived from the biome id: the previous scaffold derived the import path in
81
+ * `app.module.ts` by de-Pascal-casing the module class name while writing the
82
+ * file under the raw biome id — two independent derivations of one path that
83
+ * agreed for most ids and silently diverged for others. One constant cannot
84
+ * diverge from itself.
85
+ */
86
+ const FEATURE_DIR = SAMPLE_RESOURCE_PLURAL;
87
+
88
+ /**
89
+ * Exported class of the sample resource module. Named after the RESOURCE, not
90
+ * the biome, so the class name, the file path and the import in `app.module.ts`
91
+ * all derive from one constant. The generated conformance test imports this
92
+ * exact symbol from the compiled module — a mismatch between the class name and
93
+ * the file name is not a typecheck error, it is a silent empty capability scan.
94
+ */
95
+ const FEATURE_MODULE_CLASS = 'ItemsModule';
96
+
97
+ // ── Server biome: top-level content directories ──────────────────────
98
+
99
+ /**
100
+ * The content directories every real biome carries. They are seeded at
101
+ * biome-discovery time; an empty directory is legal, so each ships a README
102
+ * placeholder (mirroring the `.gitkeep`/README convention in real biomes) that
103
+ * tells the author what to drop in.
104
+ */
105
+ function serverBiomeRootFiles(ctx: TemplateContext): TemplateFiles {
106
+ return {
107
+ 'README.md': `# ${ctx.displayName}
108
+
109
+ ${ctx.description}
110
+
111
+ A first-party Xema **server biome** scaffolded by \`@xemahq/create-biome\`.
112
+
113
+ ## What you already have, without authoring anything
114
+
115
+ | You wrote | You already have |
116
+ |---|---|
117
+ | \`@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 |
118
+ | \`xema.capabilityDomain\` | the \`<domain>\` those refs are filed under |
119
+ | a \`search-type\` contribution | a renderable search result-type with working deep links |
120
+ | \`renderHints.routeTemplate\` | a real URL from chat and search into the detail page |
121
+
122
+ **No capability descriptor is written anywhere in this biome, and none should
123
+ be.** Rename the \`${SAMPLE_RESOURCE}\` resource and the refs follow; add a route
124
+ and it projects; delete one and the projector prunes it. Reach for
125
+ \`@XemaCapabilityHint\` to correct a derived value, and for a hand-authored
126
+ \`@XemaCapability\` only when an operation genuinely needs a curated ref.
127
+
128
+ Run \`pnpm test\` in \`api/${apiNameForBiome(ctx.biomeId)}/\` to see the exact
129
+ capability set this biome projects.
130
+
131
+ ## First steps
132
+
133
+ \`\`\`sh
134
+ # from this biome directory
135
+ xema dev
136
+ \`\`\`
137
+
138
+ \`xema dev\` boots the biome against a local platform (the workspace source
139
+ always wins — no publish, no token). When it does what you want:
140
+
141
+ \`\`\`sh
142
+ xema biome publish
143
+ \`\`\`
144
+
145
+ ## Layout
146
+
147
+ - \`xema-biome.json\` — the biome manifest (validated by \`BiomeManifestSchema\` at boot).
148
+ - \`api/${apiNameForBiome(ctx.biomeId)}/\` — the NestJS service this biome ships,
149
+ bootstrapped by \`XemaServiceModule.forBiome(...)\`.
150
+ - \`skills/\` — Skill folder bundles (\`<slug>/SKILL.md\`) seeded into the agent runtime.
151
+ - \`agents/\` — Agent definitions (\`<slug>.md\`); declare each in \`xema.agents[]\`.
152
+ - \`contributions/\` — \`*.contribution.json\` envelopes (capabilities, search
153
+ result-types, …). Ships a working \`${ctx.biomeId}.search-type.contribution.json\`
154
+ so this biome's content is renderable AND deep-linkable from search on day one.
155
+ - \`workspace-manifests/\` — \`<slug>.workspace.yaml\` agent workspace manifests.
156
+
157
+ ## In-tree monorepo development
158
+
159
+ When this biome lives inside the Xema monorepo, wire it into the workspace
160
+ toolchain after scaffolding:
161
+
162
+ \`\`\`sh
163
+ # from the monorepo root
164
+ pnpm install
165
+ node tooling/codegen/generate-service-bootstrap.mjs # sync the bootstrap descriptor
166
+ pnpm refresh # openapi -> client -> build
167
+ \`\`\`
168
+ `,
169
+ 'skills/README.md': `# Skills
170
+
171
+ Drop Skill folder bundles here, one per directory:
172
+
173
+ \`\`\`
174
+ skills/<slug>/SKILL.md
175
+ \`\`\`
176
+
177
+ \`SKILL.md\` is the only strict file (YAML frontmatter \`name\` + \`description\`,
178
+ then a free-form markdown body). \`reference/\`, \`scripts/\`, and \`assets/\` are
179
+ optional and mounted as-is. \`scope\` and \`phases\` are NOT frontmatter — they
180
+ are server-resolved.
181
+ `,
182
+ 'agents/README.md': `# Agents
183
+
184
+ Drop agent definitions here, one \`<slug>.md\` per agent. Each agent MUST also
185
+ be declared in \`xema-biome.json\` under \`xema.agents[]\` (\`slug\` + \`mode\`) — the
186
+ boot-time cross-validator enforces parity between the manifest roster and the
187
+ on-disk files. The \`agents/\` directory itself is discovered by on-disk presence.
188
+ `,
189
+ 'contributions/README.md': `# Contributions
190
+
191
+ Drop contribution envelopes here, one \`<slug>.<kind>.contribution.json\` per
192
+ declaration. Every file has the SAME two-key envelope shape:
193
+
194
+ \`\`\`json
195
+ { "kind": "<contribution-kind>", "manifest": { /* kind-specific manifest */ } }
196
+ \`\`\`
197
+
198
+ The file-name suffix and the \`kind\` field MUST agree — a mismatch fails the
199
+ biome's contribution sync at boot with the offending path. Provenance
200
+ (\`biome.id\` / \`biome.version\`) is NEVER declared inline: it is stamped from
201
+ \`xema-biome.json\` by the discovering host.
202
+
203
+ Every manifest is validated fail-fast against its kernel Zod schema at
204
+ biome-discovery time, so a malformed contribution never reaches a registry.
205
+
206
+ ## What ships here already
207
+
208
+ - \`${ctx.biomeId}.search-type.contribution.json\` — the search RESULT-TYPE for
209
+ this biome's primary entity. It declares the \`(objectKind, docType)\` facet,
210
+ the render/route hints the generic search UI reads, the searchable-field set,
211
+ and the result-level authz mapping. Edit it to match your real entity; delete
212
+ it only if this biome indexes nothing.
213
+
214
+ ## \`renderHints.routeTemplate\`
215
+
216
+ The route template turns a search hit into a real URL. Its placeholder
217
+ vocabulary is the CLOSED \`SearchRouteTemplateVariable\` set, split by WHO
218
+ supplies each value:
219
+
220
+ \`\`\`
221
+ platform-derived (always available, never declared):
222
+ orgId · projectId · docType · objectKind · sourceId · title
223
+ projected (YOU supply: declare in renderHints.routeParams AND stamp on
224
+ IndexableDocument.routeParams):
225
+ slug · containerSlug
226
+ \`\`\`
227
+
228
+ \`slug\` is your entity's own URL slug; \`containerSlug\` is the slug of the
229
+ collection a nested route puts it under (e.g. a knowledge space). Use them when
230
+ your frontend route is slug-addressed rather than id-addressed:
231
+
232
+ \`\`\`json
233
+ "renderHints": {
234
+ "routeTemplate": "/spaces/projects/{projectId}/docs/{containerSlug}/{slug}",
235
+ "routeParams": ["containerSlug", "slug"]
236
+ }
237
+ \`\`\`
238
+
239
+ A template must be a site-relative path (leading \`/\`, no whitespace, no \`//\`)
240
+ and may reference nothing else. Validation is fail-fast at parse time: an
241
+ unknown placeholder is rejected, a PROJECTED placeholder you did not declare in
242
+ \`routeParams\` is rejected (it could never expand), and a \`routeParams\` entry
243
+ the template never references is rejected as a dead declaration. At expansion
244
+ time a placeholder with no value yields NO url rather than a half-substituted,
245
+ broken link.
246
+
247
+ Point \`routeTemplate\` at the per-INSTANCE detail route whenever your biome has
248
+ one. When it genuinely has none, the biome's own registered surface (its list
249
+ page) is a better link than none — but NEVER invent a query parameter your UI
250
+ does not actually read: that produces a live-looking URL that silently ignores
251
+ the result the user clicked.
252
+ `,
253
+ [`contributions/${ctx.biomeId}.search-type.contribution.json`]: `${JSON.stringify(
254
+ {
255
+ $comment:
256
+ `Search RESULT-TYPE for this biome's primary entity — replace the placeholder ` +
257
+ `values with your real ones. \`objectKind\` must be a XemaObjectKind member; use ` +
258
+ `the kind your entity IS, or the closest one when it has no kernel kind (docType ` +
259
+ `is the real discriminator, and the natural key is the (objectKind, docType) pair). ` +
260
+ `\`searchableFields\` must match the fields your producer actually projects onto the ` +
261
+ `IndexableDocument. \`routeTemplate\` resolves to the deeplink-only detail page the ` +
262
+ `paired web biome ships (\`navHidden: true\`, slug '${ctx.biomeId}/:id'), so the link ` +
263
+ `works on day one — if you scaffolded WITHOUT a web surface, add that route before ` +
264
+ `keeping this field, and drop it entirely rather than pointing it at a list page. ` +
265
+ `\`embeddingEligibleDefault\` is false: semantic embedding is opt-in, never inferred.`,
266
+ kind: 'search-type',
267
+ manifest: {
268
+ objectKind: 'artifact',
269
+ docType: `${snakeCase(ctx.biomeId)}_item`,
270
+ renderHints: {
271
+ label: ctx.displayName,
272
+ icon: 'layout-grid',
273
+ routeTemplate: `/spaces/projects/{projectId}/${ctx.biomeId}/{sourceId}`,
274
+ },
275
+ searchableFields: ['title', 'content', 'tags'],
276
+ embeddingEligibleDefault: false,
277
+ authz: {
278
+ resourceType: `${snakeCase(ctx.biomeId)}_item`,
279
+ defaultVisibility: 'org-shared',
280
+ },
281
+ },
282
+ },
283
+ null,
284
+ 2,
285
+ )}\n`,
286
+ 'workspace-manifests/README.md': `# Workspace manifests
287
+
288
+ Drop agent workspace manifests here, one \`<slug>.workspace.yaml\` per agent
289
+ workspace. See \`xema://manifest/primary-agent-base\` for the base to extend.
290
+ `,
291
+ };
292
+ }
293
+
294
+ // ── Server biome: the NestJS API service ─────────────────────────────
295
+
296
+ /**
297
+ * Render the NestJS API service under `api/<id>-api/`. Returns files keyed
298
+ * relative to the BIOME ROOT (so the `api/<id>-api/` prefix is included).
299
+ */
300
+ function serverApiFiles(ctx: TemplateContext): TemplateFiles {
301
+ const apiName = apiNameForBiome(ctx.biomeId);
302
+ const apiDir = `api/${apiName}`;
303
+ const constName = `${constCase(apiName)}_BOOTSTRAP`;
304
+
305
+ return {
306
+ [`${apiDir}/package.json`]: `${JSON.stringify(
307
+ {
308
+ name: apiName,
309
+ version: '0.1.0',
310
+ private: true,
311
+ scripts: {
312
+ clean: 'rm -rf dist',
313
+ build: 'nest build',
314
+ format: 'prettier --write "src/**/*.ts"',
315
+ start: 'nest start',
316
+ 'start:dev': 'nest start --watch',
317
+ lint: 'eslint "src/**/*.ts"',
318
+ 'lint:fix': 'eslint "src/**/*.ts" --fix',
319
+ typecheck: 'tsc --noEmit',
320
+ // `test` builds first on purpose: the conformance tests assert the
321
+ // capabilities this service's routes PROJECT, and that assertion is
322
+ // only meaningful against compiled output carrying real decorator
323
+ // metadata. A test script that silently ran against a stale `dist`
324
+ // would pass while the source was broken.
325
+ test: 'nest build && node --test "test/*.test.mjs"',
326
+ openapi: 'NODE_PATH=node_modules xema-openapi',
327
+ 'client:generate': 'xema-client-generate',
328
+ },
329
+ dependencies: {
330
+ '@nestjs/common': 'catalog:',
331
+ '@nestjs/config': 'catalog:',
332
+ '@nestjs/core': 'catalog:',
333
+ '@nestjs/platform-express': 'catalog:',
334
+ '@nestjs/swagger': 'catalog:',
335
+ '@xemahq/identity-client': 'catalog:',
336
+ '@xemahq/kernel-contracts': 'catalog:',
337
+ '@xemahq/platform-common': 'catalog:',
338
+ '@xemahq/service-registry-nest': 'catalog:',
339
+ '@xemahq/xema-decorators': 'catalog:',
340
+ '@xemahq/xema-service-nest': 'catalog:',
341
+ 'class-transformer': 'catalog:',
342
+ 'class-validator': 'catalog:',
343
+ 'reflect-metadata': 'catalog:',
344
+ rxjs: 'catalog:',
345
+ 'swagger-ui-express': 'catalog:',
346
+ },
347
+ devDependencies: {
348
+ '@eslint/js': 'catalog:',
349
+ '@nestjs/cli': 'catalog:',
350
+ '@nestjs/schematics': 'catalog:',
351
+ '@types/express': 'catalog:',
352
+ '@types/node': 'catalog:',
353
+ '@xemahq/api-client-generator': 'catalog:',
354
+ dotenv: 'catalog:',
355
+ eslint: 'catalog:',
356
+ 'eslint-config-prettier': 'catalog:',
357
+ orval: 'catalog:',
358
+ prettier: 'catalog:',
359
+ typescript: 'catalog:',
360
+ 'typescript-eslint': 'catalog:',
361
+ },
362
+ xema: {
363
+ api: {
364
+ serviceId: apiName,
365
+ biomeId: ctx.biomeId,
366
+ title: `${ctx.displayName} API`,
367
+ description: ctx.description,
368
+ },
369
+ },
370
+ },
371
+ null,
372
+ 2,
373
+ )}\n`,
374
+ [`${apiDir}/tsconfig.json`]: `${JSON.stringify(
375
+ {
376
+ extends: '../../../../tsconfig.base.json',
377
+ compilerOptions: {
378
+ outDir: 'dist',
379
+ rootDir: 'src',
380
+ types: ['node'],
381
+ baseUrl: '.',
382
+ },
383
+ include: ['src/**/*.ts'],
384
+ exclude: ['node_modules', 'dist'],
385
+ },
386
+ null,
387
+ 2,
388
+ )}\n`,
389
+ [`${apiDir}/nest-cli.json`]: `${JSON.stringify(
390
+ {
391
+ $schema: 'https://json.schemastore.org/nest-cli',
392
+ collection: '@nestjs/schematics',
393
+ sourceRoot: 'src',
394
+ compilerOptions: {
395
+ deleteOutDir: true,
396
+ plugins: [
397
+ {
398
+ name: '@nestjs/swagger',
399
+ options: {
400
+ classValidatorShim: true,
401
+ introspectComments: true,
402
+ },
403
+ },
404
+ ],
405
+ },
406
+ },
407
+ null,
408
+ 2,
409
+ )}\n`,
410
+ [`${apiDir}/eslint.config.mjs`]: ESLINT_CONFIG,
411
+ [`${apiDir}/.gitignore`]: `node_modules
412
+ dist
413
+ .turbo
414
+ openapi.*.json
415
+ `,
416
+ [`${apiDir}/test/manifest.test.mjs`]: renderManifestTest(ctx),
417
+ [`${apiDir}/test/capabilities.test.mjs`]: renderCapabilitiesTest(
418
+ ctx,
419
+ apiName,
420
+ ),
421
+ [`${apiDir}/src/generated/${apiName}.bootstrap.generated.ts`]: renderBootstrapPlaceholder(
422
+ apiName,
423
+ constName,
424
+ `${ctx.displayName} API`,
425
+ ctx.biomeId,
426
+ ),
427
+ [`${apiDir}/src/main.ts`]: renderMainTs(apiName, ctx),
428
+ [`${apiDir}/src/app.module.ts`]: renderAppModule(apiName, constName),
429
+ [`${apiDir}/src/config/index.ts`]: renderConfigIndex(),
430
+ [`${apiDir}/src/health/health.module.ts`]: HEALTH_MODULE,
431
+ [`${apiDir}/src/health/health.controller.ts`]: HEALTH_CONTROLLER,
432
+ [`${apiDir}/src/${FEATURE_DIR}/${FEATURE_DIR}.module.ts`]:
433
+ renderFeatureModule(),
434
+ [`${apiDir}/src/${FEATURE_DIR}/${FEATURE_DIR}.controller.ts`]:
435
+ renderFeatureController(ctx),
436
+ [`${apiDir}/src/${FEATURE_DIR}/${FEATURE_DIR}.service.ts`]:
437
+ renderFeatureService(),
438
+ [`${apiDir}/src/${FEATURE_DIR}/dto/${SAMPLE_RESOURCE}.dto.ts`]:
439
+ renderItemDto(),
440
+ };
441
+ }
442
+
443
+ /**
444
+ * Seed bootstrap descriptor. `generate-service-bootstrap.mjs` is the single
445
+ * source of truth and overwrites this file from the manifest; we seed a valid
446
+ * file so the service typechecks before the author runs codegen. The body is
447
+ * kept BYTE-IDENTICAL to what the codegen emits for a fresh biome (same
448
+ * header, JSDoc, and field order — semver 0.1.0, biome-api, identity-api
449
+ * floor) so a freshly-scaffolded biome passes `generate-service-bootstrap.mjs
450
+ * --check` in CI without an immediate regenerate. The `manifestRel` line is
451
+ * the ONE field that differs by checkout location; codegen rewrites it.
452
+ *
453
+ * `biomeId` + `biomeVersion` are load-bearing, not decorative:
454
+ * `isHttpCapabilityProjectionEnabled` gates HTTP capability projection on BOTH
455
+ * being present, because a projected capability is stamped with its biome
456
+ * provenance and provenance cannot be guessed. A descriptor missing them boots
457
+ * fine and projects NOTHING — the exact silent no-op this scaffold exists to
458
+ * make impossible. `capabilityDomain` mirrors `xema.capabilityDomain`.
459
+ */
460
+ function renderBootstrapPlaceholder(
461
+ apiName: string,
462
+ constName: string,
463
+ displayName: string,
464
+ biomeId: string,
465
+ ): string {
466
+ return `// AUTO-GENERATED by tooling/codegen/generate-service-bootstrap.mjs — DO NOT EDIT.
467
+ // Source of truth: biomes/${biomeId}/xema-biome.json (xema.ships.apis[] + top-level version).
468
+ // Regenerate: \`node tooling/codegen/generate-service-bootstrap.mjs\`.
469
+ import {
470
+ ServiceKind,
471
+ type BiomeServiceDescriptor,
472
+ } from '@xemahq/xema-service-nest';
473
+
474
+ /**
475
+ * Bootstrap identity for ${apiName}, consumed by
476
+ * \`XemaServiceModule.forBiome(${constName})\`. Edit the biome manifest,
477
+ * not this file.
478
+ */
479
+ export const ${constName}: BiomeServiceDescriptor = {
480
+ name: '${apiName}',
481
+ semver: '0.1.0',
482
+ serviceKind: ServiceKind.BiomeApi,
483
+ displayName: '${displayName}',
484
+ requiresServices: ['identity-api'],
485
+ exposesCapabilities: [],
486
+ biomeId: '${biomeId}',
487
+ biomeVersion: '0.1.0',
488
+ capabilityDomain: '${biomeId}',
489
+ };
490
+ `;
491
+ }
492
+
493
+ function renderMainTs(apiName: string, ctx: TemplateContext): string {
494
+ return `import { bootstrapXemaService } from '@xemahq/xema-service-nest';
495
+
496
+ import { AppModule } from './app.module';
497
+ import { requiredEnvVars } from './config';
498
+
499
+ const swaggerDescription = \`
500
+ ${ctx.description}
501
+ \`.trim();
502
+
503
+ void bootstrapXemaService({
504
+ appModule: AppModule,
505
+ serviceName: '${apiName}',
506
+ swaggerTitle: '${ctx.displayName} API',
507
+ swaggerDescription,
508
+ requiredEnvVars,
509
+ });
510
+ `;
511
+ }
512
+
513
+ function renderAppModule(apiName: string, constName: string): string {
514
+ const featureFile = FEATURE_DIR;
515
+ const featureModule = FEATURE_MODULE_CLASS;
516
+ return `import { Module } from '@nestjs/common';
517
+ import { ConfigModule } from '@nestjs/config';
518
+ import { APP_INTERCEPTOR } from '@nestjs/core';
519
+ import { ResponseEnvelopeInterceptor } from '@xemahq/platform-common';
520
+ import { XemaServiceModule } from '@xemahq/xema-service-nest';
521
+
522
+ import { appConfig } from './config';
523
+ import { ${constName} } from './generated/${apiName}.bootstrap.generated';
524
+ import { HealthModule } from './health/health.module';
525
+ import { ${featureModule} } from './${featureFile}/${featureFile}.module';
526
+
527
+ @Module({
528
+ imports: [
529
+ ConfigModule.forRoot({ isGlobal: true, load: [appConfig] }),
530
+ // One-call platform bootstrap: Service Registry + Identity bootstrap +
531
+ // Auth (JWT + request context) + the runtime route/capability scanner.
532
+ // Service identity is the single source of truth in xema-biome.json,
533
+ // surfaced via the generated descriptor.
534
+ XemaServiceModule.forBiome(${constName}),
535
+ HealthModule,
536
+ ${featureModule},
537
+ ],
538
+ providers: [
539
+ { provide: APP_INTERCEPTOR, useClass: ResponseEnvelopeInterceptor },
540
+ ],
541
+ })
542
+ export class AppModule {}
543
+ `;
544
+ }
545
+
546
+ /**
547
+ * Flat ESLint config for the generated API.
548
+ *
549
+ * SELF-CONTAINED on purpose: it uses only the four lint devDependencies the
550
+ * generated `package.json` declares, and extends no repo-relative shared config.
551
+ * A scaffolded biome must lint cleanly wherever it is generated — including
552
+ * outside the Xema monorepo, where `../../../../eslint.api-decorators-rules.mjs`
553
+ * does not exist. Adopt the shared config once the biome lives in-tree.
554
+ *
555
+ * Typed rules are scoped to `src/**\/*.ts` because `projectService` needs the
556
+ * file to be in a tsconfig program; the `.mjs` conformance tests are not, and
557
+ * pointing typed rules at them would fail the lint on a freshly-generated biome.
558
+ */
559
+ const ESLINT_CONFIG = `import eslint from '@eslint/js';
560
+ import prettierConfig from 'eslint-config-prettier';
561
+ import tseslint from 'typescript-eslint';
562
+
563
+ export default tseslint.config(
564
+ { ignores: ['dist/', 'node_modules/', 'src/generated/', 'test/', 'eslint.config.mjs'] },
565
+ eslint.configs.recommended,
566
+ ...tseslint.configs.recommended,
567
+ prettierConfig,
568
+ {
569
+ files: ['src/**/*.ts'],
570
+ languageOptions: {
571
+ parserOptions: {
572
+ projectService: true,
573
+ tsconfigRootDir: import.meta.dirname,
574
+ },
575
+ },
576
+ rules: {
577
+ '@typescript-eslint/no-floating-promises': 'error',
578
+ '@typescript-eslint/no-misused-promises': 'error',
579
+ '@typescript-eslint/await-thenable': 'error',
580
+ '@typescript-eslint/no-explicit-any': 'error',
581
+ '@typescript-eslint/consistent-type-imports': [
582
+ 'error',
583
+ { prefer: 'type-imports', fixStyle: 'separate-type-imports' },
584
+ ],
585
+ '@typescript-eslint/no-unused-vars': [
586
+ 'error',
587
+ { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrors: 'all' },
588
+ ],
589
+ eqeqeq: ['error', 'always'],
590
+ curly: ['error', 'all'],
591
+ 'no-console': ['error', { allow: ['warn', 'error'] }],
592
+ 'no-duplicate-imports': 'error',
593
+ },
594
+ },
595
+ );
596
+ `;
597
+
598
+ function renderConfigIndex(): string {
599
+ return `import { registerAs } from '@nestjs/config';
600
+
601
+ /**
602
+ * Environment variables this service requires at boot. \`bootstrapXemaService\`
603
+ * fail-fasts if any is missing. The platform bootstrap injects the registry /
604
+ * identity wiring; add service-specific required vars here as the biome grows.
605
+ */
606
+ export const requiredEnvVars: readonly string[] = ['IDENTITY_API_URL'];
607
+
608
+ export const appConfig = registerAs('app', () => ({
609
+ port: Number(process.env.PORT ?? '3000'),
610
+ }));
611
+ `;
612
+ }
613
+
614
+ const HEALTH_MODULE = `import { Module } from '@nestjs/common';
615
+
616
+ import { HealthController } from './health.controller';
617
+
618
+ @Module({ controllers: [HealthController] })
619
+ export class HealthModule {}
620
+ `;
621
+
622
+ const HEALTH_CONTROLLER = `import { Controller, Get } from '@nestjs/common';
623
+ import { PublicRouteReason, XemaPublicRoute } from '@xemahq/xema-decorators';
624
+
625
+ /**
626
+ * Probe endpoints. \`@XemaPublicRoute\` requires a closed-domain \`reason\` —
627
+ * public-by-default is not expressible, so every unauthenticated route says
628
+ * why it is one. These are also the only routes in the service that project no
629
+ * capability: a public probe is not an agent-callable operation.
630
+ */
631
+ @Controller('health')
632
+ export class HealthController {
633
+ @Get('live')
634
+ @XemaPublicRoute({ reason: PublicRouteReason.K8sProbe })
635
+ live(): { status: 'ok' } {
636
+ return { status: 'ok' };
637
+ }
638
+
639
+ @Get('ready')
640
+ @XemaPublicRoute({ reason: PublicRouteReason.K8sProbe })
641
+ ready(): { status: 'ok' } {
642
+ return { status: 'ok' };
643
+ }
644
+ }
645
+ `;
646
+
647
+ function renderFeatureModule(): string {
648
+ return `import { Module } from '@nestjs/common';
649
+
650
+ import { ItemsController } from './items.controller';
651
+ import { ItemsService } from './items.service';
652
+
653
+ @Module({
654
+ controllers: [ItemsController],
655
+ providers: [ItemsService],
656
+ })
657
+ export class ${FEATURE_MODULE_CLASS} {}
658
+ `;
659
+ }
660
+
661
+ /**
662
+ * The controller that makes the scaffold's central promise true: FOUR
663
+ * agent-callable capabilities, in the graph, with schemas — from route
664
+ * metadata alone. No `@XemaCapability`, no `*.capability.contribution.json`.
665
+ */
666
+ function renderFeatureController(ctx: TemplateContext): string {
667
+ return `import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common';
668
+ import {
669
+ ApiCreatedResponse,
670
+ ApiNoContentResponse,
671
+ ApiOkResponse,
672
+ ApiTags,
673
+ } from '@nestjs/swagger';
674
+ import {
675
+ ApiSurface,
676
+ XemaApiSurface,
677
+ XemaCapabilityHint,
678
+ XemaResource,
679
+ XemaRoute,
680
+ } from '@xemahq/xema-decorators';
681
+
682
+ import { CreateItemDto, ItemDto } from './dto/item.dto';
683
+ import { ItemsService } from './items.service';
684
+
685
+ /**
686
+ * Resource controller for the \`${SAMPLE_RESOURCE}\` noun this biome owns.
687
+ *
688
+ * \`@XemaResource\` + \`@XemaRoute\` are the ONLY declarations needed to get
689
+ * capabilities: at boot, \`XemaServiceModule.forBiome\` stamps every public
690
+ * operation with \`x-xema-resource\` / \`x-xema-action\`, and the HTTP capability
691
+ * projector derives one capability per operation —
692
+ * \`<capabilityDomain>:<resource>.<verb>@1\` — registers it, and prunes any it
693
+ * no longer produces. The refs this file yields (domain \`${ctx.biomeId}\`, from
694
+ * \`xema.capabilityDomain\`):
695
+ *
696
+ * ${ctx.biomeId}:${SAMPLE_RESOURCE}.list@1 risk low
697
+ * ${ctx.biomeId}:${SAMPLE_RESOURCE}.read@1 risk low
698
+ * ${ctx.biomeId}:${SAMPLE_RESOURCE}.create@1 risk medium
699
+ * ${ctx.biomeId}:${SAMPLE_RESOURCE}.delete@1 risk high, requires approval
700
+ *
701
+ * Risk tiers come from the DECLARED verb→risk table, not a heuristic; an
702
+ * unrecognised verb resolves to \`high\` (never low), which over-gates visibly
703
+ * instead of silently un-gating. \`test/capabilities.test.mjs\` asserts this
704
+ * exact set, so adding or renaming a route without updating the expectation
705
+ * fails the build rather than quietly changing the biome's public surface.
706
+ */
707
+ @ApiTags('${SAMPLE_RESOURCE_PLURAL}')
708
+ // The surface is the "which generated client, and is this agent-reachable?"
709
+ // axis, and it is REQUIRED: a route with no surface is unclassified, and the
710
+ // projector only ever derives from \`ApiSurface.Public\`. There is no default —
711
+ // a silently-private route that an author believed was public would be the
712
+ // worst kind of quiet failure.
713
+ @XemaApiSurface(ApiSurface.Public)
714
+ @XemaResource('${SAMPLE_RESOURCE}')
715
+ @Controller('${SAMPLE_RESOURCE_PLURAL}')
716
+ export class ItemsController {
717
+ constructor(private readonly itemsService: ItemsService) {}
718
+
719
+ @Get()
720
+ @XemaRoute({ action: 'list' })
721
+ @ApiOkResponse({ type: ItemDto, isArray: true })
722
+ list(): ItemDto[] {
723
+ return this.itemsService.list();
724
+ }
725
+
726
+ @Get(':itemId')
727
+ @XemaRoute({ action: 'read' })
728
+ @ApiOkResponse({ type: ItemDto })
729
+ read(@Param('itemId') itemId: string): ItemDto {
730
+ return this.itemsService.read(itemId);
731
+ }
732
+
733
+ @Post()
734
+ @XemaRoute({ action: 'create' })
735
+ // The ONE knob for correcting a derived value. \`riskTier\` and
736
+ // \`requiresApproval\` are the other two; each defaults from the verb table,
737
+ // and the summary defaults to the OpenAPI description. Override only what is
738
+ // actually wrong — a hand-authored \`@XemaCapability\` always beats both, and
739
+ // is reserved for the rare operation that needs a curated v2 ref.
740
+ @XemaCapabilityHint({
741
+ summary:
742
+ 'Create one ${SAMPLE_RESOURCE} in this biome. Prefer this over editing storage directly: it is the audited, authorized write path.',
743
+ })
744
+ @ApiCreatedResponse({ type: ItemDto })
745
+ create(@Body() dto: CreateItemDto): ItemDto {
746
+ return this.itemsService.create(dto);
747
+ }
748
+
749
+ @Delete(':itemId')
750
+ @XemaRoute({ action: 'delete' })
751
+ @ApiNoContentResponse()
752
+ remove(@Param('itemId') itemId: string): void {
753
+ this.itemsService.remove(itemId);
754
+ }
755
+ }
756
+ `;
757
+ }
758
+
759
+ function renderItemDto(): string {
760
+ return `import { ApiProperty } from '@nestjs/swagger';
761
+ import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
762
+
763
+ /**
764
+ * Sample domain type — replace with the biome's real one. The DTO is not
765
+ * decoration: the capability projector derives each capability's input and
766
+ * output JSON Schema from the OpenAPI operation, so an untyped body produces a
767
+ * capability an agent cannot call correctly.
768
+ */
769
+ export class ItemDto {
770
+ @ApiProperty({ description: 'Stable identifier of the ${SAMPLE_RESOURCE}.' })
771
+ id!: string;
772
+
773
+ @ApiProperty({ description: 'Human-readable title.' })
774
+ title!: string;
775
+ }
776
+
777
+ export class CreateItemDto {
778
+ @ApiProperty({ description: 'Human-readable title.', maxLength: 200 })
779
+ @IsString()
780
+ @IsNotEmpty()
781
+ @MaxLength(200)
782
+ title!: string;
783
+ }
784
+ `;
785
+ }
786
+
787
+ function renderFeatureService(): string {
788
+ return `import { Injectable, NotFoundException } from '@nestjs/common';
789
+
790
+ import { CreateItemDto, ItemDto } from './dto/item.dto';
791
+
792
+ /**
793
+ * Sample in-memory store — replace with the biome's real persistence. Kept
794
+ * deliberately trivial so a freshly-scaffolded biome builds and tests with no
795
+ * database: the scaffold's job is to prove the capability/render/deep-link
796
+ * wiring, not to pick a storage engine for you.
797
+ */
798
+ @Injectable()
799
+ export class ItemsService {
800
+ private readonly items = new Map<string, ItemDto>();
801
+ private nextId = 1;
802
+
803
+ list(): ItemDto[] {
804
+ return [...this.items.values()];
805
+ }
806
+
807
+ read(itemId: string): ItemDto {
808
+ const found = this.items.get(itemId);
809
+ if (found === undefined) {
810
+ throw new NotFoundException(\`${SAMPLE_RESOURCE} "\${itemId}" not found\`);
811
+ }
812
+ return found;
813
+ }
814
+
815
+ create(dto: CreateItemDto): ItemDto {
816
+ const id = String(this.nextId);
817
+ this.nextId += 1;
818
+ const created: ItemDto = { id, title: dto.title };
819
+ this.items.set(id, created);
820
+ return created;
821
+ }
822
+
823
+ remove(itemId: string): void {
824
+ if (!this.items.delete(itemId)) {
825
+ throw new NotFoundException(\`${SAMPLE_RESOURCE} "\${itemId}" not found\`);
826
+ }
827
+ }
828
+ }
829
+ `;
830
+ }
831
+
832
+ // ── Server biome: conformance tests ──────────────────────────────────
833
+
834
+ /**
835
+ * Validates the biome's own declarations against the kernel schemas that the
836
+ * platform validates them against at boot — so a broken manifest or a broken
837
+ * search-type contribution fails here, in the author's terminal, instead of at
838
+ * install time in someone else's cluster.
839
+ */
840
+ function renderManifestTest(ctx: TemplateContext): string {
841
+ return `import assert from 'node:assert/strict';
842
+ import { readFileSync } from 'node:fs';
843
+ import { dirname, join } from 'node:path';
844
+ import { test } from 'node:test';
845
+ import { fileURLToPath } from 'node:url';
846
+
847
+ import { BiomeManifestSchema } from '@xemahq/kernel-contracts/biome';
848
+ import {
849
+ SearchTypeContributionManifestSchema,
850
+ assertValidSearchRouteTemplate,
851
+ } from '@xemahq/kernel-contracts/search';
852
+
853
+ // test/ -> api/<api>/ -> api/ -> biome root
854
+ const BIOME_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
855
+
856
+ const readJson = (rel) => JSON.parse(readFileSync(join(BIOME_ROOT, rel), 'utf8'));
857
+
858
+ test('xema-biome.json validates against the kernel BiomeManifestSchema', () => {
859
+ const result = BiomeManifestSchema.safeParse(readJson('xema-biome.json'));
860
+ assert.equal(
861
+ result.success,
862
+ true,
863
+ result.success ? '' : JSON.stringify(result.error.issues, null, 2),
864
+ );
865
+ });
866
+
867
+ test('the manifest declares the capability domain its routes project into', () => {
868
+ const manifest = readJson('xema-biome.json');
869
+ // Without a declared domain the projector falls back to xema.id, which is
870
+ // correct but invisible. Declaring it keeps the capability vocabulary a
871
+ // one-line edit instead of a documentation hunt.
872
+ assert.equal(manifest.xema.capabilityDomain, '${ctx.biomeId}');
873
+ });
874
+
875
+ test('the search-type contribution validates and its routeTemplate is well-formed', () => {
876
+ const envelope = readJson('contributions/${ctx.biomeId}.search-type.contribution.json');
877
+ assert.equal(envelope.kind, 'search-type');
878
+
879
+ const result = SearchTypeContributionManifestSchema.safeParse(envelope.manifest);
880
+ assert.equal(
881
+ result.success,
882
+ true,
883
+ result.success ? '' : JSON.stringify(result.error.issues, null, 2),
884
+ );
885
+
886
+ // A deep link that only LOOKS clickable is worse than none: every
887
+ // placeholder must come from the closed SearchRouteTemplateVariable set.
888
+ assertValidSearchRouteTemplate(envelope.manifest.renderHints.routeTemplate);
889
+ });
890
+ `;
891
+ }
892
+
893
+ /**
894
+ * The test that makes "nobody hand-writes a capability descriptor" checkable.
895
+ *
896
+ * It runs the SAME derivation the running service runs — `scanApiSurfaces` →
897
+ * `stampRouteExtensions` → `deriveHttpCapabilities` — against an un-listened
898
+ * Nest app, so it needs no database, no registry, no network. If a route stops
899
+ * projecting, or projects under a ref nobody expected, this fails.
900
+ */
901
+ function renderCapabilitiesTest(ctx: TemplateContext, apiName: string): string {
902
+ return `import 'reflect-metadata';
903
+
904
+ import assert from 'node:assert/strict';
905
+ import { createRequire } from 'node:module';
906
+ import { after, before, test } from 'node:test';
907
+
908
+ // Loaded through \`createRequire\`, not \`import\`: \`nest build\` emits CommonJS,
909
+ // and a decorated class's exports are not statically analysable, so an ESM
910
+ // named import of the compiled module fails. Requiring the framework packages
911
+ // the same way also guarantees the test and the compiled controller share ONE
912
+ // instance of Nest — two would mean two metadata registries and an empty scan.
913
+ const require = createRequire(import.meta.url);
914
+
915
+ const { DiscoveryService, MetadataScanner, ModulesContainer, NestFactory } = require('@nestjs/core');
916
+ const { DocumentBuilder, SwaggerModule } = require('@nestjs/swagger');
917
+ const { scanApiSurfaces, stampRouteExtensions } = require('@xemahq/xema-decorators');
918
+ const { deriveHttpCapabilities } = require('@xemahq/xema-service-nest');
919
+
920
+ const { ItemsModule } = require('../dist/${FEATURE_DIR}/${FEATURE_DIR}.module.js');
921
+
922
+ /**
923
+ * The capabilities this biome MUST project, and the risk tier each derives.
924
+ * Hand-maintained on purpose: this is the assertion, not the derivation. Change
925
+ * a route and this list must change with it — deliberately, and in review.
926
+ */
927
+ const EXPECTED = {
928
+ '${ctx.biomeId}:${SAMPLE_RESOURCE}.list@1': { riskTier: 'low', requiresApproval: false },
929
+ '${ctx.biomeId}:${SAMPLE_RESOURCE}.read@1': { riskTier: 'low', requiresApproval: false },
930
+ '${ctx.biomeId}:${SAMPLE_RESOURCE}.create@1': { riskTier: 'medium', requiresApproval: false },
931
+ '${ctx.biomeId}:${SAMPLE_RESOURCE}.delete@1': { riskTier: 'high', requiresApproval: true },
932
+ };
933
+
934
+ let derivation;
935
+ let app;
936
+
937
+ before(async () => {
938
+ // The feature module ALONE — no XemaServiceModule, so no registry, no
939
+ // identity bootstrap, no network. Capability derivation reads decorator
940
+ // metadata and the OpenAPI document; neither needs a booted platform.
941
+ app = await NestFactory.create(ItemsModule, { logger: false });
942
+ const doc = SwaggerModule.createDocument(
943
+ app,
944
+ new DocumentBuilder().setTitle('${apiName}').setVersion('0.1.0').build(),
945
+ { operationIdFactory: (controllerKey, methodKey) => \`\${controllerKey}_\${methodKey}\` },
946
+ );
947
+ const scan = scanApiSurfaces(
948
+ new DiscoveryService(app.get(ModulesContainer, { strict: false })),
949
+ new MetadataScanner(),
950
+ );
951
+ stampRouteExtensions(doc, scan.projections);
952
+
953
+ derivation = deriveHttpCapabilities({
954
+ doc,
955
+ serviceName: '${apiName}',
956
+ domain: '${ctx.biomeId}',
957
+ biomeId: '${ctx.biomeId}',
958
+ biomeVersion: '0.1.0',
959
+ sourceKey: 'http-operations',
960
+ authoredRefs: new Set(),
961
+ });
962
+ });
963
+
964
+ after(async () => {
965
+ await app?.close();
966
+ });
967
+
968
+ test('every route projects a capability — none authored by hand', () => {
969
+ const derived = [...derivation.capabilities.keys()].sort();
970
+ assert.deepEqual(derived, Object.keys(EXPECTED).sort());
971
+ });
972
+
973
+ test('risk tier and approval gating are derived from the declared verb table', () => {
974
+ for (const [ref, expected] of Object.entries(EXPECTED)) {
975
+ const capability = derivation.capabilities.get(ref);
976
+ assert.ok(capability, \`missing capability \${ref}\`);
977
+ assert.equal(capability.riskTier, expected.riskTier, ref);
978
+ assert.equal(capability.requiresApproval, expected.requiresApproval, ref);
979
+ }
980
+ });
981
+
982
+ test('each capability carries an invocation binding and IO schemas', () => {
983
+ for (const ref of Object.keys(EXPECTED)) {
984
+ const capability = derivation.capabilities.get(ref);
985
+ // Without a binding the ref is registered but unroutable — an agent could
986
+ // discover it and never call it.
987
+ assert.ok(capability.invocation, \`\${ref} has no invocation binding\`);
988
+ assert.equal(capability.provenance.biomeId, '${ctx.biomeId}');
989
+ assert.ok(capability.inputSchema, \`\${ref} has no input schema\`);
990
+ }
991
+ });
992
+
993
+ test('the @XemaCapabilityHint summary wins over the derived one', () => {
994
+ const created = derivation.capabilities.get('${ctx.biomeId}:${SAMPLE_RESOURCE}.create@1');
995
+ assert.match(created.summary, /audited, authorized write path/);
996
+ });
997
+ `;
998
+ }
999
+
1000
+ // ── Web biome ────────────────────────────────────────────────────────
1001
+
1002
+ export interface WebTemplateContext extends TemplateContext {
1003
+ /** The web biome id (e.g. `<id>-web`). */
1004
+ readonly webBiomeId: string;
1005
+ /** Display name shown in the nav rail. */
1006
+ readonly navLabel: string;
1007
+ }
1008
+
1009
+ /**
1010
+ * Render the web biome's source files, keyed relative to the WEB BIOME ROOT
1011
+ * (`biomes/<tier>/<id>-web/`). Mirrors `biomes/platform/documents-web`.
1012
+ */
1013
+ export function webBiomeFiles(ctx: WebTemplateContext): TemplateFiles {
1014
+ const pageComponent = `${pascalize(ctx.biomeId)}Page`;
1015
+ const detailComponent = `${pascalize(ctx.biomeId)}DetailPage`;
1016
+ const navId = ctx.biomeId;
1017
+ return {
1018
+ 'package.json': `${JSON.stringify(
1019
+ {
1020
+ name: `@xemahq/biomes-${ctx.webBiomeId}`,
1021
+ version: '0.1.0',
1022
+ description: ctx.description,
1023
+ license: 'Apache-2.0',
1024
+ private: true,
1025
+ type: 'module',
1026
+ main: 'src/index.tsx',
1027
+ types: 'src/index.tsx',
1028
+ files: ['src', 'xema-biome.json'],
1029
+ scripts: {
1030
+ typecheck: 'tsc --noEmit',
1031
+ lint: 'tsc --noEmit',
1032
+ format: 'prettier --write "src/**/*.{ts,tsx}"',
1033
+ },
1034
+ peerDependencies: {
1035
+ '@tanstack/react-query': '^5.0.0',
1036
+ react: '^18.3.1',
1037
+ 'react-dom': '^18.3.1',
1038
+ },
1039
+ devDependencies: {
1040
+ '@tanstack/react-query': '^5.83.0',
1041
+ '@types/react': '^18.3.0',
1042
+ '@types/react-dom': '^18.3.0',
1043
+ 'lucide-react': '^0.462.0',
1044
+ prettier: '3.6.2',
1045
+ react: '^18.3.1',
1046
+ 'react-dom': '^18.3.1',
1047
+ typescript: '5.9.3',
1048
+ },
1049
+ },
1050
+ null,
1051
+ 2,
1052
+ )}\n`,
1053
+ 'tsconfig.json': WEB_TSCONFIG,
1054
+ 'src/index.tsx': renderWebIndex(ctx, pageComponent, detailComponent, navId),
1055
+ [`src/pages/${pageComponent}.tsx`]: renderWebPage(ctx, pageComponent),
1056
+ [`src/pages/${detailComponent}.tsx`]: renderWebDetailPage(
1057
+ ctx,
1058
+ detailComponent,
1059
+ ),
1060
+ };
1061
+ }
1062
+
1063
+ const WEB_TSCONFIG = `{
1064
+ "compilerOptions": {
1065
+ "target": "ES2022",
1066
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
1067
+ "module": "ESNext",
1068
+ "moduleResolution": "bundler",
1069
+ "jsx": "react-jsx",
1070
+ "noEmit": true,
1071
+ "strict": true,
1072
+ "esModuleInterop": true,
1073
+ "skipLibCheck": true,
1074
+ "allowSyntheticDefaultImports": true,
1075
+ "isolatedModules": true,
1076
+ "useDefineForClassFields": true,
1077
+ // Mirror the HOST root tsconfig — biome sources import host primitives
1078
+ // authored under the host's compiler flags.
1079
+ "exactOptionalPropertyTypes": false,
1080
+ "baseUrl": ".",
1081
+ "paths": {
1082
+ "@/*": ["../../../src/*"],
1083
+ "@xemahq/ui-kernel": ["../../../src/lib/shared/ui-kernel/index.ts"],
1084
+ "@xemahq/ui-kernel/*": ["../../../src/lib/shared/ui-kernel/*"],
1085
+ "@xemahq/*": ["../../../src/lib/shared/*", "../../../src/lib/api/orval/clients/*"]
1086
+ }
1087
+ },
1088
+ "include": ["src/**/*.ts", "src/**/*.tsx"]
1089
+ }
1090
+ `;
1091
+
1092
+ function renderWebIndex(
1093
+ ctx: WebTemplateContext,
1094
+ pageComponent: string,
1095
+ detailComponent: string,
1096
+ navId: string,
1097
+ ): string {
1098
+ return `import { defineWebBiome } from '@xemahq/ui-kernel';
1099
+ import { LayoutGrid } from 'lucide-react';
1100
+
1101
+ /**
1102
+ * Frontend module entry-point for the \`${ctx.webBiomeId}\` biome.
1103
+ *
1104
+ * \`defineWebBiome\` builds the biome from a declarative page list: each page's
1105
+ * \`slug\` single-sources BOTH its nav route and its route path (they can never
1106
+ * drift), and the page is lazily loaded behind a \`<Suspense>\` boundary. Declare
1107
+ * WHAT each surface is via \`category\` — the HOST owns WHERE that category renders
1108
+ * in the menu, so you never pick a global section/weight.
1109
+ *
1110
+ * The host bootstrap fetches \`GET /platform/biomes/web\`, dynamically imports
1111
+ * this module for any entry where \`enabled === true\`, and registers the default
1112
+ * export via \`registerFrontendBiome()\`. Pass \`init\`/\`panels\`/\`session\` to
1113
+ * \`defineWebBiome\` for bespoke contributions; add more entries to \`pages\` (with
1114
+ * \`navHidden: true\` for deeplink-only detail routes).
1115
+ */
1116
+ const ${camelize(ctx.webBiomeId)}Biome = defineWebBiome({
1117
+ id: '${ctx.webBiomeId}',
1118
+ displayName: '${ctx.navLabel}',
1119
+ pages: [
1120
+ {
1121
+ slug: '${navId}',
1122
+ label: '${ctx.navLabel}',
1123
+ icon: LayoutGrid,
1124
+ // One of: primary | build | operate | knowledge | admin | account
1125
+ category: 'build',
1126
+ scope: 'project',
1127
+ load: () => import('./pages/${pageComponent}'),
1128
+ },
1129
+ {
1130
+ // Deeplink-only detail route. This is the LANDING TARGET of the server
1131
+ // biome's search-type \`renderHints.routeTemplate\`
1132
+ // (\`/spaces/projects/{projectId}/${navId}/{sourceId}\`) — without it the
1133
+ // template would expand to a real-looking URL that 404s, which is worse
1134
+ // than shipping no deep link at all. \`navHidden\` keeps it out of the
1135
+ // menu: it is reachable from search and from chat, never browsed to.
1136
+ slug: '${navId}/:id',
1137
+ label: '${ctx.navLabel} detail',
1138
+ icon: LayoutGrid,
1139
+ category: 'build',
1140
+ scope: 'project',
1141
+ navHidden: true,
1142
+ load: () => import('./pages/${detailComponent}'),
1143
+ },
1144
+ ],
1145
+ });
1146
+
1147
+ export default ${camelize(ctx.webBiomeId)}Biome;
1148
+ `;
1149
+ }
1150
+
1151
+ function renderWebDetailPage(
1152
+ ctx: WebTemplateContext,
1153
+ detailComponent: string,
1154
+ ): string {
1155
+ return `import { useBiomeRouteParams } from '@/lib/biomes/biome-host-next';
1156
+
1157
+ /**
1158
+ * Deeplink-only detail page for one \`${ctx.biomeId}\` record.
1159
+ *
1160
+ * Reached by expanding the search-type contribution's \`routeTemplate\`, whose
1161
+ * \`{sourceId}\` placeholder binds to the \`:id\` segment declared on this page's
1162
+ * slug. Both sides name the same closed vocabulary, so a search hit, a chat
1163
+ * citation and a manual URL all land here.
1164
+ */
1165
+ export default function ${detailComponent}(): JSX.Element {
1166
+ const { projectId, id } = useBiomeRouteParams<{
1167
+ projectId?: string;
1168
+ id?: string;
1169
+ }>();
1170
+
1171
+ return (
1172
+ <div className="p-6">
1173
+ <h1 className="text-xl font-semibold">${ctx.navLabel} detail</h1>
1174
+ <p className="mt-2 text-sm text-muted-foreground">
1175
+ Replace this with the real record view — fetch by id from the biome's
1176
+ API and render it.
1177
+ </p>
1178
+ <dl className="mt-4 text-sm">
1179
+ <dt className="font-medium">Record id</dt>
1180
+ <dd>
1181
+ <code>{id ?? '(missing — this route requires an id)'}</code>
1182
+ </dd>
1183
+ <dt className="mt-2 font-medium">Project scope</dt>
1184
+ <dd>
1185
+ <code>{projectId ?? '(none — open from a project)'}</code>
1186
+ </dd>
1187
+ </dl>
1188
+ </div>
1189
+ );
1190
+ }
1191
+ `;
1192
+ }
1193
+
1194
+ function renderWebPage(ctx: WebTemplateContext, pageComponent: string): string {
1195
+ return `import { useBiomeRouteParams } from '@/lib/biomes/biome-host-next';
1196
+
1197
+ /**
1198
+ * Sample project-scoped page for the \`${ctx.webBiomeId}\` biome. Reads the
1199
+ * project id bound by the biome route matcher via the host
1200
+ * \`useBiomeRouteParams\` bridge hook (NEVER \`next/navigation\` — biome pages
1201
+ * must go through the host's route-params context).
1202
+ */
1203
+ export default function ${pageComponent}(): JSX.Element {
1204
+ const { projectId } = useBiomeRouteParams<{ projectId?: string }>();
1205
+
1206
+ return (
1207
+ <div className="p-6">
1208
+ <h1 className="text-xl font-semibold">${ctx.navLabel}</h1>
1209
+ <p className="mt-2 text-sm text-muted-foreground">
1210
+ ${ctx.description}
1211
+ </p>
1212
+ <p className="mt-4 text-sm">
1213
+ Project scope:{' '}
1214
+ <code>{projectId ?? '(none — open from a project)'}</code>
1215
+ </p>
1216
+ </div>
1217
+ );
1218
+ }
1219
+ `;
1220
+ }
1221
+
1222
+ // ── Connector biome ──────────────────────────────────────────────────
1223
+
1224
+ /**
1225
+ * Render a CONNECTOR biome, keyed relative to the biome root.
1226
+ *
1227
+ * Mirrors `repos/xema-community/biomes/connector-telegram`: one
1228
+ * `ConnectorAdapterModule` and nothing else. The module is the SINGLE source
1229
+ * of the provider/connector descriptors — the biome ships no parallel
1230
+ * `*.connector-adapter.contribution.json`, because two declarations of the same
1231
+ * descriptor is exactly the drift this scaffold exists to prevent. The
1232
+ * `xema.contributes: ["connector-adapter"]` whitelist in the manifest is what
1233
+ * `tooling/boundaries/check-connector-biome.mjs` reads.
1234
+ */
1235
+ export function connectorBiomeFiles(ctx: TemplateContext): TemplateFiles {
1236
+ const providerKey = ctx.biomeId.replace(/^connector-/, '');
1237
+ const connectorKey = providerKey.replaceAll('-', '_').toUpperCase();
1238
+ const camel = camelize(providerKey);
1239
+ return {
1240
+ 'README.md': renderConnectorReadme(ctx, providerKey, connectorKey),
1241
+ 'package.json': `${JSON.stringify(
1242
+ {
1243
+ name: `@xemahq/biomes-${ctx.biomeId}`,
1244
+ version: '0.1.0',
1245
+ description: ctx.description,
1246
+ license: 'LicenseRef-Xema-BSL-1.1',
1247
+ private: true,
1248
+ type: 'module',
1249
+ main: 'dist/connector-adapters/index.js',
1250
+ files: ['dist', 'xema-biome.json'],
1251
+ exports: {
1252
+ './connector-adapters': {
1253
+ types: './dist/connector-adapters/index.d.ts',
1254
+ default: './dist/connector-adapters/index.js',
1255
+ },
1256
+ './package.json': './package.json',
1257
+ },
1258
+ scripts: {
1259
+ clean: 'rm -rf dist',
1260
+ build: 'tsc -p tsconfig.json',
1261
+ typecheck: 'tsc --noEmit',
1262
+ format: 'prettier --write "src/**/*.ts"',
1263
+ test: 'tsc -p tsconfig.json && node --test "tests/*.test.mjs"',
1264
+ },
1265
+ dependencies: {
1266
+ '@xemahq/biome-sdk': 'catalog:',
1267
+ '@xemahq/kernel-contracts': 'catalog:',
1268
+ },
1269
+ devDependencies: {
1270
+ '@types/node': 'catalog:',
1271
+ prettier: 'catalog:',
1272
+ typescript: 'catalog:',
1273
+ },
1274
+ },
1275
+ null,
1276
+ 2,
1277
+ )}\n`,
1278
+ 'tsconfig.json': `${JSON.stringify(
1279
+ {
1280
+ extends: '../../tsconfig.base.json',
1281
+ compilerOptions: {
1282
+ outDir: 'dist',
1283
+ rootDir: 'src',
1284
+ module: 'ESNext',
1285
+ moduleResolution: 'Bundler',
1286
+ },
1287
+ include: ['src/**/*.ts'],
1288
+ exclude: ['node_modules', 'dist'],
1289
+ },
1290
+ null,
1291
+ 2,
1292
+ )}\n`,
1293
+ '.gitignore': `node_modules
1294
+ dist
1295
+ .turbo
1296
+ `,
1297
+ 'src/connector-adapters/index.ts': renderConnectorAdapter(
1298
+ ctx,
1299
+ providerKey,
1300
+ connectorKey,
1301
+ camel,
1302
+ ),
1303
+ 'tests/provider.test.mjs': renderConnectorTest(
1304
+ ctx,
1305
+ providerKey,
1306
+ connectorKey,
1307
+ camel,
1308
+ ),
1309
+ };
1310
+ }
1311
+
1312
+ function renderConnectorReadme(
1313
+ ctx: TemplateContext,
1314
+ providerKey: string,
1315
+ connectorKey: string,
1316
+ ): string {
1317
+ return `# ${ctx.displayName}
1318
+
1319
+ ${ctx.description}
1320
+
1321
+ A Xema **connector biome** scaffolded by \`@xemahq/create-biome\`.
1322
+
1323
+ ## What you already have, without authoring anything
1324
+
1325
+ One \`ConnectorAdapterModule\` — the descriptors, the webhook verifier, the event
1326
+ mapper, the deterministic idempotency extractor and the onboarding manifest — is
1327
+ the ONLY thing this biome declares. From it the platform derives:
1328
+
1329
+ - **connector capabilities**, filed under the \`${ctx.biomeId}\` domain;
1330
+ - the **connection-setup flow**, rendered from \`onboarding.fields\` (no bespoke UI);
1331
+ - **credential custody** — the secret is sealed by the credential broker and
1332
+ resolved at use; this biome never reads or stores it;
1333
+ - **OAuth**, when the provider declares \`appAuthKind\` other than \`none\` plus its
1334
+ authorize/token endpoints;
1335
+ - **preflight**, from the declared credential kind + scopes.
1336
+
1337
+ You never write a capability descriptor, a connect dialog, or a token store.
1338
+
1339
+ ## Constraints (CI-enforced)
1340
+
1341
+ \`tooling/boundaries/check-connector-biome.mjs\` requires a \`kind: "connector"\`
1342
+ biome to contribute ≥1 \`connector-adapter\`, to ship **no** product surface (no
1343
+ web target, no pages, no BFF), and to depend on or extend **no** other biome —
1344
+ so any app can reuse it. The scaffold satisfies all three; keep it that way.
1345
+
1346
+ ## Make it real
1347
+
1348
+ 1. Replace the \`${providerKey}\` provider/connector descriptors with the real
1349
+ provider's identity, category, auth kind and scopes.
1350
+ 2. Replace the verifier with the provider's ACTUAL signature scheme, and compare
1351
+ in constant time. Never accept an unverified delivery.
1352
+ 3. Replace the event mapper: return \`ok(null)\` for events you deliberately drop,
1353
+ and \`err(adapterError('malformed-payload', …))\` for ones that are broken.
1354
+ Never forward a fabricated envelope.
1355
+ 4. Keep the idempotency key DETERMINISTIC — the provider's delivery id, or a
1356
+ hash of the canonical body. Never time-based, never random.
1357
+ 5. Declare every credential field the provider needs in \`onboarding.fields\`;
1358
+ that list IS the connect form.
1359
+
1360
+ \`\`\`sh
1361
+ pnpm build && pnpm test
1362
+ \`\`\`
1363
+
1364
+ The connector wire key is \`${connectorKey}\`.
1365
+ `;
1366
+ }
1367
+
1368
+ function renderConnectorAdapter(
1369
+ ctx: TemplateContext,
1370
+ providerKey: string,
1371
+ connectorKey: string,
1372
+ camel: string,
1373
+ ): string {
1374
+ const constPrefix = constCase(providerKey);
1375
+ return `import { createHash, timingSafeEqual } from 'node:crypto';
1376
+
1377
+ import {
1378
+ adapterError,
1379
+ defineConnectorAdapter,
1380
+ err,
1381
+ ok,
1382
+ type ConnectorAdapterModule,
1383
+ type EventMapper,
1384
+ type IdempotencyKeyExtractor,
1385
+ type MappedEnvelope,
1386
+ type WebhookVerifier,
1387
+ } from '@xemahq/biome-sdk/adapter';
1388
+ import {
1389
+ ConnectorOnboardingKind,
1390
+ CredentialFieldTransform,
1391
+ CredentialFieldType,
1392
+ CredentialKind,
1393
+ ProviderAppAuthKind,
1394
+ ProviderOrigin,
1395
+ type ConnectorDescriptor,
1396
+ type ProviderDescriptor,
1397
+ type ProviderOnboardingManifest,
1398
+ } from '@xemahq/kernel-contracts/connector';
1399
+
1400
+ // ═══════════════════════════════════════════════════════════════════════════
1401
+ // ${ctx.displayName} — ConnectorAdapterModule
1402
+ //
1403
+ // This module is the WHOLE biome. Everything a connector gets for free —
1404
+ // capabilities, the connect flow, credential custody, OAuth, preflight — is
1405
+ // derived from the declarations below. There is no capability descriptor to
1406
+ // write and no webhook route to mount: inbound deliveries terminate at the
1407
+ // single connector-gateway ingress edge, which routes them here by
1408
+ // \`provider\`.
1409
+ //
1410
+ // Replace every placeholder marked TODO with the real provider's behaviour.
1411
+ // ═══════════════════════════════════════════════════════════════════════════
1412
+
1413
+ /** Provider slug — the key the ingress edge routes deliveries by. */
1414
+ export const ${constPrefix}_PROVIDER_KEY = '${providerKey}';
1415
+
1416
+ /** Human-readable provider name, shared by onboarding + both descriptors. */
1417
+ const ${constPrefix}_DISPLAY_NAME = '${ctx.displayName}';
1418
+
1419
+ /** Lucide-react icon name, shared by onboarding + both descriptors. */
1420
+ const ${constPrefix}_ICON_NAME = 'Plug';
1421
+
1422
+ const ${constPrefix}_DESCRIPTION = '${ctx.description.replaceAll("'", "\\'")}';
1423
+
1424
+ /**
1425
+ * Biome-contributed adapter kind. NOT a member of the kernel's built-in set —
1426
+ * it is the canonical string this connector declares, and the value the
1427
+ * gateway routes on.
1428
+ */
1429
+ export const ${constPrefix}_ADAPTER_KIND = '${providerKey}';
1430
+
1431
+ /** Header the provider stamps its signature on. TODO: use the real one. */
1432
+ const SIGNATURE_HEADER = 'x-${providerKey}-signature';
1433
+
1434
+ // ── Webhook verifier — constant-time, fail-closed ───────────────────────────
1435
+
1436
+ const ${camel}Verifier: WebhookVerifier = {
1437
+ signatureHeader: SIGNATURE_HEADER,
1438
+ // TODO: switch to the provider's real algorithm (e.g. 'hmac-sha256') and
1439
+ // verify over the RAW body bytes, not a re-serialized object.
1440
+ algorithm: 'none',
1441
+ secretSource: 'org-connection-secret',
1442
+ verify({ headers, secret }) {
1443
+ const provided = headers[SIGNATURE_HEADER];
1444
+ if (typeof provided !== 'string' || provided.length === 0) {
1445
+ return err(
1446
+ adapterError('verification-failed', \`missing \${SIGNATURE_HEADER} header\`),
1447
+ );
1448
+ }
1449
+ if (provided.length !== secret.length) {
1450
+ return err(adapterError('verification-failed', 'signature length mismatch'));
1451
+ }
1452
+ let equal: boolean;
1453
+ try {
1454
+ equal = timingSafeEqual(
1455
+ Buffer.from(provided, 'utf8'),
1456
+ Buffer.from(secret, 'utf8'),
1457
+ );
1458
+ } catch {
1459
+ return err(adapterError('verification-failed', 'signature decode failed'));
1460
+ }
1461
+ return equal
1462
+ ? ok(undefined)
1463
+ : err(adapterError('verification-failed', 'signature mismatch'));
1464
+ },
1465
+ };
1466
+
1467
+ // ── Idempotency — deterministic, never time-based ───────────────────────────
1468
+
1469
+ /**
1470
+ * A retried delivery MUST produce the same key as its original, or the
1471
+ * platform will process it twice. Prefer the provider's own delivery id; fall
1472
+ * back to a hash of the canonical body so identical bytes dedup. An
1473
+ * underivable key is a hard error — never fabricate a unique one.
1474
+ */
1475
+ const ${camel}IdempotencyKeyExtractor: IdempotencyKeyExtractor = ({
1476
+ rawBody,
1477
+ }) => {
1478
+ if (rawBody && typeof rawBody === 'object') {
1479
+ // TODO: read the provider's real delivery id field.
1480
+ const deliveryId = (rawBody as Record<string, unknown>)['id'];
1481
+ if (typeof deliveryId === 'string' && deliveryId.length > 0) {
1482
+ return \`${providerKey}:\${deliveryId}\`;
1483
+ }
1484
+ }
1485
+ const canonical =
1486
+ typeof rawBody === 'string'
1487
+ ? rawBody
1488
+ : rawBody instanceof Uint8Array
1489
+ ? Buffer.from(rawBody).toString('utf8')
1490
+ : rawBody && typeof rawBody === 'object'
1491
+ ? JSON.stringify(rawBody)
1492
+ : '';
1493
+ if (canonical.length === 0) {
1494
+ throw new Error(
1495
+ '${providerKey} webhook: cannot derive a deterministic idempotency key (no delivery id and empty body)',
1496
+ );
1497
+ }
1498
+ return \`${providerKey}:\${createHash('sha256').update(canonical, 'utf8').digest('hex')}\`;
1499
+ };
1500
+
1501
+ // ── Event mapper — provider payload → canonical envelope ────────────────────
1502
+
1503
+ const ${camel}EventMapper: EventMapper = {
1504
+ map({ rawEvent }) {
1505
+ if (!rawEvent || typeof rawEvent !== 'object') {
1506
+ return err(
1507
+ adapterError('malformed-payload', '${providerKey} body is not an object'),
1508
+ );
1509
+ }
1510
+ const payload = rawEvent as Record<string, unknown>;
1511
+ const externalId = payload['id'];
1512
+ if (typeof externalId !== 'string' || externalId.length === 0) {
1513
+ // TODO: decide per event type. Return ok(null) to record-and-drop an
1514
+ // event this connector deliberately does not model; return err(...) only
1515
+ // when the payload is genuinely malformed.
1516
+ return err(
1517
+ adapterError('malformed-payload', '${providerKey} event: string \`id\` required'),
1518
+ );
1519
+ }
1520
+ const envelope: MappedEnvelope = {
1521
+ // TODO: the canonical entity kind + event this connector produces.
1522
+ entityKind: 'record',
1523
+ event: '${providerKey}.record.received',
1524
+ payload,
1525
+ externalId,
1526
+ };
1527
+ return ok(envelope);
1528
+ },
1529
+ };
1530
+
1531
+ // ── Onboarding manifest — this list IS the connect form ─────────────────────
1532
+
1533
+ const ${camel}Onboarding: ProviderOnboardingManifest = {
1534
+ provider: ${constPrefix}_PROVIDER_KEY,
1535
+ displayName: ${constPrefix}_DISPLAY_NAME,
1536
+ description: ${constPrefix}_DESCRIPTION,
1537
+ iconName: ${constPrefix}_ICON_NAME,
1538
+ kind: ConnectorOnboardingKind.CREDENTIALS,
1539
+ supportedProjectBindingAdapterKinds: [],
1540
+ fields: [
1541
+ {
1542
+ key: 'apiKey',
1543
+ label: 'API key',
1544
+ type: CredentialFieldType.PASSWORD,
1545
+ required: true,
1546
+ isSecret: true,
1547
+ hint: 'Held by the credential broker and resolved at use — this biome never reads it.',
1548
+ transform: CredentialFieldTransform.TRIM,
1549
+ },
1550
+ ],
1551
+ hint: 'TODO: tell the operator where to obtain the credential.',
1552
+ };
1553
+
1554
+ // ── Provider + connector descriptors ────────────────────────────────────────
1555
+
1556
+ export const providerDescriptor: ProviderDescriptor = {
1557
+ providerKey: ${constPrefix}_PROVIDER_KEY,
1558
+ displayName: ${constPrefix}_DISPLAY_NAME,
1559
+ description: ${constPrefix}_DESCRIPTION,
1560
+ iconName: ${constPrefix}_ICON_NAME,
1561
+ category: 'Other',
1562
+ // No shared platform app ⇒ every connection carries its own complete secret,
1563
+ // so the connector is usable with zero org-level setup. Switch to OAuth2 and
1564
+ // declare the authorize/token endpoints when the provider has a shared app.
1565
+ appAuthKind: ProviderAppAuthKind.None,
1566
+ origin: ProviderOrigin.Biome,
1567
+ allowOrgOverride: false,
1568
+ };
1569
+
1570
+ export const connectorDescriptors: readonly ConnectorDescriptor[] = [
1571
+ {
1572
+ connectorKey: '${connectorKey}',
1573
+ displayName: ${constPrefix}_DISPLAY_NAME,
1574
+ description: ${constPrefix}_DESCRIPTION,
1575
+ iconName: ${constPrefix}_ICON_NAME,
1576
+ providerKey: ${constPrefix}_PROVIDER_KEY,
1577
+ scopes: [],
1578
+ connectionCredentialKind: CredentialKind.ApiKey,
1579
+ adapterKind: ${constPrefix}_ADAPTER_KIND,
1580
+ },
1581
+ ];
1582
+
1583
+ /**
1584
+ * The provider module. The gateway's loader imports \`default\` (or the named
1585
+ * \`connectorAdapters\` array) and registers every entry.
1586
+ */
1587
+ export const ${camel}Provider: ConnectorAdapterModule = defineConnectorAdapter({
1588
+ adapterKind: ${constPrefix}_ADAPTER_KIND,
1589
+ provider: ${constPrefix}_PROVIDER_KEY,
1590
+ displayName: ${constPrefix}_DISPLAY_NAME,
1591
+ webhook: {
1592
+ verifier: ${camel}Verifier,
1593
+ eventMapper: ${camel}EventMapper,
1594
+ idempotencyKeyExtractor: ${camel}IdempotencyKeyExtractor,
1595
+ },
1596
+ credentialKind: CredentialKind.ApiKey,
1597
+ resources: {},
1598
+ actions: {},
1599
+ onboarding: ${camel}Onboarding,
1600
+ });
1601
+
1602
+ const connectorAdapters: readonly ConnectorAdapterModule[] = [${camel}Provider];
1603
+ export default connectorAdapters;
1604
+ `;
1605
+ }
1606
+
1607
+ function renderConnectorTest(
1608
+ ctx: TemplateContext,
1609
+ providerKey: string,
1610
+ connectorKey: string,
1611
+ camel: string,
1612
+ ): string {
1613
+ const constPrefix = constCase(providerKey);
1614
+ return `// ${ctx.displayName} — connector-adapter conformance tests.
1615
+ //
1616
+ // Run against the BUILT module (\`pnpm test\` builds first): these assert the
1617
+ // invariants the ingress edge relies on — fail-closed verification and a
1618
+ // deterministic idempotency key — so a change that breaks either is caught
1619
+ // here rather than as duplicate processing in production.
1620
+
1621
+ import assert from 'node:assert/strict';
1622
+ import { test } from 'node:test';
1623
+
1624
+ import connectorAdapters, {
1625
+ ${constPrefix}_ADAPTER_KIND,
1626
+ ${constPrefix}_PROVIDER_KEY,
1627
+ connectorDescriptors,
1628
+ providerDescriptor,
1629
+ ${camel}Provider,
1630
+ } from '../dist/connector-adapters/index.js';
1631
+
1632
+ test('the biome contributes exactly one ConnectorAdapterModule', () => {
1633
+ assert.equal(connectorAdapters.length, 1);
1634
+ assert.equal(connectorAdapters[0], ${camel}Provider);
1635
+ assert.equal(${camel}Provider.provider, ${constPrefix}_PROVIDER_KEY);
1636
+ assert.equal(${camel}Provider.adapterKind, ${constPrefix}_ADAPTER_KIND);
1637
+ });
1638
+
1639
+ test('the descriptors that drive connect + custody are complete', () => {
1640
+ assert.equal(providerDescriptor.providerKey, ${constPrefix}_PROVIDER_KEY);
1641
+ assert.equal(connectorDescriptors.length, 1);
1642
+ assert.equal(connectorDescriptors[0].connectorKey, '${connectorKey}');
1643
+ assert.equal(connectorDescriptors[0].providerKey, ${constPrefix}_PROVIDER_KEY);
1644
+ // The connect form is rendered from these fields; an empty list means the
1645
+ // operator is shown a form that collects nothing.
1646
+ assert.ok(${camel}Provider.onboarding.fields.length > 0);
1647
+ });
1648
+
1649
+ test('verification fails closed on a missing or mismatched signature', () => {
1650
+ const { verifier } = ${camel}Provider.webhook;
1651
+ assert.equal(
1652
+ verifier.verify({ rawBody: new Uint8Array(), headers: {}, secret: 's3cret' }).ok,
1653
+ false,
1654
+ );
1655
+ assert.equal(
1656
+ verifier.verify({
1657
+ rawBody: new Uint8Array(),
1658
+ headers: { [verifier.signatureHeader]: 'wrong!' },
1659
+ secret: 's3cret',
1660
+ }).ok,
1661
+ false,
1662
+ );
1663
+ assert.equal(
1664
+ verifier.verify({
1665
+ rawBody: new Uint8Array(),
1666
+ headers: { [verifier.signatureHeader]: 's3cret' },
1667
+ secret: 's3cret',
1668
+ }).ok,
1669
+ true,
1670
+ );
1671
+ });
1672
+
1673
+ test('the idempotency key is deterministic across redeliveries', () => {
1674
+ const extract = ${camel}Provider.webhook.idempotencyKeyExtractor;
1675
+ const body = { id: 'evt_1' };
1676
+ assert.equal(extract({ rawBody: body, headers: {} }), extract({ rawBody: body, headers: {} }));
1677
+ assert.notEqual(
1678
+ extract({ rawBody: body, headers: {} }),
1679
+ extract({ rawBody: { id: 'evt_2' }, headers: {} }),
1680
+ );
1681
+ });
1682
+
1683
+ test('an underivable idempotency key throws rather than fabricating one', () => {
1684
+ const extract = ${camel}Provider.webhook.idempotencyKeyExtractor;
1685
+ assert.throws(() => extract({ rawBody: '', headers: {} }));
1686
+ });
1687
+ `;
1688
+ }
1689
+
1690
+ // ── Public renderers ─────────────────────────────────────────────────
1691
+
1692
+ /**
1693
+ * All files for a SERVER biome (manifest is written separately by the
1694
+ * scaffolder), keyed relative to the biome root `biomes/<id>/`.
1695
+ */
1696
+ export function renderServerBiomeFiles(ctx: TemplateContext): TemplateFiles {
1697
+ return {
1698
+ ...serverBiomeRootFiles(ctx),
1699
+ ...serverApiFiles(ctx),
1700
+ };
1701
+ }