@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.
- package/dist/{ceremonies-D7tsWU3S.mjs → ceremonies-V_1BbmZQ.mjs} +9 -16
- package/dist/index.d.mts +11 -0
- package/dist/index.mjs +297 -134
- package/dist/{store-Sq0pwUQv.mjs → store-CsuER6J7.mjs} +44 -54
- package/migrations/0006_people_across_sites.sql +54 -0
- package/migrations/0007_read_tokens.sql +4 -0
- package/migrations/meta/0006_snapshot.json +2102 -0
- package/migrations/meta/0007_snapshot.json +2110 -0
- package/migrations/meta/_journal.json +14 -0
- package/package.json +2 -2
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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(`
|
|
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,
|
|
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.
|
|
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,
|
|
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.
|
|
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(
|
|
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,
|
|
680
|
-
return db.select().from(passkeys).where(
|
|
661
|
+
async function passkeysOf(db, userId) {
|
|
662
|
+
return db.select().from(passkeys).where(eq(passkeys.userId, userId));
|
|
681
663
|
}
|
|
682
|
-
async function findPasskey(db,
|
|
683
|
-
const [row] = await db.select().from(passkeys).where(
|
|
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
|
|
749
|
-
async function findSession(db,
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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 {
|
|
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'));
|