@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
@@ -10,7 +10,7 @@
10
10
  "setup": "pnpm run docker:up && pnpm run db:setup-local && pnpm run auth:db:migrate && pnpm run access:apply-local && pnpm -r --filter './packages/*' --if-present run db:migrate && pnpm -r --filter './packages/*' --if-present run db:seed",
11
11
  "docker:up": "docker compose up -d --wait",
12
12
  "docker:down": "docker compose down",
13
- "db:setup-local": "node scripts/setup-local-databases.mjs",
13
+ "db:setup-local": "percepta-db setup-local",
14
14
  "dev": "pnpm -r --parallel --if-present run dev",
15
15
  "build": "turbo run build",
16
16
  "clean": "turbo run clean",
@@ -32,6 +32,7 @@
32
32
  "devDependencies": {
33
33
  "@percepta/access-control": "^1.0.0",
34
34
  "@percepta/build": "^1.0.0",
35
+ "@percepta/database": "0.1.4",
35
36
  "@types/node": "^24.1.0",
36
37
  "oxfmt": "^0.47.0",
37
38
  "oxlint": "^1.61.0",
@@ -1,6 +1,7 @@
1
1
  name: Build & Release __APP_NAME__
2
2
 
3
3
  on:
4
+ workflow_dispatch:
4
5
  push:
5
6
  branches:
6
7
  - "main"
@@ -13,97 +14,29 @@ on:
13
14
  - "pnpm-lock.yaml"
14
15
  - "pnpm-workspace.yaml"
15
16
  - ".github/workflows/__APP_NAME__-ryvn-release.yaml"
16
- workflow_dispatch:
17
-
18
- env:
19
- SERVICE_NAME: __APP_NAME__
17
+ pull_request:
18
+ paths:
19
+ - "packages/__APP_NAME__/src/**"
20
+ - "packages/__APP_NAME__/scripts/**"
21
+ - "packages/__APP_NAME__/Dockerfile"
22
+ - "packages/__APP_NAME__/package.json"
23
+ - "package.json"
24
+ - "pnpm-lock.yaml"
25
+ - "pnpm-workspace.yaml"
26
+ - ".github/workflows/__APP_NAME__-ryvn-release.yaml"
20
27
 
21
28
  jobs:
22
- build-and-release:
23
- name: Build and Release
24
- runs-on: ubuntu-latest
29
+ release:
30
+ uses: ryvn-technologies/ryvn-build-action/.github/workflows/release.yml@v2
31
+ with:
32
+ service_name: __APP_NAME__
25
33
  permissions:
26
34
  contents: write
27
35
  id-token: write
28
-
29
- steps:
30
- - name: Checkout code
31
- uses: actions/checkout@v4
32
- with:
33
- fetch-depth: 0
34
-
35
- - name: Install Ryvn CLI
36
- uses: ryvn-technologies/install-ryvn-cli@v1.0.0
37
-
38
- - name: Generate Release Tag
39
- id: generate-tag
40
- env:
41
- RYVN_CLIENT_ID: ${{ secrets.RYVN_CLIENT_ID }}
42
- RYVN_CLIENT_SECRET: ${{ secrets.RYVN_CLIENT_SECRET }}
43
- run: |
44
- tag_info=$(ryvn generate-release-tag "$SERVICE_NAME" --prefix="${SERVICE_NAME}@" -o json --default-bump-minor)
45
-
46
- version=$(echo "$tag_info" | jq -r '.version')
47
- new_tag=$(echo "$tag_info" | jq -r '.tag')
48
- channel=$(echo "$tag_info" | jq -r '.channel')
49
- isPreview=$(echo "$tag_info" | jq -r '.isPreview')
50
-
51
- echo "version=$version" >> $GITHUB_OUTPUT
52
- echo "new_tag=$new_tag" >> $GITHUB_OUTPUT
53
- echo "channel=$channel" >> $GITHUB_OUTPUT
54
- echo "isPreview=$isPreview" >> $GITHUB_OUTPUT
55
-
56
- - name: Build and Push
57
- uses: ryvn-technologies/ryvn-build-action@v2
58
- with:
59
- service_name: ${{ env.SERVICE_NAME }}
60
- version: ${{ steps.generate-tag.outputs.version }}
61
- build_only: ${{ !(github.ref == format('refs/heads/{0}', github.event.repository.default_branch) || steps.generate-tag.outputs.isPreview == 'true') }}
62
- build_secrets: |
63
- NPM_TOKEN=${{ secrets.NPM_TOKEN }}
64
- ryvn_client_id: ${{ secrets.RYVN_CLIENT_ID }}
65
- ryvn_client_secret: ${{ secrets.RYVN_CLIENT_SECRET }}
66
-
67
- - name: Create Ryvn Release
68
- if: |
69
- !contains(github.event.head_commit.message, '[skip-release]') &&
70
- !contains(github.event.pull_request.title, '[skip-release]') &&
71
- (steps.generate-tag.outputs.isPreview == 'true' || github.ref == format('refs/heads/{0}', github.event.repository.default_branch))
72
- env:
73
- RYVN_CLIENT_ID: ${{ secrets.RYVN_CLIENT_ID }}
74
- RYVN_CLIENT_SECRET: ${{ secrets.RYVN_CLIENT_SECRET }}
75
- run: |
76
- version="${{ steps.generate-tag.outputs.new_tag }}"
77
- version="${version#"${SERVICE_NAME}@"}"
78
- version="${version#@}"
79
- channel="${{ steps.generate-tag.outputs.channel }}"
80
-
81
- if [ -n "$channel" ] && [ "$channel" != "null" ]; then
82
- ryvn create release "$SERVICE_NAME" "$version" --channel "$channel"
83
- else
84
- ryvn create release "$SERVICE_NAME" "$version"
85
- fi
86
-
87
- - name: Create GitHub Tag
88
- if: |
89
- github.ref == format('refs/heads/{0}', github.event.repository.default_branch) &&
90
- !contains(github.event.head_commit.message, '[skip-release]') &&
91
- !contains(github.event.pull_request.title, '[skip-release]')
92
- run: |
93
- git config --global user.email "github-actions[bot]@users.noreply.github.com"
94
- git config --global user.name "github-actions[bot]"
95
- git tag "${{ steps.generate-tag.outputs.new_tag }}"
96
- git push origin "${{ steps.generate-tag.outputs.new_tag }}"
97
-
98
- - name: Create GitHub Release
99
- if: |
100
- github.ref == format('refs/heads/{0}', github.event.repository.default_branch) &&
101
- !contains(github.event.head_commit.message, '[skip-release]') &&
102
- !contains(github.event.pull_request.title, '[skip-release]')
103
- uses: softprops/action-gh-release@v1
104
- with:
105
- tag_name: ${{ steps.generate-tag.outputs.new_tag }}
106
- name: ${{ steps.generate-tag.outputs.new_tag }}
107
- generate_release_notes: true
108
- draft: false
109
- prerelease: false
36
+ secrets:
37
+ RYVN_CLIENT_ID: ${{ secrets.RYVN_CLIENT_ID }}
38
+ RYVN_CLIENT_SECRET: ${{ secrets.RYVN_CLIENT_SECRET }}
39
+ BUILD_SECRETS: |
40
+ NPM_TOKEN=${{ secrets.NPM_TOKEN }}
41
+ TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
42
+ TURBO_TEAM=${{ vars.TURBO_TEAM }}
@@ -16,7 +16,7 @@ Next.js 16 full-stack application scaffolded from the Mosaic webapp template via
16
16
  - `pnpm db:generate` — generate Drizzle migrations
17
17
  - `pnpm db:migrate` — apply migrations
18
18
  - `pnpm db:studio` — run migrations, then open Drizzle Studio
19
- - `pnpm db:seed` — seed shared-auth dev users and local grants for customer admin, app admin, app user, and app non-user personas; username/password scaffolds set the dev password to `password`
19
+ - `pnpm db:seed` — seed shared-auth dev users and local grants for customer admin, app admin, app user, and app non-user personas; sign in via Authentik (dev password `password`, set in the monorepo's `authentik/blueprints/local-dev.yaml`)
20
20
 
21
21
  **Package manager**: Always use `pnpm`, never `npm` or `yarn`.
22
22
 
@@ -54,7 +54,7 @@ src/ # Application source
54
54
  │ ├── langfuse/ # LLM observability
55
55
  │ ├── llm/ # LLM provider selection and call helpers
56
56
  │ ├── logger/ # App logger setup (wraps @percepta/logger)
57
- │ └── observability/ # OpenTelemetry setup
57
+ │ └── observability/ # Frontend observability setup
58
58
  └── utils/ # Helpers (cn, pathEncryption, etc.)
59
59
 
60
60
  deploy/ # Optional release metadata
@@ -245,10 +245,10 @@ Better Auth is configured in the customer monorepo's shared `@__REPO_NAME__/auth
245
245
 
246
246
  - **Server-side**: `auth.api.getSession({ headers: await headers() })` — get session in server components or tRPC context
247
247
  - **Client-side**: `authClient.useSession()` — React hook from `src/lib/auth-client.ts`
248
- - **Sign in**: `authClient.signIn.email({ email, password })` — client-side
248
+ - **Sign in**: `authClient.signIn.oauth2({ providerId: "authentik", callbackURL })` — Authentik SSO; there is no password sign-in
249
249
  - **Sign out**: `authClient.signOut()` — client-side
250
250
  - **API route**: `src/app/api/auth/[...all]/route.ts` — Better Auth handler
251
- - **Env vars**: `BETTER_AUTH_SECRET` (required), optional `BETTER_AUTH_URL` override, `AUTH_DATABASE_URL` for deployed shared auth DB wiring
251
+ - **Env vars**: `BETTER_AUTH_SECRET` (required) plus `AUTHENTIK_ISSUER` / `AUTHENTIK_CLIENT_ID` / `AUTHENTIK_CLIENT_SECRET` (required — SSO); optional `BETTER_AUTH_URL` override; `AUTH_DATABASE_URL` for deployed shared auth DB wiring
252
252
 
253
253
  ### Background Jobs
254
254
 
@@ -7,7 +7,7 @@ Design theme: `__MOSAIC_DESIGN_THEME__`
7
7
  ## Features
8
8
 
9
9
  - **Next.js 16** with App Router
10
- - **Authentication** via Better Auth (__AUTH_MODE_LABEL__)
10
+ - **Authentication** via Better Auth brokered through Authentik SSO
11
11
  - **Database** with PostgreSQL, Drizzle ORM, and migrations
12
12
  - **Access Control** with SpiceDB schema authoring and manifest validation
13
13
  - **Logging** with Pino and structured safe/unsafe data separation
@@ -147,46 +147,34 @@ logger.error({ safe: { documentId } }, "Processing failed", error);
147
147
 
148
148
  This app consumes the customer monorepo's shared [Better Auth](https://better-auth.com) package, `@__REPO_NAME__/auth`. The app still serves local development auth routes, but the users, sessions, accounts, groups, and group memberships live in the shared customer auth database. Deployed apps should receive that shared database through `AUTH_DATABASE_URL` from the monorepo auth Secret; `DATABASE_URL` is reserved for this app's own database.
149
149
 
150
- This app was scaffolded with `__AUTH_MODE_LABEL__` as its auth setup. New apps
151
- inherit the customer monorepo's workspace auth setup from
152
- `.mosaic-workspace.json` by default, and individual apps can override that
153
- default with `AUTH_MODE`.
150
+ This app authenticates **only via Authentik SSO** there is no username/password
151
+ sign-in. The required `AUTHENTIK_ISSUER` / `AUTHENTIK_CLIENT_ID` /
152
+ `AUTHENTIK_CLIENT_SECRET` come from the monorepo's local Authentik container in
153
+ development (see the monorepo README) and from the per-app Authentik provider in
154
+ deployments. Other upstream IdPs (Okta, Entra, Google Workspace) are configured
155
+ as Authentik _sources_, not app-level settings.
154
156
 
155
157
  Every app requires:
156
158
 
157
159
  ```bash
158
- AUTH_MODE=__AUTH_MODE__
159
160
  BETTER_AUTH_SECRET=generate-with-openssl-rand-base64-32
161
+ # Local dev (provisioned by the monorepo's Authentik container + blueprint):
162
+ AUTHENTIK_ISSUER=http://localhost:9000/application/o/mosaic-local/
163
+ AUTHENTIK_CLIENT_ID=mosaic-local-client
164
+ AUTHENTIK_CLIENT_SECRET=mosaic-local-secret
160
165
  ```
161
166
 
162
167
  Auth uses `BETTER_AUTH_URL`, `APP_BASE_URL`, or `http://localhost:3000`, in
163
- that order, for its base URL.
168
+ that order, for its base URL. The Better Auth OAuth callback is
169
+ `/api/auth/oauth2/callback/authentik`; deployed Authentik providers must allow
170
+ `https://<app-host>/api/auth/oauth2/callback/authentik`.
164
171
 
165
- For username/password auth, local setup creates credential users and app access
166
- grants. Google OAuth sign-in requires `GOOGLE_CLIENT_ID` and
167
- `GOOGLE_CLIENT_SECRET`; optionally set `GOOGLE_HOSTED_DOMAIN`. Register these
168
- redirect URIs in the Google OAuth client:
169
-
170
- ```text
171
- http://localhost:3000/api/auth/callback/google
172
- https://<app-host>/api/auth/callback/google
173
- ```
174
-
175
- Okta OIDC sign-in requires `OKTA_CLIENT_ID`, `OKTA_CLIENT_SECRET`, and
176
- `OKTA_ISSUER`. The issuer usually looks like
177
- `https://dev-xxxxx.okta.com/oauth2/default`. Register these redirect URIs in
178
- the Okta app integration:
179
-
180
- ```text
181
- http://localhost:3000/api/auth/oauth2/callback/okta
182
- https://<app-host>/api/auth/oauth2/callback/okta
183
- ```
184
-
185
- OAuth users still pass through the app's SpiceDB access gate after sign-in. In
186
- local development, `pnpm db:seed` creates access principals for the example
187
- emails; provider sign-ins with the same verified email can link to those users.
188
- For other emails, grant app access from the settings UI or seed/apply an access
189
- grant before expecting the protected app pages to load.
172
+ Authentik users still pass through the app's SpiceDB access gate after sign-in.
173
+ `pnpm run setup` seeds the example users (`app-admin@example.com`, etc.) as local
174
+ principals with grants; the first Authentik sign-in links to them by email
175
+ (Authentik is a trusted provider, so linking does not require pre-verification).
176
+ For other users, grant app access from the settings UI or seed a grant before
177
+ the protected pages will load.
190
178
 
191
179
  Remote deployments should also set `AUTH_DATABASE_URL` from the shared auth
192
180
  database Secret. Local development can omit it and use the root-created local
@@ -200,7 +188,8 @@ pnpm db:seed
200
188
  # Creates app-admin@example.com as an app admin
201
189
  # Creates app-user@example.com as an app user
202
190
  # Creates non-user@example.com with no app access
203
- # Username/password scaffolds also set each password to: password
191
+ # Sign in as any of them via Authentik (password "password", set in the
192
+ # monorepo's authentik/blueprints/local-dev.yaml).
204
193
  ```
205
194
 
206
195
  ## Access Control
@@ -22,18 +22,15 @@ Langfuse is an open-source LLM observability platform. It captures traces, spans
22
22
  The template uses Next.js's instrumentation hook (called on server startup) to bootstrap OTEL with both the environment collector and optional Langfuse:
23
23
 
24
24
  ```typescript
25
- import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
26
- import { NodeSDK, tracing } from "@opentelemetry/sdk-node";
27
- import { createLangfuseSpanProcessor } from "@percepta/ai";
28
-
29
- const sdk = new NodeSDK({
30
- spanProcessors: [
31
- new tracing.BatchSpanProcessor(otlpTraceExporter),
32
- createLangfuseSpanProcessor(env, logger),
33
- ],
34
- instrumentations: [getNodeAutoInstrumentations()],
25
+ import { startPerceptaNodeTelemetry } from "@percepta/ai";
26
+ import { getEnvConfig } from "./config/getEnvConfig";
27
+ import { getLogger } from "./services/logger/AppLogger";
28
+
29
+ startPerceptaNodeTelemetry({
30
+ appName: "__APP_NAME__",
31
+ getEnv: getEnvConfig,
32
+ getLogger,
35
33
  });
36
- sdk.start();
37
34
  ```
38
35
 
39
36
  - `getNodeAutoInstrumentations()` automatically instruments HTTP calls, database queries, and other standard Node.js operations.
@@ -36,7 +36,7 @@ Ask all that are relevant:
36
36
  4. **What are the key workflows?** (e.g., "User uploads a document → system extracts text → user reviews results")
37
37
  5. **Does this app call LLMs?** If yes, which provider (OpenAI, Bedrock/Claude, both)? What for?
38
38
  6. **Does this app need background jobs?** Long-running tasks, scheduled jobs, multi-step pipelines?
39
- 7. **Auth requirements?** Which provider Google, Okta, Credentials, or just use the template default?
39
+ 7. **Auth requirements?** All apps authenticate via Authentik SSO (local + deployed); the monorepo's local Authentik container handles dev. Anything beyond the template default?
40
40
  8. **Any external integrations?** APIs, webhooks, third-party services?
41
41
 
42
42
  ### Open-Ended
@@ -124,14 +124,38 @@ test.describe("RBAC access", () => {
124
124
  });
125
125
  });
126
126
 
127
+ // Apps authenticate via Authentik SSO (there is no password form), so sign-in
128
+ // drives Authentik's hosted login flow. `pnpm run setup` starts the monorepo's
129
+ // local Authentik and seeds these users (password "password") via its blueprint.
130
+ // The provider uses implicit consent, so there is no consent screen. Inputs are
131
+ // targeted by Authentik's stable field names; each stage is submitted via its
132
+ // submit button (Authentik's web-component form doesn't submit on Enter). The
133
+ // identification stage is awaited to detach before the password stage so the two
134
+ // stages don't race during the in-place re-render. (Verified end-to-end against
135
+ // a live local Authentik: each user yields a valid OAuth code + state.)
127
136
  async function signIn(page: Page, email: string, callbackUrl: string) {
128
137
  await page.goto(
129
138
  `/auth/signin?callbackUrl=${encodeURIComponent(callbackUrl)}`,
130
139
  );
131
- await page.getByLabel("Email").fill(email);
132
- await page.getByLabel("Password").fill(password);
133
- await page.getByRole("button", { name: "Sign In" }).click();
134
- await page.waitForURL((url) => url.pathname !== "/auth/signin");
140
+ await page.getByRole("button", { name: /Continue with Authentik/i }).click();
141
+
142
+ await page.waitForURL(/\/\/localhost:9000\//);
143
+ const identifier = page.locator('input[name="uidField"]');
144
+ await identifier.waitFor({ state: "visible" });
145
+ await identifier.fill(email);
146
+ await page.locator('button[type="submit"]').first().click();
147
+ await identifier.waitFor({ state: "detached" });
148
+
149
+ const passwordField = page.locator('input[name="password"]');
150
+ await passwordField.waitFor({ state: "visible" });
151
+ await passwordField.fill(password);
152
+ await page.locator('button[type="submit"]').first().click();
153
+
154
+ // Back on the app once Authentik redirects through the OAuth callback.
155
+ await page.waitForURL(
156
+ (url) =>
157
+ !url.href.includes("localhost:9000") && url.pathname !== "/auth/signin",
158
+ );
135
159
  }
136
160
 
137
161
  async function expectNotFound(page: Page) {
@@ -6,19 +6,15 @@ APP_BASE_URL=http://localhost:3000
6
6
  # App Database
7
7
  DATABASE_URL=postgresql://postgres:postgres@localhost:5434/__DB_NAME__
8
8
 
9
- # Authentication (Better Auth)
10
- # App auth setup selected by @percepta/create: __AUTH_MODE_LABEL__
11
- # Defaults to the workspace auth setup unless this app was scaffolded with an override.
12
- AUTH_MODE=__AUTH_MODE__
9
+ # Authentication (Better Auth via Authentik SSO)
13
10
  BETTER_AUTH_SECRET=generate-with-openssl-rand-base64-32
14
- # Google OAuth apps should fill these:
15
- # GOOGLE_CLIENT_ID=
16
- # GOOGLE_CLIENT_SECRET=
17
- # GOOGLE_HOSTED_DOMAIN=
18
- # Okta OIDC apps should fill these:
19
- # OKTA_CLIENT_ID=
20
- # OKTA_CLIENT_SECRET=
21
- # OKTA_ISSUER=
11
+ # These point at the monorepo's local Authentik container (brought up by
12
+ # `pnpm run setup`) and the shared local OIDC client provisioned by its
13
+ # blueprint. Deployed environments override them with the per-app Authentik
14
+ # provider's issuer/credentials.
15
+ AUTHENTIK_ISSUER=http://localhost:9000/application/o/mosaic-local/
16
+ AUTHENTIK_CLIENT_ID=mosaic-local-client
17
+ AUTHENTIK_CLIENT_SECRET=mosaic-local-secret
22
18
 
23
19
  # Shared Auth Database
24
20
  # Deployed apps should set this from the customer monorepo auth database Secret.
@@ -40,17 +40,16 @@
40
40
  "@mantine/hooks": "^8.3.1",
41
41
  "@next/env": "^16.2.6",
42
42
  "@opentelemetry/api": "^1.9.0",
43
- "@opentelemetry/auto-instrumentations-node": "^0.75.0",
44
- "@opentelemetry/exporter-trace-otlp-proto": "^0.217.0",
45
43
  "@opentelemetry/sdk-node": "^0.217.0",
46
44
  "@__REPO_NAME__/auth": "workspace:*",
47
45
  "@percepta/access-control": "^1.0.0",
48
- "@percepta/ai": "^0.1.0",
49
- "@percepta/database": "0.1.3",
46
+ "@percepta/ai": "^0.1.1",
47
+ "@percepta/auth": "^0.1.7",
48
+ "@percepta/database": "0.1.4",
50
49
  "@percepta/design": "^0.4.1",
51
50
  "@percepta/inngest": "^0.1.0",
52
51
  "@percepta/logger": "^0.1.0",
53
- "@percepta/next-utils": "^0.2.2",
52
+ "@percepta/next-utils": "^0.2.3",
54
53
  "@percepta/utils": "^0.1.11",
55
54
  "@radix-ui/react-slot": "^1.2.3",
56
55
  "@tanstack/react-query": "^5.81.5",
@@ -16,38 +16,36 @@ const nextEnv =
16
16
  (nextEnvModule as { default?: typeof nextEnvModule }).default ??
17
17
  nextEnvModule;
18
18
 
19
+ // Local users mirror the accounts in the monorepo's Authentik seed blueprint
20
+ // (same emails, password "password"). Seeding creates the local user row +
21
+ // SpiceDB grants; the first Authentik sign-in links to it by email.
19
22
  const SEEDED_USERS = [
20
23
  {
21
24
  access: "customer_admin",
22
25
  email: "customer-admin@example.com",
23
26
  name: "Customer Admin",
24
- password: "password",
25
27
  role: "admin",
26
28
  },
27
29
  {
28
30
  access: "app_admin",
29
31
  email: "app-admin@example.com",
30
32
  name: "App Admin",
31
- password: "password",
32
33
  role: "admin",
33
34
  },
34
35
  {
35
36
  access: "app_user",
36
37
  email: "app-user@example.com",
37
38
  name: "App User",
38
- password: "password",
39
39
  role: "user",
40
40
  },
41
41
  {
42
42
  access: "none",
43
43
  email: "non-user@example.com",
44
44
  name: "App Non User",
45
- password: "password",
46
45
  role: "user",
47
46
  },
48
47
  ] as const;
49
48
 
50
- type AuthMode = "username-password" | "google" | "okta";
51
49
  interface AdminCreateUserApi {
52
50
  createUser(input: {
53
51
  body: {
@@ -58,24 +56,8 @@ interface AdminCreateUserApi {
58
56
  }): Promise<{ user: { id: string } }>;
59
57
  }
60
58
 
61
- const DEFAULT_AUTH_MODE: AuthMode = "__AUTH_MODE__" as AuthMode;
62
-
63
- function isAuthMode(value: string | undefined): value is AuthMode {
64
- return (
65
- value === "username-password" || value === "google" || value === "okta"
66
- );
67
- }
68
-
69
- function getAuthMode(): AuthMode {
70
- return isAuthMode(process.env.AUTH_MODE)
71
- ? process.env.AUTH_MODE
72
- : DEFAULT_AUTH_MODE;
73
- }
74
-
75
59
  async function main(): Promise<void> {
76
60
  nextEnv.loadEnvConfig(process.cwd());
77
- const authMode = getAuthMode();
78
- process.env.AUTH_MODE = authMode;
79
61
  // oxlint-disable-next-line typescript/no-explicit-any
80
62
  (globalThis as any).AsyncLocalStorage = AsyncLocalStorage;
81
63
 
@@ -85,10 +67,12 @@ async function main(): Promise<void> {
85
67
  const { getAccessControl, toUserSubject } =
86
68
  await import("../src/services/access/AppAccessControl");
87
69
  const { getEnvConfig } = await import("../src/config/getEnvConfig");
70
+ const { getLogger } = await import("../src/services/logger/AppLogger");
88
71
  const { createCustomerAccessControl } =
89
72
  await import("@percepta/access-control");
90
73
  const { eq, sql } = await import("drizzle-orm");
91
74
 
75
+ const logger = getLogger().child({ safe: { component: "db-seed" } });
92
76
  const envConfig = getEnvConfig();
93
77
  const access = getAccessControl();
94
78
  const appNamespace = access.manifest.appNamespace;
@@ -108,44 +92,36 @@ async function main(): Promise<void> {
108
92
  let userId: string;
109
93
  if (existing != null) {
110
94
  userId = existing.id;
111
- console.log(
112
- `Seed user "${seededUser.email}" already exists (id: ${existing.id}).`,
95
+ logger.info(
96
+ { safe: { email: seededUser.email, userId: existing.id } },
97
+ "Seed user already exists.",
113
98
  );
114
99
  } else {
115
- if (authMode === "username-password") {
116
- // Use Better Auth's signUpEmail API to create the user with a hashed password.
117
- const res = await auth.api.signUpEmail({
118
- body: {
119
- email: seededUser.email,
120
- name: seededUser.name,
121
- password: seededUser.password,
122
- },
123
- });
124
- userId = res.user.id;
125
- } else {
126
- // The admin plugin can create local access principals when this app
127
- // starts with an external OAuth provider instead of credentials.
128
- const adminApi = auth.api as typeof auth.api & AdminCreateUserApi;
129
- const res = await adminApi.createUser({
130
- body: {
131
- email: seededUser.email,
132
- name: seededUser.name,
133
- },
134
- });
135
- userId = res.user.id;
136
- }
137
-
138
- console.log(`Seed user created: ${seededUser.email} (id: ${userId})`);
139
- if (authMode === "username-password") {
140
- console.log(` Password: ${seededUser.password}`);
141
- }
100
+ // Apps authenticate via Authentik, so create the local user row with the
101
+ // admin plugin (no password). SpiceDB grants attach to this row, and the
102
+ // first Authentik sign-in links the OIDC identity to it by email.
103
+ const adminApi = auth.api as typeof auth.api & AdminCreateUserApi;
104
+ const res = await adminApi.createUser({
105
+ body: {
106
+ email: seededUser.email,
107
+ name: seededUser.name,
108
+ },
109
+ });
110
+ userId = res.user.id;
111
+ logger.info(
112
+ { safe: { email: seededUser.email, userId } },
113
+ "Seed user created.",
114
+ );
142
115
  }
143
116
 
144
117
  await authDb
145
118
  .update(users)
146
119
  .set({ role: seededUser.role })
147
120
  .where(eq(users.id, userId));
148
- console.log(` Ensured role: ${seededUser.role}`);
121
+ logger.info(
122
+ { safe: { email: seededUser.email, role: seededUser.role } },
123
+ "Seed user role ensured.",
124
+ );
149
125
 
150
126
  const subject = toUserSubject(userId);
151
127
  switch (seededUser.access) {
@@ -169,7 +145,7 @@ async function main(): Promise<void> {
169
145
  }
170
146
  }
171
147
 
172
- console.log("Ensured local customer and app access grants.");
148
+ logger.info(undefined, "Ensured local customer and app access grants.");
173
149
  process.exit(0);
174
150
  }
175
151
 
@@ -1,75 +1,23 @@
1
1
  #!/usr/bin/env tsx
2
2
 
3
- import { spawn } from "node:child_process";
4
- import { existsSync, readFileSync } from "node:fs";
5
3
  import { homedir } from "node:os";
6
4
  import path from "node:path";
5
+ import { runCommandWithEnvFile } from "@percepta/database";
7
6
 
8
- type EnvMap = Record<string, string>;
9
-
10
- const LOCAL_ENV_PATH = path.join(
11
- homedir(),
12
- ".config",
13
- "percepta",
14
- "create.env",
15
- );
16
-
17
- function parseEnvFile(filePath: string): EnvMap {
18
- if (!existsSync(filePath)) return {};
19
-
20
- const env: EnvMap = {};
21
- const content = readFileSync(filePath, "utf8");
22
- for (const rawLine of content.split(/\r?\n/)) {
23
- const line = rawLine.trim();
24
- if (!line || line.startsWith("#")) continue;
25
-
26
- const normalized = line.startsWith("export ") ? line.slice(7).trim() : line;
27
- const separatorIndex = normalized.indexOf("=");
28
- if (separatorIndex === -1) continue;
29
-
30
- const key = normalized.slice(0, separatorIndex).trim();
31
- const rawValue = normalized.slice(separatorIndex + 1).trim();
32
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
33
-
34
- env[key] = unquote(rawValue);
35
- }
36
-
37
- return env;
38
- }
39
-
40
- function unquote(value: string): string {
41
- if (
42
- (value.startsWith('"') && value.endsWith('"')) ||
43
- (value.startsWith("'") && value.endsWith("'"))
44
- ) {
45
- return value.slice(1, -1);
46
- }
47
-
48
- return value;
49
- }
7
+ const LOCAL_ENV_DIR = path.join(homedir(), ".config", "percepta");
8
+ const LOCAL_ENV_FILE = "create.env";
50
9
 
51
10
  const [command, ...args] = process.argv.slice(2);
52
11
  if (!command) {
53
12
  throw new Error("Usage: tsx scripts/with-local-env.ts <command> [...args]");
54
13
  }
55
14
 
56
- const child = spawn(command, args, {
57
- env: {
58
- ...parseEnvFile(LOCAL_ENV_PATH),
59
- ...process.env,
60
- },
61
- stdio: "inherit",
62
- });
63
-
64
- child.on("error", (error) => {
65
- throw error;
66
- });
67
-
68
- child.on("exit", (code, signal) => {
69
- if (signal) {
70
- process.kill(process.pid, signal);
71
- return;
72
- }
73
-
74
- process.exit(code ?? 1);
75
- });
15
+ process.exit(
16
+ await runCommandWithEnvFile({
17
+ args,
18
+ allowedEnvFileNames: [LOCAL_ENV_FILE],
19
+ command,
20
+ envFileBaseDir: LOCAL_ENV_DIR,
21
+ envFilePath: LOCAL_ENV_FILE,
22
+ }),
23
+ );