@hollware/server 0.3.0 → 0.4.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.
@@ -135,6 +135,11 @@ const tokens = sqliteTable("tokens", {
135
135
  userId: text("user_id").notNull(),
136
136
  label: text("label").notNull(),
137
137
  hash: text("hash").notNull(),
138
+ /**
139
+ * `personal` (`hw_pat_`) or `read` (`hw_read_`), which reads the published content of the
140
+ * site over the read API and nothing else. Its CHECK is the column's own (migration 0007).
141
+ */
142
+ kind: text("kind", { enum: ["personal", "read"] }).notNull().default("personal"),
138
143
  /** A JSON array of scopes. */
139
144
  scopes: text("scopes").notNull(),
140
145
  expiresAt: text("expires_at"),
@@ -413,10 +418,12 @@ const deliveries = sqliteTable("deliveries", {
413
418
  }),
414
419
  check("deliveries_attempts", sql`${t.attempts} >= 0`)
415
420
  ]);
416
- /** The passkeys of a person (ADR-026): WebAuthn credentials, by their id, with their public key. */
421
+ /**
422
+ * The passkeys of a person (ADR-026): WebAuthn credentials, by their id, with their public key. A
423
+ * person is one across the sites of a deployment (v0.4), and so are their passkeys and sessions.
424
+ */
417
425
  const passkeys = sqliteTable("passkeys", {
418
426
  id: id("pky"),
419
- siteId: siteId(),
420
427
  userId: text("user_id").notNull(),
421
428
  /** The credential id, base64url, as the authenticator gives it. */
422
429
  credentialId: text("credential_id").notNull(),
@@ -430,14 +437,8 @@ const passkeys = sqliteTable("passkeys", {
430
437
  lastUsedAt: text("last_used_at"),
431
438
  ...timestamps()
432
439
  }, (t) => [
433
- unique("passkeys_id_site").on(t.id, t.siteId),
434
440
  unique("passkeys_credential").on(t.credentialId),
435
441
  index("passkeys_user").on(t.userId),
436
- foreignKey({
437
- name: "passkeys_site",
438
- columns: [t.siteId],
439
- foreignColumns: [sites.id]
440
- }),
441
442
  foreignKey({
442
443
  name: "passkeys_user_fk",
443
444
  columns: [t.userId],
@@ -479,7 +480,6 @@ const invitations = sqliteTable("invitations", {
479
480
  /** WebAuthn challenges, each used once and briefly (ADR-026). */
480
481
  const authChallenges = sqliteTable("auth_challenges", {
481
482
  id: id("chl"),
482
- siteId: siteId(),
483
483
  challenge: text("challenge").notNull(),
484
484
  /** `register` or `authenticate`. */
485
485
  purpose: text("purpose").notNull(),
@@ -489,37 +489,19 @@ const authChallenges = sqliteTable("auth_challenges", {
489
489
  userId: text("user_id"),
490
490
  expiresAt: text("expires_at").notNull(),
491
491
  ...timestamps()
492
- }, (t) => [
493
- unique("auth_challenges_id_site").on(t.id, t.siteId),
494
- foreignKey({
495
- name: "auth_challenges_site",
496
- columns: [t.siteId],
497
- foreignColumns: [sites.id]
498
- }),
499
- check("auth_challenges_purpose", sql`${t.purpose} IN ('register', 'authenticate')`)
500
- ]);
492
+ }, (t) => [check("auth_challenges_purpose", sql`${t.purpose} IN ('register', 'authenticate')`)]);
501
493
  /** Signed-in people (ADR-026): the session cookie that `/authorize` reads, by its hash. */
502
494
  const sessions = sqliteTable("sessions", {
503
495
  id: id("ses"),
504
- siteId: siteId(),
505
496
  userId: text("user_id").notNull(),
506
497
  hash: text("hash").notNull(),
507
498
  expiresAt: text("expires_at").notNull(),
508
499
  ...timestamps()
509
- }, (t) => [
510
- unique("sessions_id_site").on(t.id, t.siteId),
511
- unique("sessions_hash").on(t.hash),
512
- foreignKey({
513
- name: "sessions_site",
514
- columns: [t.siteId],
515
- foreignColumns: [sites.id]
516
- }),
517
- foreignKey({
518
- name: "sessions_user",
519
- columns: [t.userId],
520
- foreignColumns: [users.id]
521
- })
522
- ]);
500
+ }, (t) => [unique("sessions_hash").on(t.hash), foreignKey({
501
+ name: "sessions_user",
502
+ columns: [t.userId],
503
+ foreignColumns: [users.id]
504
+ })]);
523
505
  /**
524
506
  * Reviews (v0.3): a person who may write but not publish asks for a revision to be published, and
525
507
  * a person who may publish approves it, which publishes it, or asks for changes with a note. An
@@ -607,7 +589,7 @@ function userHandle(userId) {
607
589
  //#region src/identity/store.ts
608
590
  function roleOf(value) {
609
591
  if (value === "owner" || value === "editor" || value === "author") return value;
610
- throw new Error(`An invitation has the role ${value}, which the schema does not allow`);
592
+ throw new Error(`A role of ${value} is no role the schema allows`);
611
593
  }
612
594
  /** Creates a link that lets one person in, and gives its secret, which is never stored. */
613
595
  async function createInvitation(db, input) {
@@ -633,24 +615,25 @@ async function createInvitation(db, input) {
633
615
  };
634
616
  }
635
617
  /** The invitation of a secret, while it is unused and has not expired. */
636
- async function findInvitation(db, site, secret, now) {
618
+ async function findInvitation(db, secret, now) {
637
619
  const [row] = await db.select({
638
620
  id: invitations.id,
621
+ site: invitations.siteId,
639
622
  role: invitations.role,
640
623
  userId: invitations.userId
641
- }).from(invitations).where(and(eq(invitations.siteId, site), eq(invitations.hash, await hashSecret(secret)), isNull(invitations.usedAt), gt(invitations.expiresAt, now.toISOString()))).limit(1);
624
+ }).from(invitations).where(and(eq(invitations.hash, await hashSecret(secret)), isNull(invitations.usedAt), gt(invitations.expiresAt, now.toISOString()))).limit(1);
642
625
  return row === void 0 ? null : {
643
626
  ...row,
644
627
  role: roleOf(row.role)
645
628
  };
646
629
  }
647
630
  /** Uses an invitation, once: `false` when it was used already or has expired. */
648
- async function redeemInvitation(db, site, id, now) {
631
+ async function redeemInvitation(db, id, now) {
649
632
  const at = now.toISOString();
650
633
  return (await db.update(invitations).set({
651
634
  usedAt: at,
652
635
  updatedAt: at
653
- }).where(and(eq(invitations.siteId, site), eq(invitations.id, id), isNull(invitations.usedAt), gt(invitations.expiresAt, at))).returning({ id: invitations.id })).length === 1;
636
+ }).where(and(eq(invitations.id, id), isNull(invitations.usedAt), gt(invitations.expiresAt, at))).returning({ id: invitations.id })).length === 1;
654
637
  }
655
638
  /** Keeps a WebAuthn challenge for five minutes. */
656
639
  async function saveChallenge(db, input) {
@@ -658,7 +641,6 @@ async function saveChallenge(db, input) {
658
641
  const now = input.now.toISOString();
659
642
  await db.insert(authChallenges).values({
660
643
  id,
661
- siteId: input.site,
662
644
  challenge: input.challenge,
663
645
  purpose: input.purpose,
664
646
  invitationId: input.invitationId,
@@ -671,16 +653,16 @@ async function saveChallenge(db, input) {
671
653
  }
672
654
  /** Takes a challenge, once, if it has the purpose asked and has not expired. */
673
655
  async function takeChallenge(db, input) {
674
- const [row] = await db.delete(authChallenges).where(and(eq(authChallenges.siteId, input.site), eq(authChallenges.id, input.id))).returning();
656
+ const [row] = await db.delete(authChallenges).where(eq(authChallenges.id, input.id)).returning();
675
657
  if (row === void 0 || row.purpose !== input.purpose) return null;
676
658
  if (row.expiresAt <= input.now.toISOString()) return null;
677
659
  return row;
678
660
  }
679
- async function passkeysOf(db, site, userId) {
680
- return db.select().from(passkeys).where(and(eq(passkeys.siteId, site), eq(passkeys.userId, userId)));
661
+ async function passkeysOf(db, userId) {
662
+ return db.select().from(passkeys).where(eq(passkeys.userId, userId));
681
663
  }
682
- async function findPasskey(db, site, credentialId) {
683
- const [row] = await db.select().from(passkeys).where(and(eq(passkeys.siteId, site), eq(passkeys.credentialId, credentialId))).limit(1);
664
+ async function findPasskey(db, credentialId) {
665
+ const [row] = await db.select().from(passkeys).where(eq(passkeys.credentialId, credentialId)).limit(1);
684
666
  return row ?? null;
685
667
  }
686
668
  /** Records a sign-in with a passkey: its new counter, and when. */
@@ -714,7 +696,6 @@ async function addPasskey(db, input) {
714
696
  const at = input.now.toISOString();
715
697
  await db.insert(passkeys).values({
716
698
  id: newId("pky"),
717
- siteId: input.site,
718
699
  userId: input.userId,
719
700
  credentialId: input.credentialId,
720
701
  publicKey: input.publicKey,
@@ -733,7 +714,6 @@ async function createSession(db, input) {
733
714
  const expiresAt = new Date(input.now.getTime() + input.hours * 36e5).toISOString();
734
715
  await db.insert(sessions).values({
735
716
  id: newId("ses"),
736
- siteId: input.site,
737
717
  userId: input.userId,
738
718
  hash: await hashSecret(secret),
739
719
  expiresAt,
@@ -745,26 +725,36 @@ async function createSession(db, input) {
745
725
  expiresAt
746
726
  };
747
727
  }
748
- /** The person of a session cookie, while it lasts, with their role in its site now. */
749
- async function findSession(db, site, secret, now) {
728
+ /** The person of a session cookie, while it lasts. */
729
+ async function findSession(db, secret, now) {
750
730
  const [row] = await db.select({
751
731
  userId: sessions.userId,
752
- name: users.name,
753
- role: members.role
754
- }).from(sessions).innerJoin(users, eq(users.id, sessions.userId)).innerJoin(members, and(eq(members.userId, sessions.userId), eq(members.siteId, sessions.siteId))).where(and(eq(sessions.siteId, site), eq(sessions.hash, await hashSecret(secret)), gt(sessions.expiresAt, now.toISOString()))).limit(1);
732
+ name: users.name
733
+ }).from(sessions).innerJoin(users, eq(users.id, sessions.userId)).where(and(eq(sessions.hash, await hashSecret(secret)), gt(sessions.expiresAt, now.toISOString()))).limit(1);
755
734
  return row ?? null;
756
735
  }
736
+ /** The sites a person is a member of, with their role in each, in the order they joined. */
737
+ async function membershipsOf(db, userId) {
738
+ return (await db.select({
739
+ site: members.siteId,
740
+ role: members.role
741
+ }).from(members).where(eq(members.userId, userId)).orderBy(members.createdAt, members.siteId)).map((row) => ({
742
+ site: row.site,
743
+ role: roleOf(row.role)
744
+ }));
745
+ }
757
746
  /** An invitation by its id, used or not: what a registration redeems. */
758
- async function invitationById(db, site, id) {
747
+ async function invitationById(db, id) {
759
748
  const [row] = await db.select({
760
749
  id: invitations.id,
750
+ site: invitations.siteId,
761
751
  role: invitations.role,
762
752
  userId: invitations.userId
763
- }).from(invitations).where(and(eq(invitations.siteId, site), eq(invitations.id, id))).limit(1);
753
+ }).from(invitations).where(eq(invitations.id, id)).limit(1);
764
754
  return row === void 0 ? null : {
765
755
  ...row,
766
756
  role: roleOf(row.role)
767
757
  };
768
758
  }
769
759
  //#endregion
770
- export { isRole as A, revisions as C, users as D, tokens as E, ROLE_SCOPES as O, revisionTags as S, sites as T, invitations as _, findInvitation as a, publications as b, invitationById as c, saveChallenge as d, takeChallenge as f, entries as g, deliveries as h, createUser as i, effectiveScopes as k, passkeysOf as l, userHandle as m, createInvitation as n, findPasskey as o, touchPasskey as p, createSession as r, findSession as s, addPasskey as t, redeemInvitation as u, members as v, schedules as w, reviews as x, outbox as y };
760
+ export { effectiveScopes as A, revisionTags as C, tokens as D, sites as E, users as O, reviews as S, schedules as T, entries as _, findInvitation as a, outbox as b, invitationById as c, redeemInvitation as d, saveChallenge as f, deliveries as g, userHandle as h, createUser as i, isRole as j, ROLE_SCOPES 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, invitations as v, revisions as w, publications as x, members as y };
@@ -0,0 +1,54 @@
1
+ -- Edited by hand (ADR-023): a person is one across the sites of a deployment (v0.4), so passkeys,
2
+ -- sessions and sign-in challenges lose site_id. Each table is rebuilt, which D1 allows here since
3
+ -- no other table points at them; the PRAGMA foreign_keys lines drizzle-kit writes are gone, as D1
4
+ -- ignores them, and the CHECKs name their columns without the temporary table's name.
5
+ CREATE TABLE `__new_auth_challenges` (
6
+ `id` text PRIMARY KEY NOT NULL,
7
+ `challenge` text NOT NULL,
8
+ `purpose` text NOT NULL,
9
+ `invitation_id` text,
10
+ `user_id` text,
11
+ `expires_at` text NOT NULL,
12
+ `created_at` text NOT NULL,
13
+ `updated_at` text NOT NULL,
14
+ CONSTRAINT "auth_challenges_purpose" CHECK("purpose" IN ('register', 'authenticate'))
15
+ );
16
+ --> statement-breakpoint
17
+ INSERT INTO `__new_auth_challenges`("id", "challenge", "purpose", "invitation_id", "user_id", "expires_at", "created_at", "updated_at") SELECT "id", "challenge", "purpose", "invitation_id", "user_id", "expires_at", "created_at", "updated_at" FROM `auth_challenges`;--> statement-breakpoint
18
+ DROP TABLE `auth_challenges`;--> statement-breakpoint
19
+ ALTER TABLE `__new_auth_challenges` RENAME TO `auth_challenges`;--> statement-breakpoint
20
+ CREATE TABLE `__new_passkeys` (
21
+ `id` text PRIMARY KEY NOT NULL,
22
+ `user_id` text NOT NULL,
23
+ `credential_id` text NOT NULL,
24
+ `public_key` text NOT NULL,
25
+ `counter` integer NOT NULL,
26
+ `transports` text NOT NULL,
27
+ `label` text NOT NULL,
28
+ `last_used_at` text,
29
+ `created_at` text NOT NULL,
30
+ `updated_at` text NOT NULL,
31
+ FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
32
+ CONSTRAINT "passkeys_counter" CHECK("counter" >= 0),
33
+ CONSTRAINT "passkeys_json" CHECK(json_valid("transports") AND json_type("transports") = 'array')
34
+ );
35
+ --> statement-breakpoint
36
+ INSERT INTO `__new_passkeys`("id", "user_id", "credential_id", "public_key", "counter", "transports", "label", "last_used_at", "created_at", "updated_at") SELECT "id", "user_id", "credential_id", "public_key", "counter", "transports", "label", "last_used_at", "created_at", "updated_at" FROM `passkeys`;--> statement-breakpoint
37
+ DROP TABLE `passkeys`;--> statement-breakpoint
38
+ ALTER TABLE `__new_passkeys` RENAME TO `passkeys`;--> statement-breakpoint
39
+ CREATE INDEX `passkeys_user` ON `passkeys` (`user_id`);--> statement-breakpoint
40
+ CREATE UNIQUE INDEX `passkeys_credential` ON `passkeys` (`credential_id`);--> statement-breakpoint
41
+ CREATE TABLE `__new_sessions` (
42
+ `id` text PRIMARY KEY NOT NULL,
43
+ `user_id` text NOT NULL,
44
+ `hash` text NOT NULL,
45
+ `expires_at` text NOT NULL,
46
+ `created_at` text NOT NULL,
47
+ `updated_at` text NOT NULL,
48
+ FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
49
+ );
50
+ --> statement-breakpoint
51
+ INSERT INTO `__new_sessions`("id", "user_id", "hash", "expires_at", "created_at", "updated_at") SELECT "id", "user_id", "hash", "expires_at", "created_at", "updated_at" FROM `sessions`;--> statement-breakpoint
52
+ DROP TABLE `sessions`;--> statement-breakpoint
53
+ ALTER TABLE `__new_sessions` RENAME TO `sessions`;--> statement-breakpoint
54
+ CREATE UNIQUE INDEX `sessions_hash` ON `sessions` (`hash`);
@@ -0,0 +1,4 @@
1
+ -- Edited by hand (ADR-023). Read tokens (v0.4): a token is personal (hw_pat_) or a read token (hw_read_), which reads the
2
+ -- published content of its site over the read API and nothing else. Every token so far is
3
+ -- personal. The CHECK is the column's own, so the table is not rebuilt.
4
+ ALTER TABLE `tokens` ADD `kind` text DEFAULT 'personal' NOT NULL CHECK (`kind` IN ('personal', 'read'));