@podosoft/podokit 1.4.2 → 1.6.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/README.md +2 -1
- package/dist/app-proxy-timeouts.d.ts +27 -0
- package/dist/app-proxy-timeouts.js +180 -0
- package/dist/assemble.d.ts +5 -2
- package/dist/assemble.js +14 -4
- package/dist/deploy-checks.d.ts +44 -0
- package/dist/deploy-checks.js +267 -0
- package/dist/deploy-compose-profile.d.ts +6 -2
- package/dist/deploy-compose-profile.js +15 -6
- package/dist/deploy-compose-render.d.ts +12 -4
- package/dist/deploy-compose-render.js +47 -17
- package/dist/deploy-compose.d.ts +2 -5
- package/dist/deploy-compose.js +7 -2
- package/dist/deploy-profile.d.ts +6 -2
- package/dist/deploy-profile.js +11 -7
- package/dist/deploy-render.d.ts +4 -1
- package/dist/deploy-render.js +28 -20
- package/dist/deploy-schema.d.ts +61 -0
- package/dist/deploy-schema.js +157 -1
- package/dist/deploy.d.ts +2 -5
- package/dist/deploy.js +5 -2
- package/dist/dev.d.ts +21 -0
- package/dist/dev.js +78 -3
- package/dist/eject.js +2 -0
- package/dist/index.js +85 -5
- package/dist/lib.d.ts +2 -0
- package/dist/lib.js +5 -2
- package/dist/lockfile.d.ts +24 -0
- package/dist/lockfile.js +77 -5
- package/dist/provider.d.ts +1 -0
- package/dist/provider.js +12 -4
- package/dist/remove.js +1 -1
- package/dist/template-files.d.ts +42 -0
- package/dist/template-files.js +102 -0
- package/dist/templates/fullstack/AGENTS.md +44 -7
- package/dist/templates/fullstack/README.md +8 -7
- package/dist/templates/fullstack/apps/api/package.json +4 -4
- package/dist/templates/fullstack/apps/api/scripts/build.extensions.mjs +6 -0
- package/dist/templates/fullstack/apps/api/scripts/build.mjs +65 -16
- package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +7 -3
- package/dist/templates/fullstack/apps/api/scripts/verify-api-contract.ts +5 -1
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -4
- package/dist/templates/fullstack/apps/api/src/app.spec.ts +22 -0
- package/dist/templates/fullstack/apps/api/src/app.ts +47 -28
- package/dist/templates/fullstack/apps/api/src/build.spec.ts +118 -0
- package/dist/templates/fullstack/apps/api/src/config/env.extensions.ts +20 -0
- package/dist/templates/fullstack/apps/api/src/config/env.validation.spec.ts +76 -0
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +46 -3
- package/dist/templates/fullstack/apps/api/src/config/provider-selection.spec.ts +53 -0
- package/dist/templates/fullstack/apps/api/src/config/provider-selection.ts +79 -0
- package/dist/templates/fullstack/apps/api/src/config/providers.ts +8 -2
- package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +17 -6
- package/dist/templates/fullstack/apps/api/src/config/server-options.ts +5 -8
- package/dist/templates/fullstack/apps/api/src/core/api-contract.extensions.ts +6 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +29 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +39 -2
- package/dist/templates/fullstack/apps/api/src/core/client-address.spec.ts +82 -0
- package/dist/templates/fullstack/apps/api/src/core/client-address.ts +68 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +43 -0
- package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +27 -0
- package/dist/templates/fullstack/apps/api/src/database/data-source.ts +7 -10
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +12 -0
- package/dist/templates/fullstack/apps/api/src/database/database.ts +2 -3
- package/dist/templates/fullstack/apps/api/src/database/entities.ts +12 -0
- package/dist/templates/fullstack/apps/api/src/database/migrate.ts +12 -2
- package/dist/templates/fullstack/apps/api/src/database/migration-files.spec.ts +116 -0
- package/dist/templates/fullstack/apps/api/src/database/migration-files.ts +134 -0
- package/dist/templates/fullstack/apps/api/src/database/migration-readiness.spec.ts +36 -0
- package/dist/templates/fullstack/apps/api/src/database/migration-readiness.ts +25 -0
- package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +12 -1
- package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +2 -36
- package/dist/templates/fullstack/apps/api/src/main.ts +2 -2
- package/dist/templates/fullstack/apps/api/tsconfig.json +2 -0
- package/dist/templates/fullstack/apps/web/Dockerfile +4 -0
- package/dist/templates/fullstack/apps/web/package.json +1 -1
- package/dist/templates/fullstack/apps/web/src/app.css +3 -0
- package/dist/templates/fullstack/apps/web/src/app.extensions.css +11 -0
- package/dist/templates/fullstack/apps/web/src/lib/components/site-runtime.svelte +3 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +19 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +4 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +370 -36
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +250 -41
- package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +5 -2
- package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +23 -0
- package/dist/templates/fullstack/apps/web/tsconfig.json +1 -1
- package/dist/templates/fullstack/apps/web/vite.config.test.ts +33 -0
- package/dist/templates/fullstack/apps/web/vite.config.ts +55 -13
- package/dist/templates/fullstack/apps/web/vite.extensions.ts +23 -0
- package/dist/templates/fullstack/compose.dev.yaml +7 -4
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +2 -2
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +12 -3
- package/dist/templates/fullstack/dot-claude/skills/podokit-update/SKILL.md +5 -2
- package/dist/templates/fullstack/dot-env.docker.example +7 -2
- package/dist/templates/fullstack/dot-env.example +21 -1
- package/dist/templates/fullstack/dot-podokit/dev.json +1 -1
- package/dist/templates/fullstack/infra/k3s/configmap.yaml +4 -0
- package/dist/templates/fullstack/tests/README.md +40 -1
- package/dist/templates/fullstack/tests/helpers/app-paths.ts +18 -0
- package/dist/templates/fullstack/tests/helpers/paths.ts +33 -0
- package/dist/templates/fullstack/tests/package.json +2 -2
- package/dist/templates/fullstack/tests/playwright.config.ts +7 -3
- package/dist/templates/fullstack/tests/playwright.extensions.ts +88 -1
- package/dist/templates/fullstack/tests/ui/smoke.ui.spec.ts +5 -4
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.spec.ts +152 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.ts +112 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +24 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +18 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +36 -13
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +13 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +37 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +15 -6
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.test.ts +33 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.ts +34 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.test.ts +31 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.ts +31 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flags.ts +12 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-nav.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.test.ts +39 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.ts +35 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts +15 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +73 -39
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/admin-sidebar.svelte +2 -23
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +7 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.test.ts +30 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/impersonation-banner.svelte +3 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/settings/app-flags-settings.svelte +65 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +32 -23
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.test.ts +27 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-footer.svelte +4 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +3 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +3 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/app-hooks.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/non-indexable-paths.ts +8 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.test.ts +31 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +5 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +2 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.svelte +1 -5
- package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/account-sessions.api.spec.ts +55 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +159 -170
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +72 -81
- package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +1 -6
- package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +43 -53
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +29 -1
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +24 -6
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/app-accounts.ts +24 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +55 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -3
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +3 -9
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/sms.ts +2 -4
- package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +11 -5
- package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +3 -10
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +4 -5
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account-sessions.ui.spec.ts +46 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +62 -59
- package/dist/templates/modules/admin-dashboard/files/tests/ui/app-flags.ui.spec.ts +63 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +12 -16
- package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +3 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +32 -35
- package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +4 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +1 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +82 -91
- package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +6 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +16 -20
- package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +6 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +2 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +5 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +7 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/same-origin-requests.ui.spec.ts +89 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +5 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +67 -73
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +84 -93
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +33 -7
- package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +29 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +51 -54
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +10 -9
- package/dist/templates/modules/admin-dashboard/module.manifest.json +7 -2
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -1
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +5 -1
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +33 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +20 -1
- package/dist/templates/modules/audit-log/module.manifest.json +15 -3
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-app-name.spec.ts +21 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +13 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-environment.ts +9 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +19 -3
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +6 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.runtime.spec.ts +10 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +54 -6
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +15 -8
- package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.spec.ts +56 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.ts +45 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.spec.ts +86 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.ts +44 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +2 -1
- package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -1
- package/dist/templates/modules/auth/files/apps/api/src/settings/app-flags.ts +9 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.spec.ts +43 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +30 -5
- package/dist/templates/modules/auth/module.manifest.json +8 -2
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +57 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +93 -9
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.spec.ts +40 -0
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +52 -5
- package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +9 -7
- package/dist/templates/modules/bullmq/files/apps/api/src/worker.extensions.ts +13 -0
- package/dist/templates/modules/bullmq/module.manifest.json +5 -1
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +2 -0
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +87 -1
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +93 -3
- package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
- package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.spec.ts +16 -0
- package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.ts +15 -10
- package/dist/templates/modules/mailer/module.manifest.json +1 -1
- package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +1 -1
- package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +5 -4
- package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +1 -1
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +2 -59
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +0 -65
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +82 -21
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +54 -15
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +43 -5
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +10 -2
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +61 -3
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +48 -15
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +63 -27
- package/dist/templates/modules/rate-limit/module.manifest.json +6 -9
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +1 -1
- package/dist/templates/todo/AGENTS.md +46 -6
- package/dist/templates/todo/README.md +6 -4
- package/dist/text-diff.d.ts +12 -0
- package/dist/text-diff.js +111 -0
- package/dist/update.d.ts +61 -3
- package/dist/update.js +152 -27
- package/package.json +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +0 -75
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +0 -80
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: podokit-update
|
|
3
|
-
description: Use when the user wants to update a Bun-based project to a newer PodoKit v1 version, pull in template/module improvements, check local edits, or take ownership of a managed file. Covers podo status/diff/doctor/update/eject.
|
|
3
|
+
description: Use when the user wants to update a Bun-based project to a newer PodoKit v1 version, pull in template/module improvements, check local edits, or take ownership of a managed file. Covers podo status/diff/doctor/update/eject/restore.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Keep a PodoKit project up to date
|
|
@@ -11,15 +11,18 @@ its own files while keeping the user's changes.
|
|
|
11
11
|
```bash
|
|
12
12
|
podo status # version, modules, file tiers, and how many managed files were edited
|
|
13
13
|
podo diff # list the PodoKit-managed files you've edited (owned files ignored)
|
|
14
|
+
podo diff <path> # line diff of one file against the template (owned files too)
|
|
14
15
|
podo doctor # check framework versions vs. the supported ranges
|
|
15
16
|
podo update # dry-run: preview what a newer version would change (writes nothing)
|
|
16
17
|
podo update --apply # apply it — clean updates are written; your edits are 3-way merged
|
|
17
18
|
podo eject <path> # take ownership of a managed file so update skips it
|
|
19
|
+
podo restore <path> # rewrite a managed file from the template; re-manages an ejected file
|
|
18
20
|
```
|
|
19
21
|
|
|
20
22
|
Rules to respect:
|
|
21
23
|
- **owned** files (routes, your components, `AGENTS.md`, shadcn `ui/**`) are never
|
|
22
|
-
touched by `update`.
|
|
24
|
+
touched by `update`. When their template changes, the plan lists them as
|
|
25
|
+
`review`; compare with `podo diff <path>` and copy what the app needs.
|
|
23
26
|
- **assembled** files are recomputed from the module set — keep edits **outside**
|
|
24
27
|
the `// podokit:begin…end` fences.
|
|
25
28
|
- Run `podo update` (dry-run) first and read the plan before `--apply`. Commit or
|
|
@@ -12,15 +12,20 @@ POSTGRES_USER=podokit
|
|
|
12
12
|
POSTGRES_PASSWORD=podokit
|
|
13
13
|
POSTGRES_DB=podokit
|
|
14
14
|
BACKEND_INTERNAL_URL=http://api:5002
|
|
15
|
+
# API: the web container and the gateway are the one proxy in front of it.
|
|
16
|
+
TRUSTED_PROXY_HOPS=1
|
|
15
17
|
# Web: milliseconds per server-side API call, and the /api proxy's wait for
|
|
16
18
|
# response headers.
|
|
17
19
|
# BACKEND_TIMEOUT_MS=2000
|
|
18
20
|
# BACKEND_PROXY_TIMEOUT_MS=30000
|
|
19
|
-
CORS_ORIGIN
|
|
21
|
+
# podo dev sets CORS_ORIGIN and BETTER_AUTH_URL for web and api from
|
|
22
|
+
# .podokit/dev.json. These two serve compose.dev.yaml run without podo dev,
|
|
23
|
+
# such as from the devcontainer.
|
|
24
|
+
CORS_ORIGIN={{devCorsOrigin}}
|
|
20
25
|
REDIS_HOST=redis
|
|
21
26
|
REDIS_PORT=6379
|
|
22
27
|
BETTER_AUTH_SECRET=
|
|
23
|
-
BETTER_AUTH_URL=
|
|
28
|
+
BETTER_AUTH_URL={{devAuthUrl}}
|
|
24
29
|
ADMIN_EMAILS=admin@example.com
|
|
25
30
|
AUDIT_LOG_ENABLED=true
|
|
26
31
|
RATE_LIMIT_TTL=60
|
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
# ── Required (runtime) ───────────────────────────────────────────────
|
|
4
4
|
NODE_ENV=development
|
|
5
5
|
PORT=5002
|
|
6
|
+
# Address the API listens on (default 0.0.0.0); 127.0.0.1 keeps it off the network.
|
|
7
|
+
# HOST=0.0.0.0
|
|
6
8
|
|
|
7
9
|
# Canonical database connection. Set this for PostgreSQL or SQLite; POSTGRES_* remains a fallback.
|
|
8
10
|
# DATABASE_URL=postgres://podokit:podokit@localhost:5432/podokit
|
|
11
|
+
# With SQLite and no DATABASE_URL, the database file, relative to apps/api.
|
|
12
|
+
# SQLITE_PATH=./data/podokit.sqlite
|
|
9
13
|
POSTGRES_HOST=localhost
|
|
10
14
|
POSTGRES_PORT=5432
|
|
11
15
|
POSTGRES_USER=podokit
|
|
@@ -27,6 +31,18 @@ POSTGRES_DB=podokit
|
|
|
27
31
|
# Largest API request body in bytes (Bun's default is 128 MiB). Requests through
|
|
28
32
|
# the web app's /api proxy are also bounded by BODY_SIZE_LIMIT below.
|
|
29
33
|
# API_MAX_REQUEST_BODY_SIZE=134217728
|
|
34
|
+
# Seconds the API keeps a silent connection open (0 to 255, 0 turns it off). Keep
|
|
35
|
+
# it, like CONNECTION_IDLE_TIMEOUT below, more than 4 above the longest /api proxy
|
|
36
|
+
# deadline in seconds.
|
|
37
|
+
# API_IDLE_TIMEOUT=35
|
|
38
|
+
|
|
39
|
+
# Reverse proxies in front of the API whose X-Forwarded-For names the caller. The
|
|
40
|
+
# web app's /api proxy is one, so 1 records the browser's address for sessions,
|
|
41
|
+
# audit entries, and rate limits; 0 uses the socket peer. Count only proxies that
|
|
42
|
+
# every request passes: with 1, a caller that reaches the API directly chooses
|
|
43
|
+
# its own recorded address.
|
|
44
|
+
TRUSTED_PROXY_HOPS=1
|
|
45
|
+
# TRUSTED_PROXY_HEADER=x-forwarded-for
|
|
30
46
|
|
|
31
47
|
# Readiness (GET /health/ready): the longest one dependency check may run, and how
|
|
32
48
|
# long a result is reused across probes (0 checks on every request).
|
|
@@ -39,13 +55,17 @@ BACKEND_INTERNAL_URL=http://localhost:5002
|
|
|
39
55
|
# waits for response headers (504 BACKEND_TIMEOUT after the second value). When
|
|
40
56
|
# the API's GET /health does not answer within BACKEND_TIMEOUT_MS either, API
|
|
41
57
|
# calls fail at once until it does. Raise BACKEND_PROXY_TIMEOUT_MS together with
|
|
42
|
-
#
|
|
58
|
+
# API_IDLE_TIMEOUT above and CONNECTION_IDLE_TIMEOUT below.
|
|
43
59
|
# BACKEND_TIMEOUT_MS=2000
|
|
44
60
|
# BACKEND_PROXY_TIMEOUT_MS=30000
|
|
45
61
|
|
|
46
62
|
CORS_ORIGIN=http://localhost:5001
|
|
47
63
|
# Web request limit; leave room for a 2 MiB multipart upload
|
|
48
64
|
BODY_SIZE_LIMIT=3M
|
|
65
|
+
# Seconds the built web server keeps a silent connection open (0 to 255, 0 turns
|
|
66
|
+
# it off). Keep it more than 4 above the longest /api proxy deadline in seconds,
|
|
67
|
+
# BACKEND_PROXY_TIMEOUT_MS or APP_PROXY_TIMEOUTS; the proxy refuses to load otherwise.
|
|
68
|
+
CONNECTION_IDLE_TIMEOUT=35
|
|
49
69
|
|
|
50
70
|
# ── Optional / development ───────────────────────────────────────────
|
|
51
71
|
# Redis — only needed by cache/queue modules (redis, bullmq, rate-limit,
|
|
@@ -9,7 +9,11 @@ data:
|
|
|
9
9
|
CORS_ORIGIN: "https://example.com"
|
|
10
10
|
BACKEND_INTERNAL_URL: "http://api:3000"
|
|
11
11
|
BODY_SIZE_LIMIT: "3M"
|
|
12
|
+
CONNECTION_IDLE_TIMEOUT: "35"
|
|
13
|
+
API_IDLE_TIMEOUT: "35"
|
|
14
|
+
TRUSTED_PROXY_HOPS: "1"
|
|
12
15
|
PROTOCOL_HEADER: "x-forwarded-proto"
|
|
13
16
|
HOST_HEADER: "x-forwarded-host"
|
|
14
17
|
ADDRESS_HEADER: "x-forwarded-for"
|
|
15
18
|
XFF_DEPTH: "1"
|
|
19
|
+
# podokit:end:runtime-config
|
|
@@ -13,9 +13,48 @@ The suite runs against a live stack (web on `E2E_BASE_URL`, proxying `/api/*` to
|
|
|
13
13
|
the API). With the recommended containerized loop, start `podo dev watch` and run:
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
E2E_BASE_URL=http://{{
|
|
16
|
+
E2E_BASE_URL=http://{{devHostname}} {{rootRun}} test:e2e
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
The default `http://localhost:5001` is for the alternative `{{rootRun}} dev`
|
|
20
20
|
host-process loop. Files ending in `*.api.spec.ts` run in the request-only `api`
|
|
21
21
|
project; `*.ui.spec.ts` run in the chromium `ui` project.
|
|
22
|
+
|
|
23
|
+
## Workers and exclusive tests
|
|
24
|
+
|
|
25
|
+
The suite runs on one worker unless `E2E_WORKERS` says otherwise. For the whole
|
|
26
|
+
suite, more workers finish sooner:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
E2E_WORKERS=2 {{rootRun}} test:e2e
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Set the count with `E2E_WORKERS`, not `--workers`: the global setup refuses more
|
|
33
|
+
than one worker when `E2E_WORKERS` is unset.
|
|
34
|
+
|
|
35
|
+
Every test shares one API, database, mail catcher, and SMS sink. A test that
|
|
36
|
+
changes state other tests rely on takes the `@exclusive` tag:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
test("maintenance mode holds visitors back", { tag: "@exclusive" }, async ({ page }) => {
|
|
40
|
+
// ...
|
|
41
|
+
});
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Tag a test when it changes a server-wide setting (a feature flag, the
|
|
45
|
+
authentication configuration such as SMTP, social providers, or sign-up
|
|
46
|
+
approval, the site settings, or a module's settings) or the profile of a seeded
|
|
47
|
+
account. Each project has an `<name>-exclusive` twin, such as `ui-exclusive`,
|
|
48
|
+
that runs only the tagged tests, one at a time. With more than one worker the
|
|
49
|
+
twins wait for every other test, so an exclusive test never runs alongside
|
|
50
|
+
another one. Never empty the shared mail catcher or SMS sink in a test: send to
|
|
51
|
+
an address or number of the test's own instead.
|
|
52
|
+
|
|
53
|
+
More than one worker suits the whole suite. When a run selects exclusive tests
|
|
54
|
+
with a file name, `--grep`, or `--project`, Playwright also runs every other test
|
|
55
|
+
first, because the twins depend on them, and a test that fails there marks the
|
|
56
|
+
exclusive tests as not run. Use the default single worker for a few files, and
|
|
57
|
+
do not combine `--no-deps` with `E2E_WORKERS` above 1.
|
|
58
|
+
|
|
59
|
+
Application-owned projects in `playwright.projects.cjs` get twins too. A project
|
|
60
|
+
that another project depends on is a setup project and keeps a single copy.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { AppPaths } from "./paths";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where this application's screens differ from the generated ones, for the
|
|
5
|
+
* managed e2e specs. PodoKit creates this file once and never overwrites it.
|
|
6
|
+
* Set only what differs; `paths.ts` documents each entry and its default. For
|
|
7
|
+
* example, an app whose first screen is a signed-in workspace without a
|
|
8
|
+
* heading, and which shows no public footer:
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* export const appPaths: Partial<AppPaths> = {
|
|
12
|
+
* homeReady: (page) => page.getByTestId("workspace"),
|
|
13
|
+
* indexable: [],
|
|
14
|
+
* footer: null,
|
|
15
|
+
* };
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export const appPaths: Partial<AppPaths> = {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Locator, Page } from "@playwright/test";
|
|
2
|
+
import { appPaths } from "./app-paths";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The application screens managed specs visit. The defaults describe the
|
|
6
|
+
* generated pages; `app-paths.ts`, which the application owns, overrides any
|
|
7
|
+
* of them, so a managed spec never needs editing for an app's own screens.
|
|
8
|
+
*/
|
|
9
|
+
export interface AppPaths {
|
|
10
|
+
/** The first screen a visitor opens. */
|
|
11
|
+
home: string;
|
|
12
|
+
/** Visible once the first screen has rendered. */
|
|
13
|
+
homeReady: (page: Page) => Locator;
|
|
14
|
+
/** Public pages search engines may index: they carry no `X-Robots-Tag`. */
|
|
15
|
+
indexable: string[];
|
|
16
|
+
/** A public page that renders the managed `SiteFooter`, or null when none does. */
|
|
17
|
+
footer: string | null;
|
|
18
|
+
/**
|
|
19
|
+
* A page that renders the managed `LanguageSwitch`, `ThemeToggle`, and
|
|
20
|
+
* `AccountMenu` for a signed-in user, or null when none does.
|
|
21
|
+
*/
|
|
22
|
+
accountMenu: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const defaults: AppPaths = {
|
|
26
|
+
home: "/",
|
|
27
|
+
homeReady: (page) => page.getByRole("heading", { level: 1 }),
|
|
28
|
+
indexable: ["/"],
|
|
29
|
+
footer: "/login",
|
|
30
|
+
accountMenu: "/",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const paths: AppPaths = { ...defaults, ...appPaths };
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
"private": true,
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test:e2e": "bunx playwright test",
|
|
7
|
-
"test:e2e:ui": "bunx playwright test --project=ui",
|
|
8
|
-
"test:e2e:api": "bunx playwright test --project=api",
|
|
7
|
+
"test:e2e:ui": "bunx playwright test --project=ui --project=ui-exclusive",
|
|
8
|
+
"test:e2e:api": "bunx playwright test --project=api --project=api-exclusive",
|
|
9
9
|
"test:e2e:report": "bunx playwright show-report"
|
|
10
10
|
},
|
|
11
11
|
"devDependencies": {
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { defineConfig, devices } from "@playwright/test";
|
|
2
|
-
import { loadPlaywrightProjects } from "./playwright.extensions";
|
|
2
|
+
import { e2eWorkers, loadPlaywrightProjects, withExclusivePhase } from "./playwright.extensions";
|
|
3
3
|
|
|
4
4
|
// The suite runs against a live stack: web on E2E_BASE_URL (proxying /api/* to
|
|
5
5
|
// the API). Start it first (see docs/testing.md) — locally via the dev harness,
|
|
6
6
|
// in CI via scripts/e2e-ci.mjs. Two projects: `api` (request-only, backend e2e)
|
|
7
7
|
// and `ui` (chromium, pages). Files are routed by suffix (*.api/*.ui.spec.ts).
|
|
8
|
+
// E2E_WORKERS sets the worker count (default 1); tests tagged @exclusive run in
|
|
9
|
+
// `<project>-exclusive` twins after the others (see playwright.extensions.ts).
|
|
8
10
|
const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
11
|
+
const workers = e2eWorkers();
|
|
9
12
|
const coreProjects = [
|
|
10
13
|
{ name: "api", testMatch: /.*\.api\.spec\.ts/ },
|
|
11
14
|
{ name: "ui", testMatch: /.*\.ui\.spec\.ts/, use: { ...devices["Desktop Chrome"] } },
|
|
@@ -16,12 +19,13 @@ export default defineConfig({
|
|
|
16
19
|
fullyParallel: true,
|
|
17
20
|
forbidOnly: !!process.env.CI,
|
|
18
21
|
retries: process.env.CI ? 2 : 0,
|
|
19
|
-
workers
|
|
22
|
+
workers,
|
|
23
|
+
globalSetup: "./playwright.extensions.ts",
|
|
20
24
|
reporter: [["list"], ["html", { open: "never" }]],
|
|
21
25
|
use: {
|
|
22
26
|
baseURL,
|
|
23
27
|
trace: "on-first-retry",
|
|
24
28
|
screenshot: "only-on-failure",
|
|
25
29
|
},
|
|
26
|
-
projects: [...coreProjects, ...loadPlaywrightProjects(coreProjects)],
|
|
30
|
+
projects: withExclusivePhase([...coreProjects, ...loadPlaywrightProjects(coreProjects)], workers),
|
|
27
31
|
});
|
|
@@ -1,17 +1,33 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import type { PlaywrightTestConfig } from "@playwright/test";
|
|
4
|
+
import type { FullConfig, PlaywrightTestConfig } from "@playwright/test";
|
|
5
5
|
|
|
6
6
|
type Projects = NonNullable<PlaywrightTestConfig["projects"]>;
|
|
7
|
+
type Project = Projects[number];
|
|
7
8
|
|
|
8
9
|
const requireFromTests = createRequire(__filename);
|
|
9
10
|
|
|
11
|
+
// A test that changes state the other tests rely on, such as a server-wide
|
|
12
|
+
// setting or the profile of a seeded account, takes the `@exclusive` tag:
|
|
13
|
+
//
|
|
14
|
+
// test("maintenance mode holds visitors back", { tag: "@exclusive" }, async ({ page }) => { ... });
|
|
15
|
+
//
|
|
16
|
+
// Playwright matches project grep patterns against the title path followed by
|
|
17
|
+
// the test's tags, separated by spaces.
|
|
18
|
+
const TAGGED = /(?:^|\s)@exclusive(?=\s|$)/;
|
|
19
|
+
const UNTAGGED = /^(?![\s\S]*(?:^|\s)@exclusive(?=\s|$))/;
|
|
20
|
+
|
|
10
21
|
function projectName(project: unknown): string | null {
|
|
11
22
|
if (typeof project !== "object" || project === null || !("name" in project)) return null;
|
|
12
23
|
return typeof project.name === "string" && project.name.length > 0 ? project.name : null;
|
|
13
24
|
}
|
|
14
25
|
|
|
26
|
+
function patterns(value: Project["grepInvert"]): RegExp[] {
|
|
27
|
+
if (value === undefined) return [];
|
|
28
|
+
return Array.isArray(value) ? value : [value];
|
|
29
|
+
}
|
|
30
|
+
|
|
15
31
|
/**
|
|
16
32
|
* Merge application-owned Playwright projects into PodoKit's managed config.
|
|
17
33
|
* The optional CommonJS file stays owned so applications can add devices and
|
|
@@ -42,3 +58,74 @@ export function loadPlaywrightProjects(coreProjects: Projects): Projects {
|
|
|
42
58
|
|
|
43
59
|
return loaded as Projects;
|
|
44
60
|
}
|
|
61
|
+
|
|
62
|
+
/** The number of Playwright workers: `E2E_WORKERS`, or 1 when it is unset. */
|
|
63
|
+
export function e2eWorkers(value = process.env.E2E_WORKERS): number {
|
|
64
|
+
if (value === undefined || value.trim() === "") return 1;
|
|
65
|
+
const workers = Number(value);
|
|
66
|
+
if (!Number.isInteger(workers) || workers < 1) {
|
|
67
|
+
throw new Error(`E2E_WORKERS must be a whole number of at least 1, got "${value}".`);
|
|
68
|
+
}
|
|
69
|
+
return workers;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Splits each test project into one that skips tests tagged `@exclusive` and an
|
|
74
|
+
* `<name>-exclusive` twin that runs only them on one worker. Projects that
|
|
75
|
+
* another project depends on, or that tear one down, are setup and cleanup
|
|
76
|
+
* projects and stay as they are.
|
|
77
|
+
*
|
|
78
|
+
* With more than one worker the twins depend on every other test project, and
|
|
79
|
+
* each twin on the one before it, so an exclusive test never runs alongside
|
|
80
|
+
* another test. A dependency runs in full, though: a run that selects only
|
|
81
|
+
* exclusive tests, with a file, `--grep`, or `--project`, then also runs every
|
|
82
|
+
* other test first, and a test that fails there skips the exclusive tests. Use
|
|
83
|
+
* more than one worker for the whole suite. With one worker no dependency is
|
|
84
|
+
* needed, so filtered runs stay small.
|
|
85
|
+
*/
|
|
86
|
+
export function withExclusivePhase(projects: Projects, workers: number): Projects {
|
|
87
|
+
const support = new Set(
|
|
88
|
+
projects.flatMap((project) => [...(project.dependencies ?? []), ...(project.teardown ? [project.teardown] : [])]),
|
|
89
|
+
);
|
|
90
|
+
const tested = projects.filter((project) => !support.has(projectName(project) ?? ""));
|
|
91
|
+
const shared = new Map(
|
|
92
|
+
tested.map((project) => [project, { ...project, grepInvert: [...patterns(project.grepInvert), TAGGED] }]),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
let before = [...shared.values()].map((project) => projectName(project) ?? "");
|
|
96
|
+
const twins = tested.map((project) => {
|
|
97
|
+
const name = `${projectName(project) ?? ""}-exclusive`;
|
|
98
|
+
const twin: Project = {
|
|
99
|
+
...project,
|
|
100
|
+
name,
|
|
101
|
+
grepInvert: [...patterns(project.grepInvert), UNTAGGED],
|
|
102
|
+
workers: 1,
|
|
103
|
+
dependencies: workers > 1 ? [...(project.dependencies ?? []), ...before] : project.dependencies,
|
|
104
|
+
};
|
|
105
|
+
before = [name];
|
|
106
|
+
return twin;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const result = [...projects.map((project) => shared.get(project) ?? project), ...twins];
|
|
110
|
+
const names = new Set<string>();
|
|
111
|
+
for (const project of result) {
|
|
112
|
+
const name = projectName(project) ?? "";
|
|
113
|
+
if (names.has(name)) throw new Error(`Duplicate Playwright project name: ${name}`);
|
|
114
|
+
names.add(name);
|
|
115
|
+
}
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Global setup: refuses more workers than `E2E_WORKERS` allows for. `--workers`
|
|
121
|
+
* changes the count after the projects were built, so with it the exclusive
|
|
122
|
+
* twins would run alongside the other tests.
|
|
123
|
+
*/
|
|
124
|
+
export default function checkWorkers(config: FullConfig): void {
|
|
125
|
+
if (config.workers > 1 && e2eWorkers() === 1) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`Set the worker count with E2E_WORKERS=${config.workers} instead of --workers. ` +
|
|
128
|
+
"Tests tagged @exclusive run after the others only when E2E_WORKERS is above 1.",
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { paths } from "../helpers/paths";
|
|
2
3
|
|
|
3
|
-
// Module-agnostic: the app
|
|
4
|
+
// Module-agnostic: the app's first screen responds (a page or a redirect to it)
|
|
5
|
+
// and renders. Its locator comes from tests/helpers/app-paths.ts.
|
|
4
6
|
test("app is reachable @smoke", async ({ page }) => {
|
|
5
|
-
const res = await page.goto(
|
|
7
|
+
const res = await page.goto(paths.home);
|
|
6
8
|
expect(res?.status()).toBeLessThan(400);
|
|
7
|
-
await expect(
|
|
8
|
-
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
|
|
9
|
+
await expect(paths.homeReady(page)).toBeVisible();
|
|
9
10
|
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test, type Mock } from "bun:test";
|
|
2
|
+
import { SQL } from "bun";
|
|
3
|
+
import { AppException } from "@podosoft/podokit-contracts";
|
|
4
|
+
import { Elysia } from "elysia";
|
|
5
|
+
import type { AuthService, AuthSession } from "../auth/auth.service";
|
|
6
|
+
import { AdminSessionsService } from "../admin-sessions/admin-sessions.service";
|
|
7
|
+
import {
|
|
8
|
+
accountSessionRoutes,
|
|
9
|
+
type AccountSessionPage,
|
|
10
|
+
type OwnSessionRevoker,
|
|
11
|
+
} from "./account-sessions.routes";
|
|
12
|
+
|
|
13
|
+
// More sessions than Better Auth's /list-sessions returns.
|
|
14
|
+
const OTHER_SESSIONS = 150;
|
|
15
|
+
const DAY_MS = 86_400_000;
|
|
16
|
+
|
|
17
|
+
// Callers name their session with a test header; its row says whose it is.
|
|
18
|
+
function authFor(db: SQL): Pick<AuthService, "requireSession"> {
|
|
19
|
+
return {
|
|
20
|
+
requireSession: async (request: Request): Promise<AuthSession> => {
|
|
21
|
+
const id = request.headers.get("x-test-session");
|
|
22
|
+
const [row] = id
|
|
23
|
+
? await db<Array<{ userId: string; createdAt: string }>>`
|
|
24
|
+
SELECT "userId", "createdAt" FROM "session" WHERE "id" = ${id}
|
|
25
|
+
`
|
|
26
|
+
: [];
|
|
27
|
+
if (!row) throw new AppException("AUTH_REQUIRED", "Authentication is required", 401);
|
|
28
|
+
return { user: { id: row.userId }, session: { id, createdAt: row.createdAt } };
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("account session routes", () => {
|
|
34
|
+
let db: SQL;
|
|
35
|
+
let revoke: Mock<OwnSessionRevoker>;
|
|
36
|
+
let freshAge: number;
|
|
37
|
+
let handle: (request: Request) => Promise<Response>;
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
|
|
40
|
+
beforeEach(async () => {
|
|
41
|
+
db = new SQL(":memory:");
|
|
42
|
+
await db`CREATE TABLE "user" ("id" TEXT PRIMARY KEY, "email" TEXT NOT NULL, "name" TEXT NOT NULL)`;
|
|
43
|
+
await db`
|
|
44
|
+
CREATE TABLE "session" (
|
|
45
|
+
"id" TEXT PRIMARY KEY, "userId" TEXT NOT NULL, "token" TEXT NOT NULL,
|
|
46
|
+
"ipAddress" TEXT, "userAgent" TEXT, "impersonatedBy" TEXT,
|
|
47
|
+
"createdAt" TEXT NOT NULL, "expiresAt" TEXT NOT NULL
|
|
48
|
+
)
|
|
49
|
+
`;
|
|
50
|
+
await db`INSERT INTO "user" VALUES ('user-1', 'one@example.com', 'One'), ('user-2', 'two@example.com', 'Two')`;
|
|
51
|
+
const expires = new Date(now + DAY_MS).toISOString();
|
|
52
|
+
// The current session is the oldest, so newest-first paging would put it last.
|
|
53
|
+
await db`
|
|
54
|
+
INSERT INTO "session" VALUES ('current', 'user-1', 'secret-current', '203.0.113.7', 'Agent',
|
|
55
|
+
NULL, ${new Date(now - 60_000).toISOString()}, ${expires})
|
|
56
|
+
`;
|
|
57
|
+
for (let index = 0; index < OTHER_SESSIONS; index += 1) {
|
|
58
|
+
await db`
|
|
59
|
+
INSERT INTO "session" VALUES (${`other-${index}`}, 'user-1', ${`secret-${index}`}, NULL, NULL, NULL,
|
|
60
|
+
${new Date(now - 30_000 + index).toISOString()}, ${expires})
|
|
61
|
+
`;
|
|
62
|
+
}
|
|
63
|
+
await db`
|
|
64
|
+
INSERT INTO "session" VALUES ('foreign', 'user-2', 'secret-foreign', NULL, NULL, NULL,
|
|
65
|
+
${new Date(now).toISOString()}, ${expires})
|
|
66
|
+
`;
|
|
67
|
+
revoke = mock<OwnSessionRevoker>(async () => undefined);
|
|
68
|
+
freshAge = 86_400;
|
|
69
|
+
const app = new Elysia()
|
|
70
|
+
.onError(({ code, error, set }) => {
|
|
71
|
+
if (error instanceof AppException) {
|
|
72
|
+
set.status = error.statusCode;
|
|
73
|
+
return { code: error.code };
|
|
74
|
+
}
|
|
75
|
+
if (code === "VALIDATION") {
|
|
76
|
+
set.status = 400;
|
|
77
|
+
return { code: "VALIDATION_ERROR" };
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
})
|
|
81
|
+
.use(accountSessionRoutes({
|
|
82
|
+
auth: authFor(db),
|
|
83
|
+
sessions: new AdminSessionsService(db, "sqlite"),
|
|
84
|
+
revoke,
|
|
85
|
+
freshAgeSeconds: async () => freshAge,
|
|
86
|
+
}));
|
|
87
|
+
handle = (request) => app.handle(request);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
afterEach(async () => {
|
|
91
|
+
await db.close();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
async function send(path: string, init: RequestInit = {}, session: string | null = "current"): Promise<Response> {
|
|
95
|
+
const headers = new Headers(init.headers);
|
|
96
|
+
if (session) headers.set("x-test-session", session);
|
|
97
|
+
return handle(new Request(`http://localhost${path}`, { ...init, headers }));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
test("returns the current session on every page and pages through every other one", async () => {
|
|
101
|
+
const seen: string[] = [];
|
|
102
|
+
let cursor: string | null = null;
|
|
103
|
+
let pages = 0;
|
|
104
|
+
do {
|
|
105
|
+
const response = await send(`/account/sessions?limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`);
|
|
106
|
+
expect(response.status).toBe(200);
|
|
107
|
+
const text = await response.text();
|
|
108
|
+
expect(text).not.toContain("secret-");
|
|
109
|
+
const page = JSON.parse(text) as AccountSessionPage;
|
|
110
|
+
expect(page.current).toMatchObject({ id: "current", ipAddress: "203.0.113.7", userAgent: "Agent" });
|
|
111
|
+
expect(page.total).toBe(OTHER_SESSIONS + 1);
|
|
112
|
+
seen.push(...page.items.map((item) => item.id));
|
|
113
|
+
cursor = page.nextCursor;
|
|
114
|
+
pages += 1;
|
|
115
|
+
} while (cursor);
|
|
116
|
+
|
|
117
|
+
expect(pages).toBe(2);
|
|
118
|
+
expect(seen).toHaveLength(OTHER_SESSIONS);
|
|
119
|
+
expect(new Set(seen).size).toBe(OTHER_SESSIONS);
|
|
120
|
+
expect(seen).not.toContain("current");
|
|
121
|
+
expect(seen).not.toContain("foreign");
|
|
122
|
+
expect(seen[0]).toBe(`other-${OTHER_SESSIONS - 1}`);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("refuses anonymous callers and a session too old to list sessions", async () => {
|
|
126
|
+
expect((await send("/account/sessions", {}, null)).status).toBe(401);
|
|
127
|
+
await db`UPDATE "session" SET "createdAt" = ${new Date(now - 2 * DAY_MS).toISOString()} WHERE "id" = 'current'`;
|
|
128
|
+
const stale = await send("/account/sessions");
|
|
129
|
+
expect(stale.status).toBe(403);
|
|
130
|
+
expect(await stale.json()).toEqual({ code: "SESSION_NOT_FRESH" });
|
|
131
|
+
freshAge = 0;
|
|
132
|
+
expect((await send("/account/sessions")).status).toBe(200);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("revokes one of the caller's sessions by id with the token looked up on the server", async () => {
|
|
136
|
+
const response = await send("/account/sessions/other-3", { method: "DELETE", headers: { cookie: "session=1" } });
|
|
137
|
+
|
|
138
|
+
expect(response.status).toBe(204);
|
|
139
|
+
expect(revoke).toHaveBeenCalledTimes(1);
|
|
140
|
+
const [token, headers] = revoke.mock.calls[0] ?? [];
|
|
141
|
+
expect(token).toBe("secret-3");
|
|
142
|
+
expect(headers?.get("cookie")).toBe("session=1");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("does not reveal or revoke another user's session", async () => {
|
|
146
|
+
const response = await send("/account/sessions/foreign", { method: "DELETE" });
|
|
147
|
+
|
|
148
|
+
expect(response.status).toBe(404);
|
|
149
|
+
expect(await response.json()).toEqual({ code: "ACCOUNT_SESSION_NOT_FOUND" });
|
|
150
|
+
expect(revoke).not.toHaveBeenCalled();
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { AppException } from "@podosoft/podokit-contracts";
|
|
2
|
+
import { Elysia, t } from "elysia";
|
|
3
|
+
import type { AuthService, AuthSession } from "../auth/auth.service";
|
|
4
|
+
import type { ElysiaPlugin } from "../core/services";
|
|
5
|
+
import {
|
|
6
|
+
ADMIN_SESSIONS_MAX_LIMIT,
|
|
7
|
+
type AdminSessionsService,
|
|
8
|
+
type AdminSessionView,
|
|
9
|
+
} from "../admin-sessions/admin-sessions.service";
|
|
10
|
+
|
|
11
|
+
export const ACCOUNT_SESSION_NOT_FOUND = "ACCOUNT_SESSION_NOT_FOUND";
|
|
12
|
+
/** Better Auth's code for a session older than `session.freshAge`. */
|
|
13
|
+
export const SESSION_NOT_FRESH = "SESSION_NOT_FRESH";
|
|
14
|
+
|
|
15
|
+
/** One of the caller's own sessions. The bearer token never leaves the server. */
|
|
16
|
+
export interface AccountSessionView {
|
|
17
|
+
id: string;
|
|
18
|
+
ipAddress: string | null;
|
|
19
|
+
userAgent: string | null;
|
|
20
|
+
createdAt: string;
|
|
21
|
+
expiresAt: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AccountSessionPage {
|
|
25
|
+
/** The session making the request, on every page. */
|
|
26
|
+
current: AccountSessionView | null;
|
|
27
|
+
/** The caller's other active sessions, newest first. */
|
|
28
|
+
items: AccountSessionView[];
|
|
29
|
+
nextCursor: string | null;
|
|
30
|
+
/** Every active session of the caller, the current one included. */
|
|
31
|
+
total: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Revokes one of the caller's sessions by its bearer token. */
|
|
35
|
+
export type OwnSessionRevoker = (sessionToken: string, headers: Headers) => Promise<void>;
|
|
36
|
+
|
|
37
|
+
export interface AccountSessionRoutesOptions {
|
|
38
|
+
auth: Pick<AuthService, "requireSession">;
|
|
39
|
+
sessions: AdminSessionsService;
|
|
40
|
+
revoke: OwnSessionRevoker;
|
|
41
|
+
/** Better Auth's `session.freshAge` in seconds; 0 turns the check off. */
|
|
42
|
+
freshAgeSeconds: () => Promise<number>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function accountView(session: AdminSessionView): AccountSessionView {
|
|
46
|
+
return {
|
|
47
|
+
id: session.id,
|
|
48
|
+
ipAddress: session.ipAddress,
|
|
49
|
+
userAgent: session.userAgent,
|
|
50
|
+
createdAt: session.createdAt,
|
|
51
|
+
expiresAt: session.expiresAt,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Better Auth lists sessions only for a fresh session; this list keeps that rule.
|
|
56
|
+
function requireFresh(session: AuthSession, freshAgeSeconds: number): void {
|
|
57
|
+
if (freshAgeSeconds === 0) return;
|
|
58
|
+
const createdAt = new Date(session.session.createdAt as string | Date).getTime();
|
|
59
|
+
if (Date.now() - createdAt >= freshAgeSeconds * 1000) {
|
|
60
|
+
throw new AppException(SESSION_NOT_FRESH, "Session is not fresh", 403);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The signed-in user's own sessions, paged on the server. Better Auth's
|
|
66
|
+
* `/list-sessions` returns at most 100 sessions, which could leave out the
|
|
67
|
+
* current one.
|
|
68
|
+
*/
|
|
69
|
+
export function accountSessionRoutes({
|
|
70
|
+
auth,
|
|
71
|
+
sessions,
|
|
72
|
+
revoke,
|
|
73
|
+
freshAgeSeconds,
|
|
74
|
+
}: AccountSessionRoutesOptions): ElysiaPlugin {
|
|
75
|
+
return new Elysia({ name: "podokit.account.sessions" })
|
|
76
|
+
.get("/account/sessions", async ({ request, query }) => {
|
|
77
|
+
const session = await auth.requireSession(request);
|
|
78
|
+
requireFresh(session, await freshAgeSeconds());
|
|
79
|
+
const currentId = String(session.session.id);
|
|
80
|
+
const [page, current] = await Promise.all([
|
|
81
|
+
sessions.list({ ...query, userId: session.user.id, excludeId: currentId }),
|
|
82
|
+
sessions.find(currentId),
|
|
83
|
+
]);
|
|
84
|
+
const result: AccountSessionPage = {
|
|
85
|
+
current: current ? accountView(current) : null,
|
|
86
|
+
items: page.items.map(accountView),
|
|
87
|
+
nextCursor: page.nextCursor,
|
|
88
|
+
total: page.total + (current ? 1 : 0),
|
|
89
|
+
};
|
|
90
|
+
return result;
|
|
91
|
+
}, {
|
|
92
|
+
query: t.Object({
|
|
93
|
+
cursor: t.Optional(t.String({ minLength: 1, maxLength: 512 })),
|
|
94
|
+
limit: t.Optional(t.Numeric({ minimum: 1, maximum: ADMIN_SESSIONS_MAX_LIMIT })),
|
|
95
|
+
}),
|
|
96
|
+
detail: {
|
|
97
|
+
tags: ["account"],
|
|
98
|
+
summary: "List the current user's active sessions",
|
|
99
|
+
description: "`current` is the session making the request; `items` are the other sessions, newest first, keyset-paged with `cursor`. Session tokens are never returned. A session older than Better Auth's `session.freshAge` gets 403 `SESSION_NOT_FRESH`, as with `/api/auth/list-sessions`.",
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
.delete("/account/sessions/:id", async ({ request, params, set }) => {
|
|
103
|
+
const session = await auth.requireSession(request);
|
|
104
|
+
const token = await sessions.tokenOf(params.id, session.user.id);
|
|
105
|
+
if (!token) throw new AppException(ACCOUNT_SESSION_NOT_FOUND, "Session not found.", 404);
|
|
106
|
+
await revoke(token, request.headers);
|
|
107
|
+
set.status = 204;
|
|
108
|
+
}, {
|
|
109
|
+
params: t.Object({ id: t.String({ minLength: 1, maxLength: 255 }) }),
|
|
110
|
+
detail: { tags: ["account"], summary: "Revoke one of the current user's sessions by id" },
|
|
111
|
+
});
|
|
112
|
+
}
|