@rowstile/client 0.1.0-alpha.6 → 0.1.0-alpha.7

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.
package/README.md CHANGED
@@ -32,7 +32,9 @@ export const digest = job(["service", 1], async () => ({ count: await db.project
32
32
  - `current()`: who the code running now acts for.
33
33
  - `Refused` (403, with the rule and the reason), `NotFound` (404), `NotSignedIn`, `ConnectionProblem`.
34
34
  - `translate(e)`: any driver's error as one of those, or `null`. `problemOf(e)` and `problemResponse(e)`: the
35
- status and an RFC 9457 body for an HTTP answer.
35
+ status and an RFC 9457 body for an HTTP answer (and 404 for what a call names that isn't there, AZ708; 400
36
+ for a missing or wrong argument, AZ710; 401 for a call that needs someone signed in or a login refused,
37
+ AZ714 and AZ703; 409 for a move inside itself, AZ713).
36
38
  - `errorCode(e)`: rowstile's code for an error (`AZ709`; `rowstile help AZ709` explains it).
37
39
  - `calls(queryable)`: the runtime's functions over anything with `query(text, values)`.
38
40
  - `Register`: the interface the generated names fill in (`rowstile client`), so a wrong permission name
package/dist/index.d.ts CHANGED
@@ -84,9 +84,11 @@ export declare class Refused extends Error {
84
84
  command?: string | undefined;
85
85
  why: string[];
86
86
  readonly status = 403;
87
- readonly code = "AZ709";
87
+ /** rowstile's code for it: AZ709 for a rule's refusal, the database's own for another (AZ705: a share). */
88
+ readonly code: string;
88
89
  constructor(message: string, table?: string | undefined, command?: string | undefined, why?: string[], options?: {
89
90
  cause?: unknown;
91
+ code?: string;
90
92
  });
91
93
  problem(): Problem;
92
94
  }
@@ -131,12 +133,18 @@ export declare function sqlstate(e: unknown): string | undefined;
131
133
  * policies with the rule and why), else null. `schemaOf` qualifies a bare table name (Prisma knows it).
132
134
  * Another 42501 (a table the app role was never granted) is the app's mistake, not a refusal: null. */
133
135
  export declare function refusal(e: unknown, schemaOf?: (table: string) => string | undefined): Refused | null;
134
- /** Whether e is strict sign-in's error: a query that needs to know who is asking, and nobody signed in. */
136
+ /** Whether e is strict sign-in's error (rowstile help AZ701): a query that needs to know who is asking, and nobody
137
+ * signed in. A login refused (AZ703) and settings changed by hand (AZ702) share its SQLSTATE, 28000, but are no
138
+ * such thing. */
135
139
  export declare function notSignedIn(e: unknown): boolean;
136
140
  /** e as rowstile's error: Refused, NotFound or NotSignedIn as they are, a database refusal as Refused, strict
137
141
  * sign-in's error as NotSignedIn; null for anything else. */
138
142
  export declare function translate(e: unknown, schemaOf?: (table: string) => string | undefined): Refused | NotFound | NotSignedIn | null;
139
- /** The problem body and status for e (Refused: 403, NotFound: 404), or null. */
143
+ /** The problem body and status for e (Refused: 403, NotFound: 404), or null. A call the database turns down that is
144
+ * no refusal answers as its code's page says, with the database's words: a login refused or a call that needs
145
+ * someone signed in 401 (AZ703, AZ714), something it names that isn't there 404 (AZ708: a share with someone who
146
+ * doesn't exist), an argument missing or wrong 400 (AZ710: a negative page size), a move inside itself 409
147
+ * (AZ713). */
140
148
  export declare function problemOf(e: unknown): Problem | null;
141
149
  /** A Response (application/problem+json) for e, or null: for route handlers. */
142
150
  export declare function problemResponse(e: unknown): Response | null;
@@ -184,6 +192,9 @@ export declare function calls(q: Queryable): AuthzCalls & {
184
192
  expect<R>(result: R, table: string, command: "update" | "delete", id: Id, row?: object): Promise<R>;
185
193
  /** What an UPDATE or DELETE that changed nothing was, asked of the database: the error to throw. */
186
194
  verdict(table: string, command: string, id: Id, row?: object): Promise<NotFound | Refused>;
195
+ /** The table as the policy names it, with its schema: a bare name (a model that names no schema) is looked
196
+ * up on the search_path, once. */
197
+ tableName(table: string): Promise<string>;
187
198
  check(): Promise<void>;
188
199
  };
189
200
  /** Whether an UPDATE or DELETE's result changed anything: rows, a count, {count}, {rowCount}, postgres.js's .count. */
package/dist/index.js CHANGED
@@ -126,13 +126,15 @@ export class Refused extends Error {
126
126
  command;
127
127
  why;
128
128
  status = 403;
129
- code = "AZ709"; // rowstile help AZ709
129
+ /** rowstile's code for it: AZ709 for a rule's refusal, the database's own for another (AZ705: a share). */
130
+ code; // rowstile help AZ709
130
131
  constructor(message, table, command, why = [], options) {
131
132
  super(message, options);
132
133
  this.table = table;
133
134
  this.command = command;
134
135
  this.why = why;
135
136
  this.name = "Refused";
137
+ this.code = options?.code ?? "AZ709";
136
138
  }
137
139
  problem() {
138
140
  return { type: "https://rowstile.dev/problems/refused", title: "Forbidden", status: 403, detail: this.message,
@@ -222,6 +224,15 @@ export function sqlstate(e) {
222
224
  }
223
225
  const RLS_GENERIC = /^new row violates row-level security policy (\(USING expression\) )?for table "([^"]+)"/;
224
226
  const OURS = /may not (insert|update|delete) this row (?:into|of) (\S+?)(?: to these values)?$/;
227
+ /** The table in a column rule's refusal, "changing locked of app.notes 7 needs: folder.manage" (always an
228
+ * update); undefined for any other message. Read with indexOf: a pattern here would backtrack on a long one. */
229
+ function columnRuleTable(message) {
230
+ const of = message.indexOf(" of "), needs = message.indexOf(" needs: ");
231
+ if (!message.startsWith("changing ") || of < 0 || needs < of)
232
+ return undefined;
233
+ const named = message.slice(of + 4, needs); // "app.notes 7"
234
+ return named.split(" ", 1)[0] || undefined;
235
+ }
225
236
  /** A Refused for an error that is the database refusing a write (SQLSTATE 42501, raised by rowstile's
226
237
  * policies with the rule and why), else null. `schemaOf` qualifies a bare table name (Prisma knows it).
227
238
  * Another 42501 (a table the app role was never granted) is the app's mistake, not a refusal: null. */
@@ -244,15 +255,25 @@ export function refusal(e, schemaOf) {
244
255
  "back (RETURNING); read it back only if the select rule allows it", table, "select", [], { cause: e });
245
256
  }
246
257
  const ours = OURS.exec(err.message);
247
- if (!ours && !err.constraint?.startsWith("authz_") && errorCode(e) === undefined)
258
+ const code = errorCode(e);
259
+ if (!ours && !err.constraint?.startsWith("authz_") && code === undefined)
260
+ return null;
261
+ // sign in first (AZ714) is a 42501 too, but no refusal: a 401
262
+ if (code !== undefined && CALL_PROBLEMS.has(code))
248
263
  return null;
249
- const table = err.schema && err.table ? `${err.schema}.${err.table}` : err.table ?? ours?.[2];
250
- const command = err.constraint?.startsWith("authz_") ? err.constraint.slice(6) : ours?.[1];
251
- return new Refused(err.message, table, command, err.detail ? err.detail.split("\n") : [], { cause: e });
264
+ // the error's own fields say which table and command; a driver that drops them (Prisma's adapters) leaves
265
+ // the message, which names both
266
+ const column = columnRuleTable(err.message);
267
+ const table = err.schema && err.table ? `${err.schema}.${err.table}` : err.table ?? ours?.[2] ?? column;
268
+ const command = err.constraint?.startsWith("authz_") ? err.constraint.slice(6) : ours?.[1] ?? (column ? "update" : undefined);
269
+ return new Refused(err.message, table, command, err.detail ? err.detail.split("\n") : [], { cause: e, code });
252
270
  }
253
- /** Whether e is strict sign-in's error: a query that needs to know who is asking, and nobody signed in. */
271
+ /** Whether e is strict sign-in's error (rowstile help AZ701): a query that needs to know who is asking, and nobody
272
+ * signed in. A login refused (AZ703) and settings changed by hand (AZ702) share its SQLSTATE, 28000, but are no
273
+ * such thing. */
254
274
  export function notSignedIn(e) {
255
- return sqlstate(e) === "28000";
275
+ const code = errorCode(e);
276
+ return sqlstate(e) === "28000" && (code === undefined || code === "AZ701");
256
277
  }
257
278
  /** e as rowstile's error: Refused, NotFound or NotSignedIn as they are, a database refusal as Refused, strict
258
279
  * sign-in's error as NotSignedIn; null for anything else. */
@@ -268,10 +289,28 @@ export function translate(e, schemaOf) {
268
289
  }
269
290
  return null;
270
291
  }
271
- /** The problem body and status for e (Refused: 403, NotFound: 404), or null. */
292
+ // rowstile's codes for a call the database turned down that is no refusal, and what their pages say to answer
293
+ // (rowstile help AZ703: a login refused; AZ708: what the call names isn't there; AZ710: a missing or wrong
294
+ // argument; AZ713: moved inside itself; AZ714: sign in first)
295
+ const CALL_PROBLEMS = new Map([
296
+ ["AZ703", { type: "https://rowstile.dev/problems/not-signed-in", title: "Unauthorized", status: 401 }],
297
+ ["AZ708", { type: "https://rowstile.dev/problems/not-found", title: "Not Found", status: 404 }],
298
+ ["AZ710", { type: "https://rowstile.dev/problems/bad-argument", title: "Bad Request", status: 400 }],
299
+ ["AZ713", { type: "https://rowstile.dev/problems/conflict", title: "Conflict", status: 409 }],
300
+ ["AZ714", { type: "https://rowstile.dev/problems/not-signed-in", title: "Unauthorized", status: 401 }],
301
+ ]);
302
+ /** The problem body and status for e (Refused: 403, NotFound: 404), or null. A call the database turns down that is
303
+ * no refusal answers as its code's page says, with the database's words: a login refused or a call that needs
304
+ * someone signed in 401 (AZ703, AZ714), something it names that isn't there 404 (AZ708: a share with someone who
305
+ * doesn't exist), an argument missing or wrong 400 (AZ710: a negative page size), a move inside itself 409
306
+ * (AZ713). */
272
307
  export function problemOf(e) {
273
308
  const t = translate(e);
274
- return t instanceof Refused || t instanceof NotFound ? t.problem() : null;
309
+ if (t instanceof Refused || t instanceof NotFound)
310
+ return t.problem();
311
+ const code = errorCode(e);
312
+ const call = code === undefined ? undefined : CALL_PROBLEMS.get(code);
313
+ return call ? { ...call, detail: dbError(e).message, code } : null;
275
314
  }
276
315
  /** A Response (application/problem+json) for e, or null: for route handlers. */
277
316
  export function problemResponse(e) {
@@ -284,20 +323,24 @@ export function checkProblems(rows) {
284
323
  }
285
324
  // The policy names tables with their schema; a table named without one (a model that names no schema) is
286
325
  // looked up on the search_path
287
- const EXPLAIN_RULE = "SELECT authz.explain_rule(coalesce((SELECT n.nspname || '.' || c.relname FROM pg_catalog.pg_class c " +
326
+ const TABLE_NAME = "coalesce((SELECT n.nspname || '.' || c.relname FROM pg_catalog.pg_class c " +
288
327
  "JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace WHERE position('.' in $1::text) = 0 " +
289
- "AND c.oid = pg_catalog.to_regclass(pg_catalog.quote_ident($1::text))), $1::text), $2, $3, $4::jsonb) AS e, " +
328
+ "AND c.oid = pg_catalog.to_regclass(pg_catalog.quote_ident($1::text))), $1::text)";
329
+ const EXPLAIN_RULE = "SELECT authz.explain_rule(t.tbl, $2, $3, $4::jsonb) AS e, t.tbl, " +
290
330
  // who is signed in, in the words the database's own refusals use
291
- "coalesce((SELECT p.principal_type || ' ' || p.principal_id FROM authz.principal() p), 'someone not signed in') AS who";
331
+ "coalesce((SELECT p.principal_type || ' ' || p.principal_id FROM authz.principal() p), 'someone not signed in') AS who " +
332
+ `FROM (SELECT ${TABLE_NAME} AS tbl) t`;
292
333
  /** The runtime's functions over q (a signed-in transaction, or something that signs each query in). */
293
334
  export function calls(q) {
294
335
  const one = async (text, values) => (await q.query(text, values)).rows[0];
336
+ const names = new Map(); // a table as the app names it -> as the policy does
295
337
  const c = {
296
338
  can: async (type, id, perm) => Boolean((await one("SELECT authz.can($1, $2::text, $3) AS ok", [type, idText(id), perm])).ok),
297
- perms: async (type, id) => ((await one("SELECT authz.perms($1, $2::text) AS p", [type, idText(id)])).p ?? []),
339
+ // (an array, empty at worst: authz.perms never answers null)
340
+ perms: async (type, id) => (await one("SELECT authz.perms($1, $2::text) AS p", [type, idText(id)])).p,
298
341
  permsOf: async (type, ids) => {
299
342
  const rows = (await q.query("SELECT id, perms FROM authz.perms_of($1, $2::text[])", [type, ids.map(idText)])).rows;
300
- return Object.fromEntries(rows.map((r) => [r.id, r.perms ?? []]));
343
+ return Object.fromEntries(rows.map((r) => [r.id, r.perms]));
301
344
  },
302
345
  list: async (type, perm, page = {}) => (await q.query("SELECT x FROM authz.list($1, $2, $3, $4) x", [type, perm, page.after === undefined ? null : idText(page.after), page.limit ?? null])).rows.map((r) => r.x),
303
346
  listShares: async (type, id) => (await q.query("SELECT * FROM authz.list_shares($1, $2::text)", [type, idText(id)])).rows,
@@ -320,7 +363,17 @@ export function calls(q) {
320
363
  },
321
364
  verdict: async (table, command, id, row) => {
322
365
  const got = await one(EXPLAIN_RULE, [table, command, idText(id), row === undefined ? null : JSON.stringify(row)]);
323
- return verdict(table, command, id, (got.e ?? null), typeof got.who === "string" ? got.who : undefined);
366
+ // named as the database names it, as in a refused insert: "public.Note" for a model that says "Note" (the
367
+ // statement's tbl and who are text, never null)
368
+ const named = String(got.tbl);
369
+ names.set(table, named);
370
+ return verdict(named, command, id, (got.e ?? null), String(got.who));
371
+ },
372
+ tableName: async (table) => {
373
+ if (!names.has(table) && !table.includes(".")) {
374
+ names.set(table, String((await one(`SELECT ${TABLE_NAME} AS tbl`, [table])).tbl));
375
+ }
376
+ return names.get(table) ?? table;
324
377
  },
325
378
  /** Throws ConnectionProblem if this connection skips row-level security. As nobody: it runs at start-up,
326
379
  * where there is no request to ask who is signed in. */
@@ -355,7 +408,7 @@ export function changed(result) {
355
408
  * database words a refused insert: "permission denied: user 2 may not update row 7 of app.notes".
356
409
  * who: "user 2", as the database says it; left out, whoever the code acts for now. */
357
410
  export function verdict(table, command, id, why, who) {
358
- if (why === null || /^\s*yes\b/.test(why[0] ?? ""))
411
+ if (why === null || /^\s*yes\b/.test(why[0]))
359
412
  return new NotFound(table, idShown(id));
360
413
  const p = current() ?? NOBODY;
361
414
  const by = who ?? (p.id === null ? "someone not signed in" : `${p.type} ${p.id}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rowstile/client",
3
- "version": "0.1.0-alpha.6",
3
+ "version": "0.1.0-alpha.7",
4
4
  "description": "rowstile for TypeScript apps: who each transaction acts for, refusals as errors, generated names",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://rowstile.dev",