create-ortha-app 0.4.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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +7 -0
  3. package/dist/cli.d.ts +3 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +277 -0
  6. package/dist/index.d.ts +11 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +32 -0
  9. package/dist/lib/conditionals.d.ts +37 -0
  10. package/dist/lib/conditionals.d.ts.map +1 -0
  11. package/dist/lib/conditionals.js +115 -0
  12. package/dist/lib/features.d.ts +185 -0
  13. package/dist/lib/features.d.ts.map +1 -0
  14. package/dist/lib/features.js +328 -0
  15. package/dist/lib/template.d.ts +46 -0
  16. package/dist/lib/template.d.ts.map +1 -0
  17. package/dist/lib/template.js +115 -0
  18. package/dist/lib/ui.d.ts +76 -0
  19. package/dist/lib/ui.d.ts.map +1 -0
  20. package/dist/lib/ui.js +310 -0
  21. package/dist/lib/validate.d.ts +13 -0
  22. package/dist/lib/validate.d.ts.map +1 -0
  23. package/dist/lib/validate.js +51 -0
  24. package/package.json +37 -0
  25. package/templates/default/README.md.tmpl +199 -0
  26. package/templates/default/_gitignore +8 -0
  27. package/templates/default/apps/admin/index.html +38 -0
  28. package/templates/default/apps/admin/src/main.tsx +5 -0
  29. package/templates/default/apps/admin/src/plugins.spec.ts +58 -0
  30. package/templates/default/apps/admin/src/plugins.ts +57 -0
  31. package/templates/default/apps/admin/src/styles.css +254 -0
  32. package/templates/default/apps/admin/tsconfig.json +29 -0
  33. package/templates/default/apps/admin/vite.config.mts +67 -0
  34. package/templates/default/apps/admin-e2e/playwright.config.ts +51 -0
  35. package/templates/default/apps/admin-e2e/src/auth.spec.ts +55 -0
  36. package/templates/default/apps/admin-e2e/src/support/seed.ts +62 -0
  37. package/templates/default/apps/admin-e2e/tsconfig.json +23 -0
  38. package/templates/default/apps/server/jest.config.js +38 -0
  39. package/templates/default/apps/server/jest.setup.js +20 -0
  40. package/templates/default/apps/server/ortha.config.ts +505 -0
  41. package/templates/default/apps/server/src/main.ts +26 -0
  42. package/templates/default/apps/server/src/plugins.spec.ts +71 -0
  43. package/templates/default/apps/server/src/plugins.ts +229 -0
  44. package/templates/default/apps/server/tsconfig.json +31 -0
  45. package/templates/default/apps/server-e2e/jest.config.js +47 -0
  46. package/templates/default/apps/server-e2e/src/api.spec.ts +107 -0
  47. package/templates/default/apps/server-e2e/src/global-setup.ts +41 -0
  48. package/templates/default/apps/server-e2e/src/jest.setup.ts +28 -0
  49. package/templates/default/apps/server-e2e/src/support/db.ts +143 -0
  50. package/templates/default/apps/server-e2e/src/support/test-app.ts +64 -0
  51. package/templates/default/apps/server-e2e/tsconfig.json +26 -0
  52. package/templates/default/docker-compose.yml +21 -0
  53. package/templates/default/env.tmpl +140 -0
  54. package/templates/default/package.json.tmpl +51 -0
  55. package/templates/default/tsconfig.json +18 -0
@@ -0,0 +1,505 @@
1
+ /**
2
+ * Typed configuration for this app.
3
+ *
4
+ * **The single place that reads `process.env`.** Everything downstream — the
5
+ * host, every plugin — receives typed values, so "where does this setting come
6
+ * from" has exactly one answer. Deploy-specific values come from the
7
+ * environment; stable product tuning lives here as literals.
8
+ */
9
+ import { join } from 'node:path';
10
+ import type {
11
+ ApiDocsOptions,
12
+ TrustProxySetting
13
+ } from '@orthacms/bootstrap-server';
14
+ import type { IdentityPluginConfig } from '@orthacms/identity-server';
15
+ // ortha:if sso-oidc
16
+ import type { OidcProviderConfig } from '@orthacms/identity-provider-oidc';
17
+ // ortha:end
18
+ import type { I18nPluginConfig } from '@orthacms/i18n-server';
19
+ import type { MediaPluginConfig } from '@orthacms/media-server';
20
+ // ortha:if media-local
21
+ import type { LocalStorageConfig } from '@orthacms/media-provider-local';
22
+ // ortha:end
23
+ // ortha:if media-s3
24
+ import type { S3StorageConfig } from '@orthacms/media-provider-s3';
25
+ // ortha:end
26
+ // ortha:if media-azure
27
+ import type { AzureStorageConfig } from '@orthacms/media-provider-azure';
28
+ // ortha:end
29
+ // ortha:if media-gcs
30
+ import type { GcsStorageConfig } from '@orthacms/media-provider-gcs';
31
+ // ortha:end
32
+ // ortha:if media-vercel-blob
33
+ import type { VercelBlobStorageConfig } from '@orthacms/media-provider-vercel-blob';
34
+ // ortha:end
35
+ import type { CopilotPluginConfig } from '@orthacms/copilot-server';
36
+ // ortha:if copilot-anthropic
37
+ import type { AnthropicProviderConfig } from '@orthacms/copilot-provider-anthropic';
38
+ // ortha:end
39
+ // ortha:if copilot-openai
40
+ import type { OpenAiProviderConfig } from '@orthacms/copilot-provider-openai';
41
+ // ortha:end
42
+ // ortha:if mcp
43
+ import type { McpPluginConfig } from '@orthacms/mcp-server';
44
+ // ortha:end
45
+
46
+ /**
47
+ * Copilot settings plus the backends this deployment can reach.
48
+ *
49
+ * The provider settings live **here**, not inside `CopilotPluginConfig`: the
50
+ * plugin is adapter-agnostic by decision, so it names no provider kind. A key
51
+ * is present only when the deployment configured that backend, and `plugins.ts`
52
+ * registers exactly the ones that are — "configured" is a fact this file can
53
+ * read, where a `defaultProvider` naming one of them could be misspelled or
54
+ * point at a backend nobody registered.
55
+ */
56
+ /**
57
+ * Identity settings, plus the identity providers this app can reach.
58
+ *
59
+ * The provider settings live here rather than inside `IdentityPluginConfig`,
60
+ * for the same reason the copilot's backends do: the plugin names no protocol,
61
+ * and this file is the one place that reads the environment. The constructed
62
+ * adapters are registered in `src/plugins.ts`.
63
+ */
64
+ export interface AppIdentityConfig extends IdentityPluginConfig {
65
+ /**
66
+ * Identity providers, keyed by the name they are registered under. That
67
+ * name appears in the sign-in URL and in every `sso_identities` row, so
68
+ * renaming one orphans the links that name it.
69
+ *
70
+ * Optional, and absent unless this app was generated with single sign-on.
71
+ */
72
+ ssoProviders?: {
73
+ // ortha:if sso-oidc
74
+ /** A generic OpenID Connect provider. Present when both env vars are set. */
75
+ oidc?: OidcProviderConfig & { name: string };
76
+ // ortha:end
77
+ };
78
+ }
79
+
80
+ export interface AppCopilotConfig extends CopilotPluginConfig {
81
+ providers: {
82
+ // ortha:if copilot-anthropic
83
+ /** Native Claude. Present when ANTHROPIC_API_KEY is set. */
84
+ claude?: AnthropicProviderConfig;
85
+ // ortha:end
86
+ // ortha:if copilot-openai
87
+ /**
88
+ * An OpenAI-wire endpoint — Ollama, vLLM, LiteLLM, Azure or OpenAI.
89
+ * Present when COPILOT_OPENAI_BASE_URL is set: an endpoint nobody
90
+ * named is a backend that can only time out.
91
+ */
92
+ openai?: OpenAiProviderConfig;
93
+ // ortha:end
94
+ };
95
+ }
96
+
97
+ /** Root configuration for this app. */
98
+ export interface OrthaConfig {
99
+ port: number;
100
+ globalPrefix: string;
101
+ trustProxy?: TrustProxySetting;
102
+ bodyLimit?: string | number;
103
+ staticDir?: string;
104
+ database: { url: string };
105
+ docs: ApiDocsOptions;
106
+ plugins: {
107
+ identity: AppIdentityConfig;
108
+ i18n: I18nPluginConfig;
109
+ media: MediaPluginConfig & {
110
+ /**
111
+ * Settings for the storage backend `plugins.ts` constructs. Typed
112
+ * by the factory it imports — the two move together.
113
+ */
114
+ // ortha:if media-local
115
+ storage: LocalStorageConfig;
116
+ // ortha:end
117
+ // ortha:if media-s3
118
+ storage: S3StorageConfig;
119
+ // ortha:end
120
+ // ortha:if media-azure
121
+ storage: AzureStorageConfig;
122
+ // ortha:end
123
+ // ortha:if media-gcs
124
+ storage: GcsStorageConfig;
125
+ // ortha:end
126
+ // ortha:if media-vercel-blob
127
+ storage: VercelBlobStorageConfig;
128
+ // ortha:end
129
+ };
130
+ copilot: AppCopilotConfig;
131
+ // ortha:if mcp
132
+ mcp: McpPluginConfig;
133
+ // ortha:end
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Reads a value the app cannot run without, failing at load rather than
139
+ * several seconds into boot.
140
+ *
141
+ * Allowed to default to `''`, a missing `DATABASE_URL` reaches `pg` as "use
142
+ * the libpq defaults" — so the first query fails with whatever the local
143
+ * environment happens to produce, and nothing in the message names the
144
+ * variable nobody set.
145
+ */
146
+ function requireEnv(name: string): string {
147
+ const raw = process.env[name]?.trim();
148
+ if (!raw) {
149
+ throw new Error(
150
+ `Missing required environment variable ${name}. ` +
151
+ 'Set it in your .env before starting the app.'
152
+ );
153
+ }
154
+ return raw;
155
+ }
156
+
157
+ /**
158
+ * A numeric setting: the default when unset, the value when it is a plain
159
+ * positive integer, and an error otherwise.
160
+ *
161
+ * Deliberately not `Number(process.env[x]) || fallback`, which is wrong in
162
+ * three directions and silent in all of them: `0` is falsy so it becomes the
163
+ * default, a negative is truthy so it is accepted (a negative session TTL
164
+ * issues every session already expired), and `1e9` parses.
165
+ */
166
+ function readPositiveInt(name: string, fallback: number): number {
167
+ const raw = process.env[name]?.trim();
168
+ if (!raw) return fallback;
169
+
170
+ if (!/^\d+$/.test(raw) || Number(raw) <= 0) {
171
+ throw new Error(
172
+ `Environment variable ${name} must be a positive whole number ` +
173
+ `(got "${raw}").`
174
+ );
175
+ }
176
+ return Number(raw);
177
+ }
178
+
179
+ /**
180
+ * Express's `trust proxy` setting: a hop count (the recommended form, and the
181
+ * only one a client cannot forge past), a boolean, or a subnet/preset string
182
+ * passed through verbatim. Unset leaves forwarded headers ignored.
183
+ */
184
+ function readTrustProxy(): TrustProxySetting | undefined {
185
+ const raw = process.env['TRUST_PROXY']?.trim();
186
+ if (!raw) return undefined;
187
+
188
+ const hops = Number(raw);
189
+ if (Number.isInteger(hops) && hops >= 0) return hops;
190
+ if (raw === 'true' || raw === 'false') return raw === 'true';
191
+
192
+ return raw;
193
+ }
194
+
195
+ /**
196
+ * True only in a deployment that said so, spelling checked.
197
+ *
198
+ * This one comparison gates two protections at once — whether the API
199
+ * reference is published, and whether the session cookie carries `Secure` — so
200
+ * a typo silently turns both off and is indistinguishable from correct
201
+ * configuration until you read a `Set-Cookie` header.
202
+ */
203
+ const NODE_ENVS = ['development', 'test', 'production'] as const;
204
+ const nodeEnv = process.env['NODE_ENV']?.trim();
205
+
206
+ if (nodeEnv && !(NODE_ENVS as readonly string[]).includes(nodeEnv)) {
207
+ throw new Error(
208
+ `NODE_ENV is "${nodeEnv}", which this app does not recognise — expected ` +
209
+ `one of ${NODE_ENVS.join(', ')}, or nothing at all for local ` +
210
+ 'development. Anything else reads as "not production", which ' +
211
+ 'publishes the API reference and drops `Secure` from the session cookie.'
212
+ );
213
+ }
214
+
215
+ const isProduction = nodeEnv === 'production';
216
+
217
+ /** A comma-separated list setting, trimmed and emptied of blanks. */
218
+ function readList(name: string, fallback: string): string[] {
219
+ return (process.env[name] ?? fallback)
220
+ .split(',')
221
+ .map((item) => item.trim())
222
+ .filter(Boolean);
223
+ }
224
+ // ortha:if copilot-anthropic
225
+ const anthropicApiKey = process.env['ANTHROPIC_API_KEY']?.trim();
226
+ // ortha:end
227
+ // ortha:if copilot-openai
228
+ const openAiBaseUrl = process.env['COPILOT_OPENAI_BASE_URL']?.trim();
229
+ // ortha:end
230
+
231
+ const config: OrthaConfig = {
232
+ port: readPositiveInt('PORT', 3000),
233
+ globalPrefix: 'api',
234
+ trustProxy: readTrustProxy(),
235
+ bodyLimit: process.env['MAX_REQUEST_BODY'] || '1mb',
236
+ // The built admin bundle, served by this same process so the API and the
237
+ // UI share one origin — which is what identity's httpOnly, SameSite=lax
238
+ // session cookie needs. `ortha dev` uses Vite's proxy for the same effect.
239
+ //
240
+ // Relative to the app root: `ortha start` runs from there, and this path
241
+ // must mean the same thing whether it is read from `dist/` or from source.
242
+ staticDir: join(process.cwd(), 'dist/admin'),
243
+ database: {
244
+ url: requireEnv('DATABASE_URL')
245
+ },
246
+ docs: {
247
+ // On outside production, where the reference is a development tool.
248
+ // `API_DOCS=true` publishes it from a deployed instance.
249
+ enabled: process.env['API_DOCS']
250
+ ? process.env['API_DOCS'] === 'true'
251
+ : !isProduction,
252
+ title: '__APP_TITLE__ API',
253
+ version: '1.0.0'
254
+ },
255
+ plugins: {
256
+ identity: {
257
+ // Origins allowed to make state-changing calls (login-CSRF
258
+ // defence). In development that is the Vite dev server; in
259
+ // production the app is same-origin, so this list is what a
260
+ // separately-hosted admin would need adding to.
261
+ allowedOrigins: (
262
+ process.env['ALLOWED_ORIGINS'] ??
263
+ `http://localhost:${readPositiveInt('ADMIN_PORT', 4200)}`
264
+ )
265
+ .split(',')
266
+ .map((origin) => origin.trim())
267
+ .filter(Boolean),
268
+ session: {
269
+ ttlSeconds: readPositiveInt(
270
+ 'SESSION_TTL_SECONDS',
271
+ 60 * 60 * 24 * 7
272
+ ),
273
+ cookieSecure: isProduction,
274
+ cookieSameSite: 'lax'
275
+ },
276
+ token: {
277
+ inviteTtlSeconds: readPositiveInt(
278
+ 'INVITE_TTL_SECONDS',
279
+ 60 * 60 * 24 * 7
280
+ ),
281
+ resetTtlSeconds: readPositiveInt('RESET_TTL_SECONDS', 60 * 60)
282
+ },
283
+ rateLimit: {
284
+ ttlSeconds: readPositiveInt('LOGIN_RATE_LIMIT_TTL_SECONDS', 60),
285
+ limit: readPositiveInt('LOGIN_RATE_LIMIT', 10)
286
+ },
287
+ // Single sign-on. The providers themselves are constructed in
288
+ // `src/plugins.ts`; these settings shape the handshake.
289
+ sso: {
290
+ // The origin browsers reach this API on. It builds the
291
+ // redirect_uri you register with each provider, and it is
292
+ // configured rather than read from the request's Host header,
293
+ // which a client controls. Leave it unset when the admin and
294
+ // the API share an origin — the usual case.
295
+ ...(process.env['SSO_PUBLIC_BASE_URL']
296
+ ? { publicBaseUrl: process.env['SSO_PUBLIC_BASE_URL'] }
297
+ : {}),
298
+ requestTtlSeconds: readPositiveInt(
299
+ 'SSO_REQUEST_TTL_SECONDS',
300
+ 600
301
+ )
302
+ },
303
+ // ortha:if sso-oidc
304
+ ssoProviders: {
305
+ // Present only when both are set: an issuer with no client id
306
+ // becomes a sign-in button that can only fail, and every SSO
307
+ // failure looks the same, so whoever clicks it learns nothing.
308
+ ...(process.env['SSO_OIDC_ISSUER'] &&
309
+ process.env['SSO_OIDC_CLIENT_ID']
310
+ ? {
311
+ oidc: {
312
+ name: process.env['SSO_OIDC_NAME'] ?? 'oidc',
313
+ issuer: process.env['SSO_OIDC_ISSUER'],
314
+ clientId: process.env['SSO_OIDC_CLIENT_ID'],
315
+ ...(process.env['SSO_OIDC_CLIENT_SECRET']
316
+ ? {
317
+ clientSecret:
318
+ process.env[
319
+ 'SSO_OIDC_CLIENT_SECRET'
320
+ ]
321
+ }
322
+ : {}),
323
+ ...(process.env['SSO_OIDC_LABEL']
324
+ ? { label: process.env['SSO_OIDC_LABEL'] }
325
+ : {}),
326
+ // The only gate on a first sign-in claiming an
327
+ // existing account. A provider that omits the
328
+ // claim — Entra ID, notably — links nobody until
329
+ // an operator asserts that this directory owns
330
+ // the addresses it reports.
331
+ emailVerifiedWhenAbsent:
332
+ process.env[
333
+ 'SSO_OIDC_EMAIL_VERIFIED_WHEN_ABSENT'
334
+ ] === 'true'
335
+ }
336
+ }
337
+ : {})
338
+ },
339
+ // ortha:end
340
+ // With an email set, an admin is provisioned on boot — idempotent
341
+ // and non-destructive. This is how you get your first login.
342
+ rootAdmin: {
343
+ email: process.env['ORTHA_ROOT_ADMIN_EMAIL'] ?? '',
344
+ password: process.env['ORTHA_ROOT_ADMIN_PASSWORD'] ?? '',
345
+ name: process.env['ORTHA_ROOT_ADMIN_NAME'] ?? ''
346
+ }
347
+ },
348
+ i18n: {
349
+ // Content locales. Stable product configuration, hence literals.
350
+ // The slugs are stored on entry rows, so removing one hides its
351
+ // rows rather than deleting them — which is what `orphanedLocales`
352
+ // is about below.
353
+ locales: [{ slug: 'en', name: 'English', isDefault: true }],
354
+ // Rows in a locale no longer listed above are intact and
355
+ // unreachable, the worst shape for a silent failure. Fail the boot
356
+ // and put the choice in front of whoever edited the array.
357
+ orphanedLocales: 'fail'
358
+ },
359
+ media: {
360
+ // ortha:if media-local
361
+ storage: {
362
+ // Point MEDIA_LOCAL_ROOT at a persistent volume in production:
363
+ // a container's own disk is wiped on every deploy.
364
+ rootDir: process.env['MEDIA_LOCAL_ROOT'] ?? './.storage/media'
365
+ },
366
+ // ortha:end
367
+ // ortha:if media-vercel-blob
368
+ storage: {
369
+ // On Vercel the SDK reads BLOB_READ_WRITE_TOKEN itself, so this
370
+ // is only for running the app elsewhere.
371
+ ...(process.env['BLOB_READ_WRITE_TOKEN']
372
+ ? { token: process.env['BLOB_READ_WRITE_TOKEN'] }
373
+ : {})
374
+ },
375
+ // ortha:end
376
+ // ortha:if media-gcs
377
+ storage: {
378
+ bucket: requireEnv('MEDIA_GCS_BUCKET'),
379
+ // Everything else is optional: with no key file and no inline
380
+ // credentials the client uses Application Default Credentials,
381
+ // which is what a GKE or Cloud Run deployment wants.
382
+ ...(process.env['MEDIA_GCS_PROJECT_ID']
383
+ ? { projectId: process.env['MEDIA_GCS_PROJECT_ID'] }
384
+ : {}),
385
+ ...(process.env['MEDIA_GCS_KEY_FILE']
386
+ ? { keyFilename: process.env['MEDIA_GCS_KEY_FILE'] }
387
+ : {}),
388
+ signWithIam: process.env['MEDIA_GCS_SIGN_WITH_IAM'] === 'true'
389
+ },
390
+ // ortha:end
391
+ // ortha:if media-azure
392
+ storage: {
393
+ container: requireEnv('MEDIA_AZURE_CONTAINER'),
394
+ connectionString: requireEnv('MEDIA_AZURE_CONNECTION_STRING')
395
+ },
396
+ // ortha:end
397
+ // ortha:if media-s3
398
+ storage: {
399
+ bucket: requireEnv('MEDIA_S3_BUCKET'),
400
+ // `auto` is what R2 expects; AWS needs its real region.
401
+ region: process.env['MEDIA_S3_REGION'] ?? 'auto',
402
+ // Omit for AWS S3 itself; set it for R2, MinIO, Spaces, B2…
403
+ ...(process.env['MEDIA_S3_ENDPOINT']
404
+ ? { endpoint: process.env['MEDIA_S3_ENDPOINT'] }
405
+ : {}),
406
+ forcePathStyle:
407
+ process.env['MEDIA_S3_FORCE_PATH_STYLE'] === 'true',
408
+ // Absent means "use the SDK's own provider chain" — an instance
409
+ // role, IRSA, a shared config file. Passing blanks instead
410
+ // would shadow all of that with credentials that cannot sign.
411
+ ...(process.env['MEDIA_S3_ACCESS_KEY_ID'] &&
412
+ process.env['MEDIA_S3_SECRET_ACCESS_KEY']
413
+ ? {
414
+ credentials: {
415
+ accessKeyId: process.env['MEDIA_S3_ACCESS_KEY_ID'],
416
+ secretAccessKey:
417
+ process.env['MEDIA_S3_SECRET_ACCESS_KEY']
418
+ }
419
+ }
420
+ : {})
421
+ },
422
+ // ortha:end
423
+ // Redirect an already-authorized download straight to the
424
+ // storage backend instead of streaming it through the app. Off
425
+ // unless asked for, and only possible on a backend that can sign a
426
+ // URL — the plugin refuses the combination at boot rather than
427
+ // proxying while the operator believes otherwise.
428
+ directServe:
429
+ process.env['MEDIA_DIRECT_SERVE'] === 'signed-url'
430
+ ? 'signed-url'
431
+ : 'off',
432
+ directServeTtlSeconds: readPositiveInt(
433
+ 'MEDIA_DIRECT_SERVE_TTL_SECONDS',
434
+ 300
435
+ ),
436
+ maxUploadBytes: readPositiveInt(
437
+ 'MEDIA_MAX_UPLOAD_BYTES',
438
+ 50 * 1024 * 1024
439
+ )
440
+ }
441
+ ,
442
+ copilot: {
443
+ // Off by default: enabling a hosted provider sends workspace
444
+ // content to a third party, which is an operator's decision to make
445
+ // explicitly.
446
+ enabled: process.env['COPILOT_ENABLED'] === 'true',
447
+ maxOutputTokens: readPositiveInt('COPILOT_MAX_OUTPUT_TOKENS', 8192),
448
+ providers: {
449
+ // ortha:if copilot-anthropic
450
+ // Setting the key is what REGISTERS this backend — leave it
451
+ // empty and there is no `claude` in the picker at all, rather
452
+ // than one that fails on the first message.
453
+ ...(anthropicApiKey
454
+ ? {
455
+ claude: {
456
+ apiKey: anthropicApiKey,
457
+ models: readList(
458
+ 'COPILOT_ANTHROPIC_MODELS',
459
+ 'claude-sonnet-5'
460
+ )
461
+ }
462
+ }
463
+ : {}),
464
+ // ortha:end
465
+ // ortha:if copilot-openai
466
+ ...(openAiBaseUrl
467
+ ? {
468
+ openai: {
469
+ baseUrl: openAiBaseUrl,
470
+ apiKey: process.env['COPILOT_OPENAI_API_KEY'] ?? '',
471
+ models: readList(
472
+ 'COPILOT_OPENAI_MODELS',
473
+ 'llama3.1'
474
+ )
475
+ }
476
+ }
477
+ : {})
478
+ // ortha:end
479
+ }
480
+ }
481
+ // ortha:if mcp
482
+ ,
483
+ mcp: {
484
+ // Off by default: once on, any holder of a full-scope API token can
485
+ // drive content CRUD from an external agent.
486
+ enabled: process.env['MCP_ENABLED'] === 'true',
487
+ // The identity MCP clients display in their connector lists.
488
+ name: '__APP_NAME__',
489
+ version: '1.0.0',
490
+ // A request/response transport owes its caller an answer, and the
491
+ // tool registry has no deadline of its own — so without this the
492
+ // only bound on a `tools/call` is the query underneath it, and a
493
+ // blocked pool turns one call into a socket held until the client
494
+ // gives up.
495
+ callTimeoutMs: readPositiveInt('MCP_CALL_TIMEOUT_MS', 30_000),
496
+ // Deliberately generous: the ceiling exists to stop a pathological
497
+ // result being serialised several times over, not to shape normal
498
+ // use. A result this large does not fit a model's context anyway.
499
+ maxResultBytes: readPositiveInt('MCP_MAX_RESULT_BYTES', 4_194_304)
500
+ }
501
+ // ortha:end
502
+ }
503
+ };
504
+
505
+ export default config;
@@ -0,0 +1,26 @@
1
+ import 'reflect-metadata';
2
+ import { Logger } from '@nestjs/common';
3
+ import { createServer } from '@orthacms/bootstrap-server';
4
+ import config from '../ortha.config';
5
+ import { buildPlugins } from './plugins';
6
+
7
+ createServer({
8
+ plugins: buildPlugins(config),
9
+ port: config.port,
10
+ globalPrefix: config.globalPrefix,
11
+ trustProxy: config.trustProxy,
12
+ bodyLimit: config.bodyLimit,
13
+ staticDir: config.staticDir,
14
+ docs: config.docs
15
+ }).catch((error: unknown) => {
16
+ // Without this the promise is simply dropped: a plugin that fails to
17
+ // initialise, an invalid TRUST_PROXY, or a port already in use all surface
18
+ // as a raw unhandled-rejection dump with no line saying the server failed
19
+ // to start. `createServer` names the failing plugin or phase; this is what
20
+ // turns the rejection into a logged fatal and a deliberate non-zero exit.
21
+ Logger.error(
22
+ 'The server failed to start.',
23
+ error instanceof Error ? (error.stack ?? error.message) : String(error)
24
+ );
25
+ process.exit(1);
26
+ });
@@ -0,0 +1,71 @@
1
+ import config from '../ortha.config';
2
+ import { buildPlugins } from './plugins';
3
+
4
+ /**
5
+ * This app's composition, asserted.
6
+ *
7
+ * Two things about the plugin list are load-bearing, and neither shows up in an
8
+ * end-to-end run:
9
+ *
10
+ * 1. **Who is in it.** Drop a plugin and its routes stop existing, while
11
+ * everything that injects its ports `@Optional()` degrades silently — no
12
+ * error, no log, just a capability that quietly went away.
13
+ * 2. **What order the migrations run in.** They are applied by walking this
14
+ * array with no transaction spanning plugins, so a plugin whose tables
15
+ * reference another's must come after it. The mistake is invisible on an
16
+ * already-migrated database and fails a *fresh* one, so it ships and bites
17
+ * the next clean install rather than its author.
18
+ *
19
+ * Deliberately *not* asserted: relative order beyond the migration constraint.
20
+ * Every plugin module is global and every `onPluginInit` runs before the Nest
21
+ * app is created, so DI order is genuinely free.
22
+ */
23
+
24
+ /** Every plugin this app registers, in order. */
25
+ const EXPECTED_PLUGINS = [
26
+ 'database',
27
+ 'identity',
28
+ 'workspaces',
29
+ 'activity',
30
+ 'users',
31
+ 'content',
32
+ // ortha:if graphql
33
+ 'content-graphql',
34
+ // ortha:end
35
+ 'i18n',
36
+ 'media',
37
+ 'copilot'
38
+ // ortha:if mcp
39
+ ,
40
+ 'mcp'
41
+ // ortha:end
42
+ ];
43
+
44
+ describe('buildPlugins()', () => {
45
+ it('registers exactly the plugins this app ships', () => {
46
+ expect(buildPlugins(config).map((plugin) => plugin.name)).toEqual(
47
+ EXPECTED_PLUGINS
48
+ );
49
+ });
50
+
51
+ it('orders the migrating plugins so their foreign keys resolve on a fresh database', () => {
52
+ const migrating = buildPlugins(config)
53
+ .filter((plugin) => plugin.migrations)
54
+ .map((plugin) => plugin.name);
55
+
56
+ // `workspaces.memberships` references `identity.users`. Reverse these
57
+ // and a fresh migrate fails with `relation "users" does not exist`,
58
+ // while an existing database migrates perfectly happily.
59
+ expect(migrating.indexOf('identity')).toBeLessThan(
60
+ migrating.indexOf('workspaces')
61
+ );
62
+ });
63
+
64
+ it('opens the database before anything that needs it', () => {
65
+ // Not a DI requirement — every `onPluginInit` runs before the app is
66
+ // created — but `database` is the only plugin that opens a resource,
67
+ // and the moment a second one does, this position becomes load-bearing
68
+ // with nothing else to catch a mistake.
69
+ expect(buildPlugins(config)[0]?.name).toBe('database');
70
+ });
71
+ });