dzql 0.7.0 → 0.9.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,209 @@
1
+ /**
2
+ * Auth-JWT — reference `DeltaAuth` implementation using JWTs (jose) and
3
+ * Postgres stored functions for credential verification.
4
+ *
5
+ * Ship this as an opt-in subpath: `@blueshed/delta/auth-jwt`. Users who want
6
+ * a different auth scheme (sessions, OAuth, magic links, none) implement
7
+ * their own `DeltaAuth` instead.
8
+ *
9
+ * Contract with Postgres:
10
+ * - `login(email, password) RETURNS jsonb` — return a user row or NULL.
11
+ * - `register(name, email, password) RETURNS jsonb` — return a user row,
12
+ * raise unique_violation (23505) on duplicate email.
13
+ *
14
+ * Both are overridable via the `loginSql` / `registerSql` options.
15
+ *
16
+ * import { jwtAuth } from "@blueshed/delta/auth-jwt";
17
+ *
18
+ * const auth = jwtAuth({
19
+ * pool,
20
+ * secret: process.env.JWT_SECRET!,
21
+ * });
22
+ *
23
+ * wireAuth(ws, auth);
24
+ * await createDocListener(ws, pool, { auth });
25
+ *
26
+ * SQL-file helpers (`authJwtSqlFile`, `authJwtSql`, `applyAuthJwtSchema`) live
27
+ * in `./auth-jwt-sql.ts` and are re-exported from here for convenience. They
28
+ * have no jose dependency, so the `delta init` CLI can load them without
29
+ * forcing consumers to install jose just to copy SQL files.
30
+ */
31
+ import { SignJWT, jwtVerify, decodeJwt } from "jose";
32
+ import type { Pool } from "pg";
33
+ import { createLogger } from "@blueshed/delta/logger";
34
+ import type { DeltaAuth } from "@blueshed/delta/auth";
35
+ import { dropClientSubscriptions } from "@blueshed/delta/backend";
36
+
37
+ /** Algorithm this module signs with — pinned on verify to prevent alg-confusion. */
38
+ const JWT_ALG = "HS256";
39
+
40
+ export {
41
+ authJwtSqlFile,
42
+ authJwtSql,
43
+ applyAuthJwtSchema,
44
+ } from "./auth-jwt-sql";
45
+
46
+ const log = createLogger("[auth-jwt]");
47
+
48
+ export interface User {
49
+ id: number | string;
50
+ name?: string;
51
+ email?: string;
52
+ [key: string]: unknown;
53
+ }
54
+
55
+ export interface JwtAuthOpts {
56
+ pool: Pool;
57
+ secret: string | Uint8Array;
58
+ expirationTime?: string;
59
+ loginSql?: string;
60
+ registerSql?: string;
61
+ /**
62
+ * Optional re-validation hook for `authenticate`. A JWT is stateless: by
63
+ * default a signed, unexpired token grants access even if the user was
64
+ * since deleted or disabled (the documented trade-off of stateless auth).
65
+ * Provide `verifyUser` to re-check the identity against the database on
66
+ * each `authenticate`; return the (possibly refreshed) user to allow, or
67
+ * `null` to reject. Not called on `login` / `register` (those just hit the DB).
68
+ */
69
+ verifyUser?: (user: User, pool: Pool) => Promise<User | null>;
70
+ }
71
+
72
+ /** Set the socket's identity, tearing down a prior *different* identity's
73
+ * live subscriptions so an identity switch on one socket can't keep
74
+ * receiving the previous user's scoped docs. `expires` is when the token it
75
+ * came from runs out (ms since the epoch); `gate` refuses it after that. */
76
+ function switchIdentity(client: any, user: User, expires: number | undefined): void {
77
+ const prev = client.data?.identity as User | undefined;
78
+ if (prev && String(prev.id) !== String(user.id)) {
79
+ dropClientSubscriptions(client);
80
+ }
81
+ if (!client.data) client.data = {};
82
+ client.data.identity = user;
83
+ client.data.identityExpires = expires;
84
+ }
85
+
86
+ /** When a token's `exp` says it runs out, in ms since the epoch. */
87
+ const expiresOf = (exp: unknown): number | undefined => (typeof exp === "number" ? exp * 1000 : undefined);
88
+
89
+ export function jwtAuth(opts: JwtAuthOpts): DeltaAuth<User> {
90
+ const secret =
91
+ typeof opts.secret === "string"
92
+ ? new TextEncoder().encode(opts.secret)
93
+ : opts.secret;
94
+ const expirationTime = opts.expirationTime ?? "7d";
95
+ const loginSql = opts.loginSql ?? "SELECT login($1, $2) AS result";
96
+ const registerSql = opts.registerSql ?? "SELECT register($1, $2, $3) AS result";
97
+
98
+ async function signToken(user: User): Promise<string> {
99
+ return new SignJWT({
100
+ sub: String(user.id),
101
+ name: user.name,
102
+ email: user.email,
103
+ })
104
+ .setProtectedHeader({ alg: JWT_ALG })
105
+ .setIssuedAt()
106
+ .setExpirationTime(expirationTime)
107
+ .sign(secret);
108
+ }
109
+
110
+ async function verifyToken(token: string): Promise<{ user: User; expires: number | undefined } | null> {
111
+ try {
112
+ // Pin the algorithm allowlist — without it, jose accepts any alg the
113
+ // token header claims, opening an alg-confusion vector.
114
+ const { payload } = await jwtVerify(token, secret, { algorithms: [JWT_ALG] });
115
+ const id = payload.sub;
116
+ if (id === undefined) return null;
117
+ const user: User = {
118
+ id: /^\d+$/.test(String(id)) ? Number(id) : String(id),
119
+ name: payload.name as string | undefined,
120
+ email: payload.email as string | undefined,
121
+ };
122
+ return { user, expires: expiresOf(payload.exp) };
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
128
+ return {
129
+ actions: {
130
+ async login(params, client) {
131
+ const { email, password } = params ?? {};
132
+ if (!email || !password) return { error: "email and password required" };
133
+ const { rows } = await opts.pool.query(loginSql, [email, password]);
134
+ const user = rows[0]?.result as User | null;
135
+ if (!user) return { error: "Invalid credentials" };
136
+ const token = await signToken(user);
137
+ switchIdentity(client, user, expiresOf(decodeJwt(token).exp));
138
+ log.info(`login user=${user.id}`);
139
+ return { result: { ...user, token } };
140
+ },
141
+
142
+ async register(params, client) {
143
+ const { name, email, password } = params ?? {};
144
+ if (!name || !email || !password) {
145
+ return { error: "name, email, and password required" };
146
+ }
147
+ try {
148
+ const { rows } = await opts.pool.query(registerSql, [name, email, password]);
149
+ const user = rows[0]?.result as User;
150
+ const token = await signToken(user);
151
+ switchIdentity(client, user, expiresOf(decodeJwt(token).exp));
152
+ log.info(`register user=${user.id}`);
153
+ return { result: { ...user, token } };
154
+ } catch (err: any) {
155
+ if (err.code === "23505") return { error: "Email already registered" };
156
+ throw err;
157
+ }
158
+ },
159
+
160
+ async authenticate(params, client) {
161
+ const { token } = params ?? {};
162
+ if (!token) return { error: "token required" };
163
+ const verified = await verifyToken(token);
164
+ if (!verified) return { error: "Invalid token" };
165
+ let user = verified.user;
166
+ if (opts.verifyUser) {
167
+ const checked = await opts.verifyUser(user, opts.pool);
168
+ if (!checked) return { error: "Invalid token" };
169
+ user = checked;
170
+ }
171
+ switchIdentity(client, user, verified.expires);
172
+ log.info(`authenticate user=${user.id}`);
173
+ return { result: user };
174
+ },
175
+
176
+ async logout(_params, client) {
177
+ const prev = client.data?.identity as User | undefined;
178
+ if (client.data) { delete client.data.identity; delete client.data.identityExpires; }
179
+ // Tear down the prior identity's live doc subscriptions so its scoped
180
+ // ops stop streaming to this socket immediately.
181
+ dropClientSubscriptions(client);
182
+ if (prev) log.info(`logout user=${prev.id}`);
183
+ return { result: { ack: true } };
184
+ },
185
+ },
186
+
187
+ gate(client) {
188
+ // A session lasts as long as its token: once it runs out the socket is
189
+ // signed out (and its documents let go), as a logout would do, and says
190
+ // so until it signs in again -- the listener asks the gate before it
191
+ // tells a socket of a change, too, so that is not always a request.
192
+ const expires = client.data?.identityExpires as number | undefined;
193
+ if (expires !== undefined && Date.now() >= expires) {
194
+ if (client.data.identity !== undefined) {
195
+ delete client.data.identity;
196
+ dropClientSubscriptions(client);
197
+ }
198
+ return { error: "Session expired: authenticate again" };
199
+ }
200
+ const identity = client.data?.identity as User | undefined;
201
+ if (!identity) return { error: "Authentication required" };
202
+ return identity;
203
+ },
204
+
205
+ asSqlArg(identity) {
206
+ return identity.id;
207
+ },
208
+ };
209
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Postgres auth helpers — RLS session context for delta-doc queries.
3
+ *
4
+ * `withAppAuth(pool, sqlArg, fn)` checks out a client, opens a transaction,
5
+ * sets `app.user_id` for the duration of the transaction, runs `fn` with the
6
+ * client, then commits and releases. Row-Level Security policies that read
7
+ * `current_setting('app.user_id', true)` then filter automatically.
8
+ *
9
+ * CREATE POLICY venues_owner ON venues
10
+ * FOR ALL USING (owner_id = current_setting('app.user_id', true)::int);
11
+ *
12
+ * await withAppAuth(pool, user.id, c =>
13
+ * c.query("SELECT delta_open($1) AS doc", [docName]),
14
+ * );
15
+ *
16
+ * The session variable is set via `SET LOCAL` so it reverts on transaction
17
+ * end — safe against pool reuse.
18
+ *
19
+ * Re-exports the `DeltaAuth` contract from `../auth` for convenience.
20
+ */
21
+ import type { Pool, PoolClient } from "pg";
22
+
23
+ export type { DeltaAuth, AuthError, AuthAction } from "@blueshed/delta/auth";
24
+ export { isAuthError, wireAuth, upgradeWithAuth } from "@blueshed/delta/auth";
25
+
26
+ /**
27
+ * Run `fn` inside a transaction with `SET LOCAL app.user_id = <sqlArg>`. All
28
+ * queries issued on the passed client observe RLS policies scoped to that
29
+ * identity.
30
+ */
31
+ export async function withAppAuth<T>(
32
+ pool: Pool,
33
+ sqlArg: string | number,
34
+ fn: (c: PoolClient) => Promise<T>,
35
+ ): Promise<T> {
36
+ const client = await pool.connect();
37
+ try {
38
+ await client.query("BEGIN");
39
+ // SET LOCAL cannot be parameterised — use set_config(..., is_local=true).
40
+ await client.query("SELECT set_config('app.user_id', $1, true)", [String(sqlArg)]);
41
+ const result = await fn(client);
42
+ await client.query("COMMIT");
43
+ return result;
44
+ } catch (err) {
45
+ try { await client.query("ROLLBACK"); } catch { /* pool may already be closed */ }
46
+ throw err;
47
+ } finally {
48
+ client.release();
49
+ }
50
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Bootstrap helpers — apply the delta-doc framework SQL to a Postgres
3
+ * database, programmatically. Alternative to copying the files into a
4
+ * consumer's `init_db/` directory.
5
+ *
6
+ * import { Pool } from "pg";
7
+ * import { applyFramework } from "@blueshed/delta/postgres";
8
+ *
9
+ * const pool = new Pool({ connectionString: process.env.PG_URL });
10
+ * await applyFramework(pool);
11
+ *
12
+ * The framework SQL is idempotent (`CREATE … IF NOT EXISTS`,
13
+ * `ON CONFLICT DO UPDATE`) so it is safe to call on every server boot.
14
+ *
15
+ * For docker-entrypoint-initdb.d workflows, use the `delta init` CLI to
16
+ * copy the same files into a directory the image can mount.
17
+ */
18
+ import type { Pool } from "pg";
19
+ import { readFileSync, readdirSync } from "node:fs";
20
+ import { join } from "node:path";
21
+
22
+ const FRAMEWORK_SQL_DIR = join(import.meta.dir, "..", "sql");
23
+
24
+ /** Absolute paths to the framework SQL files, in apply-order.
25
+ * Filters to `001*-*.sql` — siblings like `auth-jwt.sql` are applied
26
+ * separately (see `@blueshed/delta/auth-jwt` → `applyAuthJwtSchema`). */
27
+ export function frameworkSqlFiles(): string[] {
28
+ return readdirSync(FRAMEWORK_SQL_DIR)
29
+ .filter((f) => /^001[a-z]?-.+\.sql$/.test(f))
30
+ .sort()
31
+ .map((f) => join(FRAMEWORK_SQL_DIR, f));
32
+ }
33
+
34
+ /**
35
+ * Apply the framework SQL to the given pool. Runs every `001*` file in
36
+ * `src/sql/` alphabetically — 001a through 001g today. Safe to call
37
+ * repeatedly (idempotent statements).
38
+ */
39
+ export async function applyFramework(pool: Pool): Promise<void> {
40
+ for (const path of frameworkSqlFiles()) {
41
+ const sql = readFileSync(path, "utf8");
42
+ await pool.query(sql);
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Apply an arbitrary SQL string to the pool (convenience wrapper). Useful
48
+ * for layering `generateSql(schema, docs)` output on top of the framework:
49
+ *
50
+ * await applyFramework(pool);
51
+ * await applySql(pool, generateSql(schema, docs));
52
+ */
53
+ export async function applySql(pool: Pool, sql: string): Promise<void> {
54
+ if (sql.trim()) await pool.query(sql);
55
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Carrying an app's rows into, and out of, a Postgres database -- a server, or
3
+ * PGlite in this process. The same `Snapshot` every backend gives and takes
4
+ * (`../../schema`), so the data moves along the path with its ids and
5
+ * sequences unchanged.
6
+ */
7
+ import type { Pool } from "pg";
8
+ import { isoTime, lastId, q, type Schema, type Snapshot } from "@blueshed/delta/backend";
9
+
10
+ /** Every row of the schema's tables (every version of a temporal row), and each sequence's last id. */
11
+ export async function exportTables(pool: Pool, schema: Schema): Promise<Snapshot> {
12
+ const tables: Snapshot["tables"] = {};
13
+ const sequences: Record<string, number> = {};
14
+ for (const [key, table] of Object.entries(schema.tables)) {
15
+ const order = table.temporal ? "id, valid_from" : "id";
16
+ const { rows } = await pool.query(`SELECT to_jsonb(t.*) AS row FROM ${q(table.name)} t ORDER BY ${order}`);
17
+ tables[key] = rows.map(({ row }: { row: Record<string, unknown> }) =>
18
+ table.temporal ? { ...row, valid_from: isoTime(row.valid_from), valid_to: isoTime(row.valid_to) } : row,
19
+ );
20
+ const seq = (await pool.query(`SELECT last_value, is_called FROM ${q(`seq_${table.name}`)}`)).rows[0];
21
+ sequences[key] = seq ? Number(seq.last_value) - (seq.is_called ? 0 : 1) : 0;
22
+ }
23
+ return { tables, sequences };
24
+ }
25
+
26
+ /**
27
+ * Put a snapshot's rows into the schema's tables, as they are (ids kept), and
28
+ * set each sequence past them, so the next row the database names follows the
29
+ * last one named where the rows came from. The tables should be empty.
30
+ */
31
+ export async function importTables(pool: Pool, schema: Schema, snapshot: Snapshot): Promise<void> {
32
+ for (const [key, table] of Object.entries(schema.tables)) {
33
+ for (const row of snapshot.tables[key] ?? []) {
34
+ const value = table.temporal ? { valid_from: new Date().toISOString(), valid_to: null, ...row } : row;
35
+ await pool.query(`INSERT INTO ${q(table.name)} SELECT * FROM jsonb_populate_record(null::${q(table.name)}, $1)`, [JSON.stringify(value)]);
36
+ }
37
+ const last = lastId(snapshot, key);
38
+ await pool.query(`SELECT setval($1, $2, $3)`, [`seq_${table.name}`, Math.max(last, 1), last > 0]);
39
+ }
40
+ }