diff --git a/.specs/README.md b/.specs/README.md index a2906a9ce5..9baf0d0c85 100644 --- a/.specs/README.md +++ b/.specs/README.md @@ -15,3 +15,4 @@ This directory contains specifications for all major features and enhancements i - [devtools-v3-port.md](devtools-v3-port.md) - Port Effect v3 DevTools modules into `effect/unstable/devtools`. - [cli-completions-refactor.md](cli-completions-refactor.md) - Replace dynamic CLI completions with static shell script generation for Bash, Zsh, and Fish. - [ai-openai-compat.md](ai-openai-compat.md) - Add a minimal-schema OpenAI compat package for LanguageModel + embeddings. +- [http-session-storage-service.md](http-session-storage-service.md) - Add `HttpSessionStorage` backed by `Persistence` with cookie/session ID lifecycle, yieldable `HttpSessionStorage.key({ id, schema })` storage items, and `HttpApiSecurity.apiKey` integration. diff --git a/.specs/http-session-storage-service.md b/.specs/http-session-storage-service.md new file mode 100644 index 0000000000..4cac04159f --- /dev/null +++ b/.specs/http-session-storage-service.md @@ -0,0 +1,428 @@ +# HTTP Session Service + +## Overview + +Add a new unstable HTTP session service module that manages cookie-based sessions on top of `Persistence`. The module provides session ID lifecycle management, request/response cookie handling helpers, and typed item storage through a new `HttpSession.key(...)` data type. + +This design integrates with `HttpApiSecurity` by using `HttpApiSecurity.apiKey({ in: "cookie" })` (no new security kind), and adds dedicated helpers for session cookie set/clear behavior. + +## Request Context + Decisions + +- Backing store: `Persistence` service. +- Session cookie default name: `sid`. +- Session ID type: branded `Redacted` (`SessionId`), generated via `crypto.randomUUID()` by default. +- `HttpApiSecurity` integration: use `HttpApiSecurity.apiKey` with cookie location. +- Storage item API: new yieldable key type (`HttpSession.key`) instead of exposing raw `Persistable` items directly. +- Expiration strategy: align with Better Auth session model: + - `expiresIn` absolute window (default 7 days). + - `updateAge` refresh threshold (default 1 day). + - Session refresh can be disabled. + +## Goals + +- Provide an HTTP session service that is idiomatic for Effect unstable modules. +- Handle session cookie read/set/clear and session ID generation/rotation. +- Use `Persistence.make` and a distinct `storeId` per session. +- Provide typed session key APIs (`HttpSession.key`) for get/set/remove. +- Provide `HttpApiSecurity` helpers for session-cookie security setup. +- Keep defaults secure and browser-compatible. + +## Non-Goals + +- Implement full auth/user account flows. +- Add a new `HttpApiSecurity` security type. +- Add cross-device session revocation APIs. +- Add background cleanup daemons for orphaned per-session stores. + +## Constraints / Existing Architecture + +- `HttpApiBuilder.securitySetCookie` already supports `ApiKey` cookies. +- `Persistence` has key-level TTL but no key enumeration; extending TTL for all existing keys is not directly available. +- `Persistable` remains the underlying typed persistence mechanism, but HttpSession should expose an HTTP-session specific key abstraction. + +## Module + +### File + +- `packages/effect/src/unstable/http/HttpSession.ts` + +### Public service + +The service is split into two layers: + +1. A low-level `HttpSession` service with typed key storage operations (`get`, `set`, `remove`, `clear`) and the resolved `id`. +2. Session lifecycle methods that operate on top of `HttpSession` and manage cookie state. + +Low-level service (implemented): + +```ts +export class HttpSession extends ServiceMap.Service( + key: Key + ) => Effect.Effect, HttpSessionError, S["DecodingServices"]> + readonly set: ( + key: Key, + value: S["Type"] + ) => Effect.Effect + readonly remove: (key: Key) => Effect.Effect + readonly clear: Effect.Effect +}>()("effect/http/HttpSession") {} +``` + +Session lifecycle methods (not yet implemented): + +```ts +readonly rotate: Effect.Effect +``` + +Maybe make `id` a MutableRef so it can be updated on rotation without requiring +callers to re-resolve the service. + +### Session key data type + +```ts +type KeyTypeId = "~effect/http/HttpSession/Key" + +export interface Key + extends + Persistable.Persistable, + Effect.Yieldable, Option.Option, HttpSessionError, HttpSession | S["DecodingServices"]>, + PrimaryKey.PrimaryKey +{ + readonly [KeyTypeId]: KeyTypeId + readonly id: string + + readonly getOrFail: Effect.Effect + readonly set: (value: S["Type"]) => Effect.Effect + readonly remove: Effect.Effect +} + +export const key: (options: { + readonly id: string + readonly schema: S +}) => Key +``` + +Design notes: + +- Base key shape: `Persistable` + `PrimaryKey` + `TypeId` + stable `id`. +- `HttpSession.key(...)` returns this object directly, including read/write operations. +- `yield* key` returns `Option.Option` via `session.get(key)`. +- `key.getOrFail` fails with `HttpSessionError(KeyNotFound)` when absent. +- Key operations are effectful in `HttpSession` context and fail with `HttpSessionError`. +- `Yieldable` signature threads `S["DecodingServices"]` and `S["EncodingServices"]` through key operations. + +### Constructors / helpers + +- `make(options): Effect.Effect` — creates the session service by resolving a session ID and opening a persistence store. + ```ts + export const make: (options: { + readonly getSessionId: Effect.Effect, E, R> + readonly timeToLive?: Duration.DurationInput | undefined + readonly generateSessionId?: Effect.Effect | undefined + }) => Effect.Effect + ``` +- `setCookie(response, options?): Effect.Effect` — dual API, reads `session.id` from context and sets it as cookie value. +- `clearCookie(options?: Cookie["options"]): Effect.Effect` + - Set expired cookie / `maxAge: 0`. + +Request-scoping contract: + +- `make` resolves the session ID at construction time from `getSessionId`. If `None`, falls back to `generateSessionId` (default: `crypto.randomUUID()`). +- `make` opens a `Persistence` store scoped to the resolved session ID. +- Key operations (`yield* key`, `key.set`, `key.remove`) require `HttpSession` in context, not direct `Persistence` access by callers. + +## Options + +### `make` options (implemented) + +```ts +{ + readonly getSessionId: Effect.Effect, E, R> + readonly timeToLive?: Duration.DurationInput // default: 30 minutes + readonly generateSessionId?: Effect.Effect // default: crypto.randomUUID() +} +``` + +### `setCookie` options (implemented) + +```ts +{ + readonly name?: string // default: "sid" + readonly path?: string + readonly domain?: string + readonly secure?: boolean // default: true + readonly httpOnly?: boolean // default: true +} +``` + +### Full options (not yet implemented) + +The following extended options are planned for HttpSession.make + +```ts +export interface Options { + readonly cookieName?: string // default: "sid" + readonly cookie?: { + readonly secure?: boolean // default true + readonly httpOnly?: boolean // default true + readonly sameSite?: "lax" | "strict" | "none" // default "lax" + readonly path?: string // default "/" + readonly domain?: string | undefined + } + readonly storage?: { + readonly storePrefix?: string // default: "session" + } + readonly session?: { + readonly expiresIn?: Duration.DurationInput // default 7 days + readonly updateAge?: Duration.DurationInput // default 1 day + readonly disableRefresh?: boolean // default false + } + readonly sessionId?: { + readonly generate?: Effect.Effect // default crypto.randomUUID() + } +} +``` + +## Storage Model + +### Store partitioning + +- Per-session store id format: `session:${sessionId}` (configurable via `storePrefix`). +- Session data entries are stored in that per-session `PersistenceStore`. +- `Persistence.make` is opened lazily in request scope (or cached per request only), never retained in an unbounded global map. + +### Session metadata key + +Use a dedicated `Persistable` metadata key in each per-session store: + +- key: `SessionMeta` with fixed primary key `"meta"` +- value schema fields: + - `createdAt` (epoch millis) + - `expiresAt` (epoch millis) + - `lastRefreshedAt` (epoch millis) + +The metadata key is authoritative for session validity. Data reads/writes require valid metadata. + +### Expiration semantics + +- On first session creation (`HttpSession.make` path), write metadata with `expiresAt = now + expiresIn`. +- Session is expired when `now >= expiresAt`. +- Refresh threshold is `now - lastRefreshedAt >= updateAge`. +- If refresh enabled and threshold reached, refresh metadata (`expiresAt = now + expiresIn`, `lastRefreshedAt = now`) and append refreshed cookie in response. +- If configured `updateAge > expiresIn`, clamp `updateAge = expiresIn`. + +Note on `Persistence` TTL constraints: + +- Key-level TTL cannot be uniformly extended for all existing session data keys due missing key enumeration. +- Session validity is enforced by metadata key, not by guaranteed synchronized TTL of every stored item. +- Session data writes use bounded TTL `max(0, expiresAt - now)` to track current session horizon and reduce stale orphan data. + +## Cookie and HTTP behavior + +- `HttpSession.make` behavior: + - if valid session id and metadata valid, return id. + - if missing/invalid/expired, generate UUID v4, initialize metadata +- `rotate` behavior: + - create new session id + metadata. + - clear old session store best-effort. +- `clear` behavior: + - clear current per-session store best-effort. + +## HttpApiSecurity Integration + +No new `HttpApiSecurity` union member. + +### Implemented helpers + +The following integration helpers exist in `unstable/httpapi`: + +- `HttpApiMiddleware.HttpSession()(id, { security })` — convenience class factory for creating session middleware that provides `HttpSession` via a security scheme (`Bearer` or `ApiKey`): + ```ts + class SessionMiddleware extends HttpApiMiddleware.HttpSession()("SessionMiddleware", { + security: HttpApiSecurity.apiKey({ key: "sid", in: "cookie" }) + }) {} + ``` + +- `HttpApiBuilder.securityMakeSession(security, options?)` — creates an `HttpSession` service from a security scheme by decoding the cookie/bearer value into a `SessionId`. Maps empty decoded value to `Option.none()` and delegates to `HttpSession.make`. + +- `HttpApiBuilder.middlewareHttpSession(service, options?)` — creates a `Layer` for a session middleware service, wiring `Persistence` and security decoding together: + ```ts + const SessionLive = HttpApiBuilder.middlewareHttpSession(SessionMiddleware) + ``` + +Unimplemented: middlewareHttpSession should set or update the session cookie on +outgoing responses. + +## Error Model + +`HttpSessionError` tagged error wrapping specific reason types: + +```ts +export class HttpSessionError extends Data.TaggedError("HttpSessionError")<{ + readonly reason: PersistenceError | KeyNotFound | Schema.SchemaError +}> {} + +export class KeyNotFound extends Data.TaggedError("KeyNotFound")<{ + readonly key: Key +}> {} +``` + +Error sources: + +- `key.getOrFail`: `KeyNotFound` when key absent in session store. +- `rotate` (planned): invalid current session when strict rotation path is used. +- Persistence/schema failures wrapped as `HttpSessionError`. + +## API Usage Example (target shape) + +```ts +import { HttpSession } from "effect/unstable/http" +import { HttpApiBuilder, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi" + +// Define typed session keys +const CurrentUserId = HttpSession.key({ + id: "userId", + schema: Schema.String +}) + +// Use keys in effects +Effect.gen(function*() { + const userId = yield* CurrentUserId // Option + const required = yield* CurrentUserId.getOrFail // string (or fail) + yield* CurrentUserId.set("123") + yield* CurrentUserId.remove +}) + +// Define session middleware via HttpApi integration +class SessionMiddleware extends HttpApiMiddleware.HttpSession()("SessionMiddleware", { + security: HttpApiSecurity.apiKey({ key: "sid", in: "cookie" }) +}) {} + +const SessionLive = HttpApiBuilder.middlewareHttpSession(SessionMiddleware) +``` + +## Files Expected to Change (implementation phase) + +- `packages/effect/src/unstable/http/HttpSession.ts` (exists) +- `packages/effect/src/unstable/http/index.ts` (barrel export via `pnpm codegen`, not manual edit) +- `packages/effect/src/unstable/httpapi/HttpApiMiddleware.ts` (exists — `HttpSession` factory) +- `packages/effect/src/unstable/httpapi/HttpApiBuilder.ts` (exists — `securityMakeSession`, `middlewareHttpSession`) +- `packages/effect/test/unstable/http/` (new tests) +- `packages/effect/test/unstable/http/HttpSession.test.ts` (new) + +## Detailed Implementation Plan + +### Task 1: Add public scaffold + export wiring ✅ + +Completed. `HttpSession.ts` exists with: + +- `HttpSession` service class with `id`, `get`, `set`, `remove`, `clear`. +- `Key` type + `HttpSession.key(...)` constructor. +- `HttpSession.make(...)` constructor. +- `setCookie` dual response helper. +- `HttpSessionError` + `KeyNotFound` error types. +- `SessionId` branded type. + +Integration helpers also exist: + +- `HttpApiMiddleware.HttpSession` factory. +- `HttpApiBuilder.securityMakeSession` + `middlewareHttpSession`. + +### Task 2: Implement session lifecycle + persistence metadata + +- Implement session lifecycle methods: `rotate` +- Add `SessionMeta` persistable model and initialization logic. +- Implement request cookie extraction for lifecycle methods. +- Enforce metadata validity and expiration checks. +- Implement refresh logic with `expiresIn` / `updateAge` / `disableRefresh`. +- Wire pre-response cookie setting/clearing. +- Add `clearCookie` response helper. +- Add `sameSite` and `path` options to cookie helpers. + +- Add lifecycle tests: missing/invalid/expired cookie, metadata missing/corrupt, refresh boundary, rotate, clear. + +Shippable state: + +- Session IDs and validity semantics are fully enforced with persistence-backed metadata. + +Validation for task: + +- `pnpm lint-fix` +- `pnpm test packages/effect/test/unstable/http/HttpSession.test.ts` +- `pnpm check` (with `pnpm clean` fallback) +- `pnpm build` +- `pnpm docgen` + +### Task 3: Add tests for existing functionality + typed session item operations + +- Add `packages/effect/test/unstable/http/HttpSession.test.ts`. +- Cover existing: key creation, `yield* key` behavior, `getOrFail` on absent key, `set`/`remove` round-trip, `clear`, `setCookie` helper. +- Cover existing: `HttpApiMiddleware.HttpSession` + `HttpApiBuilder.middlewareHttpSession` integration. +- Add tests for typed round-trip, absent key (`Option.none`), decode failure propagation, and expired-session behavior. + +Shippable state: + +- Applications can read/write typed session items using `Persistable`. + +Validation for task: + +- `pnpm lint-fix` +- `pnpm test packages/effect/test/unstable/http/HttpSession.test.ts` +- `pnpm check` (with `pnpm clean` fallback) +- `pnpm build` +- `pnpm docgen` + +### Task 4: Add integration hardening + docs/jsdoc examples + +- Add coverage that session security uses `HttpApiSecurity.apiKey({ in: "cookie" })`. +- Add tests for cookie helper behavior parity with existing patterns. +- Add tests for clear-cookie path/domain matching behavior. +- Add jsdoc examples for `HttpSession.key`, `HttpSession.make`, `HttpSession.setCookie`. +- Add jsdoc examples for `HttpApiMiddleware.HttpSession`, `HttpApiBuilder.middlewareHttpSession`. + +Shippable state: + +- Integration path is verified and documented. + +Validation for task: + +- `pnpm lint-fix` +- `pnpm test packages/effect/test/unstable/http/HttpSession.test.ts` +- `pnpm check` (with `pnpm clean` fallback) +- `pnpm build` +- `pnpm docgen` + +## Acceptance Criteria + +- `HttpSession` module exists with service + helpers. ✅ +- `HttpSession.make` resolves session ID from caller-provided effect, falling back to generation. ✅ +- Session storage is backed by `Persistence` and separated by per-session `storeId` (`session:${sessionId}`). ✅ +- Session item definitions use `HttpSession.key({ id, schema })`. ✅ +- Keys are yieldable (`yield* key`) and support `.getOrFail`, `.set(value)`, and `.remove`. ✅ +- `Persistable` is used internally as storage encoding mechanism. ✅ +- `HttpApiSecurity` integration is via `apiKey` cookie scheme. ✅ +- `HttpApiMiddleware.HttpSession` and `HttpApiBuilder.middlewareHttpSession` provide middleware wiring. ✅ +- `setCookie` sets session ID as cookie with secure defaults. ✅ +- `ensureSessionId` is idempotent for repeated calls in one request. +- `clearSession` clears cookie even if store cleanup fails. +- Refresh updates metadata and outgoing cookie when threshold is reached. +- Tests cover lifecycle, persistence operations, and integration behavior. + +## Risks + Mitigations + +- TTL synchronization limits in `Persistence` can leave stale data keys. + - Mitigation: metadata-authoritative validity + bounded data key TTL + best-effort clear on logout/rotation. +- Cookie behavior differences across local HTTP vs HTTPS. + - Mitigation: expose cookie options; document `secure` override for local dev. +- Session fixation concerns. + - Mitigation: explicit `rotateSessionId` API and recommendation to rotate on privilege changes. + +## Open Questions Resolved + +- Cookie name: `sid`. +- Session ID type: branded `Redacted` (`SessionId`). +- Session ID generation: `crypto.randomUUID()`. +- Security integration: `HttpApiSecurity.apiKey` cookie mode, with `HttpApiMiddleware.HttpSession` factory. +- Expiration model: Better Auth style (`expiresIn`, `updateAge`, optional refresh disable) — planned, not yet implemented. Current implementation uses simple `timeToLive` (default 30 minutes) on the persistence store. diff --git a/package.json b/package.json index 0d493b31b9..7ae2b320d3 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "@changesets/changelog-github": "^0.5.2", "@changesets/cli": "^2.29.8", "@effect/docgen": "https://pkg.pr.new/Effect-TS/docgen/@effect/docgen@e7fe055", - "@effect/language-service": "^0.72.0", + "@effect/language-service": "^0.73.1", "@effect/oxc": "workspace:^", "@effect/utils": "workspace:^", "@effect/vitest": "workspace:^", diff --git a/packages/effect/dtslint/unstable/httpapi/HttpApiMiddleware.tst.ts b/packages/effect/dtslint/unstable/httpapi/HttpApiMiddleware.tst.ts index 3460a8a22c..fdd978cb0a 100644 --- a/packages/effect/dtslint/unstable/httpapi/HttpApiMiddleware.tst.ts +++ b/packages/effect/dtslint/unstable/httpapi/HttpApiMiddleware.tst.ts @@ -9,7 +9,6 @@ describe("HttpApiMiddleware", () => { error: Schema.String }) {} expect(M.error).type.toBe() - expect(M.security).type.toBe() }) it("security", () => { diff --git a/packages/effect/src/unstable/http/HttpSession.ts b/packages/effect/src/unstable/http/HttpSession.ts new file mode 100644 index 0000000000..c8ce41cc21 --- /dev/null +++ b/packages/effect/src/unstable/http/HttpSession.ts @@ -0,0 +1,452 @@ +/** + * @since 4.0.0 + */ +import type * as Brand from "../../Brand.ts" +import { Clock } from "../../Clock.ts" +import * as Data from "../../Data.ts" +import * as DateTime from "../../DateTime.ts" +import * as Duration from "../../Duration.ts" +import * as Effect from "../../Effect.ts" +import * as Exit from "../../Exit.ts" +import { identity } from "../../Function.ts" +import { YieldableProto } from "../../internal/core.ts" +import * as Option from "../../Option.ts" +import * as PrimaryKey from "../../PrimaryKey.ts" +import * as Redacted from "../../Redacted.ts" +import * as Schema from "../../Schema.ts" +import * as Scope from "../../Scope.ts" +import * as ServiceMap from "../../ServiceMap.ts" +import * as Persistable from "../persistence/Persistable.ts" +import * as Persistence from "../persistence/Persistence.ts" +import type { PersistenceError } from "../persistence/Persistence.ts" +import * as Cookies from "./Cookies.ts" +import type { HttpServerResponse } from "./HttpServerResponse.ts" +import * as Response from "./HttpServerResponse.ts" + +/** + * @since 4.0.0 + * @category Key + */ +export type KeyTypeId = "~effect/http/HttpSession/Key" + +/** + * @since 4.0.0 + * @category Key + */ +export const KeyTypeId: KeyTypeId = "~effect/http/HttpSession/Key" + +/** + * @since 4.0.0 + * @category Key + */ +export interface Key + extends + Persistable.Persistable, + Effect.Yieldable, Option.Option, HttpSessionError, HttpSession | S["DecodingServices"]> +{ + readonly [KeyTypeId]: KeyTypeId + readonly id: string + + readonly getOrFail: Effect.Effect + readonly set: (value: S["Type"]) => Effect.Effect + readonly remove: Effect.Effect +} + +/** + * @since 4.0.0 + * @category Key + * @example + * ```ts + * import { Effect, Option, Schema } from "effect" + * import { HttpSession } from "effect/unstable/http" + * + * const UserId = HttpSession.key({ + * id: "userId", + * schema: Schema.String + * }) + * + * const readUserId = Effect.gen(function*() { + * const maybeUserId = yield* UserId + * return Option.isNone(maybeUserId) ? "guest" : maybeUserId.value + * }) + * ``` + */ +export const key = (options: { + readonly id: string + readonly schema: S +}): Key => + Object.assign(Object.create(KeyProto), { + id: options.id, + [Persistable.symbol]: { + success: options.schema, + error: Schema.Never + } + }) + +const KeyProto: Omit, "id" | "schema" | typeof Persistable.symbol> = { + ...YieldableProto, + [KeyTypeId]: KeyTypeId, + asEffect(this: Key) { + return HttpSession.use((session) => session.get(this)) + }, + get getOrFail() { + const key = this as Key + return Effect.flatMap( + this.asEffect(), + (o) => Option.isNone(o) ? Effect.fail(new HttpSessionError(new KeyNotFound({ key }))) : Effect.succeed(o.value) + ) + }, + set(this: Key, value: any) { + return HttpSession.use((session) => session.set(this, value)) + }, + get remove() { + return HttpSession.use((session) => session.remove(this as any)) + }, + [PrimaryKey.symbol](this: Key): string { + return this.id + } +} + +/** + * @since 4.0.0 + * @category Session ID + */ +export type SessionId = Brand.Branded + +/** + * @since 4.0.0 + * @category Session ID + */ +export const SessionId = (value: string): SessionId => Redacted.make(value) as SessionId + +/** + * @since 4.0.0 + * @category Storage + */ +export class HttpSession extends ServiceMap.Service + readonly cookie: Effect.Effect + readonly rotate: Effect.Effect + readonly get: ( + key: Key + ) => Effect.Effect, HttpSessionError, S["DecodingServices"]> + readonly set: ( + key: Key, + value: S["Type"] + ) => Effect.Effect + readonly remove: (key: Key) => Effect.Effect + readonly clear: Effect.Effect +}>()("effect/http/HttpSession") {} + +/** + * @since 4.0.0 + * @category State + */ +export interface SessionState { + readonly id: SessionId + readonly metadata: SessionMeta + readonly storage: Persistence.PersistenceStore + readonly action: "none" | "set" | "clear" +} + +/** + * @since 4.0.0 + * @category State + */ +export class SessionMeta extends Schema.Class("effect/http/HttpSession/SessionMeta")({ + createdAt: Schema.DateTimeUtc, + expiresAt: Schema.DateTimeUtc, + lastRefreshedAt: Schema.DateTimeUtc +}) { + static key = key({ + id: "_meta", + schema: SessionMeta + }) + + isExpired(now: DateTime.Utc): boolean { + return DateTime.isLessThanOrEqualTo(this.expiresAt, now) + } +} + +/** + * @since 4.0.0 + * @category Storage + */ +export interface MakeHttpSessionOptions { + readonly cookie?: { + readonly name?: string | undefined + readonly path?: string | undefined + readonly domain?: string | undefined + readonly secure?: boolean | undefined + readonly httpOnly?: boolean | undefined + readonly sameSite?: "lax" | "strict" | "none" | undefined + } | undefined + readonly getSessionId: Effect.Effect, E, R> + readonly expiresIn?: Duration.DurationInput | undefined + readonly updateAge?: Duration.DurationInput | undefined + readonly disableRefresh?: boolean | undefined + readonly generateSessionId?: Effect.Effect | undefined +} + +/** + * @since 4.0.0 + * @category Storage + * @example + * ```ts + * import { Effect, Option } from "effect" + * import { HttpSession } from "effect/unstable/http" + * import { Persistence } from "effect/unstable/persistence" + * + * const session = HttpSession.make({ + * getSessionId: Effect.succeed(Option.none()) + * }).pipe( + * Effect.provide(Persistence.layerMemory), + * Effect.scoped + * ) + * ``` + */ +export const make = Effect.fnUntraced(function*( + options: MakeHttpSessionOptions +): Effect.fn.Return< + HttpSession["Service"], + E | HttpSessionError, + R | Persistence.Persistence | Scope.Scope +> { + const scope = yield* Scope.Scope + const persistence = yield* Persistence.Persistence + const clock = yield* Clock + const expiresIn = Duration.fromDurationInputUnsafe(options.expiresIn ?? Duration.days(7)) + const updateAge = Duration.min(Duration.fromDurationInputUnsafe(options.updateAge ?? Duration.days(1)), expiresIn) + const updateAgeMillis = Duration.toMillis(updateAge) + const disableRefresh = options.disableRefresh ?? false + const generateSessionId = options.generateSessionId ?? defaultGenerateSessionId + const mapHttpSessionError = Effect.mapError( + (error: PersistenceError | KeyNotFound | Schema.SchemaError) => new HttpSessionError(error) + ) + + const makeSessionMeta = (now: DateTime.Utc, createdAt = now): SessionMeta => + new SessionMeta({ + createdAt, + expiresAt: DateTime.addDuration(now, expiresIn), + lastRefreshedAt: now + }) + + const makeStorage = (sessionId: SessionId): Effect.Effect => + Effect.provideService( + persistence.make({ + storeId: `session:${Redacted.value(sessionId)}`, + timeToLive() { + return Duration.millis(currentState.metadata.expiresAt.epochMillis - clock.currentTimeMillisUnsafe()) + } + }), + Scope.Scope, + scope + ) + + const shouldRefresh = (metadata: SessionMeta, now: DateTime.Utc): boolean => + !disableRefresh && now.epochMillis - metadata.lastRefreshedAt.epochMillis >= updateAgeMillis + + const refreshMetadata = Effect.fnUntraced(function*(state: SessionState, now: DateTime.Utc) { + const refreshedMetadata = makeSessionMeta(now, state.metadata.createdAt) + yield* state.storage.set(SessionMeta.key, Exit.succeed(refreshedMetadata)) + return identity({ ...state, metadata: refreshedMetadata, action: "set" }) + }, Effect.catchIf(Schema.isSchemaError, Effect.die)) + + const reconcileState = Effect.fnUntraced(function*( + state: SessionState + ): Effect.fn.Return { + const metadata = yield* state.storage.get(SessionMeta.key).pipe( + Effect.catchTag("SchemaError", () => Effect.undefined) + ) + if (!metadata || metadata._tag === "Failure") { + return yield* freshState + } + const now = yield* DateTime.now + if (metadata.value.isExpired(now)) { + yield* state.storage.clear + return yield* freshState + } + const currentState = identity({ + ...state, + metadata: metadata.value + }) + if (shouldRefresh(currentState.metadata, now)) { + return yield* refreshMetadata(currentState, now) + } + return currentState + }) + + const freshState = Effect.gen(function*() { + const id = yield* generateSessionId + const now = yield* DateTime.now + const metadata = makeSessionMeta(now) + const state = identity({ + id, + metadata, + storage: yield* makeStorage(id), + action: "set" + }) + yield* state.storage.set(SessionMeta.key, Exit.succeed(state.metadata)) + return state + }).pipe(Effect.catchIf(Schema.isSchemaError, Effect.die)) + + const initialState = Effect.gen(function*() { + const sessionId = yield* options.getSessionId.pipe( + Effect.flatMap(Option.match({ + onNone: () => generateSessionId, + onSome: Effect.succeed + })) + ) + const now = yield* DateTime.now + const metadata = makeSessionMeta(now) + return identity({ + id: sessionId, + metadata, + storage: yield* makeStorage(sessionId), + action: "none" + }) + }) + + let currentState = yield* initialState + currentState = yield* reconcileState(currentState).pipe(mapHttpSessionError) + + const withStateLock = Effect.makeSemaphoreUnsafe(1).withPermit + + const withStorage = ( + f: (storage: Persistence.PersistenceStore) => Effect.Effect + ) => Effect.flatMap(ensureState, (state) => f(state.storage)) + + const ensureState = withStateLock(Effect.gen(function*() { + const now = yield* DateTime.now + if (!shouldRefresh(currentState.metadata, now)) { + return currentState + } + currentState = yield* refreshMetadata(currentState, now) + return currentState + })) + + const rotate = withStateLock(Effect.gen(function*() { + const previousState = currentState + const nextState = yield* freshState + currentState = nextState + if (Redacted.value(previousState.id) !== Redacted.value(nextState.id)) { + yield* Effect.ignore(previousState.storage.clear) + } + })) + + return HttpSession.of({ + state: ensureState.pipe(mapHttpSessionError), + cookie: Effect.sync(() => + Cookies.makeCookieUnsafe(options.cookie?.name ?? "sid", Redacted.value(currentState.id), { + ...options.cookie, + expires: new Date(currentState.metadata.expiresAt.epochMillis), + secure: options.cookie?.secure ?? true, + httpOnly: options.cookie?.httpOnly ?? true + }) + ), + rotate: rotate.pipe(mapHttpSessionError), + get: Effect.fnUntraced(function*( + key: Key + ) { + const state = yield* ensureState + const exit = yield* state.storage.get(key) + if (!exit || exit._tag === "Failure") { + return Option.none() + } + return Option.some(exit.value) + }, mapHttpSessionError), + set: (key, value) => withStorage((storage) => storage.set(key, Exit.succeed(value))).pipe(mapHttpSessionError), + remove: (key) => withStorage((storage) => storage.remove(key)).pipe(mapHttpSessionError), + clear: withStorage((storage) => storage.clear).pipe( + Effect.onExit((_) => { + currentState = { + ...currentState, + action: "clear" + } + }), + mapHttpSessionError + ) + }) +}) + +const defaultGenerateSessionId = Effect.sync(() => SessionId(crypto.randomUUID())) + +/** + * @since 4.0.0 + * @category Response helpers + */ +export const setCookie = ( + self: HttpSession["Service"], + response: HttpServerResponse +): Effect.Effect => + Effect.map( + self.cookie, + (cookie) => Response.updateCookies(response, Cookies.setCookie(cookie)) + ) + +/** + * @since 4.0.0 + * @category Response helpers + */ +export const clearCookie = ( + self: HttpSession["Service"], + response: HttpServerResponse +): Effect.Effect => + Effect.map(self.cookie, (cookie) => + Response.updateCookies( + response, + Cookies.setCookie( + Cookies.makeCookieUnsafe(cookie.name, "", { + ...cookie.options, + maxAge: 0, + expires: new Date(0) + }) + ) + )) + +/** + * @since 4.0.0 + * @category Errors + */ +export type ErrorTypeId = "~effect/http/HttpSession/Error" + +/** + * @since 4.0.0 + * @category Errors + */ +export const ErrorTypeId: ErrorTypeId = "~effect/http/HttpSession/Error" + +/** + * @since 4.0.0 + * @category Errors + */ +export class HttpSessionError extends Data.TaggedError("HttpSessionError")<{ + readonly reason: PersistenceError | KeyNotFound | Schema.SchemaError +}> { + constructor(reason: PersistenceError | KeyNotFound | Schema.SchemaError) { + super({ reason, cause: reason } as any) + } + + /** + * @since 4.0.0 + */ + readonly [ErrorTypeId]: ErrorTypeId = ErrorTypeId + + /** + * @since 4.0.0 + */ + override readonly message: string = this.reason.message +} + +/** + * @since 4.0.0 + * @category Errors + */ +export class KeyNotFound extends Data.TaggedError("KeyNotFound")<{ + readonly key: Key +}> { + /** + * @since 4.0.0 + */ + override readonly message = `Session.Key with id "${this.key.id}" not found` +} diff --git a/packages/effect/src/unstable/http/index.ts b/packages/effect/src/unstable/http/index.ts index fbaa6ab82e..bc254be21d 100644 --- a/packages/effect/src/unstable/http/index.ts +++ b/packages/effect/src/unstable/http/index.ts @@ -109,6 +109,11 @@ export * as HttpServerRespondable from "./HttpServerRespondable.ts" */ export * as HttpServerResponse from "./HttpServerResponse.ts" +/** + * @since 4.0.0 + */ +export * as HttpSession from "./HttpSession.ts" + /** * @since 4.0.0 */ diff --git a/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts b/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts index c39784ff29..ba5c9213d5 100644 --- a/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts +++ b/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts @@ -32,8 +32,10 @@ import * as Request from "../http/HttpServerRequest.ts" import { HttpServerRequest } from "../http/HttpServerRequest.ts" import * as Response from "../http/HttpServerResponse.ts" import type { HttpServerResponse } from "../http/HttpServerResponse.ts" +import * as HttpSession from "../http/HttpSession.ts" import * as Multipart from "../http/Multipart.ts" import * as UrlParams from "../http/UrlParams.ts" +import * as Persistence from "../persistence/Persistence.ts" import type * as HttpApi from "./HttpApi.ts" import * as HttpApiEndpoint from "./HttpApiEndpoint.ts" import { HttpApiSchemaError } from "./HttpApiError.ts" @@ -297,9 +299,8 @@ export const securityDecode = > => { switch (self._tag) { case "Bearer": { - return Effect.map( - HttpServerRequest.asEffect(), - (request) => Redacted.make((request.headers.authorization ?? "").slice(bearerLen)) as any + return HttpServerRequest.useSync((request) => + Redacted.make((request.headers.authorization ?? "").slice(bearerLen)) as any ) } case "ApiKey": { @@ -367,6 +368,85 @@ export const securitySetCookie = ( ) ) +/** + * @since 4.0.0 + * @category security + */ +export const securityMakeSession = ( + self: Security, + options?: Omit, "getSessionId"> +): Effect.Effect< + HttpSession.HttpSession["Service"], + HttpSession.HttpSessionError, + | Persistence.Persistence + | Scope.Scope + | Request.HttpServerRequest + | Request.ParsedSearchParams +> => + Effect.gen(function*() { + const credential = yield* securityDecode(self) + const value = Redacted.value(credential) + return yield* HttpSession.make({ + ...options, + cookie: { + ...options?.cookie, + name: self.key + }, + getSessionId: Effect.succeed(value === "" ? Option.none() : Option.some(HttpSession.SessionId(value))) + }).pipe(Effect.orDie) + }) + +/** + * @since 4.0.0 + * @category security + * @example + * ```ts + * import { HttpApiBuilder, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi" + * + * class SessionMiddleware extends HttpApiMiddleware.HttpSession()("SessionMiddleware", { + * security: HttpApiSecurity.apiKey({ key: "sid", in: "cookie" }) + * }) {} + * + * const SessionMiddlewareLive = HttpApiBuilder.middlewareHttpSession(SessionMiddleware) + * ``` + */ +export const middlewareHttpSession = < + Service extends ( + & HttpApiMiddleware.ServiceClass + & { + readonly security: HttpApiSecurity.ApiKey + } + ) +>( + service: Service, + options?: Omit, "getSessionId"> +): Layer.Layer => { + const makeSession = Effect.orDie(securityMakeSession(service.security, options)) + return Layer.effect( + service, + Persistence.Persistence.useSync((persistence) => + service.of(Effect.fnUntraced(function*(effect) { + const session = yield* Effect.provideService(makeSession, Persistence.Persistence, persistence) + let response = yield* Effect.provideService(effect, HttpSession.HttpSession, session) + if (service.security.in === "cookie") { + const state = yield* Effect.orDie(session.state) + if (state.action === "clear") { + response = yield* HttpSession.clearCookie(session, response) + } else if (state.action === "set") { + response = yield* HttpSession.setCookie(session, response) + } + } + return response + })) + ) + ) +} + // ----------------------------------------------------------------------------- // Internal // ----------------------------------------------------------------------------- diff --git a/packages/effect/src/unstable/httpapi/HttpApiMiddleware.ts b/packages/effect/src/unstable/httpapi/HttpApiMiddleware.ts index db6621de03..4388c73a2a 100644 --- a/packages/effect/src/unstable/httpapi/HttpApiMiddleware.ts +++ b/packages/effect/src/unstable/httpapi/HttpApiMiddleware.ts @@ -8,6 +8,7 @@ import * as ServiceMap from "../../ServiceMap.ts" import type { unhandled } from "../../Types.ts" import type * as HttpRouter from "../http/HttpRouter.ts" import type { HttpServerResponse } from "../http/HttpServerResponse.ts" +import type * as HttpSession_ from "../http/HttpSession.ts" import type * as HttpApiEndpoint from "./HttpApiEndpoint.ts" import type * as HttpApiGroup from "./HttpApiGroup.ts" import type * as HttpApiSecurity from "./HttpApiSecurity.ts" @@ -156,7 +157,7 @@ export type ServiceClass< readonly [TypeId]: typeof TypeId readonly error: Config["error"] } - & ([keyof Config["security"]] extends [never] ? {} : { + & ([Config["security"]] extends [never] ? {} : [keyof Config["security"]] extends [never] ? {} : { readonly [SecurityTypeId]: typeof SecurityTypeId readonly security: Config["security"] }) @@ -220,3 +221,57 @@ export const Service = < } return self } + +/** + * @since 4.0.0 + * @category HttpSession + * @example + * ```ts + * import { HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi" + * + * class SessionMiddleware extends HttpApiMiddleware.HttpSession()("SessionMiddleware", { + * security: HttpApiSecurity.apiKey({ key: "sid", in: "cookie" }) + * }) {} + * ``` + */ +export const HttpSession = (): < + const Id extends string, + Security extends HttpApiSecurity.ApiKey +>( + id: Id, + options: { + readonly security: Security + } +) => + & ServiceClass + & { + readonly security: Security + } => +( + id: string, + options: { + readonly security: HttpApiSecurity.ApiKey + } +) => { + const Err = globalThis.Error as any + const limit = Err.stackTraceLimit + Err.stackTraceLimit = 2 + const creationError = new Err() + Err.stackTraceLimit = limit + + class Service extends ServiceMap.Service()(id) {} + const self = Service as any + Object.defineProperty(Service, "stack", { + get() { + return creationError.stack + } + }) + self[TypeId] = TypeId + self.security = options.security + return self +} diff --git a/packages/effect/src/unstable/persistence/RateLimiter.ts b/packages/effect/src/unstable/persistence/RateLimiter.ts index 38092e6d9e..a3b6e800e7 100644 --- a/packages/effect/src/unstable/persistence/RateLimiter.ts +++ b/packages/effect/src/unstable/persistence/RateLimiter.ts @@ -352,6 +352,7 @@ export const RateLimiterErrorReason: Schema.Union<[ * @since 4.0.0 * @category Errors */ +// @effect-diagnostics-next-line overriddenSchemaConstructor:off export class RateLimiterError extends Schema.ErrorClass(ErrorTypeId)({ _tag: Schema.tag("RateLimiterError"), reason: RateLimiterErrorReason diff --git a/packages/effect/src/unstable/socket/Socket.ts b/packages/effect/src/unstable/socket/Socket.ts index 49323dd4d0..a7c5506e20 100644 --- a/packages/effect/src/unstable/socket/Socket.ts +++ b/packages/effect/src/unstable/socket/Socket.ts @@ -213,6 +213,7 @@ export type SocketErrorReason = * @since 4.0.0 * @category errors */ +// @effect-diagnostics-next-line overriddenSchemaConstructor:off export class SocketError extends Schema.ErrorClass(SocketErrorTypeId)({ _tag: Schema.tag("SocketError"), reason: SocketErrorReason diff --git a/packages/effect/src/unstable/workers/WorkerError.ts b/packages/effect/src/unstable/workers/WorkerError.ts index a44a8123b8..b12ef28d55 100644 --- a/packages/effect/src/unstable/workers/WorkerError.ts +++ b/packages/effect/src/unstable/workers/WorkerError.ts @@ -96,6 +96,7 @@ export const WorkerErrorReason: Schema.Union<[ * @since 4.0.0 * @category Models */ +// @effect-diagnostics-next-line overriddenSchemaConstructor:off export class WorkerError extends Schema.ErrorClass(TypeId)({ _tag: Schema.tag("WorkerError"), reason: WorkerErrorReason diff --git a/packages/effect/test/unstable/http/HttpSession.test.ts b/packages/effect/test/unstable/http/HttpSession.test.ts new file mode 100644 index 0000000000..8933505ea9 --- /dev/null +++ b/packages/effect/test/unstable/http/HttpSession.test.ts @@ -0,0 +1,923 @@ +/** @effect-diagnostics multipleEffectProvide:skip-file */ +import { assert, describe, it } from "@effect/vitest" +import { Clock, DateTime, Duration, Effect, Exit, Option, Redacted, Schema } from "effect" +import { TestClock } from "effect/testing" +import { Cookies, HttpRouter, HttpServerRequest, HttpServerResponse, HttpSession } from "effect/unstable/http" +import { HttpApiBuilder, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi" +import { Persistence } from "effect/unstable/persistence" + +const CorruptSessionMetaKey = HttpSession.key({ + id: HttpSession.SessionMeta.key.id, + schema: Schema.Struct({ + createdAt: Schema.String, + expiresAt: Schema.String, + lastRefreshedAt: Schema.String + }) +}) + +const ValueKey = HttpSession.key({ + id: "value", + schema: Schema.String +}) + +const toStoreId = (sessionId: HttpSession.SessionId) => `session:${Redacted.value(sessionId)}` + +class SessionMiddleware extends HttpApiMiddleware.HttpSession()("SessionMiddleware", { + security: HttpApiSecurity.apiKey({ key: "session_token", in: "cookie" }) +}) {} + +describe("HttpSession", () => { + it.effect("regenerates session id when metadata is missing", () => + Effect.gen(function*() { + const previousId = HttpSession.SessionId("previous") + const newId = HttpSession.SessionId("new") + + const session = yield* HttpSession.make({ + getSessionId: Effect.succeed(Option.some(previousId)), + generateSessionId: Effect.succeed(newId) + }) + + let state = yield* session.state + assert.strictEqual(Redacted.value(state.id), Redacted.value(newId)) + + const persistence = yield* Persistence.Persistence + const store = yield* persistence.make({ storeId: toStoreId(newId) }) + const metadata = yield* store.get(HttpSession.SessionMeta.key) + assert.isTrue(metadata !== undefined && metadata._tag === "Success") + }).pipe(Effect.provide(Persistence.layerMemory))) + + it.effect("regenerates session id when metadata is corrupt", () => + Effect.gen(function*() { + const previousId = HttpSession.SessionId("previous") + const newId = HttpSession.SessionId("new") + const persistence = yield* Persistence.Persistence + const store = yield* persistence.make({ storeId: toStoreId(previousId) }) + + yield* store.set( + CorruptSessionMetaKey, + Exit.succeed({ + createdAt: "bad", + expiresAt: "bad", + lastRefreshedAt: "bad" + }) + ) + + const session = yield* HttpSession.make({ + getSessionId: Effect.succeed(Option.some(previousId)), + generateSessionId: Effect.succeed(newId) + }) + + let state = yield* session.state + assert.strictEqual(Redacted.value(state.id), Redacted.value(newId)) + }).pipe(Effect.provide(Persistence.layerMemory))) + + it.effect("regenerates session id when metadata is expired", () => + Effect.gen(function*() { + const previousId = HttpSession.SessionId("previous") + const newId = HttpSession.SessionId("new") + const now = yield* DateTime.now + const persistence = yield* Persistence.Persistence + const store = yield* persistence.make({ storeId: toStoreId(previousId) }) + + yield* store.set( + HttpSession.SessionMeta.key, + Exit.succeed( + new HttpSession.SessionMeta({ + createdAt: DateTime.subtract(now, { seconds: 1 }), + expiresAt: DateTime.subtract(now, { millis: 1 }), + lastRefreshedAt: DateTime.subtract(now, { seconds: 1 }) + }) + ) + ) + + const session = yield* HttpSession.make({ + getSessionId: Effect.succeed(Option.some(previousId)), + generateSessionId: Effect.succeed(newId) + }) + + let state = yield* session.state + assert.strictEqual(Redacted.value(state.id), Redacted.value(newId)) + }).pipe(Effect.provide(Persistence.layerMemory))) + + it.effect("revalidates metadata before write operations", () => + Effect.gen(function*() { + const firstId = HttpSession.SessionId("first") + const secondId = HttpSession.SessionId("second") + const generatedIds = [firstId, secondId] + let index = 0 + + const session = yield* HttpSession.make({ + getSessionId: Effect.succeed(Option.none()), + generateSessionId: Effect.sync(() => generatedIds[index++]!) + }) + + const persistence = yield* Persistence.Persistence + const firstStore = yield* persistence.make({ storeId: toStoreId(firstId) }) + yield* firstStore.remove(HttpSession.SessionMeta.key) + + yield* session.set(ValueKey, "value") + + let state = yield* session.state + assert.strictEqual(Redacted.value(state.id), Redacted.value(secondId)) + + const firstValue = yield* firstStore.get(ValueKey) + assert.strictEqual(firstValue, undefined) + + const secondStore = yield* persistence.make({ storeId: toStoreId(secondId) }) + const secondValue = yield* (yield* secondStore.get(ValueKey))! + assert.strictEqual(secondValue, "value") + }).pipe(Effect.provide(Persistence.layerMemory))) + + it.effect("revalidates metadata before read operations", () => + Effect.gen(function*() { + const firstId = HttpSession.SessionId("first") + const secondId = HttpSession.SessionId("second") + const generatedIds = [firstId, secondId] + let index = 0 + + const session = yield* HttpSession.make({ + getSessionId: Effect.succeed(Option.none()), + generateSessionId: Effect.sync(() => generatedIds[index++]!) + }) + + const persistence = yield* Persistence.Persistence + const firstStore = yield* persistence.make({ storeId: toStoreId(firstId) }) + yield* firstStore.set(ValueKey, Exit.succeed("stale")) + yield* firstStore.remove(HttpSession.SessionMeta.key) + + const value = yield* session.get(ValueKey) + assert.isTrue(Option.isNone(value)) + const state = yield* session.state + assert.strictEqual(Redacted.value(state.id), Redacted.value(secondId)) + }).pipe(Effect.provide(Persistence.layerMemory))) + + it.effect("revalidates metadata before remove operations", () => + Effect.gen(function*() { + const firstId = HttpSession.SessionId("first") + const secondId = HttpSession.SessionId("second") + const generatedIds = [firstId, secondId] + let index = 0 + + const session = yield* HttpSession.make({ + getSessionId: Effect.succeed(Option.none()), + generateSessionId: Effect.sync(() => generatedIds[index++]!) + }) + + const persistence = yield* Persistence.Persistence + const firstStore = yield* persistence.make({ storeId: toStoreId(firstId) }) + yield* firstStore.set(ValueKey, Exit.succeed("stale")) + yield* firstStore.remove(HttpSession.SessionMeta.key) + + yield* session.remove(ValueKey) + + const state = yield* session.state + assert.strictEqual(Redacted.value(state.id), Redacted.value(secondId)) + + const firstValue = yield* (yield* firstStore.get(ValueKey))! + assert.strictEqual(firstValue, "stale") + + const secondStore = yield* persistence.make({ storeId: toStoreId(secondId) }) + const secondValue = yield* secondStore.get(ValueKey) + assert.strictEqual(secondValue, undefined) + }).pipe(Effect.provide(Persistence.layerMemory))) + + it.effect("revalidates metadata before clear operations", () => + Effect.gen(function*() { + const firstId = HttpSession.SessionId("first") + const secondId = HttpSession.SessionId("second") + const generatedIds = [firstId, secondId] + let index = 0 + + const session = yield* HttpSession.make({ + getSessionId: Effect.succeed(Option.none()), + generateSessionId: Effect.sync(() => generatedIds[index++]!) + }) + + const persistence = yield* Persistence.Persistence + const firstStore = yield* persistence.make({ storeId: toStoreId(firstId) }) + yield* firstStore.set(ValueKey, Exit.succeed("stale")) + yield* firstStore.remove(HttpSession.SessionMeta.key) + + yield* session.clear + + const state = yield* session.state + assert.strictEqual(Redacted.value(state.id), Redacted.value(secondId)) + + const firstValue = yield* (yield* firstStore.get(ValueKey))! + assert.strictEqual(firstValue, "stale") + + const secondStore = yield* persistence.make({ storeId: toStoreId(secondId) }) + const secondMeta = yield* secondStore.get(HttpSession.SessionMeta.key) + assert.strictEqual(secondMeta, undefined) + }).pipe(Effect.provide(Persistence.layerMemory))) + + it.effect("rotates session id and keeps the resolved service usable", () => + Effect.gen(function*() { + const firstId = HttpSession.SessionId("first") + const secondId = HttpSession.SessionId("second") + const thirdId = HttpSession.SessionId("third") + const generatedIds = [secondId, thirdId] + let index = 0 + + const persistence = yield* Persistence.Persistence + + const session = yield* HttpSession.make({ + getSessionId: Effect.succeed(Option.some(firstId)), + generateSessionId: Effect.sync(() => generatedIds[index++]!) + }) + + let state = yield* session.state + assert.strictEqual(Redacted.value(state.id), Redacted.value(secondId)) + + const secondStore = yield* persistence.make({ storeId: toStoreId(secondId) }) + + yield* session.set(ValueKey, "stale") + yield* session.rotate + + state = yield* session.state + assert.strictEqual(Redacted.value(state.id), Redacted.value(thirdId)) + + const secondMeta = yield* secondStore.get(HttpSession.SessionMeta.key) + assert.strictEqual(secondMeta, undefined) + const secondValue = yield* secondStore.get(ValueKey) + assert.strictEqual(secondValue, undefined) + + yield* session.set(ValueKey, "fresh") + + const thirdStore = yield* persistence.make({ storeId: toStoreId(thirdId) }) + yield* (yield* thirdStore.get(HttpSession.SessionMeta.key))! + + const thirdValue = yield* (yield* thirdStore.get(ValueKey))! + assert.strictEqual(thirdValue, "fresh") + }).pipe(Effect.provide(Persistence.layerMemory))) + + it.effect("rotates even when clearing the previous store fails", () => + Effect.gen(function*() { + const firstId = HttpSession.SessionId("first") + const secondId = HttpSession.SessionId("second") + const thirdId = HttpSession.SessionId("third") + const generatedIds = [secondId, thirdId] + let index = 0 + + const basePersistence = yield* Persistence.Persistence + const secondStore = yield* basePersistence.make({ storeId: toStoreId(secondId) }) + + const wrappedPersistence = Persistence.Persistence.of({ + make: (options) => + Effect.map( + basePersistence.make(options), + (store) => + options.storeId === toStoreId(secondId) + ? { + ...store, + clear: Effect.fail(new Persistence.PersistenceError({ message: "clear failed" })) + } + : store + ) + }) + + const session = yield* HttpSession.make({ + getSessionId: Effect.succeed(Option.some(firstId)), + generateSessionId: Effect.sync(() => generatedIds[index++]!) + }).pipe(Effect.provideService(Persistence.Persistence, wrappedPersistence)) + + let state = yield* session.state + assert.strictEqual(Redacted.value(state.id), Redacted.value(secondId)) + + yield* session.set(ValueKey, "stale") + yield* session.rotate + + state = yield* session.state + assert.strictEqual(Redacted.value(state.id), Redacted.value(thirdId)) + + const secondValue = yield* (yield* secondStore.get(ValueKey))! + assert.strictEqual(secondValue, "stale") + + yield* session.set(ValueKey, "fresh") + + const thirdStore = yield* basePersistence.make({ storeId: toStoreId(thirdId) }) + yield* (yield* thirdStore.get(HttpSession.SessionMeta.key))! + const thirdValue = yield* (yield* thirdStore.get(ValueKey))! + assert.strictEqual(thirdValue, "fresh") + }).pipe(Effect.provide(Persistence.layerMemory))) + + it.effect("keeps state consistent when rotate generates the current id", () => + Effect.gen(function*() { + const firstId = HttpSession.SessionId("first") + const secondId = HttpSession.SessionId("second") + const generatedIds = [firstId, secondId] + let index = 0 + + const persistence = yield* Persistence.Persistence + const firstStore = yield* persistence.make({ storeId: toStoreId(firstId) }) + const now = yield* DateTime.now + yield* firstStore.set( + HttpSession.SessionMeta.key, + Exit.succeed( + new HttpSession.SessionMeta({ + createdAt: now, + expiresAt: DateTime.addDuration(now, Duration.days(1)), + lastRefreshedAt: now + }) + ) + ) + + const session = yield* HttpSession.make({ + getSessionId: Effect.succeed(Option.some(firstId)), + generateSessionId: Effect.sync(() => generatedIds[index++] ?? secondId) + }) + + let state = yield* session.state + assert.strictEqual(Redacted.value(state.id), Redacted.value(firstId)) + + yield* session.set(ValueKey, "stale") + yield* session.rotate + + state = yield* session.state + assert.strictEqual(Redacted.value(state.id), Redacted.value(firstId)) + + yield* session.set(ValueKey, "fresh") + + state = yield* session.state + assert.strictEqual(Redacted.value(state.id), Redacted.value(firstId)) + + const firstMeta = yield* firstStore.get(HttpSession.SessionMeta.key) + assert.isTrue(firstMeta !== undefined && firstMeta._tag === "Success") + + const firstValue = yield* (yield* firstStore.get(ValueKey))! + assert.strictEqual(firstValue, "fresh") + + const secondStore = yield* persistence.make({ storeId: toStoreId(secondId) }) + const secondMeta = yield* secondStore.get(HttpSession.SessionMeta.key) + assert.strictEqual(secondMeta, undefined) + }).pipe(Effect.provide(Persistence.layerMemory))) + + it.effect("regenerates session id when middleware cookie is missing", () => + Effect.gen(function*() { + const middleware = yield* SessionMiddleware + const response = yield* middleware(Effect.succeed(HttpServerResponse.empty()), {} as any).pipe( + Effect.provideService( + HttpServerRequest.HttpServerRequest, + HttpServerRequest.fromWeb(new Request("http://localhost/")) + ), + Effect.provideService(HttpServerRequest.ParsedSearchParams, {}), + Effect.provideService(HttpRouter.RouteContext, { + params: {}, + route: {} as any + }) + ) + + const cookie = Cookies.get(response.cookies, "session_token") + assert.isTrue(cookie !== undefined) + if (cookie !== undefined) { + assert.strictEqual(cookie.value, "generated-missing") + } + }).pipe( + Effect.provide(HttpApiBuilder.middlewareHttpSession(SessionMiddleware, { + generateSessionId: Effect.succeed(HttpSession.SessionId("generated-missing")) + })), + Effect.provide(Persistence.layerMemory) + )) + + it.effect("regenerates session id when middleware cookie is invalid", () => + Effect.gen(function*() { + const middleware = yield* SessionMiddleware + const response = yield* middleware(Effect.succeed(HttpServerResponse.empty()), {} as any).pipe( + Effect.provideService( + HttpServerRequest.HttpServerRequest, + HttpServerRequest.fromWeb( + new Request("http://localhost/", { + headers: { + cookie: "session_token=invalid-cookie" + } + }) + ) + ), + Effect.provideService(HttpServerRequest.ParsedSearchParams, {}), + Effect.provideService(HttpRouter.RouteContext, { + params: {}, + route: {} as any + }) + ) + + const cookie = Cookies.get(response.cookies, "session_token") + assert.isTrue(cookie !== undefined) + if (cookie !== undefined) { + assert.strictEqual(cookie.value, "generated-invalid") + } + }).pipe( + Effect.provide(HttpApiBuilder.middlewareHttpSession(SessionMiddleware, { + generateSessionId: Effect.succeed(HttpSession.SessionId("generated-invalid")) + })), + Effect.provide(Persistence.layerMemory) + )) + + it.effect("regenerates session id when middleware cookie points to expired metadata", () => + Effect.gen(function*() { + const staleId = HttpSession.SessionId("expired-cookie") + const freshId = HttpSession.SessionId("fresh-cookie") + const persistence = yield* Persistence.Persistence + const staleStore = yield* persistence.make({ storeId: toStoreId(staleId) }) + const now = yield* DateTime.now + + yield* staleStore.set( + HttpSession.SessionMeta.key, + Exit.succeed( + new HttpSession.SessionMeta({ + createdAt: DateTime.subtract(now, { minutes: 1 }), + expiresAt: DateTime.subtract(now, { millis: 1 }), + lastRefreshedAt: DateTime.subtract(now, { minutes: 1 }) + }) + ) + ) + yield* staleStore.set(ValueKey, Exit.succeed("stale")) + + const middleware = yield* SessionMiddleware + const response = yield* middleware(Effect.succeed(HttpServerResponse.empty()), {} as any).pipe( + Effect.provideService( + HttpServerRequest.HttpServerRequest, + HttpServerRequest.fromWeb( + new Request("http://localhost/", { + headers: { + cookie: `session_token=${Redacted.value(staleId)}` + } + }) + ) + ), + Effect.provideService(HttpServerRequest.ParsedSearchParams, {}), + Effect.provideService(HttpRouter.RouteContext, { + params: {}, + route: {} as any + }) + ) + + const cookie = Cookies.get(response.cookies, "session_token") + assert.isTrue(cookie !== undefined) + if (cookie !== undefined) { + assert.strictEqual(cookie.value, Redacted.value(freshId)) + } + + const staleMetadata = yield* staleStore.get(HttpSession.SessionMeta.key) + assert.strictEqual(staleMetadata, undefined) + const staleValue = yield* staleStore.get(ValueKey) + assert.strictEqual(staleValue, undefined) + }).pipe( + Effect.provide(HttpApiBuilder.middlewareHttpSession(SessionMiddleware, { + generateSessionId: Effect.succeed(HttpSession.SessionId("fresh-cookie")) + })), + Effect.provide(Persistence.layerMemory) + )) + + it.effect("refreshes metadata at the updateAge threshold boundary", () => + Effect.gen(function*() { + const sessionId = HttpSession.SessionId("refresh") + const session = yield* HttpSession.make({ + getSessionId: Effect.succeed(Option.some(sessionId)), + expiresIn: Duration.minutes(10), + updateAge: Duration.minutes(2) + }) + + let state = yield* session.state + const persistence = yield* Persistence.Persistence + const store = yield* persistence.make({ storeId: toStoreId(state.id) }) + const before = yield* (yield* store.get(HttpSession.SessionMeta.key))! + + yield* TestClock.adjust(Duration.millis(Duration.toMillis(Duration.minutes(2)) - 1)) + yield* session.state + + const beforeBoundary = yield* (yield* store.get(HttpSession.SessionMeta.key))! + assert.strictEqual(beforeBoundary.lastRefreshedAt.epochMillis, before.lastRefreshedAt.epochMillis) + assert.strictEqual(beforeBoundary.expiresAt.epochMillis, before.expiresAt.epochMillis) + + yield* TestClock.adjust(Duration.millis(1)) + yield* session.state + + const atBoundary = yield* (yield* store.get(HttpSession.SessionMeta.key))! + assert.isTrue(atBoundary.lastRefreshedAt.epochMillis > before.lastRefreshedAt.epochMillis) + assert.isTrue(atBoundary.expiresAt.epochMillis > before.expiresAt.epochMillis) + }).pipe(Effect.provide(Persistence.layerMemory))) + + it.effect("does not refresh metadata when refresh is disabled", () => + Effect.gen(function*() { + const sessionId = HttpSession.SessionId("no-refresh") + const session = yield* HttpSession.make({ + getSessionId: Effect.succeed(Option.some(sessionId)), + expiresIn: Duration.minutes(10), + updateAge: Duration.minutes(1), + disableRefresh: true + }) + + let state = yield* session.state + const persistence = yield* Persistence.Persistence + const store = yield* persistence.make({ storeId: toStoreId(state.id) }) + const before = yield* (yield* store.get(HttpSession.SessionMeta.key))! + + yield* TestClock.adjust(Duration.minutes(5)) + yield* session.state + + const after = yield* (yield* store.get(HttpSession.SessionMeta.key))! + assert.strictEqual(after.lastRefreshedAt.epochMillis, before.lastRefreshedAt.epochMillis) + assert.strictEqual(after.expiresAt.epochMillis, before.expiresAt.epochMillis) + }).pipe(Effect.provide(Persistence.layerMemory))) + + it.effect("clamps updateAge to expiresIn", () => + Effect.gen(function*() { + const sessionId = HttpSession.SessionId("clamp") + const persistence = yield* Persistence.Persistence + const now = yield* DateTime.now + const previousLastRefreshedAt = DateTime.subtract(now, { minutes: 2 }) + const store = yield* persistence.make({ storeId: toStoreId(sessionId) }) + + yield* store.set( + HttpSession.SessionMeta.key, + Exit.succeed( + new HttpSession.SessionMeta({ + createdAt: DateTime.subtract(now, { minutes: 10 }), + expiresAt: DateTime.add(now, { minutes: 10 }), + lastRefreshedAt: previousLastRefreshedAt + }) + ) + ) + + const session = yield* HttpSession.make({ + getSessionId: Effect.succeed(Option.some(sessionId)), + expiresIn: Duration.minutes(1), + updateAge: Duration.minutes(5) + }) + + let state = yield* session.state + assert.strictEqual(Redacted.value(state.id), Redacted.value(sessionId)) + + const after = yield* (yield* store.get(HttpSession.SessionMeta.key))! + assert.isTrue(after.lastRefreshedAt.epochMillis > previousLastRefreshedAt.epochMillis) + assert.isTrue(after.expiresAt.epochMillis <= now.epochMillis + Duration.toMillis(Duration.minutes(2))) + }).pipe(Effect.provide(Persistence.layerMemory))) + + it.effect("bounds data key ttl to metadata expiration horizon", () => + Effect.gen(function*() { + const sessionId = HttpSession.SessionId("ttl") + const session = yield* HttpSession.make({ + getSessionId: Effect.succeed(Option.some(sessionId)), + expiresIn: Duration.minutes(2), + disableRefresh: true + }) + + yield* session.set(ValueKey, "value") + + let state = yield* session.state + const persistence = yield* Persistence.Persistence + const store = yield* persistence.make({ storeId: toStoreId(state.id) }) + const before = yield* store.get(ValueKey) + assert.isTrue(before !== undefined && before._tag === "Success") + + yield* TestClock.adjust(Duration.minutes(2)) + + const after = yield* store.get(ValueKey) + assert.strictEqual(after, undefined) + }).pipe(Effect.provide(Persistence.layerMemory))) + + it.effect("clearCookie writes explicit expiry and max-age headers", () => + Effect.gen(function*() { + const session = yield* HttpSession.make({ + getSessionId: Effect.succeed(Option.none()), + generateSessionId: Effect.succeed(HttpSession.SessionId("helper-clear")), + cookie: { + name: "session_token" + } + }) + + const response = yield* HttpSession.clearCookie(session, HttpServerResponse.empty()) + + const cookie = Cookies.get(response.cookies, "session_token") + assert.isTrue(cookie !== undefined) + if (cookie !== undefined) { + assert.strictEqual(cookie.value, "") + assert.strictEqual(cookie.options?.maxAge, 0) + assert.strictEqual(cookie.options?.expires?.getTime(), 0) + + const header = Cookies.serializeCookie(cookie) + assert.isTrue(header.includes("Max-Age=0")) + assert.isTrue(header.includes("Expires=Thu, 01 Jan 1970 00:00:00 GMT")) + } + }).pipe(Effect.provide(Persistence.layerMemory))) + + it.effect("clearCookie keeps path and domain for cookie matching", () => + Effect.gen(function*() { + const session = yield* HttpSession.make({ + getSessionId: Effect.succeed(Option.none()), + generateSessionId: Effect.succeed(HttpSession.SessionId("helper-clear-match")), + cookie: { + name: "session_token", + path: "/app", + domain: "example.com" + } + }) + + const response = yield* HttpSession.clearCookie(session, HttpServerResponse.empty()) + + const cookie = Cookies.get(response.cookies, "session_token") + assert.isTrue(cookie !== undefined) + if (cookie !== undefined) { + assert.strictEqual(cookie.options?.path, "/app") + assert.strictEqual(cookie.options?.domain, "example.com") + + const header = Cookies.serializeCookie(cookie) + assert.isTrue(header.includes("Path=/app")) + assert.isTrue(header.includes("Domain=example.com")) + } + }).pipe(Effect.provide(Persistence.layerMemory))) + + it.effect("securityMakeSession decodes session id from cookie api key", () => + Effect.gen(function*() { + const queryId = HttpSession.SessionId("query-security") + const headerId = HttpSession.SessionId("header-security") + const cookieId = HttpSession.SessionId("cookie-security") + const generatedId = HttpSession.SessionId("generated-security") + const now = yield* DateTime.now + const persistence = yield* Persistence.Persistence + + const setMetadata = (sessionId: HttpSession.SessionId) => + Effect.flatMap( + persistence.make({ storeId: toStoreId(sessionId) }), + (store) => + store.set( + HttpSession.SessionMeta.key, + Exit.succeed( + new HttpSession.SessionMeta({ + createdAt: DateTime.subtract(now, { minutes: 1 }), + expiresAt: DateTime.add(now, { minutes: 10 }), + lastRefreshedAt: DateTime.subtract(now, { minutes: 1 }) + }) + ) + ) + ) + + yield* setMetadata(queryId) + yield* setMetadata(headerId) + yield* setMetadata(cookieId) + + const makeSession = (request: Request, searchParams: any) => + HttpApiBuilder.securityMakeSession(SessionMiddleware.security, { + generateSessionId: Effect.succeed(generatedId) + }).pipe( + Effect.provideService(HttpServerRequest.HttpServerRequest, HttpServerRequest.fromWeb(request)), + Effect.provideService(HttpServerRequest.ParsedSearchParams, searchParams) + ) + + const fromHeaderOrQuery = yield* makeSession( + new Request(`http://localhost/?session_token=${Redacted.value(queryId)}`, { + headers: { + session_token: Redacted.value(headerId) + } + }), + { + session_token: Redacted.value(queryId) + } + ) + const fromHeaderOrQueryState = yield* fromHeaderOrQuery.state + assert.strictEqual(Redacted.value(fromHeaderOrQueryState.id), Redacted.value(generatedId)) + + const fromCookie = yield* makeSession( + new Request(`http://localhost/?session_token=${Redacted.value(queryId)}`, { + headers: { + session_token: Redacted.value(headerId), + cookie: `session_token=${Redacted.value(cookieId)}` + } + }), + { + session_token: Redacted.value(queryId) + } + ) + const fromCookieState = yield* fromCookie.state + assert.strictEqual(Redacted.value(fromCookieState.id), Redacted.value(cookieId)) + }).pipe(Effect.provide(Persistence.layerMemory))) + + it.effect("session middleware security is ApiKey in cookie mode", () => + Effect.sync(() => { + assert.strictEqual(SessionMiddleware.security._tag, "ApiKey") + assert.strictEqual(SessionMiddleware.security.in, "cookie") + assert.strictEqual(SessionMiddleware.security.key, "session_token") + })) + + it.effect("middleware refreshes cookie when metadata refreshes without id changes", () => + Effect.gen(function*() { + const sessionId = HttpSession.SessionId("middleware-refresh") + const middleware = yield* SessionMiddleware + + const run = (cookie: string) => + middleware(Effect.succeed(HttpServerResponse.empty()), {} as any).pipe( + Effect.provideService( + HttpServerRequest.HttpServerRequest, + HttpServerRequest.fromWeb( + new Request("http://localhost/", { + headers: { + cookie: `session_token=${cookie}` + } + }) + ) + ), + Effect.provideService(HttpServerRequest.ParsedSearchParams, {}), + Effect.provideService(HttpRouter.RouteContext, { + params: {}, + route: {} as any + }) + ) + + const first = yield* run("stale") + const firstCookie = Cookies.get(first.cookies, "session_token") + assert.isTrue(firstCookie !== undefined) + if (firstCookie !== undefined) { + assert.strictEqual(firstCookie.value, Redacted.value(sessionId)) + } + + yield* TestClock.adjust(Duration.minutes(2)) + + const second = yield* run(Redacted.value(sessionId)) + const secondCookie = Cookies.get(second.cookies, "session_token") + assert.isTrue(secondCookie !== undefined) + if (secondCookie !== undefined) { + assert.strictEqual(secondCookie.value, Redacted.value(sessionId)) + } + }).pipe( + Effect.provide(HttpApiBuilder.middlewareHttpSession(SessionMiddleware, { + generateSessionId: Effect.succeed(HttpSession.SessionId("middleware-refresh")), + expiresIn: Duration.minutes(10), + updateAge: Duration.minutes(2) + })), + Effect.provide(Persistence.layerMemory) + )) + + it.effect("middleware sets refreshed cookie when threshold is crossed during handler", () => + Effect.gen(function*() { + const sessionId = HttpSession.SessionId("middleware-refresh-during-handler") + const middleware = yield* SessionMiddleware + + const run = (cookie: string, refreshDuringHandler: boolean) => + middleware( + Effect.gen(function*() { + if (refreshDuringHandler) { + yield* TestClock.adjust(Duration.minutes(1)) + } + return HttpServerResponse.empty() + }), + {} as any + ).pipe( + Effect.provideService( + HttpServerRequest.HttpServerRequest, + HttpServerRequest.fromWeb( + new Request("http://localhost/", { + headers: { + cookie: `session_token=${cookie}` + } + }) + ) + ), + Effect.provideService(HttpServerRequest.ParsedSearchParams, {}), + Effect.provideService(HttpRouter.RouteContext, { + params: {}, + route: {} as any + }) + ) + + const first = yield* run("stale", false) + const firstCookie = Cookies.get(first.cookies, "session_token") + assert.isTrue(firstCookie !== undefined) + if (firstCookie !== undefined) { + assert.strictEqual(firstCookie.value, Redacted.value(sessionId)) + } + + const second = yield* run(Redacted.value(sessionId), true) + const secondCookie = Cookies.get(second.cookies, "session_token") + assert.isTrue(secondCookie !== undefined) + if (secondCookie !== undefined) { + assert.strictEqual(secondCookie.value, Redacted.value(sessionId)) + } + }).pipe( + Effect.provide(HttpApiBuilder.middlewareHttpSession(SessionMiddleware, { + generateSessionId: Effect.succeed(HttpSession.SessionId("middleware-refresh-during-handler")), + expiresIn: Duration.minutes(10), + updateAge: Duration.minutes(1) + })), + Effect.provide(Persistence.layerMemory) + )) + + it.effect("middleware clears cookie when clear fails but request handles the error", () => + Effect.gen(function*() { + const sessionId = HttpSession.SessionId("middleware-clear") + const basePersistence = yield* Persistence.Persistence + const wrappedPersistence = Persistence.Persistence.of({ + make: (options) => + Effect.map( + basePersistence.make(options), + (store) => + options.storeId === toStoreId(sessionId) + ? { + ...store, + clear: Effect.fail(new Persistence.PersistenceError({ message: "clear failed" })) + } + : store + ) + }) + + const response = yield* SessionMiddleware.use((middleware) => + middleware( + Effect.gen(function*() { + const session = yield* HttpSession.HttpSession + yield* Effect.ignore(session.clear) + return HttpServerResponse.empty() + }) as any, + {} as any + ) + ).pipe( + Effect.provideService( + HttpServerRequest.HttpServerRequest, + HttpServerRequest.fromWeb( + new Request("http://localhost/", { + headers: { + cookie: `session_token=${Redacted.value(sessionId)}` + } + }) + ) + ), + Effect.provideService(HttpServerRequest.ParsedSearchParams, {}), + Effect.provideService(HttpRouter.RouteContext, { + params: {}, + route: {} as any + }), + Effect.provide(HttpApiBuilder.middlewareHttpSession(SessionMiddleware, { + generateSessionId: Effect.succeed(sessionId) + })), + Effect.provideService(Persistence.Persistence, wrappedPersistence) + ) + + const cookie = Cookies.get(response.cookies, "session_token") + assert.isTrue(cookie !== undefined) + if (cookie !== undefined) { + assert.strictEqual(cookie.value, "") + assert.strictEqual(cookie.options?.maxAge, 0) + assert.strictEqual(cookie.options?.expires?.getTime(), 0) + } + }).pipe(Effect.provide(Persistence.layerMemory))) + + it.effect("never computes negative ttl for session writes", () => + Effect.gen(function*() { + let current = 0 + const clock: Clock.Clock = { + currentTimeMillisUnsafe: () => { + current += 2 + return current + }, + currentTimeMillis: Effect.sync(() => { + current += 2 + return current + }), + currentTimeNanosUnsafe: () => { + current += 2 + return BigInt(current) * 1_000_000n + }, + currentTimeNanos: Effect.sync(() => { + current += 2 + return BigInt(current) * 1_000_000n + }), + sleep: () => Effect.void + } + + const persistence = Persistence.Persistence.of({ + make: ({ timeToLive }) => + Effect.succeed({ + get: () => Effect.undefined, + getMany: () => Effect.succeed([]), + set: (key, value) => + Effect.sync(() => { + const ttl = Duration.toMillis( + Duration.fromDurationInputUnsafe(timeToLive?.(value, key) ?? Duration.infinity) + ) + assert.isTrue(ttl >= 0) + }), + setMany: (entries) => + Effect.sync(() => { + for (const [key, value] of entries) { + const ttl = Duration.toMillis( + Duration.fromDurationInputUnsafe(timeToLive?.(value, key) ?? Duration.infinity) + ) + assert.isTrue(ttl >= 0) + } + }), + remove: () => Effect.void, + clear: Effect.void + }) + }) + + const session = yield* HttpSession.make({ + getSessionId: Effect.succeed(Option.none()), + expiresIn: Duration.millis(1), + disableRefresh: true, + generateSessionId: Effect.succeed(HttpSession.SessionId("ttl-bound")) + }).pipe( + Effect.provideService(Clock.Clock, clock), + Effect.provideService(Persistence.Persistence, persistence) + ) + + yield* session.set(ValueKey, "value") + })) +}) diff --git a/packages/platform-node/package.json b/packages/platform-node/package.json index 864395d084..f3478cbed8 100644 --- a/packages/platform-node/package.json +++ b/packages/platform-node/package.json @@ -65,7 +65,7 @@ "dependencies": { "@effect/platform-node-shared": "workspace:^", "mime": "^4.1.0", - "undici": "^7.20.0" + "undici": "^7.21.0" }, "peerDependencies": { "effect": "workspace:^", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a4d25067b..5da3aa90b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,8 +38,8 @@ importers: specifier: https://pkg.pr.new/Effect-TS/docgen/@effect/docgen@e7fe055 version: https://pkg.pr.new/Effect-TS/docgen/@effect/docgen@e7fe055(tsx@4.21.0)(typescript@5.9.3) '@effect/language-service': - specifier: ^0.72.0 - version: 0.72.0 + specifier: ^0.73.1 + version: 0.73.1 '@effect/oxc': specifier: workspace:^ version: link:packages/tools/oxc @@ -378,8 +378,8 @@ importers: specifier: ^4.1.0 version: 4.1.0 undici: - specifier: ^7.20.0 - version: 7.20.0 + specifier: ^7.21.0 + version: 7.21.0 devDependencies: '@testcontainers/mysql': specifier: ^11.11.0 @@ -902,6 +902,10 @@ packages: resolution: {integrity: sha512-vSH118/wwM/pLR38g/Sgk05sNtro6TlTJKuiMXDaZqPUfjTFcudpCOt00IhOfj+1BFAX+UFAlzCU+6WXr3GLFQ==} engines: {node: '>=6.9.0'} + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + '@babel/helper-annotate-as-pure@7.27.3': resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} engines: {node: '>=6.9.0'} @@ -1402,8 +1406,8 @@ packages: tsx: ^4.19.3 typescript: ^5.8.2 - '@effect/language-service@0.72.0': - resolution: {integrity: sha512-MWkyTPCXSs5Q3OIBWR3q24SA+ipkdWW7EBJBt6EPUzlzZxjJLXtLBhXpMoCFheSEM0FTWOHT4BRLh5lufsmjVw==} + '@effect/language-service@0.73.1': + resolution: {integrity: sha512-FbOKzXmP1QM6/YMvDFZGTZ0Gk0AjKqRSY48dpAN0zUdVzq5xV/Us/6vZ5FcdmG6GjgSWP2rpESy59OMvfPeylw==} hasBin: true '@effect/markdown-toc@0.1.0': @@ -2367,8 +2371,8 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} - '@sinclair/typebox@0.27.8': - resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + '@sinclair/typebox@0.27.10': + resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} '@sindresorhus/is@7.2.0': resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} @@ -2521,6 +2525,9 @@ packages: '@types/node@25.2.0': resolution: {integrity: sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==} + '@types/node@25.2.3': + resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==} + '@types/pg-cursor@2.7.2': resolution: {integrity: sha512-m3xT8bVFCvx98LuzbvXyuCdT/Hjdd/v8ml4jL4K1QF70Y8clOfCFdgoaEB1FWdcSwcpoFYZTJQaMD9/GQ27efQ==} @@ -3841,6 +3848,9 @@ packages: get-tsconfig@4.13.1: resolution: {integrity: sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w==} + get-tsconfig@4.13.6: + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} @@ -5512,6 +5522,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + send@0.19.2: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} @@ -6058,8 +6073,8 @@ packages: resolution: {integrity: sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==} engines: {node: '>=20.18.1'} - undici@7.20.0: - resolution: {integrity: sha512-MJZrkjyd7DeC+uPZh+5/YaMDxFiiEEaDgbUSVMXayofAkDWF1088CDo+2RPg7B1BuS1qf1vgNE7xqwPxE0DuSQ==} + undici@7.21.0: + resolution: {integrity: sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==} engines: {node: '>=20.18.1'} unicorn-magic@0.3.0: @@ -6676,6 +6691,14 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + '@babel/helper-annotate-as-pure@7.27.3': dependencies: '@babel/types': 7.29.0 @@ -7269,7 +7292,7 @@ snapshots: tsx: 4.21.0 typescript: 5.9.3 - '@effect/language-service@0.72.0': {} + '@effect/language-service@0.73.1': {} '@effect/markdown-toc@0.1.0': dependencies: @@ -7542,21 +7565,21 @@ snapshots: dependencies: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 25.2.0 + '@types/node': 25.2.3 jest-mock: 29.7.0 '@jest/fake-timers@29.7.0': dependencies: '@jest/types': 29.6.3 '@sinonjs/fake-timers': 10.3.0 - '@types/node': 25.2.0 + '@types/node': 25.2.3 jest-message-util: 29.7.0 jest-mock: 29.7.0 jest-util: 29.7.0 '@jest/schemas@29.6.3': dependencies: - '@sinclair/typebox': 0.27.8 + '@sinclair/typebox': 0.27.10 '@jest/transform@29.7.0': dependencies: @@ -7583,7 +7606,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 25.2.0 + '@types/node': 25.2.3 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -7964,7 +7987,7 @@ snapshots: metro: 0.83.3 metro-config: 0.83.3 metro-core: 0.83.3 - semver: 7.7.3 + semver: 7.7.4 transitivePeerDependencies: - bufferutil - supports-color @@ -8144,7 +8167,7 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} - '@sinclair/typebox@0.27.8': {} + '@sinclair/typebox@0.27.10': {} '@sindresorhus/is@7.2.0': {} @@ -8303,7 +8326,7 @@ snapshots: '@types/graceful-fs@4.1.9': dependencies: - '@types/node': 25.2.0 + '@types/node': 25.2.3 '@types/ini@4.1.1': {} @@ -8332,6 +8355,10 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/node@25.2.3': + dependencies: + undici-types: 7.16.0 + '@types/pg-cursor@2.7.2': dependencies: '@types/node': 25.2.0 @@ -9008,7 +9035,7 @@ snapshots: chrome-launcher@0.15.2: dependencies: - '@types/node': 25.2.0 + '@types/node': 25.2.3 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -9017,7 +9044,7 @@ snapshots: chromium-edge-launcher@0.2.0: dependencies: - '@types/node': 25.2.0 + '@types/node': 25.2.3 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -9601,7 +9628,7 @@ snapshots: fuse.js: 7.1.0 mcp-proxy: 5.12.5 strict-event-emitter-types: 2.0.0 - undici: 7.20.0 + undici: 7.21.0 uri-templates: 0.2.0 xsschema: 0.4.0-beta.5(@valibot/to-json-schema@1.5.0(valibot@1.2.0(typescript@5.9.3)))(arktype@2.1.29)(effect@3.19.14)(sury@11.0.0-alpha.4)(zod-to-json-schema@3.25.1(zod@4.3.6))(zod@4.3.6) yargs: 18.0.0 @@ -9819,6 +9846,10 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + get-tsconfig@4.13.6: + dependencies: + resolve-pkg-maps: 1.0.0 + github-from-package@0.0.0: {} glob-parent@5.1.2: @@ -10172,7 +10203,7 @@ snapshots: '@jest/environment': 29.7.0 '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 25.2.0 + '@types/node': 25.2.3 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -10182,7 +10213,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.9 - '@types/node': 25.2.0 + '@types/node': 25.2.3 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -10209,7 +10240,7 @@ snapshots: jest-mock@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 25.2.0 + '@types/node': 25.2.3 jest-util: 29.7.0 jest-regex-util@29.6.3: {} @@ -10217,7 +10248,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 25.2.0 + '@types/node': 25.2.3 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -10234,7 +10265,7 @@ snapshots: jest-worker@29.7.0: dependencies: - '@types/node': 25.2.0 + '@types/node': 25.2.3 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -10641,7 +10672,7 @@ snapshots: metro-transform-plugins@0.83.3: dependencies: '@babel/core': 7.29.0 - '@babel/generator': 7.29.0 + '@babel/generator': 7.29.1 '@babel/template': 7.28.6 '@babel/traverse': 7.29.0 flow-enums-runtime: 0.0.6 @@ -10652,7 +10683,7 @@ snapshots: metro-transform-worker@0.83.3: dependencies: '@babel/core': 7.29.0 - '@babel/generator': 7.29.0 + '@babel/generator': 7.29.1 '@babel/parser': 7.29.0 '@babel/types': 7.29.0 flow-enums-runtime: 0.0.6 @@ -10673,7 +10704,7 @@ snapshots: dependencies: '@babel/code-frame': 7.29.0 '@babel/core': 7.29.0 - '@babel/generator': 7.29.0 + '@babel/generator': 7.29.1 '@babel/parser': 7.29.0 '@babel/template': 7.28.6 '@babel/traverse': 7.29.0 @@ -11438,7 +11469,7 @@ snapshots: react-refresh: 0.14.2 regenerator-runtime: 0.13.11 scheduler: 0.27.0 - semver: 7.7.3 + semver: 7.7.4 stacktrace-parser: 0.1.11 whatwg-fetch: 3.6.20 ws: 7.5.10 @@ -11711,6 +11742,8 @@ snapshots: semver@7.7.3: {} + semver@7.7.4: {} + send@0.19.2: dependencies: debug: 2.6.9 @@ -12190,7 +12223,7 @@ snapshots: ssh-remote-port-forward: 1.0.4 tar-fs: 3.1.1 tmp: 0.2.5 - undici: 7.20.0 + undici: 7.21.0 transitivePeerDependencies: - bare-abort-controller - bare-buffer @@ -12306,7 +12339,7 @@ snapshots: tsx@4.21.0: dependencies: esbuild: 0.27.2 - get-tsconfig: 4.13.1 + get-tsconfig: 4.13.6 optionalDependencies: fsevents: 2.3.3 @@ -12340,7 +12373,7 @@ snapshots: undici@7.18.2: {} - undici@7.20.0: {} + undici@7.21.0: {} unicorn-magic@0.3.0: {}