import { Schema } from 'effect'; /** * Fail-closed check that a real session secret is configured. Throws a plain * `Error` (a fatal boot condition, not a wire error) when * `VOLTRO_SESSION_SECRET` is missing or too short. Idempotent — safe to call * from every serve entry. */ export declare const assertProductionSessionSecret: (env?: NodeJS.ProcessEnv) => void; /** * Build a `Set-Cookie` header value. Pure helper — the caller adds it * to the actual HTTP response. Used by the framework's login route * pattern + by user code that wants to set its own cookies during a * mutation/action. */ export declare const buildSetCookie: (name: string, value: string, options?: SetCookieOptions) => string; /** * Check an `Authorization: Bearer ` header against an expected token. * * - `expected` unset/empty → `false`, UNLESS the caller passes * `{ openWhenUnset: true }`. See below. * - header absent, or not using the `Bearer ` scheme → `false`. * - presented token compared to `expected` in CONSTANT TIME. * * `headers` keys may be any casing (`authorization` / `Authorization`). * * **Why the default is closed.** It used to be open: an unset or empty * `expected` returned `true`, on the reading that "no token configured" meant * the caller chose not to gate the surface. That reading is only available to * the caller, and one of them declared the opposite — `scimPlugin`'s `token` is * a REQUIRED `string`, and `token: process.env.SCIM_TOKEN ?? ''` (the shape * anyone writes) silently turned the gate off. The result was SCIM 2.0 Users * and Groups readable with no credentials: a full directory dump plus the * provisioning surface that can deactivate accounts. The failure was silent and * likeliest exactly where it hurts — an env var set in production and unset in * a preview environment or a fresh cluster. * * A two-argument helper cannot know its caller's intent, so it must not assume * the permissive one. `openWhenUnset` makes the choice appear at the call site, * where the caller who genuinely wants an ungated endpoint says so and the * caller who forgot gets a 401 instead of an open door. */ export declare const checkBearer: (headers: Readonly>, expected: string | undefined, options?: { readonly openWhenUnset?: boolean; }) => boolean; /** Key id assigned to `VOLTRO_SESSION_SECRET_PREVIOUS` when no explicit * `VOLTRO_SESSION_KID_PREVIOUS` is set. The kid is a non-secret label — * rotation works with just the `_PREVIOUS` secret var. */ export declare const DEFAULT_PREVIOUS_SESSION_KID: "k-previous"; /** Key id stamped for bare-string secrets / when `VOLTRO_SESSION_KID` is * unset. Exported so callers building a `SessionSecrets` from a plain * string produce the same label the resolver would. */ export declare const DEFAULT_SESSION_KID: "k0"; /** A keyed signing secret. `kid` is stamped into the payload on sign and * matched on verify; it's an opaque, non-secret label (e.g. `'2026-06'`). */ export declare interface KeyedSecret { readonly kid: string; readonly secret: string; } /** Minimum length we accept for a session secret. 32 bytes of entropy is the * floor for HMAC-SHA256; shorter values are almost always hand-typed * placeholders, not real secrets. */ export declare const MIN_SESSION_SECRET_LENGTH: 32; /** * Read a cookie value by name from a `Cookie:` header string. * Returns undefined if the cookie isn't present. */ export declare const readCookie: (header: string | undefined, name: string) => string | undefined; /** * Reset the once-per-process warning latch. Tests only — the latch is what * makes the diagnostic readable in production and unobservable in a second * test case. */ export declare const resetSessionSecretWarningLatch: () => void; /** * The same resolution as `resolveSessionSecrets`, but yielding `undefined` * instead of throwing when `VOLTRO_SESSION_SECRET` is unset. * * This exists for callers that were GIVEN a secret explicitly and consult the * environment only for adornment — the `kid` to stamp, and any `previous` key * to also accept during a rotation. For them an unconfigured environment is * simply "no rotation set up", not an error: they already hold everything * needed to sign and verify. Making them go through the throwing resolver * would turn a working explicit-secret setup into a boot failure. */ export declare const resolveOptionalSessionSecrets: () => SessionSecrets | undefined; /** Resolve the HMAC session secret. Throws when unset — there is no fallback. */ export declare const resolveSessionSecret: () => string; /** * Resolve the keyed session-secret set for multi-key rotation. * * - `current` is built from `VOLTRO_SESSION_SECRET` (required — throws * when unset), keyed `VOLTRO_SESSION_KID` (defaults to `'k0'`). * - `previous` is built from `VOLTRO_SESSION_SECRET_PREVIOUS`, keyed * `VOLTRO_SESSION_KID_PREVIOUS` (defaults to `'k-previous'` — the kid * is a non-secret label, so rotation needs only the secret var). * During a rotation window you set the new secret as current and move * the old one into `VOLTRO_SESSION_SECRET_PREVIOUS`; sessions signed * with the old key keep verifying until they expire, then you drop * the `_PREVIOUS` var. * * Sign always uses `current`. Verify tries `current` first, then * `previous` if present. */ export declare const resolveSessionSecrets: () => SessionSecrets; /** * The cookie the framework's own session strategy writes and reads. * * Exported and single-sourced because two readers now need it — the auth * strategy in `dev.ts` and `sessionExpiryFromHeaders` below. Two copies of one * env expression is the "derived twice" shape: both sites look correct and * they disagree the moment someone sets the variable. */ export declare const SESSION_COOKIE_NAME: string; /** * The session payload format this build mints and accepts. * * Bumped when the payload's MEANING changes, not when a field is added — a * reader that ignores an unknown field is fine; a reader that would take a * field to mean something it no longer means is not. `2` dropped `scopes`: * a `1` payload asserted authority, and honouring that assertion is the defect. */ export declare const SESSION_PAYLOAD_VERSION: 2; /** * The verified expiry of the session cookie in `headers`, in unix SECONDS, or * `undefined` when there is no session cookie, it does not verify, or no * session secret is configured (that last case is logged once, loudly — see * the branch below; a security bound that is absent must not be absent * SILENTLY). * * It VERIFIES rather than decoding. An unverified read would be worse than * nothing here: the value bounds how long a subscription may live, so a client * that could forge a far-future `exp` would lift exactly the ceiling this * exists to impose. The cost is one HMAC check on a call that already carries * the cookie. * * A SHARED builder on purpose. `voltro dev` and `voltro serve` assemble their * middleware independently, and a value derived twice is the shape this repo * has been bitten by — dev and serve agreeing on a field while disagreeing on * what it contains. Both call this. */ export declare const sessionExpiryFromHeaders: (headers: Record) => number | undefined; export declare type SessionPayload = typeof SessionPayload_2.Type; declare const SessionPayload_2: Schema.Struct<{ /** Payload format version. Required and pinned — see the header. */ v: Schema.Literal<[2]>; /** WHO, never WHAT-THEY-MAY-DO. `SubjectIdentity` has no `scopes` field. */ subject: Schema.Union<[Schema.Struct<{ id: typeof Schema.String; type: Schema.Literal<["user"]>; tenantId: typeof Schema.String; metadata: Schema.optional>; }>, Schema.Struct<{ id: typeof Schema.String; type: Schema.Literal<["apiKey"]>; tenantId: typeof Schema.String; metadata: Schema.optional>; }>, Schema.Struct<{ id: typeof Schema.String; type: Schema.Literal<["serviceAccount"]>; tenantId: typeof Schema.String; metadata: Schema.optional>; }>, Schema.Struct<{ type: Schema.Literal<["anonymous"]>; id: typeof Schema.Null; tenantId: Schema.NullOr; credentialRejected: Schema.optional; }>, Schema.Struct<{ id: typeof Schema.String; type: Schema.Literal<["system"]>; tenantId: typeof Schema.Null; metadata: Schema.optional>; }>]>; /** Unix seconds when this session expires. Verify rejects past expiry. */ exp: typeof Schema.Number; /** Unix seconds when this session was issued. Drives sliding-window * auto-renewal: verify signals a renew once `now - iat` passes the * renewal threshold (default 70% of the session's lifetime). */ iat: typeof Schema.Number; /** Key id of the signing secret. Stamped from the current key on sign; * read on verify to pick which secret to check first. Absent on * values minted with a bare-string secret (single-key path). */ kid: Schema.optional; }>; declare type SessionPayload = typeof SessionPayload_2.Type; /** The resolved secret set: `current` always signs; `previous` is accepted * on verify during a rotation window. */ export declare interface SessionSecrets { readonly current: KeyedSecret; readonly previous?: KeyedSecret; } export declare interface SetCookieOptions { /** Cookie path. Default `/`. */ readonly path?: string; /** Max-Age in seconds. Omit for a session cookie that clears on * browser close. Set 0 (or a past date) to delete. */ readonly maxAgeSeconds?: number; /** Defaults to true. Block JS access to the cookie. */ readonly httpOnly?: boolean; /** Defaults to 'lax'. Use 'strict' for higher-security flows. */ readonly sameSite?: 'strict' | 'lax' | 'none'; /** Defaults to true. Required when sameSite='none'. */ readonly secure?: boolean; /** Cookie domain. Omit to scope to the current host. */ readonly domain?: string; } export declare interface SignOptions { /** TTL in seconds. Defaults to 7 days. */ readonly ttlSeconds?: number; /** Clock override (epoch milliseconds). Defaults to `Date.now`. A * testing seam — lets suites mint back-dated sessions to exercise the * sliding-window renewal without wall-clock sleeps. */ readonly now?: () => number; } /** * Mint a signed session cookie value for the given subject. Returns the * value you'd put in `Set-Cookie: =; HttpOnly; SameSite=Lax; * Secure; Path=/; Max-Age=`. * * `secret` accepts either a bare string (single-key path — the `kid` is * omitted from the payload) or a `KeyedSecret` (multi-key rotation — the * `kid` is stamped so verify can pick the matching key first). * * The cookie value itself is opaque — clients can read it but can't * forge a new one without the secret. `HttpOnly` blocks JS access for * defence-in-depth; the verify side doesn't care whether the client * could read it. * * **It REFUSES a subject carrying `scopes`.** A cookie cannot carry authority * (see the header), and the two alternatives to refusing are both worse than a * thrown error at the one call site per app that mints a session. Signing them * would reintroduce exactly the defect. Dropping them silently would change * what a caller may do with no error, no log line, and no diff — an * authorization change disguised as a no-op, discovered later as "permissions * randomly stopped working". So: throw, name the subject, name the seam that * replaces it. */ export declare const signSession: (subject: Subject | SubjectIdentity, secret: string | KeyedSecret, options?: SignOptions) => string; declare const Subject: Schema.Union<[Schema.Struct<{ type: Schema.Literal<["user"]>; id: typeof Schema.String; tenantId: typeof Schema.String; scopes: Schema.optional>; metadata: Schema.optional>; }>, Schema.Struct<{ type: Schema.Literal<["apiKey"]>; id: typeof Schema.String; tenantId: typeof Schema.String; scopes: Schema.optional>; metadata: Schema.optional>; }>, Schema.Struct<{ type: Schema.Literal<["serviceAccount"]>; id: typeof Schema.String; tenantId: typeof Schema.String; scopes: Schema.optional>; metadata: Schema.optional>; }>, Schema.Struct<{ type: Schema.Literal<["anonymous"]>; id: typeof Schema.Null; tenantId: Schema.NullOr; /** * Set when this caller PRESENTED a credential and it was rejected — an * expired token above all. Absent when they presented none. * * The two are the same Subject and must not be the same ANSWER. A deployment * measured the cost: a user's tab outlived their IdP's token lifetime, the * strategy logged `supabase jwt expired`, the caller fell through to * anonymous, and the guard then refused with `missing required scope * 'task:u:o'`. Technically true — an anonymous caller holds no scopes — and * it sent everyone who read it into the permissions system while the problem * was an expired session. They did that round. * * It stays a FALLBACK rather than a hard failure on purpose: a stale cookie * must not break an `openAccess` procedure that needs no session at all. The * fact travels, and only a guard that actually refuses spends it. */ credentialRejected: Schema.optional; }>, Schema.Struct<{ type: Schema.Literal<["system"]>; id: typeof Schema.String; tenantId: typeof Schema.Null; scopes: Schema.optional>; metadata: Schema.optional>; }>]>; declare type Subject = typeof Subject.Type; declare const SubjectIdentity: Schema.Union<[Schema.Struct<{ id: typeof Schema.String; type: Schema.Literal<["user"]>; tenantId: typeof Schema.String; metadata: Schema.optional>; }>, Schema.Struct<{ id: typeof Schema.String; type: Schema.Literal<["apiKey"]>; tenantId: typeof Schema.String; metadata: Schema.optional>; }>, Schema.Struct<{ id: typeof Schema.String; type: Schema.Literal<["serviceAccount"]>; tenantId: typeof Schema.String; metadata: Schema.optional>; }>, Schema.Struct<{ type: Schema.Literal<["anonymous"]>; id: typeof Schema.Null; tenantId: Schema.NullOr; /** * Set when this caller PRESENTED a credential and it was rejected — an * expired token above all. Absent when they presented none. * * The two are the same Subject and must not be the same ANSWER. A deployment * measured the cost: a user's tab outlived their IdP's token lifetime, the * strategy logged `supabase jwt expired`, the caller fell through to * anonymous, and the guard then refused with `missing required scope * 'task:u:o'`. Technically true — an anonymous caller holds no scopes — and * it sent everyone who read it into the permissions system while the problem * was an expired session. They did that round. * * It stays a FALLBACK rather than a hard failure on purpose: a stale cookie * must not break an `openAccess` procedure that needs no session at all. The * fact travels, and only a guard that actually refuses spends it. */ credentialRejected: Schema.optional; }>, Schema.Struct<{ id: typeof Schema.String; type: Schema.Literal<["system"]>; tenantId: typeof Schema.Null; metadata: Schema.optional>; }>]>; declare type SubjectIdentity = typeof SubjectIdentity.Type; /** * Constant-time string equality via `timingSafeEqual`. Returns false fast * on a length mismatch (the lengths aren't secret); only equal-length inputs * reach the timing-safe compare, so an attacker can't recover the secret a * byte at a time from the response time. */ export declare const timingSafeStringEqual: (a: string, b: string) => boolean; export declare interface VerifyOptions { /** Fraction of [iat, exp] after which `renew` flips true. Default 0.7. */ readonly renewFraction?: number; /** Clock override (epoch milliseconds). Defaults to `Date.now`. */ readonly now?: () => number; } /** Outcome of a keyed verify. `renew` is true once the session has crossed * the sliding-window threshold — the caller re-issues the cookie so an * active user never gets logged out mid-session. */ export declare interface VerifyResult { /** IDENTITY, not a Subject. The narrow type is the guarantee stated at the * type level: nothing downstream can read authority out of a cookie, * because the value it gets back has no field for it. It is still assignable * to `Subject` wherever one is wanted — a Subject with no scopes. */ readonly subject: SubjectIdentity; readonly renew: boolean; /** The key id that verified the value (current or previous). */ readonly kid: string; /** Unix seconds the verified session expires. */ readonly exp: number; /** Unix seconds the verified session was issued. `exp - iat` is the * session's original lifetime — a renewing caller re-issues with the * same lifetime rather than resetting to the default TTL. */ readonly iat: number; } /** * Verify a session cookie value. Returns the decoded identity on success, * null on any failure (malformed, signature mismatch, expired, or a payload * from a superseded format version). Never throws — callers shouldn't have to * wrap this in try/catch. * * Uses `timingSafeEqual` for the signature comparison so attackers * can't recover bytes one-at-a-time via response-time analysis. * * This is the single-secret entry point and stays a pure * `SubjectIdentity | null`. Use `verifySessionKeyed` for the multi-key + * sliding-window result shape. */ export declare const verifySession: (cookieValue: string, secret: string) => SubjectIdentity | null; /** * Multi-key + sliding-window verify. Tries `secrets.current` first, then * `secrets.previous` (if present). On success returns the Subject, the * `kid` that verified, and a `renew` flag the caller acts on by * re-issuing the cookie (so the session slides forward while in use). * Returns null on any failure. */ export declare const verifySessionKeyed: (cookieValue: string, secrets: SessionSecrets, options?: VerifyOptions) => VerifyResult | null; export { }