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.
Files changed (32) hide show
  1. package/dist/cli.js +6 -267
  2. package/dist/lib/features.d.ts +42 -20
  3. package/dist/lib/features.d.ts.map +1 -1
  4. package/dist/lib/features.js +56 -22
  5. package/dist/lib/run.d.ts +22 -0
  6. package/dist/lib/run.d.ts.map +1 -0
  7. package/dist/lib/run.js +318 -0
  8. package/dist/lib/template.d.ts.map +1 -1
  9. package/dist/lib/template.js +11 -1
  10. package/package.json +1 -1
  11. package/templates/default/README.md.tmpl +38 -12
  12. package/templates/default/apps/admin/src/plugins.spec.ts +5 -1
  13. package/templates/default/apps/admin/src/plugins.ts +21 -2
  14. package/templates/default/apps/admin/tsconfig.json +2 -1
  15. package/templates/default/apps/server/config/copilot-anthropic.ts +22 -0
  16. package/templates/default/apps/server/config/copilot-openai.ts +21 -0
  17. package/templates/default/apps/server/config/copilot.ts +61 -0
  18. package/templates/default/apps/server/config/docs.ts +14 -0
  19. package/templates/default/apps/server/config/i18n.ts +16 -0
  20. package/templates/default/apps/server/config/identity.ts +129 -0
  21. package/templates/default/apps/server/config/mcp.ts +25 -0
  22. package/templates/default/apps/server/config/media-storage.ts +84 -0
  23. package/templates/default/apps/server/config/media.ts +67 -0
  24. package/templates/default/apps/server/config/sso-github.ts +42 -0
  25. package/templates/default/apps/server/config/sso-oidc.ts +34 -0
  26. package/templates/default/apps/server/config/sso-saml.ts +46 -0
  27. package/templates/default/apps/server/ortha.config.ts +60 -449
  28. package/templates/default/apps/server/src/plugins.spec.ts +7 -0
  29. package/templates/default/apps/server/src/plugins.ts +84 -19
  30. package/templates/default/apps/server/tsconfig.spec.json +25 -0
  31. package/templates/default/env.tmpl +84 -2
  32. package/templates/default/tsconfig.json +3 -0
@@ -0,0 +1,129 @@
1
+ /** Identity — sessions, tokens, the SSO handshake, and the first admin. */
2
+ import type { IdentityPluginConfig } from '@orthacms/identity-server';
3
+ // ortha:if sso-oidc
4
+ import type { OidcProviderConfig } from '@orthacms/identity-provider-oidc';
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
12
+ import {
13
+ defined,
14
+ isProduction,
15
+ readEnv,
16
+ readList,
17
+ readPositiveInt
18
+ } from '@orthacms/utils-server';
19
+
20
+ // ortha:if sso-oidc
21
+ import { oidcProvider } from './sso-oidc';
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
29
+
30
+ /**
31
+ * Identity settings, plus the identity providers this app can reach.
32
+ *
33
+ * The provider settings live here rather than inside `IdentityPluginConfig`,
34
+ * for the same reason the copilot's backends do: the plugin names no protocol,
35
+ * and this file is the one place that reads the environment. The constructed
36
+ * adapters are registered in `src/plugins.ts`.
37
+ */
38
+ export interface AppIdentityConfig extends IdentityPluginConfig {
39
+ /**
40
+ * Identity providers, keyed by the name they are registered under. That
41
+ * name appears in the sign-in URL and in every `sso_identities` row, so
42
+ * renaming one orphans the links that name it.
43
+ *
44
+ * Optional, and absent unless this app was generated with single sign-on.
45
+ */
46
+ ssoProviders?: {
47
+ // ortha:if sso-oidc
48
+ /** A generic OpenID Connect provider. Present when both env vars are set. */
49
+ oidc?: OidcProviderConfig & { name: string };
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
59
+ };
60
+ }
61
+
62
+ /** Identity — sessions, tokens, the SSO handshake, and the first admin. */
63
+ export function identityConfig(): AppIdentityConfig {
64
+ return {
65
+ // Origins allowed to make state-changing calls (login-CSRF defence).
66
+ // In development that is the Vite dev server; in production the app is
67
+ // same-origin, so this list is what a separately-hosted admin would
68
+ // need adding to.
69
+ allowedOrigins: readList(
70
+ 'ALLOWED_ORIGINS',
71
+ `http://localhost:${readPositiveInt('ADMIN_PORT', 4200)}`
72
+ ),
73
+ session: {
74
+ ttlSeconds: readPositiveInt('SESSION_TTL_SECONDS', 60 * 60 * 24 * 7),
75
+ cookieSecure: isProduction(),
76
+ cookieSameSite: 'lax' as const
77
+ },
78
+ token: {
79
+ inviteTtlSeconds: readPositiveInt(
80
+ 'INVITE_TTL_SECONDS',
81
+ 60 * 60 * 24 * 7
82
+ ),
83
+ resetTtlSeconds: readPositiveInt('RESET_TTL_SECONDS', 60 * 60)
84
+ },
85
+ rateLimit: {
86
+ ttlSeconds: readPositiveInt('LOGIN_RATE_LIMIT_TTL_SECONDS', 60),
87
+ limit: readPositiveInt('LOGIN_RATE_LIMIT', 10)
88
+ },
89
+ sso: ssoConfig(),
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
+ }),
102
+ // ortha:end
103
+ // With an email set, an admin is provisioned on boot — idempotent and
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.
108
+ rootAdmin: {
109
+ email: readEnv('ORTHA_ROOT_ADMIN_EMAIL') ?? '',
110
+ password: readEnv('ORTHA_ROOT_ADMIN_PASSWORD') ?? '',
111
+ name: readEnv('ORTHA_ROOT_ADMIN_NAME') ?? ''
112
+ }
113
+ };
114
+ }
115
+
116
+ /**
117
+ * The shape of the single sign-on handshake. The providers themselves are
118
+ * constructed in `src/plugins.ts`; these settings say how the round trip runs.
119
+ */
120
+ function ssoConfig(): NonNullable<IdentityPluginConfig['sso']> {
121
+ return defined({
122
+ // The origin browsers reach this API on. It builds the redirect_uri
123
+ // you register with each provider, and it is configured rather than
124
+ // read from the request's Host header, which a client controls. Leave
125
+ // it unset when the admin and the API share an origin — the usual case.
126
+ publicBaseUrl: readEnv('SSO_PUBLIC_BASE_URL'),
127
+ requestTtlSeconds: readPositiveInt('SSO_REQUEST_TTL_SECONDS', 600)
128
+ });
129
+ }
@@ -0,0 +1,25 @@
1
+ // ortha:if mcp
2
+ import type { McpPluginConfig } from '@orthacms/mcp-server';
3
+ import { readFlag, readPositiveInt } from '@orthacms/utils-server';
4
+
5
+ /** The MCP front door — off unless an operator turns it on. */
6
+ export function mcpConfig(): McpPluginConfig {
7
+ return {
8
+ // Off by default: once on, any holder of a full-scope API token can
9
+ // drive content CRUD from an external agent.
10
+ enabled: readFlag('MCP_ENABLED', false),
11
+ // The identity MCP clients display in their connector lists.
12
+ name: '__APP_NAME__',
13
+ version: '1.0.0',
14
+ // A request/response transport owes its caller an answer, and the tool
15
+ // registry has no deadline of its own — so without this the only bound
16
+ // on a `tools/call` is the query underneath it, and a blocked pool
17
+ // turns one call into a socket held until the client gives up.
18
+ callTimeoutMs: readPositiveInt('MCP_CALL_TIMEOUT_MS', 30_000),
19
+ // Deliberately generous: the ceiling exists to stop a pathological
20
+ // result being serialised several times over, not to shape normal use.
21
+ // A result this large does not fit a model's context anyway.
22
+ maxResultBytes: readPositiveInt('MCP_MAX_RESULT_BYTES', 4_194_304)
23
+ };
24
+ }
25
+ // ortha:end
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Where uploads are written.
3
+ *
4
+ * One block per adapter, and the wizard picks exactly one — so a generated app
5
+ * has the body it chose and nothing else. Swapping backends later means
6
+ * swapping this file, the type on `AppMediaConfig.storage`, and the factory
7
+ * `src/plugins.ts` imports; nothing inside the media package changes.
8
+ */
9
+ // ortha:if media-local
10
+ import type { LocalStorageConfig } from '@orthacms/media-provider-local';
11
+ import { readEnv } from '@orthacms/utils-server';
12
+
13
+ /** Local-filesystem blobs. */
14
+ export function mediaStorage(): LocalStorageConfig {
15
+ return {
16
+ // Point MEDIA_LOCAL_ROOT at a persistent volume in production: a
17
+ // container's own disk is wiped on every deploy.
18
+ rootDir: readEnv('MEDIA_LOCAL_ROOT') ?? './.storage/media'
19
+ };
20
+ }
21
+ // ortha:end
22
+ // ortha:if media-vercel-blob
23
+ import type { VercelBlobStorageConfig } from '@orthacms/media-provider-vercel-blob';
24
+ import { defined, readEnv } from '@orthacms/utils-server';
25
+ /** Vercel Blob. */
26
+ export function mediaStorage(): VercelBlobStorageConfig {
27
+ return defined({
28
+ // On Vercel the SDK reads BLOB_READ_WRITE_TOKEN itself, so this is only
29
+ // for running the app elsewhere.
30
+ token: readEnv('BLOB_READ_WRITE_TOKEN')
31
+ });
32
+ }
33
+ // ortha:end
34
+ // ortha:if media-gcs
35
+ import type { GcsStorageConfig } from '@orthacms/media-provider-gcs';
36
+ import { defined, readEnv, readFlag, requireEnv } from '@orthacms/utils-server';
37
+ /** Google Cloud Storage. */
38
+ export function mediaStorage(): GcsStorageConfig {
39
+ return defined({
40
+ bucket: requireEnv('MEDIA_GCS_BUCKET'),
41
+ // Everything else is optional: with no key file and no inline
42
+ // credentials the client uses Application Default Credentials, which is
43
+ // what a GKE or Cloud Run deployment wants.
44
+ projectId: readEnv('MEDIA_GCS_PROJECT_ID'),
45
+ keyFilename: readEnv('MEDIA_GCS_KEY_FILE'),
46
+ signWithIam: readFlag('MEDIA_GCS_SIGN_WITH_IAM', false)
47
+ });
48
+ }
49
+ // ortha:end
50
+ // ortha:if media-azure
51
+ import type { AzureStorageConfig } from '@orthacms/media-provider-azure';
52
+ import { requireEnv } from '@orthacms/utils-server';
53
+ /** Azure Blob Storage. */
54
+ export function mediaStorage(): AzureStorageConfig {
55
+ return {
56
+ container: requireEnv('MEDIA_AZURE_CONTAINER'),
57
+ connectionString: requireEnv('MEDIA_AZURE_CONNECTION_STRING')
58
+ };
59
+ }
60
+ // ortha:end
61
+ // ortha:if media-s3
62
+ import type { S3StorageConfig } from '@orthacms/media-provider-s3';
63
+ import { defined, readEnv, readFlag, requireEnv } from '@orthacms/utils-server';
64
+ /** S3 or an S3-compatible endpoint — R2, MinIO, Spaces, B2. */
65
+ export function mediaStorage(): S3StorageConfig {
66
+ const accessKeyId = readEnv('MEDIA_S3_ACCESS_KEY_ID');
67
+ const secretAccessKey = readEnv('MEDIA_S3_SECRET_ACCESS_KEY');
68
+ return defined({
69
+ bucket: requireEnv('MEDIA_S3_BUCKET'),
70
+ // `auto` is what R2 expects; AWS needs its real region.
71
+ region: readEnv('MEDIA_S3_REGION') ?? 'auto',
72
+ // Omit for AWS S3 itself; set it for R2, MinIO, Spaces, B2…
73
+ endpoint: readEnv('MEDIA_S3_ENDPOINT'),
74
+ forcePathStyle: readFlag('MEDIA_S3_FORCE_PATH_STYLE', false),
75
+ // Absent means "use the SDK's own provider chain" — an instance role,
76
+ // IRSA, a shared config file. Passing blanks instead would shadow all
77
+ // of that with credentials that cannot sign.
78
+ credentials:
79
+ accessKeyId && secretAccessKey
80
+ ? { accessKeyId, secretAccessKey }
81
+ : undefined
82
+ });
83
+ }
84
+ // ortha:end
@@ -0,0 +1,67 @@
1
+ /** Media — the storage backend, plus how downloads and uploads are bounded. */
2
+ import type { MediaPluginConfig } from '@orthacms/media-server';
3
+ // ortha:if media-local
4
+ import type { LocalStorageConfig } from '@orthacms/media-provider-local';
5
+ // ortha:end
6
+ // ortha:if media-s3
7
+ import type { S3StorageConfig } from '@orthacms/media-provider-s3';
8
+ // ortha:end
9
+ // ortha:if media-azure
10
+ import type { AzureStorageConfig } from '@orthacms/media-provider-azure';
11
+ // ortha:end
12
+ // ortha:if media-gcs
13
+ import type { GcsStorageConfig } from '@orthacms/media-provider-gcs';
14
+ // ortha:end
15
+ // ortha:if media-vercel-blob
16
+ import type { VercelBlobStorageConfig } from '@orthacms/media-provider-vercel-blob';
17
+ // ortha:end
18
+ import { readEnv, readPositiveInt } from '@orthacms/utils-server';
19
+
20
+ import { mediaStorage } from './media-storage';
21
+
22
+ /**
23
+ * Media settings, plus whatever the storage backend `src/plugins.ts`
24
+ * constructs needs. The two move together: the type below is the one exported
25
+ * by the adapter that file imports.
26
+ */
27
+ export interface AppMediaConfig extends MediaPluginConfig {
28
+ // ortha:if media-local
29
+ storage: LocalStorageConfig;
30
+ // ortha:end
31
+ // ortha:if media-s3
32
+ storage: S3StorageConfig;
33
+ // ortha:end
34
+ // ortha:if media-azure
35
+ storage: AzureStorageConfig;
36
+ // ortha:end
37
+ // ortha:if media-gcs
38
+ storage: GcsStorageConfig;
39
+ // ortha:end
40
+ // ortha:if media-vercel-blob
41
+ storage: VercelBlobStorageConfig;
42
+ // ortha:end
43
+ }
44
+
45
+ /** Media — the storage backend, plus how downloads and uploads are bounded. */
46
+ export function mediaConfig(): AppMediaConfig {
47
+ return {
48
+ storage: mediaStorage(),
49
+ // Redirect an already-authorized download straight to the storage
50
+ // backend instead of streaming it through the app. Off unless asked
51
+ // for, and only possible on a backend that can sign a URL — the plugin
52
+ // refuses the combination at boot rather than proxying while the
53
+ // operator believes otherwise.
54
+ directServe:
55
+ readEnv('MEDIA_DIRECT_SERVE') === 'signed-url'
56
+ ? 'signed-url'
57
+ : 'off',
58
+ directServeTtlSeconds: readPositiveInt(
59
+ 'MEDIA_DIRECT_SERVE_TTL_SECONDS',
60
+ 300
61
+ ),
62
+ maxUploadBytes: readPositiveInt(
63
+ 'MEDIA_MAX_UPLOAD_BYTES',
64
+ 50 * 1024 * 1024
65
+ )
66
+ };
67
+ }
@@ -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
@@ -0,0 +1,34 @@
1
+ // ortha:if sso-oidc
2
+ import type { OidcProviderConfig } from '@orthacms/identity-provider-oidc';
3
+ import { defined, readEnv, readFlag } from '@orthacms/utils-server';
4
+
5
+ /**
6
+ * The OIDC provider, or nothing.
7
+ *
8
+ * Present only when both values are set: an issuer with no client id becomes a
9
+ * sign-in button that can only fail, and every SSO failure looks the same, so
10
+ * whoever clicks it learns nothing.
11
+ */
12
+ export function oidcProvider(): (OidcProviderConfig & { name: string }) | undefined {
13
+ const issuer = readEnv('SSO_OIDC_ISSUER');
14
+ const clientId = readEnv('SSO_OIDC_CLIENT_ID');
15
+ if (!issuer || !clientId) {
16
+ return undefined;
17
+ }
18
+ return defined({
19
+ name: readEnv('SSO_OIDC_NAME') ?? 'oidc',
20
+ issuer,
21
+ clientId,
22
+ clientSecret: readEnv('SSO_OIDC_CLIENT_SECRET'),
23
+ label: readEnv('SSO_OIDC_LABEL'),
24
+ // The only gate on a first sign-in claiming an existing account. A
25
+ // provider that omits the claim — Entra ID, notably — links nobody
26
+ // until an operator asserts that this directory owns the addresses it
27
+ // reports.
28
+ emailVerifiedWhenAbsent: readFlag(
29
+ 'SSO_OIDC_EMAIL_VERIFIED_WHEN_ABSENT',
30
+ false
31
+ )
32
+ });
33
+ }
34
+ // ortha:end
@@ -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