@percepta/create 3.1.5 → 3.3.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 +53 -46
- package/dist/index.js.map +1 -1
- package/dist/{init-OeK4Yk6_.js → init-CtCp7Tv2.js} +3 -3
- package/dist/init-CtCp7Tv2.js.map +1 -0
- package/dist/{status-DC8mvHZj.js → status-CKe4aKso.js} +2 -2
- package/dist/{status-DC8mvHZj.js.map → status-CKe4aKso.js.map} +1 -1
- package/dist/{sync-C5Pd32VM.js → sync-D1vkoofl.js} +2 -2
- package/dist/{sync-C5Pd32VM.js.map → sync-D1vkoofl.js.map} +1 -1
- package/dist/{upstream-F6m8zRBQ.js → upstream-D-LH_1z4.js} +2 -2
- package/dist/{upstream-F6m8zRBQ.js.map → upstream-D-LH_1z4.js.map} +1 -1
- package/package.json +2 -2
- package/template-versions.json +1 -1
- package/templates/monorepo/.github/workflows/access-control.yml +38 -0
- package/templates/monorepo/README.md +42 -2
- package/templates/monorepo/access/README.md +39 -0
- package/templates/monorepo/access/bootstrap-grants.yaml.example +9 -0
- package/templates/monorepo/access/dev-grants.yaml.example +19 -0
- package/templates/monorepo/access/dev-groups.yaml.example +8 -0
- package/templates/monorepo/access/reconcile.yaml.example +11 -0
- package/templates/monorepo/auth/README.md +27 -0
- package/templates/monorepo/auth/drizzle.config.ts +13 -0
- package/templates/monorepo/auth/package.json +29 -0
- package/templates/monorepo/auth/scripts/setup-database.ts +11 -0
- package/templates/monorepo/auth/src/auth.ts +47 -0
- package/templates/monorepo/auth/src/config/database.ts +15 -0
- package/templates/monorepo/auth/src/drizzle/db.ts +8 -0
- package/templates/monorepo/auth/src/drizzle/migrations/0000_shared_auth.sql +89 -0
- package/templates/monorepo/auth/src/drizzle/migrations/meta/_journal.json +13 -0
- package/templates/monorepo/auth/src/drizzle/schema/auth/accounts.ts +7 -0
- package/templates/monorepo/auth/src/drizzle/schema/auth/sessions.ts +7 -0
- package/templates/monorepo/auth/src/drizzle/schema/auth/verifications.ts +6 -0
- package/templates/monorepo/auth/src/drizzle/schema/groups.ts +16 -0
- package/templates/monorepo/auth/src/drizzle/schema/index.ts +5 -0
- package/templates/monorepo/auth/src/drizzle/schema/users.ts +6 -0
- package/templates/monorepo/auth/src/index.ts +1 -0
- package/templates/monorepo/auth/src/scim/README.md +6 -0
- package/templates/monorepo/auth/tsconfig.json +12 -0
- package/templates/monorepo/package.json.template +18 -6
- package/templates/monorepo/pnpm-workspace.yaml +1 -0
- package/templates/webapp/AGENTS.md +13 -6
- package/templates/webapp/README.md +34 -18
- package/templates/webapp/agent-skills/access-control.md +301 -0
- package/templates/webapp/agent-skills/database.md +1 -1
- package/templates/webapp/docker-compose.yml +16 -0
- package/templates/webapp/env.example.template +9 -0
- package/templates/webapp/next.config.ts +1 -0
- package/templates/webapp/package.json.template +8 -4
- package/templates/webapp/scripts/seed.ts +87 -36
- package/templates/webapp/scripts/setup-database.ts +7 -1
- package/templates/webapp/scripts/start.sh +0 -9
- package/templates/webapp/src/access/access.manifest.ts +15 -0
- package/templates/webapp/src/access/schema.zed +7 -0
- package/templates/webapp/src/app/(app)/admin/_lib/PrincipalRoleTable.tsx +113 -0
- package/templates/webapp/src/app/(app)/admin/_lib/accessAdmin.ts +85 -0
- package/templates/webapp/src/app/(app)/admin/groups/page.tsx +117 -0
- package/templates/webapp/src/app/(app)/admin/users/page.tsx +79 -0
- package/templates/webapp/src/app/(app)/layout.tsx +16 -2
- package/templates/webapp/src/app/(app)/page.tsx +1 -12
- package/templates/webapp/src/app/(auth)/auth/signin/page.tsx +2 -5
- package/templates/webapp/src/app/(auth)/auth/signup/page.tsx +2 -5
- package/templates/webapp/src/config/getEnvConfig.ts +8 -0
- package/templates/webapp/src/drizzle/db.ts +3 -4
- package/templates/webapp/src/drizzle/migrations/0000_eager_grandmaster.sql +1 -57
- package/templates/webapp/src/drizzle/migrations/meta/0000_snapshot.json +1 -347
- package/templates/webapp/src/drizzle/schema/index.ts +3 -4
- package/templates/webapp/src/lib/auth/index.ts +6 -81
- package/templates/webapp/src/server/api/root.ts +4 -1
- package/templates/webapp/src/server/api/routers/access.ts +13 -0
- package/templates/webapp/src/server/trpc.ts +42 -8
- package/templates/webapp/src/services/DatabaseService.ts +4 -5
- package/templates/webapp/src/services/access/AppAccessControl.ts +39 -0
- package/dist/init-OeK4Yk6_.js.map +0 -1
- package/templates/webapp/scripts/create-user.ts +0 -47
- package/templates/webapp/src/drizzle/schema/auth/accounts.ts +0 -33
- package/templates/webapp/src/drizzle/schema/auth/sessions.ts +0 -25
- package/templates/webapp/src/drizzle/schema/auth/users.ts +0 -38
- package/templates/webapp/src/drizzle/schema/auth/verifications.ts +0 -19
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"upstream-
|
|
1
|
+
{"version":3,"file":"upstream-D-LH_1z4.js","names":[],"sources":["../src/commands/upstream.ts"],"sourcesContent":["import path from \"node:path\";\nimport chalk from \"chalk\";\nimport fs from \"fs-extra\";\nimport { getFileAtTag } from \"../utils/git-ops.js\";\nimport {\n readManifest,\n resolveMosaicTemplatePath,\n type MosaicManifest,\n} from \"../utils/manifest.js\";\n\nexport interface UpstreamOptions {\n mosaicTemplatePath?: string;\n files?: string[];\n}\n\nasync function generateUpstreamContext(\n manifest: MosaicManifest,\n mosaicTemplatePath: string,\n tag: string,\n appDir: string,\n files: string[],\n): Promise<string> {\n let content = `# Mosaic Upstream Context\n\n## App Info\n- **App name:** ${manifest.placeholders.__APP_NAME__ || \"unknown\"}\n- **Template:** ${manifest.templateType}\n- **Template version:** ${manifest.templateVersion}\n\n## Placeholder Mappings\n\nWhen generalizing app code back to template, replace these values with placeholder tokens:\n\n| Value | Placeholder |\n|-------|------------|\n${Object.entries(manifest.placeholders)\n .sort((a, b) => b[1].length - a[1].length) // longest first to avoid partial matches\n .map(([k, v]) => `| \\`${v}\\` | \\`${k}\\` |`)\n .join(\"\\n\")}\n\n## Files to Review\n\n`;\n\n for (const file of files) {\n const appFilePath = path.resolve(appDir, file);\n const templateRelPath = `${manifest.source.templatePath}/${file}`;\n\n const appContent = (await fs.pathExists(appFilePath))\n ? await fs.readFile(appFilePath, \"utf-8\")\n : null;\n const templateContent = getFileAtTag(\n mosaicTemplatePath,\n tag,\n templateRelPath,\n );\n\n content += `### ${file}\\n\\n`;\n\n if (!templateContent && appContent) {\n content += `**New file** (not in template at ${manifest.templateVersion})\\n\\n`;\n content += `\\`\\`\\`\\n${appContent}\\n\\`\\`\\`\\n\\n`;\n } else if (templateContent && !appContent) {\n content += `**Deleted** (exists in template but not in app)\\n\\n`;\n } else if (appContent && templateContent) {\n content += `**App version:**\\n\\`\\`\\`\\n${appContent}\\n\\`\\`\\`\\n\\n`;\n content += `**Template version (at ${manifest.templateVersion}):**\\n\\`\\`\\`\\n${templateContent}\\n\\`\\`\\`\\n\\n`;\n } else {\n content += `**Not found** (file does not exist in app or template)\\n\\n`;\n }\n }\n\n content += `## Instructions\n\n1. Review each file above\n2. Determine which changes are generalizable (useful for all apps) vs app-specific\n3. For generalizable changes: apply them to the template at \\`${manifest.source.templatePath}/\\`\n4. When applying, replace app-specific values with placeholders using the mapping table above (replace longest values first)\n5. After applying, bump the version in \\`packages/create-mosaic-module/template-versions.json\\`\n6. Run \\`pnpm template:tag\\` to create the new version tag\n7. Delete this file (\\`.mosaic-upstream-context.md\\`) when done\n`;\n\n return content;\n}\n\nexport async function upstreamCommand(options: UpstreamOptions): Promise<void> {\n const cwd = process.cwd();\n\n try {\n const manifest = await readManifest(cwd);\n const mosaicTemplatePath = resolveMosaicTemplatePath(options);\n\n if (!options.files || options.files.length === 0) {\n console.error(\n chalk.red(\"Specify files with --files <file1> <file2> ...\"),\n );\n console.log(\n chalk.dim(\n \" Example: create upstream --files src/config/getEnvConfig.ts\",\n ),\n );\n process.exit(1);\n }\n\n const tag = `template/${manifest.templateType}/${manifest.templateVersion}`;\n\n const context = await generateUpstreamContext(\n manifest,\n mosaicTemplatePath,\n tag,\n cwd,\n options.files,\n );\n const contextPath = path.join(cwd, \".mosaic-upstream-context.md\");\n await fs.writeFile(contextPath, context);\n\n console.log();\n console.log(chalk.bold(\"Upstream Context Generated\"));\n console.log();\n console.log(chalk.dim(\" Files:\"), options.files.join(\", \"));\n console.log(chalk.dim(\" Context file:\"), \".mosaic-upstream-context.md\");\n console.log();\n console.log(\"Next steps:\");\n console.log(chalk.dim(\" 1.\"), \"Open Claude Code in the mosaic repo\");\n console.log(\n chalk.dim(\" 2.\"),\n `Tell Claude: \"Read ${path.resolve(cwd, \".mosaic-upstream-context.md\")} and apply generalizable changes to the template\"`,\n );\n console.log(chalk.dim(\" 3.\"), \"Review Claude's changes to the template\");\n console.log();\n } catch (error) {\n console.error(chalk.red((error as Error).message));\n process.exit(1);\n }\n}\n"],"mappings":";;;;;;AAeA,eAAe,wBACb,UACA,oBACA,KACA,QACA,OACiB;CACjB,IAAI,UAAU;;;kBAGE,SAAS,aAAa,gBAAgB,UAAU;kBAChD,SAAS,aAAa;0BACd,SAAS,gBAAgB;;;;;;;;EAQjD,OAAO,QAAQ,SAAS,aAAa,CACpC,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,EAAE,GAAG,OAAO,CACzC,KAAK,CAAC,GAAG,OAAO,OAAO,EAAE,SAAS,EAAE,MAAM,CAC1C,KAAK,KAAK,CAAC;;;;;AAMZ,MAAK,MAAM,QAAQ,OAAO;EACxB,MAAM,cAAc,KAAK,QAAQ,QAAQ,KAAK;EAC9C,MAAM,kBAAkB,GAAG,SAAS,OAAO,aAAa,GAAG;EAE3D,MAAM,aAAc,MAAM,GAAG,WAAW,YAAY,GAChD,MAAM,GAAG,SAAS,aAAa,QAAQ,GACvC;EACJ,MAAM,kBAAkB,aACtB,oBACA,KACA,gBACD;AAED,aAAW,OAAO,KAAK;AAEvB,MAAI,CAAC,mBAAmB,YAAY;AAClC,cAAW,oCAAoC,SAAS,gBAAgB;AACxE,cAAW,WAAW,WAAW;aACxB,mBAAmB,CAAC,WAC7B,YAAW;WACF,cAAc,iBAAiB;AACxC,cAAW,6BAA6B,WAAW;AACnD,cAAW,0BAA0B,SAAS,gBAAgB,gBAAgB,gBAAgB;QAE9F,YAAW;;AAIf,YAAW;;;;gEAImD,SAAS,OAAO,aAAa;;;;;;AAO3F,QAAO;;AAGT,eAAsB,gBAAgB,SAAyC;CAC7E,MAAM,MAAM,QAAQ,KAAK;AAEzB,KAAI;EACF,MAAM,WAAW,MAAM,aAAa,IAAI;EACxC,MAAM,qBAAqB,0BAA0B,QAAQ;AAE7D,MAAI,CAAC,QAAQ,SAAS,QAAQ,MAAM,WAAW,GAAG;AAChD,WAAQ,MACN,MAAM,IAAI,iDAAiD,CAC5D;AACD,WAAQ,IACN,MAAM,IACJ,gEACD,CACF;AACD,WAAQ,KAAK,EAAE;;EAKjB,MAAM,UAAU,MAAM,wBACpB,UACA,oBACA,YALsB,SAAS,aAAa,GAAG,SAAS,mBAMxD,KACA,QAAQ,MACT;EACD,MAAM,cAAc,KAAK,KAAK,KAAK,8BAA8B;AACjE,QAAM,GAAG,UAAU,aAAa,QAAQ;AAExC,UAAQ,KAAK;AACb,UAAQ,IAAI,MAAM,KAAK,6BAA6B,CAAC;AACrD,UAAQ,KAAK;AACb,UAAQ,IAAI,MAAM,IAAI,WAAW,EAAE,QAAQ,MAAM,KAAK,KAAK,CAAC;AAC5D,UAAQ,IAAI,MAAM,IAAI,kBAAkB,EAAE,8BAA8B;AACxE,UAAQ,KAAK;AACb,UAAQ,IAAI,cAAc;AAC1B,UAAQ,IAAI,MAAM,IAAI,OAAO,EAAE,sCAAsC;AACrE,UAAQ,IACN,MAAM,IAAI,OAAO,EACjB,sBAAsB,KAAK,QAAQ,KAAK,8BAA8B,CAAC,mDACxE;AACD,UAAQ,IAAI,MAAM,IAAI,OAAO,EAAE,0CAA0C;AACzE,UAAQ,KAAK;UACN,OAAO;AACd,UAAQ,MAAM,MAAM,IAAK,MAAgB,QAAQ,CAAC;AAClD,UAAQ,KAAK,EAAE"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@percepta/create",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0",
|
|
4
4
|
"description": "Scaffold a new Mosaic package",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"@types/node": "^24.1.0",
|
|
39
39
|
"@types/validate-npm-package-name": "^4.0.2",
|
|
40
40
|
"vitest": "^4.0.0",
|
|
41
|
-
"@percepta/build": "0.
|
|
41
|
+
"@percepta/build": "1.0.0"
|
|
42
42
|
},
|
|
43
43
|
"engines": {
|
|
44
44
|
"node": ">=18.0.0"
|
package/template-versions.json
CHANGED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
name: Access Control
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request: {}
|
|
5
|
+
|
|
6
|
+
env:
|
|
7
|
+
PNPM_VERSION: 10.x
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
access-control:
|
|
11
|
+
name: Merge and Validate Access Schema
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
|
|
14
|
+
steps:
|
|
15
|
+
- name: Checkout repository
|
|
16
|
+
uses: actions/checkout@v4
|
|
17
|
+
|
|
18
|
+
- name: Setup PNPM
|
|
19
|
+
uses: pnpm/action-setup@v4
|
|
20
|
+
with:
|
|
21
|
+
version: ${{ env.PNPM_VERSION }}
|
|
22
|
+
|
|
23
|
+
- name: Setup Node.js
|
|
24
|
+
uses: actions/setup-node@v4
|
|
25
|
+
with:
|
|
26
|
+
node-version: 22
|
|
27
|
+
cache: pnpm
|
|
28
|
+
|
|
29
|
+
- name: Install dependencies
|
|
30
|
+
run: pnpm install --frozen-lockfile
|
|
31
|
+
|
|
32
|
+
- name: Merge and validate access schema
|
|
33
|
+
run: |
|
|
34
|
+
if find packages -path '*/src/access/access.manifest.ts' | grep -q .; then
|
|
35
|
+
pnpm access:validate
|
|
36
|
+
else
|
|
37
|
+
echo "No app access manifests found yet; skipping access schema validation."
|
|
38
|
+
fi
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# __APP_TITLE__
|
|
2
2
|
|
|
3
|
-
A monorepo powered by [pnpm workspaces](https://pnpm.io/workspaces).
|
|
3
|
+
A customer monorepo powered by [pnpm workspaces](https://pnpm.io/workspaces).
|
|
4
4
|
|
|
5
5
|
## Getting Started
|
|
6
6
|
|
|
@@ -11,6 +11,12 @@ pnpm install
|
|
|
11
11
|
# Run development mode for all packages
|
|
12
12
|
pnpm dev
|
|
13
13
|
|
|
14
|
+
# Set up local services, access-control topology, databases, and seed users
|
|
15
|
+
pnpm run setup
|
|
16
|
+
|
|
17
|
+
# Merge and validate customer access-control schema
|
|
18
|
+
pnpm access:validate
|
|
19
|
+
|
|
14
20
|
# Build all packages
|
|
15
21
|
pnpm build
|
|
16
22
|
|
|
@@ -24,10 +30,44 @@ pnpm lint
|
|
|
24
30
|
## Structure
|
|
25
31
|
|
|
26
32
|
```
|
|
33
|
+
access/ # Customer-level SpiceDB fixtures and generated merge artifacts
|
|
34
|
+
auth/ # Shared Better Auth users/groups package for this customer
|
|
27
35
|
packages/
|
|
28
|
-
└── your-package/
|
|
36
|
+
└── your-package/ # Application and library packages
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Access Control
|
|
40
|
+
|
|
41
|
+
Application builders define app-local Zed schemas in each package's
|
|
42
|
+
`src/access/` directory. The root access scripts merge those schemas with the
|
|
43
|
+
shared `core/*` schema and apply customer-owned grants such
|
|
44
|
+
as application owners, application members, and bootstrap customer admins.
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pnpm access:merge
|
|
48
|
+
pnpm access:validate
|
|
49
|
+
pnpm access:apply
|
|
29
50
|
```
|
|
30
51
|
|
|
52
|
+
PR CI merges the customer schema and runs static schema/manifest validation.
|
|
53
|
+
`access:apply` is reserved for trusted deploy jobs and should run once per
|
|
54
|
+
target environment with that environment's SpiceDB credentials.
|
|
55
|
+
|
|
56
|
+
For local development, copy the example fixture files in `access/`, fill in
|
|
57
|
+
customer-global user/group IDs from `auth/`, then run:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pnpm access:seed-grants
|
|
61
|
+
pnpm access:bootstrap-customer-admin -- --subject core/user:<user-id>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Shared Auth
|
|
65
|
+
|
|
66
|
+
The `auth/` workspace wires the customer-global Better Auth schema from
|
|
67
|
+
`@percepta/auth`, including `users`, `groups`, and `group_members`. Apps should
|
|
68
|
+
consume this shared identity layer instead of creating app-local users or
|
|
69
|
+
groups.
|
|
70
|
+
|
|
31
71
|
## Adding a new package
|
|
32
72
|
|
|
33
73
|
Create a new directory in `packages/` with its own `package.json`:
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Customer Access Control
|
|
2
|
+
|
|
3
|
+
This directory owns the customer-level SpiceDB deployment surface:
|
|
4
|
+
|
|
5
|
+
- `pnpm access:merge` combines every app's `src/access/schema.zed` with the shared core schema.
|
|
6
|
+
- `pnpm access:validate` validates the merged schema and app manifests.
|
|
7
|
+
- `pnpm access:apply` writes the merged schema and stable application topology links to SpiceDB.
|
|
8
|
+
- `pnpm access:seed-grants` and `pnpm access:apply-bootstrap-grants` apply YAML fixture grants.
|
|
9
|
+
- `pnpm access:bootstrap-customer-admin` creates the first direct customer-admin grant.
|
|
10
|
+
- `pnpm access:reconcile` repairs SpiceDB from an explicit local projection.
|
|
11
|
+
|
|
12
|
+
The source of truth for app-specific permissions remains the app's authored Zed
|
|
13
|
+
file. This package owns only customer-level composition and customer-admin
|
|
14
|
+
bootstrap.
|
|
15
|
+
|
|
16
|
+
## Local Bootstrap
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pnpm access:merge
|
|
20
|
+
pnpm access:validate
|
|
21
|
+
cp access/bootstrap-grants.yaml.example access/bootstrap-grants.yaml
|
|
22
|
+
pnpm access:apply-local
|
|
23
|
+
pnpm access:apply-bootstrap-grants -- --endpoint localhost:50051 --insecure --key dev-spicedb-token
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Use `core/user:<users.id>` and `core/group:<groups.id>#member` subjects. The IDs
|
|
27
|
+
come from the shared `auth/` package tables, not from per-app user tables.
|
|
28
|
+
|
|
29
|
+
## Production Promotion
|
|
30
|
+
|
|
31
|
+
Run `pnpm access:validate` in PR CI. Run `pnpm access:apply` only from trusted deploy jobs
|
|
32
|
+
with the target environment's SpiceDB credentials. Promote the same merged
|
|
33
|
+
schema artifact through environments before app code that depends on new
|
|
34
|
+
relations or permissions.
|
|
35
|
+
|
|
36
|
+
Use expand/contract for destructive changes: add the new shape, deploy
|
|
37
|
+
dual-write/dual-read code, backfill with idempotent relationship writes,
|
|
38
|
+
reconcile, then remove old relationships and schema definitions in a later
|
|
39
|
+
deploy.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Production bootstrap grants for first deploy / break-glass access.
|
|
2
|
+
# Copy to bootstrap-grants.yaml, replace IDs with auth.users.id values, then run:
|
|
3
|
+
# pnpm access:apply-bootstrap-grants
|
|
4
|
+
customerAdmins:
|
|
5
|
+
- userId: "00000000-0000-0000-0000-000000000000"
|
|
6
|
+
|
|
7
|
+
applications: []
|
|
8
|
+
appRoles: []
|
|
9
|
+
resourceRelations: []
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Local development grants. Copy to dev-grants.yaml and replace IDs with rows
|
|
2
|
+
# from the shared auth.users / auth.groups tables.
|
|
3
|
+
customerAdmins:
|
|
4
|
+
- userId: "00000000-0000-0000-0000-000000000000"
|
|
5
|
+
|
|
6
|
+
applications:
|
|
7
|
+
- appNamespace: "people_app"
|
|
8
|
+
owners:
|
|
9
|
+
- userId: "00000000-0000-0000-0000-000000000000"
|
|
10
|
+
members:
|
|
11
|
+
- groupId: "11111111-1111-1111-1111-111111111111"
|
|
12
|
+
|
|
13
|
+
appRoles:
|
|
14
|
+
- appNamespace: "people_app"
|
|
15
|
+
role: "admin"
|
|
16
|
+
subjects:
|
|
17
|
+
- groupId: "11111111-1111-1111-1111-111111111111"
|
|
18
|
+
|
|
19
|
+
resourceRelations: []
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Future fixture source for the auth-owned groupSync adapter.
|
|
2
|
+
# SCIM/JIT is the production source of truth; this file is only for local dev.
|
|
3
|
+
groups:
|
|
4
|
+
- externalId: "dev-group-admins"
|
|
5
|
+
name: "Development Admins"
|
|
6
|
+
source: "fixture"
|
|
7
|
+
members:
|
|
8
|
+
- userExternalId: "dev-admin"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Explicit local projection for access:reconcile.
|
|
2
|
+
applications:
|
|
3
|
+
- appNamespace: "people_app"
|
|
4
|
+
|
|
5
|
+
groupMemberships:
|
|
6
|
+
- groupId: "11111111-1111-1111-1111-111111111111"
|
|
7
|
+
userIds:
|
|
8
|
+
- "00000000-0000-0000-0000-000000000000"
|
|
9
|
+
|
|
10
|
+
deletedUserIds: []
|
|
11
|
+
deletedGroupIds: []
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Shared Auth
|
|
2
|
+
|
|
3
|
+
This workspace wires the customer-global Better Auth database schema from
|
|
4
|
+
`@percepta/auth`. Apps in the customer monorepo should import this package for
|
|
5
|
+
session validation and user / group table references instead of creating
|
|
6
|
+
app-local auth tables.
|
|
7
|
+
|
|
8
|
+
Import auth as `@__APP_NAME__/auth`, the database handle as
|
|
9
|
+
`@__APP_NAME__/auth/db`, and table definitions as `@__APP_NAME__/auth/schema`
|
|
10
|
+
from app packages.
|
|
11
|
+
|
|
12
|
+
The important identity invariant is:
|
|
13
|
+
|
|
14
|
+
- `core/user:<id>` uses `users.id` from this package.
|
|
15
|
+
- `core/group:<id>#member` uses `groups.id` from this package.
|
|
16
|
+
- `group_members` is the local projection that reconcile uses to repair
|
|
17
|
+
SpiceDB group membership relationships.
|
|
18
|
+
|
|
19
|
+
SCIM/JIT protocol handlers are intentionally not implemented here yet; they
|
|
20
|
+
should feed the access-control `groupSync` ingestion contract when that adapter
|
|
21
|
+
lands.
|
|
22
|
+
|
|
23
|
+
## Database
|
|
24
|
+
|
|
25
|
+
By default this package uses the customer monorepo database name generated at
|
|
26
|
+
scaffold time. Override with `AUTH_DATABASE_NAME` or `AUTH_DATABASE_URL` when
|
|
27
|
+
the shared auth database lives somewhere else.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Config } from "drizzle-kit";
|
|
2
|
+
import { getAuthDatabaseConnectionString } from "./src/config/database";
|
|
3
|
+
|
|
4
|
+
const config: Config = {
|
|
5
|
+
schema: "./src/drizzle/schema",
|
|
6
|
+
out: "./src/drizzle/migrations",
|
|
7
|
+
dialect: "postgresql",
|
|
8
|
+
dbCredentials: {
|
|
9
|
+
url: getAuthDatabaseConnectionString(),
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export default config;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@__APP_NAME__/auth",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Shared customer identity package.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./db": "./src/drizzle/db.ts",
|
|
10
|
+
"./schema": "./src/drizzle/schema/index.ts"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc -p tsconfig.json",
|
|
14
|
+
"db:generate": "drizzle-kit generate",
|
|
15
|
+
"db:migrate": "drizzle-kit migrate",
|
|
16
|
+
"db:setup": "tsx ./scripts/setup-database.ts",
|
|
17
|
+
"db:setup-and-migrate": "pnpm db:setup && pnpm db:migrate"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@percepta/auth": "0.1.0",
|
|
21
|
+
"drizzle-orm": "^0.45.2"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^24.1.0",
|
|
25
|
+
"drizzle-kit": "^0.31.4",
|
|
26
|
+
"tsx": "^4.20.3",
|
|
27
|
+
"typescript": "^5.8.3"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { setupAuthDatabase } from "@percepta/auth";
|
|
2
|
+
import { getAuthDatabaseConfig } from "../src/config/database";
|
|
3
|
+
|
|
4
|
+
async function main(): Promise<void> {
|
|
5
|
+
await setupAuthDatabase({ config: getAuthDatabaseConfig() });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
void main().catch((error) => {
|
|
9
|
+
console.error("Shared auth database setup failed:", error);
|
|
10
|
+
process.exit(1);
|
|
11
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { createLazyAuth, createPerceptaAuth } from "@percepta/auth/better-auth";
|
|
2
|
+
import { db } from "./drizzle/db";
|
|
3
|
+
import { accounts } from "./drizzle/schema/auth/accounts";
|
|
4
|
+
import { sessions } from "./drizzle/schema/auth/sessions";
|
|
5
|
+
import { verifications } from "./drizzle/schema/auth/verifications";
|
|
6
|
+
import { users } from "./drizzle/schema/users";
|
|
7
|
+
|
|
8
|
+
// eslint-disable-next-line n/no-process-env -- detecting Next.js build phase
|
|
9
|
+
const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build";
|
|
10
|
+
|
|
11
|
+
function requiredEnv(name: string): string {
|
|
12
|
+
const value = process.env[name];
|
|
13
|
+
if (value == null || value.length === 0) {
|
|
14
|
+
throw new Error(`${name} is required.`);
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getSecret(): string {
|
|
20
|
+
if (isBuildPhase) {
|
|
21
|
+
return "build-placeholder-not-used-at-runtime";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return requiredEnv("BETTER_AUTH_SECRET");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function createAuth() {
|
|
28
|
+
return createPerceptaAuth({
|
|
29
|
+
baseURL: process.env.BETTER_AUTH_URL ?? "http://localhost:3000",
|
|
30
|
+
database: db,
|
|
31
|
+
schema: {
|
|
32
|
+
user: users,
|
|
33
|
+
session: sessions,
|
|
34
|
+
account: accounts,
|
|
35
|
+
verification: verifications,
|
|
36
|
+
},
|
|
37
|
+
secret: getSecret(),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Lazy proxy so app builds can import the shared auth package without requiring
|
|
43
|
+
* runtime secrets until Better Auth is actually used.
|
|
44
|
+
*/
|
|
45
|
+
export const auth = createLazyAuth(createAuth);
|
|
46
|
+
|
|
47
|
+
export type BetterAuthSession = typeof auth.$Infer.Session;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createAuthDatabaseConnectionString,
|
|
3
|
+
readAuthDatabaseConfig,
|
|
4
|
+
type AuthDatabaseConfig,
|
|
5
|
+
} from "@percepta/auth";
|
|
6
|
+
|
|
7
|
+
export type { AuthDatabaseConfig } from "@percepta/auth";
|
|
8
|
+
|
|
9
|
+
export function getAuthDatabaseConfig(): AuthDatabaseConfig {
|
|
10
|
+
return readAuthDatabaseConfig({ defaultDatabaseName: "__DB_NAME__" });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getAuthDatabaseConnectionString(): string {
|
|
14
|
+
return createAuthDatabaseConnectionString(getAuthDatabaseConfig());
|
|
15
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { createAuthDatabase } from "@percepta/auth/drizzle";
|
|
2
|
+
import { getAuthDatabaseConnectionString } from "../config/database";
|
|
3
|
+
import * as schema from "./schema";
|
|
4
|
+
|
|
5
|
+
export const { client, db } = createAuthDatabase({
|
|
6
|
+
connectionString: getAuthDatabaseConnectionString(),
|
|
7
|
+
schema,
|
|
8
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
CREATE TABLE "users" (
|
|
2
|
+
"id" uuid PRIMARY KEY NOT NULL,
|
|
3
|
+
"external_id" text,
|
|
4
|
+
"name" text NOT NULL,
|
|
5
|
+
"email" text NOT NULL,
|
|
6
|
+
"email_verified" boolean DEFAULT false NOT NULL,
|
|
7
|
+
"image" text,
|
|
8
|
+
"role" text DEFAULT 'user' NOT NULL,
|
|
9
|
+
"banned" boolean DEFAULT false,
|
|
10
|
+
"ban_reason" text,
|
|
11
|
+
"ban_expires" timestamp,
|
|
12
|
+
"created_at" timestamp DEFAULT now() NOT NULL,
|
|
13
|
+
"updated_at" timestamp DEFAULT now() NOT NULL,
|
|
14
|
+
CONSTRAINT "users_email_unique" UNIQUE("email")
|
|
15
|
+
);
|
|
16
|
+
--> statement-breakpoint
|
|
17
|
+
CREATE TABLE "account" (
|
|
18
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
19
|
+
"user_id" uuid NOT NULL,
|
|
20
|
+
"account_id" text NOT NULL,
|
|
21
|
+
"provider_id" text NOT NULL,
|
|
22
|
+
"access_token" text,
|
|
23
|
+
"refresh_token" text,
|
|
24
|
+
"expires_at" integer,
|
|
25
|
+
"access_token_expires_at" timestamp,
|
|
26
|
+
"refresh_token_expires_at" timestamp,
|
|
27
|
+
"scope" text,
|
|
28
|
+
"id_token" text,
|
|
29
|
+
"password" text,
|
|
30
|
+
"created_at" timestamp NOT NULL,
|
|
31
|
+
"updated_at" timestamp NOT NULL
|
|
32
|
+
);
|
|
33
|
+
--> statement-breakpoint
|
|
34
|
+
CREATE TABLE "session" (
|
|
35
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
36
|
+
"user_id" uuid NOT NULL,
|
|
37
|
+
"token" text NOT NULL,
|
|
38
|
+
"expires_at" timestamp NOT NULL,
|
|
39
|
+
"ip_address" text,
|
|
40
|
+
"user_agent" text,
|
|
41
|
+
"impersonated_by" text,
|
|
42
|
+
"created_at" timestamp NOT NULL,
|
|
43
|
+
"updated_at" timestamp NOT NULL,
|
|
44
|
+
CONSTRAINT "session_token_unique" UNIQUE("token")
|
|
45
|
+
);
|
|
46
|
+
--> statement-breakpoint
|
|
47
|
+
CREATE TABLE "verification" (
|
|
48
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
49
|
+
"identifier" text NOT NULL,
|
|
50
|
+
"value" text NOT NULL,
|
|
51
|
+
"expires_at" timestamp NOT NULL,
|
|
52
|
+
"created_at" timestamp,
|
|
53
|
+
"updated_at" timestamp
|
|
54
|
+
);
|
|
55
|
+
--> statement-breakpoint
|
|
56
|
+
CREATE TABLE "groups" (
|
|
57
|
+
"id" uuid PRIMARY KEY NOT NULL,
|
|
58
|
+
"external_id" text NOT NULL,
|
|
59
|
+
"name" text NOT NULL,
|
|
60
|
+
"source" text NOT NULL,
|
|
61
|
+
"deleted_at" timestamp,
|
|
62
|
+
"created_at" timestamp DEFAULT now() NOT NULL,
|
|
63
|
+
"updated_at" timestamp DEFAULT now() NOT NULL
|
|
64
|
+
);
|
|
65
|
+
--> statement-breakpoint
|
|
66
|
+
CREATE TABLE "group_members" (
|
|
67
|
+
"group_id" uuid NOT NULL,
|
|
68
|
+
"user_id" uuid NOT NULL,
|
|
69
|
+
"created_at" timestamp DEFAULT now() NOT NULL,
|
|
70
|
+
CONSTRAINT "group_members_pkey" PRIMARY KEY("group_id","user_id")
|
|
71
|
+
);
|
|
72
|
+
--> statement-breakpoint
|
|
73
|
+
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade ON UPDATE no action;
|
|
74
|
+
--> statement-breakpoint
|
|
75
|
+
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade ON UPDATE no action;
|
|
76
|
+
--> statement-breakpoint
|
|
77
|
+
ALTER TABLE "group_members" ADD CONSTRAINT "group_members_group_id_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "groups"("id") ON DELETE cascade ON UPDATE no action;
|
|
78
|
+
--> statement-breakpoint
|
|
79
|
+
ALTER TABLE "group_members" ADD CONSTRAINT "group_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade ON UPDATE no action;
|
|
80
|
+
--> statement-breakpoint
|
|
81
|
+
CREATE UNIQUE INDEX "users_lower_email_index" ON "users" USING btree (lower("email"));
|
|
82
|
+
--> statement-breakpoint
|
|
83
|
+
CREATE UNIQUE INDEX "users_external_id_index" ON "users" USING btree ("external_id") WHERE "users"."external_id" IS NOT NULL;
|
|
84
|
+
--> statement-breakpoint
|
|
85
|
+
CREATE UNIQUE INDEX "groups_live_external_id_index" ON "groups" USING btree ("external_id") WHERE "groups"."deleted_at" IS NULL;
|
|
86
|
+
--> statement-breakpoint
|
|
87
|
+
CREATE INDEX "groups_source_index" ON "groups" USING btree ("source");
|
|
88
|
+
--> statement-breakpoint
|
|
89
|
+
CREATE INDEX "group_members_user_id_index" ON "group_members" USING btree ("user_id");
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { createAccountsTable } from "@percepta/auth/drizzle";
|
|
2
|
+
import { users } from "../users";
|
|
3
|
+
|
|
4
|
+
export const accounts = createAccountsTable({ usersTable: users });
|
|
5
|
+
|
|
6
|
+
export type Account = typeof accounts.$inferSelect;
|
|
7
|
+
export type NewAccount = typeof accounts.$inferInsert;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { createSessionsTable } from "@percepta/auth/drizzle";
|
|
2
|
+
import { users } from "../users";
|
|
3
|
+
|
|
4
|
+
export const sessions = createSessionsTable({ usersTable: users });
|
|
5
|
+
|
|
6
|
+
export type Session = typeof sessions.$inferSelect;
|
|
7
|
+
export type NewSession = typeof sessions.$inferInsert;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createGroupMembersTable,
|
|
3
|
+
createGroupsTable,
|
|
4
|
+
} from "@percepta/auth/drizzle";
|
|
5
|
+
import { users } from "./users";
|
|
6
|
+
|
|
7
|
+
export const groups = createGroupsTable();
|
|
8
|
+
export const groupMembers = createGroupMembersTable({
|
|
9
|
+
groupsTable: groups,
|
|
10
|
+
usersTable: users,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export type Group = typeof groups.$inferSelect;
|
|
14
|
+
export type NewGroup = typeof groups.$inferInsert;
|
|
15
|
+
export type GroupMember = typeof groupMembers.$inferSelect;
|
|
16
|
+
export type NewGroupMember = typeof groupMembers.$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
|
-
"
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
"lint
|
|
13
|
-
"
|
|
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",
|