@renoxium/sdk 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ export type { Authz, AuthzContext, AuthzRoles, CloseContext, ConfigValue, Conn, Ctx, Deadline, Decision, EnqueueOptions, Explanation, Guard, JobContext, JobInfo, JobModule, JobPage, Ai, AiContent, AiMessage, AiRequest, AiResult, AiTool, Pdf, PdfOptions, Storage, StoragePage, StoragePutOptions, StoredFile, Webhook, WebhookContext, WebhookEvent, WebhookProvider, JobRow, JobState, Jobs, MessageContext, Principal, PrincipalRole, Realtime, RealtimeContext, RealtimePayload, ResourceRef, RoleAssignment, RoleScope, SseModule, Tool, ToolContext, ToolDefinition, ToolInputSchema, WsModule, DeliveredEvent, EmitOptions, EventContext, EventHandler, EventInfo, Events, Fetch, FunctionModule, Handler, HookFiller, HookFillerResult, HookRunResult, Hooks, HooksModule, HttpMethod, JsonValue, Log, LogFields, LogLevel, ModuleConfig, ModuleServices, Params, Password, Secrets, ServiceFunction, ServiceImplementation, Services, ServicesModule, } from "./ctx.ts";
2
+ export { defineHandlers } from "./ctx.ts";
3
+ export type { ActionName, ScopedDb } from "./authz.ts";
4
+ export { defineGuard, isForbidden, resource, userScope } from "./authz.ts";
5
+ export { defineSse, defineWs, isTopic, MAX_TOPIC_LENGTH, topic } from "./realtime.ts";
6
+ export { INPUT_KEYWORDS, tool, toolInputSchema } from "./tools.ts";
7
+ export { defineJob, isTimeZone, parseSchedule } from "./jobs.ts";
8
+ export type { TotpAlgorithm, TotpOptions, VerifyOptions } from "./totp.ts";
9
+ export { base32Decode, base32Encode, generateTotpSecret, totp, totpUri, verifyTotp } from "./totp.ts";
10
+ export type { Binary, RegisteredCredential } from "./webauthn.ts";
11
+ export { base64url, COSE_ES256, COSE_RS256, decodeCbor, fromBase64url, verifyAssertion, verifyRegistration, WebAuthnError, webauthnChallenge } from "./webauthn.ts";
12
+ export type { Action, ActionContext, ActionResult, Loader, LoaderContext, PageData, PageModule, Session, TextDirection } from "./pages.ts";
13
+ export { definePage } from "./pages.ts";
14
+ export type { EventHandlerOf, HookFillerOf, HooksDefinition, ModuleCtx, ModuleMap, ModuleSpec, ServiceDefinitions, ServiceProxy, ServicesOf, TypedEvents, TypedHooks, } from "./modules.ts";
15
+ export { defineHooks, defineServices, on } from "./modules.ts";
16
+ export type { StandardSchemaV1, ValidateInput, ValidationIssue } from "./validate.ts";
17
+ export { formToObject, validate, ValidationError } from "./validate.ts";
18
+ export { timingSafeEqual } from "./crypto.ts";
19
+ export type { RedirectStatus, SafeRedirectOptions } from "./redirect.ts";
20
+ export { redirect, safeRedirectTarget } from "./redirect.ts";
21
+ export type { Db, DbStatements, ExecuteResult, Param, Row, Scope, Value } from "./db.ts";
22
+ export type { AuthzErrorCode, DbErrorCode, DbScopeReason, DbUsageErrorCode, ForbiddenErrorDetails, ForbiddenReason, RealtimeErrorCode, JobErrorCode, StorageErrorCode, AiErrorCode, JobOp, RealtimeLimitReason, ToolErrorCode, ErrorResponseBody, FetchBlockedReason, FetchErrorCode, FetchFailedReason, EventPayloadReason, ModuleErrorCode, ModuleServiceErrorDetails, RenoxErrorCode, ResponseErrorCode, } from "./errors.ts";
23
+ export { DbBusyError, DbConstraintError, DbPermissionError, DbQuotaError, DbScopeError, DbSqlError, DbTimeoutError, DbUsageError, DeadlineError, EventPayloadError, FetchBlockedError, FetchEgressLimitError, FetchError, ForbiddenError, JobNotFoundError, JobStateError, AiQuotaError, AiUnavailableError, PdfLimitError, StorageError, isRenoxError, ModuleServiceError, ModuleUndeclaredError, RealtimeBackpressureError, RealtimeLimitError, RENOX_ERROR_CODES, RenoxError, RESPONSE_ERROR_CODES, } from "./errors.ts";
package/dist/index.js ADDED
@@ -0,0 +1,15 @@
1
+ // The `@renoxium/sdk` package: types and helpers for tenant code.
2
+ // Contract: docs/contracts/ops.md (version 1.6.0) and docs/contracts/ctx.d.ts.
3
+ export { defineHandlers } from "./ctx.js";
4
+ export { defineGuard, isForbidden, resource, userScope } from "./authz.js";
5
+ export { defineSse, defineWs, isTopic, MAX_TOPIC_LENGTH, topic } from "./realtime.js";
6
+ export { INPUT_KEYWORDS, tool, toolInputSchema } from "./tools.js";
7
+ export { defineJob, isTimeZone, parseSchedule } from "./jobs.js";
8
+ export { base32Decode, base32Encode, generateTotpSecret, totp, totpUri, verifyTotp } from "./totp.js";
9
+ export { base64url, COSE_ES256, COSE_RS256, decodeCbor, fromBase64url, verifyAssertion, verifyRegistration, WebAuthnError, webauthnChallenge } from "./webauthn.js";
10
+ export { definePage } from "./pages.js";
11
+ export { defineHooks, defineServices, on } from "./modules.js";
12
+ export { formToObject, validate, ValidationError } from "./validate.js";
13
+ export { timingSafeEqual } from "./crypto.js";
14
+ export { redirect, safeRedirectTarget } from "./redirect.js";
15
+ export { DbBusyError, DbConstraintError, DbPermissionError, DbQuotaError, DbScopeError, DbSqlError, DbTimeoutError, DbUsageError, DeadlineError, EventPayloadError, FetchBlockedError, FetchEgressLimitError, FetchError, ForbiddenError, JobNotFoundError, JobStateError, AiQuotaError, AiUnavailableError, PdfLimitError, StorageError, isRenoxError, ModuleServiceError, ModuleUndeclaredError, RealtimeBackpressureError, RealtimeLimitError, RENOX_ERROR_CODES, RenoxError, RESPONSE_ERROR_CODES, } from "./errors.js";
package/dist/jobs.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ import type { JobModule } from "./ctx.ts";
2
+ /** The values of each field of a schedule (`0 3 * * 1-5`), or the first problem as a string. */
3
+ export declare function parseSchedule(expr: string): number[][] | string;
4
+ /** Whether `tz` is an IANA zone the runtime knows (`Asia/Dubai`, `UTC`). */
5
+ export declare function isTimeZone(tz: string): boolean;
6
+ /**
7
+ * Checks a job declaration and returns it unchanged; spread it into the
8
+ * module's exports or use its fields:
9
+ *
10
+ * ```ts
11
+ * export const schedule = "0 3 * * *";
12
+ * export const tz = "Asia/Dubai";
13
+ * export async function run(ctx: JobContext) { ... }
14
+ * ```
15
+ */
16
+ export declare function defineJob<const J extends JobModule>(job: J): J;
package/dist/jobs.js ADDED
@@ -0,0 +1,72 @@
1
+ // Scheduled jobs (#284; docs/contracts/ops.md section 20; run by the F6
2
+ // scheduler, #297). A `jobs/*.ts` module exports `schedule` (five cron
3
+ // fields), optionally `tz`, and `run(ctx)`. `renox build` reads `schedule`
4
+ // and `tz` into the `job` route entry and checks them (V27) with
5
+ // `parseSchedule` below.
6
+ /** One field: allowed range and name. */
7
+ const FIELDS = [
8
+ { name: "minute", min: 0, max: 59 },
9
+ { name: "hour", min: 0, max: 23 },
10
+ { name: "day-of-month", min: 1, max: 31 },
11
+ { name: "month", min: 1, max: 12 },
12
+ { name: "day-of-week", min: 0, max: 7 },
13
+ ];
14
+ /** The values of each field of a schedule (`0 3 * * 1-5`), or the first problem as a string. */
15
+ export function parseSchedule(expr) {
16
+ if (typeof expr !== "string" || expr.length > 128)
17
+ return "a schedule is a string of at most 128 characters";
18
+ const parts = expr.trim().split(/\s+/u);
19
+ if (parts.length !== 5)
20
+ return `a schedule has 5 fields (minute hour day-of-month month day-of-week), got ${parts.length}`;
21
+ const out = [];
22
+ for (let f = 0; f < 5; f++) {
23
+ const { name, min, max } = FIELDS[f];
24
+ const values = new Set();
25
+ for (const item of parts[f].split(",")) {
26
+ const m = /^(\*|(\d+)(?:-(\d+))?)(?:\/(\d+))?$/u.exec(item);
27
+ if (!m)
28
+ return `${name}: ${JSON.stringify(item)} is not a number, *, a range a-b, or a step /n`;
29
+ const from = m[1] === "*" ? min : Number(m[2]);
30
+ const to = m[1] === "*" ? max : m[3] !== undefined ? Number(m[3]) : m[4] !== undefined ? max : from;
31
+ const step = m[4] !== undefined ? Number(m[4]) : 1;
32
+ if (from < min || to > max || from > to)
33
+ return `${name}: ${item} is outside ${min}-${max}`;
34
+ if (step < 1)
35
+ return `${name}: the step of ${item} must be at least 1`;
36
+ for (let v = from; v <= to; v += step)
37
+ values.add(v);
38
+ }
39
+ out.push([...values].sort((a, b) => a - b));
40
+ }
41
+ return out;
42
+ }
43
+ /** Whether `tz` is an IANA zone the runtime knows (`Asia/Dubai`, `UTC`). */
44
+ export function isTimeZone(tz) {
45
+ try {
46
+ new Intl.DateTimeFormat("en", { timeZone: tz });
47
+ return true;
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ }
53
+ /**
54
+ * Checks a job declaration and returns it unchanged; spread it into the
55
+ * module's exports or use its fields:
56
+ *
57
+ * ```ts
58
+ * export const schedule = "0 3 * * *";
59
+ * export const tz = "Asia/Dubai";
60
+ * export async function run(ctx: JobContext) { ... }
61
+ * ```
62
+ */
63
+ export function defineJob(job) {
64
+ const parsed = parseSchedule(job.schedule);
65
+ if (typeof parsed === "string")
66
+ throw new TypeError(`defineJob: ${parsed}`);
67
+ if (job.tz !== undefined && !isTimeZone(job.tz))
68
+ throw new TypeError(`defineJob: ${job.tz} is not an IANA time zone`);
69
+ if (typeof job.run !== "function")
70
+ throw new TypeError("defineJob: run must be a function");
71
+ return job;
72
+ }
@@ -0,0 +1,11 @@
1
+ import { type Dialect, Kysely } from "kysely";
2
+ import type { Db, DbStatements } from "./db.ts";
3
+ /** The SQLite dialect over the `db` op. */
4
+ export declare function renoxDialect(db: Db | DbStatements): Dialect;
5
+ /**
6
+ * A Kysely instance over `ctx.db` (or over the `tx` of `db.transaction`, or
7
+ * an action's `ctx.db`). `DB` is the `Tables` type of `.renox/types/db.d.ts`.
8
+ */
9
+ export declare function kysely<DB>(ctx: {
10
+ readonly db: Db | DbStatements;
11
+ }): Kysely<DB>;
package/dist/kysely.js ADDED
@@ -0,0 +1,126 @@
1
+ // `@renoxium/sdk/kysely`: Kysely over the `db` op (docs/06-data.md, "API layers";
2
+ // #237). A thin driver: every compiled query goes to `ctx.db.query` or
3
+ // `execute` with its `?` parameters in order, so the platform's limits and
4
+ // the module authorizer apply exactly as to raw SQL; there is no second
5
+ // execution path. Transactions run through `db.transaction` (a savepoint
6
+ // when one is already current, ops.md section 1). No streaming.
7
+ //
8
+ // import { kysely } from "@renoxium/sdk/kysely";
9
+ // import type { Tables } from "../.renox/types/db";
10
+ // const rows = await kysely<Tables>(ctx).selectFrom("app__products").selectAll().where("id", "=", id).execute();
11
+ //
12
+ // `kysely` is an optional peer dependency: an app that never imports this
13
+ // subpath does not need it.
14
+ import { Kysely, SqliteAdapter, SqliteIntrospector, SqliteQueryCompiler, } from "kysely";
15
+ /** A statement writes and returns no rows unless it is a SELECT, a WITH ... SELECT, or carries RETURNING. */
16
+ function returnsRows(query) {
17
+ const kind = query.query.kind;
18
+ if (kind === "SelectQueryNode")
19
+ return true;
20
+ return /\breturning\b/iu.test(query.sql) || /^\s*(select|with|pragma|values)\b/iu.test(query.sql);
21
+ }
22
+ function params(query) {
23
+ return query.parameters.map((p) => (p instanceof Date ? p.toISOString() : p));
24
+ }
25
+ class RenoxConnection {
26
+ db;
27
+ /** The open transaction while Kysely runs one on this connection. */
28
+ tx;
29
+ constructor(db) {
30
+ this.db = db;
31
+ }
32
+ async executeQuery(query) {
33
+ if (returnsRows(query)) {
34
+ const rows = await this.db.query(query.sql, params(query));
35
+ return { rows: rows };
36
+ }
37
+ const r = await this.db.execute(query.sql, params(query));
38
+ return { rows: [], numAffectedRows: BigInt(r.changes), insertId: BigInt(r.lastInsertRowid) };
39
+ }
40
+ async *streamQuery() {
41
+ throw new Error("@renoxium/sdk/kysely: streaming queries are not supported; use execute()");
42
+ }
43
+ }
44
+ /**
45
+ * Kysely's begin/commit/rollback over the callback form of `db.transaction`:
46
+ * `begin` opens the transaction and parks its callback until `commit`
47
+ * resolves it or `rollback` rejects it.
48
+ */
49
+ class TransactionConnection extends RenoxConnection {
50
+ finish;
51
+ done;
52
+ static async open(db) {
53
+ let ready;
54
+ const opened = new Promise((r) => (ready = r));
55
+ const done = db.transaction((tx) => new Promise((resolve, reject) => {
56
+ const conn = new TransactionConnection(tx);
57
+ conn.finish = (commit) => (commit ? resolve() : reject(new RollbackSignal()));
58
+ ready(conn);
59
+ }));
60
+ const conn = await Promise.race([opened, done.then(() => Promise.reject(new Error("transaction ended before it began")))]);
61
+ conn.done = done.catch((e) => {
62
+ if (!(e instanceof RollbackSignal))
63
+ throw e;
64
+ });
65
+ return conn;
66
+ }
67
+ async end(commit) {
68
+ this.finish(commit);
69
+ await this.done;
70
+ }
71
+ }
72
+ class RollbackSignal extends Error {
73
+ }
74
+ class RenoxDriver {
75
+ db;
76
+ constructor(db) {
77
+ this.db = db;
78
+ }
79
+ async init() { }
80
+ async acquireConnection() {
81
+ return new RenoxConnection(this.db);
82
+ }
83
+ async beginTransaction(connection) {
84
+ if (!("transaction" in this.db))
85
+ throw new Error("@renoxium/sdk/kysely: this db is already a transaction; run the queries on it directly");
86
+ // Kysely runs the transaction's queries on the connection it passes to
87
+ // `beginTransaction`: from here on that connection forwards to the
88
+ // transaction.
89
+ const c = connection;
90
+ c.tx = await TransactionConnection.open(this.db);
91
+ c.db = c.tx.db;
92
+ }
93
+ async commitTransaction(connection) {
94
+ await this.endTx(connection, true);
95
+ }
96
+ async rollbackTransaction(connection) {
97
+ await this.endTx(connection, false);
98
+ }
99
+ async endTx(connection, commit) {
100
+ const c = connection;
101
+ const tx = c.tx;
102
+ if (tx === undefined)
103
+ throw new Error("@renoxium/sdk/kysely: no open transaction");
104
+ c.tx = undefined;
105
+ c.db = this.db;
106
+ await tx.end(commit);
107
+ }
108
+ async releaseConnection() { }
109
+ async destroy() { }
110
+ }
111
+ /** The SQLite dialect over the `db` op. */
112
+ export function renoxDialect(db) {
113
+ return {
114
+ createAdapter: () => new SqliteAdapter(),
115
+ createDriver: () => new RenoxDriver(db),
116
+ createIntrospector: (k) => new SqliteIntrospector(k),
117
+ createQueryCompiler: () => new SqliteQueryCompiler(),
118
+ };
119
+ }
120
+ /**
121
+ * A Kysely instance over `ctx.db` (or over the `tx` of `db.transaction`, or
122
+ * an action's `ctx.db`). `DB` is the `Tables` type of `.renox/types/db.d.ts`.
123
+ */
124
+ export function kysely(ctx) {
125
+ return new Kysely({ dialect: renoxDialect(ctx.db) });
126
+ }
@@ -0,0 +1,72 @@
1
+ import type { ConfigValue, Ctx, DeliveredEvent, EmitOptions, EventContext, HookFiller, HookFillerResult, HookRunResult } from "./ctx.ts";
2
+ /** What the generated types say about one module. */
3
+ export interface ModuleSpec {
4
+ /** Caller-side services (the broker supplies the callee's ctx). */
5
+ services: object;
6
+ /** Event name → payload type, for the events the module exports. */
7
+ events: object;
8
+ /** Hook name → `{ args, result }`, for the hook points the module exports. */
9
+ hooks: object;
10
+ /** Config key → value type. */
11
+ config: object;
12
+ }
13
+ /** Installed module name → its spec; `app` has no events, hooks, or config. */
14
+ export type ModuleMap = Record<string, ModuleSpec>;
15
+ /**
16
+ * A `services.ts` export as a caller sees it: the leading `ctx` parameter is
17
+ * dropped and the result is a promise (ops.md section 15.2).
18
+ */
19
+ export type ServiceProxy<F> = F extends (ctx: never, ...args: infer A) => infer R ? (...args: A) => Promise<Awaited<R>> : never;
20
+ /** All the services of a `services.ts` module, as proxies. */
21
+ export type ServicesOf<T> = {
22
+ readonly [K in keyof T as T[K] extends (...args: never[]) => unknown ? K : never]: ServiceProxy<T[K]>;
23
+ };
24
+ /** `ctx.events` with the module's own event names and payloads. */
25
+ export interface TypedEvents<E> {
26
+ emit<N extends keyof E & string>(name: N, payload: E[N], options?: EmitOptions): Promise<string>;
27
+ }
28
+ /** `ctx.hooks` with the module's own hook points. */
29
+ export interface TypedHooks<H> {
30
+ run<N extends keyof H & string>(name: N, args: H[N] extends {
31
+ args: infer A;
32
+ } ? A : unknown): Promise<HookRunResult>;
33
+ }
34
+ /**
35
+ * The ctx of module `M`: the contract's `Ctx` with `services` typed for every
36
+ * installed module, `events` and `hooks` for `M`'s own exports, and `config`
37
+ * for `M`'s `[config]`.
38
+ */
39
+ export type ModuleCtx<Ms extends ModuleMap, M extends keyof Ms | "app"> = Omit<Ctx, "services" | "events" | "hooks" | "config"> & {
40
+ readonly services: {
41
+ readonly [K in keyof Ms]: Ms[K]["services"];
42
+ };
43
+ readonly events: TypedEvents<M extends keyof Ms ? Ms[M]["events"] : Record<never, never>>;
44
+ readonly hooks: TypedHooks<M extends keyof Ms ? Ms[M]["hooks"] : Record<never, never>>;
45
+ readonly config: Readonly<M extends keyof Ms ? Ms[M]["config"] : Record<string, ConfigValue>>;
46
+ };
47
+ /** The shape `services.ts` exports: each service takes the callee's ctx first. */
48
+ export type ServiceDefinitions = Record<string, (ctx: never, ...args: never[]) => unknown>;
49
+ /**
50
+ * Types a `services.ts` module's exports in one place and returns them
51
+ * unchanged: `export const { getActivePlan } = defineServices({ ... })`.
52
+ */
53
+ export declare function defineServices<const S extends ServiceDefinitions>(services: S): S;
54
+ /** One hook filler, typed by its args. */
55
+ export interface HookFillerOf<A> {
56
+ readonly priority?: number;
57
+ handle(ctx: Ctx, args: A): HookFillerResult | Promise<HookFillerResult>;
58
+ }
59
+ /** One event handler, typed by its payload. */
60
+ export type EventHandlerOf<P> = (ctx: EventContext, e: DeliveredEvent<P>) => void | Promise<void>;
61
+ /** What `hooks.ts` exports: `{ hooks, on }` (ops.md sections 15.3 and 15.4). */
62
+ export interface HooksDefinition {
63
+ hooks?: Readonly<Record<string, HookFiller | HookFillerOf<never>>>;
64
+ on?: Readonly<Record<string, EventHandlerOf<never>>>;
65
+ }
66
+ /**
67
+ * Types a `hooks.ts` module in one place and returns it unchanged:
68
+ * `export const { hooks, on } = defineHooks({ hooks: { ... }, on: { ... } })`.
69
+ */
70
+ export declare function defineHooks<const H extends HooksDefinition>(definition: H): H;
71
+ /** A typed event handler for the `on` map: `on: { "billing.invoice_paid": on<Paid>(async (ctx, e) => ...) }`. */
72
+ export declare function on<P>(handler: EventHandlerOf<P>): EventHandlerOf<P>;
@@ -0,0 +1,30 @@
1
+ // Typed module APIs (docs/contracts/ops.md section 15; docs/10-modules.md).
2
+ // `Ctx.services`, `events`, `hooks` and `config` are untyped in the
3
+ // contract; the CLI generates `.renox/types/modules.d.ts` from the installed
4
+ // manifests (#236), and `ModuleCtx` narrows the contract's `Ctx` with it.
5
+ //
6
+ // import type { ModuleCtx } from "../.renox/types/modules";
7
+ // export const services = defineServices({
8
+ // async getActivePlan(ctx: ModuleCtx<"billing">, userId: string) {
9
+ // const user = await ctx.services.auth.currentUser(); // typed from auth's services.ts
10
+ // await ctx.events.emit("billing.invoice_paid", { invoice_id: "i1", user_id: userId }); // payload checked
11
+ // },
12
+ // });
13
+ /**
14
+ * Types a `services.ts` module's exports in one place and returns them
15
+ * unchanged: `export const { getActivePlan } = defineServices({ ... })`.
16
+ */
17
+ export function defineServices(services) {
18
+ return services;
19
+ }
20
+ /**
21
+ * Types a `hooks.ts` module in one place and returns it unchanged:
22
+ * `export const { hooks, on } = defineHooks({ hooks: { ... }, on: { ... } })`.
23
+ */
24
+ export function defineHooks(definition) {
25
+ return definition;
26
+ }
27
+ /** A typed event handler for the `on` map: `on: { "billing.invoice_paid": on<Paid>(async (ctx, e) => ...) }`. */
28
+ export function on(handler) {
29
+ return handler;
30
+ }
@@ -0,0 +1,53 @@
1
+ import type { Ctx } from "./ctx.ts";
2
+ import type { DbStatements } from "./db.ts";
3
+ /** The session of the request; F2 carries only the id (ops.md section 14, "Session"). */
4
+ export interface Session {
5
+ /** Opaque random id, 22 base64url characters, from the signed `__Host-renox_sid` cookie. */
6
+ readonly id: string;
7
+ }
8
+ export type TextDirection = "ltr" | "rtl";
9
+ export interface LoaderContext extends Ctx {
10
+ /** The fragment the cell will render for this request, or null for the full page. */
11
+ readonly fragment: string | null;
12
+ readonly session: Session;
13
+ /** Sets the page's locale and direction (ops.md section 14, "Locale"); `dir` defaults from the language. */
14
+ setLocale(locale: string, dir?: TextDirection): void;
15
+ }
16
+ /** The template data a loader returns: a plain object (ops.md section 14, "Loader"). */
17
+ export type PageData = Record<string, unknown>;
18
+ export type Loader = (ctx: LoaderContext) => PageData | Response | Promise<PageData | Response>;
19
+ export interface ActionContext extends Omit<Ctx, "db"> {
20
+ /** The action's own IMMEDIATE transaction: committed when the action resolves, rolled back when it throws. */
21
+ readonly db: DbStatements;
22
+ /** The action name from `?/<name>`, or "default". */
23
+ readonly action: string;
24
+ /** The parsed form body (urlencoded or multipart); empty for other content types. CSRF is checked before. */
25
+ readonly form: FormData;
26
+ readonly fragment: string | null;
27
+ readonly session: Session;
28
+ /** As for the loader. */
29
+ setLocale(locale: string, dir?: TextDirection): void;
30
+ }
31
+ /**
32
+ * A `Response` is sent as is. An object (or nothing) re-runs `load` and is
33
+ * merged over its result as template data; `fragments` names the extra
34
+ * fragments rendered out of band.
35
+ */
36
+ export type ActionResult = Response | (PageData & {
37
+ fragments?: readonly string[];
38
+ }) | void;
39
+ export type Action = (ctx: ActionContext) => ActionResult | Promise<ActionResult>;
40
+ /** Shape of a `pages/**\/*.ts` module. */
41
+ export interface PageModule {
42
+ load?: Loader;
43
+ actions?: Readonly<Record<string, Action>>;
44
+ }
45
+ /**
46
+ * Optional sugar: types a page's `load` and `actions` in one place and
47
+ * returns them unchanged.
48
+ *
49
+ * ```ts
50
+ * export const { load, actions } = definePage({ load: async ({ db }) => ({ ... }), actions: { add: async ({ form }) => ... } });
51
+ * ```
52
+ */
53
+ export declare function definePage<const P extends PageModule>(page: P): P;
package/dist/pages.js ADDED
@@ -0,0 +1,14 @@
1
+ // Page modules (docs/contracts/ops.md section 14): `pages/**/*.ts` export
2
+ // `load` and `actions`. Types identical to docs/contracts/ctx.d.ts;
3
+ // tests/contract.test-d.ts enforces it.
4
+ /**
5
+ * Optional sugar: types a page's `load` and `actions` in one place and
6
+ * returns them unchanged.
7
+ *
8
+ * ```ts
9
+ * export const { load, actions } = definePage({ load: async ({ db }) => ({ ... }), actions: { add: async ({ form }) => ... } });
10
+ * ```
11
+ */
12
+ export function definePage(page) {
13
+ return page;
14
+ }
@@ -0,0 +1,15 @@
1
+ import type { SseModule, WsModule } from "./ctx.ts";
2
+ /** Topics are at most 256 characters. */
3
+ export declare const MAX_TOPIC_LENGTH = 256;
4
+ /** Whether `t` is a topic the platform accepts (`room:42`, `user:u_1:inbox`). */
5
+ export declare function isTopic(t: string): boolean;
6
+ /**
7
+ * A topic from its segments, joined with `:`. Throws a RangeError for a
8
+ * segment outside `[A-Za-z0-9_.-]+` (a `:` inside a segment would change
9
+ * the topic) or a topic over 256 characters, as `ctx.realtime` does.
10
+ */
11
+ export declare function topic(...segments: readonly (string | number)[]): string;
12
+ /** Types a `functions/ws/**` module's `ws` export and returns it unchanged. */
13
+ export declare function defineWs<const M extends WsModule>(handlers: M): M;
14
+ /** Types a `functions/sse/**` module's `sse` export and returns it unchanged. */
15
+ export declare function defineSse<const M extends SseModule>(handlers: M): M;
@@ -0,0 +1,40 @@
1
+ // Realtime helpers (#284; docs/contracts/ops.md section 18). The handler
2
+ // shapes (`WsModule`, `SseModule`) and `ctx.realtime` are in ctx.ts; these
3
+ // type a module's export and build topics the way the platform checks them.
4
+ //
5
+ // // functions/ws/chat/[room].ts
6
+ // export const ws = defineWs({
7
+ // async open(ctx) { await ctx.realtime.subscribe(ctx.conn, topic("room", ctx.params.room as string)); },
8
+ // async message(ctx) { await ctx.realtime.publish(topic("room", ctx.params.room as string), { text: String(ctx.data) }); },
9
+ // });
10
+ /** One topic segment: `[A-Za-z0-9_.-]+` (ops.md section 18, "Topics"). */
11
+ const SEGMENT = /^[A-Za-z0-9_.-]+$/u;
12
+ /** Topics are at most 256 characters. */
13
+ export const MAX_TOPIC_LENGTH = 256;
14
+ /** Whether `t` is a topic the platform accepts (`room:42`, `user:u_1:inbox`). */
15
+ export function isTopic(t) {
16
+ return typeof t === "string" && t.length > 0 && t.length <= MAX_TOPIC_LENGTH && t.split(":").every((s) => SEGMENT.test(s));
17
+ }
18
+ /**
19
+ * A topic from its segments, joined with `:`. Throws a RangeError for a
20
+ * segment outside `[A-Za-z0-9_.-]+` (a `:` inside a segment would change
21
+ * the topic) or a topic over 256 characters, as `ctx.realtime` does.
22
+ */
23
+ export function topic(...segments) {
24
+ const parts = segments.map(String);
25
+ for (const p of parts)
26
+ if (!SEGMENT.test(p))
27
+ throw new RangeError(`topic: segment ${JSON.stringify(p)} is not [A-Za-z0-9_.-]+`);
28
+ const t = parts.join(":");
29
+ if (t.length === 0 || t.length > MAX_TOPIC_LENGTH)
30
+ throw new RangeError("topic: 1 to 256 characters");
31
+ return t;
32
+ }
33
+ /** Types a `functions/ws/**` module's `ws` export and returns it unchanged. */
34
+ export function defineWs(handlers) {
35
+ return handlers;
36
+ }
37
+ /** Types a `functions/sse/**` module's `sse` export and returns it unchanged. */
38
+ export function defineSse(handlers) {
39
+ return handlers;
40
+ }
@@ -0,0 +1,19 @@
1
+ export type RedirectStatus = 301 | 302 | 303 | 307 | 308;
2
+ export interface SafeRedirectOptions {
3
+ /** Origins other than the app's own that may be redirected to, e.g. `"https://accounts.example.com"`. */
4
+ readonly allow?: readonly string[];
5
+ /** Where an unsafe target goes instead: a path of the app (default `"/"`). */
6
+ readonly fallback?: string;
7
+ /** Default 303 (See Other): the right answer to a form `POST`. */
8
+ readonly status?: RedirectStatus;
9
+ }
10
+ /**
11
+ * The `Location` to send for `target`, or `null` when it is not safe: a
12
+ * path of this app (`/…`, not `//…`, no `\`, no control characters), or an
13
+ * absolute `http(s)` URL whose origin is in `allow`.
14
+ */
15
+ export declare function safeRedirectTarget(target: string, allow?: readonly string[]): string | null;
16
+ export declare const redirect: {
17
+ /** A redirect `Response` to `target` if it is safe (see {@link safeRedirectTarget}), else to `fallback`. */
18
+ safe(target: string, options?: SafeRedirectOptions): Response;
19
+ };
@@ -0,0 +1,57 @@
1
+ // Open-redirect protection (docs/24-threat-model.md, "Open redirect via
2
+ // `next`/`return_to`"; #111). `redirect.safe(target)` redirects only to a
3
+ // path of the app itself or to an origin listed in `allow`; anything else
4
+ // (another site, `//evil.example`, `/\evil.example`, `javascript:`, a
5
+ // relative path without a leading `/`) redirects to `fallback` instead.
6
+ // `renox check` reports `Response.redirect()` with a target taken from the
7
+ // request (`no-unsafe-redirect`).
8
+ // Browsers drop tabs and newlines inside URLs and treat `\` as `/`, so
9
+ // "/\tevil.example" and "/\\evil.example" are protocol-relative to them.
10
+ const CONTROL = /[\u0000-\u001f\u007f]/u;
11
+ function origins(allow) {
12
+ const out = new Set();
13
+ for (const a of allow) {
14
+ try {
15
+ const u = new URL(a);
16
+ if (u.protocol === "https:" || u.protocol === "http:")
17
+ out.add(u.origin);
18
+ }
19
+ catch {
20
+ // An entry that is not an absolute URL allows nothing.
21
+ }
22
+ }
23
+ return out;
24
+ }
25
+ /**
26
+ * The `Location` to send for `target`, or `null` when it is not safe: a
27
+ * path of this app (`/…`, not `//…`, no `\`, no control characters), or an
28
+ * absolute `http(s)` URL whose origin is in `allow`.
29
+ */
30
+ export function safeRedirectTarget(target, allow = []) {
31
+ if (typeof target !== "string" || target.length === 0 || target.length > 2048)
32
+ return null;
33
+ if (CONTROL.test(target) || target.includes("\\"))
34
+ return null;
35
+ if (target.startsWith("/"))
36
+ return target.startsWith("//") ? null : target;
37
+ let url;
38
+ try {
39
+ url = new URL(target);
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ if (url.protocol !== "https:" && url.protocol !== "http:")
45
+ return null;
46
+ if (url.username || url.password)
47
+ return null;
48
+ return origins(allow).has(url.origin) ? url.href : null;
49
+ }
50
+ export const redirect = {
51
+ /** A redirect `Response` to `target` if it is safe (see {@link safeRedirectTarget}), else to `fallback`. */
52
+ safe(target, options = {}) {
53
+ const fallback = safeRedirectTarget(options.fallback ?? "/") ?? "/";
54
+ const location = safeRedirectTarget(target, options.allow ?? []) ?? fallback;
55
+ return new Response(null, { status: options.status ?? 303, headers: { location } });
56
+ },
57
+ };