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.
- package/LICENSE +21 -0
- package/README.md +7 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +277 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/lib/conditionals.d.ts +37 -0
- package/dist/lib/conditionals.d.ts.map +1 -0
- package/dist/lib/conditionals.js +115 -0
- package/dist/lib/features.d.ts +185 -0
- package/dist/lib/features.d.ts.map +1 -0
- package/dist/lib/features.js +328 -0
- package/dist/lib/template.d.ts +46 -0
- package/dist/lib/template.d.ts.map +1 -0
- package/dist/lib/template.js +115 -0
- package/dist/lib/ui.d.ts +76 -0
- package/dist/lib/ui.d.ts.map +1 -0
- package/dist/lib/ui.js +310 -0
- package/dist/lib/validate.d.ts +13 -0
- package/dist/lib/validate.d.ts.map +1 -0
- package/dist/lib/validate.js +51 -0
- package/package.json +37 -0
- package/templates/default/README.md.tmpl +199 -0
- package/templates/default/_gitignore +8 -0
- package/templates/default/apps/admin/index.html +38 -0
- package/templates/default/apps/admin/src/main.tsx +5 -0
- package/templates/default/apps/admin/src/plugins.spec.ts +58 -0
- package/templates/default/apps/admin/src/plugins.ts +57 -0
- package/templates/default/apps/admin/src/styles.css +254 -0
- package/templates/default/apps/admin/tsconfig.json +29 -0
- package/templates/default/apps/admin/vite.config.mts +67 -0
- package/templates/default/apps/admin-e2e/playwright.config.ts +51 -0
- package/templates/default/apps/admin-e2e/src/auth.spec.ts +55 -0
- package/templates/default/apps/admin-e2e/src/support/seed.ts +62 -0
- package/templates/default/apps/admin-e2e/tsconfig.json +23 -0
- package/templates/default/apps/server/jest.config.js +38 -0
- package/templates/default/apps/server/jest.setup.js +20 -0
- package/templates/default/apps/server/ortha.config.ts +505 -0
- package/templates/default/apps/server/src/main.ts +26 -0
- package/templates/default/apps/server/src/plugins.spec.ts +71 -0
- package/templates/default/apps/server/src/plugins.ts +229 -0
- package/templates/default/apps/server/tsconfig.json +31 -0
- package/templates/default/apps/server-e2e/jest.config.js +47 -0
- package/templates/default/apps/server-e2e/src/api.spec.ts +107 -0
- package/templates/default/apps/server-e2e/src/global-setup.ts +41 -0
- package/templates/default/apps/server-e2e/src/jest.setup.ts +28 -0
- package/templates/default/apps/server-e2e/src/support/db.ts +143 -0
- package/templates/default/apps/server-e2e/src/support/test-app.ts +64 -0
- package/templates/default/apps/server-e2e/tsconfig.json +26 -0
- package/templates/default/docker-compose.yml +21 -0
- package/templates/default/env.tmpl +140 -0
- package/templates/default/package.json.tmpl +51 -0
- 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
|
+
});
|