@percepta/create 3.3.0 → 3.4.1

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 (34) hide show
  1. package/README.md +3 -3
  2. package/package.json +2 -2
  3. package/templates/monorepo/README.md +5 -3
  4. package/templates/monorepo/access/dev-grants.yaml.example +3 -8
  5. package/templates/monorepo/auth/package.json +2 -1
  6. package/templates/monorepo/auth/src/principals.ts +11 -0
  7. package/templates/monorepo/package.json.template +1 -1
  8. package/templates/webapp/AGENTS.md +6 -7
  9. package/templates/webapp/README.md +31 -3
  10. package/templates/webapp/agent-skills/access-control.md +15 -22
  11. package/templates/webapp/e2e/rbac.spec.ts +136 -0
  12. package/templates/webapp/eslint.config.mjs +41 -7
  13. package/templates/webapp/gitignore.template +2 -0
  14. package/templates/webapp/package.json.template +8 -1
  15. package/templates/webapp/playwright.config.ts +33 -0
  16. package/templates/webapp/scripts/seed.ts +71 -24
  17. package/templates/webapp/src/access/access.manifest.ts +9 -2
  18. package/templates/webapp/src/access/schema.zed +1 -5
  19. package/templates/webapp/src/app/(admin)/admin/_components/AdminTabs.tsx +33 -0
  20. package/templates/webapp/src/app/(admin)/admin/_lib/accessAdmin.ts +62 -0
  21. package/templates/webapp/src/app/(admin)/admin/page.tsx +598 -0
  22. package/templates/webapp/src/app/(admin)/layout.tsx +43 -0
  23. package/templates/webapp/src/app/(app)/layout.tsx +13 -3
  24. package/templates/webapp/src/app/(auth)/auth/signin/CredentialsSignInForm.tsx +1 -1
  25. package/templates/webapp/src/app/global-error.tsx +1 -2
  26. package/templates/webapp/src/components/Header.tsx +23 -2
  27. package/templates/webapp/src/server/api/routers/access.ts +2 -2
  28. package/templates/webapp/src/services/access/AppAccessControl.ts +23 -0
  29. package/templates/webapp/src/services/inngest/events/AppEvents.ts +2 -2
  30. package/templates/webapp/src/services/inngest/events/payloads/ExampleEventPayload.ts +6 -10
  31. package/templates/webapp/src/app/(app)/admin/_lib/PrincipalRoleTable.tsx +0 -113
  32. package/templates/webapp/src/app/(app)/admin/_lib/accessAdmin.ts +0 -85
  33. package/templates/webapp/src/app/(app)/admin/groups/page.tsx +0 -117
  34. 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.3.0",
3
+ "version": "3.4.1",
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": "1.0.0"
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
@@ -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: []
@@ -7,6 +7,7 @@
7
7
  "exports": {
8
8
  ".": "./src/index.ts",
9
9
  "./db": "./src/drizzle/db.ts",
10
+ "./principals": "./src/principals.ts",
10
11
  "./schema": "./src/drizzle/schema/index.ts"
11
12
  },
12
13
  "scripts": {
@@ -17,7 +18,7 @@
17
18
  "db:setup-and-migrate": "pnpm db:setup && pnpm db:migrate"
18
19
  },
19
20
  "dependencies": {
20
- "@percepta/auth": "0.1.0",
21
+ "@percepta/auth": "0.1.1",
21
22
  "drizzle-orm": "^0.45.2"
22
23
  },
23
24
  "devDependencies": {
@@ -0,0 +1,11 @@
1
+ import { listAuthPrincipals } from "@percepta/auth/drizzle";
2
+ import { db } from "./drizzle/db";
3
+ import { groups, users } from "./drizzle/schema";
4
+
5
+ export function listPrincipals() {
6
+ return listAuthPrincipals({
7
+ db,
8
+ groupsTable: groups,
9
+ usersTable: users,
10
+ });
11
+ }
@@ -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.1",
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
@@ -98,6 +98,32 @@ src/
98
98
  | `pnpm db:setup-and-migrate` | Setup and migrate database |
99
99
  | `pnpm db:studio` | Setup/migrate the database, then open Drizzle Studio |
100
100
  | `pnpm db:seed` | Seed default shared-auth dev users and local access grants |
101
+ | `pnpm test:e2e:install` | Install the Chromium browser used by Playwright |
102
+ | `pnpm test:e2e` | Run Playwright e2e tests after local setup |
103
+ | `pnpm test:e2e:ui` | Run Playwright e2e tests in UI mode after local setup |
104
+
105
+ ## End-to-End Tests
106
+
107
+ This template includes focused Playwright coverage for seeded RBAC flows. Before
108
+ the first run, install the browser binary:
109
+
110
+ ```bash
111
+ pnpm test:e2e:install
112
+ ```
113
+
114
+ Then run:
115
+
116
+ ```bash
117
+ pnpm test:e2e
118
+ ```
119
+
120
+ The e2e script runs local setup first, then starts the Next.js dev server via
121
+ Playwright. To point the tests at an already-running app, set
122
+ `PLAYWRIGHT_BASE_URL`, for example:
123
+
124
+ ```bash
125
+ PLAYWRIGHT_BASE_URL=http://localhost:3000 pnpm test:e2e
126
+ ```
101
127
 
102
128
  ## Logging
103
129
 
@@ -134,11 +160,13 @@ BETTER_AUTH_SECRET=generate-with-openssl-rand-base64-32
134
160
  BETTER_AUTH_URL=http://localhost:3000
135
161
  ```
136
162
 
137
- To create a dev user:
163
+ To create dev users:
138
164
  ```bash
139
165
  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
166
+ # Creates customer-admin@example.com / password as a customer admin
167
+ # Creates app-admin@example.com / password as an app admin
168
+ # Creates app-user@example.com / password as an app user
169
+ # Creates non-user@example.com / password with no app access
142
170
  ```
143
171
 
144
172
  ## 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
@@ -0,0 +1,136 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { expect, test, type Page } from "@playwright/test";
4
+
5
+ const execFileAsync = promisify(execFile);
6
+ const password = "password";
7
+
8
+ const users = {
9
+ appAdmin: "app-admin@example.com",
10
+ appUser: "app-user@example.com",
11
+ customerAdmin: "customer-admin@example.com",
12
+ nonAppUser: "non-user@example.com",
13
+ } as const;
14
+
15
+ test.describe("RBAC access", () => {
16
+ test("customer admin can manage role mappings but cannot enter the main app", async ({
17
+ page,
18
+ }) => {
19
+ test.setTimeout(60_000);
20
+ await signIn(page, users.customerAdmin, "/admin?tab=assignments");
21
+
22
+ await expect(page.getByRole("heading", { name: "RBAC" })).toBeVisible();
23
+ await expect(page.getByRole("link", { name: "Admin" })).toBeVisible();
24
+
25
+ const userAssignments = page.getByRole("button", {
26
+ name: "User assignments",
27
+ });
28
+ const adminAssignments = page.getByRole("button", {
29
+ name: "Admin assignments",
30
+ });
31
+
32
+ await expect(userAssignments).toHaveAttribute("aria-disabled", "false");
33
+ await expect(adminAssignments).toHaveAttribute("aria-disabled", "false");
34
+
35
+ await userAssignments.click();
36
+ await expect(page.getByPlaceholder("Search users or groups")).toBeVisible();
37
+
38
+ let shouldResetSeedData = false;
39
+ try {
40
+ await page.getByRole("option", { name: /App Non User/ }).click();
41
+ shouldResetSeedData = true;
42
+ await expect(userAssignments).toContainText("App Non User");
43
+ await expect(userAssignments).toHaveAttribute("aria-busy", "false");
44
+
45
+ await page.reload();
46
+ await expect(
47
+ page.getByRole("button", { name: "User assignments" }),
48
+ ).toContainText("App Non User");
49
+ } finally {
50
+ if (shouldResetSeedData) {
51
+ await resetSeedData();
52
+ }
53
+ }
54
+
55
+ await page.reload();
56
+ await expect(
57
+ page.getByRole("button", { name: "User assignments" }),
58
+ ).not.toContainText("App Non User");
59
+
60
+ await page.goto("/");
61
+ await expect(page.getByRole("heading", { name: /Welcome to/ })).toHaveCount(
62
+ 0,
63
+ );
64
+ await expectNotFound(page);
65
+ });
66
+
67
+ test("app admin can see RBAC and main app but cannot edit default role mappings", async ({
68
+ page,
69
+ }) => {
70
+ await signIn(page, users.appAdmin, "/admin?tab=assignments");
71
+
72
+ await expect(page.getByRole("heading", { name: "RBAC" })).toBeVisible();
73
+ await expect(
74
+ page
75
+ .getByText("Only customer admins can edit default application roles.")
76
+ .first(),
77
+ ).toBeVisible();
78
+
79
+ await expect(
80
+ page.getByRole("button", { name: "User assignments" }),
81
+ ).toHaveAttribute("aria-disabled", "true");
82
+ await expect(
83
+ page.getByRole("button", { name: "Admin assignments" }),
84
+ ).toHaveAttribute("aria-disabled", "true");
85
+
86
+ await page.goto("/");
87
+ await expect(
88
+ page.getByRole("heading", { name: /Welcome to/ }),
89
+ ).toBeVisible();
90
+ });
91
+
92
+ test("app user can enter the main app but cannot reach admin", async ({
93
+ page,
94
+ }) => {
95
+ await signIn(page, users.appUser, "/");
96
+
97
+ await expect(
98
+ page.getByRole("heading", { name: /Welcome to/ }),
99
+ ).toBeVisible();
100
+ await expect(page.getByRole("link", { name: "Admin" })).toHaveCount(0);
101
+
102
+ await page.goto("/admin");
103
+ await expect(page.getByRole("heading", { name: "RBAC" })).toHaveCount(0);
104
+ await expectNotFound(page);
105
+ });
106
+
107
+ test("non app user cannot enter the app", async ({ page }) => {
108
+ await signIn(page, users.nonAppUser, "/");
109
+
110
+ await expect(page.getByRole("heading", { name: /Welcome to/ })).toHaveCount(
111
+ 0,
112
+ );
113
+ await expect(page.getByRole("heading", { name: "RBAC" })).toHaveCount(0);
114
+ await expectNotFound(page);
115
+ });
116
+ });
117
+
118
+ async function signIn(page: Page, email: string, callbackUrl: string) {
119
+ await page.goto(
120
+ `/auth/signin?callbackUrl=${encodeURIComponent(callbackUrl)}`,
121
+ );
122
+ await page.getByLabel("Email").fill(email);
123
+ await page.getByLabel("Password").fill(password);
124
+ await page.getByRole("button", { name: "Sign In" }).click();
125
+ await page.waitForURL((url) => url.pathname !== "/auth/signin");
126
+ }
127
+
128
+ async function expectNotFound(page: Page) {
129
+ await expect(
130
+ page.getByText(/This page could not be found|404/i).first(),
131
+ ).toBeVisible();
132
+ }
133
+
134
+ async function resetSeedData() {
135
+ await execFileAsync("pnpm", ["db:seed"], { cwd: process.cwd() });
136
+ }
@@ -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,10 @@ export default [
63
91
  "n/no-process-env": "off",
64
92
  },
65
93
  },
66
- ];
94
+ {
95
+ files: ["playwright.config.ts"],
96
+ rules: {
97
+ "n/no-process-env": "off",
98
+ },
99
+ },
100
+ );
@@ -12,6 +12,8 @@
12
12
 
13
13
  # testing
14
14
  /coverage
15
+ /playwright-report
16
+ /test-results
15
17
 
16
18
  # next.js
17
19
  /.next/
@@ -27,6 +27,9 @@
27
27
  "deploy:percepta-test": "tsx ./scripts/deploy-percepta-test.ts",
28
28
  "deploy:percepta-test:pr": "tsx ./scripts/open-ryvn-deploy-pr.ts",
29
29
  "test": "vitest run",
30
+ "test:e2e": "pnpm run setup && playwright test",
31
+ "test:e2e:install": "playwright install chromium",
32
+ "test:e2e:ui": "pnpm run setup && playwright test --ui",
30
33
  "test:watch": "vitest"
31
34
  },
32
35
  "dependencies": {
@@ -55,7 +58,7 @@
55
58
  "@opentelemetry/exporter-trace-otlp-proto": "^0.203.0",
56
59
  "@opentelemetry/sdk-node": "^0.203.0",
57
60
  "@__REPO_NAME__/auth": "workspace:*",
58
- "@percepta/access-control": "0.3.2",
61
+ "@percepta/access-control": "0.6.1",
59
62
  "@percepta/design": "0.3.2",
60
63
  "@percepta/logger": "0.0.6",
61
64
  "@percepta/next-utils": "0.1.0",
@@ -102,7 +105,9 @@
102
105
  "zod": "^4.1.5"
103
106
  },
104
107
  "devDependencies": {
108
+ "@eslint/js": "^9.18.0",
105
109
  "@next/eslint-plugin-next": "^15.3.5",
110
+ "@playwright/test": "^1.58.2",
106
111
  "@percepta/build": "0.4.0",
107
112
  "@tailwindcss/postcss": "^4.1.11",
108
113
  "@types/formidable": "^3.4.5",
@@ -121,9 +126,11 @@
121
126
  "eslint-plugin-n": "^17.23.1",
122
127
  "eslint-plugin-react": "^7.37.4",
123
128
  "eslint-plugin-react-hooks": "^5.2.0",
129
+ "globals": "^15.14.0",
124
130
  "husky": "^9.1.7",
125
131
  "tailwindcss": "^4.0.12",
126
132
  "typescript": "^5.7.3",
133
+ "typescript-eslint": "^8.33.0",
127
134
  "vitest": "^3.2.1",
128
135
  "yargs": "^17.7.2"
129
136
  }
@@ -0,0 +1,33 @@
1
+ import { defineConfig, devices } from "@playwright/test";
2
+
3
+ const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000);
4
+ const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${port}`;
5
+
6
+ export default defineConfig({
7
+ testDir: "./e2e",
8
+ fullyParallel: false,
9
+ forbidOnly: Boolean(process.env.CI),
10
+ retries: process.env.CI ? 2 : 0,
11
+ reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : "list",
12
+ use: {
13
+ baseURL,
14
+ trace: "on-first-retry",
15
+ },
16
+ webServer: process.env.PLAYWRIGHT_BASE_URL
17
+ ? undefined
18
+ : {
19
+ command: `pnpm dev -- --hostname 127.0.0.1 --port ${port}`,
20
+ env: {
21
+ SKIP_INNGEST_SYNC: "true",
22
+ },
23
+ reuseExistingServer: !process.env.CI,
24
+ timeout: 120_000,
25
+ url: baseURL,
26
+ },
27
+ projects: [
28
+ {
29
+ name: "chromium",
30
+ use: { ...devices["Desktop Chrome"] },
31
+ },
32
+ ],
33
+ });