@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.
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Personal access tokens — opaque, database-backed bearer tokens for API and
3
+ * mobile clients, an alternative to the stateless [`jwt`](./crypto.ts). Unlike a
4
+ * JWT, an opaque token can be *revoked* instantly (it's a row you delete), carries
5
+ * *abilities* (scopes), and tracks *last used* — at the cost of a lookup per
6
+ * request. Built on the `db()` layer, so it runs anywhere a `Connection` does.
7
+ *
8
+ * const { token } = await createToken(user.id, { abilities: ["posts:write"] });
9
+ * // → "keel_<selector>.<verifier>" — show once, never recoverable
10
+ *
11
+ * const record = await verifyToken(token); // { tokenableId, abilities, … } | null
12
+ * tokenAllows(record, "posts:write"); // true
13
+ *
14
+ * Pair it with `tokenAuth()` in ./auth.ts to protect routes. The token splits
15
+ * into a public *selector* (indexed, for lookup) and a secret *verifier* (stored
16
+ * only as a SHA-256 hash) — so a leaked database can't mint working tokens, and
17
+ * verification needs no `RETURNING`/auto-increment (portable across every driver).
18
+ *
19
+ * Expected table (`personal_access_tokens` by default), all timestamps epoch-ms:
20
+ * selector TEXT UNIQUE, hash TEXT, tokenable_id TEXT, name TEXT,
21
+ * abilities TEXT (JSON), last_used_at INTEGER, expires_at INTEGER, created_at INTEGER
22
+ */
23
+ import { db } from "./database.js";
24
+ /** The table tokens are stored in; override with `setTokensTable`. */
25
+ let table = "personal_access_tokens";
26
+ /** Change the table personal access tokens are stored in (default `personal_access_tokens`). */
27
+ export function setTokensTable(name) {
28
+ table = name;
29
+ }
30
+ /* -------------------------------- crypto -------------------------------- */
31
+ const DURATION = /^(\d+)\s*(s|m|h|d)$/;
32
+ const UNIT = { s: 1, m: 60, h: 3600, d: 86400 };
33
+ function seconds(value) {
34
+ if (typeof value === "number")
35
+ return value;
36
+ const match = DURATION.exec(value.trim());
37
+ if (!match)
38
+ throw new Error(`Invalid duration "${value}" (use e.g. 3600, "30m", "12h", "30d").`);
39
+ return Number(match[1]) * UNIT[match[2]];
40
+ }
41
+ function base64url(bytes) {
42
+ let s = "";
43
+ for (const b of bytes)
44
+ s += String.fromCharCode(b);
45
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
46
+ }
47
+ function randomToken(size) {
48
+ return base64url(crypto.getRandomValues(new Uint8Array(size)));
49
+ }
50
+ /** SHA-256 of the verifier, base64url — what we actually persist. */
51
+ async function sha256(value) {
52
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
53
+ return base64url(new Uint8Array(digest));
54
+ }
55
+ /** Constant-time string compare, so a bad hash can't be timed byte-by-byte. */
56
+ function safeEqual(a, b) {
57
+ if (a.length !== b.length)
58
+ return false;
59
+ let result = 0;
60
+ for (let i = 0; i < a.length; i++)
61
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
62
+ return result === 0;
63
+ }
64
+ /* ------------------------------- lifecycle ------------------------------ */
65
+ /** Mint a new access token for an entity. Returns the plaintext once — persist nothing but the hash. */
66
+ export async function createToken(tokenableId, options = {}) {
67
+ const selector = randomToken(12);
68
+ const verifier = randomToken(24);
69
+ const abilities = options.abilities ?? ["*"];
70
+ const expiresAt = options.expiresIn != null ? Date.now() + seconds(options.expiresIn) * 1000 : null;
71
+ await db(table, options.connection).insert({
72
+ selector,
73
+ hash: await sha256(verifier),
74
+ tokenable_id: String(tokenableId),
75
+ name: options.name ?? null,
76
+ abilities: JSON.stringify(abilities),
77
+ last_used_at: null,
78
+ expires_at: expiresAt,
79
+ created_at: Date.now(),
80
+ });
81
+ return { token: `keel_${selector}.${verifier}`, selector, abilities, expiresAt };
82
+ }
83
+ /**
84
+ * Verify a plaintext token and return its record, or `null` if it's malformed,
85
+ * unknown, tampered, or expired. On success it stamps `last_used_at`. An expired
86
+ * token is deleted in passing, so the table self-prunes as stale tokens are tried.
87
+ */
88
+ export async function verifyToken(token, connection) {
89
+ const match = /^keel_([^.]+)\.(.+)$/.exec(token);
90
+ if (!match)
91
+ return null;
92
+ const selector = match[1];
93
+ const verifier = match[2];
94
+ const row = await db(table, connection).where("selector", selector).first();
95
+ if (!row)
96
+ return null;
97
+ if (!safeEqual(await sha256(verifier), String(row.hash)))
98
+ return null;
99
+ const expiresAt = row.expires_at != null ? Number(row.expires_at) : null;
100
+ if (expiresAt != null && Date.now() >= expiresAt) {
101
+ await db(table, connection).where("selector", selector).delete();
102
+ return null;
103
+ }
104
+ const now = Date.now();
105
+ await db(table, connection).where("selector", selector).update({ last_used_at: now });
106
+ return {
107
+ selector,
108
+ tokenableId: String(row.tokenable_id),
109
+ name: row.name ?? null,
110
+ abilities: parseAbilities(row.abilities),
111
+ lastUsedAt: now,
112
+ expiresAt,
113
+ };
114
+ }
115
+ /** Whether a verified token grants an ability (`["*"]` grants everything). */
116
+ export function tokenAllows(token, ability) {
117
+ if (!token)
118
+ return false;
119
+ return token.abilities.includes("*") || token.abilities.includes(ability);
120
+ }
121
+ /** The negation of `tokenAllows`. */
122
+ export function tokenDenies(token, ability) {
123
+ return !tokenAllows(token, ability);
124
+ }
125
+ /** Revoke a single token by its selector (the part before the `.`). */
126
+ export async function revokeToken(selector, connection) {
127
+ await db(table, connection).where("selector", selector).delete();
128
+ }
129
+ /** Revoke every token belonging to an entity — a "log out everywhere" switch. */
130
+ export async function revokeTokens(tokenableId, connection) {
131
+ await db(table, connection).where("tokenable_id", String(tokenableId)).delete();
132
+ }
133
+ /** List an entity's tokens (metadata only — the secret is never stored). */
134
+ export async function listTokens(tokenableId, connection) {
135
+ const rows = await db(table, connection).where("tokenable_id", String(tokenableId)).get();
136
+ return rows.map((row) => ({
137
+ selector: String(row.selector),
138
+ tokenableId: String(row.tokenable_id),
139
+ name: row.name ?? null,
140
+ abilities: parseAbilities(row.abilities),
141
+ lastUsedAt: row.last_used_at != null ? Number(row.last_used_at) : null,
142
+ expiresAt: row.expires_at != null ? Number(row.expires_at) : null,
143
+ }));
144
+ }
145
+ function parseAbilities(value) {
146
+ if (typeof value !== "string")
147
+ return Array.isArray(value) ? value : ["*"];
148
+ try {
149
+ const parsed = JSON.parse(value);
150
+ return Array.isArray(parsed) ? parsed : ["*"];
151
+ }
152
+ catch {
153
+ return ["*"];
154
+ }
155
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * A Keel `Connection` for Cloudflare D1. Pass your D1 binding (from `env.DB`)
3
+ * and register it — dialect `"sqlite"`:
4
+ *
5
+ * import { d1Connection } from "@shaferllc/keel/db/d1";
6
+ * import { setConnection } from "@shaferllc/keel/core";
7
+ *
8
+ * setConnection(d1Connection(env.DB), "sqlite");
9
+ *
10
+ * The binding is duck-typed — this module imports no driver and no
11
+ * `@cloudflare/workers-types`, so it stays edge-native and dependency-free. Any
12
+ * object shaped like a D1 database works.
13
+ */
14
+ import type { Connection, Row } from "../core/database.js";
15
+ /** The slice of the D1 `Database` API this adapter uses. */
16
+ export interface D1Like {
17
+ prepare(sql: string): {
18
+ bind(...values: unknown[]): {
19
+ all<T = Row>(): Promise<{
20
+ results?: T[];
21
+ }>;
22
+ run(): Promise<{
23
+ meta: {
24
+ changes?: number;
25
+ last_row_id?: number;
26
+ };
27
+ }>;
28
+ };
29
+ };
30
+ }
31
+ /** Build a `Connection` backed by a Cloudflare D1 binding. */
32
+ export declare function d1Connection(database: D1Like): Connection;
package/dist/db/d1.js ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * A Keel `Connection` for Cloudflare D1. Pass your D1 binding (from `env.DB`)
3
+ * and register it — dialect `"sqlite"`:
4
+ *
5
+ * import { d1Connection } from "@shaferllc/keel/db/d1";
6
+ * import { setConnection } from "@shaferllc/keel/core";
7
+ *
8
+ * setConnection(d1Connection(env.DB), "sqlite");
9
+ *
10
+ * The binding is duck-typed — this module imports no driver and no
11
+ * `@cloudflare/workers-types`, so it stays edge-native and dependency-free. Any
12
+ * object shaped like a D1 database works.
13
+ */
14
+ /** Build a `Connection` backed by a Cloudflare D1 binding. */
15
+ export function d1Connection(database) {
16
+ return {
17
+ async select(sql, bindings) {
18
+ const { results } = await database.prepare(sql).bind(...bindings).all();
19
+ return results ?? [];
20
+ },
21
+ async write(sql, bindings) {
22
+ const { meta } = await database.prepare(sql).bind(...bindings).run();
23
+ return { rowsAffected: meta.changes ?? 0, insertId: meta.last_row_id };
24
+ },
25
+ };
26
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * A Keel `Connection` for libSQL / Turso. Pass an `@libsql/client` client (it
3
+ * runs on Node and the edge — Turso speaks HTTP) and register it with dialect
4
+ * `"sqlite"`:
5
+ *
6
+ * import { libsqlConnection } from "@shaferllc/keel/db/libsql";
7
+ * import { setConnection } from "@shaferllc/keel/core";
8
+ * import { createClient } from "@libsql/client";
9
+ *
10
+ * const client = createClient({ url: env.TURSO_URL, authToken: env.TURSO_TOKEN });
11
+ * setConnection(libsqlConnection(client), "sqlite");
12
+ *
13
+ * The client is duck-typed — this module imports no driver, so it never bundles
14
+ * `@libsql/client`.
15
+ */
16
+ import type { Connection, Row } from "../core/database.js";
17
+ /** The slice of the `@libsql/client` API this adapter uses. */
18
+ export interface LibSqlLike {
19
+ execute(stmt: {
20
+ sql: string;
21
+ args: unknown[];
22
+ }): Promise<{
23
+ rows: Row[];
24
+ rowsAffected: number;
25
+ lastInsertRowid?: bigint | number;
26
+ }>;
27
+ }
28
+ /** Build a `Connection` backed by an `@libsql/client` client. */
29
+ export declare function libsqlConnection(client: LibSqlLike): Connection;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * A Keel `Connection` for libSQL / Turso. Pass an `@libsql/client` client (it
3
+ * runs on Node and the edge — Turso speaks HTTP) and register it with dialect
4
+ * `"sqlite"`:
5
+ *
6
+ * import { libsqlConnection } from "@shaferllc/keel/db/libsql";
7
+ * import { setConnection } from "@shaferllc/keel/core";
8
+ * import { createClient } from "@libsql/client";
9
+ *
10
+ * const client = createClient({ url: env.TURSO_URL, authToken: env.TURSO_TOKEN });
11
+ * setConnection(libsqlConnection(client), "sqlite");
12
+ *
13
+ * The client is duck-typed — this module imports no driver, so it never bundles
14
+ * `@libsql/client`.
15
+ */
16
+ /** Build a `Connection` backed by an `@libsql/client` client. */
17
+ export function libsqlConnection(client) {
18
+ return {
19
+ async select(sql, bindings) {
20
+ const { rows } = await client.execute({ sql, args: bindings });
21
+ // Normalize libSQL's Row objects to plain records for casts/serialization.
22
+ return rows.map((row) => ({ ...row }));
23
+ },
24
+ async write(sql, bindings) {
25
+ const { rowsAffected, lastInsertRowid } = await client.execute({ sql, args: bindings });
26
+ return {
27
+ rowsAffected,
28
+ insertId: lastInsertRowid != null ? Number(lastInsertRowid) : undefined,
29
+ };
30
+ },
31
+ };
32
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * A Keel `Connection` for Postgres. Pass any node-postgres-compatible client —
3
+ * a `pg` `Pool`/`Client` on Node, or `@neondatabase/serverless` on the edge (both
4
+ * expose the same `query(text, values)` API) — and register it with dialect
5
+ * `"postgres"`:
6
+ *
7
+ * import { pgConnection } from "@shaferllc/keel/db/pg";
8
+ * import { setConnection } from "@shaferllc/keel/core";
9
+ * import { Pool } from "pg"; // or "@neondatabase/serverless"
10
+ *
11
+ * setConnection(pgConnection(new Pool({ connectionString })), "postgres");
12
+ *
13
+ * The client is duck-typed — this module imports no driver, so it works with
14
+ * whichever Postgres client you install and never bundles one.
15
+ *
16
+ * Note on `insertId`: Postgres only returns an inserted row when the statement
17
+ * has a `RETURNING` clause. This adapter surfaces `rows[0].id` when present;
18
+ * otherwise `insertId` is `undefined` (so `insertGetId()` needs a `RETURNING id`).
19
+ */
20
+ import type { Connection, Row } from "../core/database.js";
21
+ /** The slice of the node-postgres client API this adapter uses. */
22
+ export interface PgLike {
23
+ query(text: string, values?: unknown[]): Promise<{
24
+ rows: Row[];
25
+ rowCount: number | null;
26
+ }>;
27
+ }
28
+ /** Build a `Connection` backed by a node-postgres-compatible client. */
29
+ export declare function pgConnection(client: PgLike): Connection;
package/dist/db/pg.js ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * A Keel `Connection` for Postgres. Pass any node-postgres-compatible client —
3
+ * a `pg` `Pool`/`Client` on Node, or `@neondatabase/serverless` on the edge (both
4
+ * expose the same `query(text, values)` API) — and register it with dialect
5
+ * `"postgres"`:
6
+ *
7
+ * import { pgConnection } from "@shaferllc/keel/db/pg";
8
+ * import { setConnection } from "@shaferllc/keel/core";
9
+ * import { Pool } from "pg"; // or "@neondatabase/serverless"
10
+ *
11
+ * setConnection(pgConnection(new Pool({ connectionString })), "postgres");
12
+ *
13
+ * The client is duck-typed — this module imports no driver, so it works with
14
+ * whichever Postgres client you install and never bundles one.
15
+ *
16
+ * Note on `insertId`: Postgres only returns an inserted row when the statement
17
+ * has a `RETURNING` clause. This adapter surfaces `rows[0].id` when present;
18
+ * otherwise `insertId` is `undefined` (so `insertGetId()` needs a `RETURNING id`).
19
+ */
20
+ /** Build a `Connection` backed by a node-postgres-compatible client. */
21
+ export function pgConnection(client) {
22
+ return {
23
+ async select(sql, bindings) {
24
+ const { rows } = await client.query(sql, bindings);
25
+ return rows;
26
+ },
27
+ async write(sql, bindings) {
28
+ const { rows, rowCount } = await client.query(sql, bindings);
29
+ const insertId = rows?.[0]?.id;
30
+ return { rowsAffected: rowCount ?? 0, insertId };
31
+ },
32
+ };
33
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shaferllc/keel",
3
- "version": "0.58.0",
3
+ "version": "0.66.0",
4
4
  "type": "module",
5
5
  "description": "The house framework for Node.js — a service container, providers, routing, JSX views, and a code-generating console.",
6
6
  "license": "MIT",
@@ -28,6 +28,18 @@
28
28
  "./vite": {
29
29
  "types": "./dist/vite/index.d.ts",
30
30
  "import": "./dist/vite/index.js"
31
+ },
32
+ "./db/d1": {
33
+ "types": "./dist/db/d1.d.ts",
34
+ "import": "./dist/db/d1.js"
35
+ },
36
+ "./db/pg": {
37
+ "types": "./dist/db/pg.d.ts",
38
+ "import": "./dist/db/pg.js"
39
+ },
40
+ "./db/libsql": {
41
+ "types": "./dist/db/libsql.d.ts",
42
+ "import": "./dist/db/libsql.js"
31
43
  }
32
44
  },
33
45
  "files": [