@rowstile/client 0.1.0-alpha.5 → 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
@@ -1,83 +1,62 @@
1
- # rowstile for TypeScript
1
+ # @rowstile/client
2
2
 
3
- The SDK for TypeScript apps on a rowstile policy: every transaction signs in as whoever the request
4
- or the job acts for, refused writes become a 403 with the database's reason, rows the user can't see a 404,
5
- signed-in reads stay out of Next.js's caches, and the app refuses to start on a connection that skips
6
- row-level security. It holds no rowstile logic: it calls the `authz.*` functions the policy made, and
7
- translates their answers.
3
+ [rowstile](https://rowstile.dev) is a policy language for PostgreSQL. You write who may see and change each
4
+ row in one file, and it becomes row-level security: the database filters every read and refuses every write
5
+ the rules don't allow, whatever the code asks.
8
6
 
9
- Next.js with Prisma:
7
+ This package is the base of the TypeScript SDK: who a transaction acts for, the errors a refusal becomes, and
8
+ the runtime's calls (`can`, `perms`, `list`, `share`, ...). The driver packages build on it: `@rowstile/prisma`,
9
+ `@rowstile/pg`, `@rowstile/postgres`, `@rowstile/drizzle`. It holds no rowstile logic: it calls the `authz.*`
10
+ functions the policy made, and translates their answers.
10
11
 
11
- ```ts
12
- // src/db.ts
13
- import { PrismaPg } from "@prisma/adapter-pg";
14
- import { authz, signedIn } from "@rowstile/prisma";
15
- import "@rowstile/next"; // signed-in reads never land in a cache
16
- import "./authz.gen"; // the policy's names (rowstile client)
17
-
18
- const adapter = signedIn(new PrismaPg({ connectionString: process.env.DATABASE_URL }),
19
- { user: async () => (await auth())?.user.id });
20
- export const db = new PrismaClient({ adapter }).$extends(authz());
12
+ ```sh
13
+ # @next while only an alpha is published: a plain install gets an older one
14
+ npm install @rowstile/client@next
15
+ npm install --save-dev rowstile@next # the command: rowstile dev, rowstile migrate
21
16
  ```
22
17
 
18
+ A background job that acts for a service, whatever started it:
19
+
23
20
  ```ts
24
- // app/api/folders/[id]/route.ts: no checks; a hidden folder is 404, one the user may not rename 403
25
- export const PATCH = route(async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
26
- await db.folder.update({ where: { id: Number((await params).id) }, data: await req.json() });
27
- return Response.json({ ok: true });
28
- });
21
+ // src/jobs.ts
22
+ import { current, job } from "@rowstile/client";
23
+ import { db } from "./db.ts";
24
+
25
+ export const digest = job(["service", 1], async () => ({ count: await db.project.count(), who: current() }));
29
26
  ```
30
27
 
31
- | package | what |
32
- |---|---|
33
- | `@rowstile/client` | `actingAs(who, fn)`, `job(who, fn)`, `current()`, `Refused`, `NotFound`, `NotSignedIn`, `translate(e)` and `problemResponse(e)` for any driver's error, `errorCode(e)` (rowstile's code: `AZ709`), `calls(queryable)` (the runtime's functions), the `Register` the generated names fill in |
34
- | `@rowstile/prisma` | `signedIn(adapter, { user })`: every transaction Prisma begins signs in; `authz()`: P2025 as `NotFound` or `Refused` with the reason, refused creates naming the rule, `db.$authz` (`can`, `ids`, `permsOf`, `share`, `check`, ...) |
35
- | `@rowstile/pg` | `authz(pool, { user })`: `transaction(fn)`, `query()`, the runtime's functions; `changes(pool)` for live updates |
36
- | `@rowstile/postgres` | `authz(sql, { user })`: `begin(fn)`, the runtime's functions |
37
- | `@rowstile/drizzle` | `withAuthz(db, { user })`: `transaction(fn)`; `inIds(column, type, perm)` and `ids()` for `where`; `expect(tx, result, ...)` for updates and deletes that changed nothing |
38
- | `@rowstile/next` | `route(handler)` (403 and 404 problem bodies), `action(fn)` (`{ ok, value }` or `{ ok: false, problem }`), `authzRoutes()` (what the React kit calls), `checkAtStart()` for `instrumentation.ts`; importing it keeps signed-in reads out of caches |
39
- | `@rowstile/react` | `<AuthzProvider>`, `usePerms(type, ids)` (one call for a list), `<Can>`, `useShares` and a headless `<ShareDialog>`, `useAccessRequest`; kept current over server-sent events |
40
- | `@rowstile/vitest` | `expect.extend(matchers)`: `toBeRefused(command, reason)`, `toBeNotFound()`; `asUser(who, fn)`; `databasePerWorker(url)`. Vitest 4 or 5 |
28
+ ## What is in it
41
29
 
42
- Who a transaction acts for: `42` or `"42"` (a user), `["service", 3]` (another principal type of the policy),
43
- or `null` (nobody: only what `anyone` may see). A plain id is always a user's, whatever it holds: `"service:3"`
44
- is the user with that id, so an id from outside (a username, an identity provider's subject) can't name a
45
- service. `parsePrincipal("service:3")` reads back what `describe()` wrote, for text your own code wrote. Whoever the code acts for (`actingAs`, a `job`)
46
- comes first, else the `user` function, else nobody; a `who` given to one call (`transaction(fn, who)`) comes
47
- before all, and `null` there is nobody. Every transaction signs in, even as nobody, and nothing uses a
48
- session-level `SET`, so pools and poolers in transaction mode are safe; the change feed needs its own connection
49
- to Postgres ([Behind a pooler](../../docs/operations.md#behind-a-pooler)).
30
+ - `actingAs(who, fn)` and `job(who, fn)`: every transaction `fn` begins signs in as `who`. `42` or `"42"` is
31
+ a user, `["service", 3]` another principal type of the policy, `null` nobody.
32
+ - `current()`: who the code running now acts for.
33
+ - `Refused` (403, with the rule and the reason), `NotFound` (404), `NotSignedIn`, `ConnectionProblem`.
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 (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).
38
+ - `errorCode(e)`: rowstile's code for an error (`AZ709`; `rowstile help AZ709` explains it).
39
+ - `calls(queryable)`: the runtime's functions over anything with `query(text, values)`.
40
+ - `Register`: the interface the generated names fill in (`rowstile client`), so a wrong permission name
41
+ doesn't type-check.
50
42
 
51
- Traps it handles:
43
+ ## More
52
44
 
53
- - **Next.js caching.** Before a transaction signs in as someone, `@rowstile/next` calls Next's `connection()`:
54
- the render is dynamic, and a signed-in read inside `"use cache"` or `unstable_cache` fails instead of being
55
- cached for the next user. Reads as nobody may be cached.
56
- - **Prisma answers some calls together.** It dispatches the `findUnique` calls of one tick, and array
57
- transactions, from the first caller's context. `authz()` runs each `findUnique` in a transaction of its own,
58
- and refuses `$transaction([...])` (use `$transaction(async (tx) => ...)`), so no query runs as someone else.
59
- Use the client `$extends(authz())` returns, and only that one: the client it was made from is refused
60
- whenever it is used, inside your own extensions too. An extension you add to the client `authz()` returns is
61
- placed before it, so `authz()` stays the last one: each of your query hooks runs once for a call, and sees
62
- the errors `authz()` makes (`Refused`, `NotFound`).
63
- - **Inserts that read the row back** (`RETURNING`, Prisma's `create`) also need the select rule: Postgres's own
64
- message comes back as `Refused` naming the select rule.
65
- - **Prisma's promises are lazy**: `actingAs` and `job` await what they return inside their scope.
66
- - **A connection that drops.** node-postgres stops the process when a client that is checked out loses its
67
- connection and nobody listens: `@rowstile/pg` listens while it holds one, so the transaction fails instead.
68
- `changes(pool)` listens again after its connection drops, and tells its subscribers once.
69
- - **An update that changed nothing** is `NotFound` when the user can't see the row, `Refused` when the database
70
- says no, and `NotFound` again when the rule allows it and the statement matched nothing for another reason
71
- (a `where` with more than the key).
72
- - **The answers in the browser are one user's.** Give `<AuthzProvider user={id}>` if the user can change
73
- without a page load: the hooks forget what they had and ask again.
45
+ - [Node apps: pg, postgres.js and Drizzle](https://rowstile.dev/stacks/node): the whole setup, step by step, from an app the tests run.
46
+ - [The TypeScript SDK](https://rowstile.dev/sdk/typescript): every package, and the traps they handle.
47
+ - [Getting started](https://rowstile.dev/getting-started): a first policy, step by step.
48
+ - [Source and issues](https://github.com/rowstile/rowstile).
74
49
 
75
- The types and permissions of the policy are generated names: with a `@rowstile/*` dependency in
76
- `package.json`, `rowstile client` writes only the names (`[clients] ts = "src/authz.gen.ts"`), registered with
77
- the SDK, so `db.$authz.can("note", 1, "edt")` doesn't type-check. Policy changes ship as migrations for your
78
- tool (`rowstile migrate`, with `tool = "prisma"` or `"drizzle"` in `rowstile.toml`).
50
+ The other packages:
79
51
 
80
- The packages are an npm workspace at the repository root (`npm ci`, then `npx tsc -b sdk/typescript`).
81
- `integrations/nextjs/` is the conformance suite: a small Next.js app on Prisma 7 and Prisma Migrate, and the
82
- checks every supported stack passes, with pg, postgres.js, Drizzle and the React kit
83
- (`integrations/nextjs/test.sh`).
52
+ | package | for |
53
+ |---|---|
54
+ | [`@rowstile/prisma`](https://www.npmjs.com/package/@rowstile/prisma) | Prisma |
55
+ | [`@rowstile/pg`](https://www.npmjs.com/package/@rowstile/pg) | node-postgres |
56
+ | [`@rowstile/postgres`](https://www.npmjs.com/package/@rowstile/postgres) | postgres.js |
57
+ | [`@rowstile/drizzle`](https://www.npmjs.com/package/@rowstile/drizzle) | Drizzle ORM |
58
+ | [`@rowstile/next`](https://www.npmjs.com/package/@rowstile/next) | Next.js: route handlers, server actions, caches |
59
+ | [`@rowstile/react`](https://www.npmjs.com/package/@rowstile/react) | React: hooks and a share dialog |
60
+ | [`@rowstile/vitest`](https://www.npmjs.com/package/@rowstile/vitest) | Vitest: matchers, a database per worker |
61
+
62
+ rowstile is a 0.x preview: names may still change before 1.0. Apache-2.0.
package/dist/index.d.ts CHANGED
@@ -26,6 +26,9 @@ export type Id = string | number | bigint | readonly (string | number | bigint)[
26
26
  /** An id as the database compares it: a composite key as Postgres writes a row (every field quoted); a key of
27
27
  * one column given as an array is that value. */
28
28
  export declare function idText(id: Id): string;
29
+ /** A row's key as the database writes it, for a message: 7, or (1,2) for a composite key (a field is quoted
30
+ * only where Postgres would: when it is empty or holds a comma, a quote, a parenthesis or a space). */
31
+ export declare function idShown(id: Id): string;
29
32
  /** A user (type "user"), another principal type the policy declares (a service, a bot), or nobody (id null:
30
33
  * only what `anyone` may see). */
31
34
  export interface Principal {
@@ -81,9 +84,11 @@ export declare class Refused extends Error {
81
84
  command?: string | undefined;
82
85
  why: string[];
83
86
  readonly status = 403;
84
- 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;
85
89
  constructor(message: string, table?: string | undefined, command?: string | undefined, why?: string[], options?: {
86
90
  cause?: unknown;
91
+ code?: string;
87
92
  });
88
93
  problem(): Problem;
89
94
  }
@@ -128,12 +133,18 @@ export declare function sqlstate(e: unknown): string | undefined;
128
133
  * policies with the rule and why), else null. `schemaOf` qualifies a bare table name (Prisma knows it).
129
134
  * Another 42501 (a table the app role was never granted) is the app's mistake, not a refusal: null. */
130
135
  export declare function refusal(e: unknown, schemaOf?: (table: string) => string | undefined): Refused | null;
131
- /** 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. */
132
139
  export declare function notSignedIn(e: unknown): boolean;
133
140
  /** e as rowstile's error: Refused, NotFound or NotSignedIn as they are, a database refusal as Refused, strict
134
141
  * sign-in's error as NotSignedIn; null for anything else. */
135
142
  export declare function translate(e: unknown, schemaOf?: (table: string) => string | undefined): Refused | NotFound | NotSignedIn | null;
136
- /** 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). */
137
148
  export declare function problemOf(e: unknown): Problem | null;
138
149
  /** A Response (application/problem+json) for e, or null: for route handlers. */
139
150
  export declare function problemResponse(e: unknown): Response | null;
@@ -179,12 +190,19 @@ export interface AuthzCalls {
179
190
  /** The runtime's functions over q (a signed-in transaction, or something that signs each query in). */
180
191
  export declare function calls(q: Queryable): AuthzCalls & {
181
192
  expect<R>(result: R, table: string, command: "update" | "delete", id: Id, row?: object): Promise<R>;
193
+ /** What an UPDATE or DELETE that changed nothing was, asked of the database: the error to throw. */
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>;
182
198
  check(): Promise<void>;
183
199
  };
184
200
  /** Whether an UPDATE or DELETE's result changed anything: rows, a count, {count}, {rowCount}, postgres.js's .count. */
185
201
  export declare function changed(result: unknown): boolean;
186
202
  /** What an UPDATE or DELETE that changed nothing was: NotFound when the row isn't there for this user (why is
187
203
  * null), or when the rule allows the write (its first line says yes: the statement matched nothing for
188
- * another reason, such as a where with more than the key); Refused with the reason otherwise. */
189
- export declare function verdict(table: string, command: string, id: Id, why: string[] | null): NotFound | Refused;
204
+ * another reason, such as a where with more than the key); Refused with the reason otherwise, worded as the
205
+ * database words a refused insert: "permission denied: user 2 may not update row 7 of app.notes".
206
+ * who: "user 2", as the database says it; left out, whoever the code acts for now. */
207
+ export declare function verdict(table: string, command: string, id: Id, why: string[] | null, who?: string): NotFound | Refused;
190
208
  export {};
package/dist/index.js CHANGED
@@ -23,6 +23,20 @@ export function idText(id) {
23
23
  }
24
24
  return String(id);
25
25
  }
26
+ /** A row's key as the database writes it, for a message: 7, or (1,2) for a composite key (a field is quoted
27
+ * only where Postgres would: when it is empty or holds a comma, a quote, a parenthesis or a space). */
28
+ export function idShown(id) {
29
+ if (!Array.isArray(id))
30
+ return String(id);
31
+ if (id.length === 1)
32
+ return String(id[0]);
33
+ return "(" + id.map((v) => {
34
+ if (v === null || v === undefined)
35
+ return "";
36
+ const text = String(v);
37
+ return text === "" || /[",\\()\s]/.test(text) ? '"' + text.replace(/\\/g, "\\\\").replace(/"/g, '""') + '"' : text;
38
+ }).join(",") + ")";
39
+ }
26
40
  export const NOBODY = Object.freeze({ type: "user", id: null });
27
41
  export function principal(who) {
28
42
  if (who === null || who === undefined)
@@ -112,13 +126,15 @@ export class Refused extends Error {
112
126
  command;
113
127
  why;
114
128
  status = 403;
115
- 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
116
131
  constructor(message, table, command, why = [], options) {
117
132
  super(message, options);
118
133
  this.table = table;
119
134
  this.command = command;
120
135
  this.why = why;
121
136
  this.name = "Refused";
137
+ this.code = options?.code ?? "AZ709";
122
138
  }
123
139
  problem() {
124
140
  return { type: "https://rowstile.dev/problems/refused", title: "Forbidden", status: 403, detail: this.message,
@@ -208,6 +224,15 @@ export function sqlstate(e) {
208
224
  }
209
225
  const RLS_GENERIC = /^new row violates row-level security policy (\(USING expression\) )?for table "([^"]+)"/;
210
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
+ }
211
236
  /** A Refused for an error that is the database refusing a write (SQLSTATE 42501, raised by rowstile's
212
237
  * policies with the rule and why), else null. `schemaOf` qualifies a bare table name (Prisma knows it).
213
238
  * Another 42501 (a table the app role was never granted) is the app's mistake, not a refusal: null. */
@@ -230,15 +255,25 @@ export function refusal(e, schemaOf) {
230
255
  "back (RETURNING); read it back only if the select rule allows it", table, "select", [], { cause: e });
231
256
  }
232
257
  const ours = OURS.exec(err.message);
233
- 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))
234
263
  return null;
235
- const table = err.schema && err.table ? `${err.schema}.${err.table}` : err.table ?? ours?.[2];
236
- const command = err.constraint?.startsWith("authz_") ? err.constraint.slice(6) : ours?.[1];
237
- 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 });
238
270
  }
239
- /** 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. */
240
274
  export function notSignedIn(e) {
241
- return sqlstate(e) === "28000";
275
+ const code = errorCode(e);
276
+ return sqlstate(e) === "28000" && (code === undefined || code === "AZ701");
242
277
  }
243
278
  /** e as rowstile's error: Refused, NotFound or NotSignedIn as they are, a database refusal as Refused, strict
244
279
  * sign-in's error as NotSignedIn; null for anything else. */
@@ -254,10 +289,28 @@ export function translate(e, schemaOf) {
254
289
  }
255
290
  return null;
256
291
  }
257
- /** 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). */
258
307
  export function problemOf(e) {
259
308
  const t = translate(e);
260
- 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;
261
314
  }
262
315
  /** A Response (application/problem+json) for e, or null: for route handlers. */
263
316
  export function problemResponse(e) {
@@ -270,18 +323,24 @@ export function checkProblems(rows) {
270
323
  }
271
324
  // The policy names tables with their schema; a table named without one (a model that names no schema) is
272
325
  // looked up on the search_path
273
- 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 " +
274
327
  "JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace WHERE position('.' in $1::text) = 0 " +
275
- "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, " +
330
+ // who is signed in, in the words the database's own refusals use
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`;
276
333
  /** The runtime's functions over q (a signed-in transaction, or something that signs each query in). */
277
334
  export function calls(q) {
278
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
279
337
  const c = {
280
338
  can: async (type, id, perm) => Boolean((await one("SELECT authz.can($1, $2::text, $3) AS ok", [type, idText(id), perm])).ok),
281
- 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,
282
341
  permsOf: async (type, ids) => {
283
342
  const rows = (await q.query("SELECT id, perms FROM authz.perms_of($1, $2::text[])", [type, ids.map(idText)])).rows;
284
- return Object.fromEntries(rows.map((r) => [r.id, r.perms ?? []]));
343
+ return Object.fromEntries(rows.map((r) => [r.id, r.perms]));
285
344
  },
286
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),
287
346
  listShares: async (type, id) => (await q.query("SELECT * FROM authz.list_shares($1, $2::text)", [type, idText(id)])).rows,
@@ -300,7 +359,21 @@ export function calls(q) {
300
359
  expect: async (result, table, command, id, row) => {
301
360
  if (changed(result))
302
361
  return result;
303
- throw verdict(table, command, id, await c.explainRule(table, command, id, row));
362
+ throw await c.verdict(table, command, id, row);
363
+ },
364
+ verdict: async (table, command, id, row) => {
365
+ const got = await one(EXPLAIN_RULE, [table, command, idText(id), row === undefined ? null : JSON.stringify(row)]);
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;
304
377
  },
305
378
  /** Throws ConnectionProblem if this connection skips row-level security. As nobody: it runs at start-up,
306
379
  * where there is no request to ask who is signed in. */
@@ -331,9 +404,13 @@ export function changed(result) {
331
404
  }
332
405
  /** What an UPDATE or DELETE that changed nothing was: NotFound when the row isn't there for this user (why is
333
406
  * null), or when the rule allows the write (its first line says yes: the statement matched nothing for
334
- * another reason, such as a where with more than the key); Refused with the reason otherwise. */
335
- export function verdict(table, command, id, why) {
336
- if (why === null || /^\s*yes\b/.test(why[0] ?? ""))
337
- return new NotFound(table, idText(id));
338
- return new Refused(`permission denied: may not ${command} ${table} ${idText(id)}`, table, command, why);
407
+ * another reason, such as a where with more than the key); Refused with the reason otherwise, worded as the
408
+ * database words a refused insert: "permission denied: user 2 may not update row 7 of app.notes".
409
+ * who: "user 2", as the database says it; left out, whoever the code acts for now. */
410
+ export function verdict(table, command, id, why, who) {
411
+ if (why === null || /^\s*yes\b/.test(why[0]))
412
+ return new NotFound(table, idShown(id));
413
+ const p = current() ?? NOBODY;
414
+ const by = who ?? (p.id === null ? "someone not signed in" : `${p.type} ${p.id}`);
415
+ return new Refused(`permission denied: ${by} may not ${command} row ${idShown(id)} of ${table}`, table, command, why);
339
416
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rowstile/client",
3
- "version": "0.1.0-alpha.5",
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",