@podosoft/podokit 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/add.d.ts +3 -1
  2. package/dist/add.js +116 -14
  3. package/dist/index.js +8 -0
  4. package/dist/lib.d.ts +2 -2
  5. package/dist/lib.js +3 -2
  6. package/dist/lockfile.d.ts +3 -1
  7. package/dist/lockfile.js +17 -6
  8. package/dist/templates/fullstack/apps/api/scripts/build.mjs +1 -0
  9. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +1 -0
  10. package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +15 -0
  11. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +3 -0
  12. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +1 -1
  13. package/dist/templates/fullstack/apps/web/package.json +1 -1
  14. package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +8 -1
  15. package/dist/templates/fullstack/tests/playwright.extensions.ts +2 -2
  16. package/dist/templates/modules/admin-dashboard/files/apps/api/scripts/bootstrap-admin.mjs +21 -26
  17. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +74 -0
  18. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.spec.ts +76 -0
  19. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.ts +41 -0
  20. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.spec.ts +124 -0
  21. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.ts +153 -0
  22. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +5 -0
  23. package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +60 -0
  24. package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +35 -0
  25. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +59 -32
  26. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +81 -11
  27. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.test.ts +23 -0
  28. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.ts +2 -0
  29. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/public-paths.ts +9 -0
  30. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +13 -19
  31. package/dist/templates/modules/admin-dashboard/files/tests/api/admin-users.api.spec.ts +86 -0
  32. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +1 -0
  33. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +1 -1
  34. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -2
  35. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +2 -2
  36. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +0 -1
  37. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -3
  38. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +3 -1
  39. package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +14 -15
  40. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +13 -8
  41. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +1 -1
  42. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +14 -3
  43. package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -2
  44. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +53 -11
  45. package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +26 -4
  46. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.spec.ts +53 -0
  47. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.ts +65 -0
  48. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +16 -37
  49. package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.spec.ts +33 -0
  50. package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.ts +29 -0
  51. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +2 -0
  52. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +2 -2
  53. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +48 -1
  54. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +15 -2
  55. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +35 -0
  56. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +18 -1
  57. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +46 -0
  58. package/dist/templates/modules/sse/module.manifest.json +3 -1
  59. package/dist/update.d.ts +17 -0
  60. package/dist/update.js +33 -8
  61. package/package.json +1 -1
@@ -5,16 +5,24 @@ import { revertLatestSqliteMigration, runSqliteMigrations } from "../database/sq
5
5
  import { InitAuditLogs1720300000000 } from "../migrations/1720300000000-InitAuditLogs";
6
6
  import { IndexAuditLogFilters1792000000000 } from "../migrations/1792000000000-IndexAuditLogFilters";
7
7
 
8
- function runner(type: "postgres" | "better-sqlite3", queries: string[]): QueryRunner {
8
+ function runner(
9
+ type: "postgres" | "better-sqlite3",
10
+ queries: string[],
11
+ invalidIndexes: string[] = [],
12
+ ): QueryRunner {
9
13
  return {
10
14
  connection: { options: { type } },
11
- query: (sql: string): Promise<unknown[]> => {
15
+ query: (sql: string, parameters: unknown[] = []): Promise<unknown[]> => {
12
16
  queries.push(sql);
13
- return Promise.resolve([]);
17
+ const invalid = sql.includes("indisvalid") && invalidIndexes.some((name) => parameters[0] === `"${name}"`);
18
+ return Promise.resolve(invalid ? [{ invalid: 1 }] : []);
14
19
  },
15
20
  } as unknown as QueryRunner;
16
21
  }
17
22
 
23
+ const INVALID_CHECK =
24
+ "SELECT 1 AS \"invalid\" FROM pg_index WHERE indexrelid = to_regclass($1) AND NOT indisvalid";
25
+
18
26
  const FILTER_INDEXES = [
19
27
  "IDX_audit_logs_action_createdAt",
20
28
  "IDX_audit_logs_actorEmail_createdAt",
@@ -61,20 +69,41 @@ describe("audit log migration", () => {
61
69
  });
62
70
 
63
71
  describe("audit filter index migration", () => {
64
- it("replaces the single-column indexes on PostgreSQL and restores them on revert", async () => {
72
+ it("builds and drops the PostgreSQL indexes concurrently outside a transaction", async () => {
65
73
  const migration = new IndexAuditLogFilters1792000000000();
74
+ // A transaction block would reject CONCURRENTLY; the runner must use "each".
75
+ expect(migration.transaction).toBe(false);
66
76
  const up: string[] = [];
67
77
  await migration.up(runner("postgres", up));
68
78
  expect(up).toEqual([
69
- 'CREATE INDEX IF NOT EXISTS "IDX_audit_logs_createdAt_id" ON "audit_logs" ("createdAt", "id")',
70
- 'CREATE INDEX IF NOT EXISTS "IDX_audit_logs_action_createdAt" ON "audit_logs" ("action", "createdAt")',
71
- 'CREATE INDEX IF NOT EXISTS "IDX_audit_logs_actorEmail_createdAt" ON "audit_logs" ("actorEmail", "createdAt")',
72
- 'CREATE INDEX IF NOT EXISTS "IDX_audit_logs_targetLabel_createdAt" ON "audit_logs" ("targetLabel", "createdAt")',
73
- 'DROP INDEX IF EXISTS "IDX_audit_logs_action"',
74
- 'DROP INDEX IF EXISTS "IDX_audit_logs_createdAt"',
79
+ INVALID_CHECK,
80
+ 'CREATE INDEX CONCURRENTLY IF NOT EXISTS "IDX_audit_logs_createdAt_id" ON "audit_logs" ("createdAt", "id")',
81
+ INVALID_CHECK,
82
+ 'CREATE INDEX CONCURRENTLY IF NOT EXISTS "IDX_audit_logs_action_createdAt" ON "audit_logs" ("action", "createdAt")',
83
+ INVALID_CHECK,
84
+ 'CREATE INDEX CONCURRENTLY IF NOT EXISTS "IDX_audit_logs_actorEmail_createdAt" ON "audit_logs" ("actorEmail", "createdAt")',
85
+ INVALID_CHECK,
86
+ 'CREATE INDEX CONCURRENTLY IF NOT EXISTS "IDX_audit_logs_targetLabel_createdAt" ON "audit_logs" ("targetLabel", "createdAt")',
87
+ 'DROP INDEX CONCURRENTLY IF EXISTS "IDX_audit_logs_action"',
88
+ 'DROP INDEX CONCURRENTLY IF EXISTS "IDX_audit_logs_createdAt"',
75
89
  ]);
76
- expect(up.join("\n")).not.toContain("CONCURRENTLY");
90
+ });
91
+
92
+ it("rebuilds an invalid index left by an interrupted concurrent build", async () => {
93
+ const up: string[] = [];
94
+ await new IndexAuditLogFilters1792000000000().up(
95
+ runner("postgres", up, ["IDX_audit_logs_action_createdAt"]),
96
+ );
97
+ const drop = up.indexOf('DROP INDEX CONCURRENTLY IF EXISTS "IDX_audit_logs_action_createdAt"');
98
+ expect(drop).toBeGreaterThan(-1);
99
+ expect(up[drop + 1]).toBe(
100
+ 'CREATE INDEX CONCURRENTLY IF NOT EXISTS "IDX_audit_logs_action_createdAt" ON "audit_logs" ("action", "createdAt")',
101
+ );
102
+ expect(up.filter((sql) => sql.startsWith("DROP INDEX CONCURRENTLY"))).toHaveLength(3);
103
+ });
77
104
 
105
+ it("restores the single-column PostgreSQL indexes on revert inside its transaction", async () => {
106
+ const migration = new IndexAuditLogFilters1792000000000();
78
107
  const down: string[] = [];
79
108
  await migration.down(runner("postgres", down));
80
109
  expect(down).toEqual([
@@ -87,6 +116,19 @@ describe("audit filter index migration", () => {
87
116
  ]);
88
117
  });
89
118
 
119
+ it("keeps plain index statements on SQLite", async () => {
120
+ const up: string[] = [];
121
+ await new IndexAuditLogFilters1792000000000().up(runner("better-sqlite3", up));
122
+ expect(up).toEqual([
123
+ 'CREATE INDEX IF NOT EXISTS "IDX_audit_logs_createdAt_id" ON "audit_logs" ("createdAt", "id")',
124
+ 'CREATE INDEX IF NOT EXISTS "IDX_audit_logs_action_createdAt" ON "audit_logs" ("action", "createdAt")',
125
+ 'CREATE INDEX IF NOT EXISTS "IDX_audit_logs_actorEmail_createdAt" ON "audit_logs" ("actorEmail", "createdAt")',
126
+ 'CREATE INDEX IF NOT EXISTS "IDX_audit_logs_targetLabel_createdAt" ON "audit_logs" ("targetLabel", "createdAt")',
127
+ 'DROP INDEX IF EXISTS "IDX_audit_logs_action"',
128
+ 'DROP INDEX IF EXISTS "IDX_audit_logs_createdAt"',
129
+ ]);
130
+ });
131
+
90
132
  it("migrates up and down on SQLite", async () => {
91
133
  const database = new Database(":memory:");
92
134
  const migrations = [
@@ -2,8 +2,8 @@ import type { MigrationInterface, QueryRunner } from "typeorm";
2
2
 
3
3
  // Indexes for the keyset list: ("createdAt", "id") serves the unfiltered pages and
4
4
  // the retention prune, and each filter gets its column followed by "createdAt".
5
- // IF NOT EXISTS lets an operator build them beforehand with CREATE INDEX
6
- // CONCURRENTLY on a large PostgreSQL table; migrations run in one transaction.
5
+ // PostgreSQL builds and drops them concurrently so audit writes continue on a
6
+ // large table. IF NOT EXISTS keeps indexes an operator already built.
7
7
  const INDEXES: Array<[name: string, columns: string]> = [
8
8
  ["IDX_audit_logs_createdAt_id", `"createdAt", "id"`],
9
9
  ["IDX_audit_logs_action_createdAt", `"action", "createdAt"`],
@@ -18,14 +18,36 @@ const REPLACED: Array<[name: string, columns: string]> = [
18
18
 
19
19
  export class IndexAuditLogFilters1792000000000 implements MigrationInterface {
20
20
  name = "IndexAuditLogFilters1792000000000";
21
+ // CONCURRENTLY cannot run inside a transaction block. The runner therefore
22
+ // uses the "each" transaction mode. SQLite migrations always use one.
23
+ transaction = false;
21
24
 
22
25
  async up(queryRunner: QueryRunner): Promise<void> {
26
+ if (queryRunner.connection.options.type !== "postgres") {
27
+ for (const [name, columns] of INDEXES) {
28
+ await queryRunner.query(`CREATE INDEX IF NOT EXISTS "${name}" ON "audit_logs" (${columns})`);
29
+ }
30
+ for (const [name] of REPLACED) await queryRunner.query(`DROP INDEX IF EXISTS "${name}"`);
31
+ return;
32
+ }
23
33
  for (const [name, columns] of INDEXES) {
24
- await queryRunner.query(`CREATE INDEX IF NOT EXISTS "${name}" ON "audit_logs" (${columns})`);
34
+ // An interrupted concurrent build leaves an invalid index that
35
+ // IF NOT EXISTS would keep, so drop it and build again.
36
+ const invalid = await queryRunner.query(
37
+ `SELECT 1 AS "invalid" FROM pg_index WHERE indexrelid = to_regclass($1) AND NOT indisvalid`,
38
+ [`"${name}"`],
39
+ ) as unknown[];
40
+ if (invalid.length > 0) await queryRunner.query(`DROP INDEX CONCURRENTLY IF EXISTS "${name}"`);
41
+ await queryRunner.query(
42
+ `CREATE INDEX CONCURRENTLY IF NOT EXISTS "${name}" ON "audit_logs" (${columns})`,
43
+ );
44
+ }
45
+ for (const [name] of REPLACED) {
46
+ await queryRunner.query(`DROP INDEX CONCURRENTLY IF EXISTS "${name}"`);
25
47
  }
26
- for (const [name] of REPLACED) await queryRunner.query(`DROP INDEX IF EXISTS "${name}"`);
27
48
  }
28
49
 
50
+ // A revert runs inside a transaction, so it uses plain statements.
29
51
  async down(queryRunner: QueryRunner): Promise<void> {
30
52
  for (const [name, columns] of REPLACED) {
31
53
  await queryRunner.query(`CREATE INDEX IF NOT EXISTS "${name}" ON "audit_logs" (${columns})`);
@@ -0,0 +1,53 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { SQL } from "bun";
3
+ import type { QueryRunner } from "typeorm";
4
+ import { InitAuthConfig1720500000000 } from "../migrations/1720500000000-InitAuthConfig";
5
+ import { readAuthConfigRow, readAuthConfigRows, writeAuthConfigRow } from "./auth-config.rows";
6
+
7
+ let open: SQL | undefined;
8
+
9
+ afterEach(async () => {
10
+ await open?.close();
11
+ open = undefined;
12
+ });
13
+
14
+ async function sqliteDatabase(): Promise<SQL> {
15
+ const sql = new SQL(":memory:");
16
+ open = sql;
17
+ const runner = {
18
+ connection: { options: { type: "better-sqlite3" } },
19
+ query: (query: string): Promise<unknown> => sql.unsafe(query),
20
+ } as unknown as QueryRunner;
21
+ await new InitAuthConfig1720500000000().up(runner);
22
+ return sql;
23
+ }
24
+
25
+ describe("auth config rows on SQLite", () => {
26
+ it("reads the integer enabled column back as a boolean", async () => {
27
+ const sql = await sqliteDatabase();
28
+ await writeAuthConfigRow(sql, "sqlite", { key: "smtp", enabled: true, config: { host: "mail" }, secret: null });
29
+ await writeAuthConfigRow(sql, "sqlite", { key: "social.github", enabled: false, config: {}, secret: null });
30
+
31
+ expect(await readAuthConfigRow(sql, "smtp")).toEqual({
32
+ key: "smtp",
33
+ enabled: true,
34
+ config: JSON.stringify({ host: "mail" }),
35
+ secret: null,
36
+ });
37
+ const rows = await readAuthConfigRows(sql);
38
+ expect(rows.map((row) => [row.key, row.enabled])).toEqual([
39
+ ["smtp", true],
40
+ ["social.github", false],
41
+ ]);
42
+ expect(await readAuthConfigRow(sql, "server")).toBeNull();
43
+ });
44
+
45
+ it("keeps a boolean through a read and rewrite", async () => {
46
+ const sql = await sqliteDatabase();
47
+ await writeAuthConfigRow(sql, "sqlite", { key: "smtp", enabled: true, config: {}, secret: null });
48
+ const current = await readAuthConfigRow(sql, "smtp");
49
+ if (!current) throw new Error("expected the smtp row");
50
+ await writeAuthConfigRow(sql, "sqlite", { ...current, secret: "sealed" });
51
+ expect((await readAuthConfigRow(sql, "smtp"))?.enabled).toBe(true);
52
+ });
53
+ });
@@ -0,0 +1,65 @@
1
+ import type { SQL } from "bun";
2
+ import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
3
+ import { authConfigParam } from "./config-param";
4
+
5
+ export interface AuthConfigRow {
6
+ key: string;
7
+ enabled: boolean;
8
+ config: unknown;
9
+ secret: string | null;
10
+ }
11
+
12
+ function configObject(value: unknown): Record<string, unknown> {
13
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
14
+ return value as Record<string, unknown>;
15
+ }
16
+ if (typeof value === "string") {
17
+ try {
18
+ return configObject(JSON.parse(value) as unknown);
19
+ } catch {
20
+ return {};
21
+ }
22
+ }
23
+ return {};
24
+ }
25
+
26
+ // SQLite stores booleans as 0 or 1.
27
+ interface StoredAuthConfigRow extends Omit<AuthConfigRow, "enabled"> {
28
+ enabled: boolean | number;
29
+ }
30
+
31
+ function authConfigRow(row: StoredAuthConfigRow): AuthConfigRow {
32
+ return { ...row, enabled: row.enabled === true || row.enabled === 1 };
33
+ }
34
+
35
+ export async function readAuthConfigRows(sql: SQL): Promise<AuthConfigRow[]> {
36
+ const rows = await sql<StoredAuthConfigRow[]>`
37
+ SELECT "key", "enabled", "config", "secret" FROM "auth_config"
38
+ `;
39
+ return rows.map(authConfigRow);
40
+ }
41
+
42
+ export async function readAuthConfigRow(sql: SQL, key: string): Promise<AuthConfigRow | null> {
43
+ const rows = await sql<StoredAuthConfigRow[]>`
44
+ SELECT "key", "enabled", "config", "secret"
45
+ FROM "auth_config" WHERE "key" = ${key}
46
+ `;
47
+ return rows[0] ? authConfigRow(rows[0]) : null;
48
+ }
49
+
50
+ export async function writeAuthConfigRow(
51
+ sql: SQL,
52
+ provider: DatabaseProviderName,
53
+ row: AuthConfigRow,
54
+ ): Promise<void> {
55
+ const config = authConfigParam(provider, configObject(row.config));
56
+ await sql`
57
+ INSERT INTO "auth_config" ("key", "enabled", "config", "secret", "updatedAt")
58
+ VALUES (${row.key}, ${row.enabled}, ${config}, ${row.secret}, CURRENT_TIMESTAMP)
59
+ ON CONFLICT ("key") DO UPDATE SET
60
+ "enabled" = EXCLUDED."enabled",
61
+ "config" = EXCLUDED."config",
62
+ "secret" = EXCLUDED."secret",
63
+ "updatedAt" = CURRENT_TIMESTAMP
64
+ `;
65
+ }
@@ -12,7 +12,13 @@ import {
12
12
  } from "@podosoft/podokit-auth";
13
13
  import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
14
14
  import { refreshAuthNow } from "../auth/auth-provider";
15
- import { authConfigParam } from "./config-param";
15
+ import {
16
+ readAuthConfigRow,
17
+ readAuthConfigRows,
18
+ writeAuthConfigRow,
19
+ type AuthConfigRow,
20
+ } from "./auth-config.rows";
21
+ import { resetSessionExpirations } from "./session-expiry";
16
22
 
17
23
  export type SocialProviderView = {
18
24
  id: string;
@@ -73,13 +79,6 @@ export type AuthConfigUpdate = {
73
79
  };
74
80
  };
75
81
 
76
- interface AuthConfigRow {
77
- key: string;
78
- enabled: boolean;
79
- config: unknown;
80
- secret: string | null;
81
- }
82
-
83
82
  function object(value: unknown): Record<string, unknown> {
84
83
  if (value !== null && typeof value === "object" && !Array.isArray(value)) {
85
84
  return value as Record<string, unknown>;
@@ -110,30 +109,15 @@ export class AuthConfigService {
110
109
  ) {}
111
110
 
112
111
  private async rows(): Promise<AuthConfigRow[]> {
113
- return this.sql<AuthConfigRow[]>`
114
- SELECT "key", "enabled", "config", "secret" FROM "auth_config"
115
- `;
112
+ return readAuthConfigRows(this.sql);
116
113
  }
117
114
 
118
115
  private async row(key: string): Promise<AuthConfigRow | null> {
119
- const rows = await this.sql<AuthConfigRow[]>`
120
- SELECT "key", "enabled", "config", "secret"
121
- FROM "auth_config" WHERE "key" = ${key}
122
- `;
123
- return rows[0] ?? null;
116
+ return readAuthConfigRow(this.sql, key);
124
117
  }
125
118
 
126
119
  private async upsert(row: AuthConfigRow): Promise<void> {
127
- const config = authConfigParam(this.provider, object(row.config));
128
- await this.sql`
129
- INSERT INTO "auth_config" ("key", "enabled", "config", "secret", "updatedAt")
130
- VALUES (${row.key}, ${row.enabled}, ${config}, ${row.secret}, CURRENT_TIMESTAMP)
131
- ON CONFLICT ("key") DO UPDATE SET
132
- "enabled" = EXCLUDED."enabled",
133
- "config" = EXCLUDED."config",
134
- "secret" = EXCLUDED."secret",
135
- "updatedAt" = CURRENT_TIMESTAMP
136
- `;
120
+ await writeAuthConfigRow(this.sql, this.provider, row);
137
121
  }
138
122
 
139
123
  async describe(): Promise<AuthConfigView> {
@@ -216,7 +200,12 @@ export class AuthConfigService {
216
200
  if (update.server) await this.upsertServer(update.server);
217
201
  await refreshAuthNow();
218
202
  if (update.server?.sessionIdleTimeoutMinutes !== undefined) {
219
- await this.resetSessionExpirations(update.server.sessionIdleTimeoutMinutes);
203
+ const minutes = update.server.sessionIdleTimeoutMinutes;
204
+ await resetSessionExpirations(
205
+ this.sql,
206
+ this.provider,
207
+ minutes === null ? DEFAULT_SESSION_LIFETIME_SECONDS : minutes * 60,
208
+ );
220
209
  }
221
210
  return this.describe();
222
211
  }
@@ -285,14 +274,4 @@ export class AuthConfigService {
285
274
  }
286
275
  await this.upsert({ ...current, config });
287
276
  }
288
-
289
- private async resetSessionExpirations(minutes: number | null): Promise<void> {
290
- const lifetimeSeconds = minutes === null ? DEFAULT_SESSION_LIFETIME_SECONDS : minutes * 60;
291
- await this.sql`
292
- UPDATE "session"
293
- SET "expiresAt" = CURRENT_TIMESTAMP + (${lifetimeSeconds} * INTERVAL '1 second'),
294
- "updatedAt" = CURRENT_TIMESTAMP
295
- WHERE "expiresAt" > CURRENT_TIMESTAMP
296
- `;
297
- }
298
277
  }
@@ -0,0 +1,33 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { SQL } from "bun";
3
+ import { resetSessionExpirations } from "./session-expiry";
4
+
5
+ let open: SQL | undefined;
6
+
7
+ afterEach(async () => {
8
+ await open?.close();
9
+ open = undefined;
10
+ });
11
+
12
+ describe("session expiry reset on SQLite", () => {
13
+ it("moves active sessions to the new lifetime and leaves expired ones", async () => {
14
+ const sql = new SQL(":memory:");
15
+ open = sql;
16
+ // Better Auth stores SQLite dates as ISO 8601 text.
17
+ await sql`CREATE TABLE "session" ("id" text PRIMARY KEY, "expiresAt" date NOT NULL, "updatedAt" date NOT NULL)`;
18
+ const now = new Date("2026-10-15T10:00:00.000Z");
19
+ const active = new Date(now.getTime() + 3_600_000).toISOString();
20
+ const expired = new Date(now.getTime() - 1_000).toISOString();
21
+ await sql`INSERT INTO "session" VALUES (${"active"}, ${active}, ${active}), (${"expired"}, ${expired}, ${expired})`;
22
+
23
+ await resetSessionExpirations(sql, "sqlite", 1_800, now);
24
+
25
+ const rows = await sql<Array<{ id: string; expiresAt: string; updatedAt: string }>>`
26
+ SELECT "id", "expiresAt", "updatedAt" FROM "session" ORDER BY "id"
27
+ `;
28
+ expect(rows).toEqual([
29
+ { id: "active", expiresAt: "2026-10-15T10:30:00.000Z", updatedAt: now.toISOString() },
30
+ { id: "expired", expiresAt: expired, updatedAt: expired },
31
+ ]);
32
+ });
33
+ });
@@ -0,0 +1,29 @@
1
+ import type { SQL } from "bun";
2
+ import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
3
+
4
+ /** Give every active session the configured lifetime from now. */
5
+ export async function resetSessionExpirations(
6
+ sql: SQL,
7
+ provider: DatabaseProviderName,
8
+ lifetimeSeconds: number,
9
+ now: Date = new Date(),
10
+ ): Promise<void> {
11
+ if (provider === "sqlite") {
12
+ // Better Auth stores SQLite dates as ISO 8601 text, which sorts in time
13
+ // order. SQLite has no interval arithmetic.
14
+ const current = now.toISOString();
15
+ const expiresAt = new Date(now.getTime() + lifetimeSeconds * 1000).toISOString();
16
+ await sql`
17
+ UPDATE "session"
18
+ SET "expiresAt" = ${expiresAt}, "updatedAt" = ${current}
19
+ WHERE "expiresAt" > ${current}
20
+ `;
21
+ return;
22
+ }
23
+ await sql`
24
+ UPDATE "session"
25
+ SET "expiresAt" = CURRENT_TIMESTAMP + (${lifetimeSeconds} * INTERVAL '1 second'),
26
+ "updatedAt" = CURRENT_TIMESTAMP
27
+ WHERE "expiresAt" > CURRENT_TIMESTAMP
28
+ `;
29
+ }
@@ -1,6 +1,8 @@
1
1
  # Bootstrap the initial administrator
2
2
 
3
3
  Use the generated database-backed command after the API build and migrations.
4
+ With `NODE_ENV=production` it loads the bundled `dist/bootstrap-admin-runtime.js`;
5
+ otherwise it runs the source entry. It supports PostgreSQL and SQLite.
4
6
  Do not create the first administrator through the public sign-up page.
5
7
 
6
8
  ## Set the persistent allowlist
@@ -105,8 +105,8 @@ export class RateLimiter {
105
105
  ) {
106
106
  return { name: "runtime", ttlSeconds: this.config.ttlSeconds, limit: this.config.runtimeLimit };
107
107
  }
108
- // Admin reads need an admin session and are not credential attempts; the
109
- // server-paged admin tables issue one per page, sort, or search.
108
+ // Admin reads need an admin session and are not credential attempts. The
109
+ // dashboard and organizations pages read users through Better Auth.
110
110
  if (method === "GET" && path.startsWith("/api/auth/admin/")) {
111
111
  return { name: "general", ttlSeconds: this.config.ttlSeconds, limit: this.config.limit };
112
112
  }
@@ -4,7 +4,12 @@ import { MemoryEventBus } from "@podosoft/podokit-runtime";
4
4
  import { Elysia } from "elysia";
5
5
  import type { RequestIdentity } from "../core/services";
6
6
  import { ReadinessService } from "../health/readiness.service";
7
- import { eventsRoutes, requestEventIdentity, type EventIdentity } from "./events.module";
7
+ import {
8
+ eventsRoutes,
9
+ maxStreamsPerUser,
10
+ requestEventIdentity,
11
+ type EventIdentity,
12
+ } from "./events.module";
8
13
  import { EventsService } from "./events.service";
9
14
 
10
15
  /** Maps the `x-user`, `x-admin`, `x-roles`, and `x-org` test headers to the request identity. */
@@ -201,3 +206,45 @@ describe("default event identity", () => {
201
206
  expect(await identity.subscriber(new Request("http://localhost/events/stream"))).toBeUndefined();
202
207
  });
203
208
  });
209
+
210
+ describe("stream limit configuration", () => {
211
+ it("reads SSE_MAX_STREAMS_PER_USER and defaults to eight", () => {
212
+ expect(maxStreamsPerUser({})).toBe(8);
213
+ expect(maxStreamsPerUser({ SSE_MAX_STREAMS_PER_USER: "3" })).toBe(3);
214
+ for (const value of ["0", "-1", "1.5", "many"]) {
215
+ expect(() => maxStreamsPerUser({ SSE_MAX_STREAMS_PER_USER: value })).toThrow(
216
+ "SSE_MAX_STREAMS_PER_USER must be a positive integer",
217
+ );
218
+ }
219
+ });
220
+
221
+ it("answers 429 EVENTS_STREAM_LIMIT over HTTP", async () => {
222
+ const events = new EventsService(new MemoryEventBus(), new ReadinessService(), {
223
+ maxStreamsPerUser: 1,
224
+ });
225
+ await events.connect();
226
+ const controllers: AbortController[] = [];
227
+ opened.push({ events, controllers });
228
+ const app = new Elysia()
229
+ .onError(({ error, set }) => {
230
+ if (!(error instanceof AppException)) return undefined;
231
+ set.status = error.statusCode;
232
+ return { code: error.code };
233
+ })
234
+ .use(eventsRoutes(events, headerIdentity));
235
+ const send = (): Promise<Response> => {
236
+ const controller = new AbortController();
237
+ controllers.push(controller);
238
+ return app.handle(
239
+ new Request("http://localhost/events/stream", {
240
+ headers: { "x-user": "u1" },
241
+ signal: controller.signal,
242
+ }),
243
+ );
244
+ };
245
+ expect((await send()).status).toBe(200);
246
+ const refused = await send();
247
+ expect(refused.status).toBe(429);
248
+ expect(await refused.json()).toEqual({ code: "EVENTS_STREAM_LIMIT" });
249
+ });
250
+ });
@@ -22,6 +22,15 @@ import { EventsService } from "./events.service";
22
22
 
23
23
  export const SSE_EVENTS = Symbol("sse-events") as ServiceKey<EventsService>;
24
24
 
25
+ /** `SSE_MAX_STREAMS_PER_USER`: open streams one user may hold on each API replica. */
26
+ export function maxStreamsPerUser(env: Record<string, string | undefined> = process.env): number {
27
+ const value = Number(env.SSE_MAX_STREAMS_PER_USER ?? 8);
28
+ if (!Number.isSafeInteger(value) || value < 1) {
29
+ throw new Error("SSE_MAX_STREAMS_PER_USER must be a positive integer");
30
+ }
31
+ return value;
32
+ }
33
+
25
34
  /** Resolves who opens a stream. Register one in app.extensions.ts to replace the default. */
26
35
  export interface EventIdentity {
27
36
  subscriber(request: Request): Promise<EventSubscriber | undefined>;
@@ -84,7 +93,9 @@ export function eventsRoutes(
84
93
  "Signed-in users only. `topics` is a comma-separated list; without it the stream " +
85
94
  "carries every topic that has no authorizer. Each data event is a default " +
86
95
  "`message` whose data is `{ topic, data }`. A `resync` event means events were " +
87
- "dropped and the client should reload its state.",
96
+ "dropped and the client should reload its state. A user may hold " +
97
+ "`SSE_MAX_STREAMS_PER_USER` streams (default 8) on each API replica; another " +
98
+ "answers 429 `EVENTS_STREAM_LIMIT`.",
88
99
  },
89
100
  })
90
101
  .post("/events", async ({ request, body, set }) => {
@@ -132,7 +143,9 @@ const eventsPlugin: AppPlugin = ({ services }) =>
132
143
  export const eventsModule: PodokitModule = {
133
144
  name: "sse",
134
145
  configure: (_env, services): void => {
135
- const events = new EventsService(services.resolve(EVENTS), services.resolve(READINESS));
146
+ const events = new EventsService(services.resolve(EVENTS), services.resolve(READINESS), {
147
+ maxStreamsPerUser: maxStreamsPerUser(),
148
+ });
136
149
  services.register(SSE_EVENTS, events, () => events.close());
137
150
  services.onStart(() => events.connect());
138
151
  },
@@ -353,6 +353,41 @@ describe("per-connection safety", () => {
353
353
  });
354
354
  });
355
355
 
356
+ describe("per-user stream limit", () => {
357
+ it("refuses a user's stream beyond the limit on this replica and frees slots on close", async () => {
358
+ const events = await service({ maxStreamsPerUser: 2 });
359
+ const first = await open(events, user("a"));
360
+ await open(events, user("a"));
361
+
362
+ const refused = await open(events, user("a")).catch((error: unknown) => error);
363
+ expect(refused).toBeInstanceOf(AppException);
364
+ expect((refused as AppException).code).toBe("EVENTS_STREAM_LIMIT");
365
+ expect((refused as AppException).statusCode).toBe(429);
366
+ expect(events.streamCount).toBe(2);
367
+
368
+ // Other users keep their own allowance.
369
+ await open(events, user("b"));
370
+ first.close();
371
+ await open(events, user("a"));
372
+ expect(events.streamCount).toBe(3);
373
+ });
374
+
375
+ it("counts only live streams after a client disconnects", async () => {
376
+ const events = await service({ maxStreamsPerUser: 1 });
377
+ const controller = new AbortController();
378
+ await events.open(user("a"), undefined, controller.signal);
379
+ controller.abort();
380
+ await open(events, user("a"));
381
+ expect(events.streamCount).toBe(1);
382
+ });
383
+
384
+ it("allows eight streams per user by default", async () => {
385
+ const events = await service();
386
+ for (let index = 0; index < 8; index += 1) await open(events, user("a"));
387
+ await expect(open(events, user("a"))).rejects.toThrow("Too many open event streams");
388
+ });
389
+ });
390
+
356
391
  describe("SSE wire format", () => {
357
392
  it("encodes data events as default messages and control frames separately", () => {
358
393
  expect(encodeFrame({ kind: "event", id: "7", data: '{"topic":"t","data":1}' })).toBe(
@@ -27,6 +27,8 @@ export interface EventsServiceOptions {
27
27
  retryMs?: number;
28
28
  /** Streams end after this long so clients reconnect with a fresh identity. */
29
29
  maxConnectionMs?: number;
30
+ /** Open streams one user may hold on this replica. */
31
+ maxStreamsPerUser?: number;
30
32
  scheduler?: Scheduler;
31
33
  now?: () => number;
32
34
  }
@@ -35,7 +37,9 @@ export class EventsService {
35
37
  readonly topics = new TopicAuthorizers();
36
38
  readonly retryMs: number;
37
39
  private readonly streamOptions: EventStreamOptions;
40
+ private readonly maxStreamsPerUser: number;
38
41
  private readonly streams = new Set<EventStream>();
42
+ private readonly byUser = new Map<string, Set<EventStream>>();
39
43
  private readonly byTopic = new Map<string, Set<EventStream>>();
40
44
  private readonly everyTopic = new Set<EventStream>();
41
45
  private unregisterReadiness?: () => void;
@@ -47,6 +51,7 @@ export class EventsService {
47
51
  options: EventsServiceOptions = {},
48
52
  ) {
49
53
  this.retryMs = options.retryMs ?? 3_000;
54
+ this.maxStreamsPerUser = options.maxStreamsPerUser ?? 8;
50
55
  this.streamOptions = {
51
56
  queueLimit: options.queueLimit ?? 256,
52
57
  heartbeatMs: options.heartbeatMs ?? 5_000,
@@ -93,7 +98,9 @@ export class EventsService {
93
98
 
94
99
  /**
95
100
  * Opens a stream for `subscriber`. Without `topics` the stream receives every
96
- * topic that has no authorizer; guarded topics must be requested by name.
101
+ * topic that has no authorizer; guarded topics must be requested by name. A
102
+ * user beyond the per-user limit is refused rather than closing an older
103
+ * stream, which would make that client reconnect and refuse the next one.
97
104
  */
98
105
  async open(
99
106
  subscriber: EventSubscriber,
@@ -109,6 +116,10 @@ export class EventsService {
109
116
  );
110
117
  }
111
118
  }
119
+ // No await between this check and attach(), so concurrent opens cannot pass it together.
120
+ if ((this.byUser.get(subscriber.userId)?.size ?? 0) >= this.maxStreamsPerUser) {
121
+ throw new AppException("EVENTS_STREAM_LIMIT", "Too many open event streams", 429);
122
+ }
112
123
  const stream = new EventStream(
113
124
  subscriber,
114
125
  topics ? new Set(topics) : undefined,
@@ -142,6 +153,9 @@ export class EventsService {
142
153
 
143
154
  private attach(stream: EventStream): void {
144
155
  this.streams.add(stream);
156
+ const held = this.byUser.get(stream.subscriber.userId) ?? new Set<EventStream>();
157
+ held.add(stream);
158
+ this.byUser.set(stream.subscriber.userId, held);
145
159
  if (!stream.topics) {
146
160
  this.everyTopic.add(stream);
147
161
  return;
@@ -155,6 +169,9 @@ export class EventsService {
155
169
 
156
170
  private detach(stream: EventStream): void {
157
171
  this.streams.delete(stream);
172
+ const held = this.byUser.get(stream.subscriber.userId);
173
+ held?.delete(stream);
174
+ if (held?.size === 0) this.byUser.delete(stream.subscriber.userId);
158
175
  if (!stream.topics) {
159
176
  this.everyTopic.delete(stream);
160
177
  return;