@rebasepro/server-postgres 0.10.0 → 0.10.1-canary.14e53ae

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 (53) hide show
  1. package/dist/PostgresBootstrapper.d.ts +7 -3
  2. package/dist/auth/services.d.ts +43 -4
  3. package/dist/backup/backup-logic.d.ts +23 -0
  4. package/dist/backup/backup-service.d.ts +44 -2
  5. package/dist/backup/pg-tools.d.ts +41 -1
  6. package/dist/chunk-DSJWtz9O.js +40 -0
  7. package/dist/cli-helpers.d.ts +33 -1
  8. package/dist/ensure-collection-tables-CNlIONzj.js +304 -0
  9. package/dist/ensure-collection-tables-CNlIONzj.js.map +1 -0
  10. package/dist/index.d.ts +1 -0
  11. package/dist/index.es.js +1472 -4640
  12. package/dist/index.es.js.map +1 -1
  13. package/dist/schema/auth-schema.d.ts +170 -0
  14. package/dist/schema/destructive-sql.d.ts +49 -0
  15. package/dist/schema/ensure-collection-tables.d.ts +79 -0
  16. package/dist/schema/generate-postgres-ddl-logic.d.ts +4 -1
  17. package/dist/services/cdc/CdcListener.d.ts +7 -14
  18. package/dist/services/channel-bus/ChannelBus.d.ts +29 -0
  19. package/dist/services/channel-bus/PostgresChannelBus.d.ts +111 -0
  20. package/dist/services/channel-bus/index.d.ts +55 -0
  21. package/dist/services/channel-history.d.ts +11 -0
  22. package/dist/services/channel-presence.d.ts +66 -0
  23. package/dist/services/pg-notify-listener.d.ts +47 -0
  24. package/dist/services/realtimeService.d.ts +114 -6
  25. package/dist/src-B0v4IKaI.js +329 -0
  26. package/dist/src-B0v4IKaI.js.map +1 -0
  27. package/dist/src-DmsRg8MR.js +4056 -0
  28. package/dist/src-DmsRg8MR.js.map +1 -0
  29. package/package.json +6 -6
  30. package/src/PostgresBootstrapper.ts +72 -3
  31. package/src/auth/ensure-tables.ts +91 -3
  32. package/src/auth/services.ts +186 -48
  33. package/src/backup/backup-cli.ts +60 -1
  34. package/src/backup/backup-cron.ts +24 -1
  35. package/src/backup/backup-logic.ts +62 -0
  36. package/src/backup/backup-service.ts +132 -13
  37. package/src/backup/pg-tools.ts +70 -2
  38. package/src/cli-helpers.ts +82 -27
  39. package/src/cli.ts +152 -6
  40. package/src/index.ts +4 -0
  41. package/src/schema/auth-schema.ts +41 -3
  42. package/src/schema/destructive-sql.ts +94 -0
  43. package/src/schema/ensure-collection-tables.test.ts +156 -0
  44. package/src/schema/ensure-collection-tables.ts +297 -0
  45. package/src/schema/generate-postgres-ddl-logic.ts +3 -3
  46. package/src/services/cdc/CdcListener.ts +27 -91
  47. package/src/services/channel-bus/ChannelBus.ts +44 -0
  48. package/src/services/channel-bus/PostgresChannelBus.ts +299 -0
  49. package/src/services/channel-bus/index.ts +123 -0
  50. package/src/services/channel-history.ts +35 -0
  51. package/src/services/channel-presence.ts +148 -0
  52. package/src/services/pg-notify-listener.ts +137 -0
  53. package/src/services/realtimeService.ts +383 -14
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/server-postgres",
3
3
  "type": "module",
4
- "version": "0.10.0",
4
+ "version": "0.10.1-canary.14e53ae",
5
5
  "description": "PostgreSQL data source backend implementation for Rebase with Drizzle ORM",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -47,11 +47,11 @@
47
47
  "execa": "^9.6.1",
48
48
  "pg": "^8.21.0",
49
49
  "ws": "^8.21.0",
50
- "@rebasepro/codegen": "0.10.0",
51
- "@rebasepro/common": "0.10.0",
52
- "@rebasepro/types": "0.10.0",
53
- "@rebasepro/utils": "0.10.0",
54
- "@rebasepro/server": "0.10.0"
50
+ "@rebasepro/codegen": "0.10.1-canary.14e53ae",
51
+ "@rebasepro/server": "0.10.1-canary.14e53ae",
52
+ "@rebasepro/types": "0.10.1-canary.14e53ae",
53
+ "@rebasepro/utils": "0.10.1-canary.14e53ae",
54
+ "@rebasepro/common": "0.10.1-canary.14e53ae"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@hono/node-server": "^2.0.9",
@@ -37,6 +37,8 @@ import { buildCollectionsFromSchema, introspectSchema, readRlsStatus } from "./s
37
37
  import { buildDrizzleTablesFromSchema, buildDrizzleRelationsFromSchema } from "./schema/dynamic-tables";
38
38
  import { detectConnectionPosture, ensureAppRole, validatePolicyPgRoles, warnOnAnonymousGrants, REBASE_USER_ROLE, type RawSqlRunner } from "./security/rls-enforcement";
39
39
  import { provisionTriggerCdc, type CdcTableRef } from "./services/cdc/trigger-cdc";
40
+ import { createChannelBus, resolveChannelBusSetting } from "./services/channel-bus";
41
+ import { isChannelBusInstance } from "@rebasepro/types";
40
42
 
41
43
  export interface PostgresDriverConfig {
42
44
  connectionString?: string;
@@ -54,9 +56,13 @@ export interface PostgresDriverConfig {
54
56
  */
55
57
  introspectionSchema?: string;
56
58
  /**
57
- * Realtime options. Currently only channel retention, which is opt-in:
58
- * without rules here no channel keeps any history and broadcast stays
59
- * fire-and-forget. See {@link ChannelRetentionRule}.
59
+ * Realtime options, both opt-in:
60
+ *
61
+ * - `channels` — retention. Without rules no channel keeps any history and
62
+ * broadcast stays fire-and-forget. See {@link ChannelRetentionRule}.
63
+ * - `bus` — the cross-instance transport for channel broadcast and
64
+ * presence. Defaults to in-process only, which is correct for a single
65
+ * instance and wrong for two. See {@link ChannelBusConfig}.
60
66
  */
61
67
  realtime?: RealtimeChannelsConfig;
62
68
  }
@@ -337,6 +343,29 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
337
343
  // Prefer DATABASE_DIRECT_URL to bypass PgBouncer for LISTEN/NOTIFY.
338
344
  const directUrl = process.env.DATABASE_DIRECT_URL || pgConfig.connectionString;
339
345
 
346
+ // ── Cross-instance channel bus ───────────────────────────────────
347
+ // Entity changes already span instances (CDC / LISTEN below).
348
+ // Channel broadcast and presence did not — they lived in per-process
349
+ // maps, so behind two replicas the clients of one were invisible to
350
+ // the other. Opt-in, and a no-op when left at "memory".
351
+ try {
352
+ const busSetting = resolveChannelBusSetting(pgConfig.realtime?.bus);
353
+ // A supplied instance is always installed; a named built-in only
354
+ // when it is not the in-process default, so the common case
355
+ // touches none of this machinery.
356
+ const wantsBus = isChannelBusInstance(busSetting) || busSetting.type !== "memory";
357
+ if (wantsBus) {
358
+ await realtimeService.configureChannelBus(
359
+ createChannelBus(busSetting, {
360
+ db: schemaAwareDb as unknown as NodePgDatabase<Record<string, unknown>>,
361
+ directUrl
362
+ })
363
+ );
364
+ }
365
+ } catch (err) {
366
+ logger.warn("⚠️ [ChannelBus] Could not configure the channel bus — channel broadcast and presence stay per-instance", { error: err });
367
+ }
368
+
340
369
  // Database-level Change Data Capture. When active, realtime events are
341
370
  // emitted for EVERY committed write — including ones that bypass the
342
371
  // Rebase API (psql, another service's cron, raw SQL, the Studio SQL
@@ -615,6 +644,46 @@ authRepository };
615
644
  return internals.realtimeService;
616
645
  },
617
646
 
647
+ /**
648
+ * Create any collection tables, columns and enum types the database is
649
+ * missing — additively, never destructively.
650
+ *
651
+ * This is what lets the managed runtime boot a project against a fresh
652
+ * database and actually serve it. Before this, only auth tables were
653
+ * ensured, so a managed tenant came up with working sign-in and a 500 on
654
+ * every data route.
655
+ *
656
+ * Runs through the drizzle handle's underlying session so it uses the
657
+ * same connection (and therefore the same privileges) the driver already
658
+ * proved it can bootstrap with.
659
+ */
660
+ async ensureCollectionSchema(
661
+ collections: unknown[],
662
+ driverResult: InitializedDriver,
663
+ log?: (message: string) => void
664
+ ): Promise<{ applied: number }> {
665
+ const internals = driverResult.internals as PostgresDriverInternals;
666
+ const { ensureCollectionTables } = await import("./schema/ensure-collection-tables");
667
+ // Runs through the drizzle handle the driver already bootstrapped
668
+ // with, so it uses exactly the connection and privileges that were
669
+ // proven to work. Every statement is DDL or a catalogue read with no
670
+ // bindable values (schema names are identifiers), and the module
671
+ // validates them before they reach a string.
672
+ const queryable = {
673
+ async query<T>(text: string): Promise<{ rows: T[] }> {
674
+ const result = await internals.db.execute(sql.raw(text));
675
+ const rows = (result as unknown as { rows?: T[] }).rows;
676
+ return { rows: rows ?? (Array.isArray(result) ? (result as T[]) : []) };
677
+ }
678
+ };
679
+ const plan = await ensureCollectionTables(
680
+ queryable,
681
+ collections as Parameters<typeof ensureCollectionTables>[1],
682
+ log
683
+ );
684
+ return { applied: plan.actions.length };
685
+ },
686
+
618
687
  getAdmin(driverResult: InitializedDriver): DatabaseAdmin | undefined {
619
688
  const internals = driverResult.internals as PostgresDriverInternals;
620
689
  return internals.driver.admin;
@@ -107,6 +107,7 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
107
107
  email_verification_sent_at TIMESTAMP WITH TIME ZONE,
108
108
  is_anonymous BOOLEAN DEFAULT FALSE NOT NULL,
109
109
  metadata JSONB DEFAULT '{}' NOT NULL,
110
+ tokens_valid_after TIMESTAMP WITH TIME ZONE,
110
111
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
111
112
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
112
113
  )
@@ -209,17 +210,23 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
209
210
  `);
210
211
 
211
212
 
212
- // Create refresh tokens table (includes user_agent, ip_address, and unique constraint)
213
+ // Create refresh tokens table. One row per TOKEN, grouped into a
214
+ // sign-in by session_id — deliberately without a uniqueness rule on
215
+ // (uid, user_agent, ip_address); see the schema module for why that
216
+ // constraint had to go.
213
217
  await db.execute(sql`
214
218
  CREATE TABLE IF NOT EXISTS ${sql.raw(refreshTokensTableName)} (
215
219
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
216
220
  uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
221
+ session_id TEXT NOT NULL DEFAULT gen_random_uuid()::text,
217
222
  token_hash TEXT NOT NULL UNIQUE,
218
223
  expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
224
+ revoked BOOLEAN DEFAULT FALSE NOT NULL,
225
+ rotated_at TIMESTAMP WITH TIME ZONE,
226
+ session_started_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
219
227
  user_agent TEXT,
220
228
  ip_address TEXT,
221
- created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
222
- CONSTRAINT unique_device_session UNIQUE (uid, user_agent, ip_address)
229
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
223
230
  )
224
231
  `);
225
232
 
@@ -348,6 +355,7 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
348
355
  "email_verification_sent_at TIMESTAMP WITH TIME ZONE",
349
356
  "is_anonymous BOOLEAN DEFAULT FALSE NOT NULL",
350
357
  "metadata JSONB DEFAULT '{}' NOT NULL",
358
+ "tokens_valid_after TIMESTAMP WITH TIME ZONE",
351
359
  "created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL",
352
360
  "updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL"
353
361
  ];
@@ -358,6 +366,86 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Col
358
366
  `);
359
367
  }
360
368
 
369
+ // ── Migration: refresh_tokens become session-scoped, rotation-safe ──
370
+ // Two shapes are reconciled here, on EVERY table named refresh_tokens
371
+ // in whatever schema it lives (a database provisioned by an older era
372
+ // can carry the table in a different schema than the one this run
373
+ // derives, and auth would then read a table nobody migrated):
374
+ //
375
+ // 1. The new columns. A token is now a member of a session
376
+ // (`session_id`) and is retained after rotation (`revoked`,
377
+ // `rotated_at`) so a replayed token can be recognised instead of
378
+ // looking like a forgery. `session_started_at` is carried across
379
+ // rotations so `users.tokens_valid_after` cannot be outrun.
380
+ // 2. The removal of `unique_device_session`. It made (uid,
381
+ // user_agent, ip_address) the identity of a session, which evicted
382
+ // a second browser profile behind one NAT and churned rows as
383
+ // phones changed networks. UA and IP are metadata now.
384
+ //
385
+ // Every existing row is adopted rather than dropped: it keeps its
386
+ // token_hash, gets a session of its own, and stays unrevoked — so the
387
+ // sessions live in browsers right now survive the upgrade rather than
388
+ // everyone being signed out by the fix for being signed out.
389
+ try {
390
+ const rtTables = await db.execute(sql`
391
+ SELECT table_schema, table_name
392
+ FROM information_schema.tables
393
+ WHERE table_name = 'refresh_tokens'
394
+ `);
395
+ const found = (rtTables.rows as { table_schema: string; table_name: string }[]);
396
+ logger.info(`🔍 refresh_tokens reconcile: found ${found.length} table(s): ${found.map(r => `"${r.table_schema}"."${r.table_name}"`).join(", ") || "(none)"}`);
397
+ for (const { table_schema } of found) {
398
+ const qualified = `"${table_schema}"."refresh_tokens"`;
399
+ try {
400
+ // Added nullable, then back-filled, then constrained: adding
401
+ // `session_id NOT NULL DEFAULT gen_random_uuid()` in one step
402
+ // would stamp every existing row with the SAME uuid on some
403
+ // Postgres versions, silently merging every live session into
404
+ // one that a single logout would then wipe.
405
+ await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS session_id TEXT`);
406
+ await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS revoked BOOLEAN DEFAULT FALSE NOT NULL`);
407
+ await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS rotated_at TIMESTAMP WITH TIME ZONE`);
408
+ await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS session_started_at TIMESTAMP WITH TIME ZONE`);
409
+
410
+ // One session per pre-existing row: under the old model a row
411
+ // WAS a device session, and there is no record of which rows
412
+ // descended from the same sign-in.
413
+ await db.execute(sql`
414
+ UPDATE ${sql.raw(qualified)}
415
+ SET session_id = gen_random_uuid()::text
416
+ WHERE session_id IS NULL
417
+ `);
418
+ await db.execute(sql`
419
+ UPDATE ${sql.raw(qualified)}
420
+ SET session_started_at = COALESCE(created_at, NOW())
421
+ WHERE session_started_at IS NULL
422
+ `);
423
+ await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_id SET DEFAULT gen_random_uuid()::text`);
424
+ await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_started_at SET DEFAULT NOW()`);
425
+ // SET NOT NULL only once the back-fill above has definitely
426
+ // run; on a table that somehow still holds a NULL this throws
427
+ // and is caught below rather than failing the boot.
428
+ await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_id SET NOT NULL`);
429
+ await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_started_at SET NOT NULL`);
430
+
431
+ await db.execute(sql`
432
+ CREATE INDEX IF NOT EXISTS idx_refresh_tokens_session
433
+ ON ${sql.raw(qualified)}(session_id)
434
+ `);
435
+
436
+ // The device-session constraint is now actively harmful: two
437
+ // live tokens of one session (a rotation in flight) share a
438
+ // uid, and usually a user agent and IP too.
439
+ await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} DROP CONSTRAINT IF EXISTS unique_device_session`);
440
+ logger.info(`✅ refresh_tokens reconciled for session-scoped rotation: ${qualified}`);
441
+ } catch (perTableError: unknown) {
442
+ logger.warn(`⚠️ refresh_tokens reconcile failed for ${qualified}: ${perTableError instanceof Error ? perTableError.message : String(perTableError)}`);
443
+ }
444
+ }
445
+ } catch (migrationError: unknown) {
446
+ logger.warn(`⚠️ refresh_tokens session migration skipped: ${migrationError instanceof Error ? migrationError.message : String(migrationError)}`);
447
+ }
448
+
361
449
  // ── Migration: Copy roles from legacy junction table to inline column ──
362
450
  // If the old rebase.user_roles and rebase.roles tables exist, migrate
363
451
  // the data into the new TEXT[] column then drop the legacy tables.
@@ -14,6 +14,7 @@ import {
14
14
  RoleData,
15
15
  CreateRoleData,
16
16
  RefreshTokenInfo,
17
+ RefreshTokenSession,
17
18
  PasswordResetTokenInfo,
18
19
  MagicLinkTokenInfo,
19
20
  UserIdentityData,
@@ -529,6 +530,7 @@ export class UserService implements UserRepository {
529
530
 
530
531
  export class RefreshTokenService {
531
532
  private refreshTokensTable: RebasePgTable;
533
+ private usersTable: RebasePgTable | null;
532
534
 
533
535
  constructor(
534
536
  private db: NodePgDatabase,
@@ -536,55 +538,159 @@ export class RefreshTokenService {
536
538
  ) {
537
539
  if (tableOrTables && ((tableOrTables as Partial<AuthSchemaTables>).refreshTokens || (tableOrTables as Partial<AuthSchemaTables>).users)) {
538
540
  this.refreshTokensTable = ((tableOrTables as Partial<AuthSchemaTables>).refreshTokens || refreshTokens) as RebasePgTable;
541
+ this.usersTable = ((tableOrTables as Partial<AuthSchemaTables>).users || users) as RebasePgTable;
539
542
  } else {
540
543
  this.refreshTokensTable = (tableOrTables as RebasePgTable) || (refreshTokens as unknown as RebasePgTable);
544
+ this.usersTable = users as unknown as RebasePgTable;
541
545
  }
542
546
  }
543
547
 
544
- async createToken(uid: string, tokenHash: string, expiresAt: Date, userAgent?: string, ipAddress?: string): Promise<void> {
545
- // Fallback to empty string because UNIQUE constraints treat NULLs as strictly distinct in standard Postgres.
546
- // We want (uid, NULL, NULL) to collide and overwrite, so we map undefined/null to empty strings.
548
+ /**
549
+ * Whether the table actually carries a column, so a host application that
550
+ * supplied its own `refresh_tokens` table one that predates session
551
+ * grouping — degrades instead of throwing on every sign-in.
552
+ */
553
+ private has(column: string): boolean {
554
+ return Boolean((this.refreshTokensTable as unknown as Record<string, unknown>)[column]);
555
+ }
556
+
557
+ private col(column: string) {
558
+ return (this.refreshTokensTable as unknown as Record<string, never>)[column];
559
+ }
560
+
561
+ /** The columns to read back, narrowed to the ones this table has. */
562
+ private selection() {
563
+ const selection: Record<string, never> = {
564
+ id: this.refreshTokensTable.id,
565
+ uid: this.refreshTokensTable.uid,
566
+ tokenHash: this.refreshTokensTable.tokenHash,
567
+ expiresAt: this.refreshTokensTable.expiresAt,
568
+ createdAt: this.refreshTokensTable.createdAt,
569
+ userAgent: this.refreshTokensTable.userAgent,
570
+ ipAddress: this.refreshTokensTable.ipAddress
571
+ } as unknown as Record<string, never>;
572
+ for (const optional of ["sessionId", "rotatedAt", "revoked", "sessionStartedAt"]) {
573
+ if (this.has(optional)) selection[optional] = this.col(optional);
574
+ }
575
+ return selection;
576
+ }
577
+
578
+ async createToken(
579
+ uid: string,
580
+ tokenHash: string,
581
+ expiresAt: Date,
582
+ userAgent?: string,
583
+ ipAddress?: string,
584
+ session?: RefreshTokenSession
585
+ ): Promise<void> {
586
+ // Empty strings rather than NULLs: the device-session UNIQUE constraint
587
+ // that needed them is gone, but sessions-list UIs already render "" as
588
+ // "unknown device" and would start showing blanks otherwise.
547
589
  const safeUserAgent = userAgent || "";
548
590
  const safeIpAddress = ipAddress || "";
549
591
 
550
- // Atomic upsert keyed on the device session (uid, user_agent, ip_address).
551
- // A DELETE-then-INSERT races under concurrent refreshes cookie-mode boot can
552
- // fire two /refresh calls at once (both carrying the same refresh cookie): both
553
- // DELETE, then the second INSERT violates `unique_device_session` and 500s.
554
- // A single INSERT ... ON CONFLICT DO UPDATE rotates the token atomically.
555
- await this.db.insert(this.refreshTokensTable)
556
- .values({
557
- uid,
558
- tokenHash,
559
- expiresAt,
560
- userAgent: safeUserAgent,
561
- ipAddress: safeIpAddress
562
- })
563
- .onConflictDoUpdate({
564
- target: [
565
- this.refreshTokensTable.uid,
566
- this.refreshTokensTable.userAgent,
567
- this.refreshTokensTable.ipAddress
568
- ],
569
- set: { tokenHash, expiresAt }
570
- });
592
+ // A plain INSERT. Rotation ADDS a token; it does not replace a device's
593
+ // row. Two refreshes racing on the same session therefore both succeed
594
+ // and both end holding a usable token, where the previous upsert had
595
+ // them overwrite each other and logged one of the two tabs out.
596
+ const values: Record<string, unknown> = {
597
+ uid,
598
+ tokenHash,
599
+ expiresAt,
600
+ userAgent: safeUserAgent,
601
+ ipAddress: safeIpAddress
602
+ };
603
+ if (session && this.has("sessionId")) values.sessionId = session.id;
604
+ if (session && this.has("sessionStartedAt")) values.sessionStartedAt = session.startedAt;
605
+
606
+ await this.db.insert(this.refreshTokensTable).values(values);
571
607
  }
572
608
 
573
609
  async findByHash(tokenHash: string): Promise<RefreshTokenInfo | null> {
574
610
  const [token] = await this.db
575
- .select({
576
- id: this.refreshTokensTable.id,
577
- uid: this.refreshTokensTable.uid,
578
- tokenHash: this.refreshTokensTable.tokenHash,
579
- expiresAt: this.refreshTokensTable.expiresAt,
580
- createdAt: this.refreshTokensTable.createdAt,
581
- userAgent: this.refreshTokensTable.userAgent,
582
- ipAddress: this.refreshTokensTable.ipAddress
583
- })
611
+ .select(this.selection())
584
612
  .from(this.refreshTokensTable)
585
613
  .where(eq(this.refreshTokensTable.tokenHash, tokenHash));
586
614
 
587
- return (token as RefreshTokenInfo) || null;
615
+ return (token as unknown as RefreshTokenInfo) || null;
616
+ }
617
+
618
+ /**
619
+ * Record that a token was rotated away, keeping the row.
620
+ *
621
+ * The row is what lets `/auth/refresh` distinguish "you already used this,
622
+ * here is a fresh one" from "no idea what this is". Deleting it — which is
623
+ * what this used to do — collapsed both into a 401 and signed the user out
624
+ * for the crime of losing a response.
625
+ */
626
+ async markRotated(tokenHash: string): Promise<void> {
627
+ if (!this.has("rotatedAt")) {
628
+ await this.deleteByHash(tokenHash);
629
+ return;
630
+ }
631
+ await this.db
632
+ .update(this.refreshTokensTable)
633
+ .set({ rotatedAt: new Date() })
634
+ .where(eq(this.refreshTokensTable.tokenHash, tokenHash));
635
+ }
636
+
637
+ /** Final kill of one sign-in: logout, or revoking a device remotely. */
638
+ async revokeSession(sessionId: string): Promise<void> {
639
+ if (!this.has("sessionId")) return;
640
+ if (this.has("revoked")) {
641
+ await this.db
642
+ .update(this.refreshTokensTable)
643
+ .set({ revoked: true, ...(this.has("rotatedAt") ? { rotatedAt: new Date() } : {}) })
644
+ .where(eq(this.col("sessionId"), sessionId));
645
+ return;
646
+ }
647
+ await this.db.delete(this.refreshTokensTable).where(eq(this.col("sessionId"), sessionId));
648
+ }
649
+
650
+ /**
651
+ * Housekeeping: rotation would otherwise leave a row per refresh forever.
652
+ * Superseded rows are only needed for as long as a straggler might still
653
+ * present them, and expired ones are dead weight everywhere.
654
+ */
655
+ async prune(uid: string, sessionId: string, supersededBefore: Date): Promise<void> {
656
+ const uidCol = this.refreshTokensTable.uid;
657
+ const expiresCol = this.refreshTokensTable.expiresAt;
658
+ if (!this.has("rotatedAt") || !this.has("sessionId")) {
659
+ await this.db.delete(this.refreshTokensTable)
660
+ .where(sql`${uidCol} = ${uid} AND ${expiresCol} < NOW()`);
661
+ return;
662
+ }
663
+ const rotatedCol = this.col("rotatedAt");
664
+ const sessionCol = this.col("sessionId");
665
+ await this.db.delete(this.refreshTokensTable).where(sql`
666
+ ${uidCol} = ${uid}
667
+ AND (
668
+ ${expiresCol} < NOW()
669
+ OR (
670
+ ${sessionCol} = ${sessionId}
671
+ AND ${rotatedCol} IS NOT NULL
672
+ AND ${rotatedCol} < ${supersededBefore}
673
+ )
674
+ )
675
+ `);
676
+ }
677
+
678
+ async getTokensValidAfter(uid: string): Promise<Date | null> {
679
+ if (!this.usersTable || !(this.usersTable as unknown as Record<string, unknown>).tokensValidAfter) return null;
680
+ const [row] = await this.db
681
+ .select({ tokensValidAfter: (this.usersTable as unknown as Record<string, never>).tokensValidAfter })
682
+ .from(this.usersTable)
683
+ .where(eq(this.usersTable.id, uid));
684
+ const value = (row as { tokensValidAfter?: Date | string | null } | undefined)?.tokensValidAfter;
685
+ return value ? new Date(value) : null;
686
+ }
687
+
688
+ async setTokensValidAfter(uid: string, at: Date): Promise<void> {
689
+ if (!this.usersTable || !(this.usersTable as unknown as Record<string, unknown>).tokensValidAfter) return;
690
+ await this.db
691
+ .update(this.usersTable)
692
+ .set({ tokensValidAfter: at })
693
+ .where(eq(this.usersTable.id, uid));
588
694
  }
589
695
 
590
696
  async deleteByHash(tokenHash: string): Promise<void> {
@@ -597,20 +703,12 @@ export class RefreshTokenService {
597
703
 
598
704
  async listForUser(uid: string): Promise<RefreshTokenInfo[]> {
599
705
  const tokens = await this.db
600
- .select({
601
- id: this.refreshTokensTable.id,
602
- uid: this.refreshTokensTable.uid,
603
- tokenHash: this.refreshTokensTable.tokenHash,
604
- expiresAt: this.refreshTokensTable.expiresAt,
605
- createdAt: this.refreshTokensTable.createdAt,
606
- userAgent: this.refreshTokensTable.userAgent,
607
- ipAddress: this.refreshTokensTable.ipAddress
608
- })
706
+ .select(this.selection())
609
707
  .from(this.refreshTokensTable)
610
708
  .where(eq(this.refreshTokensTable.uid, uid))
611
709
  .orderBy(this.refreshTokensTable.createdAt);
612
710
 
613
- return tokens as RefreshTokenInfo[];
711
+ return tokens as unknown as RefreshTokenInfo[];
614
712
  }
615
713
 
616
714
  async deleteById(id: string, uid: string): Promise<void> {
@@ -804,8 +902,28 @@ export class PostgresTokenRepository implements TokenRepository {
804
902
 
805
903
  // Refresh token operations
806
904
 
807
- async createRefreshToken(uid: string, tokenHash: string, expiresAt: Date, userAgent?: string, ipAddress?: string): Promise<void> {
808
- await this.refreshTokenService.createToken(uid, tokenHash, expiresAt, userAgent, ipAddress);
905
+ async createRefreshToken(uid: string, tokenHash: string, expiresAt: Date, userAgent?: string, ipAddress?: string, session?: RefreshTokenSession): Promise<void> {
906
+ await this.refreshTokenService.createToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session);
907
+ }
908
+
909
+ async markRefreshTokenRotated(tokenHash: string): Promise<void> {
910
+ await this.refreshTokenService.markRotated(tokenHash);
911
+ }
912
+
913
+ async revokeRefreshTokenSession(sessionId: string): Promise<void> {
914
+ await this.refreshTokenService.revokeSession(sessionId);
915
+ }
916
+
917
+ async pruneRefreshTokens(uid: string, sessionId: string, supersededBefore: Date): Promise<void> {
918
+ await this.refreshTokenService.prune(uid, sessionId, supersededBefore);
919
+ }
920
+
921
+ async getTokensValidAfter(uid: string): Promise<Date | null> {
922
+ return this.refreshTokenService.getTokensValidAfter(uid);
923
+ }
924
+
925
+ async setTokensValidAfter(uid: string, at: Date): Promise<void> {
926
+ await this.refreshTokenService.setTokensValidAfter(uid, at);
809
927
  }
810
928
 
811
929
  async findRefreshTokenByHash(tokenHash: string): Promise<RefreshTokenInfo | null> {
@@ -1018,8 +1136,28 @@ collectionPermissions: null }
1018
1136
 
1019
1137
  // Token operations (delegate to PostgresTokenRepository)
1020
1138
 
1021
- async createRefreshToken(uid: string, tokenHash: string, expiresAt: Date, userAgent?: string, ipAddress?: string): Promise<void> {
1022
- await this.tokenRepository.createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress);
1139
+ async createRefreshToken(uid: string, tokenHash: string, expiresAt: Date, userAgent?: string, ipAddress?: string, session?: RefreshTokenSession): Promise<void> {
1140
+ await this.tokenRepository.createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session);
1141
+ }
1142
+
1143
+ async markRefreshTokenRotated(tokenHash: string): Promise<void> {
1144
+ await this.tokenRepository.markRefreshTokenRotated(tokenHash);
1145
+ }
1146
+
1147
+ async revokeRefreshTokenSession(sessionId: string): Promise<void> {
1148
+ await this.tokenRepository.revokeRefreshTokenSession(sessionId);
1149
+ }
1150
+
1151
+ async pruneRefreshTokens(uid: string, sessionId: string, supersededBefore: Date): Promise<void> {
1152
+ await this.tokenRepository.pruneRefreshTokens(uid, sessionId, supersededBefore);
1153
+ }
1154
+
1155
+ async getTokensValidAfter(uid: string): Promise<Date | null> {
1156
+ return this.tokenRepository.getTokensValidAfter(uid);
1157
+ }
1158
+
1159
+ async setTokensValidAfter(uid: string, at: Date): Promise<void> {
1160
+ await this.tokenRepository.setTokensValidAfter(uid, at);
1023
1161
  }
1024
1162
 
1025
1163
  async findRefreshTokenByHash(tokenHash: string): Promise<RefreshTokenInfo | null> {