cursedbelt-server 4.26.1 → 4.28.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/dist/server/activity/d1.d.ts +70 -0
- package/dist/server/activity/d1.js +68 -0
- package/dist/server/activity/migrations.d.ts +1 -3
- package/dist/server/activity/migrations.js +7 -32
- package/dist/server/activity/query.d.ts +123 -0
- package/dist/server/activity/query.js +171 -0
- package/dist/server/activity/schema.d.ts +13 -0
- package/dist/server/activity/schema.js +43 -0
- package/dist/server/activity/shareSource.d.ts +1 -1
- package/dist/server/activity/shareSource.js +1 -1
- package/dist/server/activity/store.d.ts +4 -62
- package/dist/server/activity/store.js +14 -145
- package/dist/server/engagement/d1.d.ts +67 -0
- package/dist/server/engagement/d1.js +81 -0
- package/dist/server/engagement/manifestPlaces.d.ts +9 -0
- package/dist/server/engagement/manifestPlaces.js +17 -0
- package/dist/server/engagement/places.js +2 -18
- package/dist/server/notifications/d1.d.ts +80 -0
- package/dist/server/notifications/d1.js +226 -0
- package/dist/server/notifications/service.d.ts +2 -20
- package/dist/server/notifications/types.d.ts +27 -0
- package/dist/server/notifications/types.js +1 -0
- package/dist/server/sharing/contract.d.ts +94 -0
- package/dist/server/sharing/contract.js +3 -0
- package/dist/server/sharing/d1.d.ts +77 -0
- package/dist/server/sharing/d1.js +309 -0
- package/dist/server/sharing/index.d.ts +1 -0
- package/dist/server/sharing/migrations.d.ts +1 -4
- package/dist/server/sharing/migrations.js +7 -72
- package/dist/server/sharing/router.d.ts +8 -4
- package/dist/server/sharing/router.js +7 -5
- package/dist/server/sharing/schema.d.ts +19 -0
- package/dist/server/sharing/schema.js +88 -0
- package/dist/server/sharing/sql.js +1 -1
- package/dist/server/sharing/statements.d.ts +116 -0
- package/dist/server/sharing/statements.js +139 -0
- package/dist/server/sharing/store.d.ts +3 -28
- package/dist/server/sharing/store.js +32 -105
- package/docs/activity.md +7 -0
- package/docs/engagement.md +6 -0
- package/docs/notifications.md +9 -0
- package/package.json +25 -1
- package/src/server/activity/d1.spec.ts +169 -0
- package/src/server/activity/d1.ts +172 -0
- package/src/server/activity/migrations.ts +6 -40
- package/src/server/activity/query.ts +283 -0
- package/src/server/activity/schema.ts +45 -0
- package/src/server/activity/shareSource.ts +2 -2
- package/src/server/activity/store.ts +45 -257
- package/src/server/engagement/d1.spec.ts +102 -0
- package/src/server/engagement/d1.ts +141 -0
- package/src/server/engagement/manifestPlaces.ts +24 -0
- package/src/server/engagement/places.ts +2 -16
- package/src/server/notifications/d1.spec.ts +179 -0
- package/src/server/notifications/d1.ts +334 -0
- package/src/server/notifications/service.ts +7 -23
- package/src/server/notifications/types.ts +29 -0
- package/src/server/sharing/contract.ts +109 -0
- package/src/server/sharing/d1.spec.ts +242 -0
- package/src/server/sharing/d1.ts +430 -0
- package/src/server/sharing/index.ts +3 -0
- package/src/server/sharing/migrations.ts +12 -85
- package/src/server/sharing/router.ts +21 -11
- package/src/server/sharing/schema.ts +90 -0
- package/src/server/sharing/sql.ts +1 -1
- package/src/server/sharing/statements.ts +213 -0
- package/src/server/sharing/store.ts +46 -221
- package/src/server/telemetryIsWorkerSafe.spec.ts +64 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The D1 share store is the sync store, asynchronously — the SAME results for the same calls,
|
|
3
|
+
* and the SAME rows in the SAME four tables, so a database moved from the Mac to D1 keeps
|
|
4
|
+
* meaning what it meant (task 393, `apps/family`).
|
|
5
|
+
*
|
|
6
|
+
* The parity scenario runs one script of calls through both stores, each over its own fresh
|
|
7
|
+
* database with the same clock, and compares every call's result and then every table. It
|
|
8
|
+
* runs twice: over `createLocalD1` (the local driver) and over `createRemoteD1` on the
|
|
9
|
+
* divergence-simulating fake binding, which refuses `undefined`/`boolean` binds exactly as
|
|
10
|
+
* D1 does — a store that bound either would pass locally and fail here.
|
|
11
|
+
*/
|
|
12
|
+
import { Database } from "bun:sqlite";
|
|
13
|
+
import { describe, expect, test } from "bun:test";
|
|
14
|
+
import { Hono } from "hono";
|
|
15
|
+
import { createFakeD1Binding } from "../d1/fakeD1.js";
|
|
16
|
+
import { createLocalD1 } from "../d1/local.js";
|
|
17
|
+
import { createRemoteD1 } from "../d1/remote.js";
|
|
18
|
+
import type { D1LikeDatabase } from "../d1/types.js";
|
|
19
|
+
import { runMigrations } from "../migration/runner.js";
|
|
20
|
+
import { type AsyncShareStore, createD1ShareStore, group, SHARE_DDL, user } from "./d1.js";
|
|
21
|
+
import { SHARE_MIGRATIONS } from "./migrations.js";
|
|
22
|
+
import { mountShareRoutes } from "./router.js";
|
|
23
|
+
import { createShareStore, type ShareStore } from "./store.js";
|
|
24
|
+
|
|
25
|
+
const APP = "family";
|
|
26
|
+
const OWNER = "owner@example.com";
|
|
27
|
+
|
|
28
|
+
/** A monotonic clock, one second per call, identical for both stores. */
|
|
29
|
+
const clock = () => {
|
|
30
|
+
let ticks = 0;
|
|
31
|
+
return () => new Date(Date.UTC(2026, 8, 24, 0, 0, ticks++)).toISOString();
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const d1Database = (): Database => {
|
|
35
|
+
const sqlite = new Database(":memory:");
|
|
36
|
+
sqlite.exec("PRAGMA foreign_keys = ON"); // D1 enforces foreign keys; so does this.
|
|
37
|
+
for (const statement of SHARE_DDL) sqlite.run(statement);
|
|
38
|
+
return sqlite;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const DRIVERS: Record<string, (sqlite: Database) => D1LikeDatabase> = {
|
|
42
|
+
local: (sqlite) => createLocalD1(sqlite),
|
|
43
|
+
"remote (fake D1 binding)": (sqlite) => createRemoteD1(createFakeD1Binding(sqlite)),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const syncStore = (): { sqlite: Database; store: ShareStore } => {
|
|
47
|
+
const sqlite = new Database(":memory:");
|
|
48
|
+
sqlite.exec("PRAGMA foreign_keys = ON");
|
|
49
|
+
return { sqlite, store: createShareStore({ db: sqlite, app: APP, now: clock() }) };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const asyncStore = (driver: (sqlite: Database) => D1LikeDatabase) => {
|
|
53
|
+
const sqlite = d1Database();
|
|
54
|
+
return { sqlite, store: createD1ShareStore({ db: driver(sqlite), app: APP, now: clock() }) };
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const dump = (db: Database) => ({
|
|
58
|
+
grants: db.query("SELECT * FROM share_grants ORDER BY grant_id").all(),
|
|
59
|
+
groups: db.query("SELECT * FROM share_groups ORDER BY group_id").all(),
|
|
60
|
+
members: db.query("SELECT * FROM share_group_members ORDER BY group_id, subject_id").all(),
|
|
61
|
+
events: db.query("SELECT * FROM share_events ORDER BY event_id").all(),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* One script, both stores. Grants are made in an order chosen so the sync store's multi-row
|
|
66
|
+
* revokes read their grants in grant order, which is the D1 store's DEFINED order — the sync
|
|
67
|
+
* store's is whatever index its SELECT picks (for `revokeSubject`, kind then resource id).
|
|
68
|
+
* That is the one documented difference; see the header of `./d1.ts`.
|
|
69
|
+
*/
|
|
70
|
+
const SCENARIO: ReadonlyArray<(s: ShareStore | AsyncShareStore) => unknown> = [
|
|
71
|
+
(s) => s.createGroup({ id: " cousins ", name: "The cousins", createdBy: OWNER, note: "both sides" }),
|
|
72
|
+
(s) => s.addGroupMember("cousins", " Cousin@Example.com ", OWNER),
|
|
73
|
+
(s) => s.addGroupMember("cousins", "aunt@example.com", OWNER),
|
|
74
|
+
(s) => s.grant({ kind: "person", id: "p1", subject: group("cousins"), level: "read", grantedBy: OWNER }),
|
|
75
|
+
(s) => s.grant({ kind: "person", id: "p1", subject: user(" Aunt@Example.com "), level: "read", grantedBy: OWNER, note: "she asked" }),
|
|
76
|
+
(s) => s.grant({ kind: "person", id: "p1", subject: user("aunt@example.com"), level: "write", grantedBy: OWNER }),
|
|
77
|
+
(s) => s.grant({ kind: "person", id: "p1", subject: user("aunt@example.com"), level: "write", grantedBy: OWNER }),
|
|
78
|
+
(s) => s.grant({ kind: "person", id: "p2", subject: user("uncle@example.com"), level: "read", grantedBy: OWNER }),
|
|
79
|
+
(s) => s.grant({ kind: "budget", id: "b1", subject: user("aunt@example.com"), level: "write", grantedBy: OWNER }),
|
|
80
|
+
(s) => s.grant({ kind: "person", id: "p3", subject: user("aunt@example.com"), level: "read", grantedBy: OWNER }),
|
|
81
|
+
(s) => s.accessFor({ kind: "person", id: "p1" }, { subjectId: "AUNT@example.com" }),
|
|
82
|
+
(s) => s.accessFor({ kind: "person", id: "p1" }, { subjectId: "cousin@example.com" }),
|
|
83
|
+
(s) => s.accessFor({ kind: "person", id: "p2" }, { subjectId: "cousin@example.com" }),
|
|
84
|
+
(s) => s.accessFor({ kind: "person", id: "p1" }, { subjectId: "" }),
|
|
85
|
+
(s) => s.resourceIdsFor("person", { subjectId: "aunt@example.com" }),
|
|
86
|
+
(s) => s.resourceIdsFor("person", { subjectId: "aunt@example.com" }, "write"),
|
|
87
|
+
(s) => s.resourceIdsFor("person", { subjectId: "cousin@example.com" }),
|
|
88
|
+
(s) => s.resourceIdsFor("person", { subjectId: " " }),
|
|
89
|
+
(s) => s.listForResource({ kind: "person", id: "p1" }),
|
|
90
|
+
(s) => s.listForSubject(user("AUNT@example.com")),
|
|
91
|
+
(s) => s.listGroups(),
|
|
92
|
+
(s) => s.listGroupMembers(" cousins "),
|
|
93
|
+
(s) => s.groupIdsFor("cousin@example.com"),
|
|
94
|
+
(s) => s.groupIdsFor(""),
|
|
95
|
+
(s) => s.revoke({ kind: "person", id: "p2", subject: user("uncle@example.com"), revokedBy: OWNER, note: "moved" }),
|
|
96
|
+
(s) => s.revoke({ kind: "person", id: "p2", subject: user("uncle@example.com"), revokedBy: OWNER }),
|
|
97
|
+
(s) => s.revokeResource({ kind: "person", id: "p1" }, OWNER),
|
|
98
|
+
(s) => s.revokeResource({ kind: "person", id: "nothing" }, OWNER),
|
|
99
|
+
(s) => s.grant({ kind: "person", id: "p4", subject: group("cousins"), level: "write", grantedBy: OWNER }),
|
|
100
|
+
(s) => s.renameGroup("cousins", "Cousins, all of them", OWNER),
|
|
101
|
+
(s) => s.removeGroupMember("cousins", "aunt@example.com", OWNER),
|
|
102
|
+
(s) => s.revokeSubject(user("aunt@example.com"), OWNER),
|
|
103
|
+
(s) => s.revokeSubject(user("nobody@example.com"), OWNER),
|
|
104
|
+
(s) => s.deleteGroup("cousins", OWNER),
|
|
105
|
+
(s) => s.events(),
|
|
106
|
+
(s) => s.events({ kind: "person", id: "p1", limit: 3 }),
|
|
107
|
+
(s) => s.events({ subject: user(" AUNT@example.com ") }),
|
|
108
|
+
(s) => s.events({ limit: 0 }),
|
|
109
|
+
(s) => s.sharedSql({ kind: "person", subjectId: "o'brien@example.com", column: "p.id", atLeast: "write" }),
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
describe("cursedbelt-server/sharing/d1", () => {
|
|
113
|
+
for (const [name, driver] of Object.entries(DRIVERS)) {
|
|
114
|
+
test(`🔴 parity over the ${name} driver: every result and every row match the sync store`, async () => {
|
|
115
|
+
const sync = syncStore();
|
|
116
|
+
const d1 = asyncStore(driver);
|
|
117
|
+
for (const [index, step] of SCENARIO.entries()) {
|
|
118
|
+
const expected = step(sync.store);
|
|
119
|
+
const actual = await step(d1.store);
|
|
120
|
+
expect({ step: index, result: actual }).toEqual({ step: index, result: expected });
|
|
121
|
+
}
|
|
122
|
+
expect(dump(d1.sqlite)).toEqual(dump(sync.sqlite));
|
|
123
|
+
// Not vacuous: the trail holds every kind of event the scenario exercised.
|
|
124
|
+
const actions = new Set(dump(d1.sqlite).events.map((e) => (e as { action: string }).action));
|
|
125
|
+
expect([...actions].sort()).toEqual(
|
|
126
|
+
["grant", "group.create", "group.delete", "group.join", "group.leave", "group.rename", "regrade", "revoke"].sort(),
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
test("exposes exactly the sync store's methods, and `sharedSql` stays synchronous", () => {
|
|
132
|
+
const sync = syncStore().store;
|
|
133
|
+
const d1 = asyncStore(DRIVERS.local as (s: Database) => D1LikeDatabase).store;
|
|
134
|
+
expect(Object.keys(d1).sort()).toEqual(Object.keys(sync).sort());
|
|
135
|
+
const input = { kind: "person", subjectId: "aunt@example.com", column: "id" };
|
|
136
|
+
expect(typeof d1.sharedSql(input)).toBe("string");
|
|
137
|
+
expect(d1.sharedSql(input)).toBe(sync.sharedSql(input));
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("SHARE_DDL builds exactly the tables and indexes the sync migration does", () => {
|
|
141
|
+
const shape = (db: Database) =>
|
|
142
|
+
db
|
|
143
|
+
.query("SELECT type, name, sql FROM sqlite_master WHERE name LIKE '%share%' AND sql IS NOT NULL ORDER BY name")
|
|
144
|
+
.all();
|
|
145
|
+
const migrated = new Database(":memory:");
|
|
146
|
+
runMigrations(migrated, SHARE_MIGRATIONS);
|
|
147
|
+
expect(shape(d1Database())).toEqual(shape(migrated));
|
|
148
|
+
expect(shape(migrated).length).toBe(9);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("🔴 runs no DDL: on a database without the tables it creates nothing and fails loudly", async () => {
|
|
152
|
+
const sqlite = new Database(":memory:");
|
|
153
|
+
const shares = createD1ShareStore({ db: createLocalD1(sqlite), app: APP });
|
|
154
|
+
expect(sqlite.query("SELECT COUNT(*) AS n FROM sqlite_master").get()).toEqual({ n: 0 });
|
|
155
|
+
await expect(shares.listGroups()).rejects.toThrow(/no such table/);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("🔴 a mutation is atomic: when the grant write fails, its audit row is not left behind", async () => {
|
|
159
|
+
const { sqlite, store } = asyncStore(DRIVERS.local as (s: Database) => D1LikeDatabase);
|
|
160
|
+
// The event insert runs FIRST and accepts any level; the grant's CHECK refuses this one.
|
|
161
|
+
// Only a real batch leaves neither row — a loop of awaits would leave the event.
|
|
162
|
+
await expect(
|
|
163
|
+
store.grant({
|
|
164
|
+
kind: "person",
|
|
165
|
+
id: "p1",
|
|
166
|
+
subject: user("aunt@example.com"),
|
|
167
|
+
level: "admin" as unknown as "read",
|
|
168
|
+
grantedBy: OWNER,
|
|
169
|
+
}),
|
|
170
|
+
).rejects.toThrow(/CHECK constraint/);
|
|
171
|
+
expect(dump(sqlite)).toEqual({ grants: [], groups: [], members: [], events: [] });
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("validation rejects, and writes nothing", async () => {
|
|
175
|
+
const { sqlite, store } = asyncStore(DRIVERS.local as (s: Database) => D1LikeDatabase);
|
|
176
|
+
await expect(
|
|
177
|
+
store.grant({ kind: "person", id: "p1", subject: user(" "), level: "read", grantedBy: OWNER }),
|
|
178
|
+
).rejects.toThrow(/needs a subject/);
|
|
179
|
+
await expect(
|
|
180
|
+
store.grant({ kind: "person", id: "", subject: user("a@example.com"), level: "read", grantedBy: OWNER }),
|
|
181
|
+
).rejects.toThrow(/needs a resource id/);
|
|
182
|
+
await expect(store.createGroup({ id: " ", name: "x", createdBy: OWNER })).rejects.toThrow(/needs an id/);
|
|
183
|
+
await expect(store.addGroupMember("g", " ", OWNER)).rejects.toThrow(/needs an address/);
|
|
184
|
+
expect(dump(sqlite)).toEqual({ grants: [], groups: [], members: [], events: [] });
|
|
185
|
+
expect(() => createD1ShareStore({ db: createLocalD1(sqlite), app: " " })).toThrow(/needs an `app`/);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("the hook hears every committed mutation, and neither a throw nor a rejection loses the write", async () => {
|
|
189
|
+
const sqlite = d1Database();
|
|
190
|
+
const heard: string[] = [];
|
|
191
|
+
let mode: "ok" | "throw" | "reject" = "ok";
|
|
192
|
+
const store = createD1ShareStore({
|
|
193
|
+
db: createLocalD1(sqlite),
|
|
194
|
+
app: APP,
|
|
195
|
+
onEvent: (event) => {
|
|
196
|
+
heard.push(`${event.action}:${event.subject.id}`);
|
|
197
|
+
if (mode === "throw") throw new Error("the mail server is down");
|
|
198
|
+
if (mode === "reject") return Promise.reject(new Error("the mail server is still down"));
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
await store.grant({ kind: "person", id: "p1", subject: user("a@example.com"), level: "read", grantedBy: OWNER });
|
|
202
|
+
mode = "throw";
|
|
203
|
+
await store.grant({ kind: "person", id: "p1", subject: user("b@example.com"), level: "write", grantedBy: OWNER });
|
|
204
|
+
mode = "reject";
|
|
205
|
+
expect(await store.revokeResource({ kind: "person", id: "p1" }, OWNER)).toBe(2);
|
|
206
|
+
expect(heard).toEqual(["grant:a@example.com", "grant:b@example.com", "revoke:a@example.com", "revoke:b@example.com"]);
|
|
207
|
+
expect(await store.events({ kind: "person", id: "p1" })).toHaveLength(4);
|
|
208
|
+
expect(await store.listForResource({ kind: "person", id: "p1" })).toEqual([]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("mountShareRoutes serves the D1 store — read, grant, revoke by group path", async () => {
|
|
212
|
+
const { store } = asyncStore(DRIVERS.local as (s: Database) => D1LikeDatabase);
|
|
213
|
+
const app = new Hono();
|
|
214
|
+
mountShareRoutes(app, {
|
|
215
|
+
base: "/api/people/:id",
|
|
216
|
+
kind: "person",
|
|
217
|
+
shares: store,
|
|
218
|
+
viewerOf: () => ({ email: OWNER }),
|
|
219
|
+
mustOwn: () => null,
|
|
220
|
+
extra: async (_c, id) => ({ labels: { [id]: "Grandma" } }),
|
|
221
|
+
});
|
|
222
|
+
const post = await app.request("/api/people/p1/shares", {
|
|
223
|
+
method: "POST",
|
|
224
|
+
headers: { "content-type": "application/json" },
|
|
225
|
+
body: JSON.stringify({ subject: { type: "group", id: "cousins" }, level: "write" }),
|
|
226
|
+
});
|
|
227
|
+
expect(post.status).toBe(201);
|
|
228
|
+
expect(await post.json()).toMatchObject({ subject: { type: "group", id: "cousins" }, level: "write" });
|
|
229
|
+
const read = (await (await app.request("/api/people/p1/shares")).json()) as {
|
|
230
|
+
grants: unknown[];
|
|
231
|
+
events: unknown[];
|
|
232
|
+
labels: Record<string, string>;
|
|
233
|
+
};
|
|
234
|
+
expect(read.grants).toHaveLength(1);
|
|
235
|
+
expect(read.events).toHaveLength(1);
|
|
236
|
+
expect(read.labels).toEqual({ p1: "Grandma" });
|
|
237
|
+
const del = await app.request("/api/people/p1/shares/group%3Acousins", { method: "DELETE" });
|
|
238
|
+
expect(await del.json()).toEqual({ ok: true });
|
|
239
|
+
expect(await store.listForResource({ kind: "person", id: "p1" })).toEqual([]);
|
|
240
|
+
expect((await store.events({ kind: "person", id: "p1" }))[0]?.action).toBe("revoke");
|
|
241
|
+
});
|
|
242
|
+
});
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursedbelt-server/sharing/d1` — the fleet's sharing model over D1, for an app a Worker serves.
|
|
3
|
+
*
|
|
4
|
+
* ── Why it exists ───────────────────────────────────────────────────────────
|
|
5
|
+
* `./store.ts` is synchronous `bun:sqlite`, and a Worker has neither. `apps/family`'s port to
|
|
6
|
+
* a Worker + D1 (task 393) needs the SAME grants, revokes, groups and audit trail, in the SAME
|
|
7
|
+
* four tables, so a database moved from the Mac to D1 keeps meaning what it meant. This is that
|
|
8
|
+
* store, beside the sync one — which stays exactly as it was, because `auth` and `station` are
|
|
9
|
+
* still Bun servers.
|
|
10
|
+
*
|
|
11
|
+
* ── What is the same, and what is not ─────────────────────────────────────
|
|
12
|
+
* Same methods, names, arguments, validation and results as `ShareStore`; every method that
|
|
13
|
+
* touches the database resolves instead of returning. `sharedSql` is still synchronous and
|
|
14
|
+
* emits the identical fragment, for an app to embed in its own D1 queries. The SQL is the
|
|
15
|
+
* sync store's own (`./statements.ts`), and `./d1.spec.ts` runs both stores through one
|
|
16
|
+
* scenario and compares every call's result and every table's rows.
|
|
17
|
+
*
|
|
18
|
+
* The four properties of `./store.ts`'s header hold, by different means:
|
|
19
|
+
* 1. **Every mutation writes its audit row in the same transaction.** D1 has no interactive
|
|
20
|
+
* transaction, so each mutation is ONE `db.batch([...])` — atomic on D1, a real
|
|
21
|
+
* transaction locally. Where the sync store reads the grant and then decides what to log,
|
|
22
|
+
* this store logs FROM the grant row inside that batch (`INSERT … SELECT … RETURNING`),
|
|
23
|
+
* so there is no window between the read and the write for another request to use.
|
|
24
|
+
* 2. **A revoked grant's history survives it** — the event insert runs before the delete.
|
|
25
|
+
* 3. **`revokeResource` is one call** — two statements, one batch, however many grants.
|
|
26
|
+
* 4. **The hook cannot break a write.** `onEvent` runs after the batch commits; a throw — or
|
|
27
|
+
* a rejected promise, which a Worker's hook may well return — is caught and warned.
|
|
28
|
+
*
|
|
29
|
+
* Differences a caller can observe, each deliberate:
|
|
30
|
+
* · A multi-grant revoke (`revokeResource`, `revokeSubject`, `deleteGroup`) writes its
|
|
31
|
+
* events oldest-grant-first (`ORDER BY grant_id`); the sync store writes them in whatever
|
|
32
|
+
* order its SELECT happened to return. Same events, a defined order.
|
|
33
|
+
* · `onEvent` may return a promise, and the store AWAITS it before resolving — a Worker that
|
|
34
|
+
* returned before the hook settled could have it cut off with the isolate.
|
|
35
|
+
* · Validation failures REJECT rather than throw synchronously (they are async methods).
|
|
36
|
+
* · 🔴 No DDL runs here, ever. The sync store migrates on construction; on D1 the schema is
|
|
37
|
+
* the app's `db/schema.sql`, built from {@link SHARE_DDL} (the exact statements the sync
|
|
38
|
+
* migration runs), and applied by `wrangler d1 migrations`. A request-time `CREATE TABLE`
|
|
39
|
+
* is a write per invocation against the 1,000-query budget, for nothing.
|
|
40
|
+
*
|
|
41
|
+
* 🔴 Worker-safe: nothing reachable from here at runtime imports `bun:*`
|
|
42
|
+
* (`../telemetryIsWorkerSafe.spec.ts` walks it). Do not import `./store.js` or
|
|
43
|
+
* `./migrations.js` here — `./schema.js`, `./statements.js` and `./contract.js` are the
|
|
44
|
+
* driver-free halves they were split into.
|
|
45
|
+
*
|
|
46
|
+
* ```ts
|
|
47
|
+
* import { createD1ShareStore } from "cursedbelt-server/sharing/d1";
|
|
48
|
+
*
|
|
49
|
+
* const shares = createD1ShareStore({ db: createRemoteD1(env.DB), app: "family", onEvent });
|
|
50
|
+
* await shares.grant({ kind: "person", id, subject: user(email), level: "read", grantedBy });
|
|
51
|
+
* const where = shares.sharedSql({ kind: "person", subjectId: viewer.email, column: "p.id" });
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
import {
|
|
55
|
+
levelsMeeting,
|
|
56
|
+
normalizeSubjectId,
|
|
57
|
+
type ShareAccess,
|
|
58
|
+
type ShareEvent,
|
|
59
|
+
type ShareGrant,
|
|
60
|
+
type ShareGroup,
|
|
61
|
+
type ShareGroupMember,
|
|
62
|
+
type ShareLevel,
|
|
63
|
+
type ShareResource,
|
|
64
|
+
type ShareSubject,
|
|
65
|
+
type ShareViewer,
|
|
66
|
+
strongerAccess,
|
|
67
|
+
} from "cursedbelt-core/sharing/model";
|
|
68
|
+
import type { D1LikeBindable, D1LikeDatabase, D1LikeStatement } from "../d1/types.js";
|
|
69
|
+
import type { AsyncShareStore, GrantInput, RevokeInput, ShareEventQuery, ShareTarget } from "./contract.js";
|
|
70
|
+
import { sharedResourceSql } from "./sql.js";
|
|
71
|
+
import {
|
|
72
|
+
type EventRow,
|
|
73
|
+
eventLimit,
|
|
74
|
+
eventsWhere,
|
|
75
|
+
type GrantRow,
|
|
76
|
+
type GroupMemberRow,
|
|
77
|
+
type GroupRow,
|
|
78
|
+
SHARE_SQL,
|
|
79
|
+
toEvent,
|
|
80
|
+
toGrant,
|
|
81
|
+
} from "./statements.js";
|
|
82
|
+
|
|
83
|
+
export { group, user } from "./contract.js";
|
|
84
|
+
export type {
|
|
85
|
+
AsyncShareStore,
|
|
86
|
+
Awaitable,
|
|
87
|
+
AwaitableShareStore,
|
|
88
|
+
GrantInput,
|
|
89
|
+
RevokeInput,
|
|
90
|
+
SharedSqlInput,
|
|
91
|
+
ShareEventQuery,
|
|
92
|
+
ShareTarget,
|
|
93
|
+
} from "./contract.js";
|
|
94
|
+
export {
|
|
95
|
+
SHARE_DDL,
|
|
96
|
+
SHARE_EVENTS_TABLE,
|
|
97
|
+
SHARE_GRANTS_TABLE,
|
|
98
|
+
SHARE_GROUPS_TABLE,
|
|
99
|
+
SHARE_GROUP_MEMBERS_TABLE,
|
|
100
|
+
} from "./schema.js";
|
|
101
|
+
|
|
102
|
+
export interface D1ShareStoreOptions {
|
|
103
|
+
/** The app's D1 handle — `createRemoteD1(env.DB)` on a Worker, `createLocalD1(sqlite)` in a test. */
|
|
104
|
+
db: D1LikeDatabase;
|
|
105
|
+
/** The satellite this store speaks for. Fixed at construction, as in the sync store. */
|
|
106
|
+
app: string;
|
|
107
|
+
/**
|
|
108
|
+
* Notified after every committed mutation, as in the sync store. May return a promise; the
|
|
109
|
+
* store awaits it, and neither a throw nor a rejection can undo the write.
|
|
110
|
+
*/
|
|
111
|
+
onEvent?: (event: ShareEvent) => void | Promise<void>;
|
|
112
|
+
/** Injectable clock, for tests that assert ordering. ISO-8601 strings. */
|
|
113
|
+
now?: () => string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The share store over D1. Runs no DDL — see the header; the tables come from
|
|
118
|
+
* {@link SHARE_DDL} in the app's schema.
|
|
119
|
+
*/
|
|
120
|
+
export function createD1ShareStore(options: D1ShareStoreOptions): AsyncShareStore {
|
|
121
|
+
const { db, app } = options;
|
|
122
|
+
const clock = options.now ?? (() => new Date().toISOString());
|
|
123
|
+
if (!app.trim()) throw new Error("[cursedbelt-core/sharing] a share store needs an `app`");
|
|
124
|
+
|
|
125
|
+
const emit = async (events: readonly ShareEvent[]): Promise<void> => {
|
|
126
|
+
const hook = options.onEvent;
|
|
127
|
+
if (!hook) return;
|
|
128
|
+
for (const event of events) {
|
|
129
|
+
try {
|
|
130
|
+
await hook(event);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
console.warn(`[cursedbelt-core/sharing] a share-event listener threw: ${String(error)}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const stmt = (sql: string, ...values: D1LikeBindable[]): D1LikeStatement => db.prepare(sql).bind(...values);
|
|
138
|
+
const rows = async <T>(sql: string, ...values: D1LikeBindable[]): Promise<T[]> =>
|
|
139
|
+
(await stmt(sql, ...values).all<T>()).results;
|
|
140
|
+
|
|
141
|
+
/** Run a mutation's statements as ONE atomic batch, and read back the events it wrote. */
|
|
142
|
+
const commit = async (statements: D1LikeStatement[]): Promise<ShareEvent[]> => {
|
|
143
|
+
const results = await db.batch<EventRow>(statements);
|
|
144
|
+
const events = results
|
|
145
|
+
.flatMap((result) => result.results)
|
|
146
|
+
.filter((row): row is EventRow => typeof (row as Partial<EventRow>).event_id === "number")
|
|
147
|
+
.sort((a, b) => a.event_id - b.event_id)
|
|
148
|
+
.map(toEvent);
|
|
149
|
+
await emit(events);
|
|
150
|
+
return events;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/** A plain event insert, for the mutations whose audit row does not depend on a read. */
|
|
154
|
+
const eventInsert = (input: {
|
|
155
|
+
action: ShareEvent["action"];
|
|
156
|
+
resource: ShareResource | null;
|
|
157
|
+
subject: ShareSubject;
|
|
158
|
+
level: ShareLevel | null;
|
|
159
|
+
actor: string;
|
|
160
|
+
note?: string;
|
|
161
|
+
at: string;
|
|
162
|
+
}): D1LikeStatement =>
|
|
163
|
+
stmt(
|
|
164
|
+
`${SHARE_SQL.insertEvent} RETURNING *`,
|
|
165
|
+
input.at,
|
|
166
|
+
input.action,
|
|
167
|
+
input.resource?.app ?? null,
|
|
168
|
+
input.resource?.kind ?? null,
|
|
169
|
+
input.resource?.id ?? null,
|
|
170
|
+
input.subject.type,
|
|
171
|
+
input.subject.id,
|
|
172
|
+
input.level,
|
|
173
|
+
null,
|
|
174
|
+
input.actor,
|
|
175
|
+
input.note ?? "",
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const revokeEvents = (where: string, at: string, actor: string, note: string, ...keys: D1LikeBindable[]) =>
|
|
179
|
+
stmt(SHARE_SQL.insertRevokeEvents(where), at, actor, note, ...keys);
|
|
180
|
+
|
|
181
|
+
const normalized = (subject: ShareSubject): ShareSubject => ({
|
|
182
|
+
type: subject.type,
|
|
183
|
+
id: normalizeSubjectId(subject.type, subject.id),
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
app,
|
|
188
|
+
|
|
189
|
+
async grant(input: GrantInput): Promise<ShareGrant> {
|
|
190
|
+
const subject = normalized(input.subject);
|
|
191
|
+
if (!subject.id) throw new Error("[cursedbelt-core/sharing] a grant needs a subject");
|
|
192
|
+
if (!input.id) throw new Error("[cursedbelt-core/sharing] a grant needs a resource id");
|
|
193
|
+
const at = clock();
|
|
194
|
+
const note = input.note ?? "";
|
|
195
|
+
const key = [app, input.kind, input.id, subject.type, subject.id] as const;
|
|
196
|
+
await commit([
|
|
197
|
+
// The event FIRST: it reads the grant as it stood, to tell a grant from a regrade.
|
|
198
|
+
stmt(
|
|
199
|
+
SHARE_SQL.insertGrantEvent,
|
|
200
|
+
at,
|
|
201
|
+
input.level,
|
|
202
|
+
...key,
|
|
203
|
+
input.level,
|
|
204
|
+
input.level,
|
|
205
|
+
input.grantedBy,
|
|
206
|
+
note,
|
|
207
|
+
...key,
|
|
208
|
+
),
|
|
209
|
+
stmt(SHARE_SQL.upsertGrant, ...key, input.level, input.grantedBy, at, note),
|
|
210
|
+
]);
|
|
211
|
+
return {
|
|
212
|
+
resource: { app, kind: input.kind, id: input.id },
|
|
213
|
+
subject,
|
|
214
|
+
level: input.level,
|
|
215
|
+
grantedBy: input.grantedBy,
|
|
216
|
+
grantedAt: at,
|
|
217
|
+
note,
|
|
218
|
+
};
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
async revoke(input: RevokeInput): Promise<ShareLevel | null> {
|
|
222
|
+
const subject = normalized(input.subject);
|
|
223
|
+
const at = clock();
|
|
224
|
+
const key = [app, input.kind, input.id, subject.type, subject.id] as const;
|
|
225
|
+
const [event] = await commit([
|
|
226
|
+
revokeEvents(SHARE_SQL.oneGrant, at, input.revokedBy, input.note ?? "", ...key),
|
|
227
|
+
stmt(SHARE_SQL.deleteGrant, ...key),
|
|
228
|
+
]);
|
|
229
|
+
// No grant, no event — "revoked nothing" is not logged, exactly as in the sync store.
|
|
230
|
+
return event?.level ?? null;
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
async revokeResource(target: ShareTarget, revokedBy: string): Promise<number> {
|
|
234
|
+
const at = clock();
|
|
235
|
+
const key = [app, target.kind, target.id] as const;
|
|
236
|
+
const events = await commit([
|
|
237
|
+
revokeEvents(SHARE_SQL.resourceGrants, at, revokedBy, "the record was deleted", ...key),
|
|
238
|
+
stmt(SHARE_SQL.deleteResourceGrants, ...key),
|
|
239
|
+
]);
|
|
240
|
+
return events.length;
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
async revokeSubject(subject: ShareSubject, revokedBy: string): Promise<number> {
|
|
244
|
+
const normal = normalized(subject);
|
|
245
|
+
const at = clock();
|
|
246
|
+
const key = [app, normal.type, normal.id] as const;
|
|
247
|
+
const events = await commit([
|
|
248
|
+
revokeEvents(SHARE_SQL.subjectGrants, at, revokedBy, "every share with this subject was revoked", ...key),
|
|
249
|
+
stmt(SHARE_SQL.deleteSubjectGrants, ...key),
|
|
250
|
+
]);
|
|
251
|
+
return events.length;
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
async listForResource(target: ShareTarget): Promise<ShareGrant[]> {
|
|
255
|
+
return (await rows<GrantRow>(SHARE_SQL.listForResource, app, target.kind, target.id)).map(toGrant);
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
async listForSubject(subject: ShareSubject): Promise<ShareGrant[]> {
|
|
259
|
+
const normal = normalized(subject);
|
|
260
|
+
return (await rows<GrantRow>(SHARE_SQL.listForSubject, app, normal.type, normal.id)).map(toGrant);
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
async accessFor(target: ShareTarget, viewer: ShareViewer): Promise<ShareAccess> {
|
|
264
|
+
const subjectId = normalizeSubjectId("user", viewer.subjectId);
|
|
265
|
+
if (!subjectId) return "none";
|
|
266
|
+
const found = await rows<{ level: ShareLevel }>(
|
|
267
|
+
SHARE_SQL.accessFor,
|
|
268
|
+
app,
|
|
269
|
+
target.kind,
|
|
270
|
+
target.id,
|
|
271
|
+
subjectId,
|
|
272
|
+
subjectId,
|
|
273
|
+
);
|
|
274
|
+
let access: ShareAccess = "none";
|
|
275
|
+
for (const row of found) access = strongerAccess(access, row.level);
|
|
276
|
+
return access;
|
|
277
|
+
},
|
|
278
|
+
|
|
279
|
+
async resourceIdsFor(kind: string, viewer: ShareViewer, atLeast: ShareLevel = "read"): Promise<string[]> {
|
|
280
|
+
const subjectId = normalizeSubjectId("user", viewer.subjectId);
|
|
281
|
+
if (!subjectId) return [];
|
|
282
|
+
const levels = levelsMeeting(atLeast);
|
|
283
|
+
const found = await rows<{ resource_id: string }>(
|
|
284
|
+
SHARE_SQL.resourceIdsFor(levels.length),
|
|
285
|
+
app,
|
|
286
|
+
kind,
|
|
287
|
+
...levels,
|
|
288
|
+
subjectId,
|
|
289
|
+
subjectId,
|
|
290
|
+
);
|
|
291
|
+
return found.map((row) => row.resource_id);
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
sharedSql(input: { kind: string; subjectId: string; column: string; atLeast?: ShareLevel }): string {
|
|
295
|
+
return sharedResourceSql({ app, ...input });
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
// ── Groups ───────────────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
async createGroup(input: { id: string; name: string; createdBy: string; note?: string }): Promise<ShareGroup> {
|
|
301
|
+
const id = normalizeSubjectId("group", input.id);
|
|
302
|
+
if (!id) throw new Error("[cursedbelt-core/sharing] a group needs an id");
|
|
303
|
+
const at = clock();
|
|
304
|
+
await commit([
|
|
305
|
+
stmt(SHARE_SQL.upsertGroup, id, input.name, input.createdBy, at, input.note ?? ""),
|
|
306
|
+
eventInsert({
|
|
307
|
+
action: "group.create",
|
|
308
|
+
resource: null,
|
|
309
|
+
subject: { type: "group", id },
|
|
310
|
+
level: null,
|
|
311
|
+
actor: input.createdBy,
|
|
312
|
+
note: input.name,
|
|
313
|
+
at,
|
|
314
|
+
}),
|
|
315
|
+
]);
|
|
316
|
+
return { id, name: input.name, createdBy: input.createdBy, createdAt: at, note: input.note ?? "" };
|
|
317
|
+
},
|
|
318
|
+
|
|
319
|
+
async renameGroup(id: string, name: string, actor: string): Promise<void> {
|
|
320
|
+
const groupId = normalizeSubjectId("group", id);
|
|
321
|
+
const at = clock();
|
|
322
|
+
await commit([
|
|
323
|
+
stmt(SHARE_SQL.renameGroup, name, groupId),
|
|
324
|
+
eventInsert({
|
|
325
|
+
action: "group.rename",
|
|
326
|
+
resource: null,
|
|
327
|
+
subject: { type: "group", id: groupId },
|
|
328
|
+
level: null,
|
|
329
|
+
actor,
|
|
330
|
+
note: name,
|
|
331
|
+
at,
|
|
332
|
+
}),
|
|
333
|
+
]);
|
|
334
|
+
},
|
|
335
|
+
|
|
336
|
+
/** Deletes the group AND every grant made to it, each with its own revoke event — see
|
|
337
|
+
* the sync store's `deleteGroup` for why that is not optional. */
|
|
338
|
+
async deleteGroup(id: string, actor: string): Promise<void> {
|
|
339
|
+
const groupId = normalizeSubjectId("group", id);
|
|
340
|
+
const at = clock();
|
|
341
|
+
await commit([
|
|
342
|
+
revokeEvents(SHARE_SQL.groupGrants, at, actor, "the group was deleted", app, groupId),
|
|
343
|
+
stmt(SHARE_SQL.deleteGroupGrants, app, groupId),
|
|
344
|
+
stmt(SHARE_SQL.deleteGroup, groupId),
|
|
345
|
+
eventInsert({
|
|
346
|
+
action: "group.delete",
|
|
347
|
+
resource: null,
|
|
348
|
+
subject: { type: "group", id: groupId },
|
|
349
|
+
level: null,
|
|
350
|
+
actor,
|
|
351
|
+
note: "",
|
|
352
|
+
at,
|
|
353
|
+
}),
|
|
354
|
+
]);
|
|
355
|
+
},
|
|
356
|
+
|
|
357
|
+
async listGroups(): Promise<ShareGroup[]> {
|
|
358
|
+
return (await rows<GroupRow>(SHARE_SQL.listGroups)).map((row) => ({
|
|
359
|
+
id: row.group_id,
|
|
360
|
+
name: row.name,
|
|
361
|
+
createdBy: row.created_by,
|
|
362
|
+
createdAt: row.created_at,
|
|
363
|
+
note: row.note,
|
|
364
|
+
}));
|
|
365
|
+
},
|
|
366
|
+
|
|
367
|
+
async addGroupMember(groupId: string, subjectId: string, actor: string): Promise<void> {
|
|
368
|
+
const id = normalizeSubjectId("group", groupId);
|
|
369
|
+
const member = normalizeSubjectId("user", subjectId);
|
|
370
|
+
if (!member) throw new Error("[cursedbelt-core/sharing] a group member needs an address");
|
|
371
|
+
const at = clock();
|
|
372
|
+
await commit([
|
|
373
|
+
stmt(SHARE_SQL.insertMember, id, member, actor, at),
|
|
374
|
+
eventInsert({
|
|
375
|
+
action: "group.join",
|
|
376
|
+
resource: null,
|
|
377
|
+
subject: { type: "group", id },
|
|
378
|
+
level: null,
|
|
379
|
+
actor,
|
|
380
|
+
note: member,
|
|
381
|
+
at,
|
|
382
|
+
}),
|
|
383
|
+
]);
|
|
384
|
+
},
|
|
385
|
+
|
|
386
|
+
async removeGroupMember(groupId: string, subjectId: string, actor: string): Promise<void> {
|
|
387
|
+
const id = normalizeSubjectId("group", groupId);
|
|
388
|
+
const member = normalizeSubjectId("user", subjectId);
|
|
389
|
+
const at = clock();
|
|
390
|
+
await commit([
|
|
391
|
+
stmt(SHARE_SQL.deleteMember, id, member),
|
|
392
|
+
eventInsert({
|
|
393
|
+
action: "group.leave",
|
|
394
|
+
resource: null,
|
|
395
|
+
subject: { type: "group", id },
|
|
396
|
+
level: null,
|
|
397
|
+
actor,
|
|
398
|
+
note: member,
|
|
399
|
+
at,
|
|
400
|
+
}),
|
|
401
|
+
]);
|
|
402
|
+
},
|
|
403
|
+
|
|
404
|
+
async listGroupMembers(groupId: string): Promise<ShareGroupMember[]> {
|
|
405
|
+
const found = await rows<GroupMemberRow>(SHARE_SQL.listMembers, normalizeSubjectId("group", groupId));
|
|
406
|
+
return found.map((row) => ({
|
|
407
|
+
groupId: row.group_id,
|
|
408
|
+
subjectId: row.subject_id,
|
|
409
|
+
addedBy: row.added_by,
|
|
410
|
+
addedAt: row.added_at,
|
|
411
|
+
}));
|
|
412
|
+
},
|
|
413
|
+
|
|
414
|
+
async groupIdsFor(subjectId: string): Promise<string[]> {
|
|
415
|
+
const member = normalizeSubjectId("user", subjectId);
|
|
416
|
+
if (!member) return [];
|
|
417
|
+
return (await rows<{ group_id: string }>(SHARE_SQL.groupIdsFor, member)).map((row) => row.group_id);
|
|
418
|
+
},
|
|
419
|
+
|
|
420
|
+
// ── The audit trail ──────────────────────────────────────────────────────
|
|
421
|
+
|
|
422
|
+
async events(query: ShareEventQuery = {}): Promise<ShareEvent[]> {
|
|
423
|
+
const { where, params } = eventsWhere(app, {
|
|
424
|
+
...query,
|
|
425
|
+
subject: query.subject ? normalized(query.subject) : undefined,
|
|
426
|
+
});
|
|
427
|
+
return (await rows<EventRow>(SHARE_SQL.events(where), ...params, eventLimit(query.limit))).map(toEvent);
|
|
428
|
+
},
|
|
429
|
+
};
|
|
430
|
+
}
|
|
@@ -23,6 +23,9 @@ export type { ShareRouterOptions, ShareRouterViewer, ShareRoutesPayload } from "
|
|
|
23
23
|
export { sharedResourceSql, sqlColumn, sqlLiteral } from "./sql.js";
|
|
24
24
|
export type { SharedResourceSqlOptions } from "./sql.js";
|
|
25
25
|
export { createShareStore, group, user } from "./store.js";
|
|
26
|
+
// The D1 store itself is `cursedbelt-server/sharing/d1` — never re-exported here, so this
|
|
27
|
+
// barrel stays what it was. Its TYPES are here, because `mountShareRoutes` takes either store.
|
|
28
|
+
export type { AsyncShareStore, AwaitableShareStore } from "./contract.js";
|
|
26
29
|
export type {
|
|
27
30
|
GrantInput,
|
|
28
31
|
RevokeInput,
|