@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.
- 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 +5 -2
- package/template-versions.json +2 -2
- package/templates/monorepo/README.md +28 -1
- package/templates/monorepo/auth/package.json +1 -1
- package/templates/monorepo/auth/src/auth.ts +7 -103
- 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/monorepo/oxlint.config.ts.template +5 -1
- package/templates/monorepo/package.json.template +2 -1
- package/templates/webapp/.github/workflows/__APP_NAME__-ryvn-release.yaml +22 -89
- package/templates/webapp/AGENTS.md +4 -4
- package/templates/webapp/README.md +22 -33
- package/templates/webapp/agent-skills/langfuse.md +8 -11
- 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/package.json.template +4 -5
- package/templates/webapp/scripts/seed.ts +28 -52
- package/templates/webapp/scripts/with-local-env.ts +12 -64
- 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/drizzle/db.ts +5 -9
- package/templates/webapp/src/instrumentation.ts +5 -72
- package/templates/webapp/src/lib/auth/index.ts +1 -2
- package/templates/webapp/src/services/DatabaseService.ts +3 -51
- package/templates/webapp/src/services/observability/initFaro.ts +5 -17
- package/dist/register-app-BeSQEsel.js.map +0 -1
- package/templates/monorepo/scripts/setup-local-databases.mjs +0 -183
- 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/drizzle/schema/utils/jsonbFromZod.ts +0 -25
- 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": "
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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;
|
|
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/ #
|
|
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.
|
|
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
|
|
@@ -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 {
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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?**
|
|
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.
|
|
132
|
-
|
|
133
|
-
await page.
|
|
134
|
-
|
|
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
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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.
|
|
49
|
-
"@percepta/
|
|
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.
|
|
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
|
-
|
|
112
|
-
|
|
95
|
+
logger.info(
|
|
96
|
+
{ safe: { email: seededUser.email, userId: existing.id } },
|
|
97
|
+
"Seed user already exists.",
|
|
113
98
|
);
|
|
114
99
|
} else {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
+
);
|