@percepta/create 4.1.15 → 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.
Files changed (38) hide show
  1. package/dist/index.js +24 -152
  2. package/dist/index.js.map +1 -1
  3. package/dist/{register-app-BeSQEsel.js → register-app-BtvxQeo0.js} +91 -1
  4. package/dist/register-app-BtvxQeo0.js.map +1 -0
  5. package/package.json +5 -2
  6. package/template-versions.json +2 -2
  7. package/templates/monorepo/README.md +28 -1
  8. package/templates/monorepo/auth/package.json +1 -1
  9. package/templates/monorepo/auth/src/auth.ts +7 -103
  10. package/templates/monorepo/authentik/blueprints/local-dev.yaml +123 -0
  11. package/templates/monorepo/authentik/initdb/00-authentik.sql +5 -0
  12. package/templates/monorepo/docker-compose.yml +70 -0
  13. package/templates/monorepo/oxlint.config.ts.template +5 -1
  14. package/templates/monorepo/package.json.template +2 -1
  15. package/templates/webapp/.github/workflows/__APP_NAME__-ryvn-release.yaml +22 -89
  16. package/templates/webapp/AGENTS.md +4 -4
  17. package/templates/webapp/README.md +22 -33
  18. package/templates/webapp/agent-skills/langfuse.md +8 -11
  19. package/templates/webapp/agent-skills/oneshot.md +1 -1
  20. package/templates/webapp/e2e/rbac.spec.ts +28 -4
  21. package/templates/webapp/env.example.template +8 -12
  22. package/templates/webapp/package.json.template +4 -5
  23. package/templates/webapp/scripts/seed.ts +28 -52
  24. package/templates/webapp/scripts/with-local-env.ts +12 -64
  25. package/templates/webapp/src/app/(auth)/auth/signin/SignInForm.tsx +59 -0
  26. package/templates/webapp/src/app/(auth)/auth/signin/page.tsx +3 -3
  27. package/templates/webapp/src/drizzle/db.ts +5 -9
  28. package/templates/webapp/src/instrumentation.ts +5 -72
  29. package/templates/webapp/src/lib/auth/index.ts +1 -2
  30. package/templates/webapp/src/services/DatabaseService.ts +3 -51
  31. package/templates/webapp/src/services/observability/initFaro.ts +5 -17
  32. package/dist/register-app-BeSQEsel.js.map +0 -1
  33. package/templates/monorepo/scripts/setup-local-databases.mjs +0 -183
  34. package/templates/webapp/src/app/(auth)/auth/signin/CredentialsSignInForm.tsx +0 -179
  35. package/templates/webapp/src/app/(auth)/auth/signup/CredentialsSignUpForm.tsx +0 -135
  36. package/templates/webapp/src/app/(auth)/auth/signup/page.tsx +0 -53
  37. package/templates/webapp/src/drizzle/schema/utils/jsonbFromZod.ts +0 -25
  38. package/templates/webapp/src/lib/auth/app-auth-mode.ts +0 -20
@@ -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.15",
3
+ "version": "4.2.0",
4
4
  "description": "Scaffold a new Mosaic package",
5
5
  "keywords": [
6
6
  "cli",
@@ -50,7 +50,10 @@
50
50
  "typecheck": "tsc --noEmit",
51
51
  "test": "vitest run",
52
52
  "test:watch": "vitest",
53
- "test:template": "bash scripts/test-template.sh",
53
+ "lint:templates:logging": "oxlint -c oxlint.template-logging.config.json templates --no-error-on-unmatched-pattern",
54
+ "test:template": "pnpm lint:templates:logging && pnpm test:template:contract && pnpm build && pnpm test:template:build",
55
+ "test:template:contract": "vitest run src/commands/create-output.test.ts",
56
+ "test:template:build": "bash scripts/test-template.sh",
54
57
  "create:local": "pnpm build && node dist/index.js",
55
58
  "sync-template": "tsx scripts/sync-template.ts",
56
59
  "template:tag": "tsx scripts/template-tag.ts"
@@ -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
@@ -16,7 +16,7 @@
16
16
  "db:migrate": "drizzle-kit migrate"
17
17
  },
18
18
  "dependencies": {
19
- "@percepta/auth": "^0.1.4",
19
+ "@percepta/auth": "^0.1.7",
20
20
  "better-auth": "^1.6.4",
21
21
  "drizzle-orm": "^0.45.2"
22
22
  },
@@ -1,120 +1,24 @@
1
- import { createLazyAuth, createPerceptaAuth } from "@percepta/auth/better-auth";
2
- import type { BetterAuthOptions } from "better-auth";
3
- import { admin } from "better-auth/plugins";
4
- import { genericOAuth, okta } from "better-auth/plugins/generic-oauth";
1
+ import {
2
+ createLazyAuth,
3
+ createPerceptaAuthFromEnv,
4
+ } from "@percepta/auth/better-auth";
5
5
  import { db } from "./drizzle/db";
6
6
  import { accounts } from "./drizzle/schema/auth/accounts";
7
7
  import { sessions } from "./drizzle/schema/auth/sessions";
8
8
  import { verifications } from "./drizzle/schema/auth/verifications";
9
9
  import { users } from "./drizzle/schema/users";
10
10
 
11
- type AuthMode = "username-password" | "google" | "okta";
12
-
13
- const DEFAULT_AUTH_MODE: AuthMode = "__AUTH_MODE__" as AuthMode;
14
- const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build";
15
-
16
- function isAuthMode(value: string | undefined): value is AuthMode {
17
- return (
18
- value === "username-password" || value === "google" || value === "okta"
19
- );
20
- }
21
-
22
- function getAuthMode(): AuthMode {
23
- return isAuthMode(process.env.AUTH_MODE)
24
- ? process.env.AUTH_MODE
25
- : DEFAULT_AUTH_MODE;
26
- }
27
-
28
- function requiredEnv(name: string): string {
29
- const value = process.env[name];
30
- if (value == null || value.length === 0) {
31
- throw new Error(`${name} is required.`);
32
- }
33
- return value;
34
- }
35
-
36
- function optionalEnv(name: string): string | undefined {
37
- const value = process.env[name];
38
- return value == null || value.length === 0 ? undefined : value;
39
- }
40
-
41
- function getSecret(): string {
42
- if (isBuildPhase) {
43
- return "build-placeholder-not-used-at-runtime";
44
- }
45
-
46
- return requiredEnv("BETTER_AUTH_SECRET");
47
- }
48
-
49
- function getBaseUrl(): string {
50
- return (
51
- process.env.BETTER_AUTH_URL ??
52
- process.env.APP_BASE_URL ??
53
- "http://localhost:3000"
54
- );
55
- }
56
-
57
- function getSocialProviders(
58
- authMode: AuthMode,
59
- ): BetterAuthOptions["socialProviders"] {
60
- if (authMode !== "google") return undefined;
61
-
62
- const clientId = optionalEnv("GOOGLE_CLIENT_ID");
63
- const clientSecret = optionalEnv("GOOGLE_CLIENT_SECRET");
64
- if (clientId == null || clientSecret == null) return undefined;
65
-
66
- const hostedDomain = optionalEnv("GOOGLE_HOSTED_DOMAIN");
67
- return {
68
- google: {
69
- clientId,
70
- clientSecret,
71
- ...(hostedDomain == null ? {} : { hd: hostedDomain }),
72
- },
73
- };
74
- }
75
-
76
- function getPlugins(authMode: AuthMode): BetterAuthOptions["plugins"] {
77
- if (authMode !== "okta") return undefined;
78
-
79
- const clientId = optionalEnv("OKTA_CLIENT_ID");
80
- const clientSecret = optionalEnv("OKTA_CLIENT_SECRET");
81
- const issuer = optionalEnv("OKTA_ISSUER");
82
- if (clientId == null || clientSecret == null || issuer == null) {
83
- return undefined;
84
- }
85
-
86
- return [
87
- admin(),
88
- genericOAuth({
89
- config: [
90
- okta({
91
- clientId,
92
- clientSecret,
93
- issuer,
94
- }),
95
- ],
96
- }),
97
- ];
98
- }
99
-
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.
100
13
  function createAuth() {
101
- const authMode = getAuthMode();
102
-
103
- return createPerceptaAuth({
104
- baseURL: getBaseUrl(),
14
+ return createPerceptaAuthFromEnv({
105
15
  database: db,
106
- emailAndPassword: {
107
- enabled: authMode === "username-password",
108
- },
109
- plugins: getPlugins(authMode),
110
16
  schema: {
111
17
  user: users,
112
18
  session: sessions,
113
19
  account: accounts,
114
20
  verification: verifications,
115
21
  },
116
- secret: getSecret(),
117
- socialProviders: getSocialProviders(authMode),
118
22
  });
119
23
  }
120
24
 
@@ -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: {}
@@ -1,3 +1,7 @@
1
1
  import { defineOxlintMonorepoConfig } from "@percepta/build/oxlint";
2
2
 
3
- export default defineOxlintMonorepoConfig();
3
+ export default defineOxlintMonorepoConfig({
4
+ rules: {
5
+ "no-console": "error",
6
+ },
7
+ });