create-ortha-app 0.4.2 → 0.5.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/dist/cli.js +6 -267
- package/dist/lib/features.d.ts +42 -20
- package/dist/lib/features.d.ts.map +1 -1
- package/dist/lib/features.js +56 -22
- package/dist/lib/run.d.ts +22 -0
- package/dist/lib/run.d.ts.map +1 -0
- package/dist/lib/run.js +318 -0
- package/dist/lib/template.d.ts.map +1 -1
- package/dist/lib/template.js +11 -1
- package/package.json +1 -1
- package/templates/default/README.md.tmpl +38 -12
- package/templates/default/apps/admin/src/plugins.spec.ts +5 -1
- package/templates/default/apps/admin/src/plugins.ts +21 -2
- package/templates/default/apps/admin/tsconfig.json +2 -1
- package/templates/default/apps/server/config/copilot-anthropic.ts +22 -0
- package/templates/default/apps/server/config/copilot-openai.ts +21 -0
- package/templates/default/apps/server/config/copilot.ts +61 -0
- package/templates/default/apps/server/config/docs.ts +14 -0
- package/templates/default/apps/server/config/i18n.ts +16 -0
- package/templates/default/apps/server/config/identity.ts +129 -0
- package/templates/default/apps/server/config/mcp.ts +25 -0
- package/templates/default/apps/server/config/media-storage.ts +84 -0
- package/templates/default/apps/server/config/media.ts +67 -0
- package/templates/default/apps/server/config/sso-github.ts +42 -0
- package/templates/default/apps/server/config/sso-oidc.ts +34 -0
- package/templates/default/apps/server/config/sso-saml.ts +46 -0
- package/templates/default/apps/server/ortha.config.ts +60 -449
- package/templates/default/apps/server/src/plugins.spec.ts +7 -0
- package/templates/default/apps/server/src/plugins.ts +84 -19
- package/templates/default/apps/server/tsconfig.spec.json +25 -0
- package/templates/default/env.tmpl +84 -2
- package/templates/default/tsconfig.json +3 -0
|
@@ -1,98 +1,64 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Typed configuration for this app.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* **`config/` is the single place that reads the environment**, and it reads it
|
|
5
|
+
* only through the readers in `@orthacms/utils-server` — never `process.env`
|
|
6
|
+
* directly. `readEnv` is where "an empty value means the setting is absent" is
|
|
7
|
+
* decided, and `.env` ships keys with nothing on the right-hand side; a raw
|
|
8
|
+
* `process.env['X'] ?? default` skips that decision and lets a blank line win
|
|
9
|
+
* over the default. Everything
|
|
10
|
+
* downstream — the host, every plugin — receives typed values, so "where does
|
|
11
|
+
* this setting come from" has exactly one answer. Deploy-specific values come
|
|
12
|
+
* from the environment; stable product tuning lives in the builders as literals.
|
|
13
|
+
*
|
|
14
|
+
* **Shape:** one module per plugin under `config/`, each exporting a builder,
|
|
15
|
+
* and this file assembling them. So the literal at the bottom is the table of
|
|
16
|
+
* contents — what this app runs, in one screen — and a reader who wants to know
|
|
17
|
+
* how one setting is derived opens the one module that owns it. Adding a setting
|
|
18
|
+
* means editing one builder; adding a plugin means one module and one line here.
|
|
19
|
+
*
|
|
20
|
+
* *How* a value is parsed is decided in neither place: the readers come from
|
|
21
|
+
* `@orthacms/utils-server`. Each refuses a value it cannot honour instead of
|
|
22
|
+
* guessing, and the guessing is what makes a misconfigured deployment look
|
|
23
|
+
* configured. Anything conditional is a *value* — a builder returns `undefined`
|
|
24
|
+
* for a backend you did not configure, and `defined(…)` drops the keys that were
|
|
25
|
+
* never set, so no config object here needs a `...(x ? { … } : {})` spread.
|
|
26
|
+
*
|
|
27
|
+
* This file stays the entry point rather than becoming another module in the
|
|
28
|
+
* folder: `ortha start` runs the compiled `dist/server/ortha.config.js`, and
|
|
29
|
+
* `src/plugins.ts` imports the types below.
|
|
8
30
|
*/
|
|
9
31
|
import { join } from 'node:path';
|
|
10
32
|
import type {
|
|
11
33
|
ApiDocsOptions,
|
|
12
34
|
TrustProxySetting
|
|
13
35
|
} 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
36
|
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
37
|
// ortha:if mcp
|
|
43
38
|
import type { McpPluginConfig } from '@orthacms/mcp-server';
|
|
44
39
|
// ortha:end
|
|
40
|
+
import {
|
|
41
|
+
readEnv,
|
|
42
|
+
readPositiveInt,
|
|
43
|
+
readTrustProxy,
|
|
44
|
+
requireEnv
|
|
45
|
+
} from '@orthacms/utils-server';
|
|
46
|
+
|
|
47
|
+
import { docsConfig } from './config/docs';
|
|
48
|
+
import { identityConfig, type AppIdentityConfig } from './config/identity';
|
|
49
|
+
import { i18nConfig } from './config/i18n';
|
|
50
|
+
import { mediaConfig, type AppMediaConfig } from './config/media';
|
|
51
|
+
import { copilotConfig, type AppCopilotConfig } from './config/copilot';
|
|
52
|
+
// ortha:if mcp
|
|
53
|
+
import { mcpConfig } from './config/mcp';
|
|
54
|
+
// ortha:end
|
|
45
55
|
|
|
46
56
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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`.
|
|
57
|
+
* Re-exported so `import type { AppCopilotConfig } from '../ortha.config'`
|
|
58
|
+
* keeps working. `src/plugins.ts` names these, and which file they are declared
|
|
59
|
+
* in is an implementation detail of this one.
|
|
63
60
|
*/
|
|
64
|
-
export
|
|
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
|
-
}
|
|
61
|
+
export type { AppIdentityConfig, AppMediaConfig, AppCopilotConfig };
|
|
96
62
|
|
|
97
63
|
/** Root configuration for this app. */
|
|
98
64
|
export interface OrthaConfig {
|
|
@@ -106,27 +72,7 @@ export interface OrthaConfig {
|
|
|
106
72
|
plugins: {
|
|
107
73
|
identity: AppIdentityConfig;
|
|
108
74
|
i18n: I18nPluginConfig;
|
|
109
|
-
media:
|
|
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
|
-
};
|
|
75
|
+
media: AppMediaConfig;
|
|
130
76
|
copilot: AppCopilotConfig;
|
|
131
77
|
// ortha:if mcp
|
|
132
78
|
mcp: McpPluginConfig;
|
|
@@ -134,105 +80,14 @@ export interface OrthaConfig {
|
|
|
134
80
|
};
|
|
135
81
|
}
|
|
136
82
|
|
|
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
83
|
const config: OrthaConfig = {
|
|
232
84
|
port: readPositiveInt('PORT', 3000),
|
|
233
85
|
globalPrefix: 'api',
|
|
86
|
+
// A hop count is the recommended form and the only one a client cannot
|
|
87
|
+
// forge past. Unset, forwarded headers are ignored entirely — right for an
|
|
88
|
+
// app exposed directly, wrong behind any proxy.
|
|
234
89
|
trustProxy: readTrustProxy(),
|
|
235
|
-
bodyLimit:
|
|
90
|
+
bodyLimit: readEnv('MAX_REQUEST_BODY') ?? '1mb',
|
|
236
91
|
// The built admin bundle, served by this same process so the API and the
|
|
237
92
|
// UI share one origin — which is what identity's httpOnly, SameSite=lax
|
|
238
93
|
// session cookie needs. `ortha dev` uses Vite's proxy for the same effect.
|
|
@@ -241,263 +96,19 @@ const config: OrthaConfig = {
|
|
|
241
96
|
// must mean the same thing whether it is read from `dist/` or from source.
|
|
242
97
|
staticDir: join(process.cwd(), 'dist/admin'),
|
|
243
98
|
database: {
|
|
244
|
-
url: requireEnv(
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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'
|
|
99
|
+
url: requireEnv(
|
|
100
|
+
'DATABASE_URL',
|
|
101
|
+
'Set it in your .env before starting the app.'
|
|
102
|
+
)
|
|
254
103
|
},
|
|
104
|
+
docs: docsConfig(),
|
|
255
105
|
plugins: {
|
|
256
|
-
identity:
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
}
|
|
106
|
+
identity: identityConfig(),
|
|
107
|
+
i18n: i18nConfig(),
|
|
108
|
+
media: mediaConfig(),
|
|
109
|
+
copilot: copilotConfig(),
|
|
481
110
|
// 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
|
-
}
|
|
111
|
+
mcp: mcpConfig()
|
|
501
112
|
// ortha:end
|
|
502
113
|
}
|
|
503
114
|
};
|
|
@@ -29,11 +29,18 @@ const EXPECTED_PLUGINS = [
|
|
|
29
29
|
'activity',
|
|
30
30
|
'users',
|
|
31
31
|
'content',
|
|
32
|
+
// A second entry from the content package: `ServerPlugin.migrations` holds
|
|
33
|
+
// one descriptor per entry, and saved list views ship their own tables.
|
|
34
|
+
'content-views',
|
|
32
35
|
// ortha:if graphql
|
|
33
36
|
'content-graphql',
|
|
34
37
|
// ortha:end
|
|
35
38
|
'i18n',
|
|
39
|
+
'alarms',
|
|
40
|
+
'webhooks',
|
|
36
41
|
'media',
|
|
42
|
+
'transfer',
|
|
43
|
+
'segments',
|
|
37
44
|
'copilot'
|
|
38
45
|
// ortha:if mcp
|
|
39
46
|
,
|