@percepta/create 3.2.0 → 3.4.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 (41) hide show
  1. package/README.md +3 -3
  2. package/package.json +2 -2
  3. package/templates/monorepo/README.md +9 -6
  4. package/templates/monorepo/access/dev-grants.yaml.example +3 -8
  5. package/templates/monorepo/auth/README.md +4 -3
  6. package/templates/monorepo/auth/package.json +2 -5
  7. package/templates/monorepo/auth/scripts/setup-database.ts +2 -48
  8. package/templates/monorepo/auth/src/auth.ts +10 -40
  9. package/templates/monorepo/auth/src/config/database.ts +9 -25
  10. package/templates/monorepo/auth/src/drizzle/db.ts +3 -4
  11. package/templates/monorepo/auth/src/drizzle/schema/auth/accounts.ts +2 -23
  12. package/templates/monorepo/auth/src/drizzle/schema/auth/sessions.ts +2 -16
  13. package/templates/monorepo/auth/src/drizzle/schema/auth/verifications.ts +2 -11
  14. package/templates/monorepo/auth/src/drizzle/schema/groups.ts +1 -1
  15. package/templates/monorepo/auth/src/drizzle/schema/users.ts +1 -1
  16. package/templates/monorepo/package.json.template +1 -1
  17. package/templates/webapp/AGENTS.md +6 -7
  18. package/templates/webapp/README.md +5 -3
  19. package/templates/webapp/agent-skills/access-control.md +15 -22
  20. package/templates/webapp/eslint.config.mjs +35 -7
  21. package/templates/webapp/package.json.template +4 -1
  22. package/templates/webapp/scripts/seed.ts +71 -24
  23. package/templates/webapp/src/access/access.manifest.ts +14 -1
  24. package/templates/webapp/src/access/schema.zed +1 -5
  25. package/templates/webapp/src/app/(admin)/admin/_components/AdminTabs.tsx +33 -0
  26. package/templates/webapp/src/app/(admin)/admin/_components/PrincipalMultiInput.tsx +248 -0
  27. package/templates/webapp/src/app/(admin)/admin/_lib/accessAdmin.ts +62 -0
  28. package/templates/webapp/src/app/(admin)/admin/page.tsx +683 -0
  29. package/templates/webapp/src/app/(admin)/layout.tsx +43 -0
  30. package/templates/webapp/src/app/(app)/layout.tsx +13 -3
  31. package/templates/webapp/src/app/(auth)/auth/signin/CredentialsSignInForm.tsx +1 -1
  32. package/templates/webapp/src/app/global-error.tsx +1 -2
  33. package/templates/webapp/src/components/Header.tsx +23 -2
  34. package/templates/webapp/src/server/api/routers/access.ts +2 -2
  35. package/templates/webapp/src/services/access/AppAccessControl.ts +49 -0
  36. package/templates/webapp/src/services/inngest/events/AppEvents.ts +2 -2
  37. package/templates/webapp/src/services/inngest/events/payloads/ExampleEventPayload.ts +6 -10
  38. package/templates/webapp/src/app/(app)/admin/_lib/PrincipalRoleTable.tsx +0 -113
  39. package/templates/webapp/src/app/(app)/admin/_lib/accessAdmin.ts +0 -85
  40. package/templates/webapp/src/app/(app)/admin/groups/page.tsx +0 -117
  41. package/templates/webapp/src/app/(app)/admin/users/page.tsx +0 -79
package/README.md CHANGED
@@ -8,7 +8,7 @@ Scaffold and manage Mosaic packages.
8
8
  npx @percepta/create
9
9
  ```
10
10
 
11
- That's it. The CLI prompts you for the package type, repo name, and package name as needed. Defaults yield a running app — sign in as `admin@example.com` / `password`.
11
+ That's it. The CLI prompts you for the package type, repo name, and package name as needed. Defaults yield a running app — sign in as `app-admin@example.com` / `password`.
12
12
 
13
13
  ## Options (mostly for automation)
14
14
 
@@ -43,11 +43,11 @@ The bare command above is the canonical UX. The flags below exist for tests and
43
43
  When you scaffold a webapp (the default flow), `create` automatically runs:
44
44
 
45
45
  1. `pnpm install` (at the monorepo root)
46
- 2. `pnpm run setup` — Docker Compose Postgres + Drizzle migrations + seed user
46
+ 2. `pnpm run setup` — Docker Compose Postgres + Drizzle migrations + seed users
47
47
  3. `pnpm dev` — Next.js dev server
48
48
  4. Opens the served URL in your default browser
49
49
 
50
- Sign in as `admin@example.com` / `password` to start building.
50
+ Sign in as `app-admin@example.com` / `password` to start building.
51
51
 
52
52
  To bail out of the auto-run and get manual next-steps instead, pass `--skip-install`. Then you can run install / setup / dev yourself when ready.
53
53
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@percepta/create",
3
- "version": "3.2.0",
3
+ "version": "3.4.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.5.1"
41
+ "@percepta/build": "1.1.0"
42
42
  },
43
43
  "engines": {
44
44
  "node": ">=18.0.0"
@@ -41,7 +41,7 @@ packages/
41
41
  Application builders define app-local Zed schemas in each package's
42
42
  `src/access/` directory. The root access scripts merge those schemas with the
43
43
  shared `core/*` schema and apply customer-owned grants such
44
- as application owners, application members, and bootstrap customer admins.
44
+ as application admins, application users, and bootstrap customer admins.
45
45
 
46
46
  ```bash
47
47
  pnpm access:merge
@@ -53,8 +53,10 @@ PR CI merges the customer schema and runs static schema/manifest validation.
53
53
  `access:apply` is reserved for trusted deploy jobs and should run once per
54
54
  target environment with that environment's SpiceDB credentials.
55
55
 
56
- For local development, copy the example fixture files in `access/`, fill in
57
- customer-global user/group IDs from `auth/`, then run:
56
+ For local development, `pnpm run setup` seeds the generated app's default dev
57
+ users and access grants. For additional local grants, copy the example fixture
58
+ files in `access/`, fill in customer-global user/group IDs from `auth/`, then
59
+ run:
58
60
 
59
61
  ```bash
60
62
  pnpm access:seed-grants
@@ -63,9 +65,10 @@ pnpm access:bootstrap-customer-admin -- --subject core/user:<user-id>
63
65
 
64
66
  ## Shared Auth
65
67
 
66
- The `auth/` workspace owns the customer-global Better Auth schema, including
67
- `users`, `groups`, and `group_members`. Apps should consume this shared
68
- identity layer instead of creating app-local users or groups.
68
+ The `auth/` workspace wires the customer-global Better Auth schema from
69
+ `@percepta/auth`, including `users`, `groups`, and `group_members`. Apps should
70
+ consume this shared identity layer instead of creating app-local users or
71
+ groups.
69
72
 
70
73
  ## Adding a new package
71
74
 
@@ -5,15 +5,10 @@ customerAdmins:
5
5
 
6
6
  applications:
7
7
  - appNamespace: "people_app"
8
- owners:
8
+ admins:
9
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:
10
+ users:
17
11
  - groupId: "11111111-1111-1111-1111-111111111111"
18
12
 
13
+ appRoles: []
19
14
  resourceRelations: []
@@ -1,8 +1,9 @@
1
1
  # Shared Auth
2
2
 
3
- This workspace owns the customer-global Better Auth database schema. Apps in the
4
- customer monorepo should import this package for session validation and user /
5
- group table references instead of creating app-local auth tables.
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.
6
7
 
7
8
  Import auth as `@__APP_NAME__/auth`, the database handle as
8
9
  `@__APP_NAME__/auth/db`, and table definitions as `@__APP_NAME__/auth/schema`
@@ -17,14 +17,11 @@
17
17
  "db:setup-and-migrate": "pnpm db:setup && pnpm db:migrate"
18
18
  },
19
19
  "dependencies": {
20
- "@percepta/access-control": "0.3.2",
21
- "better-auth": "^1.6.4",
22
- "drizzle-orm": "^0.45.2",
23
- "pg": "^8.16.3"
20
+ "@percepta/auth": "0.1.0",
21
+ "drizzle-orm": "^0.45.2"
24
22
  },
25
23
  "devDependencies": {
26
24
  "@types/node": "^24.1.0",
27
- "@types/pg": "^8.15.4",
28
25
  "drizzle-kit": "^0.31.4",
29
26
  "tsx": "^4.20.3",
30
27
  "typescript": "^5.8.3"
@@ -1,54 +1,8 @@
1
- import { Pool } from "pg";
1
+ import { setupAuthDatabase } from "@percepta/auth";
2
2
  import { getAuthDatabaseConfig } from "../src/config/database";
3
3
 
4
- const SHARED_DATABASES = new Set(["demos", "internal_apps"]);
5
-
6
4
  async function main(): Promise<void> {
7
- const { database, host, password, port, url, user } = getAuthDatabaseConfig();
8
-
9
- if (url != null && url.length > 0) {
10
- console.log("AUTH_DATABASE_URL is set; skipping CREATE DATABASE.");
11
- return;
12
- }
13
-
14
- console.log(`Setting up shared auth database: ${database}`);
15
- console.log(`Host: ${host}:${port}`);
16
- console.log(`User: ${user}`);
17
-
18
- if (SHARED_DATABASES.has(database)) {
19
- console.log(`${database} is a shared infra-managed database; skipping.`);
20
- return;
21
- }
22
-
23
- const adminClient = new Pool({
24
- database: "postgres",
25
- host,
26
- password,
27
- port,
28
- user,
29
- });
30
-
31
- try {
32
- const result = await adminClient.query(
33
- "SELECT 1 FROM pg_database WHERE datname = $1",
34
- [database],
35
- );
36
-
37
- if (result.rows.length === 0) {
38
- await adminClient.query(
39
- `CREATE DATABASE ${escapePgIdentifier(database)}`,
40
- );
41
- console.log(`Database ${database} created successfully.`);
42
- } else {
43
- console.log(`Database ${database} already exists.`);
44
- }
45
- } finally {
46
- await adminClient.end();
47
- }
48
- }
49
-
50
- function escapePgIdentifier(identifier: string): string {
51
- return `"${identifier.replaceAll('"', '""')}"`;
5
+ await setupAuthDatabase({ config: getAuthDatabaseConfig() });
52
6
  }
53
7
 
54
8
  void main().catch((error) => {
@@ -1,6 +1,4 @@
1
- import { betterAuth } from "better-auth";
2
- import { drizzleAdapter } from "better-auth/adapters/drizzle";
3
- import { admin } from "better-auth/plugins";
1
+ import { createLazyAuth, createPerceptaAuth } from "@percepta/auth/better-auth";
4
2
  import { db } from "./drizzle/db";
5
3
  import { accounts } from "./drizzle/schema/auth/accounts";
6
4
  import { sessions } from "./drizzle/schema/auth/sessions";
@@ -27,51 +25,23 @@ function getSecret(): string {
27
25
  }
28
26
 
29
27
  function createAuth() {
30
- return betterAuth({
28
+ return createPerceptaAuth({
31
29
  baseURL: process.env.BETTER_AUTH_URL ?? "http://localhost:3000",
32
- secret: getSecret(),
33
- database: drizzleAdapter(db, {
34
- provider: "pg",
35
- schema: {
36
- user: users,
37
- session: sessions,
38
- account: accounts,
39
- verification: verifications,
40
- },
41
- }),
42
- emailAndPassword: {
43
- enabled: true,
44
- },
45
- plugins: [admin()],
46
- advanced: {
47
- database: {
48
- generateId: false,
49
- },
30
+ database: db,
31
+ schema: {
32
+ user: users,
33
+ session: sessions,
34
+ account: accounts,
35
+ verification: verifications,
50
36
  },
37
+ secret: getSecret(),
51
38
  });
52
39
  }
53
40
 
54
- type Auth = ReturnType<typeof createAuth>;
55
-
56
- let authInstance: Auth | undefined;
57
-
58
- function getAuth(): Auth {
59
- authInstance ??= createAuth();
60
- return authInstance;
61
- }
62
-
63
41
  /**
64
42
  * Lazy proxy so app builds can import the shared auth package without requiring
65
43
  * runtime secrets until Better Auth is actually used.
66
44
  */
67
- export const auth: Auth = new Proxy({} as Auth, {
68
- get(_target, prop, receiver) {
69
- // eslint-disable-next-line @typescript-eslint/no-unsafe-return
70
- return Reflect.get(getAuth(), prop, receiver);
71
- },
72
- has(_target, prop) {
73
- return Reflect.has(getAuth(), prop);
74
- },
75
- });
45
+ export const auth = createLazyAuth(createAuth);
76
46
 
77
47
  export type BetterAuthSession = typeof auth.$Infer.Session;
@@ -1,31 +1,15 @@
1
- export interface AuthDatabaseConfig {
2
- readonly database: string;
3
- readonly host: string;
4
- readonly password: string;
5
- readonly port: number;
6
- readonly url?: string;
7
- readonly user: string;
8
- }
1
+ import {
2
+ createAuthDatabaseConnectionString,
3
+ readAuthDatabaseConfig,
4
+ type AuthDatabaseConfig,
5
+ } from "@percepta/auth";
6
+
7
+ export type { AuthDatabaseConfig } from "@percepta/auth";
9
8
 
10
9
  export function getAuthDatabaseConfig(): AuthDatabaseConfig {
11
- return {
12
- database: process.env.AUTH_DATABASE_NAME ?? "__DB_NAME__",
13
- host: process.env.DATABASE_HOST ?? "localhost",
14
- password: process.env.DATABASE_PASSWORD ?? "postgres",
15
- port: Number(process.env.DATABASE_PORT ?? 5434),
16
- url: process.env.AUTH_DATABASE_URL,
17
- user: process.env.DATABASE_USERNAME ?? "postgres",
18
- };
10
+ return readAuthDatabaseConfig({ defaultDatabaseName: "__DB_NAME__" });
19
11
  }
20
12
 
21
13
  export function getAuthDatabaseConnectionString(): string {
22
- const { database, host, password, port, url, user } = getAuthDatabaseConfig();
23
- if (url != null && url.length > 0) {
24
- return url;
25
- }
26
-
27
- return (
28
- `postgresql://${encodeURIComponent(user)}:${encodeURIComponent(password)}` +
29
- `@${host}:${port}/${encodeURIComponent(database)}`
30
- );
14
+ return createAuthDatabaseConnectionString(getAuthDatabaseConfig());
31
15
  }
@@ -1,9 +1,8 @@
1
- import { type NodePgDatabase, drizzle } from "drizzle-orm/node-postgres";
2
- import { Pool } from "pg";
1
+ import { createAuthDatabase } from "@percepta/auth/drizzle";
3
2
  import { getAuthDatabaseConnectionString } from "../config/database";
4
3
  import * as schema from "./schema";
5
4
 
6
- export const client = new Pool({
5
+ export const { client, db } = createAuthDatabase({
7
6
  connectionString: getAuthDatabaseConnectionString(),
7
+ schema,
8
8
  });
9
- export const db: NodePgDatabase<typeof schema> = drizzle(client, { schema });
@@ -1,28 +1,7 @@
1
- import { integer, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
1
+ import { createAccountsTable } from "@percepta/auth/drizzle";
2
2
  import { users } from "../users";
3
3
 
4
- export const accounts = pgTable("account", {
5
- id: text("id")
6
- .$defaultFn(() => crypto.randomUUID())
7
- .primaryKey(),
8
- userId: uuid("user_id")
9
- .notNull()
10
- .references(() => users.id, { onDelete: "cascade" }),
11
- accountId: text("account_id").notNull(),
12
- providerId: text("provider_id").notNull(),
13
- accessToken: text("access_token"),
14
- refreshToken: text("refresh_token"),
15
- expiresAt: integer("expires_at"),
16
- accessTokenExpiresAt: timestamp("access_token_expires_at", { mode: "date" }),
17
- refreshTokenExpiresAt: timestamp("refresh_token_expires_at", {
18
- mode: "date",
19
- }),
20
- scope: text("scope"),
21
- idToken: text("id_token"),
22
- password: text("password"),
23
- createdAt: timestamp("created_at", { mode: "date" }).notNull(),
24
- updatedAt: timestamp("updated_at", { mode: "date" }).notNull(),
25
- });
4
+ export const accounts = createAccountsTable({ usersTable: users });
26
5
 
27
6
  export type Account = typeof accounts.$inferSelect;
28
7
  export type NewAccount = typeof accounts.$inferInsert;
@@ -1,21 +1,7 @@
1
- import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
1
+ import { createSessionsTable } from "@percepta/auth/drizzle";
2
2
  import { users } from "../users";
3
3
 
4
- export const sessions = pgTable("session", {
5
- id: text("id")
6
- .$defaultFn(() => crypto.randomUUID())
7
- .primaryKey(),
8
- userId: uuid("user_id")
9
- .notNull()
10
- .references(() => users.id, { onDelete: "cascade" }),
11
- token: text("token").notNull().unique(),
12
- expiresAt: timestamp("expires_at", { mode: "date" }).notNull(),
13
- ipAddress: text("ip_address"),
14
- userAgent: text("user_agent"),
15
- impersonatedBy: text("impersonated_by"),
16
- createdAt: timestamp("created_at", { mode: "date" }).notNull(),
17
- updatedAt: timestamp("updated_at", { mode: "date" }).notNull(),
18
- });
4
+ export const sessions = createSessionsTable({ usersTable: users });
19
5
 
20
6
  export type Session = typeof sessions.$inferSelect;
21
7
  export type NewSession = typeof sessions.$inferInsert;
@@ -1,15 +1,6 @@
1
- import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
1
+ import { createVerificationsTable } from "@percepta/auth/drizzle";
2
2
 
3
- export const verifications = pgTable("verification", {
4
- id: text("id")
5
- .$defaultFn(() => crypto.randomUUID())
6
- .primaryKey(),
7
- identifier: text("identifier").notNull(),
8
- value: text("value").notNull(),
9
- expiresAt: timestamp("expires_at", { mode: "date" }).notNull(),
10
- createdAt: timestamp("created_at", { mode: "date" }),
11
- updatedAt: timestamp("updated_at", { mode: "date" }),
12
- });
3
+ export const verifications = createVerificationsTable();
13
4
 
14
5
  export type Verification = typeof verifications.$inferSelect;
15
6
  export type NewVerification = typeof verifications.$inferInsert;
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  createGroupMembersTable,
3
3
  createGroupsTable,
4
- } from "@percepta/access-control/drizzle";
4
+ } from "@percepta/auth/drizzle";
5
5
  import { users } from "./users";
6
6
 
7
7
  export const groups = createGroupsTable();
@@ -1,4 +1,4 @@
1
- import { createUsersTable } from "@percepta/access-control/drizzle";
1
+ import { createUsersTable } from "@percepta/auth/drizzle";
2
2
 
3
3
  export const users = createUsersTable();
4
4
 
@@ -28,7 +28,7 @@
28
28
  "pnpm": ">=9"
29
29
  },
30
30
  "devDependencies": {
31
- "@percepta/access-control": "0.3.2",
31
+ "@percepta/access-control": "0.6.0",
32
32
  "@types/node": "^24.1.0",
33
33
  "eslint": "^9.18.0",
34
34
  "rimraf": "^5.0.5",
@@ -17,7 +17,7 @@ Next.js 15 full-stack application scaffolded from the Mosaic webapp template via
17
17
  - `pnpm db:migrate` — apply migrations
18
18
  - `pnpm db:setup-and-migrate` — create DB + migrate
19
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)
20
+ - `pnpm db:seed` — seed shared-auth dev users and local grants for customer admin, app admin, app user, and app non-user personas (all use password)
21
21
 
22
22
  **Package manager**: Always use `pnpm`, never `npm` or `yarn`.
23
23
 
@@ -109,15 +109,14 @@ Also includes composite components: `ButtonWithDropdown`, `Combobox`, `IconButto
109
109
 
110
110
  ### @percepta/build — Shared Build Configs
111
111
 
112
- Provides centralized ESLint, Prettier, TypeScript, and Vitest configuration.
112
+ Provides centralized formatter, TypeScript, bundler, and Vitest configuration.
113
113
 
114
114
  ```js
115
115
  // eslint.config.mjs
116
- import createEslintConfig from "@percepta/build/eslint";
117
- export default [
118
- ...createEslintConfig({ type: "react", dirname: import.meta.dirname }),
119
- // app-specific overrides...
120
- ];
116
+ import eslint from "@eslint/js";
117
+ import tseslint from "typescript-eslint";
118
+
119
+ export default tseslint.config(eslint.configs.recommended, ...tseslint.configs.recommended);
121
120
  ```
122
121
 
123
122
  ```json
@@ -134,11 +134,13 @@ BETTER_AUTH_SECRET=generate-with-openssl-rand-base64-32
134
134
  BETTER_AUTH_URL=http://localhost:3000
135
135
  ```
136
136
 
137
- To create a dev user:
137
+ To create dev users:
138
138
  ```bash
139
139
  pnpm db:seed
140
- # Creates admin@example.com / password as an app admin
141
- # Creates user@example.com / password as a non-admin app member
140
+ # Creates customer-admin@example.com / password as a customer admin
141
+ # Creates app-admin@example.com / password as an app admin
142
+ # Creates app-user@example.com / password as an app user
143
+ # Creates non-user@example.com / password with no app access
142
144
  ```
143
145
 
144
146
  ## Access Control
@@ -15,10 +15,10 @@ There are three separate authorization layers:
15
15
  | Layer | Owner | Stored as |
16
16
  |-------|-------|-----------|
17
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 |
18
+ | Default application roles | Customer Applications admin | `core/application:<app>#admin/user@core/user:<users.id>` or `core/group:<groups.id>#member` |
19
+ | App roles/resources | This app's admin UI and app code | `<app>/system:main#<role>@<subject>` and app resource relationships |
20
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`.
21
+ Customer admins do not automatically get application access. They can manage default app roles, but to enter the app as a user, a user or group must be an application `admin` or `user`.
22
22
 
23
23
  ## Source Of Truth
24
24
 
@@ -40,11 +40,7 @@ Every app-authored SpiceDB definition must live under this app's namespace:
40
40
 
41
41
  ```zed
42
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)
43
+ // Add application-specific roles and permissions here.
48
44
  }
49
45
  ```
50
46
 
@@ -61,36 +57,33 @@ Do not use email addresses, IdP external IDs, group slugs, or display names in S
61
57
 
62
58
  Application access means "can open this app at all." It comes from the customer-level `core/application` object.
63
59
 
64
- The starter system permission is:
60
+ The core application permission is:
65
61
 
66
62
  ```zed
67
- permission access_app = application->access
63
+ permission app_access = user + admin
68
64
  ```
69
65
 
70
66
  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
67
 
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.
68
+ For resource permissions that should only be available inside the app, keep the API route behind `appProcedure` or another `canAccessApplication()` check, then let the resource permission model the object-specific rule. If a permission is intentionally usable without app enrollment, list it in `externallyShareablePermissions` in the manifest.
73
69
 
74
70
  ## App-Defined Roles
75
71
 
76
- App roles are static roles authored by the app builder in Zed and assigned by app owners/admins at runtime.
72
+ App roles are static roles authored by the app builder in Zed and assigned by app admins at runtime.
77
73
 
78
74
  To add a role:
79
75
 
80
76
  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.
77
+ 2. Include the role in `system.assignableRoles` in `access.manifest.ts` if app admins may assign it.
82
78
  3. Run `pnpm access:validate`.
83
79
 
84
80
  Example:
85
81
 
86
82
  ```zed
87
83
  definition __APP_NAME_SNAKE__/system {
88
- relation application: core/application
89
- relation admin: core/user | core/group#member
90
84
  relation hr_admin: core/user | core/group#member
91
85
 
92
- permission access_app = application->access
93
- permission manage_roles = access_app & (application->owner + admin)
86
+ permission manage_hr = hr_admin
94
87
  }
95
88
  ```
96
89
 
@@ -101,12 +94,12 @@ export const accessManifest = defineAccessManifest({
101
94
  // ...
102
95
  system: {
103
96
  // ...
104
- assignableRoles: ["admin", "hr_admin"],
97
+ assignableRoles: ["hr_admin"],
105
98
  },
106
99
  } as const);
107
100
  ```
108
101
 
109
- Role assignment is additive. Assigning `admin` and then `hr_admin` leaves both grants in place until each one is revoked.
102
+ Role assignment is additive. Assigning one app role and then another leaves both grants in place until each one is revoked.
110
103
 
111
104
  ## Permissioned Resources
112
105
 
@@ -120,8 +113,8 @@ definition __APP_NAME_SNAKE__/employee {
120
113
  relation employee_user: core/user
121
114
  relation manager: __APP_NAME_SNAKE__/employee
122
115
 
123
- permission view_basic = system->access_app
124
- permission view_private = system->access_app & (employee_user + manager->view_private + system->hr_admin)
116
+ permission view_basic = employee_user + manager->view_basic + system->hr_admin
117
+ permission view_private = employee_user + manager->view_private + system->hr_admin
125
118
  }
126
119
  ```
127
120
 
@@ -256,7 +249,7 @@ The `users.role` column is not the app permission source of truth. If Better Aut
256
249
  ## Debugging A Denied Check
257
250
 
258
251
  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`.
252
+ 2. Confirm application access first: the user or one of their groups needs `core/application:<app>#user` or `#admin`.
260
253
  3. Confirm local topology exists:
261
254
 
262
255
  ```bash
@@ -1,13 +1,13 @@
1
1
  // @ts-check
2
+ import eslint from "@eslint/js";
2
3
  import nextPlugin from "@next/eslint-plugin-next";
3
- import createEslintConfig from "@percepta/build/eslint";
4
4
  import nodePlugin from "eslint-plugin-n";
5
+ import reactPlugin from "eslint-plugin-react";
6
+ import reactHooksPlugin from "eslint-plugin-react-hooks";
7
+ import globals from "globals";
8
+ import tseslint from "typescript-eslint";
5
9
 
6
- export default [
7
- ...createEslintConfig({
8
- type: "react",
9
- dirname: import.meta.dirname,
10
- }),
10
+ export default tseslint.config(
11
11
  {
12
12
  ignores: [
13
13
  "pnpm-lock.yaml",
@@ -18,15 +18,43 @@ export default [
18
18
  "terraform/**",
19
19
  ],
20
20
  },
21
+ eslint.configs.recommended,
22
+ ...tseslint.configs.recommended,
23
+ {
24
+ files: ["**/*.{js,mjs,cjs,ts,tsx}"],
25
+ languageOptions: {
26
+ ecmaVersion: "latest",
27
+ globals: {
28
+ ...globals.browser,
29
+ ...globals.node,
30
+ },
31
+ parserOptions: {
32
+ ecmaFeatures: {
33
+ jsx: true,
34
+ },
35
+ },
36
+ sourceType: "module",
37
+ },
38
+ },
21
39
  {
22
40
  plugins: {
23
41
  "@next/next": nextPlugin,
42
+ react: reactPlugin,
43
+ "react-hooks": reactHooksPlugin,
24
44
  },
25
45
  rules: {
26
46
  ...nextPlugin.configs.recommended.rules,
27
47
  ...nextPlugin.configs["core-web-vitals"].rules,
48
+ ...reactPlugin.configs.recommended.rules,
49
+ ...reactHooksPlugin.configs.recommended.rules,
50
+ "react/jsx-uses-react": "off",
28
51
  "react/react-in-jsx-scope": "off",
29
52
  },
53
+ settings: {
54
+ react: {
55
+ version: "detect",
56
+ },
57
+ },
30
58
  },
31
59
  {
32
60
  files: ["src/**/*"],
@@ -63,4 +91,4 @@ export default [
63
91
  "n/no-process-env": "off",
64
92
  },
65
93
  },
66
- ];
94
+ );