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,309 @@
|
|
|
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 { levelsMeeting, normalizeSubjectId, strongerAccess, } from "cursedbelt-core/sharing/model";
|
|
55
|
+
import { sharedResourceSql } from "./sql.js";
|
|
56
|
+
import { eventLimit, eventsWhere, SHARE_SQL, toEvent, toGrant, } from "./statements.js";
|
|
57
|
+
export { group, user } from "./contract.js";
|
|
58
|
+
export { SHARE_DDL, SHARE_EVENTS_TABLE, SHARE_GRANTS_TABLE, SHARE_GROUPS_TABLE, SHARE_GROUP_MEMBERS_TABLE, } from "./schema.js";
|
|
59
|
+
/**
|
|
60
|
+
* The share store over D1. Runs no DDL — see the header; the tables come from
|
|
61
|
+
* {@link SHARE_DDL} in the app's schema.
|
|
62
|
+
*/
|
|
63
|
+
export function createD1ShareStore(options) {
|
|
64
|
+
const { db, app } = options;
|
|
65
|
+
const clock = options.now ?? (() => new Date().toISOString());
|
|
66
|
+
if (!app.trim())
|
|
67
|
+
throw new Error("[cursedbelt-core/sharing] a share store needs an `app`");
|
|
68
|
+
const emit = async (events) => {
|
|
69
|
+
const hook = options.onEvent;
|
|
70
|
+
if (!hook)
|
|
71
|
+
return;
|
|
72
|
+
for (const event of events) {
|
|
73
|
+
try {
|
|
74
|
+
await hook(event);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
console.warn(`[cursedbelt-core/sharing] a share-event listener threw: ${String(error)}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
const stmt = (sql, ...values) => db.prepare(sql).bind(...values);
|
|
82
|
+
const rows = async (sql, ...values) => (await stmt(sql, ...values).all()).results;
|
|
83
|
+
/** Run a mutation's statements as ONE atomic batch, and read back the events it wrote. */
|
|
84
|
+
const commit = async (statements) => {
|
|
85
|
+
const results = await db.batch(statements);
|
|
86
|
+
const events = results
|
|
87
|
+
.flatMap((result) => result.results)
|
|
88
|
+
.filter((row) => typeof row.event_id === "number")
|
|
89
|
+
.sort((a, b) => a.event_id - b.event_id)
|
|
90
|
+
.map(toEvent);
|
|
91
|
+
await emit(events);
|
|
92
|
+
return events;
|
|
93
|
+
};
|
|
94
|
+
/** A plain event insert, for the mutations whose audit row does not depend on a read. */
|
|
95
|
+
const eventInsert = (input) => stmt(`${SHARE_SQL.insertEvent} RETURNING *`, input.at, input.action, input.resource?.app ?? null, input.resource?.kind ?? null, input.resource?.id ?? null, input.subject.type, input.subject.id, input.level, null, input.actor, input.note ?? "");
|
|
96
|
+
const revokeEvents = (where, at, actor, note, ...keys) => stmt(SHARE_SQL.insertRevokeEvents(where), at, actor, note, ...keys);
|
|
97
|
+
const normalized = (subject) => ({
|
|
98
|
+
type: subject.type,
|
|
99
|
+
id: normalizeSubjectId(subject.type, subject.id),
|
|
100
|
+
});
|
|
101
|
+
return {
|
|
102
|
+
app,
|
|
103
|
+
async grant(input) {
|
|
104
|
+
const subject = normalized(input.subject);
|
|
105
|
+
if (!subject.id)
|
|
106
|
+
throw new Error("[cursedbelt-core/sharing] a grant needs a subject");
|
|
107
|
+
if (!input.id)
|
|
108
|
+
throw new Error("[cursedbelt-core/sharing] a grant needs a resource id");
|
|
109
|
+
const at = clock();
|
|
110
|
+
const note = input.note ?? "";
|
|
111
|
+
const key = [app, input.kind, input.id, subject.type, subject.id];
|
|
112
|
+
await commit([
|
|
113
|
+
// The event FIRST: it reads the grant as it stood, to tell a grant from a regrade.
|
|
114
|
+
stmt(SHARE_SQL.insertGrantEvent, at, input.level, ...key, input.level, input.level, input.grantedBy, note, ...key),
|
|
115
|
+
stmt(SHARE_SQL.upsertGrant, ...key, input.level, input.grantedBy, at, note),
|
|
116
|
+
]);
|
|
117
|
+
return {
|
|
118
|
+
resource: { app, kind: input.kind, id: input.id },
|
|
119
|
+
subject,
|
|
120
|
+
level: input.level,
|
|
121
|
+
grantedBy: input.grantedBy,
|
|
122
|
+
grantedAt: at,
|
|
123
|
+
note,
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
async revoke(input) {
|
|
127
|
+
const subject = normalized(input.subject);
|
|
128
|
+
const at = clock();
|
|
129
|
+
const key = [app, input.kind, input.id, subject.type, subject.id];
|
|
130
|
+
const [event] = await commit([
|
|
131
|
+
revokeEvents(SHARE_SQL.oneGrant, at, input.revokedBy, input.note ?? "", ...key),
|
|
132
|
+
stmt(SHARE_SQL.deleteGrant, ...key),
|
|
133
|
+
]);
|
|
134
|
+
// No grant, no event — "revoked nothing" is not logged, exactly as in the sync store.
|
|
135
|
+
return event?.level ?? null;
|
|
136
|
+
},
|
|
137
|
+
async revokeResource(target, revokedBy) {
|
|
138
|
+
const at = clock();
|
|
139
|
+
const key = [app, target.kind, target.id];
|
|
140
|
+
const events = await commit([
|
|
141
|
+
revokeEvents(SHARE_SQL.resourceGrants, at, revokedBy, "the record was deleted", ...key),
|
|
142
|
+
stmt(SHARE_SQL.deleteResourceGrants, ...key),
|
|
143
|
+
]);
|
|
144
|
+
return events.length;
|
|
145
|
+
},
|
|
146
|
+
async revokeSubject(subject, revokedBy) {
|
|
147
|
+
const normal = normalized(subject);
|
|
148
|
+
const at = clock();
|
|
149
|
+
const key = [app, normal.type, normal.id];
|
|
150
|
+
const events = await commit([
|
|
151
|
+
revokeEvents(SHARE_SQL.subjectGrants, at, revokedBy, "every share with this subject was revoked", ...key),
|
|
152
|
+
stmt(SHARE_SQL.deleteSubjectGrants, ...key),
|
|
153
|
+
]);
|
|
154
|
+
return events.length;
|
|
155
|
+
},
|
|
156
|
+
async listForResource(target) {
|
|
157
|
+
return (await rows(SHARE_SQL.listForResource, app, target.kind, target.id)).map(toGrant);
|
|
158
|
+
},
|
|
159
|
+
async listForSubject(subject) {
|
|
160
|
+
const normal = normalized(subject);
|
|
161
|
+
return (await rows(SHARE_SQL.listForSubject, app, normal.type, normal.id)).map(toGrant);
|
|
162
|
+
},
|
|
163
|
+
async accessFor(target, viewer) {
|
|
164
|
+
const subjectId = normalizeSubjectId("user", viewer.subjectId);
|
|
165
|
+
if (!subjectId)
|
|
166
|
+
return "none";
|
|
167
|
+
const found = await rows(SHARE_SQL.accessFor, app, target.kind, target.id, subjectId, subjectId);
|
|
168
|
+
let access = "none";
|
|
169
|
+
for (const row of found)
|
|
170
|
+
access = strongerAccess(access, row.level);
|
|
171
|
+
return access;
|
|
172
|
+
},
|
|
173
|
+
async resourceIdsFor(kind, viewer, atLeast = "read") {
|
|
174
|
+
const subjectId = normalizeSubjectId("user", viewer.subjectId);
|
|
175
|
+
if (!subjectId)
|
|
176
|
+
return [];
|
|
177
|
+
const levels = levelsMeeting(atLeast);
|
|
178
|
+
const found = await rows(SHARE_SQL.resourceIdsFor(levels.length), app, kind, ...levels, subjectId, subjectId);
|
|
179
|
+
return found.map((row) => row.resource_id);
|
|
180
|
+
},
|
|
181
|
+
sharedSql(input) {
|
|
182
|
+
return sharedResourceSql({ app, ...input });
|
|
183
|
+
},
|
|
184
|
+
// ── Groups ───────────────────────────────────────────────────────────────
|
|
185
|
+
async createGroup(input) {
|
|
186
|
+
const id = normalizeSubjectId("group", input.id);
|
|
187
|
+
if (!id)
|
|
188
|
+
throw new Error("[cursedbelt-core/sharing] a group needs an id");
|
|
189
|
+
const at = clock();
|
|
190
|
+
await commit([
|
|
191
|
+
stmt(SHARE_SQL.upsertGroup, id, input.name, input.createdBy, at, input.note ?? ""),
|
|
192
|
+
eventInsert({
|
|
193
|
+
action: "group.create",
|
|
194
|
+
resource: null,
|
|
195
|
+
subject: { type: "group", id },
|
|
196
|
+
level: null,
|
|
197
|
+
actor: input.createdBy,
|
|
198
|
+
note: input.name,
|
|
199
|
+
at,
|
|
200
|
+
}),
|
|
201
|
+
]);
|
|
202
|
+
return { id, name: input.name, createdBy: input.createdBy, createdAt: at, note: input.note ?? "" };
|
|
203
|
+
},
|
|
204
|
+
async renameGroup(id, name, actor) {
|
|
205
|
+
const groupId = normalizeSubjectId("group", id);
|
|
206
|
+
const at = clock();
|
|
207
|
+
await commit([
|
|
208
|
+
stmt(SHARE_SQL.renameGroup, name, groupId),
|
|
209
|
+
eventInsert({
|
|
210
|
+
action: "group.rename",
|
|
211
|
+
resource: null,
|
|
212
|
+
subject: { type: "group", id: groupId },
|
|
213
|
+
level: null,
|
|
214
|
+
actor,
|
|
215
|
+
note: name,
|
|
216
|
+
at,
|
|
217
|
+
}),
|
|
218
|
+
]);
|
|
219
|
+
},
|
|
220
|
+
/** Deletes the group AND every grant made to it, each with its own revoke event — see
|
|
221
|
+
* the sync store's `deleteGroup` for why that is not optional. */
|
|
222
|
+
async deleteGroup(id, actor) {
|
|
223
|
+
const groupId = normalizeSubjectId("group", id);
|
|
224
|
+
const at = clock();
|
|
225
|
+
await commit([
|
|
226
|
+
revokeEvents(SHARE_SQL.groupGrants, at, actor, "the group was deleted", app, groupId),
|
|
227
|
+
stmt(SHARE_SQL.deleteGroupGrants, app, groupId),
|
|
228
|
+
stmt(SHARE_SQL.deleteGroup, groupId),
|
|
229
|
+
eventInsert({
|
|
230
|
+
action: "group.delete",
|
|
231
|
+
resource: null,
|
|
232
|
+
subject: { type: "group", id: groupId },
|
|
233
|
+
level: null,
|
|
234
|
+
actor,
|
|
235
|
+
note: "",
|
|
236
|
+
at,
|
|
237
|
+
}),
|
|
238
|
+
]);
|
|
239
|
+
},
|
|
240
|
+
async listGroups() {
|
|
241
|
+
return (await rows(SHARE_SQL.listGroups)).map((row) => ({
|
|
242
|
+
id: row.group_id,
|
|
243
|
+
name: row.name,
|
|
244
|
+
createdBy: row.created_by,
|
|
245
|
+
createdAt: row.created_at,
|
|
246
|
+
note: row.note,
|
|
247
|
+
}));
|
|
248
|
+
},
|
|
249
|
+
async addGroupMember(groupId, subjectId, actor) {
|
|
250
|
+
const id = normalizeSubjectId("group", groupId);
|
|
251
|
+
const member = normalizeSubjectId("user", subjectId);
|
|
252
|
+
if (!member)
|
|
253
|
+
throw new Error("[cursedbelt-core/sharing] a group member needs an address");
|
|
254
|
+
const at = clock();
|
|
255
|
+
await commit([
|
|
256
|
+
stmt(SHARE_SQL.insertMember, id, member, actor, at),
|
|
257
|
+
eventInsert({
|
|
258
|
+
action: "group.join",
|
|
259
|
+
resource: null,
|
|
260
|
+
subject: { type: "group", id },
|
|
261
|
+
level: null,
|
|
262
|
+
actor,
|
|
263
|
+
note: member,
|
|
264
|
+
at,
|
|
265
|
+
}),
|
|
266
|
+
]);
|
|
267
|
+
},
|
|
268
|
+
async removeGroupMember(groupId, subjectId, actor) {
|
|
269
|
+
const id = normalizeSubjectId("group", groupId);
|
|
270
|
+
const member = normalizeSubjectId("user", subjectId);
|
|
271
|
+
const at = clock();
|
|
272
|
+
await commit([
|
|
273
|
+
stmt(SHARE_SQL.deleteMember, id, member),
|
|
274
|
+
eventInsert({
|
|
275
|
+
action: "group.leave",
|
|
276
|
+
resource: null,
|
|
277
|
+
subject: { type: "group", id },
|
|
278
|
+
level: null,
|
|
279
|
+
actor,
|
|
280
|
+
note: member,
|
|
281
|
+
at,
|
|
282
|
+
}),
|
|
283
|
+
]);
|
|
284
|
+
},
|
|
285
|
+
async listGroupMembers(groupId) {
|
|
286
|
+
const found = await rows(SHARE_SQL.listMembers, normalizeSubjectId("group", groupId));
|
|
287
|
+
return found.map((row) => ({
|
|
288
|
+
groupId: row.group_id,
|
|
289
|
+
subjectId: row.subject_id,
|
|
290
|
+
addedBy: row.added_by,
|
|
291
|
+
addedAt: row.added_at,
|
|
292
|
+
}));
|
|
293
|
+
},
|
|
294
|
+
async groupIdsFor(subjectId) {
|
|
295
|
+
const member = normalizeSubjectId("user", subjectId);
|
|
296
|
+
if (!member)
|
|
297
|
+
return [];
|
|
298
|
+
return (await rows(SHARE_SQL.groupIdsFor, member)).map((row) => row.group_id);
|
|
299
|
+
},
|
|
300
|
+
// ── The audit trail ──────────────────────────────────────────────────────
|
|
301
|
+
async events(query = {}) {
|
|
302
|
+
const { where, params } = eventsWhere(app, {
|
|
303
|
+
...query,
|
|
304
|
+
subject: query.subject ? normalized(query.subject) : undefined,
|
|
305
|
+
});
|
|
306
|
+
return (await rows(SHARE_SQL.events(where), ...params, eventLimit(query.limit))).map(toEvent);
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
}
|
|
@@ -13,6 +13,7 @@ export type { ShareRouterOptions, ShareRouterViewer, ShareRoutesPayload } from "
|
|
|
13
13
|
export { sharedResourceSql, sqlColumn, sqlLiteral } from "./sql.js";
|
|
14
14
|
export type { SharedResourceSqlOptions } from "./sql.js";
|
|
15
15
|
export { createShareStore, group, user } from "./store.js";
|
|
16
|
+
export type { AsyncShareStore, AwaitableShareStore } from "./contract.js";
|
|
16
17
|
export type { GrantInput, RevokeInput, ShareEventQuery, ShareStore, ShareStoreOptions, ShareTarget, } from "./store.js";
|
|
17
18
|
export { accessMeets, describeAccess, describeShareEvent, isShareAccess, isShareLevel, isShareSubjectType, levelsMeeting, normalizeSubject, normalizeSubjectId, resolveAccess, resourceKey, sameResource, sameSubject, SHARE_ACTIONS, strongerAccess, } from "cursedbelt-core/sharing/model";
|
|
18
19
|
export type { ShareAccess, ShareAction, ShareEvent, ShareGrant, ShareGroup, ShareGroupMember, ShareLevel, ShareResource, ShareSubject, ShareSubjectType, ShareViewer, } from "cursedbelt-core/sharing/model";
|
|
@@ -1,6 +1,3 @@
|
|
|
1
1
|
import type { Migration } from "../migration/types.js";
|
|
2
|
-
export
|
|
3
|
-
export declare const SHARE_GROUPS_TABLE = "share_groups";
|
|
4
|
-
export declare const SHARE_GROUP_MEMBERS_TABLE = "share_group_members";
|
|
5
|
-
export declare const SHARE_EVENTS_TABLE = "share_events";
|
|
2
|
+
export { SHARE_DDL, SHARE_EVENTS_TABLE, SHARE_GRANTS_TABLE, SHARE_GROUPS_TABLE, SHARE_GROUP_MEMBERS_TABLE, } from "./schema.js";
|
|
6
3
|
export declare const SHARE_MIGRATIONS: readonly Migration[];
|
|
@@ -1,79 +1,14 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import { SHARE_DDL } from "./schema.js";
|
|
2
|
+
// The names and the DDL live in `./schema.ts`, driver-free, so a D1 app builds its schema file
|
|
3
|
+
// from the SAME statements this migration runs (`cursedbelt-server/sharing/d1` re-exports
|
|
4
|
+
// them). Re-exported here so every existing import of this module keeps working.
|
|
5
|
+
export { SHARE_DDL, SHARE_EVENTS_TABLE, SHARE_GRANTS_TABLE, SHARE_GROUPS_TABLE, SHARE_GROUP_MEMBERS_TABLE, } from "./schema.js";
|
|
5
6
|
export const SHARE_MIGRATIONS = [
|
|
6
7
|
{
|
|
7
8
|
name: "0001_share_grants",
|
|
8
9
|
up(db) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
grant_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
12
|
-
app TEXT NOT NULL,
|
|
13
|
-
kind TEXT NOT NULL,
|
|
14
|
-
resource_id TEXT NOT NULL,
|
|
15
|
-
subject_type TEXT NOT NULL CHECK (subject_type IN ('user', 'group')),
|
|
16
|
-
subject_id TEXT NOT NULL,
|
|
17
|
-
level TEXT NOT NULL CHECK (level IN ('read', 'write')),
|
|
18
|
-
granted_by TEXT NOT NULL,
|
|
19
|
-
granted_at TEXT NOT NULL,
|
|
20
|
-
note TEXT NOT NULL DEFAULT '',
|
|
21
|
-
-- One grant per subject per resource. This is what makes raising
|
|
22
|
-
-- read to write an UPDATE the audit trail can describe, instead of
|
|
23
|
-
-- two rows where revoking one leaves access in place.
|
|
24
|
-
UNIQUE (app, kind, resource_id, subject_type, subject_id)
|
|
25
|
-
)
|
|
26
|
-
`);
|
|
27
|
-
// The hot read is the resolver's: "does this subject hold a grant on this
|
|
28
|
-
// row" — correlated per row of a list query, so it must be an index hit.
|
|
29
|
-
db.run(`CREATE INDEX IF NOT EXISTS idx_share_grants_subject
|
|
30
|
-
ON ${SHARE_GRANTS_TABLE} (subject_type, subject_id, app, kind, resource_id)`);
|
|
31
|
-
// …and the panel's: "who can see this record".
|
|
32
|
-
db.run(`CREATE INDEX IF NOT EXISTS idx_share_grants_resource
|
|
33
|
-
ON ${SHARE_GRANTS_TABLE} (app, kind, resource_id)`);
|
|
34
|
-
db.run(`
|
|
35
|
-
CREATE TABLE IF NOT EXISTS ${SHARE_GROUPS_TABLE} (
|
|
36
|
-
group_id TEXT PRIMARY KEY,
|
|
37
|
-
name TEXT NOT NULL,
|
|
38
|
-
created_by TEXT NOT NULL,
|
|
39
|
-
created_at TEXT NOT NULL,
|
|
40
|
-
note TEXT NOT NULL DEFAULT ''
|
|
41
|
-
)
|
|
42
|
-
`);
|
|
43
|
-
db.run(`
|
|
44
|
-
CREATE TABLE IF NOT EXISTS ${SHARE_GROUP_MEMBERS_TABLE} (
|
|
45
|
-
group_id TEXT NOT NULL REFERENCES ${SHARE_GROUPS_TABLE} (group_id) ON DELETE CASCADE,
|
|
46
|
-
subject_id TEXT NOT NULL,
|
|
47
|
-
added_by TEXT NOT NULL,
|
|
48
|
-
added_at TEXT NOT NULL,
|
|
49
|
-
PRIMARY KEY (group_id, subject_id)
|
|
50
|
-
)
|
|
51
|
-
`);
|
|
52
|
-
// "Which groups is this person in" — read by every shared-row query.
|
|
53
|
-
db.run(`CREATE INDEX IF NOT EXISTS idx_share_group_members_subject
|
|
54
|
-
ON ${SHARE_GROUP_MEMBERS_TABLE} (subject_id)`);
|
|
55
|
-
db.run(`
|
|
56
|
-
CREATE TABLE IF NOT EXISTS ${SHARE_EVENTS_TABLE} (
|
|
57
|
-
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
58
|
-
at TEXT NOT NULL,
|
|
59
|
-
action TEXT NOT NULL,
|
|
60
|
-
app TEXT,
|
|
61
|
-
kind TEXT,
|
|
62
|
-
resource_id TEXT,
|
|
63
|
-
subject_type TEXT NOT NULL CHECK (subject_type IN ('user', 'group')),
|
|
64
|
-
subject_id TEXT NOT NULL,
|
|
65
|
-
level TEXT,
|
|
66
|
-
previous_level TEXT,
|
|
67
|
-
actor TEXT NOT NULL,
|
|
68
|
-
note TEXT NOT NULL DEFAULT ''
|
|
69
|
-
)
|
|
70
|
-
`);
|
|
71
|
-
// "Everything that ever happened to this record", newest first.
|
|
72
|
-
db.run(`CREATE INDEX IF NOT EXISTS idx_share_events_resource
|
|
73
|
-
ON ${SHARE_EVENTS_TABLE} (app, kind, resource_id, event_id DESC)`);
|
|
74
|
-
// "Everything this person was ever given" — the offboarding question.
|
|
75
|
-
db.run(`CREATE INDEX IF NOT EXISTS idx_share_events_subject
|
|
76
|
-
ON ${SHARE_EVENTS_TABLE} (subject_id, event_id DESC)`);
|
|
10
|
+
for (const statement of SHARE_DDL)
|
|
11
|
+
db.run(statement);
|
|
77
12
|
},
|
|
78
13
|
},
|
|
79
14
|
];
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
*/
|
|
45
45
|
import type { Context, Hono } from 'hono';
|
|
46
46
|
import type { ShareEvent, ShareGrant } from 'cursedbelt-core/sharing/model';
|
|
47
|
-
import type {
|
|
47
|
+
import type { AwaitableShareStore } from './contract.js';
|
|
48
48
|
/** The `group:` prefix that lets a group subject travel in a path segment. Exported so a
|
|
49
49
|
* client and a route cannot disagree about it. */
|
|
50
50
|
export declare const GROUP_SUBJECT_PREFIX = "group:";
|
|
@@ -72,8 +72,12 @@ export interface ShareRouterOptions<V extends ShareRouterViewer> {
|
|
|
72
72
|
base: string;
|
|
73
73
|
/** The `kind` half of the store's `(app, kind, id)` triple. */
|
|
74
74
|
kind: string;
|
|
75
|
-
/**
|
|
76
|
-
|
|
75
|
+
/**
|
|
76
|
+
* The store — the sync `ShareStore` or the D1 `AsyncShareStore` (`./sharing/d1`); every
|
|
77
|
+
* call is awaited, and awaiting a sync store's plain value changes nothing. A function
|
|
78
|
+
* when it is per-request (a multi-tenant app, or a Worker whose D1 handle is per request).
|
|
79
|
+
*/
|
|
80
|
+
shares: AwaitableShareStore | ((c: Context) => AwaitableShareStore);
|
|
77
81
|
/** Who is asking. Returning null refuses every route with 401. */
|
|
78
82
|
viewerOf: (c: Context) => V | null;
|
|
79
83
|
/**
|
|
@@ -84,7 +88,7 @@ export interface ShareRouterOptions<V extends ShareRouterViewer> {
|
|
|
84
88
|
mustOwn: (c: Context, id: string, viewer: V) => Response | null | Promise<Response | null>;
|
|
85
89
|
/** Extra fields folded into the READ payload beside `{grants, events}` — family's
|
|
86
90
|
* `labels`, life's `groups`. */
|
|
87
|
-
extra?: (c: Context, id: string, viewer: V) => Record<string, unknown
|
|
91
|
+
extra?: (c: Context, id: string, viewer: V) => Record<string, unknown> | Promise<Record<string, unknown>>;
|
|
88
92
|
/** How many audit lines the read carries. Default 50. */
|
|
89
93
|
trailLimit?: number;
|
|
90
94
|
}
|
|
@@ -51,9 +51,11 @@ app, options) {
|
|
|
51
51
|
const payload = {
|
|
52
52
|
// The store's own rows, straight through — the sharing panel consumes exactly
|
|
53
53
|
// these, and a DTO in the middle is a place for a field to go missing.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
// Awaited in this order — grants, trail, extra — which is the order the sync store
|
|
55
|
+
// was always read in.
|
|
56
|
+
grants: await shares.listForResource({ kind, id }),
|
|
57
|
+
events: await shares.events({ kind, id, limit: trailLimit }),
|
|
58
|
+
...(extra ? await extra(c, id, viewer) : {}),
|
|
57
59
|
};
|
|
58
60
|
return c.json(payload);
|
|
59
61
|
});
|
|
@@ -69,7 +71,7 @@ app, options) {
|
|
|
69
71
|
if (body.level !== undefined && !isLevel(body.level)) {
|
|
70
72
|
return c.json({ error: "level must be 'read' or 'write'" }, 400);
|
|
71
73
|
}
|
|
72
|
-
const grant = shares.grant({
|
|
74
|
+
const grant = await shares.grant({
|
|
73
75
|
kind,
|
|
74
76
|
id,
|
|
75
77
|
subject: { type: body.subject?.type === 'group' ? 'group' : 'user', id: subjectId },
|
|
@@ -87,7 +89,7 @@ app, options) {
|
|
|
87
89
|
const raw = c.req.param('subject') ?? '';
|
|
88
90
|
if (!raw.trim())
|
|
89
91
|
return c.json({ error: 'which share should be removed?' }, 400);
|
|
90
|
-
shares.revoke({ kind, id, subject: parseSubjectParam(raw), revokedBy: viewer.email });
|
|
92
|
+
await shares.revoke({ kind, id, subject: parseSubjectParam(raw), revokedBy: viewer.email });
|
|
91
93
|
return c.json({ ok: true });
|
|
92
94
|
});
|
|
93
95
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sharing tables' names and DDL, with no driver attached — the ONE copy both the
|
|
3
|
+
* `bun:sqlite` migration (`./migrations.ts`) and a D1 app's `db/schema.sql` are built from.
|
|
4
|
+
*
|
|
5
|
+
* It is import-free on purpose: `./d1.ts` runs on `workerd`, and a Worker app generates its
|
|
6
|
+
* schema file from these statements rather than running DDL at request time. The migration
|
|
7
|
+
* runs exactly this list, so the two can never describe different tables. Why each table and
|
|
8
|
+
* each index exists is `./migrations.ts`'s header.
|
|
9
|
+
*/
|
|
10
|
+
export declare const SHARE_GRANTS_TABLE = "share_grants";
|
|
11
|
+
export declare const SHARE_GROUPS_TABLE = "share_groups";
|
|
12
|
+
export declare const SHARE_GROUP_MEMBERS_TABLE = "share_group_members";
|
|
13
|
+
export declare const SHARE_EVENTS_TABLE = "share_events";
|
|
14
|
+
/**
|
|
15
|
+
* Every statement of the `0001_share_grants` migration, in order. Idempotent (`IF NOT
|
|
16
|
+
* EXISTS` throughout), so a schema file built from it can be applied to a database that
|
|
17
|
+
* already has the tables.
|
|
18
|
+
*/
|
|
19
|
+
export declare const SHARE_DDL: readonly string[];
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sharing tables' names and DDL, with no driver attached — the ONE copy both the
|
|
3
|
+
* `bun:sqlite` migration (`./migrations.ts`) and a D1 app's `db/schema.sql` are built from.
|
|
4
|
+
*
|
|
5
|
+
* It is import-free on purpose: `./d1.ts` runs on `workerd`, and a Worker app generates its
|
|
6
|
+
* schema file from these statements rather than running DDL at request time. The migration
|
|
7
|
+
* runs exactly this list, so the two can never describe different tables. Why each table and
|
|
8
|
+
* each index exists is `./migrations.ts`'s header.
|
|
9
|
+
*/
|
|
10
|
+
export const SHARE_GRANTS_TABLE = "share_grants";
|
|
11
|
+
export const SHARE_GROUPS_TABLE = "share_groups";
|
|
12
|
+
export const SHARE_GROUP_MEMBERS_TABLE = "share_group_members";
|
|
13
|
+
export const SHARE_EVENTS_TABLE = "share_events";
|
|
14
|
+
/**
|
|
15
|
+
* Every statement of the `0001_share_grants` migration, in order. Idempotent (`IF NOT
|
|
16
|
+
* EXISTS` throughout), so a schema file built from it can be applied to a database that
|
|
17
|
+
* already has the tables.
|
|
18
|
+
*/
|
|
19
|
+
export const SHARE_DDL = [
|
|
20
|
+
`
|
|
21
|
+
CREATE TABLE IF NOT EXISTS ${SHARE_GRANTS_TABLE} (
|
|
22
|
+
grant_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
23
|
+
app TEXT NOT NULL,
|
|
24
|
+
kind TEXT NOT NULL,
|
|
25
|
+
resource_id TEXT NOT NULL,
|
|
26
|
+
subject_type TEXT NOT NULL CHECK (subject_type IN ('user', 'group')),
|
|
27
|
+
subject_id TEXT NOT NULL,
|
|
28
|
+
level TEXT NOT NULL CHECK (level IN ('read', 'write')),
|
|
29
|
+
granted_by TEXT NOT NULL,
|
|
30
|
+
granted_at TEXT NOT NULL,
|
|
31
|
+
note TEXT NOT NULL DEFAULT '',
|
|
32
|
+
-- One grant per subject per resource. This is what makes raising
|
|
33
|
+
-- read to write an UPDATE the audit trail can describe, instead of
|
|
34
|
+
-- two rows where revoking one leaves access in place.
|
|
35
|
+
UNIQUE (app, kind, resource_id, subject_type, subject_id)
|
|
36
|
+
)
|
|
37
|
+
`,
|
|
38
|
+
// The hot read is the resolver's: "does this subject hold a grant on this
|
|
39
|
+
// row" — correlated per row of a list query, so it must be an index hit.
|
|
40
|
+
`CREATE INDEX IF NOT EXISTS idx_share_grants_subject
|
|
41
|
+
ON ${SHARE_GRANTS_TABLE} (subject_type, subject_id, app, kind, resource_id)`,
|
|
42
|
+
// …and the panel's: "who can see this record".
|
|
43
|
+
`CREATE INDEX IF NOT EXISTS idx_share_grants_resource
|
|
44
|
+
ON ${SHARE_GRANTS_TABLE} (app, kind, resource_id)`,
|
|
45
|
+
`
|
|
46
|
+
CREATE TABLE IF NOT EXISTS ${SHARE_GROUPS_TABLE} (
|
|
47
|
+
group_id TEXT PRIMARY KEY,
|
|
48
|
+
name TEXT NOT NULL,
|
|
49
|
+
created_by TEXT NOT NULL,
|
|
50
|
+
created_at TEXT NOT NULL,
|
|
51
|
+
note TEXT NOT NULL DEFAULT ''
|
|
52
|
+
)
|
|
53
|
+
`,
|
|
54
|
+
`
|
|
55
|
+
CREATE TABLE IF NOT EXISTS ${SHARE_GROUP_MEMBERS_TABLE} (
|
|
56
|
+
group_id TEXT NOT NULL REFERENCES ${SHARE_GROUPS_TABLE} (group_id) ON DELETE CASCADE,
|
|
57
|
+
subject_id TEXT NOT NULL,
|
|
58
|
+
added_by TEXT NOT NULL,
|
|
59
|
+
added_at TEXT NOT NULL,
|
|
60
|
+
PRIMARY KEY (group_id, subject_id)
|
|
61
|
+
)
|
|
62
|
+
`,
|
|
63
|
+
// "Which groups is this person in" — read by every shared-row query.
|
|
64
|
+
`CREATE INDEX IF NOT EXISTS idx_share_group_members_subject
|
|
65
|
+
ON ${SHARE_GROUP_MEMBERS_TABLE} (subject_id)`,
|
|
66
|
+
`
|
|
67
|
+
CREATE TABLE IF NOT EXISTS ${SHARE_EVENTS_TABLE} (
|
|
68
|
+
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
69
|
+
at TEXT NOT NULL,
|
|
70
|
+
action TEXT NOT NULL,
|
|
71
|
+
app TEXT,
|
|
72
|
+
kind TEXT,
|
|
73
|
+
resource_id TEXT,
|
|
74
|
+
subject_type TEXT NOT NULL CHECK (subject_type IN ('user', 'group')),
|
|
75
|
+
subject_id TEXT NOT NULL,
|
|
76
|
+
level TEXT,
|
|
77
|
+
previous_level TEXT,
|
|
78
|
+
actor TEXT NOT NULL,
|
|
79
|
+
note TEXT NOT NULL DEFAULT ''
|
|
80
|
+
)
|
|
81
|
+
`,
|
|
82
|
+
// "Everything that ever happened to this record", newest first.
|
|
83
|
+
`CREATE INDEX IF NOT EXISTS idx_share_events_resource
|
|
84
|
+
ON ${SHARE_EVENTS_TABLE} (app, kind, resource_id, event_id DESC)`,
|
|
85
|
+
// "Everything this person was ever given" — the offboarding question.
|
|
86
|
+
`CREATE INDEX IF NOT EXISTS idx_share_events_subject
|
|
87
|
+
ON ${SHARE_EVENTS_TABLE} (subject_id, event_id DESC)`,
|
|
88
|
+
];
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* tested with hostile input; a consumer never quotes anything itself.
|
|
27
27
|
*/
|
|
28
28
|
import { levelsMeeting, normalizeSubjectId } from "cursedbelt-core/sharing/model";
|
|
29
|
-
import { SHARE_GRANTS_TABLE, SHARE_GROUP_MEMBERS_TABLE } from "./
|
|
29
|
+
import { SHARE_GRANTS_TABLE, SHARE_GROUP_MEMBERS_TABLE } from "./schema.js";
|
|
30
30
|
/**
|
|
31
31
|
* Quote a string as a SQL literal.
|
|
32
32
|
*
|