create-ortha-app 0.4.2 → 0.4.3
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 +38 -6
- package/dist/lib/features.d.ts +21 -19
- package/dist/lib/features.d.ts.map +1 -1
- package/dist/lib/features.js +26 -20
- 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 +12 -5
- package/templates/default/apps/admin/src/plugins.spec.ts +1 -0
- package/templates/default/apps/admin/src/plugins.ts +4 -0
- 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 +96 -0
- package/templates/default/apps/server/config/mcp.ts +25 -0
- package/templates/default/apps/server/config/media-storage.ts +83 -0
- package/templates/default/apps/server/config/media.ts +67 -0
- package/templates/default/apps/server/config/sso-oidc.ts +34 -0
- package/templates/default/apps/server/ortha.config.ts +55 -449
- package/templates/default/apps/server/src/plugins.spec.ts +1 -0
- package/templates/default/apps/server/src/plugins.ts +16 -6
- package/templates/default/apps/server/tsconfig.spec.json +25 -0
- package/templates/default/env.tmpl +7 -2
- package/templates/default/tsconfig.json +3 -0
|
@@ -1,98 +1,59 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Typed configuration for this app.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* host, every plugin — receives typed values, so "where does
|
|
6
|
-
* from" has exactly one answer. Deploy-specific values come
|
|
7
|
-
* environment; stable product tuning lives
|
|
4
|
+
* **`config/` is the single place that reads `process.env`.** Everything
|
|
5
|
+
* downstream — the host, every plugin — receives typed values, so "where does
|
|
6
|
+
* this setting come from" has exactly one answer. Deploy-specific values come
|
|
7
|
+
* from the environment; stable product tuning lives in the builders as literals.
|
|
8
|
+
*
|
|
9
|
+
* **Shape:** one module per plugin under `config/`, each exporting a builder,
|
|
10
|
+
* and this file assembling them. So the literal at the bottom is the table of
|
|
11
|
+
* contents — what this app runs, in one screen — and a reader who wants to know
|
|
12
|
+
* how one setting is derived opens the one module that owns it. Adding a setting
|
|
13
|
+
* means editing one builder; adding a plugin means one module and one line here.
|
|
14
|
+
*
|
|
15
|
+
* *How* a value is parsed is decided in neither place: the readers come from
|
|
16
|
+
* `@orthacms/utils-server`. Each refuses a value it cannot honour instead of
|
|
17
|
+
* guessing, and the guessing is what makes a misconfigured deployment look
|
|
18
|
+
* configured. Anything conditional is a *value* — a builder returns `undefined`
|
|
19
|
+
* for a backend you did not configure, and `defined(…)` drops the keys that were
|
|
20
|
+
* never set, so no config object here needs a `...(x ? { … } : {})` spread.
|
|
21
|
+
*
|
|
22
|
+
* This file stays the entry point rather than becoming another module in the
|
|
23
|
+
* folder: `ortha start` runs the compiled `dist/server/ortha.config.js`, and
|
|
24
|
+
* `src/plugins.ts` imports the types below.
|
|
8
25
|
*/
|
|
9
26
|
import { join } from 'node:path';
|
|
10
27
|
import type {
|
|
11
28
|
ApiDocsOptions,
|
|
12
29
|
TrustProxySetting
|
|
13
30
|
} 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
31
|
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
32
|
// ortha:if mcp
|
|
43
33
|
import type { McpPluginConfig } from '@orthacms/mcp-server';
|
|
44
34
|
// ortha:end
|
|
35
|
+
import {
|
|
36
|
+
readEnv,
|
|
37
|
+
readPositiveInt,
|
|
38
|
+
readTrustProxy,
|
|
39
|
+
requireEnv
|
|
40
|
+
} from '@orthacms/utils-server';
|
|
41
|
+
|
|
42
|
+
import { docsConfig } from './config/docs';
|
|
43
|
+
import { identityConfig, type AppIdentityConfig } from './config/identity';
|
|
44
|
+
import { i18nConfig } from './config/i18n';
|
|
45
|
+
import { mediaConfig, type AppMediaConfig } from './config/media';
|
|
46
|
+
import { copilotConfig, type AppCopilotConfig } from './config/copilot';
|
|
47
|
+
// ortha:if mcp
|
|
48
|
+
import { mcpConfig } from './config/mcp';
|
|
49
|
+
// ortha:end
|
|
45
50
|
|
|
46
51
|
/**
|
|
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`.
|
|
52
|
+
* Re-exported so `import type { AppCopilotConfig } from '../ortha.config'`
|
|
53
|
+
* keeps working. `src/plugins.ts` names these, and which file they are declared
|
|
54
|
+
* in is an implementation detail of this one.
|
|
63
55
|
*/
|
|
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
|
-
}
|
|
56
|
+
export type { AppIdentityConfig, AppMediaConfig, AppCopilotConfig };
|
|
96
57
|
|
|
97
58
|
/** Root configuration for this app. */
|
|
98
59
|
export interface OrthaConfig {
|
|
@@ -106,27 +67,7 @@ export interface OrthaConfig {
|
|
|
106
67
|
plugins: {
|
|
107
68
|
identity: AppIdentityConfig;
|
|
108
69
|
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
|
-
};
|
|
70
|
+
media: AppMediaConfig;
|
|
130
71
|
copilot: AppCopilotConfig;
|
|
131
72
|
// ortha:if mcp
|
|
132
73
|
mcp: McpPluginConfig;
|
|
@@ -134,105 +75,14 @@ export interface OrthaConfig {
|
|
|
134
75
|
};
|
|
135
76
|
}
|
|
136
77
|
|
|
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
78
|
const config: OrthaConfig = {
|
|
232
79
|
port: readPositiveInt('PORT', 3000),
|
|
233
80
|
globalPrefix: 'api',
|
|
81
|
+
// A hop count is the recommended form and the only one a client cannot
|
|
82
|
+
// forge past. Unset, forwarded headers are ignored entirely — right for an
|
|
83
|
+
// app exposed directly, wrong behind any proxy.
|
|
234
84
|
trustProxy: readTrustProxy(),
|
|
235
|
-
bodyLimit:
|
|
85
|
+
bodyLimit: readEnv('MAX_REQUEST_BODY') ?? '1mb',
|
|
236
86
|
// The built admin bundle, served by this same process so the API and the
|
|
237
87
|
// UI share one origin — which is what identity's httpOnly, SameSite=lax
|
|
238
88
|
// session cookie needs. `ortha dev` uses Vite's proxy for the same effect.
|
|
@@ -241,263 +91,19 @@ const config: OrthaConfig = {
|
|
|
241
91
|
// must mean the same thing whether it is read from `dist/` or from source.
|
|
242
92
|
staticDir: join(process.cwd(), 'dist/admin'),
|
|
243
93
|
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'
|
|
94
|
+
url: requireEnv(
|
|
95
|
+
'DATABASE_URL',
|
|
96
|
+
'Set it in your .env before starting the app.'
|
|
97
|
+
)
|
|
254
98
|
},
|
|
99
|
+
docs: docsConfig(),
|
|
255
100
|
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
|
-
}
|
|
101
|
+
identity: identityConfig(),
|
|
102
|
+
i18n: i18nConfig(),
|
|
103
|
+
media: mediaConfig(),
|
|
104
|
+
copilot: copilotConfig(),
|
|
481
105
|
// 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
|
-
}
|
|
106
|
+
mcp: mcpConfig()
|
|
501
107
|
// ortha:end
|
|
502
108
|
}
|
|
503
109
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ServerPlugin } from '@orthacms/bootstrap-server';
|
|
2
2
|
import { ActivityPlugin } from '@orthacms/activity-server';
|
|
3
|
-
import { ContentPlugin } from '@orthacms/content-server';
|
|
3
|
+
import { ContentPlugin, ContentViewsPlugin } from '@orthacms/content-server';
|
|
4
4
|
import { DatabasePlugin } from '@orthacms/database';
|
|
5
5
|
import { I18nServerPlugin } from '@orthacms/i18n-server';
|
|
6
6
|
import { IdentityPlugin } from '@orthacms/identity-server';
|
|
@@ -25,6 +25,7 @@ import { createGcsStorageProvider } from '@orthacms/media-provider-gcs';
|
|
|
25
25
|
import { createVercelBlobStorageProvider } from '@orthacms/media-provider-vercel-blob';
|
|
26
26
|
// ortha:end
|
|
27
27
|
import { UsersPlugin } from '@orthacms/users-server';
|
|
28
|
+
import { AlarmsPlugin } from '@orthacms/alarms-server';
|
|
28
29
|
// ortha:if graphql
|
|
29
30
|
import { ContentGraphqlPlugin } from '@orthacms/content-graphql';
|
|
30
31
|
// ortha:end
|
|
@@ -35,7 +36,6 @@ import {
|
|
|
35
36
|
CopilotPlugin,
|
|
36
37
|
type ProviderRegistration
|
|
37
38
|
} from '@orthacms/copilot-server';
|
|
38
|
-
import { createFakeProvider } from '@orthacms/copilot-provider-fake';
|
|
39
39
|
// ortha:if copilot-anthropic
|
|
40
40
|
import { createAnthropicProvider } from '@orthacms/copilot-provider-anthropic';
|
|
41
41
|
// ortha:end
|
|
@@ -54,9 +54,10 @@ import type { OrthaConfig } from '../ortha.config';
|
|
|
54
54
|
* one at the top of the list would be the house default and would fail on the
|
|
55
55
|
* first message.
|
|
56
56
|
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
57
|
+
* **An app that configured no backend registers none**, and has no copilot.
|
|
58
|
+
* There is no scripted offline adapter to fall back on, so `COPILOT_ENABLED`
|
|
59
|
+
* must stay `false` until a backend is configured — enabling it with an empty
|
|
60
|
+
* list fails at boot rather than shipping a chat that cannot answer.
|
|
60
61
|
*/
|
|
61
62
|
// ortha:if sso-oidc
|
|
62
63
|
/**
|
|
@@ -105,7 +106,6 @@ export function copilotProviders(config: OrthaConfig): ProviderRegistration[] {
|
|
|
105
106
|
});
|
|
106
107
|
}
|
|
107
108
|
// ortha:end
|
|
108
|
-
providers.push({ name: 'fake', provider: createFakeProvider() });
|
|
109
109
|
|
|
110
110
|
return providers;
|
|
111
111
|
}
|
|
@@ -169,6 +169,12 @@ export function buildPlugins(config: OrthaConfig): ServerPlugin[] {
|
|
|
169
169
|
// Until then the plugin serves its generic routes with an empty
|
|
170
170
|
// registry, and owns no tables of its own.
|
|
171
171
|
content,
|
|
172
|
+
// Saved list views — the named filter/sort/column slices an editor
|
|
173
|
+
// returns to. A second plugin entry from the content package because
|
|
174
|
+
// `ServerPlugin.migrations` holds one descriptor per entry; this one
|
|
175
|
+
// ships the feature's own tables. Must follow identity and workspaces:
|
|
176
|
+
// its foreign keys point at their tables and migrations run in order.
|
|
177
|
+
ContentViewsPlugin({ content }),
|
|
172
178
|
// ortha:if graphql
|
|
173
179
|
// The same public content API over GraphQL, on /api/v1/graphql. It owns
|
|
174
180
|
// no schema and adds no credential — it reuses content's bearer guards
|
|
@@ -187,6 +193,10 @@ export function buildPlugins(config: OrthaConfig): ServerPlugin[] {
|
|
|
187
193
|
// provider, writing every upload to local disk. A deployment runs
|
|
188
194
|
// exactly one; swapping backend is swapping this expression (and the
|
|
189
195
|
// type of `media.storage` with it).
|
|
196
|
+
// Content alarms — rules that flag content problems without ever
|
|
197
|
+
// blocking a save or a publish. After content, whose registry and
|
|
198
|
+
// filter surface it evaluates rules through.
|
|
199
|
+
AlarmsPlugin(),
|
|
190
200
|
MediaServerPlugin({
|
|
191
201
|
// ortha:if media-local
|
|
192
202
|
provider: createLocalStorageProvider(config.plugins.media.storage),
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "es2022",
|
|
4
|
+
"lib": [
|
|
5
|
+
"es2023"
|
|
6
|
+
],
|
|
7
|
+
"module": "commonjs",
|
|
8
|
+
"moduleResolution": "node",
|
|
9
|
+
"types": [
|
|
10
|
+
"node",
|
|
11
|
+
"jest"
|
|
12
|
+
],
|
|
13
|
+
"strict": true,
|
|
14
|
+
"skipLibCheck": true,
|
|
15
|
+
"esModuleInterop": true,
|
|
16
|
+
"forceConsistentCasingInFileNames": true,
|
|
17
|
+
"resolveJsonModule": true,
|
|
18
|
+
"experimentalDecorators": true,
|
|
19
|
+
"emitDecoratorMetadata": true,
|
|
20
|
+
"noEmit": true
|
|
21
|
+
},
|
|
22
|
+
"include": [
|
|
23
|
+
"src/**/*.spec.ts"
|
|
24
|
+
]
|
|
25
|
+
}
|
|
@@ -75,8 +75,13 @@ ORTHA_ROOT_ADMIN_EMAIL=__ADMIN_EMAIL__
|
|
|
75
75
|
ORTHA_ROOT_ADMIN_PASSWORD=__ADMIN_PASSWORD__
|
|
76
76
|
|
|
77
77
|
# --- copilot ---
|
|
78
|
-
# Global kill switch. OFF by default:
|
|
79
|
-
#
|
|
78
|
+
# Global kill switch. OFF by default: whether this app offers Ortha AI at all is
|
|
79
|
+
# your call. `false` unregisters every /api/copilot route, so the endpoints 404
|
|
80
|
+
# and the admin's chat panel, Agents view and Skills page stand down.
|
|
81
|
+
#
|
|
82
|
+
# It is not what keeps your content in-house: a backend is registered only when
|
|
83
|
+
# it is configured below, so an app with no key reaches no third party whatever
|
|
84
|
+
# this says.
|
|
80
85
|
COPILOT_ENABLED=false
|
|
81
86
|
# Ceiling on a single model response, in tokens.
|
|
82
87
|
COPILOT_MAX_OUTPUT_TOKENS=8192
|