@percepta/create 3.1.5 → 3.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 (74) hide show
  1. package/dist/index.js +53 -46
  2. package/dist/index.js.map +1 -1
  3. package/dist/{init-OeK4Yk6_.js → init-CtCp7Tv2.js} +3 -3
  4. package/dist/init-CtCp7Tv2.js.map +1 -0
  5. package/dist/{status-DC8mvHZj.js → status-CKe4aKso.js} +2 -2
  6. package/dist/{status-DC8mvHZj.js.map → status-CKe4aKso.js.map} +1 -1
  7. package/dist/{sync-C5Pd32VM.js → sync-D1vkoofl.js} +2 -2
  8. package/dist/{sync-C5Pd32VM.js.map → sync-D1vkoofl.js.map} +1 -1
  9. package/dist/{upstream-F6m8zRBQ.js → upstream-D-LH_1z4.js} +2 -2
  10. package/dist/{upstream-F6m8zRBQ.js.map → upstream-D-LH_1z4.js.map} +1 -1
  11. package/package.json +2 -2
  12. package/template-versions.json +1 -1
  13. package/templates/monorepo/.github/workflows/access-control.yml +38 -0
  14. package/templates/monorepo/README.md +41 -2
  15. package/templates/monorepo/access/README.md +39 -0
  16. package/templates/monorepo/access/bootstrap-grants.yaml.example +9 -0
  17. package/templates/monorepo/access/dev-grants.yaml.example +19 -0
  18. package/templates/monorepo/access/dev-groups.yaml.example +8 -0
  19. package/templates/monorepo/access/reconcile.yaml.example +11 -0
  20. package/templates/monorepo/auth/README.md +26 -0
  21. package/templates/monorepo/auth/drizzle.config.ts +13 -0
  22. package/templates/monorepo/auth/package.json +32 -0
  23. package/templates/monorepo/auth/scripts/setup-database.ts +57 -0
  24. package/templates/monorepo/auth/src/auth.ts +77 -0
  25. package/templates/monorepo/auth/src/config/database.ts +31 -0
  26. package/templates/monorepo/auth/src/drizzle/db.ts +9 -0
  27. package/templates/monorepo/auth/src/drizzle/migrations/0000_shared_auth.sql +89 -0
  28. package/templates/monorepo/auth/src/drizzle/migrations/meta/_journal.json +13 -0
  29. package/templates/{webapp → monorepo/auth}/src/drizzle/schema/auth/accounts.ts +1 -6
  30. package/templates/{webapp → monorepo/auth}/src/drizzle/schema/auth/sessions.ts +1 -5
  31. package/templates/{webapp → monorepo/auth}/src/drizzle/schema/auth/verifications.ts +0 -4
  32. package/templates/monorepo/auth/src/drizzle/schema/groups.ts +16 -0
  33. package/templates/monorepo/auth/src/drizzle/schema/index.ts +5 -0
  34. package/templates/monorepo/auth/src/drizzle/schema/users.ts +6 -0
  35. package/templates/monorepo/auth/src/index.ts +1 -0
  36. package/templates/monorepo/auth/src/scim/README.md +6 -0
  37. package/templates/monorepo/auth/tsconfig.json +12 -0
  38. package/templates/monorepo/package.json.template +18 -6
  39. package/templates/monorepo/pnpm-workspace.yaml +1 -0
  40. package/templates/webapp/AGENTS.md +13 -6
  41. package/templates/webapp/README.md +34 -18
  42. package/templates/webapp/agent-skills/access-control.md +301 -0
  43. package/templates/webapp/agent-skills/database.md +1 -1
  44. package/templates/webapp/docker-compose.yml +16 -0
  45. package/templates/webapp/env.example.template +9 -0
  46. package/templates/webapp/next.config.ts +1 -0
  47. package/templates/webapp/package.json.template +8 -4
  48. package/templates/webapp/scripts/seed.ts +87 -36
  49. package/templates/webapp/scripts/setup-database.ts +7 -1
  50. package/templates/webapp/scripts/start.sh +0 -9
  51. package/templates/webapp/src/access/access.manifest.ts +15 -0
  52. package/templates/webapp/src/access/schema.zed +7 -0
  53. package/templates/webapp/src/app/(app)/admin/_lib/PrincipalRoleTable.tsx +113 -0
  54. package/templates/webapp/src/app/(app)/admin/_lib/accessAdmin.ts +85 -0
  55. package/templates/webapp/src/app/(app)/admin/groups/page.tsx +117 -0
  56. package/templates/webapp/src/app/(app)/admin/users/page.tsx +79 -0
  57. package/templates/webapp/src/app/(app)/layout.tsx +16 -2
  58. package/templates/webapp/src/app/(app)/page.tsx +1 -12
  59. package/templates/webapp/src/app/(auth)/auth/signin/page.tsx +2 -5
  60. package/templates/webapp/src/app/(auth)/auth/signup/page.tsx +2 -5
  61. package/templates/webapp/src/config/getEnvConfig.ts +8 -0
  62. package/templates/webapp/src/drizzle/db.ts +3 -4
  63. package/templates/webapp/src/drizzle/migrations/0000_eager_grandmaster.sql +1 -57
  64. package/templates/webapp/src/drizzle/migrations/meta/0000_snapshot.json +1 -347
  65. package/templates/webapp/src/drizzle/schema/index.ts +3 -4
  66. package/templates/webapp/src/lib/auth/index.ts +6 -81
  67. package/templates/webapp/src/server/api/root.ts +4 -1
  68. package/templates/webapp/src/server/api/routers/access.ts +13 -0
  69. package/templates/webapp/src/server/trpc.ts +42 -8
  70. package/templates/webapp/src/services/DatabaseService.ts +4 -5
  71. package/templates/webapp/src/services/access/AppAccessControl.ts +39 -0
  72. package/dist/init-OeK4Yk6_.js.map +0 -1
  73. package/templates/webapp/scripts/create-user.ts +0 -47
  74. package/templates/webapp/src/drizzle/schema/auth/users.ts +0 -38
@@ -0,0 +1,5 @@
1
+ export { accounts } from "./auth/accounts";
2
+ export { sessions } from "./auth/sessions";
3
+ export { verifications } from "./auth/verifications";
4
+ export { groupMembers, groups } from "./groups";
5
+ export { users } from "./users";
@@ -0,0 +1,6 @@
1
+ import { createUsersTable } from "@percepta/access-control/drizzle";
2
+
3
+ export const users = createUsersTable();
4
+
5
+ export type User = typeof users.$inferSelect;
6
+ export type NewUser = typeof users.$inferInsert;
@@ -0,0 +1 @@
1
+ export { auth, type BetterAuthSession } from "./auth";
@@ -0,0 +1,6 @@
1
+ # SCIM Placeholder
2
+
3
+ SCIM `/Users` and `/Groups` endpoints are intentionally a follow-up project.
4
+ When implemented, they should update the local `users`, `groups`, and
5
+ `group_members` projection through the access-control `groupSync` ingestion
6
+ contract so SpiceDB and Postgres stay aligned.
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "../tsconfig.json",
3
+ "compilerOptions": {
4
+ "declaration": false,
5
+ "declarationMap": false,
6
+ "module": "ESNext",
7
+ "moduleResolution": "Bundler",
8
+ "noEmit": true,
9
+ "types": ["node"]
10
+ },
11
+ "include": ["drizzle.config.ts", "src/**/*.ts"]
12
+ }
@@ -5,18 +5,30 @@
5
5
  "description": "__APP_TITLE__",
6
6
  "scripts": {
7
7
  "preinstall": "npx only-allow pnpm",
8
- "dev": "pnpm -r --parallel run dev",
9
- "build": "pnpm -r run build",
10
- "clean": "pnpm -r run clean",
11
- "lint": "pnpm -r --parallel --no-bail run lint",
12
- "lint:fix": "pnpm -r --no-bail run lint:fix",
13
- "test": "pnpm -r run test"
8
+ "setup": "pnpm -r --filter './packages/*' --if-present run docker:up && pnpm run access:apply-local && pnpm run auth:db:setup-and-migrate && pnpm -r --filter './packages/*' --if-present run db:setup-and-migrate && pnpm -r --filter './packages/*' --if-present run db:seed",
9
+ "dev": "pnpm -r --parallel --if-present run dev",
10
+ "build": "pnpm -r --if-present run build",
11
+ "clean": "pnpm -r --if-present run clean",
12
+ "lint": "pnpm -r --parallel --no-bail --if-present run lint",
13
+ "lint:fix": "pnpm -r --no-bail --if-present run lint:fix",
14
+ "test": "pnpm -r --if-present run test",
15
+ "access:merge": "percepta-access-control merge --apps-dir \"$PWD/packages\" --out-dir access",
16
+ "access:validate": "percepta-access-control validate --apps-dir \"$PWD/packages\"",
17
+ "access:apply": "pnpm run access:merge && percepta-access-control apply --schema access/merged.zed --applications access/applications.generated.json",
18
+ "access:apply-local": "pnpm run access:merge && percepta-access-control apply --schema access/merged.zed --applications access/applications.generated.json --endpoint localhost:50051 --insecure --key dev-spicedb-token",
19
+ "access:seed-grants": "percepta-access-control seed-grants --fixture access/dev-grants.yaml",
20
+ "access:seed-groups": "percepta-access-control seed-groups",
21
+ "access:bootstrap-customer-admin": "percepta-access-control bootstrap-customer-admin",
22
+ "access:apply-bootstrap-grants": "percepta-access-control apply-bootstrap-grants --fixture access/bootstrap-grants.yaml",
23
+ "access:reconcile": "percepta-access-control reconcile --input access/reconcile.yaml",
24
+ "auth:db:setup-and-migrate": "pnpm --dir auth run db:setup-and-migrate"
14
25
  },
15
26
  "engines": {
16
27
  "node": ">=20",
17
28
  "pnpm": ">=9"
18
29
  },
19
30
  "devDependencies": {
31
+ "@percepta/access-control": "0.3.2",
20
32
  "@types/node": "^24.1.0",
21
33
  "eslint": "^9.18.0",
22
34
  "rimraf": "^5.0.5",
@@ -1,2 +1,3 @@
1
1
  packages:
2
2
  - "packages/*"
3
+ - "auth"
@@ -1,6 +1,6 @@
1
1
  # Webapp Template
2
2
 
3
- Next.js 15 full-stack application scaffolded from the Mosaic webapp template via `@percepta/create`. Uses React 19, TypeScript, Tailwind CSS v4, tRPC, Drizzle ORM, Better Auth, and Inngest.
3
+ Next.js 15 full-stack application scaffolded from the Mosaic webapp template via `@percepta/create`. Uses React 19, TypeScript, Tailwind CSS v4, tRPC, Drizzle ORM, Better Auth, SpiceDB access control, and Inngest.
4
4
 
5
5
  ## Build & Dev Commands
6
6
 
@@ -8,17 +8,21 @@ Next.js 15 full-stack application scaffolded from the Mosaic webapp template via
8
8
  - `pnpm build` — production build
9
9
  - `pnpm lint` — run ESLint
10
10
  - `pnpm test` — run Vitest tests
11
- - `pnpm docker:up` / `pnpm docker:down` — start PostgreSQL and wait for health / stop PostgreSQL
11
+ - `pnpm docker:up` / `pnpm docker:down` — start PostgreSQL and SpiceDB, waiting for health, or stop them
12
+ - `pnpm access:validate` — validate access schema and manifest
13
+ - `pnpm access:apply-local` — apply merged customer access schema to local SpiceDB
14
+ - `pnpm auth:db:setup-and-migrate` — setup and migrate the shared customer auth database
12
15
  - `pnpm inngest:dev` — start local Inngest dev server when working on background jobs
13
16
  - `pnpm db:generate` — generate Drizzle migrations
14
17
  - `pnpm db:migrate` — apply migrations
15
18
  - `pnpm db:setup-and-migrate` — create DB + migrate
16
- - `pnpm db:studio` — open Drizzle Studio
17
- - `pnpm db:seed` — seed default dev user (admin@example.com / password)
19
+ - `pnpm db:studio` — setup/migrate the database, then open Drizzle Studio
20
+ - `pnpm db:seed` — seed default shared-auth dev users and local access grants (admin@example.com and user@example.com / password)
18
21
 
19
22
  **Package manager**: Always use `pnpm`, never `npm` or `yarn`.
20
23
 
21
- Local development only requires Postgres by default. Run Inngest locally when a workflow needs it. Do not run a local LGTM/Langfuse stack unless you are specifically debugging telemetry; those are wired by the Ryvn environment for deploys. If local LLM calls are needed, `pnpm dev` loads shared provider keys from `~/.config/percepta/create.env` when it exists.
24
+ Local development requires PostgreSQL and SpiceDB. Run Inngest locally when a workflow needs it. Do not run a local LGTM/Langfuse stack unless you are specifically debugging telemetry; those are wired by the Ryvn environment for deploys.
25
+ If local LLM calls are needed, `pnpm dev` loads shared provider keys from `~/.config/percepta/create.env` when it exists.
22
26
 
23
27
  ## Code Style
24
28
 
@@ -34,6 +38,7 @@ Local development only requires Postgres by default. Run Inngest locally when a
34
38
 
35
39
  ```
36
40
  src/ # Application source
41
+ ├── access/ # SpiceDB schema and manifest
37
42
  ├── app/ # Next.js App Router pages, layouts, and API route handlers
38
43
  ├── components/ # React components
39
44
  ├── config/ # Env config (clientEnvConfig, getEnvConfig)
@@ -45,6 +50,7 @@ src/ # Application source
45
50
  │ ├── trpc.ts # Context & procedure builders
46
51
  │ └── api/root.ts # Root appRouter
47
52
  ├── services/
53
+ │ ├── access/ # App access-control runtime binding
48
54
  │ ├── inngest/ # Background job definitions
49
55
  │ ├── langfuse/ # LLM observability
50
56
  │ ├── llm/ # LLM provider selection and call helpers
@@ -194,6 +200,7 @@ Detailed how-to guides for each major stack component. Read the relevant guide w
194
200
  | LLM Calls | [agent-skills/llm.md](agent-skills/llm.md) | Adding backend model calls, streaming, or structured generation |
195
201
  | LLM Observability (Langfuse) | [agent-skills/langfuse.md](agent-skills/langfuse.md) | App uses LLMs and needs trace/eval monitoring |
196
202
  | Database (Drizzle) | [agent-skills/database.md](agent-skills/database.md) | Adding tables, writing migrations, querying data |
203
+ | Access Control (SpiceDB) | [agent-skills/access-control.md](agent-skills/access-control.md) | Adding Zed policy, app roles, permissioned resources, or group sync |
197
204
  | Deployment (Ryvn) | [agent-skills/ryvn.md](agent-skills/ryvn.md) | Ryvn overview and Percepta environment context |
198
205
  | Deploy to percepta-test | [agent-skills/deploy.md](agent-skills/deploy.md) | Step-by-step deploy using the pre-scaffolded `deploy/ryvn/` IaC files |
199
206
  | Build App (Oneshot) | [agent-skills/oneshot.md](agent-skills/oneshot.md) | Building a complete app from requirements end-to-end |
@@ -215,7 +222,7 @@ Client-side usage via `src/lib/trpc.ts`.
215
222
 
216
223
  ### Authentication
217
224
 
218
- Better Auth configured in `src/lib/auth/`. Email/password credentials enabled by default.
225
+ Better Auth is configured in the customer monorepo's shared `@__REPO_NAME__/auth` package. The app imports it through `src/lib/auth/` for local session validation.
219
226
 
220
227
  - **Server-side**: `auth.api.getSession({ headers: await headers() })` — get session in server components or tRPC context
221
228
  - **Client-side**: `authClient.useSession()` — React hook from `src/lib/auth-client.ts`
@@ -7,6 +7,7 @@ A production-ready Next.js application with authentication, database, logging, b
7
7
  - **Next.js 15** with App Router
8
8
  - **Authentication** via Better Auth with email/password credentials
9
9
  - **Database** with PostgreSQL, Drizzle ORM, and migrations
10
+ - **Access Control** with SpiceDB schema authoring and manifest validation
10
11
  - **Logging** with Pino and structured safe/unsafe data separation
11
12
  - **Background Jobs** with Inngest
12
13
  - **LLM Calls** with a provider-backed `LLMService`
@@ -16,23 +17,21 @@ A production-ready Next.js application with authentication, database, logging, b
16
17
 
17
18
  ## Quick Start
18
19
 
19
- ### 1. Start the Database
20
+ ### 1. Configure Environment
20
21
 
21
- ```bash
22
- pnpm docker:up
23
- ```
22
+ Copy `.env.example` to `.env.local` and configure any app-specific environment variables.
24
23
 
25
- ### 2. Initialize the Database
24
+ ### 2. Run Setup
26
25
 
27
26
  ```bash
28
- pnpm db:setup-and-migrate
27
+ pnpm run setup
29
28
  ```
30
29
 
31
- ### 3. Configure Environment
32
-
33
- Copy `.env.example` to `.env.local` and configure your environment variables.
30
+ This starts local PostgreSQL and SpiceDB, applies the local access schema,
31
+ sets up the shared customer auth database, runs app migrations, and seeds dev
32
+ users.
34
33
 
35
- ### 4. Start Inngest When Using Background Jobs
34
+ ### 3. Start Inngest When Using Background Jobs
36
35
 
37
36
  ```bash
38
37
  pnpm inngest:dev
@@ -40,7 +39,7 @@ pnpm inngest:dev
40
39
 
41
40
  Run this in a separate terminal when the app has Inngest functions or you want the local Inngest dashboard.
42
41
 
43
- ### 5. Start Development Server
42
+ ### 4. Start Development Server
44
43
 
45
44
  ```bash
46
45
  pnpm dev
@@ -62,6 +61,7 @@ ANTHROPIC_API_KEY=sk-ant-...
62
61
 
63
62
  ```
64
63
  src/
64
+ ├── access/ # SpiceDB schema and manifest
65
65
  ├── app/ # Next.js App Router pages, layouts, and API route handlers
66
66
  ├── components/ # React components
67
67
  ├── config/ # Environment configuration
@@ -72,6 +72,7 @@ src/
72
72
  ├── services/ # Business logic services
73
73
  │ ├── inngest/ # Background job definitions
74
74
  │ ├── langfuse/ # LLM observability
75
+ │ ├── access/ # App access-control runtime binding
75
76
  │ ├── llm/ # LLM provider selection and call helpers
76
77
  │ └── logger/ # Structured logging
77
78
  └── utils/ # Utility functions
@@ -85,16 +86,18 @@ src/
85
86
  | `pnpm build` | Build for production |
86
87
  | `pnpm start` | Start production server |
87
88
  | `pnpm lint` | Run ESLint |
88
- | `pnpm docker:up` | Start PostgreSQL container and wait until it is healthy |
89
- | `pnpm docker:down` | Stop PostgreSQL container |
89
+ | `pnpm docker:up` | Start PostgreSQL and SpiceDB containers and wait until they are healthy |
90
+ | `pnpm docker:down` | Stop PostgreSQL and SpiceDB containers |
91
+ | `pnpm access:validate` | Validate the access manifest and schema |
92
+ | `pnpm access:apply-local` | Apply the merged customer access schema to local SpiceDB |
93
+ | `pnpm auth:db:setup-and-migrate` | Setup and migrate the shared customer auth database |
90
94
  | `pnpm inngest:dev` | Start the local Inngest dev server for this app |
91
95
  | `pnpm db:generate` | Generate Drizzle migrations |
92
96
  | `pnpm db:migrate` | Run database migrations |
93
97
  | `pnpm db:setup` | Create database and user |
94
98
  | `pnpm db:setup-and-migrate` | Setup and migrate database |
95
- | `pnpm db:studio` | Open Drizzle Studio |
96
- | `pnpm db:create-user` | Create a user account |
97
- | `pnpm db:seed` | Seed default dev user |
99
+ | `pnpm db:studio` | Setup/migrate the database, then open Drizzle Studio |
100
+ | `pnpm db:seed` | Seed default shared-auth dev users and local access grants |
98
101
 
99
102
  ## Logging
100
103
 
@@ -122,7 +125,7 @@ logger.error(
122
125
 
123
126
  ## Authentication
124
127
 
125
- This app uses [Better Auth](https://better-auth.com) for authentication with email/password credentials enabled by default.
128
+ 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.
126
129
 
127
130
  Required environment variables:
128
131
 
@@ -134,9 +137,14 @@ BETTER_AUTH_URL=http://localhost:3000
134
137
  To create a dev user:
135
138
  ```bash
136
139
  pnpm db:seed
137
- # Creates admin@example.com / password
140
+ # Creates admin@example.com / password as an app admin
141
+ # Creates user@example.com / password as a non-admin app member
138
142
  ```
139
143
 
144
+ ## Access Control
145
+
146
+ App permissions are authored in `src/access/schema.zed`; `src/access/access.manifest.ts` describes the app integration surface for admin UI, seed scripts, and resource inventories. For the full workflow, read [agent-skills/access-control.md](agent-skills/access-control.md).
147
+
140
148
  ## Environment Variables
141
149
 
142
150
  ### Application
@@ -169,6 +177,14 @@ Generate a secret key:
169
177
  node -e "console.log(require('crypto').randomBytes(16).toString('hex'))"
170
178
  ```
171
179
 
180
+ ### Access Control
181
+
182
+ | Variable | Description | Default |
183
+ |----------|-------------|---------|
184
+ | `SPICEDB_ENDPOINT` | SpiceDB gRPC endpoint | `localhost:50051` |
185
+ | `SPICEDB_PRESHARED_KEY` | SpiceDB preshared key | `dev-spicedb-token` |
186
+ | `SPICEDB_INSECURE` | Use insecure local gRPC transport | `true` |
187
+
172
188
  ### Inngest (Background Jobs)
173
189
 
174
190
  | Variable | Description |
@@ -0,0 +1,301 @@
1
+ # Access Control
2
+
3
+ Use this guide when adding permissioned resources, changing Zed policy, assigning app roles, debugging denied checks, or wiring identity/group sync.
4
+
5
+ ## Mental Model
6
+
7
+ The customer monorepo has one shared identity layer and one shared SpiceDB deployment:
8
+
9
+ - `../../auth` owns Better Auth, `users`, `groups`, and `group_members`.
10
+ - `../../access` merges every app's Zed schema with the shared `core/*` schema and applies customer-level topology.
11
+ - This app owns only its app namespace, app-defined roles, and app resource relationships.
12
+
13
+ There are three separate authorization layers:
14
+
15
+ | Layer | Owner | Stored as |
16
+ |-------|-------|-----------|
17
+ | Customer admins | Customer bootstrap / Applications admin | `core/customer:main#admin@core/user:<users.id>` |
18
+ | Application access | Customer Applications admin | `core/application:<app>#owner/member@core/user:<users.id>` or `core/group:<groups.id>#member` |
19
+ | App roles/resources | This app's owner/admin UI and app code | `<app>/system:main#<role>@<subject>` and app resource relationships |
20
+
21
+ Customer admins do not automatically get application access. To enter an app, a user or group must be an application `owner` or `member`.
22
+
23
+ ## Source Of Truth
24
+
25
+ - `src/access/schema.zed` is the policy source of truth. SpiceDB evaluates this directly.
26
+ - `src/access/access.manifest.ts` is integration metadata for TypeScript, admin UI, seed scripts, and resource inventories. It should describe what the app uses; it should not invent policy that is missing from Zed.
27
+
28
+ After changing `schema.zed` or `access.manifest.ts`, run:
29
+
30
+ ```bash
31
+ pnpm access:validate
32
+ ```
33
+
34
+ PR CI runs the customer-level access validation, which merges every app schema
35
+ and checks the schema/manifest contract.
36
+
37
+ ## Naming Rules
38
+
39
+ Every app-authored SpiceDB definition must live under this app's namespace:
40
+
41
+ ```zed
42
+ definition __APP_NAME_SNAKE__/system {
43
+ relation application: core/application
44
+ relation admin: core/user | core/group#member
45
+
46
+ permission access_app = application->access
47
+ permission manage_roles = access_app & (application->owner + admin)
48
+ }
49
+ ```
50
+
51
+ Use canonical shared identity refs:
52
+
53
+ - Users: `core/user:<users.id>`
54
+ - Groups: `core/group:<groups.id>#member`
55
+ - Applications: `core/application:<appNamespace>`
56
+ - System singleton: `<appNamespace>/system:main`
57
+
58
+ Do not use email addresses, IdP external IDs, group slugs, or display names in SpiceDB refs. Resolve those values to shared `auth` table IDs first.
59
+
60
+ ## Application Access
61
+
62
+ Application access means "can open this app at all." It comes from the customer-level `core/application` object.
63
+
64
+ The starter system permission is:
65
+
66
+ ```zed
67
+ permission access_app = application->access
68
+ ```
69
+
70
+ The generated app gates the protected app layout with `canAccessApplication()`. Keep that gate in place for authenticated app pages. In tRPC, `protectedProcedure` means authenticated, `appProcedure` adds the default app-access gate, and `requirePermission` should be used for resource-specific checks.
71
+
72
+ For resource permissions that should only be available inside the app, include `system->access_app` in the Zed expression or make the permission depend on another permission that does. If a permission is intentionally usable without app enrollment, list it in `externallyShareablePermissions` in the manifest.
73
+
74
+ ## App-Defined Roles
75
+
76
+ App roles are static roles authored by the app builder in Zed and assigned by app owners/admins at runtime.
77
+
78
+ To add a role:
79
+
80
+ 1. Add a relation to `<appNamespace>/system` in `schema.zed`.
81
+ 2. Include the role in `system.assignableRoles` in `access.manifest.ts` if app owners may assign it.
82
+ 3. Run `pnpm access:validate`.
83
+
84
+ Example:
85
+
86
+ ```zed
87
+ definition __APP_NAME_SNAKE__/system {
88
+ relation application: core/application
89
+ relation admin: core/user | core/group#member
90
+ relation hr_admin: core/user | core/group#member
91
+
92
+ permission access_app = application->access
93
+ permission manage_roles = access_app & (application->owner + admin)
94
+ }
95
+ ```
96
+
97
+ ```ts
98
+ import { defineAccessManifest } from "@percepta/access-control";
99
+
100
+ export const accessManifest = defineAccessManifest({
101
+ // ...
102
+ system: {
103
+ // ...
104
+ assignableRoles: ["admin", "hr_admin"],
105
+ },
106
+ } as const);
107
+ ```
108
+
109
+ Role assignment is additive. Assigning `admin` and then `hr_admin` leaves both grants in place until each one is revoked.
110
+
111
+ ## Permissioned Resources
112
+
113
+ Model app data as resource definitions when access varies by object or relationship.
114
+
115
+ Example for a people app:
116
+
117
+ ```zed
118
+ definition __APP_NAME_SNAKE__/employee {
119
+ relation system: __APP_NAME_SNAKE__/system
120
+ relation employee_user: core/user
121
+ relation manager: __APP_NAME_SNAKE__/employee
122
+
123
+ permission view_basic = system->access_app
124
+ permission view_private = system->access_app & (employee_user + manager->view_private + system->hr_admin)
125
+ }
126
+ ```
127
+
128
+ Then describe the resource in `access.manifest.ts`:
129
+
130
+ ```ts
131
+ resources: {
132
+ employee: {
133
+ assignableRelations: ["employee_user", "manager"],
134
+ inventory: listEmployeesForAccessReconcile,
135
+ label: "Employee",
136
+ permissionsUsedByApp: ["view_basic", "view_private"],
137
+ ref: (id) => `__APP_NAME_SNAKE__/employee:${id}`,
138
+ systemLinked: true,
139
+ type: "__APP_NAME_SNAKE__/employee",
140
+ },
141
+ },
142
+ ```
143
+
144
+ Use `systemLinked: true` when the resource has a `relation system` and permissions depend on `system->...`. The access-control helpers call `touchResource()` when assigning resource relations, but resources created without relationships should call `touchResource()` after creation so app-level permissions can resolve.
145
+
146
+ If `access:reconcile` should repair missing system links for a resource type, provide an `inventory` callback that yields the live resource IDs from Postgres. The library cannot discover app resources by itself.
147
+
148
+ ## Permissioned Postgres Tables
149
+
150
+ When a Drizzle table backs a SpiceDB resource, bind the table to the resource manifest once. Keep Zed as the permission source of truth; the table binding only records object identity, relationship columns, and which field groups require which permissions.
151
+
152
+ ```ts
153
+ import {
154
+ createColumnPermissionChecks,
155
+ definePermissionedResourceTable,
156
+ } from "@percepta/access-control/drizzle";
157
+ import { userSubjectRef } from "@percepta/access-control";
158
+ import { accessManifest } from "@/access/access.manifest";
159
+ import { employees } from "@/drizzle/schema";
160
+
161
+ export const employeeAccess = definePermissionedResourceTable({
162
+ table: employees,
163
+ resource: accessManifest.resources.employee,
164
+ id: employees.id,
165
+ relations: {
166
+ employee_user: {
167
+ column: employees.userId,
168
+ subject: userSubjectRef,
169
+ },
170
+ manager: {
171
+ column: employees.managerEmployeeId,
172
+ subject: (employeeId: string) =>
173
+ accessManifest.resources.employee.ref(employeeId),
174
+ },
175
+ },
176
+ fieldGroups: {
177
+ basic: {
178
+ read: "view_basic",
179
+ columns: [employees.id, employees.name, employees.title],
180
+ },
181
+ compensation: {
182
+ read: "view_compensation",
183
+ write: "edit_compensation",
184
+ columns: [employees.salary, employees.bonus],
185
+ },
186
+ },
187
+ } as const);
188
+ ```
189
+
190
+ Use `createColumnPermissionChecks()` near serializers, mutation handlers, exports, and custom filters/sorts whenever the selected columns are dynamic:
191
+
192
+ ```ts
193
+ const checks = createColumnPermissionChecks(employeeAccess, {
194
+ access: "read",
195
+ columns: [employees.salary],
196
+ id: employeeId,
197
+ subject: userSubjectRef(session.user.id),
198
+ });
199
+ const [canReadSalary] = await getAccessControl().permissions.canMany(checks);
200
+ ```
201
+
202
+ Do not model ordinary Postgres columns as SpiceDB objects. Model business permissions in Zed, group columns under those permissions in the table binding, then enforce the binding at the API/data boundary.
203
+
204
+ ## App Code
205
+
206
+ Use the app access service instead of constructing raw SpiceDB clients in feature code:
207
+
208
+ ```ts
209
+ import { getAccessControl, toUserSubject } from "@/services/access/AppAccessControl";
210
+
211
+ const allowed = await getAccessControl().permissions.can({
212
+ permission: "view_private",
213
+ resource: `__APP_NAME_SNAKE__/employee:${employeeId}`,
214
+ subject: toUserSubject(session.user.id),
215
+ });
216
+ ```
217
+
218
+ For tRPC procedures, use `protectedProcedure` for authentication-only calls, `appProcedure` for calls that should require app enrollment, and `requirePermission` for resource-specific authorization. Keep permission checks near the data load or mutation they protect.
219
+
220
+ Use `canStrong` after writes that must observe a recent revocation immediately. Normal `can` checks use the request cache and standard SpiceDB read consistency.
221
+
222
+ ## Groups And Sync
223
+
224
+ The local source of truth for groups is the shared `auth` package:
225
+
226
+ - `groups` stores customer-global group IDs and upstream identifiers.
227
+ - `group_members` stores the last observed membership projection.
228
+ - SpiceDB group membership should be written through group sync/reconcile paths, not directly from app code.
229
+
230
+ Development fixtures can seed groups into the shared auth projection. Future SCIM/JIT sync should write the same projection first, then mirror to SpiceDB. `access:reconcile` repairs SpiceDB to match the projection; it does not call the IdP itself.
231
+
232
+ When assigning app roles or resource relations to a group, use the resolved shared group subject:
233
+
234
+ ```ts
235
+ await getAccessControl().app.assignAppRole(
236
+ "admin",
237
+ `core/group:${groupId}#member`,
238
+ );
239
+ ```
240
+
241
+ ## Shared Auth Boundary
242
+
243
+ This app imports Better Auth from `@__REPO_NAME__/auth` via `src/lib/auth/index.ts`. The app does not own `users`, `groups`, `group_members`, sessions, accounts, or verification tables.
244
+
245
+ When app code needs users for display, import from the shared auth package:
246
+
247
+ ```ts
248
+ import { db as authDb } from "@__REPO_NAME__/auth/db";
249
+ import { users } from "@__REPO_NAME__/auth/schema";
250
+ ```
251
+
252
+ Use `users.id` in SpiceDB refs. `users.external_id`, email, and group external IDs are ingestion lookup keys only.
253
+
254
+ The `users.role` column is not the app permission source of truth. If Better Auth admin features need it, update it deliberately for that UI. Do not rely on it for app access, app roles, or resource permissions.
255
+
256
+ ## Debugging A Denied Check
257
+
258
+ 1. Confirm the user can authenticate and has the expected shared `users.id` in the auth DB.
259
+ 2. Confirm application access first: the user or one of their groups needs `core/application:<app>#member` or `#owner`.
260
+ 3. Confirm local topology exists:
261
+
262
+ ```bash
263
+ pnpm access:apply-local
264
+ ```
265
+
266
+ 4. Validate policy and manifest:
267
+
268
+ ```bash
269
+ pnpm access:validate
270
+ ```
271
+
272
+ 5. Check whether the permission expression depends on a missing `system` link or resource relationship. For `systemLinked` resources, call `touchResource()` or assign a relationship through the app access helper.
273
+ 6. Check group membership in the shared `auth.group_members` projection before looking at SpiceDB. Reconcile only repairs what the projection says should exist.
274
+ 7. If a recently revoked grant still appears allowed inside the same request, use the strong read path for the follow-up check and avoid reusing stale cached results after writes.
275
+
276
+ ## Customer Merge Pipeline
277
+
278
+ In production, the customer monorepo owns the combined SpiceDB schema:
279
+
280
+ ```bash
281
+ pnpm --dir ../.. run access:merge
282
+ pnpm --dir ../.. run access:validate
283
+ pnpm --dir ../.. run access:apply
284
+ ```
285
+
286
+ The merge step reads every app's `src/access/schema.zed` and `access.manifest.ts`, validates namespace boundaries, includes the library `core.zed`, and emits the customer-level app registry. App packages should not apply production schema fragments independently.
287
+
288
+ Treat `pnpm --dir ../.. run access:apply` like a database migration step in the
289
+ customer deploy pipeline, not like an ordinary PR check. PR CI should merge and
290
+ validate; trusted deploy jobs should apply the already-validated merged schema to
291
+ each environment's SpiceDB before deploying app code that depends on new
292
+ relations or permissions.
293
+
294
+ For additive changes, apply the schema first, then deploy the app. For
295
+ destructive changes, use expand/contract:
296
+
297
+ 1. Add the new relation or permission while keeping the old one.
298
+ 2. Deploy app code that dual-writes or dual-reads as needed.
299
+ 3. Backfill relationships idempotently with `TOUCH` writes or `access:reconcile`.
300
+ 4. Switch permission expressions and app checks to the new shape.
301
+ 5. Stop old writes, delete old relationships, then remove the old relation in a later schema apply.
@@ -10,7 +10,7 @@ Create a new schema file alongside the existing ones:
10
10
 
11
11
  ```typescript
12
12
  import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
13
- import { users } from "./auth/users";
13
+ import { users } from "@__REPO_NAME__/auth/schema";
14
14
 
15
15
  export const documents = pgTable("documents", {
16
16
  id: uuid("id").defaultRandom().primaryKey(),
@@ -1,4 +1,20 @@
1
1
  services:
2
+ spicedb:
3
+ image: authzed/spicedb:v1.47.1
4
+ command:
5
+ - serve
6
+ - --datastore-engine
7
+ - memory
8
+ - --grpc-preshared-key
9
+ - dev-spicedb-token
10
+ ports:
11
+ - "50051:50051"
12
+ healthcheck:
13
+ test: ["CMD", "grpc_health_probe", "-addr=localhost:50051"]
14
+ interval: 2s
15
+ timeout: 2s
16
+ retries: 30
17
+
2
18
  postgres:
3
19
  image: postgres:16
4
20
  ports:
@@ -14,10 +14,19 @@ DATABASE_USE_SSL=false
14
14
  # Authentication (Better Auth)
15
15
  BETTER_AUTH_SECRET=generate-with-openssl-rand-base64-32
16
16
  BETTER_AUTH_URL=http://localhost:3000
17
+ # Optional override for the shared customer auth database. Defaults to the
18
+ # customer monorepo database generated in ../../auth.
19
+ # AUTH_DATABASE_NAME=
20
+ # AUTH_DATABASE_URL=
17
21
 
18
22
  # Security
19
23
  ENCRYPTION_SECRET_KEY=generate-with-node-e-console-log-require-crypto-randomBytes-16-toString-hex
20
24
 
25
+ # Access Control (SpiceDB)
26
+ SPICEDB_ENDPOINT=localhost:50051
27
+ SPICEDB_PRESHARED_KEY=dev-spicedb-token
28
+ SPICEDB_INSECURE=true
29
+
21
30
  # Inngest (Background Jobs)
22
31
  INNGEST_BASE_URL=http://localhost:8288
23
32
  # INNGEST_SIGNING_KEY=
@@ -9,6 +9,7 @@ const nextConfig: NextConfig = {
9
9
  // Enable standalone output for Docker:
10
10
  output: "standalone",
11
11
  outputFileTracingRoot: monorepoRoot,
12
+ transpilePackages: ["@__REPO_NAME__/auth"],
12
13
  turbopack: {
13
14
  root: monorepoRoot,
14
15
  },