feat: add application productivity foundations
Quality / quality (ubuntu-latest) (push) Failing after 22s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
2026-08-23 11:13:03 +05:30
parent 46195462c3
commit 64ab20cc95
42 changed files with 1533 additions and 40 deletions
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/helpers",
"version": "0.8.8",
"version": "0.8.9",
"private": true,
"type": "module",
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
@@ -9,6 +9,8 @@
".": "./src/index.ts"
},
"dependencies": {
"@wrnexus/authz": "workspace:*",
"@wrnexus/db": "workspace:*",
"@wrnexus/core": "workspace:*"
}
}
+90
View File
@@ -0,0 +1,90 @@
import type { Context } from "@wrnexus/core";
import { can } from "@wrnexus/authz";
export class HttpError extends Error {
constructor(
public readonly status: number,
message: string,
public readonly details?: unknown,
) {
super(message);
this.name = "HttpError";
}
}
export function subjectId(ctx: Pick<Context, "user">): string {
return String((ctx.user as { id?: unknown } | undefined)?.id ?? "").trim();
}
export function requireUser(ctx: Pick<Context, "user">): { id: string; user: unknown } {
const id = subjectId(ctx);
if (!id) throw new HttpError(401, "Unauthorized");
return { id, user: ctx.user };
}
export function requireParam(
ctx: Pick<Context, "params">,
name: string,
options: { integer?: boolean; positive?: boolean } = {},
): string | number {
const raw = String(ctx.params[name] ?? "").trim();
if (!raw) throw new HttpError(400, `Route parameter '${name}' is required.`);
if (!options.integer) return raw;
const value = Number(raw);
if (!Number.isInteger(value) || (options.positive && value < 1)) {
throw new HttpError(400, `Route parameter '${name}' must be a positive integer.`);
}
return value;
}
export async function requireJson<T = Record<string, unknown>>(
ctx: Pick<Context, "req">,
): Promise<T> {
try {
return (await ctx.req.json()) as T;
} catch {
throw new HttpError(400, "Request body must be valid JSON.");
}
}
export async function requirePermission(
ctx: Context,
permission: string,
resource?: unknown,
): Promise<void> {
if (!(await can(ctx, permission, resource))) throw new HttpError(403, "Forbidden");
}
export const json = {
ok: <T>(value: T) => Response.json(value),
created: <T>(value: T) => Response.json(value, { status: 201 }),
noContent: () => new Response(null, { status: 204 }),
error: (status: number, message: string, details?: unknown) =>
Response.json({ error: message, ...(details === undefined ? {} : { details }) }, { status }),
};
export type ApiHandler = (ctx: Context) => Response | Promise<Response>;
/** Convert thrown HttpError values into the framework's standard JSON error shape. */
export function defineApiRoute(handler: ApiHandler): ApiHandler {
return async (ctx) => {
try {
return await handler(ctx);
} catch (error) {
if (error instanceof HttpError) return json.error(error.status, error.message, error.details);
throw error;
}
};
}
export function authorized(
permission: string,
handler: ApiHandler,
resource?: (ctx: Context) => unknown | Promise<unknown>,
): ApiHandler {
return defineApiRoute(async (ctx) => {
requireUser(ctx);
await requirePermission(ctx, permission, await resource?.(ctx));
return handler(ctx);
});
}
+14
View File
@@ -157,3 +157,17 @@ export {
once,
} from "./resilience.ts";
export type { RetryOptions } from "./resilience.ts";
export {
HttpError,
authorized,
defineApiRoute,
json,
requireJson,
requireParam,
requirePermission,
requireUser,
subjectId,
} from "./http.ts";
export type { ApiHandler } from "./http.ts";
export { defineResource } from "./resource.ts";
export type { ResourceDefinition, ResourceHandlers } from "./resource.ts";
+118
View File
@@ -0,0 +1,118 @@
import type { Context } from "@wrnexus/core";
import type { Db } from "@wrnexus/db";
import { can } from "@wrnexus/authz";
import { HttpError, defineApiRoute, json, requireJson, requireParam, requireUser } from "./http.ts";
const SAFE = /^[A-Za-z_][A-Za-z0-9_]*$/;
const ident = (value: string) => {
if (!SAFE.test(value)) throw new TypeError(`Unsafe resource identifier: ${value}`);
return value;
};
export interface ResourceDefinition<T extends Record<string, unknown>> {
name: string;
db: () => Db;
table: string;
id?: string;
owner?: string;
fields: readonly (keyof T & string)[];
permissions?: { read?: string; readAny?: string; write?: string; writeAny?: string };
validate?: (input: unknown, mode: "create" | "update") => T | Promise<T>;
serialize?: (row: T) => unknown;
}
export interface ResourceHandlers {
list(ctx: Context): Response | Promise<Response>;
create(ctx: Context): Response | Promise<Response>;
get(ctx: Context): Response | Promise<Response>;
update(ctx: Context): Response | Promise<Response>;
remove(ctx: Context): Response | Promise<Response>;
}
/** Build conventional owned CRUD handlers from one checked resource declaration. */
export function defineResource<T extends Record<string, unknown>>(
definition: ResourceDefinition<T>,
): ResourceHandlers {
const table = ident(definition.table);
const id = ident(definition.id ?? "id");
const owner = definition.owner ? ident(definition.owner) : undefined;
const fields = definition.fields.map(ident);
const shape = (row: T) => definition.serialize?.(row) ?? row;
const authorize = async (ctx: Context, action: "read" | "write", row?: T) => {
const subject = requireUser(ctx).id;
const permission = definition.permissions?.[action];
const any = definition.permissions?.[`${action}Any`];
if (any && (await can(ctx, any))) return subject;
if (
permission &&
!(await can(ctx, permission, owner ? { ownerId: row?.[owner] ?? subject } : row))
) {
throw new HttpError(403, "Forbidden");
}
if (owner && row && String(row[owner]) !== subject) throw new HttpError(403, "Forbidden");
return subject;
};
const find = async (ctx: Context) => {
const value = requireParam(ctx, id, { integer: true, positive: true });
const row = await definition.db().one<T>(`SELECT * FROM ${table} WHERE ${id} = ?`, [value]);
if (!row) throw new HttpError(404, `${definition.name} not found`);
return row;
};
return {
list: defineApiRoute(async (ctx) => {
const subject = await authorize(ctx, "read");
const seeAll =
definition.permissions?.readAny && (await can(ctx, definition.permissions.readAny));
const rows = await definition
.db()
.all<T>(
`SELECT * FROM ${table}${owner && !seeAll ? ` WHERE ${owner} = ?` : ""} ORDER BY ${id} DESC`,
owner && !seeAll ? [subject] : [],
);
return json.ok({ [definition.name]: rows.map(shape) });
}),
create: defineApiRoute(async (ctx) => {
const subject = await authorize(ctx, "write");
const input = definition.validate
? await definition.validate(await requireJson(ctx), "create")
: await requireJson<T>(ctx);
const values = Object.fromEntries(
fields.filter((key) => key in input).map((key) => [key, input[key]]),
);
if (owner) values[owner] = subject;
const columns = Object.keys(values).map(ident);
const result = await definition.db().exec(
`INSERT INTO ${table} (${columns.join(", ")}) VALUES (${columns.map(() => "?").join(", ")})`,
columns.map((key) => values[key]),
);
return json.created({ ok: true, id: result.lastInsertId });
}),
get: defineApiRoute(async (ctx) => {
const row = await find(ctx);
await authorize(ctx, "read", row);
return json.ok({ [definition.name.replace(/s$/, "")]: shape(row) });
}),
update: defineApiRoute(async (ctx) => {
const row = await find(ctx);
await authorize(ctx, "write", row);
const input = definition.validate
? await definition.validate(await requireJson(ctx), "update")
: await requireJson<T>(ctx);
const keys = fields.filter((key) => key in input);
if (!keys.length) throw new HttpError(400, "No writable fields supplied.");
await definition
.db()
.exec(`UPDATE ${table} SET ${keys.map((key) => `${key} = ?`).join(", ")} WHERE ${id} = ?`, [
...keys.map((key) => input[key]),
row[id],
]);
return json.ok({ ok: true });
}),
remove: defineApiRoute(async (ctx) => {
const row = await find(ctx);
await authorize(ctx, "write", row);
await definition.db().exec(`DELETE FROM ${table} WHERE ${id} = ?`, [row[id]]);
return json.ok({ ok: true });
}),
};
}