@rowstile/next 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 +75 -72
- package/dist/index.d.ts +9 -3
- package/dist/index.js +20 -18
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,83 +1,86 @@
|
|
|
1
|
-
# rowstile
|
|
1
|
+
# @rowstile/next
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
7
|
+
This package is rowstile for Next.js (App Router). Importing it keeps signed-in reads out of Next's caches.
|
|
8
|
+
`route(handler)` answers a refusal with 403 and a hidden row with 404 (and what a call names that isn't there
|
|
9
|
+
404, a missing or wrong argument 400, a call that needs someone signed in 401, a move inside itself 409).
|
|
10
|
+
`action(fn)` gives a server action's
|
|
11
|
+
refusal back as a value. `authzRoutes()` is what the React kit calls, and `checkAtStart()` stops a server
|
|
12
|
+
whose connection skips row-level security. It goes with a driver package, most often `@rowstile/prisma`.
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
# @next on each while only an alpha is published: a plain install gets an older one
|
|
16
|
+
npm install @rowstile/next@next @rowstile/prisma@next
|
|
17
|
+
npm install --save-dev rowstile@next # the command: rowstile dev, rowstile migrate
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
A route handler with no checks: a hidden note is 404, one the user may not edit 403 with the reason.
|
|
10
21
|
|
|
11
22
|
```ts
|
|
12
|
-
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
23
|
+
import { route } from "@rowstile/next";
|
|
24
|
+
import { db } from "@/db";
|
|
25
|
+
|
|
26
|
+
type Params = { params: Promise<{ id: string }> };
|
|
27
|
+
|
|
28
|
+
export const PATCH = route(async (req: Request, { params }: Params) => {
|
|
29
|
+
const id = Number((await params).id);
|
|
30
|
+
const { body } = (await req.json()) as { body: string };
|
|
31
|
+
await db.note.update({ where: { id }, data: { body } });
|
|
32
|
+
return Response.json({ id });
|
|
33
|
+
});
|
|
21
34
|
```
|
|
22
35
|
|
|
36
|
+
A server action: Next.js hides a thrown error's message, so the refusal comes back as `{ ok: false, problem }`.
|
|
37
|
+
|
|
23
38
|
```ts
|
|
24
|
-
// app/
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
39
|
+
// app/actions.ts
|
|
40
|
+
import { action } from "@rowstile/next";
|
|
41
|
+
|
|
42
|
+
export const renameNote = action(async (id: number, body: string) => {
|
|
43
|
+
await db.note.update({ where: { id }, data: { body } });
|
|
44
|
+
return id;
|
|
28
45
|
});
|
|
29
46
|
```
|
|
30
47
|
|
|
31
|
-
|
|
48
|
+
At start-up:
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
// instrumentation.ts
|
|
52
|
+
import { checkAtStart } from "@rowstile/next";
|
|
53
|
+
|
|
54
|
+
export async function register() {
|
|
55
|
+
if (process.env.NEXT_RUNTIME === "nodejs") await checkAtStart((await import("./src/db.ts")).db.$authz);
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Good to know
|
|
60
|
+
|
|
61
|
+
- Before a transaction signs in as someone, it calls Next's `connection()`: the render is dynamic, and a
|
|
62
|
+
signed-in read inside `"use cache"` or `unstable_cache` fails instead of being cached for the next user.
|
|
63
|
+
Reads as nobody may be cached.
|
|
64
|
+
- `authzRoutes({ calls, changes })` in `app/api/authz/[...authz]/route.ts` serves `@rowstile/react`:
|
|
65
|
+
permissions for a list, shares, access requests, live updates.
|
|
66
|
+
|
|
67
|
+
## More
|
|
68
|
+
|
|
69
|
+
- [Next.js with Prisma](https://rowstile.dev/stacks/nextjs): the whole setup, step by step, from an app the tests run.
|
|
70
|
+
- [The TypeScript SDK](https://rowstile.dev/sdk/typescript): every package, and the traps they handle.
|
|
71
|
+
- [Getting started](https://rowstile.dev/getting-started): a first policy, step by step.
|
|
72
|
+
- [Source and issues](https://github.com/rowstile/rowstile).
|
|
73
|
+
|
|
74
|
+
The other packages:
|
|
75
|
+
|
|
76
|
+
| package | for |
|
|
32
77
|
|---|---|
|
|
33
|
-
| `@rowstile/client` |
|
|
34
|
-
| `@rowstile/prisma`
|
|
35
|
-
| `@rowstile/pg`
|
|
36
|
-
| `@rowstile/postgres`
|
|
37
|
-
| `@rowstile/drizzle`
|
|
38
|
-
| `@rowstile/
|
|
39
|
-
| `@rowstile/
|
|
40
|
-
|
|
41
|
-
|
|
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)).
|
|
50
|
-
|
|
51
|
-
Traps it handles:
|
|
52
|
-
|
|
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.
|
|
74
|
-
|
|
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`).
|
|
79
|
-
|
|
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`).
|
|
78
|
+
| [`@rowstile/client`](https://www.npmjs.com/package/@rowstile/client) | who a transaction acts for, the errors, the runtime's calls |
|
|
79
|
+
| [`@rowstile/prisma`](https://www.npmjs.com/package/@rowstile/prisma) | Prisma |
|
|
80
|
+
| [`@rowstile/pg`](https://www.npmjs.com/package/@rowstile/pg) | node-postgres |
|
|
81
|
+
| [`@rowstile/postgres`](https://www.npmjs.com/package/@rowstile/postgres) | postgres.js |
|
|
82
|
+
| [`@rowstile/drizzle`](https://www.npmjs.com/package/@rowstile/drizzle) | Drizzle ORM |
|
|
83
|
+
| [`@rowstile/react`](https://www.npmjs.com/package/@rowstile/react) | React: hooks and a share dialog |
|
|
84
|
+
| [`@rowstile/vitest`](https://www.npmjs.com/package/@rowstile/vitest) | Vitest: matchers, a database per worker |
|
|
85
|
+
|
|
86
|
+
rowstile is a 0.x preview: names may still change before 1.0. Apache-2.0.
|
package/dist/index.d.ts
CHANGED
|
@@ -10,7 +10,10 @@ declare function keepOutOfCaches(p: Principal): Promise<void>;
|
|
|
10
10
|
export declare function checkAtStart(calls: {
|
|
11
11
|
check(): Promise<void>;
|
|
12
12
|
}): Promise<void>;
|
|
13
|
-
/** A route handler whose refusals answer 403 with the reason, and hidden rows 404 (RFC 9457 problem bodies)
|
|
13
|
+
/** A route handler whose refusals answer 403 with the reason, and hidden rows 404 (RFC 9457 problem bodies); a call
|
|
14
|
+
* the database turns down that is no refusal answers as its code's page says, with its words: 401 for a login
|
|
15
|
+
* refused or a call that needs someone signed in (AZ703, AZ714), 404 for something it names that isn't there
|
|
16
|
+
* (AZ708), 400 for an argument missing or wrong (AZ710), 409 for a move inside itself (AZ713). */
|
|
14
17
|
export declare function route<A extends unknown[]>(handler: (...args: A) => Response | Promise<Response>): (...args: A) => Promise<Response>;
|
|
15
18
|
/** What a server action returns: its value, or the problem (a server action can't answer 403, and Next.js hides
|
|
16
19
|
* a thrown error's message in production). */
|
|
@@ -21,7 +24,8 @@ export type ActionResult<R> = {
|
|
|
21
24
|
ok: false;
|
|
22
25
|
problem: Problem;
|
|
23
26
|
};
|
|
24
|
-
/** A server action whose refusals and hidden rows come back as { ok: false, problem } instead of an error
|
|
27
|
+
/** A server action whose refusals and hidden rows come back as { ok: false, problem } instead of an error, and
|
|
28
|
+
* so does what route() answers 401, 404, 400 or 409 for (AZ703, AZ714, AZ708, AZ710, AZ713). */
|
|
25
29
|
export declare function action<A extends unknown[], R>(fn: (...args: A) => Promise<R>): (...args: A) => Promise<ActionResult<R>>;
|
|
26
30
|
export interface RoutesOptions {
|
|
27
31
|
/** The runtime's functions, each signed in as the request's user: db.$authz (Prisma), authz(pool) (pg), ... */
|
|
@@ -36,7 +40,9 @@ export interface RoutesOptions {
|
|
|
36
40
|
* GET perms?type=folder&ids=1&ids=2 (POST perms {type, ids} for a long list), GET shares?type=folder&id=3,
|
|
37
41
|
* GET events (server-sent events), POST share, POST unshare, POST request. Each answers as the signed-in
|
|
38
42
|
* user, and only what they may see. The POST routes take application/json only: a form on another site
|
|
39
|
-
* can't send that without the browser asking this one first.
|
|
43
|
+
* can't send that without the browser asking this one first. They answer as route() does: a share with
|
|
44
|
+
* someone who doesn't exist 404, a request for access without a reason 400, and one from someone not signed in
|
|
45
|
+
* 401, with the database's words. */
|
|
40
46
|
export declare function authzRoutes(options: RoutesOptions): {
|
|
41
47
|
GET: (req: Request) => Promise<Response>;
|
|
42
48
|
POST: (req: Request) => Promise<Response>;
|
package/dist/index.js
CHANGED
|
@@ -43,7 +43,10 @@ export async function checkAtStart(calls) {
|
|
|
43
43
|
process.exit(1);
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
|
-
/** A route handler whose refusals answer 403 with the reason, and hidden rows 404 (RFC 9457 problem bodies)
|
|
46
|
+
/** A route handler whose refusals answer 403 with the reason, and hidden rows 404 (RFC 9457 problem bodies); a call
|
|
47
|
+
* the database turns down that is no refusal answers as its code's page says, with its words: 401 for a login
|
|
48
|
+
* refused or a call that needs someone signed in (AZ703, AZ714), 404 for something it names that isn't there
|
|
49
|
+
* (AZ708), 400 for an argument missing or wrong (AZ710), 409 for a move inside itself (AZ713). */
|
|
47
50
|
export function route(handler) {
|
|
48
51
|
return async (...args) => {
|
|
49
52
|
try {
|
|
@@ -57,7 +60,8 @@ export function route(handler) {
|
|
|
57
60
|
}
|
|
58
61
|
};
|
|
59
62
|
}
|
|
60
|
-
/** A server action whose refusals and hidden rows come back as { ok: false, problem } instead of an error
|
|
63
|
+
/** A server action whose refusals and hidden rows come back as { ok: false, problem } instead of an error, and
|
|
64
|
+
* so does what route() answers 401, 404, 400 or 409 for (AZ703, AZ714, AZ708, AZ710, AZ713). */
|
|
61
65
|
export function action(fn) {
|
|
62
66
|
return async (...args) => {
|
|
63
67
|
try {
|
|
@@ -76,7 +80,9 @@ const json = (body, status = 200) => new Response(JSON.stringify(body), { status
|
|
|
76
80
|
* GET perms?type=folder&ids=1&ids=2 (POST perms {type, ids} for a long list), GET shares?type=folder&id=3,
|
|
77
81
|
* GET events (server-sent events), POST share, POST unshare, POST request. Each answers as the signed-in
|
|
78
82
|
* user, and only what they may see. The POST routes take application/json only: a form on another site
|
|
79
|
-
* can't send that without the browser asking this one first.
|
|
83
|
+
* can't send that without the browser asking this one first. They answer as route() does: a share with
|
|
84
|
+
* someone who doesn't exist 404, a request for access without a reason 400, and one from someone not signed in
|
|
85
|
+
* 401, with the database's words. */
|
|
80
86
|
export function authzRoutes(options) {
|
|
81
87
|
const { calls } = options;
|
|
82
88
|
const streams = { open: 0, most: options.maxStreams ?? 1000 };
|
|
@@ -130,32 +136,28 @@ function events(req, changes, streams) {
|
|
|
130
136
|
return json({ title: "Service Unavailable", status: 503, detail: "too many event streams open" }, 503);
|
|
131
137
|
}
|
|
132
138
|
const encoder = new TextEncoder();
|
|
133
|
-
let stop
|
|
139
|
+
let stop; // set as the stream starts, which is at once
|
|
134
140
|
const stream = new ReadableStream({
|
|
135
141
|
start(controller) {
|
|
136
142
|
streams.open += 1;
|
|
137
143
|
let counted = true;
|
|
138
|
-
|
|
139
|
-
const
|
|
144
|
+
// a stream that ended, told of a change all the same: nothing to send, and it stops
|
|
145
|
+
const send = (text) => {
|
|
140
146
|
try {
|
|
141
|
-
controller.enqueue(encoder.encode(
|
|
147
|
+
controller.enqueue(encoder.encode(text));
|
|
142
148
|
}
|
|
143
149
|
catch {
|
|
144
150
|
stop();
|
|
145
151
|
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
151
|
-
catch {
|
|
152
|
-
stop();
|
|
153
|
-
}
|
|
154
|
-
}, 25000);
|
|
152
|
+
};
|
|
153
|
+
send(": connected\n\n");
|
|
154
|
+
const unsubscribe = changes(() => send("event: changed\ndata: {}\n\n"));
|
|
155
|
+
const ping = setInterval(() => send(": ping\n\n"), 25000);
|
|
155
156
|
stop = () => {
|
|
156
|
-
if (counted)
|
|
157
|
-
|
|
157
|
+
if (!counted)
|
|
158
|
+
return; // once: a page that leaves cancels the stream and aborts the request
|
|
158
159
|
counted = false;
|
|
160
|
+
streams.open -= 1;
|
|
159
161
|
clearInterval(ping);
|
|
160
162
|
unsubscribe();
|
|
161
163
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rowstile/next",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.7",
|
|
4
4
|
"description": "rowstile with Next.js: signed-in reads kept out of caches, 403 and 404 from route handlers and server actions",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://rowstile.dev",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
],
|
|
21
21
|
"sideEffects": true,
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@rowstile/client": "0.1.0-alpha.
|
|
23
|
+
"@rowstile/client": "0.1.0-alpha.7"
|
|
24
24
|
},
|
|
25
25
|
"peerDependencies": {
|
|
26
26
|
"next": ">=15"
|