@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.
@@ -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 `entry.unpublished`. */
395
+ /** `entry.published`, `entry.unpublished`, or `site.changed`. */
390
396
  kind: text("kind").notNull(),
391
- entryId: text("entry_id").notNull(),
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 { users as A, reviewComments as C, schedules as D, revisions as E, effectiveScopes as M, isRole as N, sites as O, publications as S, revisionTags as T, entries as _, findInvitation as a, members as b, invitationById as c, redeemInvitation as d, saveChallenge as f, deliveries as g, userHandle as h, createUser as i, ROLE_SCOPES as j, tokens as k, membershipsOf as l, touchPasskey as m, createInvitation as n, findPasskey as o, takeChallenge as p, createSession as r, findSession as s, addPasskey as t, passkeysOf as u, entryEmbeddings as v, reviews as w, outbox as x, invitations as y };
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;