@hollware/server 0.10.0 → 1.0.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  /**
@@ -688,6 +688,44 @@ const reviewComments = sqliteTable("review_comments", {
688
688
  }),
689
689
  check("review_comments_body", sql`length(${t.body}) BETWEEN 1 AND 4000`)
690
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
+ ]);
691
729
  //#endregion
692
730
  //#region src/identity/secrets.ts
693
731
  /**
@@ -863,6 +901,33 @@ async function findSession(db, secret, now) {
863
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);
864
902
  return row ?? null;
865
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
+ }
866
931
  /** The sites a person is a member of, with their role in each, in the order they joined. */
867
932
  async function membershipsOf(db, userId) {
868
933
  return (await db.select({
@@ -873,6 +938,15 @@ async function membershipsOf(db, userId) {
873
938
  role: roleOf(row.role)
874
939
  }));
875
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
+ }
876
950
  /** An invitation by its id, used or not: what a registration redeems. */
877
951
  async function invitationById(db, id) {
878
952
  const [row] = await db.select({
@@ -887,4 +961,4 @@ async function invitationById(db, id) {
887
961
  };
888
962
  }
889
963
  //#endregion
890
- 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,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;