@percepta/create 4.1.16 → 4.2.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.
@@ -5,6 +5,8 @@ import chalk from "chalk";
5
5
  import fs from "fs-extra";
6
6
  import { isMap, isSeq, parseDocument } from "yaml";
7
7
  //#region src/commands/infra/register-app.ts
8
+ const AUTHENTIK_WEBAPP_OIDC_SERVICE = "authentik-webapp-oidc";
9
+ const WEBAPP_OIDC_CLIENTS_INPUT = "webapp_oidc_clients";
8
10
  const OS_POSTGRESQL_TERRAFORM_SUFFIX = "postgresql-terraform";
9
11
  const LEGACY_OS_POSTGRESQL_TERRAFORM_ALIAS = "os-postgresql-terraform";
10
12
  const OS_POSTGRESQL_TERRAFORM_SERVICES = new Set(["os-postgresql-terraform-aws", "os-postgresql-terraform-azure"]);
@@ -144,6 +146,7 @@ function updateBlueprint(blueprintContent, appName, options) {
144
146
  changed = addAppInput(document, inputs, renderLangfuseSecretKeyInput()) || changed;
145
147
  changed = addAppInput(document, inputs, renderInngestEventKeyInput()) || changed;
146
148
  changed = addAppInput(document, inputs, renderInngestSigningKeyInput()) || changed;
149
+ changed = addAppInput(document, inputs, renderWebappOidcClientsInput()) || changed;
147
150
  }
148
151
  if (options.appDatabase) changed = addAppDatabase(document, inputs, appName) || changed;
149
152
  if (options.appInstallation) {
@@ -153,6 +156,9 @@ function updateBlueprint(blueprintContent, appName, options) {
153
156
  changed = ensureOsPostgresqlInstallationAlias(installations, postgresqlInstallationName) || changed;
154
157
  changed = ensureAppInstallationPostgresqlOutputRefs(installations, postgresqlInstallationName) || changed;
155
158
  changed = addAppInstallation(document, installations, appName, postgresqlInstallationName) || changed;
159
+ changed = ensureAuthentikWebappOidcInstallation(document, installations) || changed;
160
+ changed = ensureAppInstallationAuthentikEnv(document, installations, appName) || changed;
161
+ changed = addWebappOidcClient(document, inputs, appName) || changed;
156
162
  }
157
163
  return changed ? document.toString() : blueprintContent;
158
164
  }
@@ -242,6 +248,63 @@ function addAppInstallation(document, installations, appName, postgresqlInstalla
242
248
  }));
243
249
  return true;
244
250
  }
251
+ function addWebappOidcClient(document, inputs, appName) {
252
+ const clientsInput = inputs.items.find((item) => isMap(item) && item.get("name") === WEBAPP_OIDC_CLIENTS_INPUT);
253
+ if (!isMap(clientsInput)) throw new Error(`OS blueprint must include a ${WEBAPP_OIDC_CLIENTS_INPUT} input.`);
254
+ const defaultValue = clientsInput.get("default", true);
255
+ if (!isSeq(defaultValue)) throw new Error(`OS blueprint ${WEBAPP_OIDC_CLIENTS_INPUT} default must be a sequence.`);
256
+ if (defaultValue.items.some((item) => isMap(item) && item.get("slug") === appName)) return false;
257
+ defaultValue.add(document.createNode({
258
+ slug: appName,
259
+ name: toTitleCase(appName)
260
+ }));
261
+ return true;
262
+ }
263
+ function ensureAuthentikWebappOidcInstallation(document, installations) {
264
+ if (installations.items.some((item) => isMap(item) && item.get("service") === AUTHENTIK_WEBAPP_OIDC_SERVICE)) return false;
265
+ installations.add(document.createNode({
266
+ service: AUTHENTIK_WEBAPP_OIDC_SERVICE,
267
+ config: renderAuthentikWebappOidcConfig()
268
+ }));
269
+ return true;
270
+ }
271
+ function ensureAppInstallationAuthentikEnv(document, installations, appName) {
272
+ const installation = installations.items.find((item) => isMap(item) && item.get("service") === appName);
273
+ if (!isMap(installation)) return false;
274
+ const env = installation.get("env", true);
275
+ if (!isSeq(env)) return false;
276
+ const existingKeys = /* @__PURE__ */ new Set();
277
+ for (const item of env.items) if (isMap(item)) existingKeys.add(item.get("key"));
278
+ let changed = false;
279
+ for (const entry of renderAppAuthentikEnv(appName)) {
280
+ if (existingKeys.has(entry.key)) continue;
281
+ env.add(document.createNode(entry));
282
+ changed = true;
283
+ }
284
+ return changed;
285
+ }
286
+ function renderAuthentikWebappOidcConfig() {
287
+ const ingressDomain = ingressDomainInputName();
288
+ return [
289
+ `authentik_url: 'https://authentik.{{ input "${ingressDomain}" }}'`,
290
+ `ingress_domain: '{{ input "${ingressDomain}" }}'`,
291
+ "namespace: '{{ .ryvn.env.name }}'",
292
+ "env_secret_name: '{{ (blueprintInstallation \"mosaic\").outputs.authentik_env_secret_name }}'",
293
+ `${WEBAPP_OIDC_CLIENTS_INPUT}:`,
294
+ `{{ input "${WEBAPP_OIDC_CLIENTS_INPUT}" | toYaml | nindent 2 }}`,
295
+ ""
296
+ ].join("\n");
297
+ }
298
+ function renderWebappOidcClientsInput() {
299
+ return {
300
+ name: WEBAPP_OIDC_CLIENTS_INPUT,
301
+ type: "array",
302
+ group: "applications",
303
+ displayName: "Webapp OIDC Clients",
304
+ description: "OS webapps registered as Authentik OIDC clients (slug + display name). The authentik-webapp-oidc service provisions a provider/application per entry; the generated client_id/client_secret/issuer are wired into each app as AUTHENTIK_*.",
305
+ default: []
306
+ };
307
+ }
245
308
  function renderIngressDomainInput() {
246
309
  return {
247
310
  name: ingressDomainInputName(),
@@ -421,6 +484,33 @@ function renderAppInstallationEnv(appName, postgresqlInstallationName) {
421
484
  blueprintInstallation: "mosaic",
422
485
  name: "spicedb_insecure"
423
486
  }
487
+ },
488
+ ...renderAppAuthentikEnv(appName)
489
+ ];
490
+ }
491
+ function renderAppAuthentikEnv(appName) {
492
+ return [
493
+ {
494
+ key: "AUTHENTIK_ISSUER",
495
+ valueFromOutput: {
496
+ serviceInstallation: AUTHENTIK_WEBAPP_OIDC_SERVICE,
497
+ name: `issuers.${appName}`
498
+ }
499
+ },
500
+ {
501
+ key: "AUTHENTIK_CLIENT_ID",
502
+ valueFromOutput: {
503
+ serviceInstallation: AUTHENTIK_WEBAPP_OIDC_SERVICE,
504
+ name: `client_ids.${appName}`
505
+ }
506
+ },
507
+ {
508
+ key: "AUTHENTIK_CLIENT_SECRET",
509
+ isSecret: true,
510
+ valueFromOutput: {
511
+ serviceInstallation: AUTHENTIK_WEBAPP_OIDC_SERVICE,
512
+ name: `client_secrets.${appName}`
513
+ }
424
514
  }
425
515
  ];
426
516
  }
@@ -502,4 +592,4 @@ function normalizeAppName(appNameInput) {
502
592
  //#endregion
503
593
  export { registerAppCommand };
504
594
 
505
- //# sourceMappingURL=register-app-BeSQEsel.js.map
595
+ //# sourceMappingURL=register-app-BtvxQeo0.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"register-app-BtvxQeo0.js","names":[],"sources":["../src/commands/infra/register-app.ts"],"sourcesContent":["import path from \"node:path\";\nimport chalk from \"chalk\";\nimport fs from \"fs-extra\";\nimport { isMap, isSeq, parseDocument } from \"yaml\";\nimport {\n toKebabCase,\n toSnakeCase,\n toTitleCase,\n} from \"../../utils/case-converters.js\";\nimport { detectMonorepo } from \"../../utils/detect-monorepo.js\";\nimport { validateProjectName } from \"../../utils/validate.js\";\nimport { readWorkspaceManifest } from \"../../utils/workspace-manifest.js\";\nimport {\n createInfraGitHubApi,\n createOrUpdateInfraPullRequestFiles,\n INFRA_BASE_BRANCH,\n INFRA_REPOSITORY,\n type InfraGitHubApi,\n type InfraPullRequestFile,\n resolveGitHubToken,\n} from \"./github.js\";\n\nconst AUTHENTIK_WEBAPP_OIDC_SERVICE = \"authentik-webapp-oidc\";\nconst WEBAPP_OIDC_CLIENTS_INPUT = \"webapp_oidc_clients\";\nconst OS_POSTGRESQL_TERRAFORM_SUFFIX = \"postgresql-terraform\";\nconst LEGACY_OS_POSTGRESQL_TERRAFORM_ALIAS = \"os-postgresql-terraform\";\nconst OS_POSTGRESQL_TERRAFORM_SERVICES = new Set([\n \"os-postgresql-terraform-aws\",\n \"os-postgresql-terraform-azure\",\n]);\nconst OS_BLUEPRINT_INPUT_GROUPS = [\n {\n name: \"general\",\n displayName: \"General\",\n description: \"Shared OS infrastructure settings.\",\n },\n {\n name: \"applications\",\n displayName: \"Applications\",\n description: \"Generated OS webapp settings.\",\n },\n {\n name: \"aws_postgresql\",\n displayName: \"AWS PostgreSQL\",\n description: \"AWS Aurora PostgreSQL settings.\",\n condition: '{{ eq EnvironmentProviderType \"aws\" }}',\n },\n {\n name: \"azure_postgresql\",\n displayName: \"Azure PostgreSQL\",\n description: \"Azure PostgreSQL Flexible Server settings.\",\n condition: '{{ eq EnvironmentProviderType \"azure\" }}',\n },\n];\n\nexport interface RegisterAppResult {\n appName: string;\n blueprintName: string;\n blueprintPath: string;\n branchName: string;\n customerSlug: string;\n pullRequestUrl: string | null;\n repository: typeof INFRA_REPOSITORY;\n status: \"already_registered\" | \"created_pr\" | \"updated_pr\";\n servicePath: string;\n targetPath: string;\n}\n\nexport async function registerApp(\n appNameInput: string,\n args: {\n cwd?: string;\n github?: InfraGitHubApi;\n } = {},\n): Promise<RegisterAppResult> {\n const appName = normalizeAppName(appNameInput);\n const cwd = args.cwd ?? process.cwd();\n const monorepoContext = await detectMonorepo(cwd);\n if (!monorepoContext.found || !monorepoContext.rootDir) {\n throw new Error(\n \"Run this command from a Mosaic customer monorepo with a .mosaic-workspace.json file.\",\n );\n }\n\n const workspaceManifest = await readWorkspaceManifest(\n monorepoContext.rootDir,\n );\n const customerSlug = workspaceManifest?.customerSlug;\n if (!customerSlug) {\n throw new Error(\n \".mosaic-workspace.json is missing customerSlug. Recreate the monorepo with a current @percepta/create.\",\n );\n }\n\n const github = args.github ?? createInfraGitHubApi(resolveGitHubToken());\n const blueprintName = `${customerSlug}-os`;\n const branchName = `blueberry/register-${customerSlug}-${appName}`;\n const blueprintPath = [\n \"ryvn\",\n \"definitions\",\n customerSlug,\n \"blueprints\",\n `${blueprintName}.blueprint.yaml`,\n ].join(\"/\");\n const servicePath = [\n \"ryvn\",\n \"definitions\",\n customerSlug,\n \"services\",\n `${appName}.service.yaml`,\n ].join(\"/\");\n\n const mainBlueprintFile = await github.getFile(\n blueprintPath,\n INFRA_BASE_BRANCH,\n );\n if (!mainBlueprintFile) {\n throw new Error(\n `${blueprintPath} does not exist in ${INFRA_REPOSITORY}. Run \\`pnpm mosaic infra register-os-blueprint\\` and merge that infra PR first.`,\n );\n }\n\n const mainServiceFile = await github.getFile(servicePath, INFRA_BASE_BRANCH);\n const serviceContent =\n mainServiceFile == null\n ? await readLocalServiceDefinition(monorepoContext.rootDir, appName)\n : null;\n const blueprintContent = registerAppInBlueprint(\n mainBlueprintFile.content,\n appName,\n );\n\n const files: InfraPullRequestFile[] = [];\n if (blueprintContent !== mainBlueprintFile.content) {\n files.push({\n baseFileSha: mainBlueprintFile.sha,\n content: blueprintContent,\n message: `Register ${appName} in ${blueprintName}`,\n path: blueprintPath,\n });\n }\n if (serviceContent != null) {\n files.push({\n content: serviceContent,\n message: `Register ${appName} service`,\n path: servicePath,\n });\n }\n\n if (files.length === 0) {\n return {\n appName,\n blueprintName,\n blueprintPath,\n branchName,\n customerSlug,\n pullRequestUrl: null,\n repository: INFRA_REPOSITORY,\n status: \"already_registered\",\n servicePath,\n targetPath: blueprintPath,\n };\n }\n\n const pullRequest = await createOrUpdateInfraPullRequestFiles({\n branchName,\n github,\n files,\n title: `Register ${appName} app`,\n body: [\n `Registers the ${appName} service and deployment in ${blueprintName}.`,\n \"\",\n \"Generated by `mosaic infra register-app`.\",\n ].join(\"\\n\"),\n });\n\n return {\n appName,\n blueprintName,\n blueprintPath,\n branchName,\n customerSlug,\n pullRequestUrl: pullRequest.pullRequestUrl,\n repository: INFRA_REPOSITORY,\n status: pullRequest.status,\n servicePath,\n targetPath: blueprintPath,\n };\n}\n\nexport async function registerAppCommand(appName: string): Promise<void> {\n try {\n const result = await registerApp(appName);\n\n if (result.status === \"already_registered\") {\n console.log(\n chalk.green(\"✔\"),\n `${result.appName} is already registered in ${result.repository} at`,\n chalk.cyan(result.targetPath),\n );\n return;\n }\n\n const verb =\n result.status === \"created_pr\" ? \"Created\" : \"Updated existing\";\n console.log(\n chalk.green(\"✔\"),\n `${verb} infra PR for ${result.appName}:`,\n chalk.cyan(result.pullRequestUrl),\n );\n } catch (error) {\n console.error(chalk.red(\"Error:\"), (error as Error).message);\n process.exit(1);\n }\n}\n\nexport function addAppDatabaseToBlueprint(\n blueprintContent: string,\n appName: string,\n): string {\n return updateBlueprint(blueprintContent, appName, {\n appDatabase: true,\n appInstallation: false,\n appInputs: false,\n });\n}\n\nexport function registerAppInBlueprint(\n blueprintContent: string,\n appName: string,\n): string {\n return updateBlueprint(blueprintContent, appName, {\n appDatabase: true,\n appInstallation: true,\n appInputs: true,\n });\n}\n\nfunction updateBlueprint(\n blueprintContent: string,\n appName: string,\n options: {\n appDatabase: boolean;\n appInstallation: boolean;\n appInputs: boolean;\n },\n): string {\n const document = parseDocument(blueprintContent);\n if (document.errors.length > 0) {\n throw new Error(\n `Invalid OS blueprint YAML: ${document.errors.map((error) => error.message).join(\"; \")}`,\n );\n }\n\n const spec = document.get(\"spec\", true);\n if (!isMap(spec)) {\n throw new Error(\"OS blueprint must include a spec map.\");\n }\n\n let changed = false;\n const inputs = spec.get(\"inputs\", true);\n if (!isSeq(inputs)) {\n throw new Error(\"OS blueprint spec.inputs must be a sequence.\");\n }\n\n changed = ensureInputGroups(document, spec) || changed;\n\n if (options.appInputs) {\n changed =\n addAppInput(document, inputs, renderIngressDomainInput()) || changed;\n changed =\n addAppInput(document, inputs, renderBetterAuthSecretInput(appName)) ||\n changed;\n changed =\n addAppInput(document, inputs, renderLangfusePublicKeyInput()) || changed;\n changed =\n addAppInput(document, inputs, renderLangfuseSecretKeyInput()) || changed;\n changed =\n addAppInput(document, inputs, renderInngestEventKeyInput()) || changed;\n changed =\n addAppInput(document, inputs, renderInngestSigningKeyInput()) || changed;\n changed =\n addAppInput(document, inputs, renderWebappOidcClientsInput()) || changed;\n }\n\n if (options.appDatabase) {\n changed = addAppDatabase(document, inputs, appName) || changed;\n }\n\n if (options.appInstallation) {\n const installations = spec.get(\"installations\", true);\n if (!isSeq(installations)) {\n throw new Error(\"OS blueprint spec.installations must be a sequence.\");\n }\n const postgresqlInstallationName = getOsPostgresqlInstallationName(\n getBlueprintName(document),\n );\n changed =\n ensureOsPostgresqlInstallationAlias(\n installations,\n postgresqlInstallationName,\n ) || changed;\n changed =\n ensureAppInstallationPostgresqlOutputRefs(\n installations,\n postgresqlInstallationName,\n ) || changed;\n changed =\n addAppInstallation(\n document,\n installations,\n appName,\n postgresqlInstallationName,\n ) || changed;\n changed =\n ensureAuthentikWebappOidcInstallation(document, installations) || changed;\n changed =\n ensureAppInstallationAuthentikEnv(document, installations, appName) ||\n changed;\n changed = addWebappOidcClient(document, inputs, appName) || changed;\n }\n\n return changed ? document.toString() : blueprintContent;\n}\n\nfunction ensureInputGroups(\n document: ReturnType<typeof parseDocument>,\n spec: {\n get(key: string, keepScalar?: true): unknown;\n set(key: string, value: unknown): void;\n },\n): boolean {\n let changed = false;\n const inputGroups = spec.get(\"inputGroups\", true);\n\n if (inputGroups == null) {\n spec.set(\"inputGroups\", document.createNode(OS_BLUEPRINT_INPUT_GROUPS));\n return true;\n }\n\n if (!isSeq(inputGroups)) {\n throw new Error(\"OS blueprint spec.inputGroups must be a sequence.\");\n }\n\n for (const group of OS_BLUEPRINT_INPUT_GROUPS) {\n const exists = inputGroups.items.some(\n (item) => isMap(item) && item.get(\"name\") === group.name,\n );\n if (exists) continue;\n\n inputGroups.add(document.createNode(group));\n changed = true;\n }\n\n return changed;\n}\n\nfunction ensureOsPostgresqlInstallationAlias(\n installations: { items: unknown[] },\n postgresqlInstallationName: string,\n): boolean {\n let changed = false;\n\n for (const installation of installations.items) {\n if (!isMap(installation)) continue;\n\n const service = installation.get(\"service\");\n if (\n typeof service !== \"string\" ||\n !OS_POSTGRESQL_TERRAFORM_SERVICES.has(service)\n ) {\n continue;\n }\n\n if (installation.get(\"name\") === postgresqlInstallationName) continue;\n\n installation.set(\"name\", postgresqlInstallationName);\n changed = true;\n }\n\n return changed;\n}\n\nfunction ensureAppInstallationPostgresqlOutputRefs(\n installations: { items: unknown[] },\n postgresqlInstallationName: string,\n): boolean {\n let changed = false;\n\n for (const installation of installations.items) {\n if (!isMap(installation)) continue;\n if (isOsPostgresqlInstallation(installation)) continue;\n\n const env = installation.get(\"env\", true);\n if (!isSeq(env)) continue;\n\n for (const envVar of env.items) {\n if (!isMap(envVar)) continue;\n\n const valueFromOutput = envVar.get(\"valueFromOutput\", true);\n if (!isMap(valueFromOutput)) continue;\n if (\n valueFromOutput.get(\"serviceInstallation\") !==\n LEGACY_OS_POSTGRESQL_TERRAFORM_ALIAS\n ) {\n continue;\n }\n\n const outputName = valueFromOutput.get(\"name\");\n if (\n typeof outputName !== \"string\" ||\n (outputName !== \"auth_database_url\" &&\n !outputName.startsWith(\"app_database_urls.\"))\n ) {\n continue;\n }\n\n valueFromOutput.set(\"serviceInstallation\", postgresqlInstallationName);\n changed = true;\n }\n }\n\n return changed;\n}\n\nfunction isOsPostgresqlInstallation(installation: {\n get(key: string, keepScalar?: true): unknown;\n}): boolean {\n const service = installation.get(\"service\");\n return (\n typeof service === \"string\" && OS_POSTGRESQL_TERRAFORM_SERVICES.has(service)\n );\n}\n\nfunction getBlueprintName(document: ReturnType<typeof parseDocument>): string {\n const metadata = document.get(\"metadata\", true);\n if (!isMap(metadata)) {\n throw new Error(\"OS blueprint must include a metadata map.\");\n }\n\n const name = metadata.get(\"name\");\n if (typeof name !== \"string\" || name.length === 0) {\n throw new Error(\"OS blueprint metadata.name must be a non-empty string.\");\n }\n\n return name;\n}\n\nfunction getOsPostgresqlInstallationName(blueprintName: string): string {\n return `${blueprintName}-${OS_POSTGRESQL_TERRAFORM_SUFFIX}`;\n}\n\nfunction addAppInput(\n document: ReturnType<typeof parseDocument>,\n inputs: { add(value: unknown): void; items: unknown[] },\n input: Record<string, unknown> & { name: string },\n): boolean {\n if (\n inputs.items.some((item) => isMap(item) && item.get(\"name\") === input.name)\n ) {\n return false;\n }\n\n inputs.add(document.createNode(input));\n return true;\n}\n\nfunction addAppDatabase(\n document: ReturnType<typeof parseDocument>,\n inputs: { items: unknown[] },\n appName: string,\n): boolean {\n const appDatabasesInput = inputs.items.find(\n (item) => isMap(item) && item.get(\"name\") === \"app_databases\",\n );\n if (!isMap(appDatabasesInput)) {\n throw new Error(\"OS blueprint must include an app_databases input.\");\n }\n\n const defaultValue = appDatabasesInput.get(\"default\", true);\n if (!isMap(defaultValue)) {\n throw new Error(\"OS blueprint app_databases default must be a map.\");\n }\n\n if (defaultValue.has(appName)) return false;\n\n defaultValue.flow = false;\n const appDatabaseValue = document.createNode({\n schema_name: toSnakeCase(appName),\n });\n defaultValue.set(appName, appDatabaseValue);\n return true;\n}\n\nfunction addAppInstallation(\n document: ReturnType<typeof parseDocument>,\n installations: { add(value: unknown): void; items: unknown[] },\n appName: string,\n postgresqlInstallationName: string,\n): boolean {\n if (\n installations.items.some(\n (item) => isMap(item) && item.get(\"service\") === appName,\n )\n ) {\n return false;\n }\n\n installations.add(\n document.createNode({\n service: appName,\n env: renderAppInstallationEnv(appName, postgresqlInstallationName),\n config: renderAppInstallationConfig(appName),\n }),\n );\n return true;\n}\n\n// Adds the app (slug + display name) to the shared webapp_oidc_clients input\n// the authentik-webapp-oidc service consumes. Mirrors addAppDatabase, but the\n// default is a sequence of objects rather than a map.\nfunction addWebappOidcClient(\n document: ReturnType<typeof parseDocument>,\n inputs: { items: unknown[] },\n appName: string,\n): boolean {\n const clientsInput = inputs.items.find(\n (item) => isMap(item) && item.get(\"name\") === WEBAPP_OIDC_CLIENTS_INPUT,\n );\n if (!isMap(clientsInput)) {\n throw new Error(\n `OS blueprint must include a ${WEBAPP_OIDC_CLIENTS_INPUT} input.`,\n );\n }\n\n const defaultValue = clientsInput.get(\"default\", true);\n if (!isSeq(defaultValue)) {\n throw new Error(\n `OS blueprint ${WEBAPP_OIDC_CLIENTS_INPUT} default must be a sequence.`,\n );\n }\n\n if (\n defaultValue.items.some(\n (item) => isMap(item) && item.get(\"slug\") === appName,\n )\n ) {\n return false;\n }\n\n defaultValue.add(\n document.createNode({ slug: appName, name: toTitleCase(appName) }),\n );\n return true;\n}\n\n// Ensures the customer OS blueprint installs the authentik-webapp-oidc service\n// (one per blueprint). It provisions the per-app OIDC clients against this\n// environment's Authentik and exposes their credentials as outputs the app\n// installations read.\nfunction ensureAuthentikWebappOidcInstallation(\n document: ReturnType<typeof parseDocument>,\n installations: { add(value: unknown): void; items: unknown[] },\n): boolean {\n if (\n installations.items.some(\n (item) =>\n isMap(item) && item.get(\"service\") === AUTHENTIK_WEBAPP_OIDC_SERVICE,\n )\n ) {\n return false;\n }\n\n installations.add(\n document.createNode({\n service: AUTHENTIK_WEBAPP_OIDC_SERVICE,\n config: renderAuthentikWebappOidcConfig(),\n }),\n );\n return true;\n}\n\n// Ensures the app's server installation carries the AUTHENTIK_* env. New\n// installs already get it via renderAppInstallationEnv; this backfills apps that\n// were registered before SSO wiring existed, so re-running register-app doesn't\n// leave them provisioned-but-unwired.\nfunction ensureAppInstallationAuthentikEnv(\n document: ReturnType<typeof parseDocument>,\n installations: { items: unknown[] },\n appName: string,\n): boolean {\n const installation = installations.items.find(\n (item) => isMap(item) && item.get(\"service\") === appName,\n );\n if (!isMap(installation)) return false;\n\n const env = installation.get(\"env\", true);\n if (!isSeq(env)) return false;\n\n const existingKeys = new Set<unknown>();\n for (const item of env.items) {\n if (isMap(item)) existingKeys.add(item.get(\"key\"));\n }\n\n let changed = false;\n for (const entry of renderAppAuthentikEnv(appName)) {\n if (existingKeys.has(entry.key)) continue;\n env.add(document.createNode(entry));\n changed = true;\n }\n return changed;\n}\n\nfunction renderAuthentikWebappOidcConfig(): string {\n const ingressDomain = ingressDomainInputName();\n return [\n `authentik_url: 'https://authentik.{{ input \"${ingressDomain}\" }}'`,\n `ingress_domain: '{{ input \"${ingressDomain}\" }}'`,\n \"namespace: '{{ .ryvn.env.name }}'\",\n \"env_secret_name: '{{ (blueprintInstallation \\\"mosaic\\\").outputs.authentik_env_secret_name }}'\",\n `${WEBAPP_OIDC_CLIENTS_INPUT}:`,\n `{{ input \"${WEBAPP_OIDC_CLIENTS_INPUT}\" | toYaml | nindent 2 }}`,\n \"\",\n ].join(\"\\n\");\n}\n\nfunction renderWebappOidcClientsInput(): Record<string, unknown> & {\n name: string;\n} {\n return {\n name: WEBAPP_OIDC_CLIENTS_INPUT,\n type: \"array\",\n group: \"applications\",\n displayName: \"Webapp OIDC Clients\",\n description:\n \"OS webapps registered as Authentik OIDC clients (slug + display name). The authentik-webapp-oidc service provisions a provider/application per entry; the generated client_id/client_secret/issuer are wired into each app as AUTHENTIK_*.\",\n default: [],\n };\n}\n\nfunction renderIngressDomainInput(): Record<string, unknown> & {\n name: string;\n} {\n return {\n name: ingressDomainInputName(),\n type: \"string\",\n group: \"applications\",\n displayName: \"Ingress Domain\",\n description: \"Shared ingress domain for generated OS webapps.\",\n default: '{{ default \"example.local\" .ryvn.env.state.public_domain.name }}',\n };\n}\n\nfunction renderBetterAuthSecretInput(\n appName: string,\n): Record<string, unknown> & { name: string } {\n return {\n name: betterAuthSecretInputName(appName),\n type: \"string\",\n isSecret: true,\n group: \"applications\",\n displayName: `${toTitleCase(appName)} Better Auth Secret`,\n description: `Generated Better Auth signing secret for ${appName}.`,\n hidden: true,\n generated: {\n type: \"random-bytes\",\n length: 32,\n },\n };\n}\n\nfunction renderLangfusePublicKeyInput(): Record<string, unknown> & {\n name: string;\n} {\n return {\n name: langfusePublicKeyInputName(),\n type: \"string\",\n group: \"applications\",\n displayName: \"Langfuse Public Key\",\n description:\n \"Shared Langfuse public key for generated OS webapps. Leave empty to disable Langfuse export.\",\n default: \"\",\n };\n}\n\nfunction renderLangfuseSecretKeyInput(): Record<string, unknown> & {\n name: string;\n} {\n return {\n name: langfuseSecretKeyInputName(),\n type: \"string\",\n isSecret: true,\n group: \"applications\",\n displayName: \"Langfuse Secret Key\",\n description:\n \"Shared Langfuse secret key for generated OS webapps. Leave unset to disable Langfuse export.\",\n condition: `{{ ne (input \"${langfusePublicKeyInputName()}\") \"\" }}`,\n };\n}\n\nfunction renderInngestEventKeyInput(): Record<string, unknown> & {\n name: string;\n} {\n return {\n name: inngestEventKeyInputName(),\n type: \"string\",\n isSecret: true,\n group: \"applications\",\n displayName: \"Inngest Event Key\",\n description: \"Shared Inngest event key for generated OS webapps.\",\n };\n}\n\nfunction renderInngestSigningKeyInput(): Record<string, unknown> & {\n name: string;\n} {\n return {\n name: inngestSigningKeyInputName(),\n type: \"string\",\n isSecret: true,\n group: \"applications\",\n displayName: \"Inngest Signing Key\",\n description: \"Shared Inngest signing key for generated OS webapps.\",\n };\n}\n\nfunction renderAppInstallationEnv(\n appName: string,\n postgresqlInstallationName: string,\n): Array<Record<string, unknown>> {\n const appInternalEndpoint = `http://${appName}-web-server.{{ .ryvn.env.name }}.svc.cluster.local:3000/api/inngest`;\n\n return [\n {\n key: \"DATABASE_URL\",\n isSecret: true,\n valueFromOutput: {\n serviceInstallation: postgresqlInstallationName,\n name: `app_database_urls.${appName}`,\n },\n },\n {\n key: \"AUTH_DATABASE_URL\",\n isSecret: true,\n valueFromOutput: {\n serviceInstallation: postgresqlInstallationName,\n name: \"auth_database_url\",\n },\n },\n {\n key: \"DATABASE_SCHEMA\",\n value: toSnakeCase(appName),\n },\n {\n key: \"INGRESS_DOMAIN\",\n valueFromInput: {\n name: ingressDomainInputName(),\n },\n },\n {\n key: \"APP_BASE_URL\",\n value: `https://${appName}.$(INGRESS_DOMAIN)`,\n },\n {\n key: \"BETTER_AUTH_URL\",\n value: `https://${appName}.$(INGRESS_DOMAIN)`,\n },\n {\n key: \"DEPLOYMENT_ENVIRONMENT\",\n value: \"{{ .ryvn.env.name }}\",\n },\n {\n key: \"BETTER_AUTH_SECRET\",\n isSecret: true,\n valueFromInput: {\n name: betterAuthSecretInputName(appName),\n },\n },\n {\n key: \"INNGEST_BASE_URL\",\n valueFromOutput: {\n blueprintInstallation: \"mosaic\",\n name: \"inngest_base_url\",\n },\n },\n {\n key: \"INNGEST_EVENT_KEY\",\n isSecret: true,\n valueFromInput: {\n name: inngestEventKeyInputName(),\n },\n },\n {\n key: \"INNGEST_SIGNING_KEY\",\n isSecret: true,\n valueFromInput: {\n name: inngestSigningKeyInputName(),\n },\n },\n {\n key: \"INNGEST_APP_URL\",\n value: appInternalEndpoint,\n },\n {\n key: \"INNGEST_SERVE_HOST\",\n value: appInternalEndpoint,\n },\n {\n key: \"LANGFUSE_BASE_URL\",\n valueFromOutput: {\n blueprintInstallation: \"mosaic\",\n name: \"langfuse_internal_url\",\n },\n },\n {\n key: \"LANGFUSE_PUBLIC_KEY\",\n valueFromInput: {\n name: langfusePublicKeyInputName(),\n },\n },\n {\n key: \"LANGFUSE_SECRET_KEY\",\n isSecret: true,\n valueFromInput: {\n name: langfuseSecretKeyInputName(),\n },\n },\n {\n key: \"OTEL_EXPORTER_OTLP_ENDPOINT\",\n valueFromOutput: {\n blueprintInstallation: \"mosaic\",\n name: \"otel_exporter_otlp_endpoint\",\n },\n },\n {\n key: \"SPICEDB_ENDPOINT\",\n valueFromOutput: {\n blueprintInstallation: \"mosaic\",\n name: \"spicedb_endpoint\",\n },\n },\n {\n key: \"SPICEDB_PRESHARED_KEY\",\n isSecret: true,\n valueFromOutput: {\n blueprintInstallation: \"mosaic\",\n name: \"spicedb_preshared_key\",\n },\n },\n {\n key: \"SPICEDB_INSECURE\",\n valueFromOutput: {\n blueprintInstallation: \"mosaic\",\n name: \"spicedb_insecure\",\n },\n },\n ...renderAppAuthentikEnv(appName),\n ];\n}\n\n// AUTHENTIK_* env read from the authentik-webapp-oidc service's per-slug map\n// outputs. Shared by renderAppInstallationEnv (new installs) and\n// ensureAppInstallationAuthentikEnv (existing installs) so re-running\n// register-app wires already-registered apps too.\nfunction renderAppAuthentikEnv(\n appName: string,\n): Array<Record<string, unknown>> {\n return [\n {\n key: \"AUTHENTIK_ISSUER\",\n valueFromOutput: {\n serviceInstallation: AUTHENTIK_WEBAPP_OIDC_SERVICE,\n name: `issuers.${appName}`,\n },\n },\n {\n key: \"AUTHENTIK_CLIENT_ID\",\n valueFromOutput: {\n serviceInstallation: AUTHENTIK_WEBAPP_OIDC_SERVICE,\n name: `client_ids.${appName}`,\n },\n },\n {\n key: \"AUTHENTIK_CLIENT_SECRET\",\n isSecret: true,\n valueFromOutput: {\n serviceInstallation: AUTHENTIK_WEBAPP_OIDC_SERVICE,\n name: `client_secrets.${appName}`,\n },\n },\n ];\n}\n\nfunction renderAppInstallationConfig(appName: string): string {\n const appHost = `${appName}.{{ input \"${ingressDomainInputName()}\" }}`;\n\n return [\n \"replicaCount: 1\",\n \"\",\n \"service:\",\n \" port: 3000\",\n \"\",\n \"livenessEnabled: true\",\n \"readinessEnabled: true\",\n \"startupEnabled: true\",\n \"\",\n \"resources:\",\n \" requests:\",\n ' cpu: \"100m\"',\n \" memory: 256Mi\",\n \" limits:\",\n ' cpu: \"500m\"',\n \" memory: 512Mi\",\n \"\",\n \"ingress:\",\n \" enabled: true\",\n \" className: external-nginx\",\n \" annotations:\",\n \" cert-manager.io/cluster-issuer: external-issuer\",\n ' nginx.ingress.kubernetes.io/ssl-redirect: \"true\"',\n \" hosts:\",\n ` - host: '${appHost}'`,\n \" paths:\",\n \" - path: /\",\n \" pathType: Prefix\",\n \" tls:\",\n ` - secretName: ${appName}-tls`,\n \" hosts:\",\n ` - '${appHost}'`,\n \"\",\n ].join(\"\\n\");\n}\n\nasync function readLocalServiceDefinition(\n monorepoRoot: string,\n appName: string,\n): Promise<string> {\n const serviceDefinitionPath = path.join(\n monorepoRoot,\n \"packages\",\n appName,\n \"deploy\",\n \"ryvn\",\n `${appName}.service.yaml`,\n );\n if (!(await fs.pathExists(serviceDefinitionPath))) {\n throw new Error(\n `${serviceDefinitionPath} does not exist. Add the app's Ryvn service definition before registering it in infra.`,\n );\n }\n\n const content = await fs.readFile(serviceDefinitionPath, \"utf-8\");\n validateLocalServiceDefinition(content, appName, serviceDefinitionPath);\n return content.endsWith(\"\\n\") ? content : `${content}\\n`;\n}\n\nfunction validateLocalServiceDefinition(\n content: string,\n appName: string,\n serviceDefinitionPath: string,\n): void {\n const document = parseDocument(content);\n if (document.errors.length > 0) {\n throw new Error(\n `Invalid Ryvn service YAML at ${serviceDefinitionPath}: ${document.errors.map((error) => error.message).join(\"; \")}`,\n );\n }\n\n const service = document.toJS() as {\n kind?: unknown;\n metadata?: { name?: unknown };\n };\n if (service.kind !== \"Service\" || service.metadata?.name !== appName) {\n throw new Error(\n `${serviceDefinitionPath} must define kind: Service with metadata.name: ${appName}.`,\n );\n }\n}\n\nfunction ingressDomainInputName(): string {\n return \"ingress_domain\";\n}\n\nfunction betterAuthSecretInputName(appName: string): string {\n return `${toSnakeCase(appName)}_better_auth_secret`;\n}\n\nfunction langfusePublicKeyInputName(): string {\n return \"langfuse_public_key\";\n}\n\nfunction langfuseSecretKeyInputName(): string {\n return \"langfuse_secret_key\";\n}\n\nfunction inngestEventKeyInputName(): string {\n return \"inngest_event_key\";\n}\n\nfunction inngestSigningKeyInputName(): string {\n return \"inngest_signing_key\";\n}\n\nfunction normalizeAppName(appNameInput: string): string {\n const appName = toKebabCase(appNameInput);\n const validation = validateProjectName(appName);\n if (!validation.valid) {\n throw new Error(`Invalid app name: ${validation.error}`);\n }\n return appName;\n}\n"],"mappings":"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ACd,MAAM,0BAA0B,OAAO,EACzC;EACF;EACA;GACE,KAAK;GACL,iBAAiB;IACf,uBAAuB;IACvB,MAAM;GACR;EACF;EACA;GACE,KAAK;GACL,UAAU;GACV,gBAAgB,EACd,MAAM,yBAAyB,EACjC;EACF;EACA;GACE,KAAK;GACL,UAAU;GACV,gBAAgB,EACd,MAAM,2BAA2B,EACnC;EACF;EACA;GACE,KAAK;GACL,OAAO;EACT;EACA;GACE,KAAK;GACL,OAAO;EACT;EACA;GACE,KAAK;GACL,iBAAiB;IACf,uBAAuB;IACvB,MAAM;GACR;EACF;EACA;GACE,KAAK;GACL,gBAAgB,EACd,MAAM,2BAA2B,EACnC;EACF;EACA;GACE,KAAK;GACL,UAAU;GACV,gBAAgB,EACd,MAAM,2BAA2B,EACnC;EACF;EACA;GACE,KAAK;GACL,iBAAiB;IACf,uBAAuB;IACvB,MAAM;GACR;EACF;EACA;GACE,KAAK;GACL,iBAAiB;IACf,uBAAuB;IACvB,MAAM;GACR;EACF;EACA;GACE,KAAK;GACL,UAAU;GACV,iBAAiB;IACf,uBAAuB;IACvB,MAAM;GACR;EACF;EACA;GACE,KAAK;GACL,iBAAiB;IACf,uBAAuB;IACvB,MAAM;GACR;EACF;EACA,GAAG,sBAAsB,OAAO;CAClC;AACF;AAMA,SAAS,sBACP,SACgC;CAChC,OAAO;EACL;GACE,KAAK;GACL,iBAAiB;IACf,qBAAqB;IACrB,MAAM,WAAW;GACnB;EACF;EACA;GACE,KAAK;GACL,iBAAiB;IACf,qBAAqB;IACrB,MAAM,cAAc;GACtB;EACF;EACA;GACE,KAAK;GACL,UAAU;GACV,iBAAiB;IACf,qBAAqB;IACrB,MAAM,kBAAkB;GAC1B;EACF;CACF;AACF;AAEA,SAAS,4BAA4B,SAAyB;CAC5D,MAAM,UAAU,GAAG,QAAQ,aAAa,uBAAuB,EAAE;CAEjE,OAAO;EACL;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA,gBAAgB,QAAQ;EACxB;EACA;EACA;EACA;EACA,qBAAqB,QAAQ;EAC7B;EACA,cAAc,QAAQ;EACtB;CACF,EAAE,KAAK,IAAI;AACb;AAEA,eAAe,2BACb,cACA,SACiB;CACjB,MAAM,wBAAwB,KAAK,KACjC,cACA,YACA,SACA,UACA,QACA,GAAG,QAAQ,cACb;CACA,IAAI,CAAE,MAAM,GAAG,WAAW,qBAAqB,GAC7C,MAAM,IAAI,MACR,GAAG,sBAAsB,uFAC3B;CAGF,MAAM,UAAU,MAAM,GAAG,SAAS,uBAAuB,OAAO;CAChE,+BAA+B,SAAS,SAAS,qBAAqB;CACtE,OAAO,QAAQ,SAAS,IAAI,IAAI,UAAU,GAAG,QAAQ;AACvD;AAEA,SAAS,+BACP,SACA,SACA,uBACM;CACN,MAAM,WAAW,cAAc,OAAO;CACtC,IAAI,SAAS,OAAO,SAAS,GAC3B,MAAM,IAAI,MACR,gCAAgC,sBAAsB,IAAI,SAAS,OAAO,KAAK,UAAU,MAAM,OAAO,EAAE,KAAK,IAAI,GACnH;CAGF,MAAM,UAAU,SAAS,KAAK;CAI9B,IAAI,QAAQ,SAAS,aAAa,QAAQ,UAAU,SAAS,SAC3D,MAAM,IAAI,MACR,GAAG,sBAAsB,iDAAiD,QAAQ,EACpF;AAEJ;AAEA,SAAS,yBAAiC;CACxC,OAAO;AACT;AAEA,SAAS,0BAA0B,SAAyB;CAC1D,OAAO,GAAG,YAAY,OAAO,EAAE;AACjC;AAEA,SAAS,6BAAqC;CAC5C,OAAO;AACT;AAEA,SAAS,6BAAqC;CAC5C,OAAO;AACT;AAEA,SAAS,2BAAmC;CAC1C,OAAO;AACT;AAEA,SAAS,6BAAqC;CAC5C,OAAO;AACT;AAEA,SAAS,iBAAiB,cAA8B;CACtD,MAAM,UAAU,YAAY,YAAY;CACxC,MAAM,aAAa,oBAAoB,OAAO;CAC9C,IAAI,CAAC,WAAW,OACd,MAAM,IAAI,MAAM,qBAAqB,WAAW,OAAO;CAEzD,OAAO;AACT"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@percepta/create",
3
- "version": "4.1.16",
3
+ "version": "4.2.0",
4
4
  "description": "Scaffold a new Mosaic package",
5
5
  "keywords": [
6
6
  "cli",
@@ -1,5 +1,5 @@
1
1
  {
2
- "monorepo": "1.0.6",
3
- "webapp": "1.3.4",
2
+ "monorepo": "1.0.8",
3
+ "webapp": "1.3.6",
4
4
  "library": "1.0.0"
5
5
  }
@@ -35,7 +35,8 @@ pnpm lint
35
35
  ```
36
36
  access/ # Customer-level SpiceDB fixtures and generated merge artifacts
37
37
  auth/ # Shared Better Auth users/groups package for this customer
38
- docker-compose.yml # Root-owned local PostgreSQL and SpiceDB services
38
+ authentik/ # Local Authentik provisioning (initdb SQL + dev blueprints)
39
+ docker-compose.yml # Root-owned local PostgreSQL, SpiceDB, and Authentik services
39
40
  scripts/ # Root-owned local infrastructure helpers
40
41
  packages/
41
42
  └── your-package/ # Application and library packages
@@ -52,6 +53,32 @@ When you add another webapp with `pnpm mosaic add webapp my-app`, the generated
52
53
  app gets its own local `DATABASE_URL`. Re-running `pnpm run setup` creates that
53
54
  database before running migrations.
54
55
 
56
+ ## Authentication (local Authentik)
57
+
58
+ Apps authenticate via Authentik SSO in every environment — there is no
59
+ username/password fallback. `pnpm run setup` brings up a local **Authentik**
60
+ container (admin UI at <http://localhost:9000>, `akadmin` / `akadmin`) on the
61
+ shared local Postgres + a Redis cache. Its worker applies the blueprints in
62
+ `authentik/blueprints/` on startup, provisioning one shared dev OIDC client
63
+ (application slug `mosaic-local`) and the seed users — `app-admin@example.com`,
64
+ `app-user@example.com`, `customer-admin@example.com`, `non-user@example.com`,
65
+ all with password `password`.
66
+
67
+ Every webapp's generated `.env.local` already points at this client, so locally
68
+ you just visit the app, click **Continue with Authentik**, and sign in as one of
69
+ the seed users; Better Auth links the SSO identity to the seeded local principal
70
+ (and its SpiceDB grants) by email.
71
+
72
+ > Local SSO is wired to `http://localhost:3000` (each app's `APP_BASE_URL`). The
73
+ > OIDC client also allows callbacks on `3000-3003`, so if port 3000 is taken or
74
+ > you run several apps at once, set that app's `BETTER_AUTH_URL` to its actual
75
+ > port (e.g. `http://localhost:3001`) so the OAuth redirect matches.
76
+
77
+ > The `authentik` database is created by `authentik/initdb/` on a **fresh**
78
+ > Postgres volume. If you have a pre-existing volume from before Authentik was
79
+ > added, run `pnpm run docker:down` then `docker compose down -v` once to reset
80
+ > it before `pnpm run setup`.
81
+
55
82
  ## Access Control
56
83
 
57
84
  Application builders define app-local Zed schemas in each package's
@@ -1,7 +1,6 @@
1
1
  import {
2
2
  createLazyAuth,
3
3
  createPerceptaAuthFromEnv,
4
- type PerceptaAuthMode,
5
4
  } from "@percepta/auth/better-auth";
6
5
  import { db } from "./drizzle/db";
7
6
  import { accounts } from "./drizzle/schema/auth/accounts";
@@ -9,12 +8,11 @@ import { sessions } from "./drizzle/schema/auth/sessions";
9
8
  import { verifications } from "./drizzle/schema/auth/verifications";
10
9
  import { users } from "./drizzle/schema/users";
11
10
 
12
- const DEFAULT_AUTH_MODE = "__AUTH_MODE__" satisfies PerceptaAuthMode;
13
-
11
+ // Authentik SSO is required in every environment (local + deployed). There is
12
+ // no auth mode to select and no username/password fallback — see @percepta/auth.
14
13
  function createAuth() {
15
14
  return createPerceptaAuthFromEnv({
16
15
  database: db,
17
- defaultAuthMode: DEFAULT_AUTH_MODE,
18
16
  schema: {
19
17
  user: users,
20
18
  session: sessions,
@@ -0,0 +1,123 @@
1
+ # Local-dev Authentik provisioning, applied automatically by the worker on
2
+ # startup. Provisions ONE shared OIDC client (application slug `mosaic-local`)
3
+ # that every app in this monorepo uses locally — apps run one-at-a-time (or on
4
+ # 3000-3003) against the same local Authentik. Deployments use per-app clients
5
+ # instead. All credentials here are throwaway local-dev values.
6
+ version: 1
7
+ metadata:
8
+ name: mosaic-local-dev
9
+ entries:
10
+ - model: authentik_providers_oauth2.oauth2provider
11
+ id: mosaic-local-provider
12
+ identifiers:
13
+ name: mosaic-local
14
+ state: present
15
+ attrs:
16
+ name: mosaic-local
17
+ client_type: confidential
18
+ client_id: mosaic-local-client
19
+ client_secret: mosaic-local-secret
20
+ issuer_mode: per_provider
21
+ sub_mode: hashed_user_id
22
+ include_claims_in_id_token: true
23
+ grant_types:
24
+ - authorization_code
25
+ - refresh_token
26
+ redirect_uris:
27
+ # Better Auth's generic-oauth callback. 3000-3003 cover `pnpm dev`
28
+ # running several apps in parallel (Next auto-increments the port).
29
+ - matching_mode: strict
30
+ url: http://localhost:3000/api/auth/oauth2/callback/authentik
31
+ redirect_uri_type: authorization
32
+ - matching_mode: strict
33
+ url: http://localhost:3001/api/auth/oauth2/callback/authentik
34
+ redirect_uri_type: authorization
35
+ - matching_mode: strict
36
+ url: http://localhost:3002/api/auth/oauth2/callback/authentik
37
+ redirect_uri_type: authorization
38
+ - matching_mode: strict
39
+ url: http://localhost:3003/api/auth/oauth2/callback/authentik
40
+ redirect_uri_type: authorization
41
+ authorization_flow:
42
+ !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
43
+ invalidation_flow:
44
+ !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
45
+ signing_key:
46
+ !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
47
+ property_mappings:
48
+ - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]]
49
+ - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]]
50
+ - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]]
51
+ - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-offline_access]]
52
+
53
+ - model: authentik_core.application
54
+ identifiers:
55
+ slug: mosaic-local
56
+ state: present
57
+ attrs:
58
+ name: Mosaic (local dev)
59
+ slug: mosaic-local
60
+ provider: !KeyOf mosaic-local-provider
61
+
62
+ # Seed users — mirror packages/*/scripts/seed.ts SEEDED_USERS (same emails).
63
+ # Local seeding creates the matching local user row + SpiceDB grants; the
64
+ # first Authentik sign-in links to it by email. Password for all: "password".
65
+ - model: authentik_core.user
66
+ id: seed-customer-admin
67
+ state: present
68
+ identifiers:
69
+ username: customer-admin@example.com
70
+ attrs:
71
+ name: Customer Admin
72
+ email: customer-admin@example.com
73
+ is_active: true
74
+ type: internal
75
+ password: password
76
+
77
+ - model: authentik_core.user
78
+ id: seed-app-admin
79
+ state: present
80
+ identifiers:
81
+ username: app-admin@example.com
82
+ attrs:
83
+ name: App Admin
84
+ email: app-admin@example.com
85
+ is_active: true
86
+ type: internal
87
+ password: password
88
+
89
+ - model: authentik_core.user
90
+ id: seed-app-user
91
+ state: present
92
+ identifiers:
93
+ username: app-user@example.com
94
+ attrs:
95
+ name: App User
96
+ email: app-user@example.com
97
+ is_active: true
98
+ type: internal
99
+ password: password
100
+
101
+ - model: authentik_core.user
102
+ id: seed-non-user
103
+ state: present
104
+ identifiers:
105
+ username: non-user@example.com
106
+ attrs:
107
+ name: App Non User
108
+ email: non-user@example.com
109
+ is_active: true
110
+ type: internal
111
+ password: password
112
+
113
+ - model: authentik_core.group
114
+ id: mosaic-local-group
115
+ state: present
116
+ identifiers:
117
+ name: mosaic-local-users
118
+ attrs:
119
+ users:
120
+ - !KeyOf seed-customer-admin
121
+ - !KeyOf seed-app-admin
122
+ - !KeyOf seed-app-user
123
+ - !KeyOf seed-non-user
@@ -0,0 +1,5 @@
1
+ -- Creates the dedicated database + role that the local Authentik container uses
2
+ -- on the shared local postgres instance. Runs once, only on a fresh data dir
3
+ -- (Postgres docker-entrypoint-initdb.d). Local-dev throwaway credentials.
4
+ CREATE USER authentik WITH PASSWORD 'authentik';
5
+ CREATE DATABASE authentik OWNER authentik;
@@ -25,11 +25,81 @@ services:
25
25
  POSTGRES_DB: postgres
26
26
  volumes:
27
27
  - postgres_data:/var/lib/postgresql/data
28
+ # Creates the dedicated `authentik` database + role on a fresh volume.
29
+ # (Runs only when the data dir is empty — `docker compose down -v` to reset.)
30
+ - ./authentik/initdb:/docker-entrypoint-initdb.d:ro
28
31
  healthcheck:
29
32
  test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
30
33
  interval: 5s
31
34
  timeout: 5s
32
35
  retries: 5
33
36
 
37
+ # --- Local Authentik (SSO) -------------------------------------------------
38
+ # Apps authenticate via Authentik in every environment; locally this container
39
+ # plays the role of deployed Authentik. The worker applies the blueprints under
40
+ # ./authentik/blueprints on startup, provisioning a shared dev OIDC client
41
+ # (application slug `mosaic-local`) plus the seed users. Admin UI:
42
+ # http://localhost:9000 (akadmin / akadmin). All secrets here are throwaway
43
+ # local-dev values.
44
+ authentik-redis:
45
+ image: redis:alpine
46
+ command: --save 60 1 --loglevel warning
47
+ healthcheck:
48
+ test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
49
+ interval: 5s
50
+ timeout: 3s
51
+ retries: 10
52
+
53
+ authentik-server:
54
+ image: ghcr.io/goauthentik/server:2026.5.3
55
+ command: server
56
+ environment: &authentik-env
57
+ AUTHENTIK_SECRET_KEY: mosaic-local-dev-secret-key
58
+ AUTHENTIK_POSTGRESQL__HOST: postgres
59
+ AUTHENTIK_POSTGRESQL__NAME: authentik
60
+ AUTHENTIK_POSTGRESQL__USER: authentik
61
+ AUTHENTIK_POSTGRESQL__PASSWORD: authentik
62
+ AUTHENTIK_REDIS__HOST: authentik-redis
63
+ AUTHENTIK_BOOTSTRAP_PASSWORD: akadmin
64
+ AUTHENTIK_BOOTSTRAP_EMAIL: akadmin@example.com
65
+ AUTHENTIK_DISABLE_UPDATE_CHECK: "true"
66
+ AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
67
+ ports:
68
+ - "9000:9000"
69
+ volumes:
70
+ - ./authentik/blueprints:/blueprints/custom:ro
71
+ healthcheck:
72
+ test: ["CMD", "ak", "healthcheck"]
73
+ start_period: 120s
74
+ interval: 10s
75
+ timeout: 30s
76
+ retries: 15
77
+ depends_on:
78
+ postgres:
79
+ condition: service_healthy
80
+ authentik-redis:
81
+ condition: service_healthy
82
+
83
+ authentik-worker:
84
+ image: ghcr.io/goauthentik/server:2026.5.3
85
+ command: worker
86
+ # The worker manages embedded outpost containers via the docker socket.
87
+ user: root
88
+ environment: *authentik-env
89
+ volumes:
90
+ - /var/run/docker.sock:/var/run/docker.sock
91
+ - ./authentik/blueprints:/blueprints/custom:ro
92
+ healthcheck:
93
+ test: ["CMD", "ak", "healthcheck"]
94
+ start_period: 120s
95
+ interval: 10s
96
+ timeout: 30s
97
+ retries: 15
98
+ depends_on:
99
+ postgres:
100
+ condition: service_healthy
101
+ authentik-redis:
102
+ condition: service_healthy
103
+
34
104
  volumes:
35
105
  postgres_data: {}
@@ -16,7 +16,7 @@ Next.js 16 full-stack application scaffolded from the Mosaic webapp template via
16
16
  - `pnpm db:generate` — generate Drizzle migrations
17
17
  - `pnpm db:migrate` — apply migrations
18
18
  - `pnpm db:studio` — run migrations, then open Drizzle Studio
19
- - `pnpm db:seed` — seed shared-auth dev users and local grants for customer admin, app admin, app user, and app non-user personas; username/password scaffolds set the dev password to `password`
19
+ - `pnpm db:seed` — seed shared-auth dev users and local grants for customer admin, app admin, app user, and app non-user personas; sign in via Authentik (dev password `password`, set in the monorepo's `authentik/blueprints/local-dev.yaml`)
20
20
 
21
21
  **Package manager**: Always use `pnpm`, never `npm` or `yarn`.
22
22
 
@@ -245,10 +245,10 @@ Better Auth is configured in the customer monorepo's shared `@__REPO_NAME__/auth
245
245
 
246
246
  - **Server-side**: `auth.api.getSession({ headers: await headers() })` — get session in server components or tRPC context
247
247
  - **Client-side**: `authClient.useSession()` — React hook from `src/lib/auth-client.ts`
248
- - **Sign in**: `authClient.signIn.email({ email, password })` — client-side
248
+ - **Sign in**: `authClient.signIn.oauth2({ providerId: "authentik", callbackURL })` — Authentik SSO; there is no password sign-in
249
249
  - **Sign out**: `authClient.signOut()` — client-side
250
250
  - **API route**: `src/app/api/auth/[...all]/route.ts` — Better Auth handler
251
- - **Env vars**: `BETTER_AUTH_SECRET` (required), optional `BETTER_AUTH_URL` override, `AUTH_DATABASE_URL` for deployed shared auth DB wiring
251
+ - **Env vars**: `BETTER_AUTH_SECRET` (required) plus `AUTHENTIK_ISSUER` / `AUTHENTIK_CLIENT_ID` / `AUTHENTIK_CLIENT_SECRET` (required — SSO); optional `BETTER_AUTH_URL` override; `AUTH_DATABASE_URL` for deployed shared auth DB wiring
252
252
 
253
253
  ### Background Jobs
254
254
 
@@ -7,7 +7,7 @@ Design theme: `__MOSAIC_DESIGN_THEME__`
7
7
  ## Features
8
8
 
9
9
  - **Next.js 16** with App Router
10
- - **Authentication** via Better Auth (__AUTH_MODE_LABEL__)
10
+ - **Authentication** via Better Auth brokered through Authentik SSO
11
11
  - **Database** with PostgreSQL, Drizzle ORM, and migrations
12
12
  - **Access Control** with SpiceDB schema authoring and manifest validation
13
13
  - **Logging** with Pino and structured safe/unsafe data separation
@@ -147,46 +147,34 @@ logger.error({ safe: { documentId } }, "Processing failed", error);
147
147
 
148
148
  This app consumes the customer monorepo's shared [Better Auth](https://better-auth.com) package, `@__REPO_NAME__/auth`. The app still serves local development auth routes, but the users, sessions, accounts, groups, and group memberships live in the shared customer auth database. Deployed apps should receive that shared database through `AUTH_DATABASE_URL` from the monorepo auth Secret; `DATABASE_URL` is reserved for this app's own database.
149
149
 
150
- This app was scaffolded with `__AUTH_MODE_LABEL__` as its auth setup. New apps
151
- inherit the customer monorepo's workspace auth setup from
152
- `.mosaic-workspace.json` by default, and individual apps can override that
153
- default with `AUTH_MODE`.
150
+ This app authenticates **only via Authentik SSO** there is no username/password
151
+ sign-in. The required `AUTHENTIK_ISSUER` / `AUTHENTIK_CLIENT_ID` /
152
+ `AUTHENTIK_CLIENT_SECRET` come from the monorepo's local Authentik container in
153
+ development (see the monorepo README) and from the per-app Authentik provider in
154
+ deployments. Other upstream IdPs (Okta, Entra, Google Workspace) are configured
155
+ as Authentik _sources_, not app-level settings.
154
156
 
155
157
  Every app requires:
156
158
 
157
159
  ```bash
158
- AUTH_MODE=__AUTH_MODE__
159
160
  BETTER_AUTH_SECRET=generate-with-openssl-rand-base64-32
161
+ # Local dev (provisioned by the monorepo's Authentik container + blueprint):
162
+ AUTHENTIK_ISSUER=http://localhost:9000/application/o/mosaic-local/
163
+ AUTHENTIK_CLIENT_ID=mosaic-local-client
164
+ AUTHENTIK_CLIENT_SECRET=mosaic-local-secret
160
165
  ```
161
166
 
162
167
  Auth uses `BETTER_AUTH_URL`, `APP_BASE_URL`, or `http://localhost:3000`, in
163
- that order, for its base URL.
168
+ that order, for its base URL. The Better Auth OAuth callback is
169
+ `/api/auth/oauth2/callback/authentik`; deployed Authentik providers must allow
170
+ `https://<app-host>/api/auth/oauth2/callback/authentik`.
164
171
 
165
- For username/password auth, local setup creates credential users and app access
166
- grants. Google OAuth sign-in requires `GOOGLE_CLIENT_ID` and
167
- `GOOGLE_CLIENT_SECRET`; optionally set `GOOGLE_HOSTED_DOMAIN`. Register these
168
- redirect URIs in the Google OAuth client:
169
-
170
- ```text
171
- http://localhost:3000/api/auth/callback/google
172
- https://<app-host>/api/auth/callback/google
173
- ```
174
-
175
- Okta OIDC sign-in requires `OKTA_CLIENT_ID`, `OKTA_CLIENT_SECRET`, and
176
- `OKTA_ISSUER`. The issuer usually looks like
177
- `https://dev-xxxxx.okta.com/oauth2/default`. Register these redirect URIs in
178
- the Okta app integration:
179
-
180
- ```text
181
- http://localhost:3000/api/auth/oauth2/callback/okta
182
- https://<app-host>/api/auth/oauth2/callback/okta
183
- ```
184
-
185
- OAuth users still pass through the app's SpiceDB access gate after sign-in. In
186
- local development, `pnpm db:seed` creates access principals for the example
187
- emails; provider sign-ins with the same verified email can link to those users.
188
- For other emails, grant app access from the settings UI or seed/apply an access
189
- grant before expecting the protected app pages to load.
172
+ Authentik users still pass through the app's SpiceDB access gate after sign-in.
173
+ `pnpm run setup` seeds the example users (`app-admin@example.com`, etc.) as local
174
+ principals with grants; the first Authentik sign-in links to them by email
175
+ (Authentik is a trusted provider, so linking does not require pre-verification).
176
+ For other users, grant app access from the settings UI or seed a grant before
177
+ the protected pages will load.
190
178
 
191
179
  Remote deployments should also set `AUTH_DATABASE_URL` from the shared auth
192
180
  database Secret. Local development can omit it and use the root-created local
@@ -200,7 +188,8 @@ pnpm db:seed
200
188
  # Creates app-admin@example.com as an app admin
201
189
  # Creates app-user@example.com as an app user
202
190
  # Creates non-user@example.com with no app access
203
- # Username/password scaffolds also set each password to: password
191
+ # Sign in as any of them via Authentik (password "password", set in the
192
+ # monorepo's authentik/blueprints/local-dev.yaml).
204
193
  ```
205
194
 
206
195
  ## Access Control
@@ -36,7 +36,7 @@ Ask all that are relevant:
36
36
  4. **What are the key workflows?** (e.g., "User uploads a document → system extracts text → user reviews results")
37
37
  5. **Does this app call LLMs?** If yes, which provider (OpenAI, Bedrock/Claude, both)? What for?
38
38
  6. **Does this app need background jobs?** Long-running tasks, scheduled jobs, multi-step pipelines?
39
- 7. **Auth requirements?** Which provider Google, Okta, Credentials, or just use the template default?
39
+ 7. **Auth requirements?** All apps authenticate via Authentik SSO (local + deployed); the monorepo's local Authentik container handles dev. Anything beyond the template default?
40
40
  8. **Any external integrations?** APIs, webhooks, third-party services?
41
41
 
42
42
  ### Open-Ended