@hollware/server 0.3.0 → 0.5.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-BM9G03lZ.mjs} +9 -16
- package/dist/index.d.mts +151 -2
- package/dist/index.mjs +1142 -193
- package/dist/{store-Sq0pwUQv.mjs → store-DiS8UFI2.mjs} +85 -54
- package/migrations/0006_people_across_sites.sql +54 -0
- package/migrations/0007_read_tokens.sql +4 -0
- package/migrations/0008_search.sql +47 -0
- package/migrations/0009_embeddings.sql +19 -0
- package/migrations/meta/0006_snapshot.json +2102 -0
- package/migrations/meta/0007_snapshot.json +2110 -0
- package/migrations/meta/0008_snapshot.json +2110 -0
- package/migrations/meta/0009_snapshot.json +2257 -0
- package/migrations/meta/_journal.json +28 -0
- package/package.json +2 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as findInvitation, c as invitationById, d as
|
|
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-DiS8UFI2.mjs";
|
|
2
2
|
import { HollwareError, newId } from "@hollware/core";
|
|
3
3
|
import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse } from "@simplewebauthn/server";
|
|
4
4
|
import { isoBase64URL } from "@simplewebauthn/server/helpers";
|
|
@@ -17,10 +17,10 @@ function refused(message, fix) {
|
|
|
17
17
|
}
|
|
18
18
|
/** The options of `navigator.credentials.create` for the person of an invitation. */
|
|
19
19
|
async function registrationOptions(ceremony, invitationSecret) {
|
|
20
|
-
const invitation = await findInvitation(ceremony.db,
|
|
20
|
+
const invitation = await findInvitation(ceremony.db, invitationSecret, ceremony.now);
|
|
21
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
22
|
const userId = invitation.userId ?? newId("usr");
|
|
23
|
-
const existing = invitation.userId === null ? [] : await passkeysOf(ceremony.db,
|
|
23
|
+
const existing = invitation.userId === null ? [] : await passkeysOf(ceremony.db, invitation.userId);
|
|
24
24
|
const rp = relyingParty(ceremony);
|
|
25
25
|
const options = await generateRegistrationOptions({
|
|
26
26
|
rpName: "Hollware",
|
|
@@ -36,7 +36,6 @@ async function registrationOptions(ceremony, invitationSecret) {
|
|
|
36
36
|
});
|
|
37
37
|
return {
|
|
38
38
|
challengeId: await saveChallenge(ceremony.db, {
|
|
39
|
-
site: ceremony.site,
|
|
40
39
|
challenge: options.challenge,
|
|
41
40
|
purpose: "register",
|
|
42
41
|
invitationId: invitation.id,
|
|
@@ -52,7 +51,6 @@ async function registrationOptions(ceremony, invitationSecret) {
|
|
|
52
51
|
*/
|
|
53
52
|
async function verifyRegistration(ceremony, input) {
|
|
54
53
|
const challenge = await takeChallenge(ceremony.db, {
|
|
55
|
-
site: ceremony.site,
|
|
56
54
|
id: input.challengeId,
|
|
57
55
|
purpose: "register",
|
|
58
56
|
now: ceremony.now
|
|
@@ -66,18 +64,17 @@ async function verifyRegistration(ceremony, input) {
|
|
|
66
64
|
expectedRPID: rp.id
|
|
67
65
|
});
|
|
68
66
|
if (!verified.verified) throw refused("The passkey could not be verified.", "Try again.");
|
|
69
|
-
const invitation = await invitationById(ceremony.db,
|
|
70
|
-
if (invitation === null || !await redeemInvitation(ceremony.db,
|
|
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.");
|
|
71
69
|
if (invitation.userId === null) await createUser(ceremony.db, {
|
|
72
70
|
id: challenge.userId,
|
|
73
71
|
name: input.name.trim() === "" ? "Unnamed" : input.name.trim(),
|
|
74
72
|
role: invitation.role,
|
|
75
|
-
site:
|
|
73
|
+
site: invitation.site,
|
|
76
74
|
now: ceremony.now
|
|
77
75
|
});
|
|
78
76
|
const { credential } = verified.registrationInfo;
|
|
79
77
|
await addPasskey(ceremony.db, {
|
|
80
|
-
site: ceremony.site,
|
|
81
78
|
userId: challenge.userId,
|
|
82
79
|
credentialId: credential.id,
|
|
83
80
|
publicKey: isoBase64URL.fromBuffer(credential.publicKey),
|
|
@@ -87,7 +84,6 @@ async function verifyRegistration(ceremony, input) {
|
|
|
87
84
|
now: ceremony.now
|
|
88
85
|
});
|
|
89
86
|
const session = await createSession(ceremony.db, {
|
|
90
|
-
site: ceremony.site,
|
|
91
87
|
userId: challenge.userId,
|
|
92
88
|
now: ceremony.now,
|
|
93
89
|
hours: SESSION_HOURS
|
|
@@ -97,7 +93,7 @@ async function verifyRegistration(ceremony, input) {
|
|
|
97
93
|
session: session.secret
|
|
98
94
|
};
|
|
99
95
|
}
|
|
100
|
-
/** The options of `navigator.credentials.get`: any passkey of this
|
|
96
|
+
/** The options of `navigator.credentials.get`: any passkey of this deployment, chosen by the person. */
|
|
101
97
|
async function authenticationOptions(ceremony) {
|
|
102
98
|
const rp = relyingParty(ceremony);
|
|
103
99
|
const options = await generateAuthenticationOptions({
|
|
@@ -106,7 +102,6 @@ async function authenticationOptions(ceremony) {
|
|
|
106
102
|
});
|
|
107
103
|
return {
|
|
108
104
|
challengeId: await saveChallenge(ceremony.db, {
|
|
109
|
-
site: ceremony.site,
|
|
110
105
|
challenge: options.challenge,
|
|
111
106
|
purpose: "authenticate",
|
|
112
107
|
invitationId: null,
|
|
@@ -116,16 +111,15 @@ async function authenticationOptions(ceremony) {
|
|
|
116
111
|
options
|
|
117
112
|
};
|
|
118
113
|
}
|
|
119
|
-
/** Verifies a sign-in with a passkey of this
|
|
114
|
+
/** Verifies a sign-in with a passkey of this deployment, and opens the person's session. */
|
|
120
115
|
async function verifyAuthentication(ceremony, input) {
|
|
121
116
|
const challenge = await takeChallenge(ceremony.db, {
|
|
122
|
-
site: ceremony.site,
|
|
123
117
|
id: input.challengeId,
|
|
124
118
|
purpose: "authenticate",
|
|
125
119
|
now: ceremony.now
|
|
126
120
|
});
|
|
127
121
|
if (challenge === null) throw refused("The sign-in expired.", "Sign in again.");
|
|
128
|
-
const passkey = await findPasskey(ceremony.db,
|
|
122
|
+
const passkey = await findPasskey(ceremony.db, input.response.id);
|
|
129
123
|
if (passkey === null) throw refused("This passkey is not one of this Hollware.", "Use a passkey you registered here.");
|
|
130
124
|
const rp = relyingParty(ceremony);
|
|
131
125
|
const verified = await verifyAuthenticationResponse({
|
|
@@ -143,7 +137,6 @@ async function verifyAuthentication(ceremony, input) {
|
|
|
143
137
|
if (!verified.verified) throw refused("The passkey could not be verified.", "Try again.");
|
|
144
138
|
await touchPasskey(ceremony.db, passkey.id, verified.authenticationInfo.newCounter, ceremony.now);
|
|
145
139
|
const session = await createSession(ceremony.db, {
|
|
146
|
-
site: ceremony.site,
|
|
147
140
|
userId: passkey.userId,
|
|
148
141
|
now: ceremony.now,
|
|
149
142
|
hours: SESSION_HOURS
|
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AcknowledgedWarning, EntryDocument, EntryLookup, EntryRef, HollwareConfig, OperationByName, OperationName, ResolvedSite, Scope, Verdict, describeSite, getEntry, getTranslations, listEntries } from "@hollware/core";
|
|
1
|
+
import { AcknowledgedWarning, EntryDocument, EntryLookup, EntryRef, HollwareConfig, OperationByName, OperationName, ResolvedSite, Scope, Verdict, describeSite, getEntry, getTranslations, listEntries, search } from "@hollware/core";
|
|
2
2
|
import * as z from "zod";
|
|
3
3
|
import { WorkerEntrypoint } from "cloudflare:workers";
|
|
4
4
|
//#region src/ports.d.ts
|
|
@@ -102,6 +102,27 @@ interface ListQuery {
|
|
|
102
102
|
/** The `nextCursor` of the previous page. */
|
|
103
103
|
readonly cursor?: string;
|
|
104
104
|
}
|
|
105
|
+
/** A full-text search of the published entries of a site. */
|
|
106
|
+
interface SearchQuery {
|
|
107
|
+
/** What the person or the agent typed: its words are matched, its punctuation is not. */
|
|
108
|
+
readonly text: string;
|
|
109
|
+
readonly collection?: string;
|
|
110
|
+
readonly locale?: string;
|
|
111
|
+
readonly limit: number;
|
|
112
|
+
}
|
|
113
|
+
/** An entry that a search found, best first. */
|
|
114
|
+
interface SearchHit {
|
|
115
|
+
readonly entry: EntrySummaryView;
|
|
116
|
+
/** The words around the match in the text, the match between `[` and `]`. */
|
|
117
|
+
readonly snippet: string;
|
|
118
|
+
/** How well it matches: higher is better. */
|
|
119
|
+
readonly score: number;
|
|
120
|
+
}
|
|
121
|
+
/** An entry close to another by meaning, and how close: higher is closer. */
|
|
122
|
+
interface RelatedEntry {
|
|
123
|
+
readonly id: string;
|
|
124
|
+
readonly score: number;
|
|
125
|
+
}
|
|
105
126
|
/** The answer to a write that the store could not make. */
|
|
106
127
|
type WriteRefusal = {
|
|
107
128
|
readonly outcome: 'conflict';
|
|
@@ -179,6 +200,15 @@ interface ContentStore {
|
|
|
179
200
|
readonly entries: readonly EntrySummaryView[];
|
|
180
201
|
readonly nextCursor: string | null;
|
|
181
202
|
}>;
|
|
203
|
+
/**
|
|
204
|
+
* The published entries whose title, tags or text hold every word of the query, best first:
|
|
205
|
+
* the title weighs most, then the tags, then the text (bm25).
|
|
206
|
+
*/
|
|
207
|
+
searchEntries(site: string, query: SearchQuery): Promise<readonly SearchHit[]>;
|
|
208
|
+
/** The entries of those ids that the view has, in the order of the ids. */
|
|
209
|
+
findSummaries(site: string, ids: readonly string[], view: View): Promise<readonly EntrySummaryView[]>;
|
|
210
|
+
/** The entries closest to one by meaning, closest first (ADR-030); `[]` when it has no vectors. */
|
|
211
|
+
relatedOf(site: string, entryId: string): Promise<readonly RelatedEntry[]>;
|
|
182
212
|
/** The entries of a translation group, one per locale, in the given view. */
|
|
183
213
|
listTranslations(site: string, collection: string, translationGroup: string, view: View): Promise<readonly EntrySummaryView[]>;
|
|
184
214
|
/** What cross-entry contracts read, scoped to the site. */
|
|
@@ -290,6 +320,83 @@ interface ContentStore {
|
|
|
290
320
|
interface Clock {
|
|
291
321
|
now(): Date;
|
|
292
322
|
}
|
|
323
|
+
/** Turns texts into vectors, all of the same length. */
|
|
324
|
+
interface Embedder {
|
|
325
|
+
/** The model, which the stored vectors name: another model makes them stale. */
|
|
326
|
+
readonly model: string;
|
|
327
|
+
embed(texts: readonly string[]): Promise<number[][]>;
|
|
328
|
+
}
|
|
329
|
+
/** What a vector says about where it comes from, to filter and to read a match back. */
|
|
330
|
+
interface VectorMetadata {
|
|
331
|
+
readonly site: string;
|
|
332
|
+
readonly entry: string;
|
|
333
|
+
readonly collection: string;
|
|
334
|
+
readonly locale: string;
|
|
335
|
+
/** The piece of the entry: 0 for its summary. */
|
|
336
|
+
readonly piece: number;
|
|
337
|
+
/** The start of the piece's text, for the snippet of a match. */
|
|
338
|
+
readonly excerpt: string;
|
|
339
|
+
}
|
|
340
|
+
interface StoredVector {
|
|
341
|
+
/** `<entry id>:<piece>`. */
|
|
342
|
+
readonly id: string;
|
|
343
|
+
readonly values: readonly number[];
|
|
344
|
+
readonly metadata: VectorMetadata;
|
|
345
|
+
}
|
|
346
|
+
interface VectorMatch {
|
|
347
|
+
readonly id: string;
|
|
348
|
+
/** How close: higher is closer. */
|
|
349
|
+
readonly score: number;
|
|
350
|
+
readonly metadata: VectorMetadata;
|
|
351
|
+
}
|
|
352
|
+
interface VectorIndex {
|
|
353
|
+
upsert(vectors: readonly StoredVector[]): Promise<void>;
|
|
354
|
+
remove(ids: readonly string[]): Promise<void>;
|
|
355
|
+
/** The stored values of those vectors; an id the index does not have is left out. */
|
|
356
|
+
get(ids: readonly string[]): Promise<readonly {
|
|
357
|
+
readonly id: string;
|
|
358
|
+
readonly values: readonly number[];
|
|
359
|
+
}[]>;
|
|
360
|
+
/** The `topK` vectors nearest to `values` among those of the site, narrowed as asked. */
|
|
361
|
+
query(values: readonly number[], options: {
|
|
362
|
+
readonly topK: number;
|
|
363
|
+
readonly site: string;
|
|
364
|
+
readonly collection?: string;
|
|
365
|
+
readonly locale?: string;
|
|
366
|
+
/** Only the summaries, one vector per entry. */
|
|
367
|
+
readonly summariesOnly?: boolean;
|
|
368
|
+
}): Promise<readonly VectorMatch[]>;
|
|
369
|
+
}
|
|
370
|
+
/** What semantic search needs: both services, or the deployment has none. */
|
|
371
|
+
interface Semantic {
|
|
372
|
+
readonly embedder: Embedder;
|
|
373
|
+
readonly index: VectorIndex;
|
|
374
|
+
}
|
|
375
|
+
/** A page view, as a site's beacon reports it. */
|
|
376
|
+
interface PageView {
|
|
377
|
+
readonly site: string;
|
|
378
|
+
/** The path of the page, such as `/blog/edge-caching/`. */
|
|
379
|
+
readonly path: string;
|
|
380
|
+
/** The two letters of the reader's country, or `XX` when it is not known. */
|
|
381
|
+
readonly country: string;
|
|
382
|
+
}
|
|
383
|
+
/** Where page views are counted. */
|
|
384
|
+
interface AnalyticsWriter {
|
|
385
|
+
record(view: PageView): void;
|
|
386
|
+
}
|
|
387
|
+
/** A page and how often it was read. */
|
|
388
|
+
interface PageCount {
|
|
389
|
+
readonly path: string;
|
|
390
|
+
readonly views: number;
|
|
391
|
+
}
|
|
392
|
+
/** Reads the counts back. */
|
|
393
|
+
interface AnalyticsReader {
|
|
394
|
+
/** The `limit` most read paths of the site in the last `days`, most read first. */
|
|
395
|
+
topPages(site: string, query: {
|
|
396
|
+
readonly days: number;
|
|
397
|
+
readonly limit: number;
|
|
398
|
+
}): Promise<readonly PageCount[]>;
|
|
399
|
+
}
|
|
293
400
|
//#endregion
|
|
294
401
|
//#region src/events/run.d.ts
|
|
295
402
|
/** A message of the events Queue: the site and the id of an outbox row. */
|
|
@@ -333,6 +440,17 @@ type Principal = {
|
|
|
333
440
|
readonly site: string;
|
|
334
441
|
/** The scopes of the grant intersected with those of the user's role, as of now. */
|
|
335
442
|
readonly scopes: readonly Scope[];
|
|
443
|
+
} | {
|
|
444
|
+
/**
|
|
445
|
+
* A read token (`hw_read_`): it reads the published content of its site over the read API,
|
|
446
|
+
* and the gateway turns it away from every other route.
|
|
447
|
+
*/
|
|
448
|
+
readonly kind: 'read';
|
|
449
|
+
readonly userId: string;
|
|
450
|
+
readonly tokenId: string;
|
|
451
|
+
readonly site: string;
|
|
452
|
+
/** `content:read` while its user's role has it, else nothing. */
|
|
453
|
+
readonly scopes: readonly Scope[];
|
|
336
454
|
} | {
|
|
337
455
|
/** A Worker of the same account, over the RPC binding: it reads published content. */
|
|
338
456
|
readonly kind: 'binding';
|
|
@@ -360,6 +478,10 @@ interface OperationContext {
|
|
|
360
478
|
* the Queue now rather than at the next sweep of the cron trigger (ADR-025). Absent in tests.
|
|
361
479
|
*/
|
|
362
480
|
readonly notify?: (eventIds: readonly string[]) => void;
|
|
481
|
+
/** The model and the vector index, when the Worker has both (ADR-030); absent or `null` without. */
|
|
482
|
+
readonly semantic?: Semantic | null;
|
|
483
|
+
/** The counts of page views, when the deployment can read them (ADR-031); absent or `null` without. */
|
|
484
|
+
readonly analytics?: AnalyticsReader | null;
|
|
363
485
|
}
|
|
364
486
|
/** The handler of one operation: its parsed input in, its output out. */
|
|
365
487
|
type OperationHandler<Name extends OperationName> = (input: z.output<OperationByName<Name>['input']>, context: OperationContext) => Promise<z.output<OperationByName<Name>['output']>>;
|
|
@@ -407,6 +529,24 @@ export interface HollwareEnv {
|
|
|
407
529
|
readonly ASSETS?: Fetcher;
|
|
408
530
|
/** The Queue of publication events (ADR-025); without it, the cron trigger delivers them. */
|
|
409
531
|
readonly EVENTS?: EventQueue;
|
|
532
|
+
/**
|
|
533
|
+
* Workers AI and a Vectorize index (v0.5): with both, every published entry is embedded and
|
|
534
|
+
* `search` can be semantic; without either, search is full text only.
|
|
535
|
+
*/
|
|
536
|
+
readonly AI?: Ai;
|
|
537
|
+
/** `wrangler types` names a Vectorize binding `VectorizeIndex`; either works. */
|
|
538
|
+
readonly VECTORIZE?: Vectorize | VectorizeIndex;
|
|
539
|
+
/** The embedding model of Workers AI, `@cf/baai/bge-m3` when not set; its index must match it. */
|
|
540
|
+
readonly HOLLWARE_EMBEDDING_MODEL?: string;
|
|
541
|
+
/**
|
|
542
|
+
* Analytics (ADR-031): the Analytics Engine dataset that the beacon of the sites writes to, and
|
|
543
|
+
* what `content_stats` reads it with: the account, an API token with Account Analytics Read
|
|
544
|
+
* (a secret), and the dataset's name when it is not `hollware_page_views`.
|
|
545
|
+
*/
|
|
546
|
+
readonly ANALYTICS?: AnalyticsEngineDataset;
|
|
547
|
+
readonly HOLLWARE_ANALYTICS_ACCOUNT_ID?: string;
|
|
548
|
+
readonly HOLLWARE_ANALYTICS_TOKEN?: string;
|
|
549
|
+
readonly HOLLWARE_ANALYTICS_DATASET?: string;
|
|
410
550
|
}
|
|
411
551
|
type Output<Spec extends {
|
|
412
552
|
output: z.ZodType;
|
|
@@ -418,6 +558,8 @@ type Output<Spec extends {
|
|
|
418
558
|
export interface HollwareRpc {
|
|
419
559
|
getEntry(site: string, collection: string, slug: string, locale: string, options?: {
|
|
420
560
|
readonly formats?: readonly ('markdown' | 'document' | 'html')[];
|
|
561
|
+
/** Also the published entries closest by meaning (ADR-030). */
|
|
562
|
+
readonly related?: boolean;
|
|
421
563
|
}): Promise<Output<typeof getEntry>>;
|
|
422
564
|
listEntries(site: string, collection: string, options?: {
|
|
423
565
|
readonly locale?: string;
|
|
@@ -427,6 +569,13 @@ export interface HollwareRpc {
|
|
|
427
569
|
}): Promise<Output<typeof listEntries>>;
|
|
428
570
|
getTranslations(site: string, collection: string, translationGroup: string): Promise<Output<typeof getTranslations>>;
|
|
429
571
|
describeSite(site: string): Promise<Output<typeof describeSite>>;
|
|
572
|
+
/** The published entries that match `query`, best first: by its words, by meaning, or both. */
|
|
573
|
+
search(site: string, query: string, options?: {
|
|
574
|
+
readonly collection?: string;
|
|
575
|
+
readonly locale?: string;
|
|
576
|
+
readonly limit?: number;
|
|
577
|
+
readonly mode?: 'fulltext' | 'semantic' | 'hybrid';
|
|
578
|
+
}): Promise<Output<typeof search>>;
|
|
430
579
|
}
|
|
431
580
|
/**
|
|
432
581
|
* What `createHollware(config)` returns. The user's Worker is
|
|
@@ -453,4 +602,4 @@ export type CreateHollware = (config: HollwareConfig) => HollwareWorker;
|
|
|
453
602
|
*/
|
|
454
603
|
export declare const createHollware: CreateHollware;
|
|
455
604
|
//#endregion
|
|
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 };
|
|
605
|
+
export type { Actor, AnalyticsReader, AnalyticsWriter, Clock, ContentStore, Embedder, EntrySummaryView, EntryView, EventMessage, Handlers, ListQuery, NewRevision, OperationContext, OperationHandler, OutboxEvent, PageCount, PageView, Principal, PublishWrite, RelatedEntry, ReviewStatus, RevisionSummary, SearchHit, SearchQuery, Semantic, StoredEntry, StoredPublication, StoredReview, StoredRevision, StoredSchedule, StoredVector, VectorIndex, VectorMatch, VectorMetadata, View, WriteRefusal };
|