leases.ts 2.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
  1. import { randomBytes } from 'node:crypto'
  2. import { HttpError } from './errors.js'
  3. export type RealtimeChannel = 'chat' | 'groups'
  4. export type UpstreamCredential = { name: 'ticket' | 'token'; value: string }
  5. export interface RealtimeLease {
  6. id: string
  7. channel: RealtimeChannel
  8. credential: UpstreamCredential
  9. origin: string
  10. accountKeys: ReadonlySet<string>
  11. epoch?: string
  12. cursor?: number
  13. expiresAt: number
  14. }
  15. export interface IssueLeaseInput {
  16. channel: RealtimeChannel
  17. credential: UpstreamCredential
  18. origin: string
  19. accountKeys: Iterable<string>
  20. epoch?: string
  21. cursor?: number
  22. }
  23. export class RealtimeLeaseStore {
  24. readonly #leases = new Map<string, RealtimeLease>()
  25. constructor(
  26. readonly ttlMs = 20_000,
  27. readonly now: () => number = Date.now,
  28. ) {}
  29. issue(input: IssueLeaseInput): RealtimeLease {
  30. this.prune()
  31. const accountKeys = new Set([...input.accountKeys].filter(Boolean))
  32. if (accountKeys.size === 0) throw new HttpError(401, 'Missing account scope', 'missing_account')
  33. const activeForAccount = [...this.#leases.values()].filter((lease) =>
  34. [...accountKeys].some((key) => lease.accountKeys.has(key)),
  35. ).length
  36. if (activeForAccount >= 16) {
  37. throw new HttpError(429, 'Too many pending realtime leases', 'lease_limit')
  38. }
  39. const id = randomBytes(32).toString('base64url')
  40. const lease: RealtimeLease = {
  41. id,
  42. channel: input.channel,
  43. credential: input.credential,
  44. origin: input.origin,
  45. accountKeys,
  46. epoch: input.epoch,
  47. cursor: input.cursor,
  48. expiresAt: this.now() + this.ttlMs,
  49. }
  50. this.#leases.set(id, lease)
  51. return lease
  52. }
  53. consume(
  54. id: string,
  55. channel: RealtimeChannel,
  56. origin: string,
  57. accountKey: string,
  58. ): RealtimeLease {
  59. this.prune()
  60. const lease = this.#leases.get(id)
  61. if (!lease || lease.channel !== channel || lease.origin !== origin || !lease.accountKeys.has(accountKey)) {
  62. throw new HttpError(401, 'Realtime lease is invalid or expired', 'invalid_lease')
  63. }
  64. this.#leases.delete(id)
  65. return lease
  66. }
  67. prune(): void {
  68. const now = this.now()
  69. for (const [id, lease] of this.#leases) {
  70. if (lease.expiresAt <= now) this.#leases.delete(id)
  71. }
  72. }
  73. get size(): number {
  74. this.prune()
  75. return this.#leases.size
  76. }
  77. }
  78. export function canonicalEpoch(value: unknown): string {
  79. if (typeof value !== 'string'
  80. || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value)) {
  81. throw new HttpError(400, 'epoch must be a canonical lowercase UUID', 'invalid_epoch')
  82. }
  83. return value
  84. }
  85. export function groupCursor(value: unknown): number {
  86. const cursor = typeof value === 'number' ? value : Number(value ?? 0)
  87. if (!Number.isSafeInteger(cursor) || cursor < 0) {
  88. throw new HttpError(400, 'cursor must be a non-negative integer', 'invalid_cursor')
  89. }
  90. return cursor
  91. }