@puzzle-ui/session 0.0.0-stage → 0.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 (61) hide show
  1. package/README.md +143 -2
  2. package/dist/LICENSE +21 -0
  3. package/dist/errors/session.errors.cjs +47 -0
  4. package/dist/errors/session.errors.cjs.map +1 -0
  5. package/dist/errors/session.errors.d.cts +19 -0
  6. package/dist/errors/session.errors.d.ts +19 -0
  7. package/dist/errors/session.errors.js +42 -0
  8. package/dist/errors/session.errors.js.map +1 -0
  9. package/dist/index.cjs +32 -0
  10. package/dist/index.d.cts +12 -0
  11. package/dist/index.d.ts +12 -0
  12. package/dist/index.js +9 -0
  13. package/dist/session-data.manager.cjs +141 -0
  14. package/dist/session-data.manager.cjs.map +1 -0
  15. package/dist/session-data.manager.d.cts +26 -0
  16. package/dist/session-data.manager.d.ts +26 -0
  17. package/dist/session-data.manager.js +141 -0
  18. package/dist/session-data.manager.js.map +1 -0
  19. package/dist/session.cookies.cjs +64 -0
  20. package/dist/session.cookies.cjs.map +1 -0
  21. package/dist/session.cookies.d.cts +10 -0
  22. package/dist/session.cookies.d.ts +10 -0
  23. package/dist/session.cookies.js +61 -0
  24. package/dist/session.cookies.js.map +1 -0
  25. package/dist/session.csrf.cjs +42 -0
  26. package/dist/session.csrf.cjs.map +1 -0
  27. package/dist/session.csrf.d.cts +6 -0
  28. package/dist/session.csrf.d.ts +6 -0
  29. package/dist/session.csrf.js +41 -0
  30. package/dist/session.csrf.js.map +1 -0
  31. package/dist/session.manager.cjs +289 -0
  32. package/dist/session.manager.cjs.map +1 -0
  33. package/dist/session.manager.d.cts +25 -0
  34. package/dist/session.manager.d.ts +25 -0
  35. package/dist/session.manager.js +289 -0
  36. package/dist/session.manager.js.map +1 -0
  37. package/dist/session.migration.cjs +163 -0
  38. package/dist/session.migration.cjs.map +1 -0
  39. package/dist/session.migration.d.cts +50 -0
  40. package/dist/session.migration.d.ts +50 -0
  41. package/dist/session.migration.js +163 -0
  42. package/dist/session.migration.js.map +1 -0
  43. package/dist/session.storage.cjs +64 -0
  44. package/dist/session.storage.cjs.map +1 -0
  45. package/dist/session.storage.d.cts +8 -0
  46. package/dist/session.storage.d.ts +8 -0
  47. package/dist/session.storage.js +58 -0
  48. package/dist/session.storage.js.map +1 -0
  49. package/dist/types/session-lock.types.d.cts +4 -0
  50. package/dist/types/session-lock.types.d.ts +4 -0
  51. package/dist/types/session-manager.types.d.cts +37 -0
  52. package/dist/types/session-manager.types.d.ts +37 -0
  53. package/dist/types/session.types.d.cts +51 -0
  54. package/dist/types/session.types.d.ts +51 -0
  55. package/dist/utils/session-crypto.cjs +29 -0
  56. package/dist/utils/session-crypto.cjs.map +1 -0
  57. package/dist/utils/session-crypto.d.cts +3 -0
  58. package/dist/utils/session-crypto.d.ts +3 -0
  59. package/dist/utils/session-crypto.js +27 -0
  60. package/dist/utils/session-crypto.js.map +1 -0
  61. package/package.json +43 -4
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session.manager.js","names":[],"sources":["../src/session.manager.ts"],"sourcesContent":["import {\n StorageConflictError,\n StorageDuplicateError,\n type StorageAdapter,\n type StorageBatchOperation,\n} from \"@puzzle-ui/storage\";\n\nimport {\n SessionConfigurationError,\n SessionNotFoundError,\n} from \"./errors/session.errors\";\n\nimport {\n generateSessionId,\n generateSessionToken,\n hashSessionToken,\n} from \"./utils/session-crypto\";\n\nimport {\n SESSION_COLLECTION,\n SESSION_USER_LOCK_COLLECTION,\n sessionCollection,\n sessionUserLockCollection,\n} from \"./session.storage\";\n\nimport type {\n SessionDevice,\n SessionRecord,\n SessionStorageRecord,\n} from \"./types/session.types\";\n\nimport type { SessionUserLockRecord } from \"./types/session-lock.types\";\n\nimport type {\n CreatedSession,\n CreateSessionInput,\n SessionManager,\n SessionManagerOptions,\n ValidateSessionOptions,\n RotateSessionInput,\n} from \"./types/session-manager.types\";\n\nconst DEFAULT_OPTIONS: SessionManagerOptions = {\n lifetime: {\n idleTimeoutSeconds: 60 * 60 * 24 * 7,\n absoluteLifetimeSeconds: 60 * 60 * 24 * 30,\n },\n maximumSessionsPerUser: 10,\n touchIntervalSeconds: 60,\n};\n\nconst toPublicSession = (record: SessionStorageRecord): SessionRecord => ({\n id: record.id,\n tokenHash: record.tokenHash,\n userId: record.userId,\n kind: record.kind,\n createdAt: record.createdAt,\n lastSeenAt: record.lastSeenAt,\n expiresAt: record.expiresAt,\n revokedAt: record.revokedAt,\n metadata: record.metadata,\n});\n\nconst isActive = (session: SessionStorageRecord, now: Date): boolean =>\n session.revokedAt === null && session.expiresAt.getTime() > now.getTime();\n\nconst validateOptions = (options: SessionManagerOptions): void => {\n const { idleTimeoutSeconds, absoluteLifetimeSeconds } = options.lifetime;\n\n if (\n !Number.isFinite(idleTimeoutSeconds) ||\n !Number.isFinite(absoluteLifetimeSeconds) ||\n idleTimeoutSeconds <= 0 ||\n absoluteLifetimeSeconds <= 0 ||\n idleTimeoutSeconds > absoluteLifetimeSeconds\n ) {\n throw new SessionConfigurationError(\n \"Session lifetimes must be positive and the idle timeout cannot exceed the absolute lifetime.\",\n );\n }\n\n if (\n options.maximumSessionsPerUser !== undefined &&\n (!Number.isInteger(options.maximumSessionsPerUser) ||\n options.maximumSessionsPerUser < 1)\n ) {\n throw new SessionConfigurationError(\n \"maximumSessionsPerUser must be a positive integer.\",\n );\n }\n\n if (\n options.touchIntervalSeconds !== undefined &&\n (!Number.isFinite(options.touchIntervalSeconds) ||\n options.touchIntervalSeconds < 0)\n ) {\n throw new SessionConfigurationError(\n \"touchIntervalSeconds must be a non-negative number.\",\n );\n }\n};\n\nexport class StorageSessionManager implements SessionManager {\n private readonly options: SessionManagerOptions;\n private readonly collection: string;\n private readonly clock: () => Date;\n\n constructor(\n private readonly storage: StorageAdapter,\n options: Partial<SessionManagerOptions> = {},\n ) {\n this.options = {\n ...DEFAULT_OPTIONS,\n ...options,\n lifetime: {\n ...DEFAULT_OPTIONS.lifetime,\n ...options.lifetime,\n },\n };\n\n validateOptions(this.options);\n\n this.collection = options.collectionName ?? SESSION_COLLECTION;\n this.clock = options.clock ?? (() => new Date());\n\n if (\n !storage.capabilities.uniqueConstraints ||\n !storage.capabilities.conditionalWrites ||\n !storage.capabilities.atomicBatch\n ) {\n throw new SessionConfigurationError(\n \"The storage adapter does not support the atomicity required by session management.\",\n );\n }\n }\n\n async initialize(): Promise<void> {\n await this.storage.registerCollection({\n ...sessionCollection,\n name: this.collection,\n });\n\n await this.storage.registerCollection(sessionUserLockCollection);\n }\n\n private now(): Date {\n return this.clock();\n }\n\n private absoluteExpiry(createdAt: Date): Date {\n return new Date(\n createdAt.getTime() +\n this.options.lifetime.absoluteLifetimeSeconds * 1000,\n );\n }\n\n private idleExpiry(lastSeenAt: Date): Date {\n return new Date(\n lastSeenAt.getTime() + this.options.lifetime.idleTimeoutSeconds * 1000,\n );\n }\n\n private effectiveExpiry(createdAt: Date, lastSeenAt: Date): Date {\n return new Date(\n Math.min(\n this.absoluteExpiry(createdAt).getTime(),\n this.idleExpiry(lastSeenAt).getTime(),\n ),\n );\n }\n\n private async createRecord(\n input: CreateSessionInput,\n now: Date,\n ): Promise<CreatedSession> {\n if (input.kind === \"authenticated\" && !input.userId) {\n throw new SessionConfigurationError(\n \"Authenticated sessions require a user ID.\",\n );\n }\n\n if (input.kind === \"anonymous\" && input.userId !== null) {\n throw new SessionConfigurationError(\n \"Anonymous sessions cannot have a user ID.\",\n );\n }\n\n const token = generateSessionToken();\n const tokenHash = await hashSessionToken(token);\n\n const absoluteExpiry = this.absoluteExpiry(now);\n const requestedExpiry = input.expiresAt ?? absoluteExpiry;\n\n if (\n !(requestedExpiry instanceof Date) ||\n !Number.isFinite(requestedExpiry.getTime()) ||\n requestedExpiry.getTime() <= now.getTime()\n ) {\n throw new SessionConfigurationError(\n \"Session expiration must be a valid future date.\",\n );\n }\n\n const record: SessionStorageRecord = {\n id: generateSessionId(),\n tokenHash,\n userId: input.userId,\n kind: input.kind,\n createdAt: now,\n updatedAt: now,\n revision: 1,\n lastSeenAt: now,\n expiresAt: new Date(\n Math.min(\n requestedExpiry.getTime(),\n absoluteExpiry.getTime(),\n this.idleExpiry(now).getTime(),\n ),\n ),\n revokedAt: null,\n metadata: input.metadata,\n };\n\n return {\n session: toPublicSession(record),\n token,\n };\n }\n\n private toStorageRecord(session: SessionRecord): SessionStorageRecord {\n return {\n ...session,\n updatedAt: session.lastSeenAt,\n revision: 1,\n };\n }\n\n async create(input: CreateSessionInput): Promise<CreatedSession> {\n const created = await this.createRecord(input, this.now());\n\n if (!input.userId) {\n await this.storage.insert<SessionStorageRecord>(\n this.collection,\n this.toStorageRecord(created.session),\n );\n\n return created;\n }\n\n await this.createAuthenticatedSessionAtomically(input.userId, created);\n\n return created;\n }\n\n private async createAuthenticatedSessionAtomically(\n userId: string,\n created: CreatedSession,\n ): Promise<void> {\n const maximum = this.options.maximumSessionsPerUser;\n\n for (let attempt = 0; attempt < 8; attempt++) {\n const now = this.now();\n\n const lock = await this.storage.findOne<SessionUserLockRecord>(\n SESSION_USER_LOCK_COLLECTION,\n {\n where: {\n userId: { operator: \"eq\", value: userId },\n },\n },\n );\n\n const sessions = await this.storage.findMany<SessionStorageRecord>(\n this.collection,\n {\n where: {\n userId: { operator: \"eq\", value: userId },\n revokedAt: { operator: \"eq\", value: null },\n },\n orderBy: [\n { field: \"lastSeenAt\", direction: \"desc\" },\n { field: \"createdAt\", direction: \"desc\" },\n ],\n },\n );\n\n const active = sessions.filter((session) => isActive(session, now));\n\n const excess =\n maximum === undefined ? [] : active.slice(Math.max(0, maximum - 1));\n\n const operations: (\n | StorageBatchOperation<SessionStorageRecord>\n | StorageBatchOperation<SessionUserLockRecord>\n )[] = [];\n\n if (lock) {\n operations.push({\n type: \"update\",\n collection: SESSION_USER_LOCK_COLLECTION,\n id: lock.id,\n condition: {\n expectedRevision: lock.revision,\n },\n changes: {},\n });\n } else {\n operations.push({\n type: \"insert\",\n collection: SESSION_USER_LOCK_COLLECTION,\n record: {\n id: generateSessionId(),\n userId,\n createdAt: now,\n updatedAt: now,\n revision: 1,\n },\n });\n }\n\n operations.push({\n type: \"insert\",\n collection: this.collection,\n record: this.toStorageRecord(created.session),\n });\n\n for (const session of excess) {\n operations.push({\n type: \"update\",\n collection: this.collection,\n id: session.id,\n condition: {\n expectedRevision: session.revision,\n },\n changes: {\n revokedAt: now,\n },\n });\n }\n\n try {\n await this.storage.atomicBatch<\n SessionStorageRecord | SessionUserLockRecord\n >(operations);\n\n return;\n } catch (error) {\n if (\n error instanceof StorageConflictError ||\n error instanceof StorageDuplicateError\n ) {\n continue;\n }\n\n throw error;\n }\n }\n\n throw new SessionConfigurationError(\n \"Could not acquire the user session concurrency lock after repeated conflicts.\",\n );\n }\n\n async validate(\n token: string,\n options: ValidateSessionOptions = {},\n ): Promise<SessionRecord | null> {\n if (!token) return null;\n\n const tokenHash = await hashSessionToken(token);\n\n const session = await this.storage.findOne<SessionStorageRecord>(\n this.collection,\n {\n where: {\n tokenHash: { operator: \"eq\", value: tokenHash },\n },\n },\n );\n\n if (!session) return null;\n\n const now = this.now();\n\n if (!isActive(session, now)) return null;\n\n if (\n this.idleExpiry(session.lastSeenAt).getTime() <= now.getTime() ||\n this.absoluteExpiry(session.createdAt).getTime() <= now.getTime()\n ) {\n await this.revoke(session.id);\n return null;\n }\n\n const touch =\n options.touch !== false &&\n now.getTime() - session.lastSeenAt.getTime() >=\n (this.options.touchIntervalSeconds ?? 60) * 1000;\n\n if (!touch) return toPublicSession(session);\n\n try {\n const updated = await this.storage.update<SessionStorageRecord>(\n this.collection,\n session.id,\n {\n lastSeenAt: now,\n expiresAt: this.effectiveExpiry(session.createdAt, now),\n },\n { expectedRevision: session.revision },\n );\n\n return updated ? toPublicSession(updated) : null;\n } catch (error) {\n if (error instanceof StorageConflictError) {\n return null;\n }\n\n throw error;\n }\n }\n\n async get(sessionId: string): Promise<SessionRecord | null> {\n const session = await this.storage.findById<SessionStorageRecord>(\n this.collection,\n sessionId,\n );\n\n if (!session || !isActive(session, this.now())) return null;\n\n const now = this.now();\n\n if (\n this.idleExpiry(session.lastSeenAt).getTime() <= now.getTime() ||\n this.absoluteExpiry(session.createdAt).getTime() <= now.getTime()\n ) {\n await this.revoke(sessionId);\n return null;\n }\n\n return toPublicSession(session);\n }\n\n async rotate(input: RotateSessionInput): Promise<CreatedSession> {\n const current = await this.storage.findById<SessionStorageRecord>(\n this.collection,\n input.sessionId,\n );\n\n const now = this.now();\n\n if (\n !current ||\n !isActive(current, now) ||\n this.idleExpiry(current.lastSeenAt).getTime() <= now.getTime() ||\n this.absoluteExpiry(current.createdAt).getTime() <= now.getTime()\n ) {\n throw new SessionNotFoundError();\n }\n\n const created = await this.createRecord(\n {\n userId: current.userId,\n kind: current.kind,\n metadata: input.metadata ?? current.metadata,\n },\n now,\n );\n\n const operations: StorageBatchOperation<SessionStorageRecord>[] = [\n {\n type: \"insert\",\n collection: this.collection,\n record: this.toStorageRecord(created.session),\n },\n {\n type: \"update\",\n collection: this.collection,\n id: current.id,\n condition: {\n expectedRevision: current.revision,\n },\n changes: {\n revokedAt: now,\n },\n },\n ];\n\n await this.storage.atomicBatch(operations);\n\n return created;\n }\n\n async revoke(sessionId: string): Promise<void> {\n const session = await this.storage.findById<SessionStorageRecord>(\n this.collection,\n sessionId,\n );\n\n if (!session || session.revokedAt) return;\n\n await this.storage.update<SessionStorageRecord>(\n this.collection,\n sessionId,\n { revokedAt: this.now() },\n { expectedRevision: session.revision },\n );\n }\n\n async revokeAll(userId: string): Promise<void> {\n if (!userId) {\n throw new SessionConfigurationError(\n \"A user ID is required to revoke sessions.\",\n );\n }\n\n await this.storage.updateMany<SessionStorageRecord>(\n this.collection,\n {\n where: {\n userId: { operator: \"eq\", value: userId },\n revokedAt: { operator: \"eq\", value: null },\n },\n },\n { revokedAt: this.now() },\n );\n }\n\n async list(\n userId: string,\n currentSessionId?: string,\n ): Promise<readonly SessionDevice[]> {\n const now = this.now();\n\n const sessions = await this.storage.findMany<SessionStorageRecord>(\n this.collection,\n {\n where: {\n userId: { operator: \"eq\", value: userId },\n revokedAt: { operator: \"eq\", value: null },\n },\n orderBy: [{ field: \"lastSeenAt\", direction: \"desc\" }],\n },\n );\n\n return sessions\n .filter(\n (session) =>\n isActive(session, now) &&\n this.idleExpiry(session.lastSeenAt).getTime() > now.getTime() &&\n this.absoluteExpiry(session.createdAt).getTime() > now.getTime(),\n )\n .map((session) => ({\n sessionId: session.id,\n kind: session.kind,\n createdAt: session.createdAt,\n lastSeenAt: session.lastSeenAt,\n expiresAt: session.expiresAt,\n current: session.id === currentSessionId,\n metadata: session.metadata,\n }));\n }\n}\n"],"mappings":";;;;;AA0CA,MAAM,kBAAyC;CAC7C,UAAU;EACR,oBAAoB;EACpB,yBAAyB;CAC3B;CACA,wBAAwB;CACxB,sBAAsB;AACxB;AAEA,MAAM,mBAAmB,YAAiD;CACxE,IAAI,OAAO;CACX,WAAW,OAAO;CAClB,QAAQ,OAAO;CACf,MAAM,OAAO;CACb,WAAW,OAAO;CAClB,YAAY,OAAO;CACnB,WAAW,OAAO;CAClB,WAAW,OAAO;CAClB,UAAU,OAAO;AACnB;AAEA,MAAM,YAAY,SAA+B,QAC/C,QAAQ,cAAc,QAAQ,QAAQ,UAAU,QAAQ,IAAI,IAAI,QAAQ;AAE1E,MAAM,mBAAmB,YAAyC;CAChE,MAAM,EAAE,oBAAoB,4BAA4B,QAAQ;CAEhE,IACE,CAAC,OAAO,SAAS,kBAAkB,KACnC,CAAC,OAAO,SAAS,uBAAuB,KACxC,sBAAsB,KACtB,2BAA2B,KAC3B,qBAAqB,yBAErB,MAAM,IAAI,0BACR,8FACF;CAGF,IACE,QAAQ,2BAA2B,KAAA,MAClC,CAAC,OAAO,UAAU,QAAQ,sBAAsB,KAC/C,QAAQ,yBAAyB,IAEnC,MAAM,IAAI,0BACR,oDACF;CAGF,IACE,QAAQ,yBAAyB,KAAA,MAChC,CAAC,OAAO,SAAS,QAAQ,oBAAoB,KAC5C,QAAQ,uBAAuB,IAEjC,MAAM,IAAI,0BACR,qDACF;AAEJ;AAEA,IAAa,wBAAb,MAA6D;CAMxC;CALnB;CACA;CACA;CAEA,YACE,SACA,UAA0C,CAAC,GAC3C;EAFiB,KAAA,UAAA;EAGjB,KAAK,UAAU;GACb,GAAG;GACH,GAAG;GACH,UAAU;IACR,GAAG,gBAAgB;IACnB,GAAG,QAAQ;GACb;EACF;EAEA,gBAAgB,KAAK,OAAO;EAE5B,KAAK,aAAa,QAAQ,kBAAA;EAC1B,KAAK,QAAQ,QAAQ,gCAAgB,IAAI,KAAK;EAE9C,IACE,CAAC,QAAQ,aAAa,qBACtB,CAAC,QAAQ,aAAa,qBACtB,CAAC,QAAQ,aAAa,aAEtB,MAAM,IAAI,0BACR,oFACF;CAEJ;CAEA,MAAM,aAA4B;EAChC,MAAM,KAAK,QAAQ,mBAAmB;GACpC,GAAG;GACH,MAAM,KAAK;EACb,CAAC;EAED,MAAM,KAAK,QAAQ,mBAAmB,yBAAyB;CACjE;CAEA,MAAoB;EAClB,OAAO,KAAK,MAAM;CACpB;CAEA,eAAuB,WAAuB;EAC5C,OAAO,IAAI,KACT,UAAU,QAAQ,IAChB,KAAK,QAAQ,SAAS,0BAA0B,GACpD;CACF;CAEA,WAAmB,YAAwB;EACzC,OAAO,IAAI,KACT,WAAW,QAAQ,IAAI,KAAK,QAAQ,SAAS,qBAAqB,GACpE;CACF;CAEA,gBAAwB,WAAiB,YAAwB;EAC/D,OAAO,IAAI,KACT,KAAK,IACH,KAAK,eAAe,SAAS,CAAC,CAAC,QAAQ,GACvC,KAAK,WAAW,UAAU,CAAC,CAAC,QAAQ,CACtC,CACF;CACF;CAEA,MAAc,aACZ,OACA,KACyB;EACzB,IAAI,MAAM,SAAS,mBAAmB,CAAC,MAAM,QAC3C,MAAM,IAAI,0BACR,2CACF;EAGF,IAAI,MAAM,SAAS,eAAe,MAAM,WAAW,MACjD,MAAM,IAAI,0BACR,2CACF;EAGF,MAAM,QAAQ,qBAAqB;EACnC,MAAM,YAAY,MAAM,iBAAiB,KAAK;EAE9C,MAAM,iBAAiB,KAAK,eAAe,GAAG;EAC9C,MAAM,kBAAkB,MAAM,aAAa;EAE3C,IACE,EAAE,2BAA2B,SAC7B,CAAC,OAAO,SAAS,gBAAgB,QAAQ,CAAC,KAC1C,gBAAgB,QAAQ,KAAK,IAAI,QAAQ,GAEzC,MAAM,IAAI,0BACR,iDACF;EAGF,MAAM,SAA+B;GACnC,IAAI,kBAAkB;GACtB;GACA,QAAQ,MAAM;GACd,MAAM,MAAM;GACZ,WAAW;GACX,WAAW;GACX,UAAU;GACV,YAAY;GACZ,WAAW,IAAI,KACb,KAAK,IACH,gBAAgB,QAAQ,GACxB,eAAe,QAAQ,GACvB,KAAK,WAAW,GAAG,CAAC,CAAC,QAAQ,CAC/B,CACF;GACA,WAAW;GACX,UAAU,MAAM;EAClB;EAEA,OAAO;GACL,SAAS,gBAAgB,MAAM;GAC/B;EACF;CACF;CAEA,gBAAwB,SAA8C;EACpE,OAAO;GACL,GAAG;GACH,WAAW,QAAQ;GACnB,UAAU;EACZ;CACF;CAEA,MAAM,OAAO,OAAoD;EAC/D,MAAM,UAAU,MAAM,KAAK,aAAa,OAAO,KAAK,IAAI,CAAC;EAEzD,IAAI,CAAC,MAAM,QAAQ;GACjB,MAAM,KAAK,QAAQ,OACjB,KAAK,YACL,KAAK,gBAAgB,QAAQ,OAAO,CACtC;GAEA,OAAO;EACT;EAEA,MAAM,KAAK,qCAAqC,MAAM,QAAQ,OAAO;EAErE,OAAO;CACT;CAEA,MAAc,qCACZ,QACA,SACe;EACf,MAAM,UAAU,KAAK,QAAQ;EAE7B,KAAK,IAAI,UAAU,GAAG,UAAU,GAAG,WAAW;GAC5C,MAAM,MAAM,KAAK,IAAI;GAErB,MAAM,OAAO,MAAM,KAAK,QAAQ,QAC9B,8BACA,EACE,OAAO,EACL,QAAQ;IAAE,UAAU;IAAM,OAAO;GAAO,EAC1C,EACF,CACF;GAgBA,MAAM,UAAS,MAdQ,KAAK,QAAQ,SAClC,KAAK,YACL;IACE,OAAO;KACL,QAAQ;MAAE,UAAU;MAAM,OAAO;KAAO;KACxC,WAAW;MAAE,UAAU;MAAM,OAAO;KAAK;IAC3C;IACA,SAAS,CACP;KAAE,OAAO;KAAc,WAAW;IAAO,GACzC;KAAE,OAAO;KAAa,WAAW;IAAO,CAC1C;GACF,CACF,EAAA,CAEwB,QAAQ,YAAY,SAAS,SAAS,GAAG,CAAC;GAElE,MAAM,SACJ,YAAY,KAAA,IAAY,CAAC,IAAI,OAAO,MAAM,KAAK,IAAI,GAAG,UAAU,CAAC,CAAC;GAEpE,MAAM,aAGA,CAAC;GAEP,IAAI,MACF,WAAW,KAAK;IACd,MAAM;IACN,YAAY;IACZ,IAAI,KAAK;IACT,WAAW,EACT,kBAAkB,KAAK,SACzB;IACA,SAAS,CAAC;GACZ,CAAC;QAED,WAAW,KAAK;IACd,MAAM;IACN,YAAY;IACZ,QAAQ;KACN,IAAI,kBAAkB;KACtB;KACA,WAAW;KACX,WAAW;KACX,UAAU;IACZ;GACF,CAAC;GAGH,WAAW,KAAK;IACd,MAAM;IACN,YAAY,KAAK;IACjB,QAAQ,KAAK,gBAAgB,QAAQ,OAAO;GAC9C,CAAC;GAED,KAAK,MAAM,WAAW,QACpB,WAAW,KAAK;IACd,MAAM;IACN,YAAY,KAAK;IACjB,IAAI,QAAQ;IACZ,WAAW,EACT,kBAAkB,QAAQ,SAC5B;IACA,SAAS,EACP,WAAW,IACb;GACF,CAAC;GAGH,IAAI;IACF,MAAM,KAAK,QAAQ,YAEjB,UAAU;IAEZ;GACF,SAAS,OAAO;IACd,IACE,iBAAiB,wBACjB,iBAAiB,uBAEjB;IAGF,MAAM;GACR;EACF;EAEA,MAAM,IAAI,0BACR,+EACF;CACF;CAEA,MAAM,SACJ,OACA,UAAkC,CAAC,GACJ;EAC/B,IAAI,CAAC,OAAO,OAAO;EAEnB,MAAM,YAAY,MAAM,iBAAiB,KAAK;EAE9C,MAAM,UAAU,MAAM,KAAK,QAAQ,QACjC,KAAK,YACL,EACE,OAAO,EACL,WAAW;GAAE,UAAU;GAAM,OAAO;EAAU,EAChD,EACF,CACF;EAEA,IAAI,CAAC,SAAS,OAAO;EAErB,MAAM,MAAM,KAAK,IAAI;EAErB,IAAI,CAAC,SAAS,SAAS,GAAG,GAAG,OAAO;EAEpC,IACE,KAAK,WAAW,QAAQ,UAAU,CAAC,CAAC,QAAQ,KAAK,IAAI,QAAQ,KAC7D,KAAK,eAAe,QAAQ,SAAS,CAAC,CAAC,QAAQ,KAAK,IAAI,QAAQ,GAChE;GACA,MAAM,KAAK,OAAO,QAAQ,EAAE;GAC5B,OAAO;EACT;EAOA,IAAI,EAJF,QAAQ,UAAU,SAClB,IAAI,QAAQ,IAAI,QAAQ,WAAW,QAAQ,MACxC,KAAK,QAAQ,wBAAwB,MAAM,MAEpC,OAAO,gBAAgB,OAAO;EAE1C,IAAI;GACF,MAAM,UAAU,MAAM,KAAK,QAAQ,OACjC,KAAK,YACL,QAAQ,IACR;IACE,YAAY;IACZ,WAAW,KAAK,gBAAgB,QAAQ,WAAW,GAAG;GACxD,GACA,EAAE,kBAAkB,QAAQ,SAAS,CACvC;GAEA,OAAO,UAAU,gBAAgB,OAAO,IAAI;EAC9C,SAAS,OAAO;GACd,IAAI,iBAAiB,sBACnB,OAAO;GAGT,MAAM;EACR;CACF;CAEA,MAAM,IAAI,WAAkD;EAC1D,MAAM,UAAU,MAAM,KAAK,QAAQ,SACjC,KAAK,YACL,SACF;EAEA,IAAI,CAAC,WAAW,CAAC,SAAS,SAAS,KAAK,IAAI,CAAC,GAAG,OAAO;EAEvD,MAAM,MAAM,KAAK,IAAI;EAErB,IACE,KAAK,WAAW,QAAQ,UAAU,CAAC,CAAC,QAAQ,KAAK,IAAI,QAAQ,KAC7D,KAAK,eAAe,QAAQ,SAAS,CAAC,CAAC,QAAQ,KAAK,IAAI,QAAQ,GAChE;GACA,MAAM,KAAK,OAAO,SAAS;GAC3B,OAAO;EACT;EAEA,OAAO,gBAAgB,OAAO;CAChC;CAEA,MAAM,OAAO,OAAoD;EAC/D,MAAM,UAAU,MAAM,KAAK,QAAQ,SACjC,KAAK,YACL,MAAM,SACR;EAEA,MAAM,MAAM,KAAK,IAAI;EAErB,IACE,CAAC,WACD,CAAC,SAAS,SAAS,GAAG,KACtB,KAAK,WAAW,QAAQ,UAAU,CAAC,CAAC,QAAQ,KAAK,IAAI,QAAQ,KAC7D,KAAK,eAAe,QAAQ,SAAS,CAAC,CAAC,QAAQ,KAAK,IAAI,QAAQ,GAEhE,MAAM,IAAI,qBAAqB;EAGjC,MAAM,UAAU,MAAM,KAAK,aACzB;GACE,QAAQ,QAAQ;GAChB,MAAM,QAAQ;GACd,UAAU,MAAM,YAAY,QAAQ;EACtC,GACA,GACF;EAEA,MAAM,aAA4D,CAChE;GACE,MAAM;GACN,YAAY,KAAK;GACjB,QAAQ,KAAK,gBAAgB,QAAQ,OAAO;EAC9C,GACA;GACE,MAAM;GACN,YAAY,KAAK;GACjB,IAAI,QAAQ;GACZ,WAAW,EACT,kBAAkB,QAAQ,SAC5B;GACA,SAAS,EACP,WAAW,IACb;EACF,CACF;EAEA,MAAM,KAAK,QAAQ,YAAY,UAAU;EAEzC,OAAO;CACT;CAEA,MAAM,OAAO,WAAkC;EAC7C,MAAM,UAAU,MAAM,KAAK,QAAQ,SACjC,KAAK,YACL,SACF;EAEA,IAAI,CAAC,WAAW,QAAQ,WAAW;EAEnC,MAAM,KAAK,QAAQ,OACjB,KAAK,YACL,WACA,EAAE,WAAW,KAAK,IAAI,EAAE,GACxB,EAAE,kBAAkB,QAAQ,SAAS,CACvC;CACF;CAEA,MAAM,UAAU,QAA+B;EAC7C,IAAI,CAAC,QACH,MAAM,IAAI,0BACR,2CACF;EAGF,MAAM,KAAK,QAAQ,WACjB,KAAK,YACL,EACE,OAAO;GACL,QAAQ;IAAE,UAAU;IAAM,OAAO;GAAO;GACxC,WAAW;IAAE,UAAU;IAAM,OAAO;GAAK;EAC3C,EACF,GACA,EAAE,WAAW,KAAK,IAAI,EAAE,CAC1B;CACF;CAEA,MAAM,KACJ,QACA,kBACmC;EACnC,MAAM,MAAM,KAAK,IAAI;EAarB,QAAO,MAXgB,KAAK,QAAQ,SAClC,KAAK,YACL;GACE,OAAO;IACL,QAAQ;KAAE,UAAU;KAAM,OAAO;IAAO;IACxC,WAAW;KAAE,UAAU;KAAM,OAAO;IAAK;GAC3C;GACA,SAAS,CAAC;IAAE,OAAO;IAAc,WAAW;GAAO,CAAC;EACtD,CACF,EAAA,CAGG,QACE,YACC,SAAS,SAAS,GAAG,KACrB,KAAK,WAAW,QAAQ,UAAU,CAAC,CAAC,QAAQ,IAAI,IAAI,QAAQ,KAC5D,KAAK,eAAe,QAAQ,SAAS,CAAC,CAAC,QAAQ,IAAI,IAAI,QAAQ,CACnE,CAAC,CACA,KAAK,aAAa;GACjB,WAAW,QAAQ;GACnB,MAAM,QAAQ;GACd,WAAW,QAAQ;GACnB,YAAY,QAAQ;GACpB,WAAW,QAAQ;GACnB,SAAS,QAAQ,OAAO;GACxB,UAAU,QAAQ;EACpB,EAAE;CACN;AACF"}
@@ -0,0 +1,163 @@
1
+ const require_session_errors = require("./errors/session.errors.cjs");
2
+ const require_session_storage = require("./session.storage.cjs");
3
+ const require_session_crypto = require("./utils/session-crypto.cjs");
4
+ let _puzzle_ui_storage = require("@puzzle-ui/storage");
5
+ // src/session.migration.ts
6
+ const serialize = (value) => {
7
+ let result;
8
+ try {
9
+ result = JSON.stringify(value);
10
+ } catch {
11
+ throw new require_session_errors.SessionSecurityError("Migrated session data must be JSON serializable.");
12
+ }
13
+ if (result === void 0) throw new require_session_errors.SessionSecurityError("Migrated session data must be JSON serializable.");
14
+ return result;
15
+ };
16
+ const toBytes = (value) => new TextEncoder().encode(serialize(value)).byteLength;
17
+ const defaultMerge = (records) => records.map((record) => ({
18
+ sourceIds: [record.id],
19
+ namespace: record.namespace,
20
+ key: record.key,
21
+ value: record.value,
22
+ expiresAt: record.expiresAt
23
+ }));
24
+ var SessionMigrationManager = class {
25
+ storage;
26
+ options;
27
+ clock;
28
+ constructor(storage, options) {
29
+ this.storage = storage;
30
+ this.options = options;
31
+ this.clock = options.clock ?? (() => /* @__PURE__ */ new Date());
32
+ if (!options.allowedNamespaces.length || !Number.isFinite(options.idleTimeoutSeconds) || !Number.isFinite(options.absoluteLifetimeSeconds) || options.idleTimeoutSeconds <= 0 || options.absoluteLifetimeSeconds <= 0 || options.idleTimeoutSeconds > options.absoluteLifetimeSeconds) throw new Error("Invalid anonymous session migration configuration.");
33
+ if (options.maximumRecords !== void 0 && (!Number.isInteger(options.maximumRecords) || options.maximumRecords < 0)) throw new Error("maximumRecords must be a non-negative integer.");
34
+ if (options.maximumTotalBytes !== void 0 && (!Number.isInteger(options.maximumTotalBytes) || options.maximumTotalBytes < 0)) throw new Error("maximumTotalBytes must be a non-negative integer.");
35
+ }
36
+ async migrate(input) {
37
+ if (!input.anonymousSessionId || !input.userId) throw new require_session_errors.SessionSecurityError("An anonymous session ID and authenticated user ID are required.");
38
+ const now = this.clock();
39
+ const oldSession = await this.storage.findById(require_session_storage.SESSION_COLLECTION, input.anonymousSessionId);
40
+ if (!oldSession || oldSession.kind !== "anonymous" || oldSession.userId !== null || oldSession.revokedAt !== null) throw new require_session_errors.SessionSecurityError("The anonymous session is not eligible for migration.");
41
+ if (oldSession.expiresAt.getTime() <= now.getTime() || oldSession.lastSeenAt.getTime() + this.options.idleTimeoutSeconds * 1e3 <= now.getTime() || oldSession.createdAt.getTime() + this.options.absoluteLifetimeSeconds * 1e3 <= now.getTime()) throw new require_session_errors.SessionSecurityError("The anonymous session has expired.");
42
+ const records = (await this.storage.findMany(require_session_storage.SESSION_DATA_COLLECTION, { where: { ownerId: {
43
+ operator: "eq",
44
+ value: oldSession.id
45
+ } } })).filter((record) => this.options.allowedNamespaces.includes(record.namespace) && (!record.expiresAt || record.expiresAt.getTime() > now.getTime()));
46
+ const maximumRecords = this.options.maximumRecords ?? 100;
47
+ const maximumTotalBytes = this.options.maximumTotalBytes ?? 65536;
48
+ const maximumEntryBytes = this.options.maximumEntryBytes ?? 16384;
49
+ if (records.length > maximumRecords) throw new require_session_errors.SessionSecurityError("Anonymous session contains too many records to migrate.");
50
+ if (records.reduce((total, record) => total + toBytes(record.value), 0) > maximumTotalBytes) throw new require_session_errors.SessionSecurityError("Anonymous session data exceeds the migration size limit.");
51
+ const entries = this.options.merge ? await this.options.merge({
52
+ anonymousSession: oldSession,
53
+ targetUserId: input.userId,
54
+ records
55
+ }) : defaultMerge(records);
56
+ this.validateEntries(entries, records, now, maximumEntryBytes, maximumTotalBytes);
57
+ const token = require_session_crypto.generateSessionToken();
58
+ const tokenHash = await require_session_crypto.hashSessionToken(token);
59
+ const id = require_session_crypto.generateSessionId();
60
+ const absoluteExpiry = new Date(now.getTime() + this.options.absoluteLifetimeSeconds * 1e3);
61
+ const idleExpiry = new Date(now.getTime() + this.options.idleTimeoutSeconds * 1e3);
62
+ const expiresAt = new Date(Math.min(absoluteExpiry.getTime(), idleExpiry.getTime()));
63
+ const nextSession = {
64
+ id,
65
+ tokenHash,
66
+ userId: input.userId,
67
+ kind: "authenticated",
68
+ createdAt: now,
69
+ updatedAt: now,
70
+ revision: 1,
71
+ lastSeenAt: now,
72
+ expiresAt,
73
+ revokedAt: null,
74
+ metadata: input.metadata
75
+ };
76
+ const sourceMap = new Map(records.map((record) => [record.id, record]));
77
+ const usedIds = /* @__PURE__ */ new Set();
78
+ const operations = [{
79
+ type: "insert",
80
+ collection: require_session_storage.SESSION_COLLECTION,
81
+ record: nextSession
82
+ }, {
83
+ type: "update",
84
+ collection: require_session_storage.SESSION_COLLECTION,
85
+ id: oldSession.id,
86
+ condition: { expectedRevision: oldSession.revision },
87
+ changes: { revokedAt: now }
88
+ }];
89
+ for (const entry of entries) {
90
+ const sources = entry.sourceIds.map((sourceId) => {
91
+ const source = sourceMap.get(sourceId);
92
+ if (!source || usedIds.has(sourceId)) throw new require_session_errors.SessionSecurityError("The migration merge callback returned an invalid or duplicate source ID.");
93
+ usedIds.add(sourceId);
94
+ return source;
95
+ });
96
+ const primary = sources[0];
97
+ if (!primary) throw new require_session_errors.SessionSecurityError("A migration entry must have at least one source record.");
98
+ operations.push({
99
+ type: "update",
100
+ collection: require_session_storage.SESSION_DATA_COLLECTION,
101
+ id: primary.id,
102
+ condition: { expectedRevision: primary.revision },
103
+ changes: {
104
+ ownerId: id,
105
+ namespace: entry.namespace,
106
+ key: entry.key,
107
+ value: entry.value,
108
+ expiresAt: entry.expiresAt
109
+ }
110
+ });
111
+ for (const duplicate of sources.slice(1)) operations.push({
112
+ type: "delete",
113
+ collection: require_session_storage.SESSION_DATA_COLLECTION,
114
+ id: duplicate.id,
115
+ condition: { expectedRevision: duplicate.revision }
116
+ });
117
+ }
118
+ try {
119
+ await this.storage.atomicBatch(operations);
120
+ } catch (error) {
121
+ if (error instanceof _puzzle_ui_storage.StorageConflictError) throw new require_session_errors.SessionSecurityError("The anonymous session was already migrated or modified concurrently.");
122
+ throw error;
123
+ }
124
+ return {
125
+ session: {
126
+ id,
127
+ userId: input.userId,
128
+ kind: "authenticated",
129
+ createdAt: now,
130
+ lastSeenAt: now,
131
+ expiresAt,
132
+ revokedAt: null,
133
+ metadata: input.metadata
134
+ },
135
+ token,
136
+ migratedRecords: entries.length
137
+ };
138
+ }
139
+ validateEntries(entries, records, now, maximumEntryBytes, maximumTotalBytes) {
140
+ const allowedIds = new Set(records.map((record) => record.id));
141
+ const sourceIds = /* @__PURE__ */ new Set();
142
+ const targetKeys = /* @__PURE__ */ new Set();
143
+ let totalBytes = 0;
144
+ for (const entry of entries) {
145
+ if (!entry.sourceIds.length || !entry.namespace || !entry.key || !this.options.allowedNamespaces.includes(entry.namespace)) throw new require_session_errors.SessionSecurityError("The migration merge callback returned an invalid entry.");
146
+ const targetKey = `${entry.namespace}\0${entry.key}`;
147
+ if (targetKeys.has(targetKey)) throw new require_session_errors.SessionSecurityError("The migration merge callback returned duplicate target keys.");
148
+ targetKeys.add(targetKey);
149
+ for (const sourceId of entry.sourceIds) {
150
+ if (!allowedIds.has(sourceId) || sourceIds.has(sourceId)) throw new require_session_errors.SessionSecurityError("The migration merge callback referenced an invalid source record.");
151
+ sourceIds.add(sourceId);
152
+ }
153
+ if (entry.expiresAt && entry.expiresAt.getTime() <= now.getTime()) throw new require_session_errors.SessionSecurityError("The migration merge callback returned expired data.");
154
+ const size = toBytes(entry.value);
155
+ if (size > maximumEntryBytes) throw new require_session_errors.SessionSecurityError("A migrated session data entry exceeds the maximum allowed size.");
156
+ totalBytes += size;
157
+ }
158
+ if (totalBytes > maximumTotalBytes) throw new require_session_errors.SessionSecurityError("Merged session data exceeds the maximum allowed size.");
159
+ }
160
+ };
161
+ exports.SessionMigrationManager = SessionMigrationManager;
162
+
163
+ //# sourceMappingURL=session.migration.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session.migration.cjs","names":["SessionSecurityError","SESSION_COLLECTION","SESSION_DATA_COLLECTION","generateSessionToken","hashSessionToken","generateSessionId","StorageConflictError"],"sources":["../src/session.migration.ts"],"sourcesContent":["import {\n StorageConflictError,\n type StorageAdapter,\n type StorageBatchOperation,\n} from \"@puzzle-ui/storage\";\n\nimport { SessionSecurityError } from \"./errors/session.errors\";\n\nimport { SESSION_COLLECTION, SESSION_DATA_COLLECTION } from \"./session.storage\";\n\nimport type {\n SessionDataRecord,\n SessionStorageRecord,\n} from \"./types/session.types\";\n\nimport {\n generateSessionId,\n generateSessionToken,\n hashSessionToken,\n} from \"./utils/session-crypto\";\n\nexport type SessionMigrationEntry = {\n readonly sourceIds: readonly string[];\n readonly namespace: string;\n readonly key: string;\n readonly value: unknown;\n readonly expiresAt: Date | null;\n};\n\nexport type SessionMigrationContext = {\n readonly anonymousSession: SessionStorageRecord;\n readonly targetUserId: string;\n readonly records: readonly SessionDataRecord[];\n};\n\nexport type AnonymousMigrationOptions = {\n readonly allowedNamespaces: readonly string[];\n readonly maximumRecords?: number;\n readonly maximumTotalBytes?: number;\n readonly maximumEntryBytes?: number;\n readonly idleTimeoutSeconds: number;\n readonly absoluteLifetimeSeconds: number;\n readonly clock?: () => Date;\n\n readonly merge?: (\n context: SessionMigrationContext,\n ) =>\n | readonly SessionMigrationEntry[]\n | Promise<readonly SessionMigrationEntry[]>;\n};\n\nexport type AnonymousMigrationResult = {\n readonly session: {\n readonly id: string;\n readonly userId: string;\n readonly kind: \"authenticated\";\n readonly createdAt: Date;\n readonly lastSeenAt: Date;\n readonly expiresAt: Date;\n readonly revokedAt: null;\n readonly metadata?: Readonly<Record<string, unknown>>;\n };\n readonly token: string;\n readonly migratedRecords: number;\n};\n\nconst serialize = (value: unknown): string => {\n let result: string | undefined;\n\n try {\n result = JSON.stringify(value);\n } catch {\n throw new SessionSecurityError(\n \"Migrated session data must be JSON serializable.\",\n );\n }\n\n if (result === undefined) {\n throw new SessionSecurityError(\n \"Migrated session data must be JSON serializable.\",\n );\n }\n\n return result;\n};\n\nconst toBytes = (value: unknown): number =>\n new TextEncoder().encode(serialize(value)).byteLength;\n\nconst defaultMerge = (\n records: readonly SessionDataRecord[],\n): readonly SessionMigrationEntry[] =>\n records.map((record) => ({\n sourceIds: [record.id],\n namespace: record.namespace,\n key: record.key,\n value: record.value,\n expiresAt: record.expiresAt,\n }));\n\nexport class SessionMigrationManager {\n private readonly clock: () => Date;\n\n constructor(\n private readonly storage: StorageAdapter,\n private readonly options: AnonymousMigrationOptions,\n ) {\n this.clock = options.clock ?? (() => new Date());\n\n if (\n !options.allowedNamespaces.length ||\n !Number.isFinite(options.idleTimeoutSeconds) ||\n !Number.isFinite(options.absoluteLifetimeSeconds) ||\n options.idleTimeoutSeconds <= 0 ||\n options.absoluteLifetimeSeconds <= 0 ||\n options.idleTimeoutSeconds > options.absoluteLifetimeSeconds\n ) {\n throw new Error(\"Invalid anonymous session migration configuration.\");\n }\n\n if (\n options.maximumRecords !== undefined &&\n (!Number.isInteger(options.maximumRecords) || options.maximumRecords < 0)\n ) {\n throw new Error(\"maximumRecords must be a non-negative integer.\");\n }\n\n if (\n options.maximumTotalBytes !== undefined &&\n (!Number.isInteger(options.maximumTotalBytes) ||\n options.maximumTotalBytes < 0)\n ) {\n throw new Error(\"maximumTotalBytes must be a non-negative integer.\");\n }\n }\n\n async migrate(input: {\n readonly anonymousSessionId: string;\n readonly userId: string;\n readonly metadata?: Readonly<Record<string, unknown>>;\n }): Promise<AnonymousMigrationResult> {\n if (!input.anonymousSessionId || !input.userId) {\n throw new SessionSecurityError(\n \"An anonymous session ID and authenticated user ID are required.\",\n );\n }\n\n const now = this.clock();\n\n const oldSession = await this.storage.findById<SessionStorageRecord>(\n SESSION_COLLECTION,\n input.anonymousSessionId,\n );\n\n if (\n !oldSession ||\n oldSession.kind !== \"anonymous\" ||\n oldSession.userId !== null ||\n oldSession.revokedAt !== null\n ) {\n throw new SessionSecurityError(\n \"The anonymous session is not eligible for migration.\",\n );\n }\n\n if (\n oldSession.expiresAt.getTime() <= now.getTime() ||\n oldSession.lastSeenAt.getTime() +\n this.options.idleTimeoutSeconds * 1000 <=\n now.getTime() ||\n oldSession.createdAt.getTime() +\n this.options.absoluteLifetimeSeconds * 1000 <=\n now.getTime()\n ) {\n throw new SessionSecurityError(\"The anonymous session has expired.\");\n }\n\n const allRecords = await this.storage.findMany<SessionDataRecord>(\n SESSION_DATA_COLLECTION,\n {\n where: {\n ownerId: {\n operator: \"eq\",\n value: oldSession.id,\n },\n },\n },\n );\n\n const records = allRecords.filter(\n (record) =>\n this.options.allowedNamespaces.includes(record.namespace) &&\n (!record.expiresAt || record.expiresAt.getTime() > now.getTime()),\n );\n\n const maximumRecords = this.options.maximumRecords ?? 100;\n const maximumTotalBytes = this.options.maximumTotalBytes ?? 65_536;\n const maximumEntryBytes = this.options.maximumEntryBytes ?? 16_384;\n\n if (records.length > maximumRecords) {\n throw new SessionSecurityError(\n \"Anonymous session contains too many records to migrate.\",\n );\n }\n\n const sourceBytes = records.reduce(\n (total, record) => total + toBytes(record.value),\n 0,\n );\n\n if (sourceBytes > maximumTotalBytes) {\n throw new SessionSecurityError(\n \"Anonymous session data exceeds the migration size limit.\",\n );\n }\n\n const entries = this.options.merge\n ? await this.options.merge({\n anonymousSession: oldSession,\n targetUserId: input.userId,\n records,\n })\n : defaultMerge(records);\n\n this.validateEntries(\n entries,\n records,\n now,\n maximumEntryBytes,\n maximumTotalBytes,\n );\n\n const token = generateSessionToken();\n const tokenHash = await hashSessionToken(token);\n const id = generateSessionId();\n\n const absoluteExpiry = new Date(\n now.getTime() + this.options.absoluteLifetimeSeconds * 1000,\n );\n\n const idleExpiry = new Date(\n now.getTime() + this.options.idleTimeoutSeconds * 1000,\n );\n\n const expiresAt = new Date(\n Math.min(absoluteExpiry.getTime(), idleExpiry.getTime()),\n );\n\n const nextSession: SessionStorageRecord = {\n id,\n tokenHash,\n userId: input.userId,\n kind: \"authenticated\",\n createdAt: now,\n updatedAt: now,\n revision: 1,\n lastSeenAt: now,\n expiresAt,\n revokedAt: null,\n metadata: input.metadata,\n };\n\n const sourceMap = new Map(records.map((record) => [record.id, record]));\n\n const usedIds = new Set<string>();\n\n const operations: (\n | StorageBatchOperation<SessionStorageRecord>\n | StorageBatchOperation<SessionDataRecord>\n )[] = [\n {\n type: \"insert\",\n collection: SESSION_COLLECTION,\n record: nextSession,\n },\n {\n type: \"update\",\n collection: SESSION_COLLECTION,\n id: oldSession.id,\n condition: {\n expectedRevision: oldSession.revision,\n },\n changes: {\n revokedAt: now,\n },\n },\n ];\n\n for (const entry of entries) {\n const sources = entry.sourceIds.map((sourceId) => {\n const source = sourceMap.get(sourceId);\n\n if (!source || usedIds.has(sourceId)) {\n throw new SessionSecurityError(\n \"The migration merge callback returned an invalid or duplicate source ID.\",\n );\n }\n\n usedIds.add(sourceId);\n\n return source;\n });\n\n const primary = sources[0];\n\n if (!primary) {\n throw new SessionSecurityError(\n \"A migration entry must have at least one source record.\",\n );\n }\n\n operations.push({\n type: \"update\",\n collection: SESSION_DATA_COLLECTION,\n id: primary.id,\n condition: {\n expectedRevision: primary.revision,\n },\n changes: {\n ownerId: id,\n namespace: entry.namespace,\n key: entry.key,\n value: entry.value,\n expiresAt: entry.expiresAt,\n },\n });\n\n for (const duplicate of sources.slice(1)) {\n operations.push({\n type: \"delete\",\n collection: SESSION_DATA_COLLECTION,\n id: duplicate.id,\n condition: {\n expectedRevision: duplicate.revision,\n },\n });\n }\n }\n\n try {\n await this.storage.atomicBatch<SessionStorageRecord | SessionDataRecord>(\n operations,\n );\n } catch (error) {\n if (error instanceof StorageConflictError) {\n throw new SessionSecurityError(\n \"The anonymous session was already migrated or modified concurrently.\",\n );\n }\n\n throw error;\n }\n\n return {\n session: {\n id,\n userId: input.userId,\n kind: \"authenticated\",\n createdAt: now,\n lastSeenAt: now,\n expiresAt,\n revokedAt: null,\n metadata: input.metadata,\n },\n token,\n migratedRecords: entries.length,\n };\n }\n\n private validateEntries(\n entries: readonly SessionMigrationEntry[],\n records: readonly SessionDataRecord[],\n now: Date,\n maximumEntryBytes: number,\n maximumTotalBytes: number,\n ): void {\n const allowedIds = new Set(records.map((record) => record.id));\n const sourceIds = new Set<string>();\n const targetKeys = new Set<string>();\n\n let totalBytes = 0;\n\n for (const entry of entries) {\n if (\n !entry.sourceIds.length ||\n !entry.namespace ||\n !entry.key ||\n !this.options.allowedNamespaces.includes(entry.namespace)\n ) {\n throw new SessionSecurityError(\n \"The migration merge callback returned an invalid entry.\",\n );\n }\n\n const targetKey = `${entry.namespace}\\0${entry.key}`;\n\n if (targetKeys.has(targetKey)) {\n throw new SessionSecurityError(\n \"The migration merge callback returned duplicate target keys.\",\n );\n }\n\n targetKeys.add(targetKey);\n\n for (const sourceId of entry.sourceIds) {\n if (!allowedIds.has(sourceId) || sourceIds.has(sourceId)) {\n throw new SessionSecurityError(\n \"The migration merge callback referenced an invalid source record.\",\n );\n }\n\n sourceIds.add(sourceId);\n }\n\n if (entry.expiresAt && entry.expiresAt.getTime() <= now.getTime()) {\n throw new SessionSecurityError(\n \"The migration merge callback returned expired data.\",\n );\n }\n\n const size = toBytes(entry.value);\n\n if (size > maximumEntryBytes) {\n throw new SessionSecurityError(\n \"A migrated session data entry exceeds the maximum allowed size.\",\n );\n }\n\n totalBytes += size;\n }\n\n if (totalBytes > maximumTotalBytes) {\n throw new SessionSecurityError(\n \"Merged session data exceeds the maximum allowed size.\",\n );\n }\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,50 @@
1
+ import { SessionDataRecord, SessionStorageRecord } from "./types/session.types.cjs";
2
+ import { StorageAdapter } from "@puzzle-ui/storage";
3
+ export type SessionMigrationEntry = {
4
+ readonly sourceIds: readonly string[];
5
+ readonly namespace: string;
6
+ readonly key: string;
7
+ readonly value: unknown;
8
+ readonly expiresAt: Date | null;
9
+ };
10
+ export type SessionMigrationContext = {
11
+ readonly anonymousSession: SessionStorageRecord;
12
+ readonly targetUserId: string;
13
+ readonly records: readonly SessionDataRecord[];
14
+ };
15
+ export type AnonymousMigrationOptions = {
16
+ readonly allowedNamespaces: readonly string[];
17
+ readonly maximumRecords?: number;
18
+ readonly maximumTotalBytes?: number;
19
+ readonly maximumEntryBytes?: number;
20
+ readonly idleTimeoutSeconds: number;
21
+ readonly absoluteLifetimeSeconds: number;
22
+ readonly clock?: () => Date;
23
+ readonly merge?: (context: SessionMigrationContext) => readonly SessionMigrationEntry[] | Promise<readonly SessionMigrationEntry[]>;
24
+ };
25
+ export type AnonymousMigrationResult = {
26
+ readonly session: {
27
+ readonly id: string;
28
+ readonly userId: string;
29
+ readonly kind: "authenticated";
30
+ readonly createdAt: Date;
31
+ readonly lastSeenAt: Date;
32
+ readonly expiresAt: Date;
33
+ readonly revokedAt: null;
34
+ readonly metadata?: Readonly<Record<string, unknown>>;
35
+ };
36
+ readonly token: string;
37
+ readonly migratedRecords: number;
38
+ };
39
+ export declare class SessionMigrationManager {
40
+ private readonly storage;
41
+ private readonly options;
42
+ private readonly clock;
43
+ constructor(storage: StorageAdapter, options: AnonymousMigrationOptions);
44
+ migrate(input: {
45
+ readonly anonymousSessionId: string;
46
+ readonly userId: string;
47
+ readonly metadata?: Readonly<Record<string, unknown>>;
48
+ }): Promise<AnonymousMigrationResult>;
49
+ private validateEntries;
50
+ }
@@ -0,0 +1,50 @@
1
+ import { SessionDataRecord, SessionStorageRecord } from "./types/session.types.js";
2
+ import { StorageAdapter } from "@puzzle-ui/storage";
3
+ export type SessionMigrationEntry = {
4
+ readonly sourceIds: readonly string[];
5
+ readonly namespace: string;
6
+ readonly key: string;
7
+ readonly value: unknown;
8
+ readonly expiresAt: Date | null;
9
+ };
10
+ export type SessionMigrationContext = {
11
+ readonly anonymousSession: SessionStorageRecord;
12
+ readonly targetUserId: string;
13
+ readonly records: readonly SessionDataRecord[];
14
+ };
15
+ export type AnonymousMigrationOptions = {
16
+ readonly allowedNamespaces: readonly string[];
17
+ readonly maximumRecords?: number;
18
+ readonly maximumTotalBytes?: number;
19
+ readonly maximumEntryBytes?: number;
20
+ readonly idleTimeoutSeconds: number;
21
+ readonly absoluteLifetimeSeconds: number;
22
+ readonly clock?: () => Date;
23
+ readonly merge?: (context: SessionMigrationContext) => readonly SessionMigrationEntry[] | Promise<readonly SessionMigrationEntry[]>;
24
+ };
25
+ export type AnonymousMigrationResult = {
26
+ readonly session: {
27
+ readonly id: string;
28
+ readonly userId: string;
29
+ readonly kind: "authenticated";
30
+ readonly createdAt: Date;
31
+ readonly lastSeenAt: Date;
32
+ readonly expiresAt: Date;
33
+ readonly revokedAt: null;
34
+ readonly metadata?: Readonly<Record<string, unknown>>;
35
+ };
36
+ readonly token: string;
37
+ readonly migratedRecords: number;
38
+ };
39
+ export declare class SessionMigrationManager {
40
+ private readonly storage;
41
+ private readonly options;
42
+ private readonly clock;
43
+ constructor(storage: StorageAdapter, options: AnonymousMigrationOptions);
44
+ migrate(input: {
45
+ readonly anonymousSessionId: string;
46
+ readonly userId: string;
47
+ readonly metadata?: Readonly<Record<string, unknown>>;
48
+ }): Promise<AnonymousMigrationResult>;
49
+ private validateEntries;
50
+ }
@@ -0,0 +1,163 @@
1
+ import { SessionSecurityError } from "./errors/session.errors.js";
2
+ import { SESSION_COLLECTION, SESSION_DATA_COLLECTION } from "./session.storage.js";
3
+ import { generateSessionId, generateSessionToken, hashSessionToken } from "./utils/session-crypto.js";
4
+ import { StorageConflictError } from "@puzzle-ui/storage";
5
+ // src/session.migration.ts
6
+ const serialize = (value) => {
7
+ let result;
8
+ try {
9
+ result = JSON.stringify(value);
10
+ } catch {
11
+ throw new SessionSecurityError("Migrated session data must be JSON serializable.");
12
+ }
13
+ if (result === void 0) throw new SessionSecurityError("Migrated session data must be JSON serializable.");
14
+ return result;
15
+ };
16
+ const toBytes = (value) => new TextEncoder().encode(serialize(value)).byteLength;
17
+ const defaultMerge = (records) => records.map((record) => ({
18
+ sourceIds: [record.id],
19
+ namespace: record.namespace,
20
+ key: record.key,
21
+ value: record.value,
22
+ expiresAt: record.expiresAt
23
+ }));
24
+ var SessionMigrationManager = class {
25
+ storage;
26
+ options;
27
+ clock;
28
+ constructor(storage, options) {
29
+ this.storage = storage;
30
+ this.options = options;
31
+ this.clock = options.clock ?? (() => /* @__PURE__ */ new Date());
32
+ if (!options.allowedNamespaces.length || !Number.isFinite(options.idleTimeoutSeconds) || !Number.isFinite(options.absoluteLifetimeSeconds) || options.idleTimeoutSeconds <= 0 || options.absoluteLifetimeSeconds <= 0 || options.idleTimeoutSeconds > options.absoluteLifetimeSeconds) throw new Error("Invalid anonymous session migration configuration.");
33
+ if (options.maximumRecords !== void 0 && (!Number.isInteger(options.maximumRecords) || options.maximumRecords < 0)) throw new Error("maximumRecords must be a non-negative integer.");
34
+ if (options.maximumTotalBytes !== void 0 && (!Number.isInteger(options.maximumTotalBytes) || options.maximumTotalBytes < 0)) throw new Error("maximumTotalBytes must be a non-negative integer.");
35
+ }
36
+ async migrate(input) {
37
+ if (!input.anonymousSessionId || !input.userId) throw new SessionSecurityError("An anonymous session ID and authenticated user ID are required.");
38
+ const now = this.clock();
39
+ const oldSession = await this.storage.findById(SESSION_COLLECTION, input.anonymousSessionId);
40
+ if (!oldSession || oldSession.kind !== "anonymous" || oldSession.userId !== null || oldSession.revokedAt !== null) throw new SessionSecurityError("The anonymous session is not eligible for migration.");
41
+ if (oldSession.expiresAt.getTime() <= now.getTime() || oldSession.lastSeenAt.getTime() + this.options.idleTimeoutSeconds * 1e3 <= now.getTime() || oldSession.createdAt.getTime() + this.options.absoluteLifetimeSeconds * 1e3 <= now.getTime()) throw new SessionSecurityError("The anonymous session has expired.");
42
+ const records = (await this.storage.findMany(SESSION_DATA_COLLECTION, { where: { ownerId: {
43
+ operator: "eq",
44
+ value: oldSession.id
45
+ } } })).filter((record) => this.options.allowedNamespaces.includes(record.namespace) && (!record.expiresAt || record.expiresAt.getTime() > now.getTime()));
46
+ const maximumRecords = this.options.maximumRecords ?? 100;
47
+ const maximumTotalBytes = this.options.maximumTotalBytes ?? 65536;
48
+ const maximumEntryBytes = this.options.maximumEntryBytes ?? 16384;
49
+ if (records.length > maximumRecords) throw new SessionSecurityError("Anonymous session contains too many records to migrate.");
50
+ if (records.reduce((total, record) => total + toBytes(record.value), 0) > maximumTotalBytes) throw new SessionSecurityError("Anonymous session data exceeds the migration size limit.");
51
+ const entries = this.options.merge ? await this.options.merge({
52
+ anonymousSession: oldSession,
53
+ targetUserId: input.userId,
54
+ records
55
+ }) : defaultMerge(records);
56
+ this.validateEntries(entries, records, now, maximumEntryBytes, maximumTotalBytes);
57
+ const token = generateSessionToken();
58
+ const tokenHash = await hashSessionToken(token);
59
+ const id = generateSessionId();
60
+ const absoluteExpiry = new Date(now.getTime() + this.options.absoluteLifetimeSeconds * 1e3);
61
+ const idleExpiry = new Date(now.getTime() + this.options.idleTimeoutSeconds * 1e3);
62
+ const expiresAt = new Date(Math.min(absoluteExpiry.getTime(), idleExpiry.getTime()));
63
+ const nextSession = {
64
+ id,
65
+ tokenHash,
66
+ userId: input.userId,
67
+ kind: "authenticated",
68
+ createdAt: now,
69
+ updatedAt: now,
70
+ revision: 1,
71
+ lastSeenAt: now,
72
+ expiresAt,
73
+ revokedAt: null,
74
+ metadata: input.metadata
75
+ };
76
+ const sourceMap = new Map(records.map((record) => [record.id, record]));
77
+ const usedIds = /* @__PURE__ */ new Set();
78
+ const operations = [{
79
+ type: "insert",
80
+ collection: SESSION_COLLECTION,
81
+ record: nextSession
82
+ }, {
83
+ type: "update",
84
+ collection: SESSION_COLLECTION,
85
+ id: oldSession.id,
86
+ condition: { expectedRevision: oldSession.revision },
87
+ changes: { revokedAt: now }
88
+ }];
89
+ for (const entry of entries) {
90
+ const sources = entry.sourceIds.map((sourceId) => {
91
+ const source = sourceMap.get(sourceId);
92
+ if (!source || usedIds.has(sourceId)) throw new SessionSecurityError("The migration merge callback returned an invalid or duplicate source ID.");
93
+ usedIds.add(sourceId);
94
+ return source;
95
+ });
96
+ const primary = sources[0];
97
+ if (!primary) throw new SessionSecurityError("A migration entry must have at least one source record.");
98
+ operations.push({
99
+ type: "update",
100
+ collection: SESSION_DATA_COLLECTION,
101
+ id: primary.id,
102
+ condition: { expectedRevision: primary.revision },
103
+ changes: {
104
+ ownerId: id,
105
+ namespace: entry.namespace,
106
+ key: entry.key,
107
+ value: entry.value,
108
+ expiresAt: entry.expiresAt
109
+ }
110
+ });
111
+ for (const duplicate of sources.slice(1)) operations.push({
112
+ type: "delete",
113
+ collection: SESSION_DATA_COLLECTION,
114
+ id: duplicate.id,
115
+ condition: { expectedRevision: duplicate.revision }
116
+ });
117
+ }
118
+ try {
119
+ await this.storage.atomicBatch(operations);
120
+ } catch (error) {
121
+ if (error instanceof StorageConflictError) throw new SessionSecurityError("The anonymous session was already migrated or modified concurrently.");
122
+ throw error;
123
+ }
124
+ return {
125
+ session: {
126
+ id,
127
+ userId: input.userId,
128
+ kind: "authenticated",
129
+ createdAt: now,
130
+ lastSeenAt: now,
131
+ expiresAt,
132
+ revokedAt: null,
133
+ metadata: input.metadata
134
+ },
135
+ token,
136
+ migratedRecords: entries.length
137
+ };
138
+ }
139
+ validateEntries(entries, records, now, maximumEntryBytes, maximumTotalBytes) {
140
+ const allowedIds = new Set(records.map((record) => record.id));
141
+ const sourceIds = /* @__PURE__ */ new Set();
142
+ const targetKeys = /* @__PURE__ */ new Set();
143
+ let totalBytes = 0;
144
+ for (const entry of entries) {
145
+ if (!entry.sourceIds.length || !entry.namespace || !entry.key || !this.options.allowedNamespaces.includes(entry.namespace)) throw new SessionSecurityError("The migration merge callback returned an invalid entry.");
146
+ const targetKey = `${entry.namespace}\0${entry.key}`;
147
+ if (targetKeys.has(targetKey)) throw new SessionSecurityError("The migration merge callback returned duplicate target keys.");
148
+ targetKeys.add(targetKey);
149
+ for (const sourceId of entry.sourceIds) {
150
+ if (!allowedIds.has(sourceId) || sourceIds.has(sourceId)) throw new SessionSecurityError("The migration merge callback referenced an invalid source record.");
151
+ sourceIds.add(sourceId);
152
+ }
153
+ if (entry.expiresAt && entry.expiresAt.getTime() <= now.getTime()) throw new SessionSecurityError("The migration merge callback returned expired data.");
154
+ const size = toBytes(entry.value);
155
+ if (size > maximumEntryBytes) throw new SessionSecurityError("A migrated session data entry exceeds the maximum allowed size.");
156
+ totalBytes += size;
157
+ }
158
+ if (totalBytes > maximumTotalBytes) throw new SessionSecurityError("Merged session data exceeds the maximum allowed size.");
159
+ }
160
+ };
161
+ export { SessionMigrationManager };
162
+
163
+ //# sourceMappingURL=session.migration.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session.migration.js","names":[],"sources":["../src/session.migration.ts"],"sourcesContent":["import {\n StorageConflictError,\n type StorageAdapter,\n type StorageBatchOperation,\n} from \"@puzzle-ui/storage\";\n\nimport { SessionSecurityError } from \"./errors/session.errors\";\n\nimport { SESSION_COLLECTION, SESSION_DATA_COLLECTION } from \"./session.storage\";\n\nimport type {\n SessionDataRecord,\n SessionStorageRecord,\n} from \"./types/session.types\";\n\nimport {\n generateSessionId,\n generateSessionToken,\n hashSessionToken,\n} from \"./utils/session-crypto\";\n\nexport type SessionMigrationEntry = {\n readonly sourceIds: readonly string[];\n readonly namespace: string;\n readonly key: string;\n readonly value: unknown;\n readonly expiresAt: Date | null;\n};\n\nexport type SessionMigrationContext = {\n readonly anonymousSession: SessionStorageRecord;\n readonly targetUserId: string;\n readonly records: readonly SessionDataRecord[];\n};\n\nexport type AnonymousMigrationOptions = {\n readonly allowedNamespaces: readonly string[];\n readonly maximumRecords?: number;\n readonly maximumTotalBytes?: number;\n readonly maximumEntryBytes?: number;\n readonly idleTimeoutSeconds: number;\n readonly absoluteLifetimeSeconds: number;\n readonly clock?: () => Date;\n\n readonly merge?: (\n context: SessionMigrationContext,\n ) =>\n | readonly SessionMigrationEntry[]\n | Promise<readonly SessionMigrationEntry[]>;\n};\n\nexport type AnonymousMigrationResult = {\n readonly session: {\n readonly id: string;\n readonly userId: string;\n readonly kind: \"authenticated\";\n readonly createdAt: Date;\n readonly lastSeenAt: Date;\n readonly expiresAt: Date;\n readonly revokedAt: null;\n readonly metadata?: Readonly<Record<string, unknown>>;\n };\n readonly token: string;\n readonly migratedRecords: number;\n};\n\nconst serialize = (value: unknown): string => {\n let result: string | undefined;\n\n try {\n result = JSON.stringify(value);\n } catch {\n throw new SessionSecurityError(\n \"Migrated session data must be JSON serializable.\",\n );\n }\n\n if (result === undefined) {\n throw new SessionSecurityError(\n \"Migrated session data must be JSON serializable.\",\n );\n }\n\n return result;\n};\n\nconst toBytes = (value: unknown): number =>\n new TextEncoder().encode(serialize(value)).byteLength;\n\nconst defaultMerge = (\n records: readonly SessionDataRecord[],\n): readonly SessionMigrationEntry[] =>\n records.map((record) => ({\n sourceIds: [record.id],\n namespace: record.namespace,\n key: record.key,\n value: record.value,\n expiresAt: record.expiresAt,\n }));\n\nexport class SessionMigrationManager {\n private readonly clock: () => Date;\n\n constructor(\n private readonly storage: StorageAdapter,\n private readonly options: AnonymousMigrationOptions,\n ) {\n this.clock = options.clock ?? (() => new Date());\n\n if (\n !options.allowedNamespaces.length ||\n !Number.isFinite(options.idleTimeoutSeconds) ||\n !Number.isFinite(options.absoluteLifetimeSeconds) ||\n options.idleTimeoutSeconds <= 0 ||\n options.absoluteLifetimeSeconds <= 0 ||\n options.idleTimeoutSeconds > options.absoluteLifetimeSeconds\n ) {\n throw new Error(\"Invalid anonymous session migration configuration.\");\n }\n\n if (\n options.maximumRecords !== undefined &&\n (!Number.isInteger(options.maximumRecords) || options.maximumRecords < 0)\n ) {\n throw new Error(\"maximumRecords must be a non-negative integer.\");\n }\n\n if (\n options.maximumTotalBytes !== undefined &&\n (!Number.isInteger(options.maximumTotalBytes) ||\n options.maximumTotalBytes < 0)\n ) {\n throw new Error(\"maximumTotalBytes must be a non-negative integer.\");\n }\n }\n\n async migrate(input: {\n readonly anonymousSessionId: string;\n readonly userId: string;\n readonly metadata?: Readonly<Record<string, unknown>>;\n }): Promise<AnonymousMigrationResult> {\n if (!input.anonymousSessionId || !input.userId) {\n throw new SessionSecurityError(\n \"An anonymous session ID and authenticated user ID are required.\",\n );\n }\n\n const now = this.clock();\n\n const oldSession = await this.storage.findById<SessionStorageRecord>(\n SESSION_COLLECTION,\n input.anonymousSessionId,\n );\n\n if (\n !oldSession ||\n oldSession.kind !== \"anonymous\" ||\n oldSession.userId !== null ||\n oldSession.revokedAt !== null\n ) {\n throw new SessionSecurityError(\n \"The anonymous session is not eligible for migration.\",\n );\n }\n\n if (\n oldSession.expiresAt.getTime() <= now.getTime() ||\n oldSession.lastSeenAt.getTime() +\n this.options.idleTimeoutSeconds * 1000 <=\n now.getTime() ||\n oldSession.createdAt.getTime() +\n this.options.absoluteLifetimeSeconds * 1000 <=\n now.getTime()\n ) {\n throw new SessionSecurityError(\"The anonymous session has expired.\");\n }\n\n const allRecords = await this.storage.findMany<SessionDataRecord>(\n SESSION_DATA_COLLECTION,\n {\n where: {\n ownerId: {\n operator: \"eq\",\n value: oldSession.id,\n },\n },\n },\n );\n\n const records = allRecords.filter(\n (record) =>\n this.options.allowedNamespaces.includes(record.namespace) &&\n (!record.expiresAt || record.expiresAt.getTime() > now.getTime()),\n );\n\n const maximumRecords = this.options.maximumRecords ?? 100;\n const maximumTotalBytes = this.options.maximumTotalBytes ?? 65_536;\n const maximumEntryBytes = this.options.maximumEntryBytes ?? 16_384;\n\n if (records.length > maximumRecords) {\n throw new SessionSecurityError(\n \"Anonymous session contains too many records to migrate.\",\n );\n }\n\n const sourceBytes = records.reduce(\n (total, record) => total + toBytes(record.value),\n 0,\n );\n\n if (sourceBytes > maximumTotalBytes) {\n throw new SessionSecurityError(\n \"Anonymous session data exceeds the migration size limit.\",\n );\n }\n\n const entries = this.options.merge\n ? await this.options.merge({\n anonymousSession: oldSession,\n targetUserId: input.userId,\n records,\n })\n : defaultMerge(records);\n\n this.validateEntries(\n entries,\n records,\n now,\n maximumEntryBytes,\n maximumTotalBytes,\n );\n\n const token = generateSessionToken();\n const tokenHash = await hashSessionToken(token);\n const id = generateSessionId();\n\n const absoluteExpiry = new Date(\n now.getTime() + this.options.absoluteLifetimeSeconds * 1000,\n );\n\n const idleExpiry = new Date(\n now.getTime() + this.options.idleTimeoutSeconds * 1000,\n );\n\n const expiresAt = new Date(\n Math.min(absoluteExpiry.getTime(), idleExpiry.getTime()),\n );\n\n const nextSession: SessionStorageRecord = {\n id,\n tokenHash,\n userId: input.userId,\n kind: \"authenticated\",\n createdAt: now,\n updatedAt: now,\n revision: 1,\n lastSeenAt: now,\n expiresAt,\n revokedAt: null,\n metadata: input.metadata,\n };\n\n const sourceMap = new Map(records.map((record) => [record.id, record]));\n\n const usedIds = new Set<string>();\n\n const operations: (\n | StorageBatchOperation<SessionStorageRecord>\n | StorageBatchOperation<SessionDataRecord>\n )[] = [\n {\n type: \"insert\",\n collection: SESSION_COLLECTION,\n record: nextSession,\n },\n {\n type: \"update\",\n collection: SESSION_COLLECTION,\n id: oldSession.id,\n condition: {\n expectedRevision: oldSession.revision,\n },\n changes: {\n revokedAt: now,\n },\n },\n ];\n\n for (const entry of entries) {\n const sources = entry.sourceIds.map((sourceId) => {\n const source = sourceMap.get(sourceId);\n\n if (!source || usedIds.has(sourceId)) {\n throw new SessionSecurityError(\n \"The migration merge callback returned an invalid or duplicate source ID.\",\n );\n }\n\n usedIds.add(sourceId);\n\n return source;\n });\n\n const primary = sources[0];\n\n if (!primary) {\n throw new SessionSecurityError(\n \"A migration entry must have at least one source record.\",\n );\n }\n\n operations.push({\n type: \"update\",\n collection: SESSION_DATA_COLLECTION,\n id: primary.id,\n condition: {\n expectedRevision: primary.revision,\n },\n changes: {\n ownerId: id,\n namespace: entry.namespace,\n key: entry.key,\n value: entry.value,\n expiresAt: entry.expiresAt,\n },\n });\n\n for (const duplicate of sources.slice(1)) {\n operations.push({\n type: \"delete\",\n collection: SESSION_DATA_COLLECTION,\n id: duplicate.id,\n condition: {\n expectedRevision: duplicate.revision,\n },\n });\n }\n }\n\n try {\n await this.storage.atomicBatch<SessionStorageRecord | SessionDataRecord>(\n operations,\n );\n } catch (error) {\n if (error instanceof StorageConflictError) {\n throw new SessionSecurityError(\n \"The anonymous session was already migrated or modified concurrently.\",\n );\n }\n\n throw error;\n }\n\n return {\n session: {\n id,\n userId: input.userId,\n kind: \"authenticated\",\n createdAt: now,\n lastSeenAt: now,\n expiresAt,\n revokedAt: null,\n metadata: input.metadata,\n },\n token,\n migratedRecords: entries.length,\n };\n }\n\n private validateEntries(\n entries: readonly SessionMigrationEntry[],\n records: readonly SessionDataRecord[],\n now: Date,\n maximumEntryBytes: number,\n maximumTotalBytes: number,\n ): void {\n const allowedIds = new Set(records.map((record) => record.id));\n const sourceIds = new Set<string>();\n const targetKeys = new Set<string>();\n\n let totalBytes = 0;\n\n for (const entry of entries) {\n if (\n !entry.sourceIds.length ||\n !entry.namespace ||\n !entry.key ||\n !this.options.allowedNamespaces.includes(entry.namespace)\n ) {\n throw new SessionSecurityError(\n \"The migration merge callback returned an invalid entry.\",\n );\n }\n\n const targetKey = `${entry.namespace}\\0${entry.key}`;\n\n if (targetKeys.has(targetKey)) {\n throw new SessionSecurityError(\n \"The migration merge callback returned duplicate target keys.\",\n );\n }\n\n targetKeys.add(targetKey);\n\n for (const sourceId of entry.sourceIds) {\n if (!allowedIds.has(sourceId) || sourceIds.has(sourceId)) {\n throw new SessionSecurityError(\n \"The migration merge callback referenced an invalid source record.\",\n );\n }\n\n sourceIds.add(sourceId);\n }\n\n if (entry.expiresAt && entry.expiresAt.getTime() <= now.getTime()) {\n throw new SessionSecurityError(\n \"The migration merge callback returned expired data.\",\n );\n }\n\n const size = toBytes(entry.value);\n\n if (size > maximumEntryBytes) {\n throw new SessionSecurityError(\n \"A migrated session data entry exceeds the maximum allowed size.\",\n );\n }\n\n totalBytes += size;\n }\n\n if (totalBytes > maximumTotalBytes) {\n throw new SessionSecurityError(\n \"Merged session data exceeds the maximum allowed size.\",\n );\n }\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,64 @@
1
+ // src/session.storage.ts
2
+ const SESSION_COLLECTION = "sessions";
3
+ const SESSION_DATA_COLLECTION = "session_data";
4
+ const SESSION_USER_LOCK_COLLECTION = "session_user_locks";
5
+ const sessionCollection = {
6
+ name: SESSION_COLLECTION,
7
+ indexes: [
8
+ {
9
+ name: "token_hash_unique",
10
+ fields: ["tokenHash"],
11
+ unique: true
12
+ },
13
+ {
14
+ name: "user_id",
15
+ fields: ["userId"]
16
+ },
17
+ {
18
+ name: "expires_at",
19
+ fields: ["expiresAt"]
20
+ },
21
+ {
22
+ name: "revoked_at",
23
+ fields: ["revokedAt"]
24
+ }
25
+ ]
26
+ };
27
+ const sessionDataCollection = {
28
+ name: SESSION_DATA_COLLECTION,
29
+ indexes: [{
30
+ name: "owner_namespace_key",
31
+ fields: [
32
+ "ownerId",
33
+ "namespace",
34
+ "key"
35
+ ],
36
+ unique: true
37
+ }, {
38
+ name: "expires_at",
39
+ fields: ["expiresAt"]
40
+ }]
41
+ };
42
+ const sessionUserLockCollection = {
43
+ name: SESSION_USER_LOCK_COLLECTION,
44
+ indexes: [{
45
+ name: "user_id_unique",
46
+ fields: ["userId"],
47
+ unique: true
48
+ }]
49
+ };
50
+ const initializeSessionStorage = async (storage) => {
51
+ if (!storage.capabilities.uniqueConstraints || !storage.capabilities.conditionalWrites || !storage.capabilities.atomicBatch) throw new Error("Session storage requires unique constraints, conditional writes and atomic batches.");
52
+ await storage.registerCollection(sessionCollection);
53
+ await storage.registerCollection(sessionDataCollection);
54
+ await storage.registerCollection(sessionUserLockCollection);
55
+ };
56
+ exports.SESSION_COLLECTION = SESSION_COLLECTION;
57
+ exports.SESSION_DATA_COLLECTION = SESSION_DATA_COLLECTION;
58
+ exports.SESSION_USER_LOCK_COLLECTION = SESSION_USER_LOCK_COLLECTION;
59
+ exports.initializeSessionStorage = initializeSessionStorage;
60
+ exports.sessionCollection = sessionCollection;
61
+ exports.sessionDataCollection = sessionDataCollection;
62
+ exports.sessionUserLockCollection = sessionUserLockCollection;
63
+
64
+ //# sourceMappingURL=session.storage.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session.storage.cjs","names":[],"sources":["../src/session.storage.ts"],"sourcesContent":["import type { StorageAdapter, StorageCollection } from \"@puzzle-ui/storage\";\n\nimport type {\n SessionDataRecord,\n SessionStorageRecord,\n} from \"./types/session.types\";\n\nimport type { SessionUserLockRecord } from \"./types/session-lock.types\";\n\nexport const SESSION_COLLECTION = \"sessions\";\nexport const SESSION_DATA_COLLECTION = \"session_data\";\nexport const SESSION_USER_LOCK_COLLECTION = \"session_user_locks\";\n\nexport const sessionCollection: StorageCollection<SessionStorageRecord> = {\n name: SESSION_COLLECTION,\n indexes: [\n {\n name: \"token_hash_unique\",\n fields: [\"tokenHash\"],\n unique: true,\n },\n {\n name: \"user_id\",\n fields: [\"userId\"],\n },\n {\n name: \"expires_at\",\n fields: [\"expiresAt\"],\n },\n {\n name: \"revoked_at\",\n fields: [\"revokedAt\"],\n },\n ],\n};\n\nexport const sessionDataCollection: StorageCollection<SessionDataRecord> = {\n name: SESSION_DATA_COLLECTION,\n indexes: [\n {\n name: \"owner_namespace_key\",\n fields: [\"ownerId\", \"namespace\", \"key\"],\n unique: true,\n },\n {\n name: \"expires_at\",\n fields: [\"expiresAt\"],\n },\n ],\n};\n\nexport const sessionUserLockCollection: StorageCollection<SessionUserLockRecord> =\n {\n name: SESSION_USER_LOCK_COLLECTION,\n indexes: [\n {\n name: \"user_id_unique\",\n fields: [\"userId\"],\n unique: true,\n },\n ],\n };\n\nexport const initializeSessionStorage = async (\n storage: StorageAdapter,\n): Promise<void> => {\n if (\n !storage.capabilities.uniqueConstraints ||\n !storage.capabilities.conditionalWrites ||\n !storage.capabilities.atomicBatch\n ) {\n throw new Error(\n \"Session storage requires unique constraints, conditional writes and atomic batches.\",\n );\n }\n\n await storage.registerCollection(sessionCollection);\n await storage.registerCollection(sessionDataCollection);\n await storage.registerCollection(sessionUserLockCollection);\n};\n"],"mappings":";AASA,MAAa,qBAAqB;AAClC,MAAa,0BAA0B;AACvC,MAAa,+BAA+B;AAE5C,MAAa,oBAA6D;CACxE,MAAM;CACN,SAAS;EACP;GACE,MAAM;GACN,QAAQ,CAAC,WAAW;GACpB,QAAQ;EACV;EACA;GACE,MAAM;GACN,QAAQ,CAAC,QAAQ;EACnB;EACA;GACE,MAAM;GACN,QAAQ,CAAC,WAAW;EACtB;EACA;GACE,MAAM;GACN,QAAQ,CAAC,WAAW;EACtB;CACF;AACF;AAEA,MAAa,wBAA8D;CACzE,MAAM;CACN,SAAS,CACP;EACE,MAAM;EACN,QAAQ;GAAC;GAAW;GAAa;EAAK;EACtC,QAAQ;CACV,GACA;EACE,MAAM;EACN,QAAQ,CAAC,WAAW;CACtB,CACF;AACF;AAEA,MAAa,4BACX;CACE,MAAM;CACN,SAAS,CACP;EACE,MAAM;EACN,QAAQ,CAAC,QAAQ;EACjB,QAAQ;CACV,CACF;AACF;AAEF,MAAa,2BAA2B,OACtC,YACkB;CAClB,IACE,CAAC,QAAQ,aAAa,qBACtB,CAAC,QAAQ,aAAa,qBACtB,CAAC,QAAQ,aAAa,aAEtB,MAAM,IAAI,MACR,qFACF;CAGF,MAAM,QAAQ,mBAAmB,iBAAiB;CAClD,MAAM,QAAQ,mBAAmB,qBAAqB;CACtD,MAAM,QAAQ,mBAAmB,yBAAyB;AAC5D"}
@@ -0,0 +1,8 @@
1
+ import { SessionDataRecord, SessionStorageRecord } from "./types/session.types.cjs";
2
+ import "./types/session-lock.types.cjs";
3
+ import { StorageAdapter, StorageCollection } from "@puzzle-ui/storage";
4
+ export declare const SESSION_COLLECTION = "sessions";
5
+ export declare const SESSION_DATA_COLLECTION = "session_data";
6
+ export declare const sessionCollection: StorageCollection<SessionStorageRecord>;
7
+ export declare const sessionDataCollection: StorageCollection<SessionDataRecord>;
8
+ export declare const initializeSessionStorage: (storage: StorageAdapter) => Promise<void>;