@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.
- package/dist/index.js +24 -152
- package/dist/index.js.map +1 -1
- package/dist/{register-app-BeSQEsel.js → register-app-BtvxQeo0.js} +91 -1
- package/dist/register-app-BtvxQeo0.js.map +1 -0
- package/package.json +1 -1
- package/template-versions.json +2 -2
- package/templates/monorepo/README.md +28 -1
- package/templates/monorepo/auth/src/auth.ts +2 -4
- package/templates/monorepo/authentik/blueprints/local-dev.yaml +123 -0
- package/templates/monorepo/authentik/initdb/00-authentik.sql +5 -0
- package/templates/monorepo/docker-compose.yml +70 -0
- package/templates/webapp/AGENTS.md +3 -3
- package/templates/webapp/README.md +22 -33
- package/templates/webapp/agent-skills/oneshot.md +1 -1
- package/templates/webapp/e2e/rbac.spec.ts +28 -4
- package/templates/webapp/env.example.template +8 -12
- package/templates/webapp/scripts/seed.ts +14 -45
- package/templates/webapp/src/app/(auth)/auth/signin/SignInForm.tsx +59 -0
- package/templates/webapp/src/app/(auth)/auth/signin/page.tsx +3 -3
- package/templates/webapp/src/lib/auth/index.ts +1 -2
- package/dist/register-app-BeSQEsel.js.map +0 -1
- package/templates/webapp/src/app/(auth)/auth/signin/CredentialsSignInForm.tsx +0 -179
- package/templates/webapp/src/app/(auth)/auth/signup/CredentialsSignUpForm.tsx +0 -135
- package/templates/webapp/src/app/(auth)/auth/signup/page.tsx +0 -53
- package/templates/webapp/src/lib/auth/app-auth-mode.ts +0 -12
|
@@ -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-
|
|
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
package/template-versions.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|
|
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.
|
|
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)
|
|
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
|
|
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
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
#
|
|
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?**
|
|
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
|