slopkit 0.3.0 → 0.4.0-rc.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.
- package/DEPS.md +5 -3
- package/README.md +93 -1
- package/RULES.md +26 -6
- package/dist/api.d.ts +93 -0
- package/dist/api.js +111 -0
- package/dist/client.d.ts +5 -1
- package/dist/conformance/agent-access.test.d.ts +1 -0
- package/dist/conformance/agent-access.test.js +74 -0
- package/dist/conformance/paging-state.test.js +1 -0
- package/dist/conformance/states.test.js +19 -8
- package/dist/server/auth.d.ts +4 -3
- package/dist/server/auth.js +14 -6
- package/dist/server/cli.d.ts +117 -0
- package/dist/server/cli.js +1534 -0
- package/dist/server/config.d.ts +4 -1
- package/dist/server/config.js +2 -2
- package/dist/server/keys.d.ts +17 -0
- package/dist/server/keys.js +127 -0
- package/dist/server/store.d.ts +101 -0
- package/dist/server/store.js +347 -0
- package/dist/server/sync.d.ts +158 -0
- package/dist/server/sync.js +293 -0
- package/dist/server/web.d.ts +54 -5
- package/dist/server/web.js +236 -28
- package/dist/states.d.ts +52 -4
- package/dist/states.js +20 -0
- package/dist/sync.d.ts +2 -0
- package/dist/sync.js +1 -0
- package/dist/test/agents.d.ts +19 -0
- package/dist/test/agents.js +62 -0
- package/dist/test/page.d.ts +91 -0
- package/dist/test/page.js +763 -0
- package/dist/test/states.d.ts +44 -7
- package/dist/test/states.js +152 -0
- package/dist/web/replica.d.ts +7 -0
- package/dist/web/replica.js +214 -43
- package/dist/web/runtime.d.ts +102 -0
- package/dist/web/runtime.js +335 -0
- package/package.json +39 -1
package/DEPS.md
CHANGED
|
@@ -28,9 +28,11 @@ seeded network's PRNG and scheduler.
|
|
|
28
28
|
|
|
29
29
|
## Budgets
|
|
30
30
|
|
|
31
|
-
- **Kit client core**, gated by `pack:check` on the packed package: `schema`, `contract`, `
|
|
32
|
-
`money`, `digest`, `states` and `web/
|
|
33
|
-
paged first copy added 1,470, refused pushes and the bounded outbox 1,755
|
|
31
|
+
- **Kit client core**, gated by `pack:check` on the packed package: `schema`, `contract`, `api`, `sync`, `client`,
|
|
32
|
+
`machine`, `money`, `digest`, `states`, `web/replica` and `web/runtime`, minified and gzipped together, ≤ 17,286
|
|
33
|
+
bytes (17,286 measured; the paged first copy added 1,470, refused pushes and the bounded outbox 1,755, the sync
|
|
34
|
+
contract and the page's runtime 2,428, in place of each app's own, a working state's owed work 107, and an upgrade
|
|
35
|
+
keeping the offline copy 800).
|
|
34
36
|
`cron` is browser-safe but outside the core; an app that ships it counts it in its own budget. The budget grows only
|
|
35
37
|
with a new entry or guarantee, by its measured size.
|
|
36
38
|
- **Hello app** (preact, signals, and `sync`, `client`, `web/replica`, `schema`, `contract`, `states`): ≤ 17 KB gzip
|
package/README.md
CHANGED
|
@@ -59,7 +59,8 @@ const update: Update<Note, Msg, Effect> = (note, msg) => {
|
|
|
59
59
|
const ui = states<"check">({ order: MEANINGS });
|
|
60
60
|
const view = ui.subject<Note>("note", {
|
|
61
61
|
draft: { meaning: "none", where: "main", claim: false, why: "unsent", text: () => "draft", action: null },
|
|
62
|
-
saving: { meaning: "working", where: "main", claim: false, why: "sent", text: () => "saving", action: null
|
|
62
|
+
saving: { meaning: "working", where: "main", claim: false, why: "sent", text: () => "saving", action: null,
|
|
63
|
+
owes: () => ({ sync: true }) }, // a working state names its work, checked to be under way
|
|
63
64
|
saved: { meaning: "ok", where: "main", claim: false, why: "kept", text: () => "saved", action: null },
|
|
64
65
|
unsure: { meaning: "you", where: "main", claim: true, why: "may have saved", text: () => "saved?", action: "check" },
|
|
65
66
|
});
|
|
@@ -72,6 +73,14 @@ const { model, effects } = replay(update, { kind: "draft" }, [
|
|
|
72
73
|
view.line(model).text; // "saved?", drawn ⚠ with a check action
|
|
73
74
|
effects; // [{ kind: "send", text: "hi" }], one send
|
|
74
75
|
|
|
76
|
+
// the page's own state (route, filters, applied forms) lives in the model too, so every view a replay reaches is checked
|
|
77
|
+
const { problems } = replayProblems(update, { kind: "draft" }, messages, { view: (note) => view.line(note), order: MEANINGS });
|
|
78
|
+
|
|
79
|
+
// the app's real page, headless, over its own server in this process, signed in by the test login
|
|
80
|
+
const page = await openPage({ server, page: () => import("../src/web/main.tsx"), at: "2026-01-01T12:00:00Z", path: "/?note=a" });
|
|
81
|
+
await page.press(/save/); // by accessible name; settles, then checks the view
|
|
82
|
+
await page.reload(); // same device: IndexedDB, localStorage, sessionStorage and the address kept
|
|
83
|
+
|
|
75
84
|
// the page calls it, and the server answers after checking the login, the request and the reply
|
|
76
85
|
const sent = await call(save, { text: "hi" }); // { ok: true, value: { id } } or a typed failure
|
|
77
86
|
await serveWeb({
|
|
@@ -94,6 +103,20 @@ export default defineConfig({
|
|
|
94
103
|
});
|
|
95
104
|
```
|
|
96
105
|
|
|
106
|
+
agents call your app with a key (`addKey(home, name)` from `slopkit/server/keys`, allowed as `key:<name>` in
|
|
107
|
+
`web.allow`), never as the person. prove it in your own tests against your real server:
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
import { agentAccess, reads, refused } from "slopkit/test/agents";
|
|
111
|
+
|
|
112
|
+
await agentAccess({
|
|
113
|
+
url: server.url,
|
|
114
|
+
token: await addKey(home, "agent"),
|
|
115
|
+
reads: [reads(list, {})],
|
|
116
|
+
refused: [refused(push, request, { refusal: (reply) => reply.results.every(forbidden) })],
|
|
117
|
+
});
|
|
118
|
+
```
|
|
119
|
+
|
|
97
120
|
push handlers return per-command outcomes as ledger receipts. once any command in a request has acted, never throw
|
|
98
121
|
an `invalid`, `forbidden` or `conflict` refusal for the whole request: a refusal proves only that attempt did not act,
|
|
99
122
|
not that an earlier delivery did not. request schemas are checked before the handler runs.
|
|
@@ -145,6 +168,75 @@ import { pullConformance } from "slopkit/conformance/pull";
|
|
|
145
168
|
pullConformance({ name: "my app", create: async (limits) => fixtureOverMyRealStore(limits) });
|
|
146
169
|
```
|
|
147
170
|
|
|
171
|
+
### the app shell
|
|
172
|
+
|
|
173
|
+
the page's runtime, the server's sync, the store and the command line are the same in every app, so they live here.
|
|
174
|
+
an app declares its rows, commands and outcomes once:
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
import { syncApi } from "slopkit/api";
|
|
178
|
+
|
|
179
|
+
// pull, push, telemetry and the socket's poke, plus the outbox record. rows need an `id`
|
|
180
|
+
export const api = syncApi({ rows: { notes: note, tags: tag }, server, command, outcome });
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
the store is the kit's core (meta, revisions, the command ledger, jobs) plus your tables:
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
import { schemaVersion, SyncStore } from "slopkit/server/store";
|
|
187
|
+
|
|
188
|
+
const SCHEMA = { base: "create table notes(...) strict; ...", replicated: ["notes", "tags"], baseVersion: 1,
|
|
189
|
+
migrations: [], indexes: "create index if not exists notes_order on notes(at, id, deleted);" };
|
|
190
|
+
|
|
191
|
+
class Store extends SyncStore {
|
|
192
|
+
readonly ledger = this.createLedger<Kept>({ encode: JSON.stringify, decode: readKept });
|
|
193
|
+
constructor(path: string) { super(path, { name: "notes", schema: SCHEMA }); }
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
the server answers pull and push over it, and serves them with the poke socket and telemetry:
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
import { serveSync, syncHandlers } from "slopkit/server/sync";
|
|
201
|
+
|
|
202
|
+
const sync = syncHandlers({ api, store, ledger: store.ledger, log, server: (caller) => facts(caller),
|
|
203
|
+
history: { row: "notes", table: "notes", order: ["at", "id"], version: 1, live: "deleted = 0", read: toNote,
|
|
204
|
+
through: ([at]) => String(at) },
|
|
205
|
+
unpaged: (since) => ({ tags: store.tags(since) }), everything: () => store.everything(),
|
|
206
|
+
names: (kept) => [kept.note], limits, act: (command) => ({ local: () => store.apply(command) }),
|
|
207
|
+
outcome: (kept) => kept });
|
|
208
|
+
|
|
209
|
+
await serveSync({ name: "notes", api, sync, home, config, env, log, page, assets: "dist/web" });
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
your outcome union must admit the kit's own answers (`failed` with `conflict` or `forbidden`, and `retired`): if it
|
|
213
|
+
can't, `serveSync` doesn't compile. the page runs the same engine headless or in a browser:
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
import { createRuntime } from "slopkit/web/runtime";
|
|
217
|
+
|
|
218
|
+
const runtime = createRuntime({ name: "notes", sync, api, replica: { version: 1, tables, empty } });
|
|
219
|
+
runtime.subscribe((model) => draw(model));
|
|
220
|
+
runtime.start();
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
and `main.ts` is the command line: `serve | update | rollback | probe | version`, one server per home, releases that
|
|
224
|
+
boot on a copy of the store before they replace the running one, automatic rollback of a crashing update, and `probe`,
|
|
225
|
+
the live server checked from its host with no identity (it serves, the version that passed, no test login, requests
|
|
226
|
+
without identity refused, the public door matching `web.json`):
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
import { runCli } from "slopkit/server/cli";
|
|
230
|
+
|
|
231
|
+
process.exitCode = await runCli(process.argv.slice(2), {
|
|
232
|
+
name: "notes", root: new URL("../", import.meta.url), schema: schemaVersion(SCHEMA), serves: "your notes.",
|
|
233
|
+
serve: async ({ home, version, log, web, env }) => {
|
|
234
|
+
/* open the store, serveSync when web isn't off */
|
|
235
|
+
return { url, close };
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
```
|
|
239
|
+
|
|
148
240
|
the shared lint rules go on top of ultracite's presets (`oxlint.config.ts`):
|
|
149
241
|
|
|
150
242
|
```ts
|
package/RULES.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Rules for apps on slopkit
|
|
2
2
|
|
|
3
|
-
Rules version:
|
|
3
|
+
Rules version: 2
|
|
4
4
|
|
|
5
5
|
The standard every app built on `slopkit` meets, and the kit itself. The aim is speed and correctness,
|
|
6
6
|
solved once in the kit so every app built on it starts with these problems solved. Each rule is a property of the
|
|
@@ -56,9 +56,21 @@ What the screen can say is decided for every state, as a pure function of the ap
|
|
|
56
56
|
with composition rules, not one giant enum. Prevents a concurrent fact being lost to another.
|
|
57
57
|
- **Every state's display is decided, with its reason.** A typed table `satisfies Record<State["kind"], Display>` maps
|
|
58
58
|
each state to where it is drawn (`main`: it changes what the person should believe about the numbers or needs them
|
|
59
|
-
to act; `badge`; `panel`; `log`), why, tone, glyph, text and action
|
|
59
|
+
to act; `badge`; `panel`; `log`), why, tone, glyph, text and action, and for a working state what it owes. A new
|
|
60
|
+
state doesn't compile until it is placed.
|
|
60
61
|
Two states share a display only when the statement is true for both and implies the same safe action. A hidden state
|
|
61
62
|
has a reason, and hiding it changes no claim or action.
|
|
63
|
+
- **Working names what it waits on.** A working state's table entry names the work it waits on (a job, a command,
|
|
64
|
+
the page's own sync), and the state is drawn only while that work is under way. One whose work is the world's (a
|
|
65
|
+
parcel in transit) says so in its entry, with why. Prevents "checking…" on a check nobody runs.
|
|
66
|
+
- **The page's own state is model state.** What changes what the screen says or what a tap does (the route and open
|
|
67
|
+
item, modes, open panels, filters and searches, selections, forms with drafts and applied values held apart, and
|
|
68
|
+
persisted view settings) is part of the model. It changes only through the model's pure `update` (a message in; the
|
|
69
|
+
model and effects out), so a replay of messages reaches it like the rest of the model. The address and storage are
|
|
70
|
+
read once at load and written as effects, so a deep link opens the screen navigating there does, back returns, and
|
|
71
|
+
a reload keeps what was set on that device. The numbers never read a form's draft. Focus, hover, scroll and
|
|
72
|
+
animation stay in the browser unless the view changes with them. Prevents a selection that moves the numbers with
|
|
73
|
+
no state of its own.
|
|
62
74
|
- **Never claim more than is true.** Order by placement, then severity, with each app setting its own severity order
|
|
63
75
|
by consequence. Facts that change what the numbers mean (incomplete, possibly wrong, not saved, stale) sit next to
|
|
64
76
|
the numbers and are never folded into a count behind another headline; a headline may lead with an action (sign in
|
|
@@ -78,8 +90,10 @@ What the screen can say is decided for every state, as a pure function of the ap
|
|
|
78
90
|
and accessibility violations fail the build. No new colours, boxes or spinners.
|
|
79
91
|
- **Verified over the whole catalog.** Tests enumerate every state and bounded combinations through the real engine
|
|
80
92
|
and `view`: placement holds, states needing different actions never render the same text, and no number is shown
|
|
81
|
-
without its claim-critical facts. Every
|
|
82
|
-
|
|
93
|
+
without its claim-critical facts. Every view an app's tests reach (replayed messages, the app's real page driven
|
|
94
|
+
headless) passes the kit's `viewProblems`: no broken value, "ok" only when every part is, claims kept whole, and
|
|
95
|
+
every working line's owed work under way. Every state can also be drawn on its own at phone size (a gallery of the
|
|
96
|
+
catalog), so each display can be seen as rendered.
|
|
83
97
|
|
|
84
98
|
## 4. Outside services
|
|
85
99
|
|
|
@@ -154,7 +168,9 @@ work up.
|
|
|
154
168
|
overlap, interrupted commits, shared storage, identity changes, reconnects. More happy paths or seeds can't cover a missing failure dimension.
|
|
155
169
|
- Headless correctness doesn't prove the UI binding: a renderer can drop a recovery button or swap actions while
|
|
156
170
|
`view()` passes. Thin rendering and binding checks prove each action in `view` reaches the right command and each
|
|
157
|
-
warning is drawn,
|
|
171
|
+
warning is drawn, on the app's real page driven headless, and in each target browser for what a DOM shim can't
|
|
172
|
+
show: layout and overflow, and platform bindings (a popup inside the tap, storage persistence, the service worker;
|
|
173
|
+
Safari and iOS PWA storage differ). A packed-consumer check proves the app
|
|
158
174
|
runs the kit as shipped.
|
|
159
175
|
- Tiers: pure model tests in milliseconds; a seeded headless end-to-end gate in seconds; adapter and binding checks; a
|
|
160
176
|
read-only check of the deployed app on synthetic data.
|
|
@@ -202,7 +218,11 @@ Performance is a requirement, aimed at the limits of every service and system in
|
|
|
202
218
|
- **Dependencies.** Few, from `DEPS.md`, so apps stay small. Few never means hand-rolled crypto, JWT, protocol parsers
|
|
203
219
|
or weaker storage semantics: those use a vetted library (for example `jose`).
|
|
204
220
|
- **Defaults:** IndexedDB in the browser (SQLite in the browser only where demonstrably better); SQLite on the server;
|
|
205
|
-
|
|
221
|
+
logins to outside accounts added in the UI.
|
|
222
|
+
- **Its own login.** An app signs people in itself, with OpenID Connect, and serves on a local binding. An
|
|
223
|
+
identity-aware proxy in front (Cloudflare Access's signed header) is an optional outer door, never the app's login.
|
|
224
|
+
A test identity (the kit's OIDC stand-in) signs in on a test home, so the app is proven end to end without a real
|
|
225
|
+
login.
|
|
206
226
|
- **Look.** Brutalist, mobile-first PWAs: dense, monospace, black and white, no shadows or rounded corners.
|
|
207
227
|
- Shipped source is generic: no hostnames, accounts or private data in code or docs; private data lives in the app's
|
|
208
228
|
data directory and configuration.
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { Endpoint } from "./contract.ts";
|
|
2
|
+
import type { Schema } from "./schema.ts";
|
|
3
|
+
import type { Page, Pending, Receipt } from "./sync.ts";
|
|
4
|
+
/** The answers the kit's server gives a command itself (`slopkit/server/sync`): an app's outcomes must admit them. */
|
|
5
|
+
export type KitOutcome = {
|
|
6
|
+
kind: "failed";
|
|
7
|
+
reason: "conflict" | "forbidden";
|
|
8
|
+
message: string;
|
|
9
|
+
} | {
|
|
10
|
+
kind: "retired";
|
|
11
|
+
};
|
|
12
|
+
/** Each row key's list, as a pull reply carries them. */
|
|
13
|
+
export type Rows<R> = {
|
|
14
|
+
[K in keyof R]: R[K][];
|
|
15
|
+
};
|
|
16
|
+
export type PullRequest = {
|
|
17
|
+
client: string;
|
|
18
|
+
dataset: string | null;
|
|
19
|
+
since: number;
|
|
20
|
+
verify?: boolean;
|
|
21
|
+
page?: string | null;
|
|
22
|
+
commands?: {
|
|
23
|
+
client: string;
|
|
24
|
+
id: number;
|
|
25
|
+
}[];
|
|
26
|
+
};
|
|
27
|
+
export type PullReply<R, S, O> = Rows<R> & {
|
|
28
|
+
dataset: string;
|
|
29
|
+
revision: number;
|
|
30
|
+
reset: boolean;
|
|
31
|
+
commands: Receipt<O>[];
|
|
32
|
+
server: S;
|
|
33
|
+
checksum?: string;
|
|
34
|
+
page?: Page;
|
|
35
|
+
};
|
|
36
|
+
export type PushRequest<C> = {
|
|
37
|
+
client: string;
|
|
38
|
+
dataset: string | null;
|
|
39
|
+
since: number;
|
|
40
|
+
commands: {
|
|
41
|
+
id: number;
|
|
42
|
+
command: C;
|
|
43
|
+
}[];
|
|
44
|
+
};
|
|
45
|
+
export type PushReply<O> = {
|
|
46
|
+
results: Receipt<O>[];
|
|
47
|
+
revision: number;
|
|
48
|
+
};
|
|
49
|
+
export type TelemetryEvent = {
|
|
50
|
+
name: string;
|
|
51
|
+
at: string;
|
|
52
|
+
attributes?: Record<string, string | number | boolean | null>;
|
|
53
|
+
};
|
|
54
|
+
export type TelemetryRequest = {
|
|
55
|
+
client: string;
|
|
56
|
+
events: TelemetryEvent[];
|
|
57
|
+
};
|
|
58
|
+
/** What the socket sends: the server's revision, when it moves and as a heartbeat. */
|
|
59
|
+
export type Poke = {
|
|
60
|
+
type: "poke";
|
|
61
|
+
revision: number;
|
|
62
|
+
};
|
|
63
|
+
export type SyncApi<R, S, C, O> = {
|
|
64
|
+
/** The row keys, in the order the fingerprint and the checksum digest them. */
|
|
65
|
+
rows: readonly (keyof R & string)[];
|
|
66
|
+
/** What the server says of itself, read with every pull. */
|
|
67
|
+
server: Schema<S>;
|
|
68
|
+
pull: Endpoint<PullRequest, PullReply<R, S, O>>;
|
|
69
|
+
push: Endpoint<PushRequest<C>, PushReply<O>>;
|
|
70
|
+
telemetry: Endpoint<TelemetryRequest, {
|
|
71
|
+
ok: true;
|
|
72
|
+
}>;
|
|
73
|
+
socket: {
|
|
74
|
+
path: string;
|
|
75
|
+
message: Schema<Poke>;
|
|
76
|
+
};
|
|
77
|
+
/** A command as a device's outbox keeps it, checked when read back. */
|
|
78
|
+
pending: Schema<Pending<C, O>>;
|
|
79
|
+
};
|
|
80
|
+
export declare function syncApi<R extends Record<string, {
|
|
81
|
+
id: string | number;
|
|
82
|
+
}>, S, C, O>(definition: {
|
|
83
|
+
rows: {
|
|
84
|
+
[K in keyof R]: Schema<R[K]>;
|
|
85
|
+
};
|
|
86
|
+
server: Schema<S>;
|
|
87
|
+
command: Schema<C>;
|
|
88
|
+
outcome: Schema<O>;
|
|
89
|
+
/** A store's dataset id; default text of 1 to 200 characters. */
|
|
90
|
+
dataset?: Schema<string>;
|
|
91
|
+
/** A page's `through`, as the app formats an order group; default text of at most 64 characters. */
|
|
92
|
+
through?: Schema<string>;
|
|
93
|
+
}): SyncApi<R, S, C, O>;
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sync contract's endpoints, built from an app's own schemas: pull (what changed since a revision, paged), push
|
|
3
|
+
* (commands, each acting once), telemetry (the page's counts and timings, for the server's log) and the socket's poke.
|
|
4
|
+
* The page, the server and the engine share these shapes, so an app declares only its rows, what its server says of
|
|
5
|
+
* itself, its commands and their outcomes.
|
|
6
|
+
*/
|
|
7
|
+
import { endpoint } from "./contract.js";
|
|
8
|
+
import { array, boolean, literal, nullable, number, object, optional, record, scalar, string, variant, } from "./schema.js";
|
|
9
|
+
import { PUSH_MAX } from "./sync.js";
|
|
10
|
+
/** A reply's own fields: no row list may take one of their names. */
|
|
11
|
+
const RESERVED = new Set([
|
|
12
|
+
"dataset",
|
|
13
|
+
"revision",
|
|
14
|
+
"reset",
|
|
15
|
+
"commands",
|
|
16
|
+
"server",
|
|
17
|
+
"checksum",
|
|
18
|
+
"page",
|
|
19
|
+
]);
|
|
20
|
+
const revision = number({ integer: true, min: 0 });
|
|
21
|
+
const client = string({ pattern: /^[A-Za-z0-9_-]{8,64}$/ });
|
|
22
|
+
const commandId = number({ integer: true, min: 1 });
|
|
23
|
+
const cursor = string({ min: 1, max: 1000 });
|
|
24
|
+
const iso = string({
|
|
25
|
+
pattern: /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/,
|
|
26
|
+
});
|
|
27
|
+
export function syncApi(definition) {
|
|
28
|
+
const keys = Object.keys(definition.rows);
|
|
29
|
+
const taken = keys.find((key) => RESERVED.has(key));
|
|
30
|
+
if (taken !== undefined) {
|
|
31
|
+
throw new Error(`syncApi: a row key can't be a reply's own field: ${taken}`);
|
|
32
|
+
}
|
|
33
|
+
const dataset = definition.dataset ?? string({ min: 1, max: 200 });
|
|
34
|
+
const receipt = object({ id: commandId, outcome: definition.outcome, revision, client: optional(client) }, { strict: true });
|
|
35
|
+
const page = object({
|
|
36
|
+
from: nullable(cursor),
|
|
37
|
+
next: nullable(cursor),
|
|
38
|
+
through: nullable(definition.through ?? string({ max: 64 })),
|
|
39
|
+
held: number({ integer: true, min: 0 }),
|
|
40
|
+
total: number({ integer: true, min: 0 }),
|
|
41
|
+
}, { strict: true });
|
|
42
|
+
const lists = Object.fromEntries(Object.entries(definition.rows).map(([key, row]) => [key, array(row)]));
|
|
43
|
+
const reply = object({
|
|
44
|
+
dataset,
|
|
45
|
+
revision,
|
|
46
|
+
reset: boolean(),
|
|
47
|
+
...lists,
|
|
48
|
+
commands: array(receipt),
|
|
49
|
+
server: definition.server,
|
|
50
|
+
checksum: optional(string({ pattern: /^[0-9a-f]{64}$/ })),
|
|
51
|
+
page: optional(page),
|
|
52
|
+
}, { strict: true });
|
|
53
|
+
const pull = endpoint("GET", "/api/pull", object({
|
|
54
|
+
client,
|
|
55
|
+
dataset: nullable(dataset),
|
|
56
|
+
since: revision,
|
|
57
|
+
verify: optional(boolean()),
|
|
58
|
+
page: optional(nullable(cursor)),
|
|
59
|
+
commands: optional(array(object({ client, id: commandId }, { strict: true }), { max: PUSH_MAX })),
|
|
60
|
+
}, { strict: true }), {
|
|
61
|
+
expected: reply.expected,
|
|
62
|
+
shape: reply.shape,
|
|
63
|
+
// SAFETY: `reply` is the strict object of exactly PullReply's fields: the fixed ones above and one array of
|
|
64
|
+
// each row schema under its key; the spread of `lists` is what TypeScript can't follow.
|
|
65
|
+
// oxlint-disable-next-line typescript/consistent-type-assertions, typescript/no-unsafe-type-assertion
|
|
66
|
+
parse: (value, path) => reply.parse(value, path),
|
|
67
|
+
});
|
|
68
|
+
const push = endpoint("POST", "/api/push", object({
|
|
69
|
+
client,
|
|
70
|
+
dataset: nullable(dataset),
|
|
71
|
+
since: revision,
|
|
72
|
+
commands: array(object({ id: commandId, command: definition.command }, { strict: true }), {
|
|
73
|
+
max: PUSH_MAX,
|
|
74
|
+
}),
|
|
75
|
+
}, { strict: true }), object({ results: array(receipt), revision }, { strict: true }));
|
|
76
|
+
const telemetry = endpoint("POST", "/api/telemetry", object({
|
|
77
|
+
client,
|
|
78
|
+
events: array(object({
|
|
79
|
+
name: string({ pattern: /^[a-z][a-z0-9.-]{0,63}$/ }),
|
|
80
|
+
at: iso,
|
|
81
|
+
attributes: optional(record(scalar())),
|
|
82
|
+
}, { strict: true }), { max: 100 }),
|
|
83
|
+
}, { strict: true }), object({ ok: literal(true) }, { strict: true }));
|
|
84
|
+
const message = variant("type", {
|
|
85
|
+
poke: object({ type: literal("poke"), revision }, { strict: true }),
|
|
86
|
+
});
|
|
87
|
+
const pending = object({
|
|
88
|
+
client,
|
|
89
|
+
id: commandId,
|
|
90
|
+
dataset: nullable(dataset),
|
|
91
|
+
since: optional(revision),
|
|
92
|
+
command: definition.command,
|
|
93
|
+
at: number(),
|
|
94
|
+
state: variant("kind", {
|
|
95
|
+
saving: object({ kind: literal("saving") }, { strict: true }),
|
|
96
|
+
queued: object({ kind: literal("queued") }, { strict: true }),
|
|
97
|
+
sending: object({ kind: literal("sending") }, { strict: true }),
|
|
98
|
+
unknown: object({ kind: literal("unknown"), failure: string(), parked: optional(literal(true)) }, { strict: true }),
|
|
99
|
+
done: object({ kind: literal("done"), outcome: definition.outcome, revision }, { strict: true }),
|
|
100
|
+
}),
|
|
101
|
+
}, { strict: true });
|
|
102
|
+
return {
|
|
103
|
+
rows: keys,
|
|
104
|
+
server: definition.server,
|
|
105
|
+
pull,
|
|
106
|
+
push,
|
|
107
|
+
telemetry,
|
|
108
|
+
socket: { path: "/api/socket", message },
|
|
109
|
+
pending,
|
|
110
|
+
};
|
|
111
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -13,11 +13,15 @@ export declare class Replaced extends Error {
|
|
|
13
13
|
constructor();
|
|
14
14
|
}
|
|
15
15
|
export type StoragePort<T, S, C, O> = {
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* The replica and the outbox; `unreadable` counts kept commands this version can't read (left on disk); `restart`
|
|
18
|
+
* when the copy owes a real reset before it is trusted (one lifted from an earlier release's row shapes).
|
|
19
|
+
*/
|
|
17
20
|
load: () => Promise<{
|
|
18
21
|
replica: Replica<T, S>;
|
|
19
22
|
commands: Pending<C, O>[];
|
|
20
23
|
unreadable: number;
|
|
24
|
+
restart?: "paged";
|
|
21
25
|
}>;
|
|
22
26
|
/**
|
|
23
27
|
* Commit a patch, decided against the disk inside its transaction; returns what the disk now holds, and `whole:
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, expect, test } from "vitest";
|
|
5
|
+
import { endpoint } from "../contract.js";
|
|
6
|
+
import { array, literal, object, string } from "../schema.js";
|
|
7
|
+
import { parseWebConfig } from "../server/config.js";
|
|
8
|
+
import { addKey } from "../server/keys.js";
|
|
9
|
+
import { Log, scoped } from "../server/log.js";
|
|
10
|
+
import { route, serveWeb } from "../server/web.js";
|
|
11
|
+
import { agentAccess, reads, refused } from "../test/agents.js";
|
|
12
|
+
import { identityProvider } from "../test/oidc.js";
|
|
13
|
+
const cleanups = [];
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
for (const close of cleanups.splice(0).toReversed()) {
|
|
16
|
+
await close();
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
const list = endpoint("GET", "/api/list", object({}), object({ items: array(string()) }));
|
|
20
|
+
const grade = endpoint("POST", "/api/grade", object({ item: string() }), object({ ok: literal(true) }));
|
|
21
|
+
const push = endpoint("POST", "/api/push", object({ item: string() }), object({ outcome: literal("done", "forbidden") }));
|
|
22
|
+
/** An app on loopback with one key: `person` says which of its routes only a person's login may call. */
|
|
23
|
+
async function app(person) {
|
|
24
|
+
const home = await mkdtemp(join(tmpdir(), "agent-access-"));
|
|
25
|
+
const provider = await identityProvider("owner");
|
|
26
|
+
const token = await addKey(home, "agent");
|
|
27
|
+
const server = await serveWeb({
|
|
28
|
+
home,
|
|
29
|
+
config: parseWebConfig({
|
|
30
|
+
port: 0,
|
|
31
|
+
oidc: { issuer: provider.issuer, clientId: "test" },
|
|
32
|
+
allow: ["sub:owner", "key:agent"],
|
|
33
|
+
}, []),
|
|
34
|
+
env: {},
|
|
35
|
+
log: scoped(new Log(home, "test", { echo: false }), "web"),
|
|
36
|
+
assets: home,
|
|
37
|
+
manifest: {},
|
|
38
|
+
cookie: "app_session",
|
|
39
|
+
routes: [
|
|
40
|
+
route({ ...list, person: person.list }, () => ({ items: ["a"] })),
|
|
41
|
+
route({ ...grade, person: person.grade }, () => ({ ok: true })),
|
|
42
|
+
route(push, (_request, caller) => ({ outcome: caller.person ? "done" : "forbidden" })),
|
|
43
|
+
],
|
|
44
|
+
});
|
|
45
|
+
cleanups.push(async () => {
|
|
46
|
+
await server.close();
|
|
47
|
+
await provider.close();
|
|
48
|
+
await rm(home, { recursive: true, force: true });
|
|
49
|
+
});
|
|
50
|
+
return { url: server.url, token };
|
|
51
|
+
}
|
|
52
|
+
const cases = {
|
|
53
|
+
reads: [reads(list, {})],
|
|
54
|
+
refused: [
|
|
55
|
+
refused(grade, { item: "a" }),
|
|
56
|
+
refused(push, { item: "a" }, { refusal: (reply) => reply.outcome === "forbidden" }),
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
test("an app whose key reads its agents' calls and is refused the person's passes", async () => {
|
|
60
|
+
const running = await app({ list: false, grade: true });
|
|
61
|
+
await expect(agentAccess({ ...running, ...cases })).resolves.toBeUndefined();
|
|
62
|
+
});
|
|
63
|
+
test("a person's call a key can make fails, naming it", async () => {
|
|
64
|
+
const running = await app({ list: false, grade: false });
|
|
65
|
+
await expect(agentAccess({ ...running, ...cases })).rejects.toThrow("POST /api/grade: a key should be refused, got an answer");
|
|
66
|
+
});
|
|
67
|
+
test("an agent's read a key can't make fails, naming it", async () => {
|
|
68
|
+
const running = await app({ list: true, grade: true });
|
|
69
|
+
await expect(agentAccess({ ...running, ...cases })).rejects.toThrow("GET /api/list: a key should read it, got forbidden");
|
|
70
|
+
});
|
|
71
|
+
test("a check with nothing to read or nothing refused proves nothing and fails", async () => {
|
|
72
|
+
const running = await app({ list: false, grade: true });
|
|
73
|
+
await expect(agentAccess({ ...running, reads: [], refused: cases.refused })).rejects.toThrow("proves nothing");
|
|
74
|
+
});
|
|
@@ -45,6 +45,7 @@ const table = {
|
|
|
45
45
|
why: "Tap waits, numbers unchanged",
|
|
46
46
|
text: () => "keeping the tap",
|
|
47
47
|
action: null,
|
|
48
|
+
owes: null,
|
|
48
49
|
},
|
|
49
50
|
};
|
|
50
51
|
const kit = () => states({ order });
|
|
@@ -288,14 +289,24 @@ describe("states: independent conformance", () => {
|
|
|
288
289
|
text: () => meaning,
|
|
289
290
|
action: null,
|
|
290
291
|
}
|
|
291
|
-
:
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
292
|
+
: meaning === "working"
|
|
293
|
+
? {
|
|
294
|
+
meaning,
|
|
295
|
+
where: "main",
|
|
296
|
+
claim: false,
|
|
297
|
+
why: "An obligation",
|
|
298
|
+
text: () => meaning,
|
|
299
|
+
action: null,
|
|
300
|
+
owes: null,
|
|
301
|
+
}
|
|
302
|
+
: {
|
|
303
|
+
meaning,
|
|
304
|
+
where: "main",
|
|
305
|
+
claim: false,
|
|
306
|
+
why: "An obligation",
|
|
307
|
+
text: () => meaning,
|
|
308
|
+
action: null,
|
|
309
|
+
},
|
|
299
310
|
})
|
|
300
311
|
.line({ kind: "x" }));
|
|
301
312
|
for (let i = 0; i < subjects.length; i++) {
|
package/dist/server/auth.d.ts
CHANGED
|
@@ -12,15 +12,16 @@ export declare class WebAuth {
|
|
|
12
12
|
private writing;
|
|
13
13
|
private readonly pending;
|
|
14
14
|
private readonly env;
|
|
15
|
+
private readonly keys;
|
|
15
16
|
constructor(config: WebConfig, home: string, env: Readonly<Record<string, string | undefined>>);
|
|
16
17
|
get oidc(): boolean;
|
|
17
18
|
private allowed;
|
|
18
19
|
/**
|
|
19
|
-
* The allowed identities of a request: from
|
|
20
|
-
* else from the session token (the session cookie's value). `undefined` means not signed in; `false`, signed in but
|
|
20
|
+
* The allowed identities of a request: from an agent's bearer key when it sends one, else the proxy's JWT when it
|
|
21
|
+
* sends one (a bad one of either is refused, never ignored), else from the session token (the session cookie's value). `undefined` means not signed in; `false`, signed in but
|
|
21
22
|
* not allowed.
|
|
22
23
|
*/
|
|
23
|
-
identify(proxyJwt: string | undefined, token: string | undefined): Promise<Identities | false | undefined>;
|
|
24
|
+
identify(proxyJwt: string | undefined, token: string | undefined, bearer?: string): Promise<Identities | false | undefined>;
|
|
24
25
|
private discover;
|
|
25
26
|
/** Where to send a browser to sign in, with PKCE; `redirect` is this server's own callback URL. */
|
|
26
27
|
loginUrl(redirect: string): Promise<string>;
|
package/dist/server/auth.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Who a web request is:
|
|
3
|
-
* own OpenID Connect login (authorization code with
|
|
4
|
-
* Either way the identity must be on `web.allow`.
|
|
2
|
+
* Who a web request is: an agent's key (`Authorization: Bearer`, see `keys.ts`), Cloudflare Access's signed JWT
|
|
3
|
+
* (`Cf-Access-Jwt-Assertion`), or a session from this server's own OpenID Connect login (authorization code with
|
|
4
|
+
* PKCE). The issuer, audience and claims come from `web.json`. Either way the identity must be on `web.allow`.
|
|
5
5
|
*/
|
|
6
6
|
import { createHash, randomBytes } from "node:crypto";
|
|
7
7
|
import { readFile, rename, writeFile } from "node:fs/promises";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
10
|
+
import { Keys } from "./keys.js";
|
|
10
11
|
const SESSION_DAYS = 7;
|
|
11
12
|
const PENDING_MS = 10 * 60_000;
|
|
12
13
|
/** A claim by dotted path, as a string, or `undefined`. */
|
|
@@ -42,6 +43,7 @@ export class WebAuth {
|
|
|
42
43
|
writing = Promise.resolve();
|
|
43
44
|
pending = new Map();
|
|
44
45
|
env;
|
|
46
|
+
keys;
|
|
45
47
|
constructor(config, home, env) {
|
|
46
48
|
if (config.access === undefined && config.oidc === undefined) {
|
|
47
49
|
throw new Error("web needs a login: set web.access (an identity-aware proxy) or web.oidc in web.json");
|
|
@@ -52,6 +54,7 @@ export class WebAuth {
|
|
|
52
54
|
this.config = config;
|
|
53
55
|
this.env = env;
|
|
54
56
|
this.sessionsPath = join(home, "web-sessions.json");
|
|
57
|
+
this.keys = new Keys(home);
|
|
55
58
|
this.accessKeys =
|
|
56
59
|
config.access === undefined
|
|
57
60
|
? undefined
|
|
@@ -66,11 +69,16 @@ export class WebAuth {
|
|
|
66
69
|
: undefined;
|
|
67
70
|
}
|
|
68
71
|
/**
|
|
69
|
-
* The allowed identities of a request: from
|
|
70
|
-
* else from the session token (the session cookie's value). `undefined` means not signed in; `false`, signed in but
|
|
72
|
+
* The allowed identities of a request: from an agent's bearer key when it sends one, else the proxy's JWT when it
|
|
73
|
+
* sends one (a bad one of either is refused, never ignored), else from the session token (the session cookie's value). `undefined` means not signed in; `false`, signed in but
|
|
71
74
|
* not allowed.
|
|
72
75
|
*/
|
|
73
|
-
async identify(proxyJwt, token) {
|
|
76
|
+
async identify(proxyJwt, token, bearer) {
|
|
77
|
+
// A bearer decides alone: one that doesn't verify is refused, never passed over for a cookie or a proxy's header.
|
|
78
|
+
if (bearer !== undefined) {
|
|
79
|
+
const name = await this.keys.verify(bearer);
|
|
80
|
+
return name === undefined ? false : (this.allowed([`key:${name}`]) ?? false);
|
|
81
|
+
}
|
|
74
82
|
if (proxyJwt !== undefined) {
|
|
75
83
|
const access = this.config.access;
|
|
76
84
|
if (access === undefined || this.accessKeys === undefined) {
|