@hollware/server 0.1.0 → 0.3.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,157 @@
1
+ import { a as findInvitation, c as invitationById, d as saveChallenge, f as takeChallenge, i as createUser, l as passkeysOf, m as userHandle, o as findPasskey, p as touchPasskey, r as createSession, t as addPasskey, u as redeemInvitation } from "./store-Sq0pwUQv.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, ceremony.site, 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, ceremony.site, 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
+ site: ceremony.site,
40
+ challenge: options.challenge,
41
+ purpose: "register",
42
+ invitationId: invitation.id,
43
+ userId,
44
+ now: ceremony.now
45
+ }),
46
+ options
47
+ };
48
+ }
49
+ /**
50
+ * Verifies a registration and redeems its invitation, once: a new person is made with the
51
+ * invitation's role, or the passkey joins the person the link names. Opens their session.
52
+ */
53
+ async function verifyRegistration(ceremony, input) {
54
+ const challenge = await takeChallenge(ceremony.db, {
55
+ site: ceremony.site,
56
+ id: input.challengeId,
57
+ purpose: "register",
58
+ now: ceremony.now
59
+ });
60
+ if (challenge === null || challenge.invitationId === null || challenge.userId === null) throw refused("The registration expired.", "Start again from the link.");
61
+ const rp = relyingParty(ceremony);
62
+ const verified = await verifyRegistrationResponse({
63
+ response: input.response,
64
+ expectedChallenge: challenge.challenge,
65
+ expectedOrigin: rp.origin,
66
+ expectedRPID: rp.id
67
+ });
68
+ if (!verified.verified) throw refused("The passkey could not be verified.", "Try again.");
69
+ const invitation = await invitationById(ceremony.db, ceremony.site, challenge.invitationId);
70
+ if (invitation === null || !await redeemInvitation(ceremony.db, ceremony.site, 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.");
71
+ if (invitation.userId === null) await createUser(ceremony.db, {
72
+ id: challenge.userId,
73
+ name: input.name.trim() === "" ? "Unnamed" : input.name.trim(),
74
+ role: invitation.role,
75
+ site: ceremony.site,
76
+ now: ceremony.now
77
+ });
78
+ const { credential } = verified.registrationInfo;
79
+ await addPasskey(ceremony.db, {
80
+ site: ceremony.site,
81
+ userId: challenge.userId,
82
+ credentialId: credential.id,
83
+ publicKey: isoBase64URL.fromBuffer(credential.publicKey),
84
+ counter: credential.counter,
85
+ transports: credential.transports ?? [],
86
+ label: input.label.trim() === "" ? "Passkey" : input.label.trim(),
87
+ now: ceremony.now
88
+ });
89
+ const session = await createSession(ceremony.db, {
90
+ site: ceremony.site,
91
+ userId: challenge.userId,
92
+ now: ceremony.now,
93
+ hours: SESSION_HOURS
94
+ });
95
+ return {
96
+ userId: challenge.userId,
97
+ session: session.secret
98
+ };
99
+ }
100
+ /** The options of `navigator.credentials.get`: any passkey of this site, chosen by the person. */
101
+ async function authenticationOptions(ceremony) {
102
+ const rp = relyingParty(ceremony);
103
+ const options = await generateAuthenticationOptions({
104
+ rpID: rp.id,
105
+ userVerification: "preferred"
106
+ });
107
+ return {
108
+ challengeId: await saveChallenge(ceremony.db, {
109
+ site: ceremony.site,
110
+ challenge: options.challenge,
111
+ purpose: "authenticate",
112
+ invitationId: null,
113
+ userId: null,
114
+ now: ceremony.now
115
+ }),
116
+ options
117
+ };
118
+ }
119
+ /** Verifies a sign-in with a passkey of this site, and opens the person's session. */
120
+ async function verifyAuthentication(ceremony, input) {
121
+ const challenge = await takeChallenge(ceremony.db, {
122
+ site: ceremony.site,
123
+ id: input.challengeId,
124
+ purpose: "authenticate",
125
+ now: ceremony.now
126
+ });
127
+ if (challenge === null) throw refused("The sign-in expired.", "Sign in again.");
128
+ const passkey = await findPasskey(ceremony.db, ceremony.site, input.response.id);
129
+ if (passkey === null) throw refused("This passkey is not one of this Hollware.", "Use a passkey you registered here.");
130
+ const rp = relyingParty(ceremony);
131
+ const verified = await verifyAuthenticationResponse({
132
+ response: input.response,
133
+ expectedChallenge: challenge.challenge,
134
+ expectedOrigin: rp.origin,
135
+ expectedRPID: rp.id,
136
+ credential: {
137
+ id: passkey.credentialId,
138
+ publicKey: isoBase64URL.toBuffer(passkey.publicKey),
139
+ counter: passkey.counter,
140
+ transports: JSON.parse(passkey.transports)
141
+ }
142
+ });
143
+ if (!verified.verified) throw refused("The passkey could not be verified.", "Try again.");
144
+ await touchPasskey(ceremony.db, passkey.id, verified.authenticationInfo.newCounter, ceremony.now);
145
+ const session = await createSession(ceremony.db, {
146
+ site: ceremony.site,
147
+ userId: passkey.userId,
148
+ now: ceremony.now,
149
+ hours: SESSION_HOURS
150
+ });
151
+ return {
152
+ userId: passkey.userId,
153
+ session: session.secret
154
+ };
155
+ }
156
+ //#endregion
157
+ export { authenticationOptions, registrationOptions, verifyAuthentication, verifyRegistration };
package/dist/index.d.mts CHANGED
@@ -1,26 +1,6 @@
1
1
  import { AcknowledgedWarning, EntryDocument, EntryLookup, EntryRef, HollwareConfig, OperationByName, OperationName, ResolvedSite, Scope, Verdict, describeSite, getEntry, getTranslations, listEntries } from "@hollware/core";
2
2
  import * as z from "zod";
3
3
  import { WorkerEntrypoint } from "cloudflare:workers";
4
- //#region src/auth/types.d.ts
5
- /**
6
- * Who calls an operation. The gateway turns each request into a principal before any handler
7
- * runs, and the handlers check their scopes against it at call time (ADR-019).
8
- */
9
- type Principal = {
10
- /** A personal token. */
11
- readonly kind: 'token';
12
- readonly userId: string;
13
- readonly tokenId: string;
14
- /** The site the token belongs to; it never reaches another one. */
15
- readonly site: string;
16
- /** The token's own scopes intersected with those of its user's role, as of now. */
17
- readonly scopes: readonly Scope[];
18
- } | {
19
- /** A Worker of the same account, over the RPC binding: it reads published content. */
20
- readonly kind: 'binding';
21
- readonly scopes: readonly ['content:read'];
22
- };
23
- //#endregion
24
4
  //#region src/ports.d.ts
25
5
  /**
26
6
  * The ports of the operation handlers: what they need from the world, as interfaces. The D1
@@ -142,6 +122,50 @@ type WriteRefusal = {
142
122
  readonly bytes: number;
143
123
  readonly limit: number;
144
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
+ }
143
+ /** A scheduled publication, as the cron trigger reads it. */
144
+ interface StoredSchedule {
145
+ readonly entryId: string;
146
+ readonly scheduledAt: string;
147
+ /** The version the entry had once scheduled; another one means it changed since. */
148
+ readonly version: number;
149
+ readonly acknowledged: readonly AcknowledgedWarning[];
150
+ readonly actor: Actor;
151
+ }
152
+ /** One entry of a publication: what `publish` and `publishGroup` write for it. */
153
+ interface PublishWrite {
154
+ readonly entryId: string;
155
+ readonly expectedVersion: number;
156
+ readonly verdict: Verdict;
157
+ readonly acknowledged: readonly AcknowledgedWarning[];
158
+ readonly actor: Actor;
159
+ /** What orders the entry in published listings. */
160
+ readonly sortKey: string;
161
+ /** The event of the publication, written to the outbox in the same batch (ADR-025). */
162
+ readonly event?: OutboxEvent | null;
163
+ }
164
+ /** An event for the outbox: its id and what subscribers get, as JSON. */
165
+ interface OutboxEvent {
166
+ readonly id: string;
167
+ readonly payload: string;
168
+ }
145
169
  interface ContentStore {
146
170
  /** The entry with that collection, locale and slug, in the given view, or `null`. */
147
171
  findEntry(site: string, ref: EntryRef, view: View): Promise<EntryView | null>;
@@ -171,7 +195,8 @@ interface ContentStore {
171
195
  } | WriteRefusal>;
172
196
  /**
173
197
  * Appends a revision and makes it the head, if the entry is still at `expectedVersion`. In
174
- * 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.
175
200
  */
176
201
  appendRevision(site: string, write: {
177
202
  readonly entryId: string;
@@ -184,24 +209,78 @@ interface ContentStore {
184
209
  } | WriteRefusal>;
185
210
  /**
186
211
  * Publishes the head revision, if the entry is still at `expectedVersion`, and appends the
187
- * 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.
214
+ */
215
+ publish(site: string, write: PublishWrite): Promise<{
216
+ readonly outcome: 'published';
217
+ readonly view: EntryView;
218
+ } | WriteRefusal>;
219
+ /**
220
+ * Publishes the head revisions of a group of entries in one batch, all or none (ADR-024): every
221
+ * entry must still be at its `expectedVersion`. A refusal names the entry that caused it.
222
+ */
223
+ publishGroup(site: string, writes: readonly PublishWrite[]): Promise<{
224
+ readonly outcome: 'published';
225
+ readonly views: readonly EntryView[];
226
+ } | (WriteRefusal & {
227
+ readonly entryId: string;
228
+ })>;
229
+ /**
230
+ * Schedules the publication of an entry that is not published, if it is still at
231
+ * `expectedVersion`: the entry becomes `scheduled` at the next version, and its schedule keeps
232
+ * the acknowledged warnings and the actor for the cron trigger (ADR-025).
188
233
  */
189
- publish(site: string, write: {
234
+ schedule(site: string, write: {
190
235
  readonly entryId: string;
191
236
  readonly expectedVersion: number;
192
- readonly verdict: Verdict;
237
+ readonly scheduledAt: string;
193
238
  readonly acknowledged: readonly AcknowledgedWarning[];
194
239
  readonly actor: Actor;
195
- /** What orders the entry in published listings. */
196
- readonly sortKey: string;
197
240
  }): Promise<{
198
- readonly outcome: 'published';
241
+ readonly outcome: 'scheduled';
199
242
  readonly view: EntryView;
200
243
  } | WriteRefusal>;
244
+ /** The schedules whose time has come, at most 50, oldest first. */
245
+ dueSchedules(site: string, now: string): Promise<readonly StoredSchedule[]>;
246
+ /** Takes a scheduled entry back to a draft and drops its schedule. */
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
+ }>;
201
279
  /** Takes the entry off the public site and appends the unpublish. */
202
280
  unpublish(site: string, write: {
203
281
  readonly entryId: string;
204
282
  readonly actor: Actor;
283
+ readonly event?: OutboxEvent | null;
205
284
  }): Promise<{
206
285
  readonly outcome: 'unpublished';
207
286
  readonly view: EntryView;
@@ -212,6 +291,54 @@ interface Clock {
212
291
  now(): Date;
213
292
  }
214
293
  //#endregion
294
+ //#region src/events/run.d.ts
295
+ /** A message of the events Queue: the site and the id of an outbox row. */
296
+ interface EventMessage {
297
+ readonly site: string;
298
+ readonly id: string;
299
+ }
300
+ /** The part of a Queue binding that sends. */
301
+ interface EventQueue {
302
+ sendBatch(messages: Iterable<{
303
+ readonly body: EventMessage;
304
+ }>): Promise<unknown>;
305
+ }
306
+ /** The part of a Queue message the consumer uses; its body is checked before use. */
307
+ interface EventDelivery {
308
+ readonly body: unknown;
309
+ ack(): void;
310
+ retry(): void;
311
+ }
312
+ //#endregion
313
+ //#region src/auth/types.d.ts
314
+ /**
315
+ * Who calls an operation. The gateway turns each request into a principal before any handler
316
+ * runs, and the handlers check their scopes against it at call time (ADR-019).
317
+ */
318
+ type Principal = {
319
+ /** A personal token. */
320
+ readonly kind: 'token';
321
+ readonly userId: string;
322
+ readonly tokenId: string;
323
+ /** The site the token belongs to; it never reaches another one. */
324
+ readonly site: string;
325
+ /** The token's own scopes intersected with those of its user's role, as of now. */
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
+ /** A Worker of the same account, over the RPC binding: it reads published content. */
338
+ readonly kind: 'binding';
339
+ readonly scopes: readonly ['content:read'];
340
+ };
341
+ //#endregion
215
342
  //#region src/ops/types.d.ts
216
343
  /**
217
344
  * The handlers of the operations (ADR-017): one per spec of `@hollware/core`, paired by type,
@@ -228,6 +355,11 @@ interface OperationContext {
228
355
  /** What the client said about itself; writes record it as the actor's meta. */
229
356
  readonly client: Readonly<Record<string, unknown>> | null;
230
357
  readonly requestId: string;
358
+ /**
359
+ * Called with the ids of the events that a write committed to the outbox, so that they reach
360
+ * the Queue now rather than at the next sweep of the cron trigger (ADR-025). Absent in tests.
361
+ */
362
+ readonly notify?: (eventIds: readonly string[]) => void;
231
363
  }
232
364
  /** The handler of one operation: its parsed input in, its output out. */
233
365
  type OperationHandler<Name extends OperationName> = (input: z.output<OperationByName<Name>['input']>, context: OperationContext) => Promise<z.output<OperationByName<Name>['output']>>;
@@ -266,6 +398,15 @@ export interface HollwareEnv {
266
398
  * Origin the Worker answers; without it, every request fails.
267
399
  */
268
400
  readonly HOLLWARE_PUBLIC_URL: string;
401
+ /**
402
+ * The KV namespace of the OAuth server (ADR-026): its clients, grants and tokens, all by hash.
403
+ * Personal tokens work without it; OAuth does not.
404
+ */
405
+ readonly OAUTH_KV: KVNamespace;
406
+ /** The static assets of the Worker, which hold the admin at `/admin/`; without them, no admin. */
407
+ readonly ASSETS?: Fetcher;
408
+ /** The Queue of publication events (ADR-025); without it, the cron trigger delivers them. */
409
+ readonly EVENTS?: EventQueue;
269
410
  }
270
411
  type Output<Spec extends {
271
412
  output: z.ZodType;
@@ -294,6 +435,12 @@ export interface HollwareRpc {
294
435
  export interface HollwareWorker {
295
436
  /** MCP at `/mcp`, and the other routes of the gateway. */
296
437
  readonly fetch: (request: Request, env: HollwareEnv, ctx: ExecutionContext) => Promise<Response>;
438
+ /** The consumer of the events Queue (`queues.consumers` in wrangler.jsonc). */
439
+ readonly queue: (batch: {
440
+ readonly messages: readonly EventDelivery[];
441
+ }, env: HollwareEnv, ctx: ExecutionContext) => Promise<void>;
442
+ /** The cron trigger of the project (`triggers.crons` in wrangler.jsonc, every minute). */
443
+ readonly scheduled: (controller: ScheduledController, env: HollwareEnv, ctx: ExecutionContext) => Promise<void>;
297
444
  /** The RPC entrypoint, for the user's Worker to export under the name `Hollware`. */
298
445
  readonly Hollware: new (ctx: ExecutionContext, env: HollwareEnv) => WorkerEntrypoint<HollwareEnv> & HollwareRpc;
299
446
  }
@@ -306,4 +453,4 @@ export type CreateHollware = (config: HollwareConfig) => HollwareWorker;
306
453
  */
307
454
  export declare const createHollware: CreateHollware;
308
455
  //#endregion
309
- export type { Actor, Clock, ContentStore, EntrySummaryView, EntryView, Handlers, ListQuery, NewRevision, OperationContext, OperationHandler, Principal, RevisionSummary, StoredEntry, StoredPublication, StoredRevision, View, WriteRefusal };
456
+ 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 };