@podosoft/podokit 1.4.1 → 1.5.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 (97) hide show
  1. package/dist/deploy-compose-profile.js +2 -2
  2. package/dist/deploy-compose-render.js +3 -1
  3. package/dist/deploy-profile.js +2 -2
  4. package/dist/deploy-render.js +16 -8
  5. package/dist/deploy-schema.d.ts +7 -0
  6. package/dist/deploy-schema.js +40 -0
  7. package/dist/lockfile.d.ts +8 -1
  8. package/dist/lockfile.js +23 -4
  9. package/dist/provider.d.ts +1 -0
  10. package/dist/provider.js +5 -2
  11. package/dist/templates/fullstack/AGENTS.md +27 -2
  12. package/dist/templates/fullstack/README.md +1 -1
  13. package/dist/templates/fullstack/apps/api/package.json +1 -1
  14. package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +4 -0
  15. package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -2
  16. package/dist/templates/fullstack/apps/api/src/app.ts +6 -3
  17. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +28 -0
  18. package/dist/templates/fullstack/apps/api/src/config/providers.ts +4 -2
  19. package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +17 -6
  20. package/dist/templates/fullstack/apps/api/src/config/server-options.ts +5 -8
  21. package/dist/templates/fullstack/apps/api/src/core/client-address.spec.ts +82 -0
  22. package/dist/templates/fullstack/apps/api/src/core/client-address.ts +68 -0
  23. package/dist/templates/fullstack/apps/api/src/core/services.ts +30 -0
  24. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +66 -0
  25. package/dist/templates/fullstack/apps/api/src/database/database.ts +34 -11
  26. package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +54 -6
  27. package/dist/templates/fullstack/apps/web/Dockerfile +5 -1
  28. package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +116 -0
  29. package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +34 -4
  30. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +436 -6
  31. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +352 -38
  32. package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +5 -2
  33. package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +22 -0
  34. package/dist/templates/fullstack/apps/web/src/routes/healthz/+server.ts +13 -0
  35. package/dist/templates/fullstack/dot-env.docker.example +6 -0
  36. package/dist/templates/fullstack/dot-env.example +23 -0
  37. package/dist/templates/fullstack/infra/k3s/configmap.yaml +3 -0
  38. package/dist/templates/fullstack/infra/k3s/web-deployment.yaml +6 -3
  39. package/dist/templates/fullstack/tests/api/health.api.spec.ts +8 -0
  40. package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.spec.ts +152 -0
  41. package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.ts +112 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +24 -0
  43. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +18 -0
  44. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +36 -13
  45. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +13 -1
  46. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +24 -1
  47. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.test.ts +33 -0
  48. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.ts +34 -0
  49. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +62 -38
  50. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +3 -1
  51. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +6 -0
  52. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +5 -0
  53. package/dist/templates/modules/admin-dashboard/files/tests/api/account-sessions.api.spec.ts +55 -0
  54. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +167 -154
  55. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +94 -57
  56. package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +75 -0
  57. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +49 -39
  58. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +26 -0
  59. package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +25 -0
  60. package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +80 -0
  61. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +3 -10
  62. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-sessions.ui.spec.ts +46 -0
  63. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +54 -47
  64. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +15 -11
  65. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +35 -31
  66. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +90 -76
  67. package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +19 -15
  68. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +17 -3
  69. package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +15 -6
  70. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +71 -64
  71. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +89 -79
  72. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +25 -0
  73. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +54 -50
  74. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -1
  75. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +16 -0
  76. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +2 -1
  77. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +4 -1
  78. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +54 -6
  79. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -4
  80. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +19 -8
  81. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +83 -0
  82. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +51 -7
  83. package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.spec.ts +56 -0
  84. package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.ts +45 -0
  85. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +2 -59
  86. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +0 -65
  87. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +82 -21
  88. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +54 -15
  89. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +43 -5
  90. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +10 -2
  91. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +61 -3
  92. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +48 -15
  93. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +20 -9
  94. package/dist/templates/modules/rate-limit/module.manifest.json +6 -9
  95. package/dist/templates/todo/AGENTS.md +41 -5
  96. package/dist/templates/todo/README.md +1 -1
  97. package/package.json +2 -2
@@ -11,6 +11,8 @@
11
11
  import { deviceLabel } from "#lib/device-label.js";
12
12
  import { toast } from "svelte-sonner";
13
13
  import { api } from "#lib/api.js";
14
+ import { listAccountSessions, revokeAccountSession, type AccountSession } from "#lib/account-sessions.js";
15
+ import { KeysetPages } from "#lib/admin/sessions.js";
14
16
  import { getI18n, fmt, formatDateTime } from "#lib/i18n/index.js";
15
17
  import { untrack } from "svelte";
16
18
  import type { SessionUser } from "../../app.d.ts";
@@ -392,28 +394,34 @@
392
394
  await goto("/login", { invalidateAll: true });
393
395
  }
394
396
 
395
- // Sessions — this user's own devices
396
- /** better-auth's code for "this session is older than `session.freshAge`". */
397
+ // Sessions — this user's own devices. The API pages them, because Better Auth's
398
+ // /list-sessions returns at most 100 and could leave out the current one.
399
+ /** Better Auth's code for "this session is older than `session.freshAge`". */
397
400
  const SESSION_NOT_FRESH = "SESSION_NOT_FRESH";
398
- type Session = { id: string; token: string; userAgent?: string | null; ipAddress?: string | null; createdAt: string | Date };
401
+ type Session = AccountSession & { current: boolean };
399
402
  const PAGE_SIZE = DEFAULT_PAGE_SIZE;
403
+ // The API pages newest first by keyset, so the order is fixed rather than sortable.
404
+ const sessionsSort: SortState = { key: "createdAt", dir: "desc" };
405
+ const sessionPages = new KeysetPages();
400
406
  let sessions = $state<Session[]>([]);
407
+ let sessionsTotal = $state(0);
408
+ let sessionsPagerTotal = $state(0);
401
409
  let sessionsPage = $state(1);
402
- let sessionsSort = $state<SortState | null>(null);
403
410
  let busy = $state(false);
411
+ let latestSessionsRequest = 0;
404
412
  const sessionsColumns: DataTableColumn<Session>[] = [
405
- { key: "userAgent", label: i18n.t.sessions.device, sortable: true, value: (s) => deviceLabel(s.userAgent) ?? s.userAgent ?? "" },
406
- { key: "ipAddress", label: i18n.t.sessions.ip, sortable: true, value: (s) => s.ipAddress ?? "" },
407
- { key: "createdAt", label: i18n.t.sessions.since, sortable: true, value: (s) => new Date(s.createdAt).getTime() },
413
+ { key: "userAgent", label: i18n.t.sessions.device },
414
+ { key: "ipAddress", label: i18n.t.sessions.ip },
415
+ { key: "createdAt", label: i18n.t.sessions.since },
408
416
  { key: "status", label: i18n.t.sessions.status },
409
417
  { key: "actions", label: "", class: "w-10" },
410
418
  ];
411
419
  /**
412
420
  * True when the session is too old to list sessions.
413
421
  *
414
- * better-auth guards `/list-sessions` (and `/unlink-account`) with its freshness
415
- * middleware, and `session.freshAge` defaults to 24 hours. So for anyone who signed
416
- * in yesterday — which is most people, most of the time — this call returns 403
422
+ * The API keeps better-auth's freshness rule for listing sessions, and
423
+ * `session.freshAge` defaults to 24 hours. So for anyone who signed in yesterday —
424
+ * which is most people, most of the time — the list answers 403
417
425
  * `SESSION_NOT_FRESH`. It is not a failure to report as one: the tab has to say what
418
426
  * happened and what to do, or it is a blank table under an untranslated toast.
419
427
  */
@@ -423,32 +431,45 @@
423
431
  await api.auth.signOut();
424
432
  await goto("/login", { invalidateAll: true });
425
433
  }
426
- async function loadSessions(): Promise<void> {
427
- const { data: res, error } = await api.auth.listSessions();
428
- if (error?.code === SESSION_NOT_FRESH) {
429
- // Explained in the panel below, not thrown at the corner of the screen.
430
- sessionsStale = true;
431
- sessions = [];
432
- return;
434
+ /** Loads one page; the current session leads the first. A newer request supersedes any in flight. */
435
+ async function loadSessions(target = 1): Promise<void> {
436
+ if (target === 1) sessionPages.reset();
437
+ const cursor = sessionPages.cursorFor(target);
438
+ if (cursor === undefined) return;
439
+ const request = ++latestSessionsRequest;
440
+ try {
441
+ const result = await listAccountSessions(api, { limit: PAGE_SIZE, cursor });
442
+ if (request !== latestSessionsRequest) return;
443
+ // A revoke can empty the last page; show the one before it instead.
444
+ if (result.items.length === 0 && target > 1) return loadSessions(target - 1);
445
+ sessionsStale = false;
446
+ sessionPages.record(target, result.nextCursor);
447
+ const others = result.items.map((s) => ({ ...s, current: false }));
448
+ sessions = target === 1 && result.current ? [{ ...result.current, current: true }, ...others] : others;
449
+ sessionsTotal = result.total;
450
+ sessionsPagerTotal = sessionPages.pagerTotal(result.total - (result.current ? 1 : 0), PAGE_SIZE);
451
+ sessionsPage = target;
452
+ } catch (error) {
453
+ if (request !== latestSessionsRequest) return;
454
+ if (error instanceof ApiError && error.code === SESSION_NOT_FRESH) {
455
+ // Explained in the panel below, not thrown at the corner of the screen.
456
+ sessionsStale = true;
457
+ sessions = [];
458
+ return;
459
+ }
460
+ toast.error(i18n.t.sessions.loadFailed);
433
461
  }
434
- sessionsStale = false;
435
- if (error) return void toast.error(error.message ?? i18n.t.sessions.loadFailed);
436
- // Current session first (users expect it on top; also keeps it on page 1),
437
- // then newest to oldest.
438
- sessions = ((res ?? []) as Session[]).sort((a, b) => {
439
- if (a.id === data.currentSessionId) return -1;
440
- if (b.id === data.currentSessionId) return 1;
441
- return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
442
- });
443
462
  }
444
- async function revoke(token: string): Promise<void> {
463
+ async function revoke(id: string): Promise<void> {
445
464
  busy = true;
446
- const { error } = await api.auth.revokeSession({ token });
447
- busy = false;
448
- if (error) toast.error(error.message ?? i18n.t.sessions.revokeFailed);
449
- else {
465
+ try {
466
+ await revokeAccountSession(api, id);
450
467
  toast.success(i18n.t.sessions.revoked);
451
- await loadSessions();
468
+ await loadSessions(sessionsPage);
469
+ } catch {
470
+ toast.error(i18n.t.sessions.revokeFailed);
471
+ } finally {
472
+ busy = false;
452
473
  }
453
474
  }
454
475
  async function signOutOthers(): Promise<void> {
@@ -824,7 +845,7 @@
824
845
  <Card.Root>
825
846
  <Card.Header class="flex flex-row items-center justify-between gap-2">
826
847
  <Card.Title>{i18n.t.sessions.title}</Card.Title>
827
- <Button variant="outline" size="sm" disabled={busy || sessionsStale || sessions.length <= 1} onclick={signOutOthers}>{i18n.t.sessions.signOutOthers}</Button>
848
+ <Button variant="outline" size="sm" disabled={busy || sessionsStale || sessionsTotal <= 1} onclick={signOutOthers}>{i18n.t.sessions.signOutOthers}</Button>
828
849
  </Card.Header>
829
850
  <Card.Content>
830
851
  {#if sessionsStale}
@@ -843,20 +864,23 @@
843
864
  columns={sessionsColumns}
844
865
  rows={sessions}
845
866
  getKey={(s) => s.id}
846
- bind:sort={sessionsSort}
867
+ sort={sessionsSort}
868
+ manualSort
869
+ manualPagination
847
870
  bind:page={sessionsPage}
848
871
  perPage={PAGE_SIZE}
849
- label={fmt(i18n.t.sessions.total, { count: sessions.length })}
872
+ total={sessionsPagerTotal}
873
+ label={fmt(i18n.t.sessions.total, { count: sessionsTotal })}
850
874
  empty={i18n.t.sessions.empty}
875
+ onChange={(state) => void loadSessions(state.page)}
851
876
  >
852
877
  {#snippet row(s)}
853
- {@const isCurrent = s.id === data.currentSessionId}
854
878
  <Table.Cell class="max-w-xs truncate" title={s.userAgent ?? ""}>{deviceLabel(s.userAgent) ?? s.userAgent ?? i18n.t.sessions.unknown}</Table.Cell>
855
879
  <Table.Cell class="text-muted-foreground">{s.ipAddress ?? "—"}</Table.Cell>
856
880
  <Table.Cell class="text-muted-foreground">{formatDateTime(s.createdAt)}</Table.Cell>
857
- <Table.Cell>{#if isCurrent}<Badge>{i18n.t.sessions.current}</Badge>{/if}</Table.Cell>
881
+ <Table.Cell>{#if s.current}<Badge>{i18n.t.sessions.current}</Badge>{/if}</Table.Cell>
858
882
  <Table.Cell>
859
- <Button variant="ghost" size="sm" disabled={busy || isCurrent} onclick={() => revoke(s.token)}>{i18n.t.sessions.revoke}</Button>
883
+ <Button variant="ghost" size="sm" disabled={busy || s.current} onclick={() => revoke(s.id)}>{i18n.t.sessions.revoke}</Button>
860
884
  </Table.Cell>
861
885
  {/snippet}
862
886
  </DataTable>
@@ -86,7 +86,9 @@
86
86
  {:else}
87
87
  <Label class="text-sm" for="toolbar-search">{searchFields[0].label}</Label>
88
88
  {/if}
89
- <Input id="toolbar-search" bind:value={search} placeholder={searchPlaceholder} class="max-w-xs flex-1" />
89
+ <!-- The minimum width makes a narrow row wrap the input to its own line
90
+ instead of squeezing it next to the field select. -->
91
+ <Input id="toolbar-search" bind:value={search} placeholder={searchPlaceholder} class="min-w-48 max-w-xs flex-1" />
90
92
  <Button type="submit">{searchButton}</Button>
91
93
  </form>
92
94
  {/if}
@@ -61,6 +61,12 @@ describe("needsRuntimeContext", () => {
61
61
  expect(needsRuntimeContext(request("/apiary", { headers: { accept: NAVIGATION } }))).toBe(true);
62
62
  });
63
63
 
64
+ it("skips them for the web server's health route, which must answer while the API is down", () => {
65
+ expect(needsRuntimeContext(request("/healthz", { headers: { accept: "*/*" } }))).toBe(false);
66
+ expect(needsRuntimeContext(request("/healthz", { headers: { accept: NAVIGATION } }))).toBe(false);
67
+ expect(needsRuntimeContext(request("/healthz/extra", { routeId: null, headers: { accept: NAVIGATION } }))).toBe(true);
68
+ });
69
+
64
70
  it("skips them while prerendering", () => {
65
71
  expect(needsRuntimeContext(request("/pricing", { headers: { accept: NAVIGATION } }), { building: true })).toBe(false);
66
72
  });
@@ -1,5 +1,8 @@
1
1
  import { APP_ASSET_PATHS } from "./asset-paths";
2
2
 
3
+ /** The web server's own health route (`routes/healthz/+server.ts`), which never calls the API. */
4
+ const WEB_HEALTH_PATH = "/healthz";
5
+
3
6
  /** The request fields that decide whether a response can use the session and site settings. */
4
7
  export interface RuntimeContextRequest {
5
8
  url: URL;
@@ -92,6 +95,8 @@ export function needsRuntimeContext(
92
95
  const { pathname } = event.url;
93
96
  // The backend proxy forwards the cookies; the API resolves the session itself.
94
97
  if (underPrefix(pathname, "/api")) return false;
98
+ // The web server's health route answers the probes even while the API is down.
99
+ if (pathname === WEB_HEALTH_PATH) return false;
95
100
  // Remote functions run application code that may read the session.
96
101
  if (event.isRemoteRequest) return true;
97
102
  if (event.route.id === null) {
@@ -0,0 +1,55 @@
1
+ import { signInOnSeparateDevices } from "../helpers/devices";
2
+ import { expect, test } from "../helpers/disposable-users";
3
+
4
+ const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
5
+ const origin = { origin: base };
6
+
7
+ type Page = {
8
+ current: { id: string } | null;
9
+ items: Array<{ id: string }>;
10
+ nextCursor: string | null;
11
+ total: number;
12
+ };
13
+
14
+ test("account sessions page the user's own sessions and revoke one by id", async ({ playwright, disposableUsers }) => {
15
+ const email = `api-account-sessions-${Date.now()}@example.com`;
16
+ const password = "Podokit3e-Str0ng!pw";
17
+ await disposableUsers.create({ email, password, name: "Account sessions" });
18
+ expect(await signInOnSeparateDevices(email, password, 4)).toEqual([200, 200, 200, 200]);
19
+ const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
20
+ expect((await ctx.post("/api/auth/sign-in/email", { data: { email, password } })).ok()).toBeTruthy();
21
+ const current = (await (await ctx.get("/api/auth/get-session")).json()) as { session: { id: string } };
22
+
23
+ const first = await ctx.get("/api/account/sessions?limit=2");
24
+ expect(first.ok()).toBeTruthy();
25
+ const firstText = await first.text();
26
+ expect(firstText).not.toContain("token");
27
+ const page1 = JSON.parse(firstText) as Page;
28
+ expect(page1.current?.id).toBe(current.session.id);
29
+ expect(page1.total).toBe(5);
30
+ expect(page1.items).toHaveLength(2);
31
+ expect(page1.nextCursor).toBeTruthy();
32
+ const page2 = (await (await ctx.get(`/api/account/sessions?limit=2&cursor=${encodeURIComponent(page1.nextCursor ?? "")}`)).json()) as Page;
33
+ expect(page2.current?.id).toBe(current.session.id);
34
+ expect(page2.nextCursor).toBeNull();
35
+ const ids = [...page1.items, ...page2.items].map((item) => item.id);
36
+ expect(new Set(ids).size).toBe(4);
37
+ expect(ids).not.toContain(current.session.id);
38
+
39
+ // Another user's session cannot be revoked through this endpoint.
40
+ const otherEmail = `api-account-sessions-other-${Date.now()}@example.com`;
41
+ await disposableUsers.create({ email: otherEmail, password, name: "Other user" });
42
+ const otherUser = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
43
+ expect((await otherUser.post("/api/auth/sign-in/email", { data: { email: otherEmail, password } })).ok()).toBeTruthy();
44
+ const otherSession = (await (await otherUser.get("/api/auth/get-session")).json()) as { session: { id: string } };
45
+ const foreign = await ctx.delete(`/api/account/sessions/${encodeURIComponent(otherSession.session.id)}`);
46
+ expect(foreign.status()).toBe(404);
47
+ expect(await foreign.json()).toMatchObject({ error: { code: "ACCOUNT_SESSION_NOT_FOUND" } });
48
+ expect((await otherUser.get("/api/auth/get-session")).ok()).toBeTruthy();
49
+ await otherUser.dispose();
50
+
51
+ const revoked = await ctx.delete(`/api/account/sessions/${encodeURIComponent(ids[0] ?? "")}`);
52
+ expect(revoked.status()).toBe(204);
53
+ expect(((await (await ctx.get("/api/account/sessions?limit=2")).json()) as Page).total).toBe(4);
54
+ await ctx.dispose();
55
+ });
@@ -4,6 +4,7 @@ import {
4
4
  isUserCreationAllowed,
5
5
  } from "../../apps/api/src/auth/feature-gate";
6
6
  import { ADMIN } from "../helpers/accounts";
7
+ import { exclusive } from "../helpers/exclusive";
7
8
 
8
9
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
9
10
  const origin = { origin: base };
@@ -17,62 +18,68 @@ async function adminCtx(playwright: import("@playwright/test").PlaywrightWorkerA
17
18
  }
18
19
 
19
20
  test("admin can enable Google OAuth from the DB and it applies without a restart", async ({ playwright }) => {
20
- const admin = await adminCtx(playwright);
21
- await admin.put("/api/account/auth-config", { data: { social: { google: { enabled: false } } } }); // clean start
22
- const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
23
- const social = () => anon.post("/api/auth/sign-in/social", { data: { provider: "google", callbackURL: `${base}/admin` } });
24
-
25
- // Before: provider not configured.
26
- await expect.poll(async () => (await social()).status(), { timeout: 8000 }).toBe(404);
27
-
28
- // Paste credentials via the admin API.
29
- const put = await admin.put("/api/account/auth-config", {
30
- data: { social: { google: { enabled: true, clientId: "dummy-google-client-id", clientSecret: "dummy-google-client-secret" } } },
21
+ test.slow();
22
+ await exclusive("social-providers", async () => {
23
+ const admin = await adminCtx(playwright);
24
+ await admin.put("/api/account/auth-config", { data: { social: { google: { enabled: false } } } }); // clean start
25
+ const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
26
+ const social = () => anon.post("/api/auth/sign-in/social", { data: { provider: "google", callbackURL: `${base}/admin` } });
27
+
28
+ // Before: provider not configured.
29
+ await expect.poll(async () => (await social()).status(), { timeout: 8000 }).toBe(404);
30
+
31
+ // Paste credentials via the admin API.
32
+ const put = await admin.put("/api/account/auth-config", {
33
+ data: { social: { google: { enabled: true, clientId: "dummy-google-client-id", clientSecret: "dummy-google-client-secret" } } },
34
+ });
35
+ expect(put.ok()).toBeTruthy();
36
+ const view = await put.json();
37
+ expect(view.social.google.enabled).toBe(true);
38
+ expect(view.social.google.hasSecret).toBe(true);
39
+ expect(view.social.google.clientId).toBe("dummy-google-client-id");
40
+ expect("clientSecret" in view.social.google).toBe(false); // secret is never returned
41
+
42
+ // Applied live (no restart): social sign-in now redirects to Google.
43
+ await expect
44
+ .poll(async () => {
45
+ const r = await social();
46
+ return r.ok() ? (((await r.json())?.url as string) ?? "") : "";
47
+ }, { timeout: 8000 })
48
+ .toContain("accounts.google.com");
49
+ const caps = await (await anon.get("/api/account/capabilities")).json();
50
+ expect(caps.providers).toContain("google");
51
+
52
+ await admin.put("/api/account/auth-config", { data: { social: { google: { enabled: false } } } }); // restore
53
+ await anon.dispose();
54
+ await admin.dispose();
31
55
  });
32
- expect(put.ok()).toBeTruthy();
33
- const view = await put.json();
34
- expect(view.social.google.enabled).toBe(true);
35
- expect(view.social.google.hasSecret).toBe(true);
36
- expect(view.social.google.clientId).toBe("dummy-google-client-id");
37
- expect("clientSecret" in view.social.google).toBe(false); // secret is never returned
38
-
39
- // Applied live (no restart): social sign-in now redirects to Google.
40
- await expect
41
- .poll(async () => {
42
- const r = await social();
43
- return r.ok() ? (((await r.json())?.url as string) ?? "") : "";
44
- }, { timeout: 8000 })
45
- .toContain("accounts.google.com");
46
- const caps = await (await anon.get("/api/account/capabilities")).json();
47
- expect(caps.providers).toContain("google");
48
-
49
- await admin.put("/api/account/auth-config", { data: { social: { google: { enabled: false } } } }); // restore
50
- await anon.dispose();
51
- await admin.dispose();
52
56
  });
53
57
 
54
58
  test("admin can add and remove a social provider dynamically", async ({ playwright }) => {
55
- const admin = await adminCtx(playwright);
56
- // The catalog offers providers beyond the two legacy env-backed ones.
57
- const catalog = (await (await admin.get("/api/account/auth-config")).json()).catalog as Array<{ id: string }>;
58
- expect(catalog.map((c) => c.id)).toEqual(expect.arrayContaining(["google", "github", "apple"]));
59
+ test.slow();
60
+ await exclusive("social-providers", async () => {
61
+ const admin = await adminCtx(playwright);
62
+ // The catalog offers providers beyond the two legacy env-backed ones.
63
+ const catalog = (await (await admin.get("/api/account/auth-config")).json()).catalog as Array<{ id: string }>;
64
+ expect(catalog.map((c) => c.id)).toEqual(expect.arrayContaining(["google", "github", "apple"]));
65
+
66
+ const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
67
+ const github = () => anon.post("/api/auth/sign-in/social", { data: { provider: "github", callbackURL: `${base}/admin` } });
68
+
69
+ // Add GitHub (a provider with no env fallback) → applies live, no restart.
70
+ await admin.put("/api/account/auth-config", { data: { social: { github: { enabled: true, clientId: "dummy-github-client-id", clientSecret: "dummy-github-client-secret" } } } });
71
+ await expect
72
+ .poll(async () => { const r = await github(); return r.ok() ? (((await r.json())?.url as string) ?? "") : ""; }, { timeout: 8000 })
73
+ .toContain("github.com");
74
+
75
+ // Remove it → the row is deleted and the provider stops working (again live).
76
+ await admin.put("/api/account/auth-config", { data: { social: { github: { delete: true } } } });
77
+ expect("github" in (await (await admin.get("/api/account/auth-config")).json()).social).toBe(false);
78
+ await expect.poll(async () => (await github()).status(), { timeout: 8000 }).toBe(404);
59
79
 
60
- const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
61
- const github = () => anon.post("/api/auth/sign-in/social", { data: { provider: "github", callbackURL: `${base}/admin` } });
62
-
63
- // Add GitHub (a provider with no env fallback) → applies live, no restart.
64
- await admin.put("/api/account/auth-config", { data: { social: { github: { enabled: true, clientId: "dummy-github-client-id", clientSecret: "dummy-github-client-secret" } } } });
65
- await expect
66
- .poll(async () => { const r = await github(); return r.ok() ? (((await r.json())?.url as string) ?? "") : ""; }, { timeout: 8000 })
67
- .toContain("github.com");
68
-
69
- // Remove it → the row is deleted and the provider stops working (again live).
70
- await admin.put("/api/account/auth-config", { data: { social: { github: { delete: true } } } });
71
- expect("github" in (await (await admin.get("/api/account/auth-config")).json()).social).toBe(false);
72
- await expect.poll(async () => (await github()).status(), { timeout: 8000 }).toBe(404);
73
-
74
- await anon.dispose();
75
- await admin.dispose();
80
+ await anon.dispose();
81
+ await admin.dispose();
82
+ });
76
83
  });
77
84
 
78
85
  test("auth-config is admin-only and never leaks secrets @smoke", async ({ playwright }) => {
@@ -85,43 +92,46 @@ test("auth-config is admin-only and never leaks secrets @smoke", async ({ playwr
85
92
  });
86
93
 
87
94
  test("automatic logout validates and applies a sliding session lifetime", async ({ playwright }) => {
88
- const admin = await adminCtx(playwright);
89
- const freshAdmin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
90
-
91
- try {
92
- expect(
93
- (await admin.put("/api/account/auth-config", {
94
- data: { server: { sessionIdleTimeoutMinutes: 4 } },
95
- })).status(),
96
- ).toBe(400);
97
-
98
- const enabled = await admin.put("/api/account/auth-config", {
99
- data: { server: { sessionIdleTimeoutMinutes: 30 } },
100
- });
101
- expect(enabled.ok()).toBeTruthy();
102
- expect((await enabled.json()).server.sessionIdleTimeoutMinutes).toBe(30);
103
-
104
- const caps = await (await admin.get("/api/account/capabilities")).json();
105
- expect(caps.sessionIdleTimeoutMinutes).toBe(30);
106
-
107
- const signIn = await freshAdmin.post("/api/auth/sign-in/email", {
108
- data: { email: ADMIN.email, password: ADMIN.password },
109
- });
110
- expect(signIn.ok()).toBeTruthy();
111
- expect(signIn.headers()["set-cookie"]).toMatch(/Max-Age=1800(?:;|,)/i);
112
-
113
- const sessions = (await (await admin.get("/api/auth/list-sessions")).json()) as Array<{
114
- expiresAt: string;
115
- }>;
116
- const latestExpiry = Math.max(...sessions.map((session) => new Date(session.expiresAt).getTime()));
117
- expect(latestExpiry).toBeLessThanOrEqual(Date.now() + 30 * 60_000 + 10_000);
118
- } finally {
119
- await admin.put("/api/account/auth-config", {
120
- data: { server: { sessionIdleTimeoutMinutes: null } },
121
- });
122
- await freshAdmin.dispose();
123
- await admin.dispose();
124
- }
95
+ test.slow();
96
+ await exclusive("session-idle-timeout", async () => {
97
+ const admin = await adminCtx(playwright);
98
+ const freshAdmin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
99
+
100
+ try {
101
+ expect(
102
+ (await admin.put("/api/account/auth-config", {
103
+ data: { server: { sessionIdleTimeoutMinutes: 4 } },
104
+ })).status(),
105
+ ).toBe(400);
106
+
107
+ const enabled = await admin.put("/api/account/auth-config", {
108
+ data: { server: { sessionIdleTimeoutMinutes: 30 } },
109
+ });
110
+ expect(enabled.ok()).toBeTruthy();
111
+ expect((await enabled.json()).server.sessionIdleTimeoutMinutes).toBe(30);
112
+
113
+ const caps = await (await admin.get("/api/account/capabilities")).json();
114
+ expect(caps.sessionIdleTimeoutMinutes).toBe(30);
115
+
116
+ const signIn = await freshAdmin.post("/api/auth/sign-in/email", {
117
+ data: { email: ADMIN.email, password: ADMIN.password },
118
+ });
119
+ expect(signIn.ok()).toBeTruthy();
120
+ expect(signIn.headers()["set-cookie"]).toMatch(/Max-Age=1800(?:;|,)/i);
121
+
122
+ const sessions = (await (await admin.get("/api/auth/list-sessions")).json()) as Array<{
123
+ expiresAt: string;
124
+ }>;
125
+ const latestExpiry = Math.max(...sessions.map((session) => new Date(session.expiresAt).getTime()));
126
+ expect(latestExpiry).toBeLessThanOrEqual(Date.now() + 30 * 60_000 + 10_000);
127
+ } finally {
128
+ await admin.put("/api/account/auth-config", {
129
+ data: { server: { sessionIdleTimeoutMinutes: null } },
130
+ });
131
+ await freshAdmin.dispose();
132
+ await admin.dispose();
133
+ }
134
+ });
125
135
  });
126
136
 
127
137
  test("new registrations require approval when the policy is enabled", async ({ playwright }) => {
@@ -204,75 +214,78 @@ test("new registrations require approval when the policy is enabled", async ({ p
204
214
  });
205
215
 
206
216
  test("closed public sign-up blocks new OAuth users but preserves existing sign-in", async ({ playwright }) => {
207
- const admin = await adminCtx(playwright);
208
- const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
209
- const password = "Podokit3e-Str0ng!pw";
210
- const adminCreatedEmail = `closed-admin-${Date.now()}@example.com`;
211
- const initialSite = (await (await anon.get("/api/site/settings")).json()) as {
212
- allowSignup?: string | null;
213
- };
214
- const initialAllowSignup = initialSite.allowSignup === "false" ? "false" : "true";
215
- let adminCreatedId = "";
216
-
217
- try {
218
- expect(
219
- (await admin.put("/api/account/auth-config", {
220
- data: {
221
- social: {
222
- google: {
223
- enabled: true,
224
- clientId: "dummy-google-client-id",
225
- clientSecret: "dummy-google-client-secret",
217
+ test.slow();
218
+ await exclusive(["site-general", "social-providers"], async () => {
219
+ const admin = await adminCtx(playwright);
220
+ const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
221
+ const password = "Podokit3e-Str0ng!pw";
222
+ const adminCreatedEmail = `closed-admin-${Date.now()}@example.com`;
223
+ const initialSite = (await (await anon.get("/api/site/settings")).json()) as {
224
+ allowSignup?: string | null;
225
+ };
226
+ const initialAllowSignup = initialSite.allowSignup === "false" ? "false" : "true";
227
+ let adminCreatedId = "";
228
+
229
+ try {
230
+ expect(
231
+ (await admin.put("/api/account/auth-config", {
232
+ data: {
233
+ social: {
234
+ google: {
235
+ enabled: true,
236
+ clientId: "dummy-google-client-id",
237
+ clientSecret: "dummy-google-client-secret",
238
+ },
226
239
  },
227
240
  },
228
- },
229
- })).ok(),
230
- ).toBeTruthy();
231
- expect((await admin.put("/api/site/settings", { data: { allowSignup: "false" } })).ok()).toBeTruthy();
241
+ })).ok(),
242
+ ).toBeTruthy();
243
+ expect((await admin.put("/api/site/settings", { data: { allowSignup: "false" } })).ok()).toBeTruthy();
232
244
 
233
- // The site policy uses a short cache shared by sign-up requests and user
234
- // creation hooks, so wait for the setting to become live.
235
- await new Promise((resolve) => setTimeout(resolve, 3_200));
236
-
237
- const signup = await anon.post("/api/auth/sign-up/email", {
238
- data: {
239
- email: `closed-${Date.now()}@example.com`,
240
- password,
241
- name: "Closed Registration",
242
- },
243
- });
244
- expect(signup.status()).toBe(403);
245
- expect((await signup.json()).code).toBe(PUBLIC_SIGNUP_DISABLED);
245
+ // The site policy uses a short cache shared by sign-up requests and user
246
+ // creation hooks, so wait for the setting to become live.
247
+ await new Promise((resolve) => setTimeout(resolve, 3_200));
246
248
 
247
- // OAuth initiation must stay available for existing social users. Only a
248
- // callback that attempts to create a new user is rejected by the DB hook.
249
- const social = await anon.post("/api/auth/sign-in/social", {
250
- data: { provider: "google", callbackURL: `${base}/` },
251
- });
252
- expect(social.ok()).toBeTruthy();
253
- expect(((await social.json()).url as string)).toContain("accounts.google.com");
254
- expect(isUserCreationAllowed(false, "/callback/google")).toBe(false);
255
- expect(isUserCreationAllowed(false, undefined)).toBe(false);
256
-
257
- // Deliberate administrator provisioning remains available while the public
258
- // site is closed.
259
- expect(isUserCreationAllowed(false, "/admin/create-user")).toBe(true);
260
- const adminCreated = await admin.post("/api/auth/admin/create-user", {
261
- data: { email: adminCreatedEmail, password, name: "Admin Created", role: "user" },
262
- });
263
- expect(adminCreated.ok()).toBeTruthy();
264
- adminCreatedId = ((await adminCreated.json()).user as { id: string }).id;
265
- } finally {
266
- if (adminCreatedId) {
267
- await admin.post("/api/auth/admin/remove-user", { data: { userId: adminCreatedId } });
249
+ const signup = await anon.post("/api/auth/sign-up/email", {
250
+ data: {
251
+ email: `closed-${Date.now()}@example.com`,
252
+ password,
253
+ name: "Closed Registration",
254
+ },
255
+ });
256
+ expect(signup.status()).toBe(403);
257
+ expect((await signup.json()).code).toBe(PUBLIC_SIGNUP_DISABLED);
258
+
259
+ // OAuth initiation must stay available for existing social users. Only a
260
+ // callback that attempts to create a new user is rejected by the DB hook.
261
+ const social = await anon.post("/api/auth/sign-in/social", {
262
+ data: { provider: "google", callbackURL: `${base}/` },
263
+ });
264
+ expect(social.ok()).toBeTruthy();
265
+ expect(((await social.json()).url as string)).toContain("accounts.google.com");
266
+ expect(isUserCreationAllowed(false, "/callback/google")).toBe(false);
267
+ expect(isUserCreationAllowed(false, undefined)).toBe(false);
268
+
269
+ // Deliberate administrator provisioning remains available while the public
270
+ // site is closed.
271
+ expect(isUserCreationAllowed(false, "/admin/create-user")).toBe(true);
272
+ const adminCreated = await admin.post("/api/auth/admin/create-user", {
273
+ data: { email: adminCreatedEmail, password, name: "Admin Created", role: "user" },
274
+ });
275
+ expect(adminCreated.ok()).toBeTruthy();
276
+ adminCreatedId = ((await adminCreated.json()).user as { id: string }).id;
277
+ } finally {
278
+ if (adminCreatedId) {
279
+ await admin.post("/api/auth/admin/remove-user", { data: { userId: adminCreatedId } });
280
+ }
281
+ await admin.put("/api/site/settings", { data: { allowSignup: initialAllowSignup } });
282
+ await admin.put("/api/account/auth-config", {
283
+ data: { social: { google: { enabled: false } } },
284
+ });
285
+ await new Promise((resolve) => setTimeout(resolve, 3_200));
286
+ await anon.get("/api/auth/get-session");
287
+ await anon.dispose();
288
+ await admin.dispose();
268
289
  }
269
- await admin.put("/api/site/settings", { data: { allowSignup: initialAllowSignup } });
270
- await admin.put("/api/account/auth-config", {
271
- data: { social: { google: { enabled: false } } },
272
- });
273
- await new Promise((resolve) => setTimeout(resolve, 3_200));
274
- await anon.get("/api/auth/get-session");
275
- await anon.dispose();
276
- await admin.dispose();
277
- }
290
+ });
278
291
  });