create-ortha-app 0.4.3 → 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.
@@ -0,0 +1,318 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.USAGE = void 0;
4
+ exports.lockedOf = lockedOf;
5
+ exports.resolveAnswers = resolveAnswers;
6
+ exports.main = main;
7
+ const tslib_1 = require("tslib");
8
+ /**
9
+ * The scaffolder itself: read the flags, ask what a terminal can be asked,
10
+ * write the app.
11
+ *
12
+ * Separate from `src/cli.ts`, which is the `bin` and does nothing but call
13
+ * `main()` and turn a thrown error into an exit code. The split is what makes
14
+ * this testable: importing a module that scaffolds on import would scaffold
15
+ * during a test run, so the refusals and the defaults below could only be
16
+ * checked by spawning a process.
17
+ */
18
+ const node_crypto_1 = require("node:crypto");
19
+ const node_child_process_1 = require("node:child_process");
20
+ const node_fs_1 = require("node:fs");
21
+ const node_path_1 = require("node:path");
22
+ const promises_1 = require("node:readline/promises");
23
+ const features_1 = require("./features");
24
+ const template_1 = require("./template");
25
+ const ui = tslib_1.__importStar(require("./ui"));
26
+ const validate_1 = require("./validate");
27
+ exports.USAGE = `create-ortha-app — scaffold an Ortha CMS app
28
+
29
+ Usage: npx create-ortha-app <directory> [options]
30
+
31
+ Options:
32
+ --yes Accept every default, asking nothing
33
+ --media <id> Storage adapter (default: media-local)
34
+ --copilot <ids> Comma-separated copilot providers, or "none"
35
+ --sso <ids> Comma-separated identity providers (sso-oidc, sso-github,
36
+ sso-saml), or "none"
37
+ --protocols <ids> Comma-separated protocols beyond REST (graphql, mcp), or "none"
38
+ --no-install Skip installing dependencies
39
+ --no-git Skip initialising a git repository
40
+ -h, --help Show this message
41
+ `;
42
+ /** Whether a bare `--flag` is present. */
43
+ function flag(argv, name) {
44
+ return argv.includes(`--${name}`);
45
+ }
46
+ /** Reads `--name=value` or `--name value` from argv. */
47
+ function option(argv, name) {
48
+ const inline = argv.find((arg) => arg.startsWith(`--${name}=`));
49
+ if (inline)
50
+ return inline.slice(`--${name}=`.length);
51
+ const index = argv.indexOf(`--${name}`);
52
+ const next = index === -1 ? undefined : argv[index + 1];
53
+ return next && !next.startsWith('-') ? next : undefined;
54
+ }
55
+ /** Splits a comma-separated flag value; `none` means an empty selection. */
56
+ function idList(raw) {
57
+ if (raw === undefined)
58
+ return undefined;
59
+ if (raw.trim() === 'none')
60
+ return [];
61
+ return raw
62
+ .split(',')
63
+ .map((id) => id.trim())
64
+ .filter(Boolean);
65
+ }
66
+ /**
67
+ * The ids a `--media` / `--copilot` / `--sso` / `--protocols` flag named, checked
68
+ * against the group it belongs to.
69
+ *
70
+ * An id nobody recognises has to stop the run. The flags take *feature ids*
71
+ * (`media-s3`), not the label or the bare adapter name, and `--media s3` is the
72
+ * natural guess — it used to select nothing, and "nothing" is a real answer for
73
+ * three of the four groups, so it scaffolded an app quietly missing whatever was
74
+ * asked for. For storage that app cannot even compile: no adapter means no
75
+ * `storage` setting and no `mediaStorage()` for the config to call. Naming the
76
+ * valid ids here costs a typo nothing and turns a broken app into one line of
77
+ * output.
78
+ *
79
+ * `required` marks a single-choice group that must end up with something —
80
+ * storage, where "no adapter" is not a configuration an app can run.
81
+ */
82
+ function selected(argv, name, group, required = false) {
83
+ const chosen = idList(option(argv, name)) ?? defaultsOf(group);
84
+ const known = new Set(group.map((feature) => feature.id));
85
+ const unknown = chosen.filter((id) => !known.has(id));
86
+ if (unknown.length > 0) {
87
+ throw new Error(`--${name}: no such option ${unknown.map((id) => `"${id}"`).join(', ')}. ` +
88
+ `Expected ${[...known].join(', ')}${required ? '' : ', or none'}.`);
89
+ }
90
+ if (required && chosen.length === 0) {
91
+ throw new Error(`--${name}: an app needs one of ${[...known].join(', ')} — ` +
92
+ 'there is no configuration for "no adapter at all".');
93
+ }
94
+ return chosen;
95
+ }
96
+ /**
97
+ * This package's own version, which every `@orthacms/*` dependency in the
98
+ * generated app is pinned to.
99
+ *
100
+ * The scaffolder is released in lockstep with the packages it scaffolds, so its
101
+ * version *is* the matching set — which is the whole mechanism keeping a
102
+ * generated app internally consistent. Reading it from the manifest rather than
103
+ * resolving `latest` from the registry also means `npx create-ortha-app@0.3.0`
104
+ * generates a 0.3.0 app, not whatever shipped since.
105
+ */
106
+ function ownVersion() {
107
+ const manifest = (0, node_path_1.join)(__dirname, '../../package.json');
108
+ return JSON.parse((0, node_fs_1.readFileSync)(manifest, 'utf8'))
109
+ .version;
110
+ }
111
+ /** Runs a command in `cwd`, returning whether it succeeded. */
112
+ function runCommand(command, args, cwd) {
113
+ return ((0, node_child_process_1.spawnSync)(command, args, {
114
+ cwd,
115
+ stdio: 'inherit',
116
+ shell: process.platform === 'win32'
117
+ }).status === 0);
118
+ }
119
+ /** Turns a feature into a picker row. */
120
+ function toChoice(feature) {
121
+ return {
122
+ value: feature.id,
123
+ label: feature.label,
124
+ hint: feature.hint,
125
+ selected: feature.enabledByDefault,
126
+ disabled: !feature.available,
127
+ locked: feature.locked
128
+ };
129
+ }
130
+ /**
131
+ * The ids a feature group falls back to when nothing is picked.
132
+ *
133
+ * A locked feature is in every answer, including `--protocols none`: REST is
134
+ * not something the flag can switch off.
135
+ */
136
+ function defaultsOf(features) {
137
+ return features
138
+ .filter((feature) => (feature.enabledByDefault || feature.locked) &&
139
+ feature.available)
140
+ .map((feature) => feature.id);
141
+ }
142
+ /** The ids that are on regardless of what was asked or passed. */
143
+ function lockedOf(features) {
144
+ return features
145
+ .filter((feature) => feature.locked && feature.available)
146
+ .map((feature) => feature.id);
147
+ }
148
+ /** Asks a free-text question, re-asking until it validates. */
149
+ async function askText(label, fallback, validate) {
150
+ const rl = (0, promises_1.createInterface)({
151
+ input: process.stdin,
152
+ output: process.stdout
153
+ });
154
+ try {
155
+ for (;;) {
156
+ const raw = await rl.question(`${ui.cyan('◆')} ${ui.bold(label)} ${ui.dim(`(${fallback})`)} `);
157
+ const answer = raw.trim() || fallback;
158
+ const problem = validate?.(answer);
159
+ if (!problem)
160
+ return answer;
161
+ ui.error(problem);
162
+ }
163
+ }
164
+ finally {
165
+ rl.close();
166
+ }
167
+ }
168
+ /**
169
+ * Resolves the answers, asking only when there is a terminal to ask in.
170
+ *
171
+ * Non-interactive is not an error case to warn about — it is CI, a piped
172
+ * install, and `--yes`. A scaffolder that blocks on a prompt nobody can answer
173
+ * hangs a pipeline until it times out, which is a far worse failure than
174
+ * defaulting, so the flags and the defaults cover every question.
175
+ */
176
+ async function resolveAnswers(argv, defaultName) {
177
+ const asked = !flag(argv, 'yes') && ui.interactive();
178
+ const defaultDatabaseUrl = `postgresql://ortha:ortha@localhost:5432/${defaultName.replace(/[^a-z0-9_]/gi, '_')}`;
179
+ // Storage is `required`: it is a single choice, and an app with no adapter
180
+ // has no `storage` setting for `plugins.ts` to hand its factory.
181
+ const media = selected(argv, 'media', features_1.MEDIA_PROVIDERS, true);
182
+ const copilot = selected(argv, 'copilot', features_1.COPILOT_PROVIDERS);
183
+ const sso = selected(argv, 'sso', features_1.SSO_PROVIDERS);
184
+ const protocols = [
185
+ ...lockedOf(features_1.PROTOCOLS),
186
+ ...selected(argv, 'protocols', features_1.PROTOCOLS)
187
+ ];
188
+ if (!asked) {
189
+ return {
190
+ appName: defaultName,
191
+ databaseUrl: defaultDatabaseUrl,
192
+ adminEmail: 'admin@example.com',
193
+ selection: {
194
+ enabled: new Set([...media, ...copilot, ...sso, ...protocols])
195
+ }
196
+ };
197
+ }
198
+ ui.section('Project');
199
+ const appName = await askText('App name', defaultName, validate_1.validateAppName);
200
+ const databaseUrl = await askText('Database URL', defaultDatabaseUrl, validate_1.validateDatabaseUrl);
201
+ const adminEmail = await askText('Admin email', 'admin@example.com');
202
+ ui.section('Features');
203
+ ui.note('Everything else is installed for you. These are the choices.');
204
+ console.log('');
205
+ // Only ask when there is more than one answer available. S3 is not
206
+ // published, so today this is a question with a single possible reply, and
207
+ // asking it would be noise pretending to be a choice.
208
+ const selectable = features_1.MEDIA_PROVIDERS.filter((provider) => provider.available);
209
+ const chosenMedia = selectable.length > 1
210
+ ? [
211
+ (await ui.select('Where should uploads be stored?', features_1.MEDIA_PROVIDERS.map(toChoice))) ?? media[0]
212
+ ]
213
+ : media;
214
+ const chosenCopilot = (await ui.multiselect('AI copilot — which model backends?', features_1.COPILOT_PROVIDERS.map(toChoice))) ?? copilot;
215
+ const chosenSso = (await ui.multiselect('Single sign-on — which identity providers?', features_1.SSO_PROVIDERS.map(toChoice))) ?? sso;
216
+ const chosenProtocols = (await ui.multiselect('Which protocols should the content API speak?', features_1.PROTOCOLS.map(toChoice))) ?? protocols;
217
+ return {
218
+ appName,
219
+ databaseUrl,
220
+ adminEmail,
221
+ selection: {
222
+ enabled: new Set([
223
+ ...chosenMedia.filter(Boolean),
224
+ ...chosenCopilot,
225
+ ...chosenSso,
226
+ ...chosenProtocols
227
+ ])
228
+ }
229
+ };
230
+ }
231
+ /** Human-readable summary of what was chosen. */
232
+ function describeSelection(selection) {
233
+ const labelsFor = (features) => {
234
+ const chosen = features
235
+ .filter((feature) => selection.enabled.has(feature.id))
236
+ .map((feature) => feature.label);
237
+ return chosen.length > 0 ? chosen.join(', ') : ui.dim('none');
238
+ };
239
+ const providers = features_1.COPILOT_PROVIDERS.filter((provider) => selection.enabled.has(provider.id));
240
+ return [
241
+ ['Storage', labelsFor(features_1.MEDIA_PROVIDERS)],
242
+ ['Protocols', labelsFor(features_1.PROTOCOLS)],
243
+ [
244
+ 'Copilot',
245
+ providers.length > 0
246
+ ? providers.map((p) => p.label).join(', ')
247
+ : ui.dim('no backend')
248
+ ],
249
+ ['Sign-in', labelsFor(features_1.SSO_PROVIDERS)],
250
+ ['Ortha packages', String((0, features_1.resolvePackages)(selection).length + 1)]
251
+ ];
252
+ }
253
+ async function main(argv = process.argv.slice(2)) {
254
+ if (flag(argv, 'help') || argv.includes('-h')) {
255
+ console.log(exports.USAGE);
256
+ return;
257
+ }
258
+ const positional = argv.filter((arg) => !arg.startsWith('-'));
259
+ const target = (0, node_path_1.resolve)(positional[0] ?? '.');
260
+ ui.banner('Ortha CMS', `Creating an app in ${target}`);
261
+ if ((0, template_1.isNonEmptyDirectory)(target)) {
262
+ throw new Error(`${target} already exists and is not empty. Pick a new directory, ` +
263
+ `or empty that one first.`);
264
+ }
265
+ const answers = await resolveAnswers(argv, (0, node_path_1.basename)(target));
266
+ // Generated rather than prompted: a password typed at a prompt is echoed to
267
+ // the terminal and lands in shell history. This one is written only to the
268
+ // git-ignored .env, and printed once below.
269
+ const adminPassword = (0, node_crypto_1.randomBytes)(12).toString('base64url');
270
+ ui.summary('Your app', [
271
+ ['Name', answers.appName],
272
+ ['Database', (0, validate_1.databaseNameFrom)(answers.databaseUrl)],
273
+ ...describeSelection(answers.selection)
274
+ ]);
275
+ (0, node_fs_1.mkdirSync)(target, { recursive: true });
276
+ (0, template_1.renderTemplate)((0, node_path_1.join)(__dirname, '../../templates/default'), target, {
277
+ appName: answers.appName,
278
+ appTitle: answers.appName,
279
+ databaseUrl: answers.databaseUrl,
280
+ databaseName: (0, validate_1.databaseNameFrom)(answers.databaseUrl),
281
+ adminEmail: answers.adminEmail,
282
+ adminPassword,
283
+ orthaVersion: ownVersion(),
284
+ selection: answers.selection
285
+ });
286
+ console.log('');
287
+ ui.success('Files written');
288
+ if (!flag(argv, 'no-git') && !(0, node_fs_1.existsSync)((0, node_path_1.join)(target, '.git'))) {
289
+ if (runCommand('git', ['init', '--quiet'], target)) {
290
+ ui.success('Git repository initialised');
291
+ }
292
+ }
293
+ let installed = true;
294
+ if (!flag(argv, 'no-install')) {
295
+ console.log('');
296
+ ui.note('Installing dependencies…');
297
+ installed = runCommand('npm', ['install', '--no-audit', '--no-fund'], target);
298
+ if (installed)
299
+ ui.success('Dependencies installed');
300
+ }
301
+ if (!installed) {
302
+ ui.error('Dependency installation failed — fix the error above, then run ' +
303
+ '`npm install` in the new directory.');
304
+ process.exitCode = 1;
305
+ }
306
+ ui.summary('Your admin account', [
307
+ ['Email', answers.adminEmail],
308
+ ['Password', ui.bold(adminPassword)],
309
+ ['', ui.dim('Also written to .env')]
310
+ ]);
311
+ ui.nextSteps([
312
+ `cd ${(0, node_path_1.basename)(target)}`,
313
+ ...(flag(argv, 'no-install') ? ['npm install'] : []),
314
+ 'docker compose up -d',
315
+ 'npm run migrate',
316
+ `npm run dev ${ui.dim('→ http://localhost:4200')}`
317
+ ]);
318
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-ortha-app",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "description": "Create an Ortha CMS app: npx create-ortha-app my-cms",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/ortha-source/ortha-cms/tree/main/packages/create-ortha-app",
@@ -22,7 +22,7 @@ provisioned on boot, idempotently — clear those two variables once you have it
22
22
  | `npm run build` | Compiles the server and builds the admin bundle |
23
23
  | `npm start` | Runs the built server — API **and** admin, one process, one origin |
24
24
  | `npm run migrate` | Applies every plugin's pending migrations |
25
- | `npm run generate -- --name=<name>` | Generates a migration for your own content tables |
25
+ | `npm run generate -- --name=<name>` | Generates a migration for your own content tables — needs a `drizzle.config.ts` first, see below |
26
26
  | `npm run studio` | Opens Drizzle Studio on this app's database |
27
27
  | `npm test` | Unit tests — both halves |
28
28
  | `npm run e2e` | Both end-to-end suites |
@@ -109,7 +109,7 @@ source lands in the same place here.
109
109
  | --- | --- |
110
110
  | `apps/server/src/plugins.ts` | Which plugins the API runs, and **in what order** |
111
111
  | `apps/admin/src/plugins.ts` | Which plugins the admin UI runs |
112
- | `apps/server/ortha.config.ts` | Every setting, typed — the only reader of `process.env` |
112
+ | `apps/server/ortha.config.ts` | Every setting, typed — with `apps/server/config/`, the only reader of the environment |
113
113
  | `apps/admin/src/styles.css` | The theme tokens: this app's whole visual identity |
114
114
 
115
115
  Upgrading is a version bump. Keep the `@orthacms/*` versions **in step with each
@@ -121,9 +121,9 @@ and UI that silently stops talking to itself.
121
121
 
122
122
  A server plugin is a `ServerPlugin` from `@orthacms/bootstrap-server` — a name,
123
123
  a NestJS module, and optionally its own migrations — added to the array in
124
- `src/server/plugins.ts`. `@InjectDatabase()` from `@orthacms/database` gives it
124
+ `apps/server/src/plugins.ts`. `@InjectDatabase()` from `@orthacms/database` gives it
125
125
  the shared connection. An admin plugin is an `AdminPlugin` from
126
- `@orthacms/bootstrap-admin`, added to `src/admin/plugins.ts`.
126
+ `@orthacms/bootstrap-admin`, added to `apps/admin/src/plugins.ts`.
127
127
 
128
128
  `@orthacms/utils-server` and `@orthacms/utils-admin` hold the shared toolkit
129
129
  (filter parsing, `apiClient`, slots, table URL state), and
@@ -154,7 +154,7 @@ It is not the switch that keeps your content in-house, though. A backend is
154
154
  registered only when its settings are present (below), so an app with no key
155
155
  reaches no third party either way.
156
156
 
157
- Backends are registered in `src/server/plugins.ts`, and **the order is the
157
+ Backends are registered in `apps/server/src/plugins.ts`, and **the order is the
158
158
  setting** — there is no `defaultProvider`. The first registered backend serves a
159
159
  run that names none, and it is what the admin's model picker opens on. A backend
160
160
  is only registered when its connection settings are present, so an unconfigured
@@ -189,8 +189,27 @@ This app starts with none. To add some:
189
189
  1. Write them under `apps/server/src/content/` and export a `contentTypes` array.
190
190
  2. Pass them to `ContentPlugin({ types: contentTypes, migrations: … })` in
191
191
  `apps/server/src/plugins.ts` — the comment there has the exact shape.
192
- 3. Add `apps/server/drizzle.config.ts` pointing `schema` at your
193
- `src/content/index.ts` and `out` at `../../migrations`.
192
+ 3. Add `apps/server/drizzle.config.ts`:
193
+
194
+ ```ts
195
+ import { defineConfig } from 'drizzle-kit';
196
+
197
+ export default defineConfig({
198
+ dialect: 'postgresql',
199
+ schema: 'apps/server/src/content/index.ts',
200
+ out: 'migrations'
201
+ });
202
+ ```
203
+
204
+ Both paths are relative to **this app's root**, not to the config file.
205
+ drizzle-kit resolves `schema` and `out` from the working directory, and
206
+ `ortha generate` runs it from the root — so `out: 'migrations'` is the
207
+ same `migrations/` the plugin's descriptor in step 2 points at, and a
208
+ `../../` in front of it would write two levels above your project.
209
+
210
+ No `dbCredentials`: generating a migration only diffs the schema against
211
+ the last snapshot and never connects, so there is no secret to put here.
212
+
194
213
  4. `npm run generate -- --name=add_content_types && npm run migrate`
195
214
 
196
215
  ## Deploying
@@ -31,11 +31,14 @@ const EXPECTED_PLUGINS = [
31
31
  'i18n',
32
32
  'wysiwyg',
33
33
  'media',
34
+ 'transfer',
34
35
  'alarms',
35
36
  'copilot',
37
+ 'segments',
36
38
  'users',
37
39
  'activity',
38
- 'api-tokens'
40
+ 'api-tokens',
41
+ 'webhooks'
39
42
  ];
40
43
 
41
44
  describe('buildPlugins()', () => {
@@ -1,6 +1,7 @@
1
1
  import type { AdminPlugin } from '@orthacms/bootstrap-admin';
2
2
  import { ActivityPlugin } from '@orthacms/activity-admin';
3
3
  import { ApiTokensPlugin } from '@orthacms/api-tokens-admin';
4
+ import { WebhooksPlugin } from '@orthacms/webhooks-admin';
4
5
  import { ContentPlugin } from '@orthacms/content-admin';
5
6
  import { I18nPlugin } from '@orthacms/i18n-admin';
6
7
  import { IdentityPlugin } from '@orthacms/identity-admin';
@@ -12,9 +13,12 @@ import { WorkspacesPlugin } from '@orthacms/workspaces-admin';
12
13
  import { WysiwygPlugin } from '@orthacms/wysiwyg-admin';
13
14
  import { CopilotPlugin } from '@orthacms/copilot-admin';
14
15
  import { AlarmsPlugin } from '@orthacms/alarms-admin';
16
+ import { transferAdminPlugin } from '@orthacms/transfer-admin';
17
+ import { SegmentsPlugin } from '@orthacms/segments-admin';
15
18
 
16
19
  /**
17
- * The admin's composition, mirroring `src/server/plugins.ts` on the UI side.
20
+ * The admin's composition, mirroring `apps/server/src/plugins.ts` on the UI
21
+ * side.
18
22
  *
19
23
  * **Two positions matter; the rest is legibility.**
20
24
  *
@@ -47,6 +51,10 @@ export function buildPlugins(): AdminPlugin[] {
47
51
  I18nPlugin(),
48
52
  WysiwygPlugin(),
49
53
  MediaPlugin(),
54
+ // Export/import. Another Content Library slot filler — the entry menu,
55
+ // the records selection bar and the collection toolbar — so it reads
56
+ // after ContentPlugin() for the same reason I18nPlugin() does.
57
+ transferAdminPlugin(),
50
58
  // Another Content Library slot filler — the entry rail's checks block,
51
59
  // an optional records column, and "Save as rule" in the toolbar.
52
60
  AlarmsPlugin(),
@@ -54,8 +62,15 @@ export function buildPlugins(): AdminPlugin[] {
54
62
  // the workspace-interior features: the panel mounts into the workspace
55
63
  // shell's sidebar footer.
56
64
  CopilotPlugin(),
65
+ // Reader entitlements — who may *read* published content. It fills the
66
+ // Content Library's entry-header and entry-tab slots, so like the other
67
+ // library fillers it reads after ContentPlugin(); its own audience
68
+ // directory is independent of that order. Inert until an audience
69
+ // exists.
70
+ SegmentsPlugin(),
57
71
  UsersPlugin(),
58
72
  ActivityPlugin(),
59
- ApiTokensPlugin()
73
+ ApiTokensPlugin(),
74
+ WebhooksPlugin()
60
75
  ];
61
76
  }
@@ -14,7 +14,7 @@ export function openAiProvider(): OpenAiProviderConfig | undefined {
14
14
  }
15
15
  return {
16
16
  baseUrl,
17
- apiKey: process.env['COPILOT_OPENAI_API_KEY'] ?? '',
17
+ apiKey: readEnv('COPILOT_OPENAI_API_KEY') ?? '',
18
18
  models: readList('COPILOT_OPENAI_MODELS', 'llama3.1')
19
19
  };
20
20
  }
@@ -3,6 +3,12 @@ import type { IdentityPluginConfig } from '@orthacms/identity-server';
3
3
  // ortha:if sso-oidc
4
4
  import type { OidcProviderConfig } from '@orthacms/identity-provider-oidc';
5
5
  // ortha:end
6
+ // ortha:if sso-github
7
+ import type { GithubProviderConfig } from '@orthacms/identity-provider-github';
8
+ // ortha:end
9
+ // ortha:if sso-saml
10
+ import type { SamlProviderConfig } from '@orthacms/identity-provider-saml';
11
+ // ortha:end
6
12
  import {
7
13
  defined,
8
14
  isProduction,
@@ -14,6 +20,12 @@ import {
14
20
  // ortha:if sso-oidc
15
21
  import { oidcProvider } from './sso-oidc';
16
22
  // ortha:end
23
+ // ortha:if sso-github
24
+ import { githubProvider } from './sso-github';
25
+ // ortha:end
26
+ // ortha:if sso-saml
27
+ import { samlProvider } from './sso-saml';
28
+ // ortha:end
17
29
 
18
30
  /**
19
31
  * Identity settings, plus the identity providers this app can reach.
@@ -36,6 +48,14 @@ export interface AppIdentityConfig extends IdentityPluginConfig {
36
48
  /** A generic OpenID Connect provider. Present when both env vars are set. */
37
49
  oidc?: OidcProviderConfig & { name: string };
38
50
  // ortha:end
51
+ // ortha:if sso-github
52
+ /** GitHub or GitHub Enterprise Server. Present when both env vars are set. */
53
+ github?: GithubProviderConfig & { name: string };
54
+ // ortha:end
55
+ // ortha:if sso-saml
56
+ /** A SAML 2.0 identity provider. Present when all three env vars are set. */
57
+ saml?: SamlProviderConfig & { name: string };
58
+ // ortha:end
39
59
  };
40
60
  }
41
61
 
@@ -67,15 +87,28 @@ export function identityConfig(): AppIdentityConfig {
67
87
  limit: readPositiveInt('LOGIN_RATE_LIMIT', 10)
68
88
  },
69
89
  sso: ssoConfig(),
70
- // ortha:if sso-oidc
71
- ssoProviders: defined({ oidc: oidcProvider() }),
90
+ // ortha:if sso
91
+ ssoProviders: defined({
92
+ // ortha:if sso-oidc
93
+ oidc: oidcProvider(),
94
+ // ortha:end
95
+ // ortha:if sso-github
96
+ github: githubProvider(),
97
+ // ortha:end
98
+ // ortha:if sso-saml
99
+ saml: samlProvider()
100
+ // ortha:end
101
+ }),
72
102
  // ortha:end
73
103
  // With an email set, an admin is provisioned on boot — idempotent and
74
104
  // non-destructive. This is how you get your first login.
105
+ // Read through `readEnv`, so all three are trimmed and a whitespace-only
106
+ // value is nothing rather than a value — a password of three spaces
107
+ // would otherwise be provisioned as the administrator's, silently.
75
108
  rootAdmin: {
76
- email: process.env['ORTHA_ROOT_ADMIN_EMAIL'] ?? '',
77
- password: process.env['ORTHA_ROOT_ADMIN_PASSWORD'] ?? '',
78
- name: process.env['ORTHA_ROOT_ADMIN_NAME'] ?? ''
109
+ email: readEnv('ORTHA_ROOT_ADMIN_EMAIL') ?? '',
110
+ password: readEnv('ORTHA_ROOT_ADMIN_PASSWORD') ?? '',
111
+ name: readEnv('ORTHA_ROOT_ADMIN_NAME') ?? ''
79
112
  }
80
113
  };
81
114
  }
@@ -8,13 +8,14 @@
8
8
  */
9
9
  // ortha:if media-local
10
10
  import type { LocalStorageConfig } from '@orthacms/media-provider-local';
11
+ import { readEnv } from '@orthacms/utils-server';
11
12
 
12
13
  /** Local-filesystem blobs. */
13
14
  export function mediaStorage(): LocalStorageConfig {
14
15
  return {
15
16
  // Point MEDIA_LOCAL_ROOT at a persistent volume in production: a
16
17
  // container's own disk is wiped on every deploy.
17
- rootDir: process.env['MEDIA_LOCAL_ROOT'] ?? './.storage/media'
18
+ rootDir: readEnv('MEDIA_LOCAL_ROOT') ?? './.storage/media'
18
19
  };
19
20
  }
20
21
  // ortha:end
@@ -67,7 +68,7 @@ export function mediaStorage(): S3StorageConfig {
67
68
  return defined({
68
69
  bucket: requireEnv('MEDIA_S3_BUCKET'),
69
70
  // `auto` is what R2 expects; AWS needs its real region.
70
- region: process.env['MEDIA_S3_REGION'] ?? 'auto',
71
+ region: readEnv('MEDIA_S3_REGION') ?? 'auto',
71
72
  // Omit for AWS S3 itself; set it for R2, MinIO, Spaces, B2…
72
73
  endpoint: readEnv('MEDIA_S3_ENDPOINT'),
73
74
  forcePathStyle: readFlag('MEDIA_S3_FORCE_PATH_STYLE', false),
@@ -0,0 +1,42 @@
1
+ // ortha:if sso-github
2
+ import type { GithubProviderConfig } from '@orthacms/identity-provider-github';
3
+ import { defined, readEnv, readList } from '@orthacms/utils-server';
4
+
5
+ /**
6
+ * The GitHub provider, or nothing.
7
+ *
8
+ * Present only when both values are set. The secret is not optional the way an
9
+ * OIDC one can be: GitHub's code exchange has no PKCE, so the secret is the
10
+ * only thing proving the code is being redeemed by this application — and the
11
+ * adapter refuses at construction rather than at the first sign-in, because
12
+ * every SSO failure looks the same to whoever clicked the button.
13
+ */
14
+ export function githubProvider():
15
+ | (GithubProviderConfig & { name: string })
16
+ | undefined {
17
+ const clientId = readEnv('SSO_GITHUB_CLIENT_ID');
18
+ const clientSecret = readEnv('SSO_GITHUB_CLIENT_SECRET');
19
+ if (!clientId || !clientSecret) {
20
+ return undefined;
21
+ }
22
+ // `.env` ships this key blank, and `readList` answers a blank with `[]` —
23
+ // which is a value, not an absence, so passing it straight through would
24
+ // request *no* scopes and leave the profile read with nothing to read.
25
+ const scopes = readList('SSO_GITHUB_SCOPES', '');
26
+ return defined({
27
+ name: readEnv('SSO_GITHUB_NAME') ?? 'github',
28
+ clientId,
29
+ clientSecret,
30
+ label: readEnv('SSO_GITHUB_LABEL'),
31
+ // Defaults to `read:user user:email` — a profile and the verified
32
+ // addresses on it. GitHub's scopes are coarse, so anything wider hands
33
+ // the CMS access it has no use for.
34
+ scopes: scopes.length > 0 ? scopes : undefined,
35
+ // Only for GitHub Enterprise Server; github.com needs none.
36
+ enterpriseBaseUrl: readEnv('SSO_GITHUB_ENTERPRISE_BASE_URL'),
37
+ // Cosmetic, like Google's `hd`: it shapes the account chooser. Who
38
+ // actually gets in is this CMS's own decision, not GitHub's.
39
+ organization: readEnv('SSO_GITHUB_ORGANIZATION')
40
+ });
41
+ }
42
+ // ortha:end
@@ -16,7 +16,7 @@ export function oidcProvider(): (OidcProviderConfig & { name: string }) | undefi
16
16
  return undefined;
17
17
  }
18
18
  return defined({
19
- name: process.env['SSO_OIDC_NAME'] ?? 'oidc',
19
+ name: readEnv('SSO_OIDC_NAME') ?? 'oidc',
20
20
  issuer,
21
21
  clientId,
22
22
  clientSecret: readEnv('SSO_OIDC_CLIENT_SECRET'),
@@ -0,0 +1,46 @@
1
+ // ortha:if sso-saml
2
+ import type { SamlProviderConfig } from '@orthacms/identity-provider-saml';
3
+ import { defined, readEnv, readFlag } from '@orthacms/utils-server';
4
+
5
+ /**
6
+ * The SAML provider, or nothing.
7
+ *
8
+ * All three of the entry point, the certificate and this app's entity id are
9
+ * required, and there is nothing to fall back to: SAML has no discovery
10
+ * document and no key endpoint, so the certificate an operator copies out of
11
+ * their IdP is the whole of the trust relationship.
12
+ */
13
+ export function samlProvider():
14
+ | (SamlProviderConfig & { name: string })
15
+ | undefined {
16
+ const entryPoint = readEnv('SSO_SAML_ENTRY_POINT');
17
+ const idpCert = readEnv('SSO_SAML_IDP_CERT');
18
+ const issuer = readEnv('SSO_SAML_ISSUER');
19
+ if (!entryPoint || !idpCert || !issuer) {
20
+ return undefined;
21
+ }
22
+ return defined({
23
+ name: readEnv('SSO_SAML_NAME') ?? 'saml',
24
+ entryPoint,
25
+ // A PEM body on one line, `\n` escapes and all: an environment
26
+ // variable cannot hold real newlines, so they are put back here rather
27
+ // than left for the XML parser to fail on.
28
+ idpCert: idpCert.replace(/\\n/g, '\n'),
29
+ issuer,
30
+ label: readEnv('SSO_SAML_LABEL'),
31
+ // Worth setting whenever the IdP's NameID format is `emailAddress`: an
32
+ // address is not a stable identifier, and a profile whose subject is
33
+ // one is refused.
34
+ subjectAttribute: readEnv('SSO_SAML_SUBJECT_ATTRIBUTE'),
35
+ emailAttribute: readEnv('SSO_SAML_EMAIL_ATTRIBUTE'),
36
+ nameAttribute: readEnv('SSO_SAML_NAME_ATTRIBUTE'),
37
+ groupsAttribute: readEnv('SSO_SAML_GROUPS_ATTRIBUTE'),
38
+ // Defaults to false, and stays an assertion rather than a reading:
39
+ // **SAML carries no verification claim at all**, so there is nothing
40
+ // an adapter could inspect and be honest about. Setting it says this
41
+ // directory is authoritative for the addresses it reports — which is
42
+ // the only gate on a first sign-in claiming an existing account.
43
+ emailVerified: readFlag('SSO_SAML_EMAIL_VERIFIED', false)
44
+ });
45
+ }
46
+ // ortha:end