alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
363 lines (362 loc) • 13.1 kB
JavaScript
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