@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.
@@ -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) and the entry.
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 };