@hollware/server 0.9.1 → 0.11.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/{ceremonies-D5dIEbha.mjs → ceremonies-CI3DkOEL.mjs} +20 -6
- package/dist/index.d.mts +69 -4
- package/dist/index.mjs +1789 -486
- package/dist/{store-C2hiR0yd.mjs → store-DjA9s95l.mjs} +88 -6
- package/migrations/0013_site_events.sql +31 -0
- package/migrations/0014_entry_trash.sql +1 -0
- package/migrations/0015_site_changes_append_only.sql +28 -0
- package/migrations/0016_audit_log.sql +65 -0
- package/migrations/meta/0013_snapshot.json +2619 -0
- package/migrations/meta/0014_snapshot.json +2626 -0
- package/migrations/meta/0015_snapshot.json +2626 -0
- package/migrations/meta/0016_snapshot.json +2823 -0
- package/migrations/meta/_journal.json +28 -0
- package/package.json +8 -8
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { newId } from "@hollware/core";
|
|
2
|
-
import { and, eq, gt, isNull, sql } from "drizzle-orm";
|
|
2
|
+
import { and, eq, gt, isNull, lte, sql } from "drizzle-orm";
|
|
3
3
|
import { check, foreignKey, index, integer, sqliteTable, text, unique, uniqueIndex } from "drizzle-orm/sqlite-core";
|
|
4
4
|
//#region src/auth/roles.ts
|
|
5
5
|
/**
|
|
@@ -189,6 +189,11 @@ const entries = sqliteTable("entries", {
|
|
|
189
189
|
publishedAt: text("published_at"),
|
|
190
190
|
publishedSortKey: text("published_sort_key"),
|
|
191
191
|
scheduledAt: text("scheduled_at"),
|
|
192
|
+
/**
|
|
193
|
+
* When the entry went to the trash, or null. A trashed entry keeps its row, its slug and its
|
|
194
|
+
* translation group: only a draft goes there, and it comes back as the draft it was.
|
|
195
|
+
*/
|
|
196
|
+
deletedAt: text("deleted_at"),
|
|
192
197
|
...timestamps()
|
|
193
198
|
}, (t) => [
|
|
194
199
|
unique("entries_id_site").on(t.id, t.siteId),
|
|
@@ -381,14 +386,16 @@ const schedules = sqliteTable("schedules", {
|
|
|
381
386
|
/**
|
|
382
387
|
* The effects of a publication (ADR-025), written in the batch that publishes or unpublishes: one
|
|
383
388
|
* row per event. The Queue consumer delivers each event to every subscriber, and records it in
|
|
384
|
-
* `deliveries`; `sent_at` says that the event was put on the Queue.
|
|
389
|
+
* `deliveries`; `sent_at` says that the event was put on the Queue. A change of the structure of
|
|
390
|
+
* the site (ADR-033) is an event too, `site.changed`, about no entry.
|
|
385
391
|
*/
|
|
386
392
|
const outbox = sqliteTable("outbox", {
|
|
387
393
|
id: id("evt"),
|
|
388
394
|
siteId: siteId(),
|
|
389
|
-
/** `entry.published` or `
|
|
395
|
+
/** `entry.published`, `entry.unpublished`, or `site.changed`. */
|
|
390
396
|
kind: text("kind").notNull(),
|
|
391
|
-
|
|
397
|
+
/** The entry of an event about one, and none for `site.changed`. */
|
|
398
|
+
entryId: text("entry_id"),
|
|
392
399
|
/** A JSON object: what subscribers get. */
|
|
393
400
|
payload: text("payload").notNull(),
|
|
394
401
|
sentAt: text("sent_at"),
|
|
@@ -401,7 +408,8 @@ const outbox = sqliteTable("outbox", {
|
|
|
401
408
|
columns: [t.siteId],
|
|
402
409
|
foreignColumns: [sites.id]
|
|
403
410
|
}),
|
|
404
|
-
check("outbox_kind", sql`${t.kind} IN ('entry.published', 'entry.unpublished')`),
|
|
411
|
+
check("outbox_kind", sql`${t.kind} IN ('entry.published', 'entry.unpublished', 'site.changed')`),
|
|
412
|
+
check("outbox_entry", sql`(${t.kind} = 'site.changed') = (${t.entryId} IS NULL)`),
|
|
405
413
|
check("outbox_json", sql`json_valid(${t.payload}) AND json_type(${t.payload}) = 'object'`)
|
|
406
414
|
]);
|
|
407
415
|
/** Each delivery of an event to a subscriber, so that a retry never delivers it twice. */
|
|
@@ -680,6 +688,44 @@ const reviewComments = sqliteTable("review_comments", {
|
|
|
680
688
|
}),
|
|
681
689
|
check("review_comments_body", sql`length(${t.body}) BETWEEN 1 AND 4000`)
|
|
682
690
|
]);
|
|
691
|
+
sqliteTable("audit_log", {
|
|
692
|
+
id: id("aud"),
|
|
693
|
+
siteId: siteId(),
|
|
694
|
+
/** One of `AUDIT_ACTIONS` of `@hollware/core`. */
|
|
695
|
+
action: text("action").notNull(),
|
|
696
|
+
outcome: text("outcome").notNull(),
|
|
697
|
+
/** The code of the error the caller got, when the outcome is not `ok`. */
|
|
698
|
+
errorCode: text("error_code"),
|
|
699
|
+
actorKind: text("actor_kind").notNull(),
|
|
700
|
+
actorUserId: text("actor_user_id"),
|
|
701
|
+
actorTokenId: text("actor_token_id"),
|
|
702
|
+
/** The OAuth client a person allowed: its metadata document URL, or its registered id. */
|
|
703
|
+
actorClientId: text("actor_client_id"),
|
|
704
|
+
/** What the client said about itself (its MCP client, its model), as JSON: its own claim. */
|
|
705
|
+
actorMeta: text("actor_meta"),
|
|
706
|
+
targetType: text("target_type"),
|
|
707
|
+
targetId: text("target_id"),
|
|
708
|
+
/** A small object about the change (a role, a label, scopes): never a secret or content. */
|
|
709
|
+
detail: text("detail"),
|
|
710
|
+
requestId: text("request_id"),
|
|
711
|
+
/** HMAC-SHA256 of the address under a key of the site, when the site keeps addresses. */
|
|
712
|
+
ipHash: text("ip_hash"),
|
|
713
|
+
...timestamps()
|
|
714
|
+
}, (t) => [
|
|
715
|
+
unique("audit_log_id_site").on(t.id, t.siteId),
|
|
716
|
+
index("audit_log_site_time").on(t.siteId, t.createdAt, t.id),
|
|
717
|
+
index("audit_log_actor").on(t.siteId, t.actorUserId, t.createdAt),
|
|
718
|
+
index("audit_log_target").on(t.siteId, t.targetType, t.targetId, t.createdAt),
|
|
719
|
+
foreignKey({
|
|
720
|
+
name: "audit_log_site",
|
|
721
|
+
columns: [t.siteId],
|
|
722
|
+
foreignColumns: [sites.id]
|
|
723
|
+
}),
|
|
724
|
+
check("audit_log_outcome", sql`${t.outcome} IN ('ok', 'denied', 'failed')`),
|
|
725
|
+
check("audit_log_actor_kind", sql`${t.actorKind} IN ('user', 'token', 'oauth_client', 'system')`),
|
|
726
|
+
check("audit_log_json", sql`(${t.actorMeta} IS NULL OR json_valid(${t.actorMeta})) AND (${t.detail} IS NULL OR json_valid(${t.detail}))`),
|
|
727
|
+
check("audit_log_sizes", sql`length(${t.action}) <= 64 AND (${t.detail} IS NULL OR length(${t.detail}) <= 2000) AND (${t.actorMeta} IS NULL OR length(${t.actorMeta}) <= 2000)`)
|
|
728
|
+
]);
|
|
683
729
|
//#endregion
|
|
684
730
|
//#region src/identity/secrets.ts
|
|
685
731
|
/**
|
|
@@ -855,6 +901,33 @@ async function findSession(db, secret, now) {
|
|
|
855
901
|
}).from(sessions).innerJoin(users, eq(users.id, sessions.userId)).where(and(eq(sessions.hash, await hashSecret(secret)), gt(sessions.expiresAt, now.toISOString()))).limit(1);
|
|
856
902
|
return row ?? null;
|
|
857
903
|
}
|
|
904
|
+
/** The passkeys of a person, the oldest first. */
|
|
905
|
+
async function passkeySummaries(db, userId) {
|
|
906
|
+
return db.select({
|
|
907
|
+
id: passkeys.id,
|
|
908
|
+
label: passkeys.label,
|
|
909
|
+
createdAt: passkeys.createdAt,
|
|
910
|
+
lastUsedAt: passkeys.lastUsedAt
|
|
911
|
+
}).from(passkeys).where(eq(passkeys.userId, userId)).orderBy(passkeys.createdAt, passkeys.id);
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Removes a passkey of a person (T-54), never their last one: the statement that deletes it counts
|
|
915
|
+
* the person's passkeys in the same step, so two removals at once cannot leave them with none.
|
|
916
|
+
*/
|
|
917
|
+
async function removePasskey(db, input) {
|
|
918
|
+
if ((await db.prepare(`DELETE FROM passkeys WHERE id = ?1 AND user_id = ?2
|
|
919
|
+
AND (SELECT count(*) FROM passkeys WHERE user_id = ?2) > 1`).bind(input.id, input.userId).run()).meta.changes === 1) return "removed";
|
|
920
|
+
return await db.prepare("SELECT 1 AS found FROM passkeys WHERE id = ?1 AND user_id = ?2").bind(input.id, input.userId).first() === null ? "not_found" : "last";
|
|
921
|
+
}
|
|
922
|
+
/** Ends the session of a cookie, whoever's it is: the one browser that holds it signs out. */
|
|
923
|
+
async function endSession(db, secret) {
|
|
924
|
+
const [ended] = await db.delete(sessions).where(eq(sessions.hash, await hashSecret(secret))).returning({ userId: sessions.userId });
|
|
925
|
+
return ended?.userId ?? null;
|
|
926
|
+
}
|
|
927
|
+
/** Ends every session of a person (T-44): each browser signs in with a passkey again. */
|
|
928
|
+
async function endSessions(db, userId) {
|
|
929
|
+
return (await db.delete(sessions).where(eq(sessions.userId, userId)).returning({ id: sessions.id })).length;
|
|
930
|
+
}
|
|
858
931
|
/** The sites a person is a member of, with their role in each, in the order they joined. */
|
|
859
932
|
async function membershipsOf(db, userId) {
|
|
860
933
|
return (await db.select({
|
|
@@ -865,6 +938,15 @@ async function membershipsOf(db, userId) {
|
|
|
865
938
|
role: roleOf(row.role)
|
|
866
939
|
}));
|
|
867
940
|
}
|
|
941
|
+
/**
|
|
942
|
+
* Drops what of sign-in has expired: the sessions, and the WebAuthn challenges that nobody took.
|
|
943
|
+
* Anyone may ask for a challenge (`/auth/signin/options`), so without this the table would grow
|
|
944
|
+
* by a row for every request (T-11 of docs/security/threat-model.md). The cron trigger calls it.
|
|
945
|
+
*/
|
|
946
|
+
async function dropExpired(db, now) {
|
|
947
|
+
const at = now.toISOString();
|
|
948
|
+
await db.batch([db.delete(sessions).where(lte(sessions.expiresAt, at)), db.delete(authChallenges).where(lte(authChallenges.expiresAt, at))]);
|
|
949
|
+
}
|
|
868
950
|
/** An invitation by its id, used or not: what a registration redeems. */
|
|
869
951
|
async function invitationById(db, id) {
|
|
870
952
|
const [row] = await db.select({
|
|
@@ -879,4 +961,4 @@ async function invitationById(db, id) {
|
|
|
879
961
|
};
|
|
880
962
|
}
|
|
881
963
|
//#endregion
|
|
882
|
-
export {
|
|
964
|
+
export { revisionTags as A, entryEmbeddings as C, publications as D, outbox as E, users as F, ROLE_SCOPES as I, effectiveScopes as L, schedules as M, sites as N, reviewComments as O, tokens as P, isRole as R, entries as S, members as T, saveChallenge as _, dropExpired as a, userHandle as b, findInvitation as c, invitationById as d, membershipsOf as f, removePasskey as g, redeemInvitation as h, createUser as i, revisions as j, reviews as k, findPasskey as l, passkeysOf as m, createInvitation as n, endSession as o, passkeySummaries as p, createSession as r, endSessions as s, addPasskey as t, findSession as u, takeChallenge as v, invitations as w, deliveries as x, touchPasskey as y };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
-- Edited by hand (ADR-023): the outbox takes the events of a change of a site's structure
|
|
2
|
+
-- (`site.changed`, ADR-033), which are about no entry, so the table is rebuilt with a wider kind and
|
|
3
|
+
-- an entry_id that only such an event leaves empty. `deliveries` points at the outbox, and SQLite
|
|
4
|
+
-- counts a row that points at a dropped table as broken even when the rebuilt one holds its key, so
|
|
5
|
+
-- the deliveries are set aside, the outbox is rebuilt with nothing pointing at it, and they come
|
|
6
|
+
-- back, every one to a row that exists. The PRAGMA foreign_keys lines drizzle-kit writes are gone,
|
|
7
|
+
-- as D1 ignores them, and the CHECKs name their columns without the temporary table's name.
|
|
8
|
+
CREATE TABLE `__kept_deliveries` AS SELECT * FROM `deliveries`;--> statement-breakpoint
|
|
9
|
+
DELETE FROM `deliveries`;--> statement-breakpoint
|
|
10
|
+
CREATE TABLE `__new_outbox` (
|
|
11
|
+
`id` text PRIMARY KEY NOT NULL,
|
|
12
|
+
`site_id` text NOT NULL,
|
|
13
|
+
`kind` text NOT NULL,
|
|
14
|
+
`entry_id` text,
|
|
15
|
+
`payload` text NOT NULL,
|
|
16
|
+
`sent_at` text,
|
|
17
|
+
`created_at` text NOT NULL,
|
|
18
|
+
`updated_at` text NOT NULL,
|
|
19
|
+
FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON UPDATE no action ON DELETE no action,
|
|
20
|
+
CONSTRAINT "outbox_kind" CHECK("kind" IN ('entry.published', 'entry.unpublished', 'site.changed')),
|
|
21
|
+
CONSTRAINT "outbox_entry" CHECK(("kind" = 'site.changed') = ("entry_id" IS NULL)),
|
|
22
|
+
CONSTRAINT "outbox_json" CHECK(json_valid("payload") AND json_type("payload") = 'object')
|
|
23
|
+
);
|
|
24
|
+
--> statement-breakpoint
|
|
25
|
+
INSERT INTO `__new_outbox`("id", "site_id", "kind", "entry_id", "payload", "sent_at", "created_at", "updated_at") SELECT "id", "site_id", "kind", "entry_id", "payload", "sent_at", "created_at", "updated_at" FROM `outbox`;--> statement-breakpoint
|
|
26
|
+
DROP TABLE `outbox`;--> statement-breakpoint
|
|
27
|
+
ALTER TABLE `__new_outbox` RENAME TO `outbox`;--> statement-breakpoint
|
|
28
|
+
CREATE INDEX `outbox_unsent` ON `outbox` (`sent_at`,`created_at`);--> statement-breakpoint
|
|
29
|
+
CREATE UNIQUE INDEX `outbox_id_site` ON `outbox` (`id`,`site_id`);--> statement-breakpoint
|
|
30
|
+
INSERT INTO `deliveries` SELECT * FROM `__kept_deliveries`;--> statement-breakpoint
|
|
31
|
+
DROP TABLE `__kept_deliveries`;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE `entries` ADD `deleted_at` text;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
-- Written by hand (ADR-023): the history of the structure of the sites (`site_changes`, ADR-033)
|
|
2
|
+
-- is append-only, as revisions and publications are (ADR-021), so it is the record of who changed
|
|
3
|
+
-- what (T-83 of docs/security/threat-model.md). drizzle-kit does not model triggers; the test
|
|
4
|
+
-- append-only.workerd.test.ts lists them after every migration.
|
|
5
|
+
--
|
|
6
|
+
-- Two of the three triggers of 0002: a line is never deleted, and never changed. There is no
|
|
7
|
+
-- `_no_replace` on the unique (site_id, version): a write that lost a race to the same version in
|
|
8
|
+
-- the same millisecond adds its line with INSERT OR IGNORE, which such a trigger would abort
|
|
9
|
+
-- instead of ignoring; the one on the id still stops INSERT OR REPLACE of a line by its id.
|
|
10
|
+
|
|
11
|
+
CREATE TRIGGER `site_changes_no_delete`
|
|
12
|
+
BEFORE DELETE ON `site_changes`
|
|
13
|
+
BEGIN
|
|
14
|
+
SELECT RAISE(ABORT, 'append-only: a change of the structure is never deleted');
|
|
15
|
+
END;
|
|
16
|
+
--> statement-breakpoint
|
|
17
|
+
CREATE TRIGGER `site_changes_immutable`
|
|
18
|
+
BEFORE UPDATE ON `site_changes`
|
|
19
|
+
BEGIN
|
|
20
|
+
SELECT RAISE(ABORT, 'append-only: a change of the structure never changes');
|
|
21
|
+
END;
|
|
22
|
+
--> statement-breakpoint
|
|
23
|
+
CREATE TRIGGER `site_changes_no_replace`
|
|
24
|
+
BEFORE INSERT ON `site_changes`
|
|
25
|
+
WHEN EXISTS (SELECT 1 FROM `site_changes` WHERE `id` = NEW.`id`)
|
|
26
|
+
BEGIN
|
|
27
|
+
SELECT RAISE(ABORT, 'append-only: a change of the structure is written once');
|
|
28
|
+
END;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
-- Generated by drizzle-kit, then edited by hand (ADR-023): the triggers at the end are not in the
|
|
2
|
+
-- schema, since drizzle-kit does not model them; append-only.workerd.test.ts lists them after
|
|
3
|
+
-- every migration.
|
|
4
|
+
--
|
|
5
|
+
-- The activity log (ADR-037). A line is never changed and never replaced by its id. It is deleted
|
|
6
|
+
-- only once it is older than the retention its site's definition holds in `audit.retentionDays`,
|
|
7
|
+
-- 30 to 3650 days; a site with none keeps every line. The bounds are checked here again, so a
|
|
8
|
+
-- definition written around the operations still cannot shorten the log below 30 days.
|
|
9
|
+
CREATE TABLE `audit_log` (
|
|
10
|
+
`id` text PRIMARY KEY NOT NULL,
|
|
11
|
+
`site_id` text NOT NULL,
|
|
12
|
+
`action` text NOT NULL,
|
|
13
|
+
`outcome` text NOT NULL,
|
|
14
|
+
`error_code` text,
|
|
15
|
+
`actor_kind` text NOT NULL,
|
|
16
|
+
`actor_user_id` text,
|
|
17
|
+
`actor_token_id` text,
|
|
18
|
+
`actor_client_id` text,
|
|
19
|
+
`actor_meta` text,
|
|
20
|
+
`target_type` text,
|
|
21
|
+
`target_id` text,
|
|
22
|
+
`detail` text,
|
|
23
|
+
`request_id` text,
|
|
24
|
+
`ip_hash` text,
|
|
25
|
+
`created_at` text NOT NULL,
|
|
26
|
+
`updated_at` text NOT NULL,
|
|
27
|
+
FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON UPDATE no action ON DELETE no action,
|
|
28
|
+
CONSTRAINT "audit_log_outcome" CHECK("audit_log"."outcome" IN ('ok', 'denied', 'failed')),
|
|
29
|
+
CONSTRAINT "audit_log_actor_kind" CHECK("audit_log"."actor_kind" IN ('user', 'token', 'oauth_client', 'system')),
|
|
30
|
+
CONSTRAINT "audit_log_json" CHECK(("audit_log"."actor_meta" IS NULL OR json_valid("audit_log"."actor_meta")) AND ("audit_log"."detail" IS NULL OR json_valid("audit_log"."detail"))),
|
|
31
|
+
CONSTRAINT "audit_log_sizes" CHECK(length("audit_log"."action") <= 64 AND ("audit_log"."detail" IS NULL OR length("audit_log"."detail") <= 2000) AND ("audit_log"."actor_meta" IS NULL OR length("audit_log"."actor_meta") <= 2000))
|
|
32
|
+
);
|
|
33
|
+
--> statement-breakpoint
|
|
34
|
+
CREATE INDEX `audit_log_site_time` ON `audit_log` (`site_id`,`created_at`,`id`);--> statement-breakpoint
|
|
35
|
+
CREATE INDEX `audit_log_actor` ON `audit_log` (`site_id`,`actor_user_id`,`created_at`);--> statement-breakpoint
|
|
36
|
+
CREATE INDEX `audit_log_target` ON `audit_log` (`site_id`,`target_type`,`target_id`,`created_at`);--> statement-breakpoint
|
|
37
|
+
CREATE UNIQUE INDEX `audit_log_id_site` ON `audit_log` (`id`,`site_id`);
|
|
38
|
+
|
|
39
|
+
--> statement-breakpoint
|
|
40
|
+
CREATE TRIGGER `audit_log_immutable`
|
|
41
|
+
BEFORE UPDATE ON `audit_log`
|
|
42
|
+
BEGIN
|
|
43
|
+
SELECT RAISE(ABORT, 'append-only: a line of the activity log never changes');
|
|
44
|
+
END;
|
|
45
|
+
--> statement-breakpoint
|
|
46
|
+
CREATE TRIGGER `audit_log_no_replace`
|
|
47
|
+
BEFORE INSERT ON `audit_log`
|
|
48
|
+
WHEN EXISTS (SELECT 1 FROM `audit_log` WHERE `id` = NEW.`id`)
|
|
49
|
+
BEGIN
|
|
50
|
+
SELECT RAISE(ABORT, 'append-only: a line of the activity log is written once');
|
|
51
|
+
END;
|
|
52
|
+
--> statement-breakpoint
|
|
53
|
+
CREATE TRIGGER `audit_log_no_delete`
|
|
54
|
+
BEFORE DELETE ON `audit_log`
|
|
55
|
+
WHEN NOT EXISTS (
|
|
56
|
+
SELECT 1 FROM `sites`
|
|
57
|
+
WHERE `id` = OLD.`site_id`
|
|
58
|
+
AND json_valid(`definition`)
|
|
59
|
+
AND json_type(`definition`, '$.audit.retentionDays') = 'integer'
|
|
60
|
+
AND json_extract(`definition`, '$.audit.retentionDays') BETWEEN 30 AND 3650
|
|
61
|
+
AND OLD.`created_at` < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || json_extract(`definition`, '$.audit.retentionDays') || ' days')
|
|
62
|
+
)
|
|
63
|
+
BEGIN
|
|
64
|
+
SELECT RAISE(ABORT, 'append-only: a line of the activity log is kept for the retention of its site');
|
|
65
|
+
END;
|