@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 +3 -1
- package/dist/index.d.ts +14 -3
- package/dist/index.js +69 -16
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|