@shaferllc/keel 0.58.0 → 0.66.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.
@@ -7,6 +7,9 @@
7
7
  *
8
8
  * const token = await encryption.encrypt({ userId: 1 });
9
9
  * await encryption.decrypt(token); // { userId: 1 } | null
10
+ *
11
+ * const token = await jwt.sign({ sub: "42" }, { expiresIn: "1h" });
12
+ * await jwt.verify(token); // { sub: "42", iat, exp } | null
10
13
  */
11
14
  import { config } from "./helpers.js";
12
15
  /* --------------------------- shared utilities -------------------------- */
@@ -39,6 +42,9 @@ function appKey() {
39
42
  }
40
43
  /* ------------------------------- hashing ------------------------------- */
41
44
  const DEFAULT_ITERATIONS = 100_000;
45
+ /** When true, `hash` skips PBKDF2 for a trivial (insecure) scheme — tests only. */
46
+ let faking = false;
47
+ const FAKE_PREFIX = "fake$";
42
48
  async function pbkdf2(password, salt, iterations) {
43
49
  const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveBits"]);
44
50
  const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", salt: salt, iterations, hash: "SHA-256" }, key, 256);
@@ -47,12 +53,16 @@ async function pbkdf2(password, salt, iterations) {
47
53
  export const hash = {
48
54
  /** Hash a password (PBKDF2-SHA256 with a random salt). */
49
55
  async make(password, iterations = DEFAULT_ITERATIONS) {
56
+ if (faking)
57
+ return `${FAKE_PREFIX}${password}`;
50
58
  const salt = crypto.getRandomValues(new Uint8Array(16));
51
59
  const derived = await pbkdf2(password, salt, iterations);
52
60
  return `pbkdf2_sha256$${iterations}$${b64(salt)}$${b64(derived)}`;
53
61
  },
54
62
  /** Verify a password against a stored hash. Returns false for any malformed hash. */
55
63
  async verify(hashed, password) {
64
+ if (faking)
65
+ return hashed === `${FAKE_PREFIX}${password}`;
56
66
  const [algo, iter, salt64, hash64] = hashed.split("$");
57
67
  if (algo !== "pbkdf2_sha256" || !iter || !salt64 || !hash64)
58
68
  return false;
@@ -70,9 +80,35 @@ export const hash = {
70
80
  },
71
81
  /** Whether a hash was made with fewer iterations than the current default. */
72
82
  needsRehash(hashed, iterations = DEFAULT_ITERATIONS) {
83
+ if (faking)
84
+ return false;
73
85
  const iter = Number(hashed.split("$")[1]);
74
86
  return !iter || iter < iterations;
75
87
  },
88
+ /**
89
+ * Swap real hashing for a trivial, **insecure** scheme so tests that create
90
+ * many users don't pay for PBKDF2. `make` returns `fake$<password>` and
91
+ * `verify` just compares — near-instant. Call in a test setup hook; undo with
92
+ * `restore()`. Never use outside tests.
93
+ */
94
+ fake() {
95
+ faking = true;
96
+ },
97
+ /** Restore real PBKDF2 hashing after `fake()`. */
98
+ restore() {
99
+ faking = false;
100
+ },
101
+ /**
102
+ * A valid dummy hash (of a random secret) at the default cost. Compare against
103
+ * it when a user *isn't* found so login spends the same time as a wrong
104
+ * password — otherwise a fast "no such user" response leaks which emails are
105
+ * registered (a timing/enumeration attack).
106
+ *
107
+ * const user = await findUserByEmail(email);
108
+ * const ok = await hash.verify(user?.password ?? hash.dummy, password);
109
+ * if (ok && user) auth().login(user.id); // `user &&` so the dummy never authenticates
110
+ */
111
+ dummy: "pbkdf2_sha256$100000$7uVVFNW3RCry5kPKJQUgTw==$fOfxeFDnxv5A3rhl6bcWGKJQhmcK8x6XNfe9Z88WO/A=",
76
112
  };
77
113
  /* ----------------------------- encryption ------------------------------ */
78
114
  async function aesKey() {
@@ -80,24 +116,132 @@ async function aesKey() {
80
116
  return crypto.subtle.importKey("raw", digest, "AES-GCM", false, ["encrypt", "decrypt"]);
81
117
  }
82
118
  export const encryption = {
83
- /** Encrypt any JSON-serializable value (AES-GCM), keyed by config('app.key'). */
84
- async encrypt(value) {
119
+ /**
120
+ * Encrypt any JSON-serializable value (AES-GCM), keyed by `config('app.key')`.
121
+ * `expiresIn` makes the token self-expire; `purpose` binds it to a context
122
+ * (e.g. `"password-reset"`) so a token minted for one use can't be replayed for
123
+ * another.
124
+ */
125
+ async encrypt(value, options = {}) {
126
+ // Wrap in a small envelope so expiry/purpose travel inside the ciphertext.
127
+ const envelope = { __k: 1, v: value };
128
+ if (options.expiresIn != null)
129
+ envelope.exp = Date.now() + seconds(options.expiresIn) * 1000;
130
+ if (options.purpose != null)
131
+ envelope.p = options.purpose;
85
132
  const iv = crypto.getRandomValues(new Uint8Array(12));
86
- const data = new TextEncoder().encode(JSON.stringify(value));
133
+ const data = new TextEncoder().encode(JSON.stringify(envelope));
87
134
  const cipher = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv }, await aesKey(), data));
88
135
  const packed = new Uint8Array(iv.length + cipher.length);
89
136
  packed.set(iv);
90
137
  packed.set(cipher, iv.length);
91
138
  return b64(packed);
92
139
  },
93
- /** Decrypt a value; returns null if the payload is tampered or invalid. */
94
- async decrypt(payload) {
140
+ /**
141
+ * Decrypt a value; returns `null` if the payload is tampered, invalid, expired,
142
+ * or minted for a different `purpose`. Never throws.
143
+ */
144
+ async decrypt(payload, options = {}) {
95
145
  try {
96
146
  const bytes = fromB64(payload);
97
147
  const iv = bytes.slice(0, 12);
98
148
  const cipher = bytes.slice(12);
99
149
  const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv: iv }, await aesKey(), cipher);
100
- return JSON.parse(new TextDecoder().decode(plain));
150
+ const parsed = JSON.parse(new TextDecoder().decode(plain));
151
+ // Envelope (new format): enforce expiry + purpose.
152
+ if (parsed && typeof parsed === "object" && parsed.__k === 1) {
153
+ const env = parsed;
154
+ if (typeof env.exp === "number" && Date.now() >= env.exp)
155
+ return null;
156
+ if ((options.purpose ?? null) !== (env.p ?? null))
157
+ return null;
158
+ return env.v;
159
+ }
160
+ // Legacy plain value (encrypted before envelopes). A required purpose can't match.
161
+ if (options.purpose != null)
162
+ return null;
163
+ return parsed;
164
+ }
165
+ catch {
166
+ return null;
167
+ }
168
+ },
169
+ };
170
+ const DURATION = /^(\d+)\s*(s|m|h|d)$/;
171
+ const UNIT = { s: 1, m: 60, h: 3600, d: 86400 };
172
+ /** Coerce a lifetime to seconds — a bare number passes through; `"1h"` → 3600. */
173
+ function seconds(value) {
174
+ if (typeof value === "number")
175
+ return value;
176
+ const match = DURATION.exec(value.trim());
177
+ if (!match)
178
+ throw new Error(`Invalid duration "${value}" (use e.g. 30, "30s", "15m", "1h", "7d").`);
179
+ return Number(match[1]) * UNIT[match[2]];
180
+ }
181
+ /* base64url — JWT segments use the URL-safe alphabet with no padding. */
182
+ function b64url(bytes) {
183
+ return b64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
184
+ }
185
+ function fromB64url(str) {
186
+ const pad = str.length % 4 ? "=".repeat(4 - (str.length % 4)) : "";
187
+ return fromB64(str.replace(/-/g, "+").replace(/_/g, "/") + pad);
188
+ }
189
+ function b64urlJson(value) {
190
+ return b64url(new TextEncoder().encode(JSON.stringify(value)));
191
+ }
192
+ /** HMAC-SHA256 the signing input, returned base64url — the JWT signature. */
193
+ async function hmacSha256(data, secret) {
194
+ const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
195
+ const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
196
+ return b64url(new Uint8Array(sig));
197
+ }
198
+ const JWT_HEADER = b64urlJson({ alg: "HS256", typ: "JWT" });
199
+ export const jwt = {
200
+ /** Sign a payload into an HS256 JWT. Adds `iat`, and `exp` when `expiresIn` is set. */
201
+ async sign(payload, options = {}) {
202
+ const now = Math.floor(Date.now() / 1000);
203
+ const claims = { ...payload, iat: now };
204
+ if (options.subject !== undefined)
205
+ claims.sub = options.subject;
206
+ if (options.issuer !== undefined)
207
+ claims.iss = options.issuer;
208
+ if (options.audience !== undefined)
209
+ claims.aud = options.audience;
210
+ if (options.expiresIn !== undefined)
211
+ claims.exp = now + seconds(options.expiresIn);
212
+ const body = `${JWT_HEADER}.${b64urlJson(claims)}`;
213
+ const sig = await hmacSha256(body, options.secret ?? appKey());
214
+ return `${body}.${sig}`;
215
+ },
216
+ /**
217
+ * Verify an HS256 JWT and return its payload, or `null` if the token is
218
+ * malformed, tampered, expired, not-yet-valid, or fails an issuer/audience
219
+ * check. Only HS256 is accepted — `alg: none` and asymmetric algs are refused,
220
+ * closing the classic JWT algorithm-confusion hole.
221
+ */
222
+ async verify(token, options = {}) {
223
+ try {
224
+ const parts = token.split(".");
225
+ if (parts.length !== 3)
226
+ return null;
227
+ const [header, body, sig] = parts;
228
+ const alg = JSON.parse(new TextDecoder().decode(fromB64url(header))).alg;
229
+ if (alg !== "HS256")
230
+ return null;
231
+ const expected = await hmacSha256(`${header}.${body}`, options.secret ?? appKey());
232
+ if (!safeEqual(sig, expected))
233
+ return null;
234
+ const claims = JSON.parse(new TextDecoder().decode(fromB64url(body)));
235
+ const now = Math.floor(Date.now() / 1000);
236
+ if (typeof claims.exp === "number" && now >= claims.exp)
237
+ return null;
238
+ if (typeof claims.nbf === "number" && now < claims.nbf)
239
+ return null;
240
+ if (options.issuer !== undefined && claims.iss !== options.issuer)
241
+ return null;
242
+ if (options.audience !== undefined && claims.aud !== options.audience)
243
+ return null;
244
+ return claims;
101
245
  }
102
246
  catch {
103
247
  return null;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * CSRF protection for server-rendered apps. A synchronizer token lives in the
3
+ * session; state-changing requests (`POST`/`PUT`/`PATCH`/`DELETE`) must echo it
4
+ * back, or they're rejected with `419 Page Expired`. Needs `sessionMiddleware()`.
5
+ *
6
+ * this.use(sessionMiddleware());
7
+ * this.use(csrf());
8
+ *
9
+ * Put the token in forms with `csrfField()`; SPAs get it for free — `csrf()` also
10
+ * writes an `XSRF-TOKEN` cookie that axios/fetch libraries send back as the
11
+ * `X-XSRF-TOKEN` header automatically. The token is also read from a `_token` or
12
+ * `_csrf` field, or the `X-CSRF-Token` header.
13
+ */
14
+ import type { MiddlewareHandler } from "hono";
15
+ /** The current request's CSRF token, minting and storing one if absent. */
16
+ export declare function csrfToken(): string;
17
+ /** A hidden form input carrying the CSRF token — drop it into any `<form method="POST">`. */
18
+ export declare function csrfField(): string;
19
+ export interface CsrfOptions {
20
+ /** Paths exempt from verification (webhooks, callbacks). Trailing `*` matches a prefix. */
21
+ except?: (string | RegExp)[];
22
+ /** Write the readable `XSRF-TOKEN` cookie for SPA libraries. Default true. */
23
+ cookie?: boolean;
24
+ }
25
+ export declare function csrf(options?: CsrfOptions): MiddlewareHandler;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * CSRF protection for server-rendered apps. A synchronizer token lives in the
3
+ * session; state-changing requests (`POST`/`PUT`/`PATCH`/`DELETE`) must echo it
4
+ * back, or they're rejected with `419 Page Expired`. Needs `sessionMiddleware()`.
5
+ *
6
+ * this.use(sessionMiddleware());
7
+ * this.use(csrf());
8
+ *
9
+ * Put the token in forms with `csrfField()`; SPAs get it for free — `csrf()` also
10
+ * writes an `XSRF-TOKEN` cookie that axios/fetch libraries send back as the
11
+ * `X-XSRF-TOKEN` header automatically. The token is also read from a `_token` or
12
+ * `_csrf` field, or the `X-CSRF-Token` header.
13
+ */
14
+ import { setCookie } from "hono/cookie";
15
+ import { HttpException } from "./exceptions.js";
16
+ import { session } from "./session.js";
17
+ import { request } from "./request.js";
18
+ const KEY = "_csrf";
19
+ const UNSAFE = new Set(["POST", "PUT", "PATCH", "DELETE"]);
20
+ function randomToken() {
21
+ const bytes = crypto.getRandomValues(new Uint8Array(32));
22
+ let s = "";
23
+ for (const b of bytes)
24
+ s += String.fromCharCode(b);
25
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
26
+ }
27
+ function safeEqual(a, b) {
28
+ if (a.length !== b.length)
29
+ return false;
30
+ let result = 0;
31
+ for (let i = 0; i < a.length; i++)
32
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
33
+ return result === 0;
34
+ }
35
+ /** The current request's CSRF token, minting and storing one if absent. */
36
+ export function csrfToken() {
37
+ const sess = session();
38
+ let token = sess.get(KEY, undefined);
39
+ if (!token) {
40
+ token = randomToken();
41
+ sess.put(KEY, token);
42
+ }
43
+ return token;
44
+ }
45
+ /** A hidden form input carrying the CSRF token — drop it into any `<form method="POST">`. */
46
+ export function csrfField() {
47
+ return `<input type="hidden" name="_token" value="${csrfToken()}">`;
48
+ }
49
+ async function submitted(c) {
50
+ const header = c.req.header("x-csrf-token") ?? c.req.header("x-xsrf-token");
51
+ if (header)
52
+ return header;
53
+ const all = await request.all(); // shares keel's body cache — safe to read
54
+ return (all._token ?? all._csrf);
55
+ }
56
+ function isExcepted(path, except) {
57
+ return except.some((rule) => typeof rule === "string"
58
+ ? rule.endsWith("*")
59
+ ? path.startsWith(rule.slice(0, -1))
60
+ : path === rule
61
+ : rule.test(path));
62
+ }
63
+ export function csrf(options = {}) {
64
+ const except = options.except ?? [];
65
+ return async (c, next) => {
66
+ const token = csrfToken(); // ensures a token exists in the session
67
+ if (options.cookie !== false) {
68
+ setCookie(c, "XSRF-TOKEN", token, { path: "/", sameSite: "Lax" }); // readable by JS on purpose
69
+ }
70
+ if (UNSAFE.has(c.req.method.toUpperCase()) && !isExcepted(c.req.path, except)) {
71
+ const provided = await submitted(c);
72
+ if (!provided || !safeEqual(provided, token)) {
73
+ throw new HttpException(419, "CSRF token mismatch");
74
+ }
75
+ }
76
+ await next();
77
+ };
78
+ }
@@ -31,16 +31,40 @@ export interface Connection {
31
31
  }
32
32
  export type Dialect = "sqlite" | "mysql" | "postgres";
33
33
  export type Operator = "=" | "!=" | "<" | "<=" | ">" | ">=" | "like";
34
- /** Register the database connection (and dialect) used by `db()`. */
34
+ /** A registered connection — the driver bridge plus its SQL dialect. */
35
+ interface Source {
36
+ conn: Connection;
37
+ dialect: Dialect;
38
+ }
39
+ /** Register the default connection (and dialect) used by `db()`. */
35
40
  export declare function setConnection(conn: Connection, driverDialect?: Dialect): void;
41
+ /**
42
+ * Register a *named* connection alongside any others — the way to use more than
43
+ * one database. Point a query or model at it by name; nothing else changes.
44
+ *
45
+ * addConnection("reporting", pgConn, "postgres");
46
+ * await db("events", "reporting").where("kind", "signup").count();
47
+ */
48
+ export declare function addConnection(name: string, conn: Connection, driverDialect?: Dialect): void;
49
+ /** Choose which registered connection `db()` and models use when none is named. */
50
+ export declare function setDefaultConnection(name: string): void;
51
+ /** The names of every registered connection. */
52
+ export declare function connectionNames(): string[];
53
+ /** Unregister every connection — a test helper for a clean slate. */
54
+ export declare function clearConnections(): void;
36
55
  export declare class QueryBuilder<T extends Row = Row> {
37
56
  private table;
57
+ private getSource;
38
58
  private wheres;
39
59
  private orders;
40
60
  private columns;
41
61
  private _limit?;
42
62
  private _offset?;
43
- constructor(table: string);
63
+ constructor(table: string, getSource: () => Source);
64
+ /** Run a row-returning query on this builder's connection, dialect-adjusted. */
65
+ private runSelect;
66
+ /** Run a write on this builder's connection, dialect-adjusted. */
67
+ private runWrite;
44
68
  select(...columns: string[]): this;
45
69
  where(column: string, value: unknown): this;
46
70
  where(column: string, operator: Operator, value: unknown): this;
@@ -79,5 +103,26 @@ export declare class QueryBuilder<T extends Row = Row> {
79
103
  update(data: Row): Promise<WriteResult>;
80
104
  delete(): Promise<WriteResult>;
81
105
  }
82
- /** Start a query against a table. */
83
- export declare function db<T extends Row = Row>(table: string): QueryBuilder<T>;
106
+ /** Start a query against a table, on the default connection or a named one. */
107
+ export declare function db<T extends Row = Row>(table: string, connectionName?: string): QueryBuilder<T>;
108
+ /** A handle to one registered connection — query it, or run raw SQL on it. */
109
+ export interface ConnectionHandle {
110
+ /** Start a query against a table on this connection. */
111
+ table<T extends Row = Row>(table: string): QueryBuilder<T>;
112
+ /** Run a raw row-returning query (`?` placeholders, dialect-adjusted). */
113
+ select(sql: string, bindings?: unknown[]): Promise<Row[]>;
114
+ /** Run a raw write (`?` placeholders, dialect-adjusted). */
115
+ write(sql: string, bindings?: unknown[]): Promise<WriteResult>;
116
+ /** This connection's SQL dialect. */
117
+ readonly dialect: Dialect;
118
+ }
119
+ /**
120
+ * Get a handle to a named connection (or the default). Use it to run several
121
+ * queries against one database without repeating the name, or to reach the raw
122
+ * `select`/`write` bridge.
123
+ *
124
+ * const reporting = connection("reporting");
125
+ * await reporting.table("events").where("kind", "signup").count();
126
+ */
127
+ export declare function connection(name?: string): ConnectionHandle;
128
+ export {};
@@ -9,20 +9,56 @@
9
9
  * const user = await db("users").where("id", 1).first();
10
10
  * await db("users").insert({ email });
11
11
  */
12
- let connection;
13
- let dialect = "sqlite";
14
- /** Register the database connection (and dialect) used by `db()`. */
12
+ /**
13
+ * The connection registry. An app can talk to several databases at once —
14
+ * register each by name, then route a query with `db(table, name)`, a whole
15
+ * model with `static connection`, or a handle from `connection(name)`. The
16
+ * unnamed default lives under `"default"` so `db(table)` and `setConnection()`
17
+ * keep working unchanged.
18
+ */
19
+ const registry = new Map();
20
+ let defaultConnection = "default";
21
+ /** Register the default connection (and dialect) used by `db()`. */
15
22
  export function setConnection(conn, driverDialect = "sqlite") {
16
- connection = conn;
17
- dialect = driverDialect;
23
+ registry.set("default", { conn, dialect: driverDialect });
24
+ defaultConnection = "default";
25
+ }
26
+ /**
27
+ * Register a *named* connection alongside any others — the way to use more than
28
+ * one database. Point a query or model at it by name; nothing else changes.
29
+ *
30
+ * addConnection("reporting", pgConn, "postgres");
31
+ * await db("events", "reporting").where("kind", "signup").count();
32
+ */
33
+ export function addConnection(name, conn, driverDialect = "sqlite") {
34
+ registry.set(name, { conn, dialect: driverDialect });
35
+ }
36
+ /** Choose which registered connection `db()` and models use when none is named. */
37
+ export function setDefaultConnection(name) {
38
+ if (!registry.has(name))
39
+ throw new Error(`No database connection "${name}" to make default.`);
40
+ defaultConnection = name;
41
+ }
42
+ /** The names of every registered connection. */
43
+ export function connectionNames() {
44
+ return [...registry.keys()];
18
45
  }
19
- function conn() {
20
- if (!connection)
21
- throw new Error("No database connection. Call setConnection(conn, dialect).");
22
- return connection;
46
+ /** Unregister every connection — a test helper for a clean slate. */
47
+ export function clearConnections() {
48
+ registry.clear();
49
+ defaultConnection = "default";
23
50
  }
24
- /** Render `?` placeholders for the active dialect (Postgres uses $1, $2, …). */
25
- function placeholders(sql) {
51
+ /** Resolve a connection by name (or the default); throws if it isn't registered. */
52
+ function resolve(name) {
53
+ const source = registry.get(name ?? defaultConnection);
54
+ if (!source) {
55
+ throw new Error(`No database connection${name ? ` "${name}"` : ""}. ` +
56
+ `Call setConnection(conn, dialect) or addConnection(name, conn, dialect).`);
57
+ }
58
+ return source;
59
+ }
60
+ /** Render `?` placeholders for a dialect (Postgres uses $1, $2, …). */
61
+ function placeholders(sql, dialect) {
26
62
  if (dialect !== "postgres")
27
63
  return sql;
28
64
  let i = 0;
@@ -30,13 +66,28 @@ function placeholders(sql) {
30
66
  }
31
67
  export class QueryBuilder {
32
68
  table;
69
+ getSource;
33
70
  wheres = [];
34
71
  orders = [];
35
72
  columns = "*";
36
73
  _limit;
37
74
  _offset;
38
- constructor(table) {
75
+ // The connection is resolved lazily, when a query actually runs — so building
76
+ // a query never throws, and an unregistered connection surfaces as a rejected
77
+ // read/write rather than a synchronous error at construction.
78
+ constructor(table, getSource) {
39
79
  this.table = table;
80
+ this.getSource = getSource;
81
+ }
82
+ /** Run a row-returning query on this builder's connection, dialect-adjusted. */
83
+ runSelect(sql, bindings) {
84
+ const source = this.getSource();
85
+ return source.conn.select(placeholders(sql, source.dialect), bindings);
86
+ }
87
+ /** Run a write on this builder's connection, dialect-adjusted. */
88
+ runWrite(sql, bindings) {
89
+ const source = this.getSource();
90
+ return source.conn.write(placeholders(sql, source.dialect), bindings);
40
91
  }
41
92
  select(...columns) {
42
93
  this.columns = columns.length ? columns.join(", ") : "*";
@@ -114,7 +165,7 @@ export class QueryBuilder {
114
165
  sql += ` LIMIT ${this._limit}`;
115
166
  if (this._offset != null)
116
167
  sql += ` OFFSET ${this._offset}`;
117
- return (await conn().select(placeholders(sql), where.bindings));
168
+ return (await this.runSelect(sql, where.bindings));
118
169
  }
119
170
  async first() {
120
171
  this._limit = 1;
@@ -123,7 +174,7 @@ export class QueryBuilder {
123
174
  }
124
175
  async count() {
125
176
  const where = this.whereClause();
126
- const rows = (await conn().select(placeholders(`SELECT COUNT(*) AS count FROM ${this.table}${where.sql}`), where.bindings));
177
+ const rows = (await this.runSelect(`SELECT COUNT(*) AS count FROM ${this.table}${where.sql}`, where.bindings));
127
178
  return Number(rows[0]?.count ?? 0);
128
179
  }
129
180
  async exists() {
@@ -131,7 +182,7 @@ export class QueryBuilder {
131
182
  }
132
183
  async aggregate(fn, column) {
133
184
  const where = this.whereClause();
134
- const rows = (await conn().select(placeholders(`SELECT ${fn}(${column}) AS agg FROM ${this.table}${where.sql}`), where.bindings));
185
+ const rows = (await this.runSelect(`SELECT ${fn}(${column}) AS agg FROM ${this.table}${where.sql}`, where.bindings));
135
186
  return Number(rows[0]?.agg ?? 0);
136
187
  }
137
188
  sum(column) {
@@ -178,7 +229,7 @@ export class QueryBuilder {
178
229
  const sql = `INSERT INTO ${this.table} (${keys.join(", ")}) VALUES (${keys
179
230
  .map(() => "?")
180
231
  .join(", ")})`;
181
- return conn().write(placeholders(sql), Object.values(data));
232
+ return this.runWrite(sql, Object.values(data));
182
233
  }
183
234
  async insertGetId(data) {
184
235
  return (await this.insert(data)).insertId;
@@ -187,17 +238,34 @@ export class QueryBuilder {
187
238
  const keys = Object.keys(data);
188
239
  const set = keys.map((k) => `${k} = ?`).join(", ");
189
240
  const where = this.whereClause();
190
- return conn().write(placeholders(`UPDATE ${this.table} SET ${set}${where.sql}`), [
241
+ return this.runWrite(`UPDATE ${this.table} SET ${set}${where.sql}`, [
191
242
  ...Object.values(data),
192
243
  ...where.bindings,
193
244
  ]);
194
245
  }
195
246
  async delete() {
196
247
  const where = this.whereClause();
197
- return conn().write(placeholders(`DELETE FROM ${this.table}${where.sql}`), where.bindings);
248
+ return this.runWrite(`DELETE FROM ${this.table}${where.sql}`, where.bindings);
198
249
  }
199
250
  }
200
- /** Start a query against a table. */
201
- export function db(table) {
202
- return new QueryBuilder(table);
251
+ /** Start a query against a table, on the default connection or a named one. */
252
+ export function db(table, connectionName) {
253
+ return new QueryBuilder(table, () => resolve(connectionName));
254
+ }
255
+ /**
256
+ * Get a handle to a named connection (or the default). Use it to run several
257
+ * queries against one database without repeating the name, or to reach the raw
258
+ * `select`/`write` bridge.
259
+ *
260
+ * const reporting = connection("reporting");
261
+ * await reporting.table("events").where("kind", "signup").count();
262
+ */
263
+ export function connection(name) {
264
+ const source = resolve(name);
265
+ return {
266
+ table: (t) => new QueryBuilder(t, () => source),
267
+ select: (sql, bindings = []) => source.conn.select(placeholders(sql, source.dialect), bindings),
268
+ write: (sql, bindings = []) => source.conn.write(placeholders(sql, source.dialect), bindings),
269
+ dialect: source.dialect,
270
+ };
203
271
  }
@@ -38,6 +38,12 @@ export declare function instance<T>(token: Token<T>, value: T): T;
38
38
  export declare function make<T>(token: Token<T>): T;
39
39
  /** Whether a token is bound or has a cached instance. */
40
40
  export declare function bound(token: Token): boolean;
41
+ /** Register an alias token that resolves to another token. */
42
+ export declare function alias<T>(aliasToken: Token<T>, target: Token<T>): void;
43
+ /** Temporarily replace a binding with a fake (tests). Undo with `restore()`. */
44
+ export declare function swap<T>(token: Token<T>, factory: Factory<T>): void;
45
+ /** Undo a `swap()` — restore the original binding. No token restores every swap. */
46
+ export declare function restore(token?: Token): void;
41
47
  /** The application's event emitter. */
42
48
  export declare function events(): Events;
43
49
  /** Emit an event, awaiting every listener. */
@@ -65,6 +65,18 @@ export function make(token) {
65
65
  export function bound(token) {
66
66
  return app().bound(token);
67
67
  }
68
+ /** Register an alias token that resolves to another token. */
69
+ export function alias(aliasToken, target) {
70
+ app().alias(aliasToken, target);
71
+ }
72
+ /** Temporarily replace a binding with a fake (tests). Undo with `restore()`. */
73
+ export function swap(token, factory) {
74
+ app().swap(token, factory);
75
+ }
76
+ /** Undo a `swap()` — restore the original binding. No token restores every swap. */
77
+ export function restore(token) {
78
+ app().restore(token);
79
+ }
68
80
  /* ------------------------------- events -------------------------------- */
69
81
  /** The application's event emitter. */
70
82
  export function events() {
@@ -4,7 +4,7 @@ export type { Token, Constructor, Factory } from "./container.js";
4
4
  export { Application } from "./application.js";
5
5
  export type { BootOptions, LifecycleHook, Configurator } from "./application.js";
6
6
  export { Config, env } from "./config.js";
7
- export { app, config, view, bind, singleton, instance, make, bound, events, emit, listen, cache, logger, onReady, onShutdown, terminate, } from "./helpers.js";
7
+ export { app, config, view, bind, singleton, instance, make, bound, alias, swap, restore, events, emit, listen, cache, logger, onReady, onShutdown, terminate, } from "./helpers.js";
8
8
  export { Logger } from "./logger.js";
9
9
  export type { LogLevel, LoggerOptions } from "./logger.js";
10
10
  export { requestLogger, requestLog } from "./request-logger.js";
@@ -18,11 +18,18 @@ export type { StaticOptions } from "./static.js";
18
18
  export { Storage, MemoryDisk, storage, setDisk } from "./storage.js";
19
19
  export type { Disk, Contents } from "./storage.js";
20
20
  export { dump, dd } from "./debug.js";
21
- export { hash, encryption } from "./crypto.js";
21
+ export { hash, encryption, jwt } from "./crypto.js";
22
+ export type { JwtPayload, JwtSignOptions, JwtVerifyOptions, EncryptOptions } from "./crypto.js";
22
23
  export { rateLimiter } from "./rate-limit.js";
23
24
  export type { RateLimiterOptions } from "./rate-limit.js";
24
- export { db, setConnection, QueryBuilder } from "./database.js";
25
- export type { Connection, WriteResult, Row, Dialect, Operator, Paginated } from "./database.js";
25
+ export { cors } from "./cors.js";
26
+ export type { CorsOptions } from "./cors.js";
27
+ export { securityHeaders } from "./shield.js";
28
+ export type { SecurityHeadersOptions, HstsOptions } from "./shield.js";
29
+ export { csrf, csrfToken, csrfField } from "./csrf.js";
30
+ export type { CsrfOptions } from "./csrf.js";
31
+ export { db, connection, setConnection, addConnection, setDefaultConnection, connectionNames, clearConnections, QueryBuilder, } from "./database.js";
32
+ export type { Connection, ConnectionHandle, WriteResult, Row, Dialect, Operator, Paginated } from "./database.js";
26
33
  export { Model } from "./model.js";
27
34
  export type { CastType, Casts } from "./casts.js";
28
35
  export { Relation, HasOne, HasMany, BelongsTo, BelongsToMany } from "./relations.js";
@@ -62,10 +69,14 @@ export { validate, validateRequest, validated } from "./validation.js";
62
69
  export type { Schema, RequestSchemas } from "./validation.js";
63
70
  export { Session, session, sessionMiddleware } from "./session.js";
64
71
  export type { SessionOptions } from "./session.js";
65
- export { Auth, auth, authGuard, setUserProvider } from "./auth.js";
66
- export type { UserProvider } from "./auth.js";
67
- export { define, policy, gateBefore, setUserResolver, clearAuthorization, can, cannot, canFor, authorize, authorizeFor, } from "./authorization.js";
68
- export type { GateCallback, BeforeCallback } from "./authorization.js";
72
+ export { Auth, auth, authGuard, bearerAuth, basicAuth, tokenAuth, token, tokenCan, setUserProvider } from "./auth.js";
73
+ export type { UserProvider, BasicVerifier } from "./auth.js";
74
+ export { createToken, verifyToken, revokeToken, revokeTokens, listTokens, tokenAllows, tokenDenies, setTokensTable, } from "./tokens.js";
75
+ export type { AccessToken, CreateTokenOptions, IssuedToken } from "./tokens.js";
76
+ export { social, github, google, discord, oauthDriver, oauthState, OAuthDriver, OAuthError, twitter, oauth1Driver, oauth1Signature, OAuth1Driver, } from "./social.js";
77
+ export type { SocialUser, OAuthToken, OAuthConfig, ProviderSpec, RedirectOptions, OAuth1Config, OAuth1Token, OAuth1ProviderSpec, } from "./social.js";
78
+ export { define, policy, gateBefore, gateAfter, setUserResolver, clearAuthorization, can, cannot, canFor, authorize, authorizeFor, } from "./authorization.js";
79
+ export type { GateCallback, BeforeCallback, AfterCallback } from "./authorization.js";
69
80
  export { Transformer } from "./transformer.js";
70
81
  export type { Attributes, DocumentOptions } from "./transformer.js";
71
82
  export { Broker, Service, LocalTransporter, ServiceNotFoundError, RequestTimeoutError, broker, setBroker, } from "./broker.js";