UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

363 lines (362 loc) 13.1 kB
import { $hook, $inject, $module, Alepha, AlephaError, KIND, Primitive, createPrimitive } from "alepha"; import { AlephaServer } from "alepha/server"; import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import { deflateRawSync, inflateRawSync } from "node:zlib"; import { SecretProvider } from "alepha/crypto"; import { DateTimeProvider } from "alepha/datetime"; import { $logger } from "alepha/logger"; //#region ../../src/server/cookies/services/CookieParser.ts var CookieParser = class { parseRequestCookies(header) { const cookies = {}; const parts = header.split(";"); for (const part of parts) { const eqIndex = part.indexOf("="); if (eqIndex === -1) continue; const key = part.slice(0, eqIndex).trim(); const value = part.slice(eqIndex + 1).trim(); if (!key || !value) continue; cookies[key] = value; } return cookies; } serializeResponseCookies(cookies, isHttps) { const headers = []; for (const [name, cookie] of Object.entries(cookies)) { if (cookie == null) { headers.push(`${name}=; Path=/; Max-Age=0`); continue; } if (!cookie.value) continue; headers.push(this.cookieToString(name, cookie, isHttps)); } return headers; } cookieToString(name, cookie, isHttps) { const parts = []; parts.push(`${name}=${cookie.value}`); if (cookie.path) parts.push(`Path=${cookie.path}`); if (cookie.maxAge != null) parts.push(`Max-Age=${cookie.maxAge}`); if (cookie.secure !== false && isHttps) parts.push("Secure"); if (cookie.httpOnly) parts.push("HttpOnly"); if (cookie.sameSite) parts.push(`SameSite=${cookie.sameSite}`); if (cookie.domain) parts.push(`Domain=${cookie.domain}`); return parts.join("; "); } }; //#endregion //#region ../../src/server/cookies/providers/ServerCookiesProvider.ts var ServerCookiesProvider = class { alepha = $inject(Alepha); log = $logger(); cookieParser = $inject(CookieParser); dateTimeProvider = $inject(DateTimeProvider); secretProvider = $inject(SecretProvider); ALGORITHM = "aes-256-gcm"; IV_LENGTH = 16; AUTH_TAG_LENGTH = 16; SIGNATURE_LENGTH = 32; onRequest = $hook({ on: "server:onRequest", handler: ({ request }) => { request.cookies = { req: this.cookieParser.parseRequestCookies(request.headers.cookie ?? ""), res: {} }; } }); onAction = $hook({ on: "action:onRequest", handler: ({ request }) => { request.cookies = { req: this.cookieParser.parseRequestCookies(request.headers.cookie ?? ""), res: {} }; } }); onSend = $hook({ on: "server:onSend", handler: ({ request }) => { if (request.cookies && Object.keys(request.cookies.res).length > 0) { const setCookieHeaders = this.cookieParser.serializeResponseCookies(request.cookies.res, request.url.protocol === "https:"); if (setCookieHeaders.length > 0) request.reply.headers["set-cookie"] = setCookieHeaders; } } }); getCookiesFromContext(cookies) { const contextCookies = this.alepha.store.get("alepha.http.request")?.cookies; if (cookies) return cookies; if (contextCookies) return contextCookies; throw new AlephaError("Cookie context is not available. This method must be called within a server request cycle."); } /** * Namespaces a cookie name with the app's `APP_NAME` (lowercased) so that * two Alepha apps sharing a cookie jar do not collide. * * Cookies are scoped by host + path only — never by port — so two apps on * `localhost:3000` and `localhost:4000` would otherwise both read/write a * cookie literally named `tokens` and silently clobber each other (and, * because each encrypts with its own `APP_SECRET`, the foreign cookie fails * to decrypt and gets deleted, logging the user out). * * When `APP_NAME` is unset the name is returned unchanged, so existing * single-app deployments keep their current cookie names. Same convention as * the `APP_NAME` prefix used by R2 storage and server-timing. * * Pass `prefix = false` to skip the namespace entirely — used for * `persist: "cookie"` atoms, whose browser-side counterpart cannot see * `APP_NAME` and therefore always uses the bare name. See * `CookiePrimitiveOptions.prefix`. */ prefixName(name, prefix = true) { if (!prefix) return name; const app = this.alepha.env.APP_NAME; if (!app) return name; return `${String(app).toLowerCase()}.${name}`; } getCookie(name, options, contextCookies) { const cookies = this.getCookiesFromContext(contextCookies); const prefixed = options.prefix !== false; let rawValue = cookies.req[this.prefixName(name, prefixed)]; if (!rawValue) return void 0; try { rawValue = decodeURIComponent(rawValue); if (options.sign) { const signature = rawValue.substring(0, this.SIGNATURE_LENGTH * 2); const value = rawValue.substring(this.SIGNATURE_LENGTH * 2); const expectedSignature = this.sign(value); if (!timingSafeEqual(Buffer.from(signature, "hex"), Buffer.from(expectedSignature, "hex"))) { this.log.warn(`Invalid signature for cookie "${name}".`); return; } rawValue = value; } if (options.encrypt) rawValue = this.decrypt(rawValue); if (options.compress) rawValue = inflateRawSync(Buffer.from(rawValue, "base64")).toString("utf8"); return this.alepha.codec.decode(options.schema, JSON.parse(rawValue)); } catch (error) { this.log.warn(`Failed to parse cookie "${name}"`, error); this.deleteCookie(name, cookies, prefixed); return; } } setCookie(name, options, data, contextCookies) { const cookies = this.getCookiesFromContext(contextCookies); let value = JSON.stringify(this.alepha.codec.decode(options.schema, data)); if (options.compress) value = deflateRawSync(value).toString("base64"); if (options.encrypt) value = this.encrypt(value); if (options.sign) value = this.sign(value) + value; const cookie = { value: encodeURIComponent(value), path: options.path ?? "/", sameSite: options.sameSite ?? "lax", secure: options.secure ?? this.alepha.isProduction(), httpOnly: options.httpOnly, domain: options.domain }; if (options.ttl) cookie.maxAge = this.dateTimeProvider.duration(options.ttl).as("seconds"); cookies.res[this.prefixName(name, options.prefix !== false)] = cookie; } deleteCookie(name, contextCookies, prefix = true) { const cookies = this.getCookiesFromContext(contextCookies); cookies.res[this.prefixName(name, prefix)] = null; } encrypt(text) { const iv = randomBytes(this.IV_LENGTH); const cipher = createCipheriv(this.ALGORITHM, this.deriveKey(), iv); const encrypted = Buffer.concat([cipher.update(text, "utf8"), cipher.final()]); const authTag = cipher.getAuthTag(); return Buffer.concat([ iv, authTag, encrypted ]).toString("base64"); } decrypt(encryptedText) { const data = Buffer.from(encryptedText, "base64"); const iv = data.subarray(0, this.IV_LENGTH); const authTag = data.subarray(this.IV_LENGTH, this.IV_LENGTH + this.AUTH_TAG_LENGTH); const encrypted = data.subarray(this.IV_LENGTH + this.AUTH_TAG_LENGTH); const decipher = createDecipheriv(this.ALGORITHM, this.deriveKey(), iv); decipher.setAuthTag(authTag); return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8"); } /** * Derives a 32-byte key from APP_SECRET via SHA-256. * Accepts any string length — no padding or truncation needed. */ deriveKey() { return createHash("sha256").update(this.secretProvider.secretKey).digest(); } sign(data) { return createHmac("sha256", this.deriveKey()).update(data).digest("hex"); } }; //#endregion //#region ../../src/server/cookies/primitives/$cookie.ts /** * Declares a type-safe, configurable HTTP cookie. * This primitive provides methods to get, set, and delete the cookie * within the server request/response cycle. */ const $cookie = (options, extendedOptions) => { if (extendedOptions) options = { key: options.key, schema: options.schema, ...extendedOptions }; return createPrimitive(CookiePrimitive, options); }; var CookiePrimitive = class extends Primitive { serverCookiesProvider = $inject(ServerCookiesProvider); onInit() { if (this.options.key) { this.alepha.events.on("state:mutate", ({ key, value }) => { if (key === this.options.key) this.set(value); }); this.alepha.events.on("server:onRequest", ({ request }) => { try { const value = this.get(request); if (value !== void 0) this.alepha.store.set(this.options.key, value, { skipEvents: true }); } catch {} }); } } get schema() { return this.options.schema; } get name() { return this.options.name ?? `${this.config.propertyKey}`; } /** * Sets the cookie with the given value in the current request's response. */ set(value, options) { this.serverCookiesProvider.setCookie(this.name, { ...this.options, ttl: options?.ttl ?? this.options.ttl }, value, options?.cookies); } /** * Gets the cookie value from the current request. Returns undefined if not found or invalid. */ get(options) { return this.serverCookiesProvider.getCookie(this.name, this.options, options?.cookies); } /** * Deletes the cookie in the current request's response. */ del(options) { this.serverCookiesProvider.deleteCookie(this.name, options?.cookies); } }; $cookie[KIND] = CookiePrimitive; //#endregion //#region ../../src/server/cookies/providers/AtomCookiePersistence.ts /** * Binds every atom declared with `persist: "cookie"` to an HTTP cookie. * * - `server:onRequest` — seeds the request-scoped state from the cookie, so * SSR renders with the persisted value. * - `state:mutate` — writes the new value back as a Set-Cookie header. * - `state:register` — an atom registering lazily *during* a request (first * touched by an SSR render, after `server:onRequest` already ran) reads * its cookie right away, so that render still sees the persisted value. * * Atoms are resolved from the `StateManager` registry * (`alepha.store.listAtoms()`) on every request and every mutation, never * from a map built up from `state:register` events. That event fires exactly * once per atom and is never replayed, while `$module.register()` registers * `atoms[]` BEFORE it wires `imports[]` and injects `services[]` — so the * documented `$module({ atoms, imports: [AlephaServerCookies], services })` * shape registers every atom before this provider exists. An event-sourced * map would stay empty forever there, making `persist: "cookie"` a silent * no-op in both directions. Reading the registry on demand is * order-independent. * * The cookie is named after the atom key, lives 365 days, SameSite=lax, * path "/". For custom cookie options (encryption, signing, custom TTL), * declare an explicit `$cookie({ key: atom.key, ... })` binding instead. */ var AtomCookiePersistence = class { alepha = $inject(Alepha); log = $logger(); serverCookies = $inject(ServerCookiesProvider); onRequest = $hook({ on: "server:onRequest", after: [this.serverCookies], handler: ({ request }) => { for (const atom of this.cookieAtoms()) this.read(atom, request.cookies); } }); onRegister = $hook({ on: "state:register", handler: ({ atom }) => { if (atom.options.persist !== "cookie") return; if (this.alepha.store.get("alepha.http.request")) this.read(atom); } }); onMutate = $hook({ on: "state:mutate", handler: ({ key, value }) => { const atom = this.alepha.store.getAtom(String(key)); if (!atom || atom.options.persist !== "cookie") return; try { this.serverCookies.setCookie(atom.key, this.cookieOptions(atom), value); } catch (error) { this.log.debug(`Cannot persist atom "${atom.key}" to cookie`, { error }); } } }); /** * Every registered atom declared with `persist: "cookie"`, read live from * the state manager's registry. */ cookieAtoms() { return this.alepha.store.listAtoms().filter((atom) => atom.options.persist === "cookie"); } read(atom, cookies) { if (!this.alepha.context.exists()) { this.log.debug(`Skipping cookie read for atom "${atom.key}": no active request context.`); return; } try { const value = this.serverCookies.getCookie(atom.key, this.cookieOptions(atom), cookies); if (value !== void 0) this.alepha.store.set(atom.key, value, { skipEvents: true }); } catch (error) { this.log.debug(`Cannot read cookie for atom "${atom.key}"`, { error }); } } cookieOptions(atom) { return { schema: atom.schema, path: "/", sameSite: "lax", ttl: [365, "days"], prefix: false }; } }; //#endregion //#region ../../src/server/cookies/index.ts /** * Server and browser-safe cookie handling. * * **Features:** * - Cookie management on server and browser * * @module alepha.server.cookies */ const AlephaServerCookies = $module({ name: "alepha.server.cookies", primitives: [$cookie], services: [ AlephaServer, ServerCookiesProvider, CookieParser, AtomCookiePersistence ] }); //#endregion export { $cookie, AlephaServerCookies, AtomCookiePersistence, CookiePrimitive, ServerCookiesProvider }; //# sourceMappingURL=index.js.map