feat: centralize application framework primitives
This commit is contained in:
@@ -22,7 +22,20 @@ import {
|
||||
/** Translate a key for the active language, interpolating `{param}` placeholders. */
|
||||
export type TFunction = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
export type Context = {
|
||||
export interface PaginationOptions {
|
||||
defaultLimit?: number;
|
||||
maxLimit?: number;
|
||||
}
|
||||
|
||||
export interface PaginationInput {
|
||||
limit: number;
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
export interface Context<
|
||||
Params extends Record<string, string> = Record<string, string>,
|
||||
User = unknown,
|
||||
> {
|
||||
/** The raw incoming web-standard Request. */
|
||||
req: Request;
|
||||
/** Parsed URL of the request (pathname, query, etc.). */
|
||||
@@ -32,17 +45,19 @@ export type Context = {
|
||||
/** Translate a key for the active language (identity until the runtime sets it). */
|
||||
t: TFunction;
|
||||
/** Dynamic route params, e.g. `/users/[id]` -> `{ id: "42" }`. */
|
||||
params: Record<string, string>;
|
||||
params: Params;
|
||||
/**
|
||||
* Per-request scratch space. Middleware can attach values here
|
||||
* (e.g. the authenticated user) and downstream handlers can read them.
|
||||
*/
|
||||
locals: Record<string, unknown>;
|
||||
/** Resource resolved by a declarative route guard, when present. */
|
||||
resource?: unknown;
|
||||
/**
|
||||
* The authenticated user for this request, or null when anonymous. Populated
|
||||
* by the `sessionAuth` middleware (or `logIn`); read via `getUser(ctx)`.
|
||||
*/
|
||||
user?: unknown;
|
||||
user?: User | null;
|
||||
/** Active tenant/workspace resolved by tenant middleware. */
|
||||
tenant?: Tenant;
|
||||
/** Request tracer installed by observability middleware. */
|
||||
@@ -59,7 +74,9 @@ export type Context = {
|
||||
session: SessionStore;
|
||||
/** Read-only localStorage snapshot sent by the browser for CSR data bindings. */
|
||||
localStorage: LocalStorageSnapshot;
|
||||
};
|
||||
/** Parse and bound the standard `limit` and `cursor` query parameters. */
|
||||
pagination(options?: PaginationOptions): PaginationInput;
|
||||
}
|
||||
|
||||
/** Calls the next middleware in the chain (or the final route handler). */
|
||||
export type Next = () => Promise<Response> | Response;
|
||||
@@ -99,12 +116,15 @@ export type PageMeta = SeoConfig;
|
||||
export type PageComponent = (ctx: Context) => string | Promise<string>;
|
||||
|
||||
/** Create a fresh context for an incoming request. */
|
||||
export function createContext(req: Request, url: URL): Context {
|
||||
export function createContext<Params extends Record<string, string> = Record<string, string>>(
|
||||
req: Request,
|
||||
url: URL,
|
||||
): Context<Params> {
|
||||
const cookies = createCookieStore(req);
|
||||
return {
|
||||
req,
|
||||
url,
|
||||
params: {},
|
||||
params: {} as Params,
|
||||
locals: {},
|
||||
lang: "",
|
||||
t: (key) => key,
|
||||
@@ -113,9 +133,24 @@ export function createContext(req: Request, url: URL): Context {
|
||||
// cookies get `Secure` behind a TLS-terminating proxy (matches CSRF cookies).
|
||||
session: createSessionStore(cookies, req, undefined, url.protocol === "https:"),
|
||||
localStorage: createLocalStorageSnapshot(req),
|
||||
pagination(options = {}) {
|
||||
const defaultLimit = positivePaginationInteger(options.defaultLimit, 25);
|
||||
const maxLimit = positivePaginationInteger(options.maxLimit, 100);
|
||||
const requested = Number(url.searchParams.get("limit"));
|
||||
const limit =
|
||||
Number.isInteger(requested) && requested > 0
|
||||
? Math.min(requested, maxLimit)
|
||||
: Math.min(defaultLimit, maxLimit);
|
||||
const cursor = url.searchParams.get("cursor")?.trim() || undefined;
|
||||
return { limit, cursor };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function positivePaginationInteger(value: number | undefined, fallback: number): number {
|
||||
return Number.isInteger(value) && Number(value) > 0 ? Number(value) : fallback;
|
||||
}
|
||||
|
||||
/** Apply headers accumulated on the context, such as Set-Cookie. */
|
||||
export function withContextHeaders(ctx: Context, res: Response): Response {
|
||||
const headers = new Headers(res.headers);
|
||||
|
||||
@@ -33,6 +33,10 @@ export interface EndpointDefinition<I, O> {
|
||||
input?: SchemaLike<I> | OutputSchemaLike<I>;
|
||||
output?: SchemaLike<O> | OutputSchemaLike<O>;
|
||||
auth?: "optional" | "required";
|
||||
/** Permission checked through an installed @wrnexus/authz middleware. */
|
||||
permission?: string;
|
||||
/** Resource supplied to bound authorization policies. */
|
||||
resource?: (ctx: Context, input: I) => unknown | Promise<unknown>;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
handler(input: I, ctx: Context): O | Promise<O>;
|
||||
@@ -111,6 +115,31 @@ export function defineEndpoint(
|
||||
: await ctx.req.json().catch(() => ({}));
|
||||
input = schemaValue(definition.input, resolvedInput);
|
||||
}
|
||||
if (definition.permission) {
|
||||
const authz = (
|
||||
ctx as Context & {
|
||||
authz?: {
|
||||
decide(
|
||||
permission: string,
|
||||
resource?: unknown,
|
||||
): Promise<{ allowed: boolean; reason?: string }>;
|
||||
};
|
||||
}
|
||||
).authz;
|
||||
if (!authz) {
|
||||
throw new EndpointError(
|
||||
500,
|
||||
"AUTHZ_NOT_CONFIGURED",
|
||||
"Authorization middleware is not configured.",
|
||||
);
|
||||
}
|
||||
const resource = definition.resource ? await definition.resource(ctx, input) : undefined;
|
||||
const decision = await authz.decide(definition.permission, resource);
|
||||
if (!decision.allowed) {
|
||||
throw new EndpointError(403, "FORBIDDEN", "Permission denied.");
|
||||
}
|
||||
ctx.resource = resource;
|
||||
}
|
||||
const rawOutput = await definition.handler(input, ctx);
|
||||
const output = definition.output ? schemaValue(definition.output, rawOutput) : rawOutput;
|
||||
return output instanceof Response ? output : json({ data: output });
|
||||
|
||||
@@ -9,6 +9,8 @@ export type {
|
||||
PageComponent,
|
||||
SeoConfig,
|
||||
TFunction,
|
||||
PaginationOptions,
|
||||
PaginationInput,
|
||||
} from "./context.ts";
|
||||
export { createContext, withContextHeaders } from "./context.ts";
|
||||
export {
|
||||
@@ -127,6 +129,7 @@ export {
|
||||
export type {
|
||||
CookieOptions,
|
||||
CookieStore,
|
||||
TransactionCookie,
|
||||
LocalStorageSnapshot,
|
||||
SessionStore,
|
||||
SessionBackend,
|
||||
|
||||
@@ -17,6 +17,16 @@ export interface CookieStore {
|
||||
set(name: string, value: string, options?: CookieOptions): void;
|
||||
delete(name: string, options?: CookieOptions): void;
|
||||
headers(): string[];
|
||||
transaction<T extends Record<string, unknown> = Record<string, unknown>>(
|
||||
name: string,
|
||||
options?: CookieOptions,
|
||||
): TransactionCookie<T>;
|
||||
}
|
||||
|
||||
export interface TransactionCookie<T extends Record<string, unknown>> {
|
||||
set(value: T): void;
|
||||
consume(): T | undefined;
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
export interface SessionStore {
|
||||
@@ -259,7 +269,7 @@ export function createCookieStore(req: Request): CookieStore {
|
||||
const incoming = parseCookieHeader(req.headers.get("cookie") ?? "");
|
||||
const outgoing: string[] = [];
|
||||
|
||||
return {
|
||||
const store: CookieStore = {
|
||||
get(name) {
|
||||
return incoming[name];
|
||||
},
|
||||
@@ -287,7 +297,45 @@ export function createCookieStore(req: Request): CookieStore {
|
||||
headers() {
|
||||
return [...outgoing];
|
||||
},
|
||||
transaction<T extends Record<string, unknown>>(name: string, options: CookieOptions = {}) {
|
||||
const policy: CookieOptions = {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "Lax",
|
||||
maxAge: 600,
|
||||
secure: new URL(req.url).protocol === "https:",
|
||||
...options,
|
||||
};
|
||||
return {
|
||||
set(value: T) {
|
||||
const json = JSON.stringify(value);
|
||||
const encoded = btoa(unescape(encodeURIComponent(json)))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
store.set(name, encoded, policy);
|
||||
},
|
||||
consume(): T | undefined {
|
||||
const encoded = store.get(name);
|
||||
store.delete(name, policy);
|
||||
if (!encoded) return undefined;
|
||||
try {
|
||||
const padded = encoded
|
||||
.replace(/-/g, "+")
|
||||
.replace(/_/g, "/")
|
||||
.padEnd(Math.ceil(encoded.length / 4) * 4, "=");
|
||||
return JSON.parse(decodeURIComponent(escape(atob(padded)))) as T;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
clear() {
|
||||
store.delete(name, policy);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
return store;
|
||||
}
|
||||
|
||||
export function createSessionStore(
|
||||
|
||||
Reference in New Issue
Block a user