@hollware/server 0.2.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-V_1BbmZQ.mjs +150 -0
- package/dist/index.d.mts +81 -3
- package/dist/index.mjs +1610 -590
- package/dist/store-CsuER6J7.mjs +760 -0
- package/migrations/0004_identity.sql +90 -0
- package/migrations/0005_reviews.sql +27 -0
- package/migrations/0006_people_across_sites.sql +54 -0
- package/migrations/0007_read_tokens.sql +4 -0
- package/migrations/meta/0004_snapshot.json +1977 -0
- package/migrations/meta/0005_snapshot.json +2188 -0
- package/migrations/meta/0006_snapshot.json +2102 -0
- package/migrations/meta/0007_snapshot.json +2110 -0
- package/migrations/meta/_journal.json +28 -0
- package/package.json +4 -2
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { a as findInvitation, c as invitationById, d as redeemInvitation, f as saveChallenge, h as userHandle, i as createUser, m as touchPasskey, o as findPasskey, p as takeChallenge, r as createSession, t as addPasskey, u as passkeysOf } from "./store-CsuER6J7.mjs";
|
|
2
|
+
import { HollwareError, newId } from "@hollware/core";
|
|
3
|
+
import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse } from "@simplewebauthn/server";
|
|
4
|
+
import { isoBase64URL } from "@simplewebauthn/server/helpers";
|
|
5
|
+
//#region src/identity/ceremonies.ts
|
|
6
|
+
/** How long a session lasts: a working day. */
|
|
7
|
+
const SESSION_HOURS = 12;
|
|
8
|
+
function relyingParty(ceremony) {
|
|
9
|
+
const url = new URL(ceremony.publicUrl);
|
|
10
|
+
return {
|
|
11
|
+
id: url.hostname,
|
|
12
|
+
origin: url.origin
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function refused(message, fix) {
|
|
16
|
+
return new HollwareError("invalid_input", message, fix);
|
|
17
|
+
}
|
|
18
|
+
/** The options of `navigator.credentials.create` for the person of an invitation. */
|
|
19
|
+
async function registrationOptions(ceremony, invitationSecret) {
|
|
20
|
+
const invitation = await findInvitation(ceremony.db, invitationSecret, ceremony.now);
|
|
21
|
+
if (invitation === null) throw refused("This link is not valid: it was used already, or it expired.", "Ask the owner of this Hollware for a new invitation.");
|
|
22
|
+
const userId = invitation.userId ?? newId("usr");
|
|
23
|
+
const existing = invitation.userId === null ? [] : await passkeysOf(ceremony.db, invitation.userId);
|
|
24
|
+
const rp = relyingParty(ceremony);
|
|
25
|
+
const options = await generateRegistrationOptions({
|
|
26
|
+
rpName: "Hollware",
|
|
27
|
+
rpID: rp.id,
|
|
28
|
+
userName: userId,
|
|
29
|
+
userID: userHandle(userId),
|
|
30
|
+
attestationType: "none",
|
|
31
|
+
excludeCredentials: existing.map((passkey) => ({ id: passkey.credentialId })),
|
|
32
|
+
authenticatorSelection: {
|
|
33
|
+
residentKey: "required",
|
|
34
|
+
userVerification: "preferred"
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
return {
|
|
38
|
+
challengeId: await saveChallenge(ceremony.db, {
|
|
39
|
+
challenge: options.challenge,
|
|
40
|
+
purpose: "register",
|
|
41
|
+
invitationId: invitation.id,
|
|
42
|
+
userId,
|
|
43
|
+
now: ceremony.now
|
|
44
|
+
}),
|
|
45
|
+
options
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Verifies a registration and redeems its invitation, once: a new person is made with the
|
|
50
|
+
* invitation's role, or the passkey joins the person the link names. Opens their session.
|
|
51
|
+
*/
|
|
52
|
+
async function verifyRegistration(ceremony, input) {
|
|
53
|
+
const challenge = await takeChallenge(ceremony.db, {
|
|
54
|
+
id: input.challengeId,
|
|
55
|
+
purpose: "register",
|
|
56
|
+
now: ceremony.now
|
|
57
|
+
});
|
|
58
|
+
if (challenge === null || challenge.invitationId === null || challenge.userId === null) throw refused("The registration expired.", "Start again from the link.");
|
|
59
|
+
const rp = relyingParty(ceremony);
|
|
60
|
+
const verified = await verifyRegistrationResponse({
|
|
61
|
+
response: input.response,
|
|
62
|
+
expectedChallenge: challenge.challenge,
|
|
63
|
+
expectedOrigin: rp.origin,
|
|
64
|
+
expectedRPID: rp.id
|
|
65
|
+
});
|
|
66
|
+
if (!verified.verified) throw refused("The passkey could not be verified.", "Try again.");
|
|
67
|
+
const invitation = await invitationById(ceremony.db, challenge.invitationId);
|
|
68
|
+
if (invitation === null || !await redeemInvitation(ceremony.db, invitation.id, ceremony.now)) throw refused("This link is not valid: it was used already, or it expired.", "Ask the owner of this Hollware for a new invitation.");
|
|
69
|
+
if (invitation.userId === null) await createUser(ceremony.db, {
|
|
70
|
+
id: challenge.userId,
|
|
71
|
+
name: input.name.trim() === "" ? "Unnamed" : input.name.trim(),
|
|
72
|
+
role: invitation.role,
|
|
73
|
+
site: invitation.site,
|
|
74
|
+
now: ceremony.now
|
|
75
|
+
});
|
|
76
|
+
const { credential } = verified.registrationInfo;
|
|
77
|
+
await addPasskey(ceremony.db, {
|
|
78
|
+
userId: challenge.userId,
|
|
79
|
+
credentialId: credential.id,
|
|
80
|
+
publicKey: isoBase64URL.fromBuffer(credential.publicKey),
|
|
81
|
+
counter: credential.counter,
|
|
82
|
+
transports: credential.transports ?? [],
|
|
83
|
+
label: input.label.trim() === "" ? "Passkey" : input.label.trim(),
|
|
84
|
+
now: ceremony.now
|
|
85
|
+
});
|
|
86
|
+
const session = await createSession(ceremony.db, {
|
|
87
|
+
userId: challenge.userId,
|
|
88
|
+
now: ceremony.now,
|
|
89
|
+
hours: SESSION_HOURS
|
|
90
|
+
});
|
|
91
|
+
return {
|
|
92
|
+
userId: challenge.userId,
|
|
93
|
+
session: session.secret
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/** The options of `navigator.credentials.get`: any passkey of this deployment, chosen by the person. */
|
|
97
|
+
async function authenticationOptions(ceremony) {
|
|
98
|
+
const rp = relyingParty(ceremony);
|
|
99
|
+
const options = await generateAuthenticationOptions({
|
|
100
|
+
rpID: rp.id,
|
|
101
|
+
userVerification: "preferred"
|
|
102
|
+
});
|
|
103
|
+
return {
|
|
104
|
+
challengeId: await saveChallenge(ceremony.db, {
|
|
105
|
+
challenge: options.challenge,
|
|
106
|
+
purpose: "authenticate",
|
|
107
|
+
invitationId: null,
|
|
108
|
+
userId: null,
|
|
109
|
+
now: ceremony.now
|
|
110
|
+
}),
|
|
111
|
+
options
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/** Verifies a sign-in with a passkey of this deployment, and opens the person's session. */
|
|
115
|
+
async function verifyAuthentication(ceremony, input) {
|
|
116
|
+
const challenge = await takeChallenge(ceremony.db, {
|
|
117
|
+
id: input.challengeId,
|
|
118
|
+
purpose: "authenticate",
|
|
119
|
+
now: ceremony.now
|
|
120
|
+
});
|
|
121
|
+
if (challenge === null) throw refused("The sign-in expired.", "Sign in again.");
|
|
122
|
+
const passkey = await findPasskey(ceremony.db, input.response.id);
|
|
123
|
+
if (passkey === null) throw refused("This passkey is not one of this Hollware.", "Use a passkey you registered here.");
|
|
124
|
+
const rp = relyingParty(ceremony);
|
|
125
|
+
const verified = await verifyAuthenticationResponse({
|
|
126
|
+
response: input.response,
|
|
127
|
+
expectedChallenge: challenge.challenge,
|
|
128
|
+
expectedOrigin: rp.origin,
|
|
129
|
+
expectedRPID: rp.id,
|
|
130
|
+
credential: {
|
|
131
|
+
id: passkey.credentialId,
|
|
132
|
+
publicKey: isoBase64URL.toBuffer(passkey.publicKey),
|
|
133
|
+
counter: passkey.counter,
|
|
134
|
+
transports: JSON.parse(passkey.transports)
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
if (!verified.verified) throw refused("The passkey could not be verified.", "Try again.");
|
|
138
|
+
await touchPasskey(ceremony.db, passkey.id, verified.authenticationInfo.newCounter, ceremony.now);
|
|
139
|
+
const session = await createSession(ceremony.db, {
|
|
140
|
+
userId: passkey.userId,
|
|
141
|
+
now: ceremony.now,
|
|
142
|
+
hours: SESSION_HOURS
|
|
143
|
+
});
|
|
144
|
+
return {
|
|
145
|
+
userId: passkey.userId,
|
|
146
|
+
session: session.secret
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
//#endregion
|
|
150
|
+
export { authenticationOptions, registrationOptions, verifyAuthentication, verifyRegistration };
|
package/dist/index.d.mts
CHANGED
|
@@ -122,6 +122,24 @@ type WriteRefusal = {
|
|
|
122
122
|
readonly bytes: number;
|
|
123
123
|
readonly limit: number;
|
|
124
124
|
};
|
|
125
|
+
/** The status of a review (v0.3). */
|
|
126
|
+
type ReviewStatus = 'pending' | 'approved' | 'changes_requested' | 'cancelled';
|
|
127
|
+
/** A request to publish a revision, and what was decided about it. */
|
|
128
|
+
interface StoredReview {
|
|
129
|
+
readonly id: string;
|
|
130
|
+
readonly entryId: string;
|
|
131
|
+
readonly revisionId: string;
|
|
132
|
+
readonly status: ReviewStatus;
|
|
133
|
+
/** The warnings the person who asked acknowledged. */
|
|
134
|
+
readonly acknowledged: readonly AcknowledgedWarning[];
|
|
135
|
+
readonly requestedBy: string;
|
|
136
|
+
/** The name of the user who asked, or `null` when they gave none. */
|
|
137
|
+
readonly requestedByName: string | null;
|
|
138
|
+
readonly requestedAt: string;
|
|
139
|
+
readonly decidedBy: string | null;
|
|
140
|
+
readonly decidedAt: string | null;
|
|
141
|
+
readonly note: string | null;
|
|
142
|
+
}
|
|
125
143
|
/** A scheduled publication, as the cron trigger reads it. */
|
|
126
144
|
interface StoredSchedule {
|
|
127
145
|
readonly entryId: string;
|
|
@@ -177,7 +195,8 @@ interface ContentStore {
|
|
|
177
195
|
} | WriteRefusal>;
|
|
178
196
|
/**
|
|
179
197
|
* Appends a revision and makes it the head, if the entry is still at `expectedVersion`. In
|
|
180
|
-
* one batch: the revision, its tags (only where the revision was inserted)
|
|
198
|
+
* one batch: the revision, its tags (only where the revision was inserted), the cancellation of
|
|
199
|
+
* a pending review, which asked for another revision, and the entry.
|
|
181
200
|
*/
|
|
182
201
|
appendRevision(site: string, write: {
|
|
183
202
|
readonly entryId: string;
|
|
@@ -190,7 +209,8 @@ interface ContentStore {
|
|
|
190
209
|
} | WriteRefusal>;
|
|
191
210
|
/**
|
|
192
211
|
* Publishes the head revision, if the entry is still at `expectedVersion`, and appends the
|
|
193
|
-
* publication with its verdict and acknowledged warnings.
|
|
212
|
+
* publication with its verdict and acknowledged warnings. A pending review of the entry is
|
|
213
|
+
* approved by the actor in the same batch: what it asked for is published.
|
|
194
214
|
*/
|
|
195
215
|
publish(site: string, write: PublishWrite): Promise<{
|
|
196
216
|
readonly outcome: 'published';
|
|
@@ -225,6 +245,37 @@ interface ContentStore {
|
|
|
225
245
|
dueSchedules(site: string, now: string): Promise<readonly StoredSchedule[]>;
|
|
226
246
|
/** Takes a scheduled entry back to a draft and drops its schedule. */
|
|
227
247
|
cancelSchedule(site: string, entryId: string): Promise<void>;
|
|
248
|
+
/**
|
|
249
|
+
* Asks for the head revision of an entry to be published, if the entry is still at
|
|
250
|
+
* `expectedVersion`. An entry has one pending review at most: asking again answers it.
|
|
251
|
+
*/
|
|
252
|
+
requestReview(site: string, write: {
|
|
253
|
+
readonly entryId: string;
|
|
254
|
+
readonly expectedVersion: number;
|
|
255
|
+
readonly acknowledged: readonly AcknowledgedWarning[];
|
|
256
|
+
readonly actor: Actor;
|
|
257
|
+
}): Promise<{
|
|
258
|
+
readonly outcome: 'pending_review';
|
|
259
|
+
readonly review: StoredReview;
|
|
260
|
+
} | WriteRefusal>;
|
|
261
|
+
/** A review of the site, by its id, or `null`. */
|
|
262
|
+
findReview(site: string, id: string): Promise<StoredReview | null>;
|
|
263
|
+
/** The reviews of the site with this status, newest first. */
|
|
264
|
+
listReviews(site: string, query: {
|
|
265
|
+
readonly status: ReviewStatus;
|
|
266
|
+
readonly limit: number;
|
|
267
|
+
}): Promise<readonly StoredReview[]>;
|
|
268
|
+
/** Closes a pending review with a note, without publishing. */
|
|
269
|
+
requestChanges(site: string, write: {
|
|
270
|
+
readonly id: string;
|
|
271
|
+
readonly note: string;
|
|
272
|
+
readonly userId: string;
|
|
273
|
+
}): Promise<{
|
|
274
|
+
readonly outcome: 'changes_requested' | 'not_pending';
|
|
275
|
+
readonly review: StoredReview;
|
|
276
|
+
} | {
|
|
277
|
+
readonly outcome: 'not_found';
|
|
278
|
+
}>;
|
|
228
279
|
/** Takes the entry off the public site and appends the unpublish. */
|
|
229
280
|
unpublish(site: string, write: {
|
|
230
281
|
readonly entryId: string;
|
|
@@ -273,6 +324,26 @@ type Principal = {
|
|
|
273
324
|
readonly site: string;
|
|
274
325
|
/** The token's own scopes intersected with those of its user's role, as of now. */
|
|
275
326
|
readonly scopes: readonly Scope[];
|
|
327
|
+
} | {
|
|
328
|
+
/** An access token of the OAuth server (ADR-026): a person, through a client they allowed. */
|
|
329
|
+
readonly kind: 'oauth';
|
|
330
|
+
readonly userId: string;
|
|
331
|
+
/** The client the person allowed: a Client ID Metadata Document URL, or a registered id. */
|
|
332
|
+
readonly clientId: string;
|
|
333
|
+
readonly site: string;
|
|
334
|
+
/** The scopes of the grant intersected with those of the user's role, as of now. */
|
|
335
|
+
readonly scopes: readonly Scope[];
|
|
336
|
+
} | {
|
|
337
|
+
/**
|
|
338
|
+
* A read token (`hw_read_`): it reads the published content of its site over the read API,
|
|
339
|
+
* and the gateway turns it away from every other route.
|
|
340
|
+
*/
|
|
341
|
+
readonly kind: 'read';
|
|
342
|
+
readonly userId: string;
|
|
343
|
+
readonly tokenId: string;
|
|
344
|
+
readonly site: string;
|
|
345
|
+
/** `content:read` while its user's role has it, else nothing. */
|
|
346
|
+
readonly scopes: readonly Scope[];
|
|
276
347
|
} | {
|
|
277
348
|
/** A Worker of the same account, over the RPC binding: it reads published content. */
|
|
278
349
|
readonly kind: 'binding';
|
|
@@ -338,6 +409,13 @@ export interface HollwareEnv {
|
|
|
338
409
|
* Origin the Worker answers; without it, every request fails.
|
|
339
410
|
*/
|
|
340
411
|
readonly HOLLWARE_PUBLIC_URL: string;
|
|
412
|
+
/**
|
|
413
|
+
* The KV namespace of the OAuth server (ADR-026): its clients, grants and tokens, all by hash.
|
|
414
|
+
* Personal tokens work without it; OAuth does not.
|
|
415
|
+
*/
|
|
416
|
+
readonly OAUTH_KV: KVNamespace;
|
|
417
|
+
/** The static assets of the Worker, which hold the admin at `/admin/`; without them, no admin. */
|
|
418
|
+
readonly ASSETS?: Fetcher;
|
|
341
419
|
/** The Queue of publication events (ADR-025); without it, the cron trigger delivers them. */
|
|
342
420
|
readonly EVENTS?: EventQueue;
|
|
343
421
|
}
|
|
@@ -386,4 +464,4 @@ export type CreateHollware = (config: HollwareConfig) => HollwareWorker;
|
|
|
386
464
|
*/
|
|
387
465
|
export declare const createHollware: CreateHollware;
|
|
388
466
|
//#endregion
|
|
389
|
-
export type { Actor, Clock, ContentStore, EntrySummaryView, EntryView, EventMessage, Handlers, ListQuery, NewRevision, OperationContext, OperationHandler, OutboxEvent, Principal, PublishWrite, RevisionSummary, StoredEntry, StoredPublication, StoredRevision, StoredSchedule, View, WriteRefusal };
|
|
467
|
+
export type { Actor, Clock, ContentStore, EntrySummaryView, EntryView, EventMessage, Handlers, ListQuery, NewRevision, OperationContext, OperationHandler, OutboxEvent, Principal, PublishWrite, ReviewStatus, RevisionSummary, StoredEntry, StoredPublication, StoredReview, StoredRevision, StoredSchedule, View, WriteRefusal };
|