wikimemory 0.2.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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +111 -0
  3. package/dist/npm-cli/scripts/cli-options.js +29 -0
  4. package/dist/npm-cli/scripts/cli.js +79 -0
  5. package/dist/npm-cli/scripts/client-tools.js +57 -0
  6. package/dist/npm-cli/scripts/deployment-record.js +72 -0
  7. package/dist/npm-cli/scripts/dev.js +49 -0
  8. package/dist/npm-cli/scripts/lifecycle-runtime.js +38 -0
  9. package/dist/npm-cli/scripts/package-root.js +15 -0
  10. package/dist/npm-cli/scripts/passkeys.js +197 -0
  11. package/dist/npm-cli/scripts/setup.js +523 -0
  12. package/dist/npm-cli/scripts/status.js +49 -0
  13. package/dist/npm-cli/scripts/uninstall.js +220 -0
  14. package/dist/npm-cli/scripts/upgrade.js +301 -0
  15. package/dist/npm-cli/src/version.js +2 -0
  16. package/dist/web/assets/index-CjnFBnXp.css +1 -0
  17. package/dist/web/assets/index-buhGyrxi.js +72 -0
  18. package/dist/web/index.html +14 -0
  19. package/migrations/0001_initial.sql +297 -0
  20. package/migrations/0002_passkeys.sql +30 -0
  21. package/migrations/0003_passkey_management.sql +38 -0
  22. package/migrations/0004_credential_bound_registration_tokens.sql +18 -0
  23. package/package.json +89 -0
  24. package/release-manifest.json +22 -0
  25. package/skills/wikimemory-ingest/SKILL.md +24 -0
  26. package/skills/wikimemory-ingest/agents/openai.yaml +4 -0
  27. package/skills/wikimemory-install/SKILL.md +81 -0
  28. package/skills/wikimemory-install/agents/openai.yaml +4 -0
  29. package/skills/wikimemory-lint/SKILL.md +18 -0
  30. package/skills/wikimemory-lint/agents/openai.yaml +4 -0
  31. package/skills/wikimemory-recall/SKILL.md +20 -0
  32. package/skills/wikimemory-recall/agents/openai.yaml +4 -0
  33. package/src/auth/local.ts +131 -0
  34. package/src/auth/passkey-api.ts +100 -0
  35. package/src/auth/passkey-management.ts +150 -0
  36. package/src/auth/passkey.ts +908 -0
  37. package/src/auth/props.ts +96 -0
  38. package/src/auth/resource.ts +36 -0
  39. package/src/domain/crypto.ts +27 -0
  40. package/src/domain/errors.ts +32 -0
  41. package/src/domain/export-service.ts +363 -0
  42. package/src/domain/guards.ts +9 -0
  43. package/src/domain/memory-service.ts +1092 -0
  44. package/src/domain/secret-scanner.ts +45 -0
  45. package/src/domain/text-chunk.ts +24 -0
  46. package/src/domain/types.ts +169 -0
  47. package/src/env.ts +20 -0
  48. package/src/index.ts +138 -0
  49. package/src/mcp/schemas.ts +117 -0
  50. package/src/mcp/server.ts +506 -0
  51. package/src/version.ts +2 -0
  52. package/src/web/app.ts +299 -0
@@ -0,0 +1,908 @@
1
+ import type { AuthRequest } from "@cloudflare/workers-oauth-provider";
2
+ import { OAuthError } from "@cloudflare/workers-oauth-provider";
3
+ import type {
4
+ AuthenticationResponseJSON,
5
+ AuthenticatorTransportFuture,
6
+ RegistrationResponseJSON,
7
+ VerifiedAuthenticationResponse,
8
+ VerifiedRegistrationResponse,
9
+ WebAuthnCredential
10
+ } from "@simplewebauthn/server";
11
+ import {
12
+ generateAuthenticationOptions,
13
+ generateRegistrationOptions,
14
+ verifyAuthenticationResponse,
15
+ verifyRegistrationResponse
16
+ } from "@simplewebauthn/server";
17
+ import { z } from "zod";
18
+ import { sha256 } from "../domain/crypto";
19
+ import { DomainError } from "../domain/errors";
20
+ import { isMemoryScope } from "../domain/guards";
21
+ import { MemoryService } from "../domain/memory-service";
22
+ import type { ActorContext, MemoryScope, OwnerContext } from "../domain/types";
23
+ import type { Env } from "../env";
24
+ import { PASSKEY_OWNER_ID, registrationToken } from "./passkey-management";
25
+ import { bindAuthorizationResource } from "./resource";
26
+
27
+ const PRINCIPAL_ID = PASSKEY_OWNER_ID;
28
+ const WORKSPACE_ID = "primary-workspace";
29
+ const SESSION_PREFIX = "web-session:";
30
+ const FLOW_TTL_SECONDS = 300;
31
+ const BASE64URL = /^[A-Za-z0-9_-]+$/;
32
+ const TRANSPORT_SCHEMA = z.enum(["ble", "cable", "hybrid", "internal", "nfc", "smart-card", "usb"]);
33
+ const ATTACHMENT_SCHEMA = z.enum(["cross-platform", "platform"]);
34
+ const AUTH_REQUEST_SCHEMA = z.object({
35
+ responseType: z.string(),
36
+ clientId: z.string(),
37
+ redirectUri: z.string(),
38
+ scope: z.array(z.string()),
39
+ state: z.string(),
40
+ codeChallenge: z.string().optional(),
41
+ codeChallengeMethod: z.string().optional(),
42
+ resource: z.union([z.string(), z.array(z.string())]).optional()
43
+ });
44
+ const REGISTRATION_RESPONSE_SCHEMA = z.object({
45
+ id: z.string().regex(BASE64URL),
46
+ rawId: z.string().regex(BASE64URL),
47
+ response: z.object({
48
+ clientDataJSON: z.string().regex(BASE64URL),
49
+ attestationObject: z.string().regex(BASE64URL),
50
+ authenticatorData: z.string().regex(BASE64URL).optional(),
51
+ transports: z.array(TRANSPORT_SCHEMA).optional(),
52
+ publicKeyAlgorithm: z.number().int().optional(),
53
+ publicKey: z.string().regex(BASE64URL).optional()
54
+ }),
55
+ authenticatorAttachment: ATTACHMENT_SCHEMA.optional(),
56
+ clientExtensionResults: z.record(z.string(), z.unknown()),
57
+ type: z.literal("public-key")
58
+ });
59
+ const AUTHENTICATION_RESPONSE_SCHEMA = z.object({
60
+ id: z.string().regex(BASE64URL),
61
+ rawId: z.string().regex(BASE64URL),
62
+ response: z.object({
63
+ clientDataJSON: z.string().regex(BASE64URL),
64
+ authenticatorData: z.string().regex(BASE64URL),
65
+ signature: z.string().regex(BASE64URL),
66
+ userHandle: z.string().regex(BASE64URL).optional()
67
+ }),
68
+ authenticatorAttachment: ATTACHMENT_SCHEMA.optional(),
69
+ clientExtensionResults: z.record(z.string(), z.unknown()),
70
+ type: z.literal("public-key")
71
+ });
72
+ const PASSKEY_LABEL_SCHEMA = z.string().trim().min(1).max(80);
73
+ const SETUP_REQUEST_SCHEMA = z.object({
74
+ token: z.string().min(32).max(512),
75
+ label: PASSKEY_LABEL_SCHEMA
76
+ });
77
+ const SETUP_VERIFY_SCHEMA = z.object({ flowId: z.uuid(), response: REGISTRATION_RESPONSE_SCHEMA });
78
+ const REGISTRATION_REQUEST_SCHEMA = z.object({ token: z.string().min(32).max(512) });
79
+ const AUTH_VERIFY_SCHEMA = z.object({ flowId: z.uuid(), response: AUTHENTICATION_RESPONSE_SCHEMA });
80
+ const TRANSPORTS_SCHEMA = z.array(TRANSPORT_SCHEMA);
81
+ const SETUP_PAYLOAD_SCHEMA = z.object({
82
+ mode: z.enum(["initial", "recovery"]),
83
+ label: PASSKEY_LABEL_SCHEMA
84
+ });
85
+ const REGISTRATION_PAYLOAD_SCHEMA = z.object({ label: PASSKEY_LABEL_SCHEMA });
86
+ const AUTH_FLOW_PAYLOAD_SCHEMA = z.object({
87
+ kind: z.enum(["mcp", "web"]),
88
+ options: z.unknown(),
89
+ auth: AUTH_REQUEST_SCHEMA.optional(),
90
+ clientName: z.string().optional(),
91
+ requestedScopes: z.array(z.enum(["memory:read", "memory:write", "memory:admin"])).optional()
92
+ });
93
+
94
+ interface CredentialRow {
95
+ credential_id: string;
96
+ label: string;
97
+ public_key: string;
98
+ counter: number;
99
+ transports_json: string;
100
+ device_type: "singleDevice" | "multiDevice";
101
+ backed_up: number;
102
+ }
103
+
104
+ interface SessionRecord {
105
+ principalId: string;
106
+ workspaceId: string;
107
+ credentialId: string;
108
+ authenticatedAt: string;
109
+ createdAt: string;
110
+ }
111
+
112
+ interface ChallengeRow {
113
+ challenge: string;
114
+ payload_json: string | null;
115
+ token_hash: string | null;
116
+ }
117
+
118
+ export interface WebSessionSummary {
119
+ sessionRef: string;
120
+ authenticatedAt: string;
121
+ createdAt: string;
122
+ current: boolean;
123
+ }
124
+
125
+ type RelyingPartyEnv = Pick<Env, "APP_BASE_URL">;
126
+ type RegistrationEnv = Pick<Env, "DB" | "APP_BASE_URL" | "SETUP_TOKEN_HASH">;
127
+ type DatabaseEnv = Pick<Env, "DB">;
128
+ type RegistrationVerifier = (
129
+ options: Parameters<typeof verifyRegistrationResponse>[0]
130
+ ) => Promise<VerifiedRegistrationResponse>;
131
+ type AuthenticationVerifier = (
132
+ options: Parameters<typeof verifyAuthenticationResponse>[0]
133
+ ) => Promise<VerifiedAuthenticationResponse>;
134
+ type RecoveryStateRevoker = (env: Env) => Promise<void>;
135
+
136
+ async function revokeRecoveryState(env: Env): Promise<void> {
137
+ await revokeAllUserGrants(env);
138
+ await revokeAllProductionWebSessions(env);
139
+ }
140
+
141
+ function relyingParty(env: RelyingPartyEnv): { origin: string; rpID: string } {
142
+ if (env.APP_BASE_URL === undefined) throw new Error("APP_BASE_URL is required");
143
+ const url = new URL(env.APP_BASE_URL);
144
+ return { origin: url.origin, rpID: url.hostname };
145
+ }
146
+
147
+ function mcpResource(env: Env): string {
148
+ return new URL("/mcp", relyingParty(env).origin).toString();
149
+ }
150
+
151
+ function base64UrlEncode(bytes: Uint8Array): string {
152
+ let binary = "";
153
+ for (const byte of bytes) binary += String.fromCharCode(byte);
154
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, "");
155
+ }
156
+
157
+ function base64UrlDecode(value: string): Uint8Array<ArrayBuffer> {
158
+ if (!BASE64URL.test(value))
159
+ throw new DomainError("validation_failed", "Invalid passkey encoding");
160
+ const normalized = value.replaceAll("-", "+").replaceAll("_", "/");
161
+ const binary = atob(normalized + "=".repeat((4 - (normalized.length % 4)) % 4));
162
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
163
+ }
164
+
165
+ function scopes(auth: AuthRequest): MemoryScope[] {
166
+ const requested = auth.scope.length === 0 ? ["memory:read", "memory:write"] : auth.scope;
167
+ if (!requested.every((scope): scope is MemoryScope => isMemoryScope(scope))) {
168
+ throw new OAuthError("invalid_scope", { description: "Unsupported Wikimemory scope" });
169
+ }
170
+ return requested;
171
+ }
172
+
173
+ function actor(clientId = "wikimemory-web"): ActorContext {
174
+ return {
175
+ workspaceId: WORKSPACE_ID,
176
+ principalId: PRINCIPAL_ID,
177
+ clientId,
178
+ scopes: new Set(["memory:read", "memory:write", "memory:admin"]),
179
+ requestId: crypto.randomUUID()
180
+ };
181
+ }
182
+
183
+ async function ensureOwner(env: DatabaseEnv): Promise<void> {
184
+ const createdAt = new Date().toISOString().replace(/\.\d{3}Z$/u, "Z");
185
+ await env.DB.batch([
186
+ env.DB.prepare(`INSERT OR IGNORE INTO principals
187
+ (id, provider, provider_subject, email, email_verified, display_name, created_at)
188
+ VALUES (?, 'passkey', ?, NULL, 0, 'Wikimemory Owner', ?)`).bind(
189
+ PRINCIPAL_ID,
190
+ PRINCIPAL_ID,
191
+ createdAt
192
+ ),
193
+ env.DB.prepare(
194
+ "INSERT OR IGNORE INTO workspaces(id, name, created_at) VALUES (?, 'Wikimemory', ?)"
195
+ ).bind(WORKSPACE_ID, createdAt),
196
+ env.DB.prepare(`INSERT OR IGNORE INTO memberships(workspace_id, principal_id, role, created_at)
197
+ VALUES (?, ?, 'owner', ?)`).bind(WORKSPACE_ID, PRINCIPAL_ID, createdAt)
198
+ ]);
199
+ const service = new MemoryService(env.DB);
200
+ const owner = actor("wikimemory-passkey-seed");
201
+ await service.ingest(owner, {
202
+ operationId: "seed-home-v1",
203
+ reason: "seed orientation",
204
+ slug: "home",
205
+ type: "system",
206
+ title: "Wikimemory home",
207
+ summary: "Standard orientation page.",
208
+ body: "# Wikimemory\n\nThe database is authoritative. See [[now]] for current focus."
209
+ });
210
+ await service.ingest(owner, {
211
+ operationId: "seed-now-v1",
212
+ reason: "seed current focus",
213
+ slug: "now",
214
+ type: "system",
215
+ title: "Now",
216
+ summary: "Current focus and active threads.",
217
+ body: "# Now\n\n_(No active work has been recorded yet.)_"
218
+ });
219
+ }
220
+
221
+ async function credentialRows(env: DatabaseEnv): Promise<CredentialRow[]> {
222
+ const result =
223
+ await env.DB.prepare(`SELECT credential_id, label, public_key, counter, transports_json, device_type, backed_up
224
+ FROM passkey_credentials WHERE principal_id = ? ORDER BY created_at`)
225
+ .bind(PRINCIPAL_ID)
226
+ .all<CredentialRow>();
227
+ return result.results;
228
+ }
229
+
230
+ function transports(value: string): AuthenticatorTransportFuture[] {
231
+ return TRANSPORTS_SCHEMA.parse(JSON.parse(value));
232
+ }
233
+
234
+ function expiry(): string {
235
+ return new Date(Date.now() + FLOW_TTL_SECONDS * 1000).toISOString();
236
+ }
237
+
238
+ function passkeyUserId(): Uint8Array<ArrayBuffer> {
239
+ const encoded = new TextEncoder().encode(PRINCIPAL_ID);
240
+ const copied = new Uint8Array(new ArrayBuffer(encoded.byteLength));
241
+ copied.set(encoded);
242
+ return copied;
243
+ }
244
+
245
+ async function consumeChallenge(
246
+ env: DatabaseEnv,
247
+ flowId: string,
248
+ kind: "setup" | "mcp" | "web" | "registration"
249
+ ): Promise<ChallengeRow | null> {
250
+ return await env.DB.prepare(`DELETE FROM passkey_challenges
251
+ WHERE flow_id = ? AND kind = ? AND expires_at > ?
252
+ RETURNING challenge, payload_json, token_hash`)
253
+ .bind(flowId, kind, new Date().toISOString())
254
+ .first<ChallengeRow>();
255
+ }
256
+
257
+ function authRequest(value: z.infer<typeof AUTH_REQUEST_SCHEMA>): AuthRequest {
258
+ return {
259
+ responseType: value.responseType,
260
+ clientId: value.clientId,
261
+ redirectUri: value.redirectUri,
262
+ scope: value.scope,
263
+ state: value.state,
264
+ ...(value.codeChallenge === undefined ? {} : { codeChallenge: value.codeChallenge }),
265
+ ...(value.codeChallengeMethod === undefined
266
+ ? {}
267
+ : { codeChallengeMethod: value.codeChallengeMethod }),
268
+ ...(value.resource === undefined ? {} : { resource: value.resource })
269
+ };
270
+ }
271
+
272
+ function registrationResponseJson(
273
+ value: z.infer<typeof REGISTRATION_RESPONSE_SCHEMA>
274
+ ): RegistrationResponseJSON {
275
+ return {
276
+ id: value.id,
277
+ rawId: value.rawId,
278
+ type: value.type,
279
+ clientExtensionResults: value.clientExtensionResults,
280
+ ...(value.authenticatorAttachment === undefined
281
+ ? {}
282
+ : { authenticatorAttachment: value.authenticatorAttachment }),
283
+ response: {
284
+ clientDataJSON: value.response.clientDataJSON,
285
+ attestationObject: value.response.attestationObject,
286
+ ...(value.response.authenticatorData === undefined
287
+ ? {}
288
+ : { authenticatorData: value.response.authenticatorData }),
289
+ ...(value.response.transports === undefined ? {} : { transports: value.response.transports }),
290
+ ...(value.response.publicKeyAlgorithm === undefined
291
+ ? {}
292
+ : { publicKeyAlgorithm: value.response.publicKeyAlgorithm }),
293
+ ...(value.response.publicKey === undefined ? {} : { publicKey: value.response.publicKey })
294
+ }
295
+ };
296
+ }
297
+
298
+ function authenticationResponseJson(
299
+ value: z.infer<typeof AUTHENTICATION_RESPONSE_SCHEMA>
300
+ ): AuthenticationResponseJSON {
301
+ return {
302
+ id: value.id,
303
+ rawId: value.rawId,
304
+ type: value.type,
305
+ clientExtensionResults: value.clientExtensionResults,
306
+ ...(value.authenticatorAttachment === undefined
307
+ ? {}
308
+ : { authenticatorAttachment: value.authenticatorAttachment }),
309
+ response: {
310
+ clientDataJSON: value.response.clientDataJSON,
311
+ authenticatorData: value.response.authenticatorData,
312
+ signature: value.response.signature,
313
+ ...(value.response.userHandle === undefined ? {} : { userHandle: value.response.userHandle })
314
+ }
315
+ };
316
+ }
317
+
318
+ export async function setupOptions(request: Request, env: RegistrationEnv): Promise<Response> {
319
+ const body = SETUP_REQUEST_SCHEMA.parse(await request.json());
320
+ const tokenHash = await sha256(body.token);
321
+ if (env.SETUP_TOKEN_HASH === undefined || tokenHash !== env.SETUP_TOKEN_HASH) {
322
+ return Response.json({ error: "This setup link is invalid or expired." }, { status: 403 });
323
+ }
324
+ const used = await env.DB.prepare(
325
+ "SELECT 1 AS present FROM passkey_bootstrap WHERE used_token_hash = ?"
326
+ )
327
+ .bind(tokenHash)
328
+ .first<{ present: number }>();
329
+ if (used !== null)
330
+ return Response.json({ error: "This setup link has already been used." }, { status: 409 });
331
+ const existing = await credentialRows(env);
332
+ const { rpID } = relyingParty(env);
333
+ const options = await generateRegistrationOptions({
334
+ rpName: "Wikimemory",
335
+ rpID,
336
+ userName: "owner",
337
+ userID: passkeyUserId(),
338
+ userDisplayName: "Wikimemory Owner",
339
+ attestationType: "none",
340
+ authenticatorSelection: { residentKey: "preferred", userVerification: "required" },
341
+ supportedAlgorithmIDs: [-7, -257],
342
+ excludeCredentials: existing.map((row) => ({
343
+ id: row.credential_id,
344
+ transports: transports(row.transports_json)
345
+ }))
346
+ });
347
+ const flowId = crypto.randomUUID();
348
+ await env.DB.batch([
349
+ env.DB.prepare("DELETE FROM passkey_challenges WHERE expires_at <= ?").bind(
350
+ new Date().toISOString()
351
+ ),
352
+ env.DB.prepare(`INSERT INTO passkey_challenges(flow_id, kind, challenge, payload_json, token_hash, expires_at)
353
+ VALUES (?, 'setup', ?, ?, ?, ?)`).bind(
354
+ flowId,
355
+ options.challenge,
356
+ JSON.stringify({ mode: existing.length === 0 ? "initial" : "recovery", label: body.label }),
357
+ tokenHash,
358
+ expiry()
359
+ )
360
+ ]);
361
+ return Response.json({ flowId, options });
362
+ }
363
+
364
+ export async function setupVerify(
365
+ request: Request,
366
+ env: Env,
367
+ verify: RegistrationVerifier = verifyRegistrationResponse,
368
+ revokeState: RecoveryStateRevoker = revokeRecoveryState
369
+ ): Promise<Response> {
370
+ const body = SETUP_VERIFY_SCHEMA.parse(await request.json());
371
+ const flow = await consumeChallenge(env, body.flowId, "setup");
372
+ if (flow === null)
373
+ return Response.json(
374
+ { error: "This setup attempt expired or was already used." },
375
+ { status: 410 }
376
+ );
377
+ if (env.SETUP_TOKEN_HASH === undefined || flow.token_hash !== env.SETUP_TOKEN_HASH) {
378
+ return Response.json(
379
+ { error: "This setup link expired after a configuration change." },
380
+ { status: 403 }
381
+ );
382
+ }
383
+ const used = await env.DB.prepare(
384
+ "SELECT 1 AS present FROM passkey_bootstrap WHERE used_token_hash = ?"
385
+ )
386
+ .bind(flow.token_hash)
387
+ .first<{ present: number }>();
388
+ if (used !== null)
389
+ return Response.json({ error: "This setup link has already been used." }, { status: 409 });
390
+ const payload = SETUP_PAYLOAD_SCHEMA.parse(JSON.parse(flow.payload_json ?? "null"));
391
+ const { origin, rpID } = relyingParty(env);
392
+ const registrationResponse = registrationResponseJson(body.response);
393
+ const verification = await verify({
394
+ response: registrationResponse,
395
+ expectedChallenge: flow.challenge,
396
+ expectedOrigin: origin,
397
+ expectedRPID: rpID,
398
+ requireUserVerification: true,
399
+ supportedAlgorithmIDs: [-7, -257]
400
+ });
401
+ if (!verification.verified)
402
+ return Response.json({ error: "Passkey verification failed." }, { status: 403 });
403
+ await ensureOwner(env);
404
+ const now = new Date().toISOString();
405
+ const credential = verification.registrationInfo.credential;
406
+ try {
407
+ if (payload.mode === "recovery") {
408
+ await revokeState(env);
409
+ }
410
+ const insert = env.DB.prepare(`INSERT INTO passkey_credentials
411
+ (credential_id, principal_id, public_key, counter, transports_json, device_type, backed_up, created_at, label)
412
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).bind(
413
+ credential.id,
414
+ PRINCIPAL_ID,
415
+ base64UrlEncode(credential.publicKey),
416
+ credential.counter,
417
+ JSON.stringify(credential.transports ?? []),
418
+ verification.registrationInfo.credentialDeviceType,
419
+ verification.registrationInfo.credentialBackedUp ? 1 : 0,
420
+ now,
421
+ payload.label
422
+ );
423
+ await env.DB.batch(
424
+ payload.mode === "recovery"
425
+ ? [
426
+ env.DB.prepare("DELETE FROM passkey_registration_tokens WHERE principal_id = ?").bind(
427
+ PRINCIPAL_ID
428
+ ),
429
+ insert,
430
+ env.DB.prepare(
431
+ "DELETE FROM passkey_credentials WHERE principal_id = ? AND credential_id <> ?"
432
+ ).bind(PRINCIPAL_ID, credential.id),
433
+ env.DB.prepare(
434
+ "INSERT INTO passkey_bootstrap(used_token_hash, completed_at) VALUES (?, ?)"
435
+ ).bind(flow.token_hash, now)
436
+ ]
437
+ : [
438
+ insert,
439
+ env.DB.prepare(
440
+ "INSERT INTO passkey_bootstrap(used_token_hash, completed_at) VALUES (?, ?)"
441
+ ).bind(flow.token_hash, now)
442
+ ]
443
+ );
444
+ } catch (error) {
445
+ if (error instanceof Error && error.message.includes("passkey_bootstrap.used_token_hash")) {
446
+ return Response.json({ error: "This setup link has already been used." }, { status: 409 });
447
+ }
448
+ throw error;
449
+ }
450
+ return Response.json({ ok: true, mode: payload.mode });
451
+ }
452
+
453
+ export async function registrationOptions(
454
+ request: Request,
455
+ env: RegistrationEnv
456
+ ): Promise<Response> {
457
+ const body = REGISTRATION_REQUEST_SCHEMA.parse(await request.json());
458
+ const available = await registrationToken(env.DB, body.token);
459
+ if (available === null)
460
+ return Response.json(
461
+ { error: "This passkey registration link is invalid or expired." },
462
+ { status: 403 }
463
+ );
464
+ const existing = await credentialRows(env);
465
+ const { rpID } = relyingParty(env);
466
+ const options = await generateRegistrationOptions({
467
+ rpName: "Wikimemory",
468
+ rpID,
469
+ userName: "owner",
470
+ userID: passkeyUserId(),
471
+ userDisplayName: "Wikimemory Owner",
472
+ attestationType: "none",
473
+ authenticatorSelection: { residentKey: "preferred", userVerification: "required" },
474
+ supportedAlgorithmIDs: [-7, -257],
475
+ excludeCredentials: existing.map((row) => ({
476
+ id: row.credential_id,
477
+ transports: transports(row.transports_json)
478
+ }))
479
+ });
480
+ const flowId = crypto.randomUUID();
481
+ await env.DB.batch([
482
+ env.DB.prepare("DELETE FROM passkey_challenges WHERE expires_at <= ?").bind(
483
+ new Date().toISOString()
484
+ ),
485
+ env.DB.prepare(`INSERT INTO passkey_challenges(flow_id, kind, challenge, payload_json, token_hash, expires_at)
486
+ VALUES (?, 'registration', ?, ?, ?, ?)`).bind(
487
+ flowId,
488
+ options.challenge,
489
+ JSON.stringify({ label: available.label }),
490
+ available.tokenHash,
491
+ expiry()
492
+ )
493
+ ]);
494
+ return Response.json({ flowId, options, label: available.label });
495
+ }
496
+
497
+ export async function registrationVerify(
498
+ request: Request,
499
+ env: RegistrationEnv,
500
+ verify: RegistrationVerifier = verifyRegistrationResponse
501
+ ): Promise<Response> {
502
+ const body = SETUP_VERIFY_SCHEMA.parse(await request.json());
503
+ const flow = await consumeChallenge(env, body.flowId, "registration");
504
+ if (flow === null || flow.token_hash === null)
505
+ return Response.json(
506
+ { error: "This registration attempt expired or was already used." },
507
+ { status: 410 }
508
+ );
509
+ const payload = REGISTRATION_PAYLOAD_SCHEMA.parse(JSON.parse(flow.payload_json ?? "null"));
510
+ const token = await env.DB.prepare(`SELECT 1 AS present FROM passkey_registration_tokens t
511
+ JOIN passkey_credentials c
512
+ ON c.credential_id = t.authorizing_credential_id AND c.principal_id = t.principal_id
513
+ WHERE t.token_hash = ? AND t.principal_id = ? AND t.expires_at > ?`)
514
+ .bind(flow.token_hash, PRINCIPAL_ID, new Date().toISOString())
515
+ .first<{ present: number }>();
516
+ if (token === null)
517
+ return Response.json(
518
+ { error: "This passkey registration link is invalid or expired." },
519
+ { status: 403 }
520
+ );
521
+ const { origin, rpID } = relyingParty(env);
522
+ const verification = await verify({
523
+ response: registrationResponseJson(body.response),
524
+ expectedChallenge: flow.challenge,
525
+ expectedOrigin: origin,
526
+ expectedRPID: rpID,
527
+ requireUserVerification: true,
528
+ supportedAlgorithmIDs: [-7, -257]
529
+ });
530
+ if (!verification.verified)
531
+ return Response.json({ error: "Passkey verification failed." }, { status: 403 });
532
+ const credential = verification.registrationInfo.credential;
533
+ const now = new Date().toISOString();
534
+ const results = await env.DB.batch([
535
+ env.DB.prepare(`INSERT INTO passkey_credentials
536
+ (credential_id, principal_id, public_key, counter, transports_json, device_type, backed_up, created_at, label)
537
+ SELECT ?, ?, ?, ?, ?, ?, ?, ?, ? WHERE EXISTS (
538
+ SELECT 1 FROM passkey_registration_tokens t
539
+ JOIN passkey_credentials c
540
+ ON c.credential_id = t.authorizing_credential_id AND c.principal_id = t.principal_id
541
+ WHERE t.token_hash = ? AND t.principal_id = ? AND t.expires_at > ?
542
+ )`).bind(
543
+ credential.id,
544
+ PRINCIPAL_ID,
545
+ base64UrlEncode(credential.publicKey),
546
+ credential.counter,
547
+ JSON.stringify(credential.transports ?? []),
548
+ verification.registrationInfo.credentialDeviceType,
549
+ verification.registrationInfo.credentialBackedUp ? 1 : 0,
550
+ now,
551
+ payload.label,
552
+ flow.token_hash,
553
+ PRINCIPAL_ID,
554
+ now
555
+ ),
556
+ env.DB.prepare(
557
+ "DELETE FROM passkey_registration_tokens WHERE token_hash = ? AND principal_id = ?"
558
+ ).bind(flow.token_hash, PRINCIPAL_ID)
559
+ ]);
560
+ if (results[0]?.meta.changes !== 1)
561
+ return Response.json({ error: "This passkey registration link expired." }, { status: 409 });
562
+ return Response.json({ ok: true, label: payload.label });
563
+ }
564
+
565
+ export async function beginPasskeyAuthorization(
566
+ request: Request,
567
+ env: Env,
568
+ kind: "mcp" | "web"
569
+ ): Promise<Response> {
570
+ const parsedAuth =
571
+ kind === "mcp" ? await env.OAUTH_PROVIDER.parseAuthRequest(request) : undefined;
572
+ const auth =
573
+ parsedAuth === undefined ? undefined : bindAuthorizationResource(parsedAuth, mcpResource(env));
574
+ const requestedScopes = auth === undefined ? undefined : scopes(auth);
575
+ const client = auth === undefined ? null : await env.OAUTH_PROVIDER.lookupClient(auth.clientId);
576
+ const credentials = await credentialRows(env);
577
+ if (credentials.length === 0)
578
+ return new Response(
579
+ "Wikimemory has not been set up. Use the one-time setup URL from the installer.",
580
+ { status: 503 }
581
+ );
582
+ const { rpID } = relyingParty(env);
583
+ const options = await generateAuthenticationOptions({
584
+ rpID,
585
+ userVerification: "required",
586
+ allowCredentials: credentials.map((row) => ({
587
+ id: row.credential_id,
588
+ transports: transports(row.transports_json)
589
+ }))
590
+ });
591
+ const flowId = crypto.randomUUID();
592
+ await env.DB.batch([
593
+ env.DB.prepare("DELETE FROM passkey_challenges WHERE expires_at <= ?").bind(
594
+ new Date().toISOString()
595
+ ),
596
+ env.DB.prepare(`INSERT INTO passkey_challenges(flow_id, kind, challenge, payload_json, expires_at)
597
+ VALUES (?, ?, ?, ?, ?)`).bind(
598
+ flowId,
599
+ kind,
600
+ options.challenge,
601
+ JSON.stringify({
602
+ kind,
603
+ options,
604
+ ...(auth === undefined ? {} : { auth }),
605
+ ...(client?.clientName === undefined && client?.clientId === undefined
606
+ ? {}
607
+ : { clientName: client.clientName ?? client.clientId }),
608
+ ...(requestedScopes === undefined ? {} : { requestedScopes })
609
+ }),
610
+ expiry()
611
+ )
612
+ ]);
613
+ return Response.redirect(
614
+ new URL(`/login?flowId=${encodeURIComponent(flowId)}`, relyingParty(env).origin).toString(),
615
+ 302
616
+ );
617
+ }
618
+
619
+ export async function passkeyAuthorizationOptions(request: Request, env: Env): Promise<Response> {
620
+ const flowId = new URL(request.url).searchParams.get("flowId");
621
+ if (flowId === null || !z.uuid().safeParse(flowId).success)
622
+ throw new DomainError("validation_failed", "Invalid authentication flow");
623
+ const row = await env.DB.prepare(`SELECT payload_json FROM passkey_challenges
624
+ WHERE flow_id = ? AND kind IN ('mcp', 'web') AND expires_at > ?`)
625
+ .bind(flowId, new Date().toISOString())
626
+ .first<{ payload_json: string | null }>();
627
+ if (row?.payload_json === null || row === null)
628
+ return Response.json({ error: "This authentication attempt expired." }, { status: 410 });
629
+ const payload = AUTH_FLOW_PAYLOAD_SCHEMA.parse(JSON.parse(row.payload_json));
630
+ return Response.json({
631
+ flowId,
632
+ kind: payload.kind,
633
+ options: payload.options,
634
+ clientName: payload.clientName,
635
+ requestedScopes: payload.requestedScopes
636
+ });
637
+ }
638
+
639
+ export async function verifyPasskeyAuthorization(
640
+ request: Request,
641
+ env: Env,
642
+ verify: AuthenticationVerifier = verifyAuthenticationResponse
643
+ ): Promise<Response> {
644
+ const body = AUTH_VERIFY_SCHEMA.parse(await request.json());
645
+ const mcpFlow = await consumeChallenge(env, body.flowId, "mcp");
646
+ const flowKind = mcpFlow === null ? "web" : "mcp";
647
+ const flow = mcpFlow ?? (await consumeChallenge(env, body.flowId, "web"));
648
+ if (flow === null)
649
+ return Response.json(
650
+ { error: "This authentication attempt expired or was already used." },
651
+ { status: 410 }
652
+ );
653
+ const row =
654
+ await env.DB.prepare(`SELECT credential_id, label, public_key, counter, transports_json, device_type, backed_up
655
+ FROM passkey_credentials WHERE credential_id = ? AND principal_id = ?`)
656
+ .bind(body.response.id, PRINCIPAL_ID)
657
+ .first<CredentialRow>();
658
+ if (row === null) return Response.json({ error: "Unknown passkey." }, { status: 403 });
659
+ const credential: WebAuthnCredential = {
660
+ id: row.credential_id,
661
+ publicKey: base64UrlDecode(row.public_key),
662
+ counter: row.counter,
663
+ transports: transports(row.transports_json)
664
+ };
665
+ const { origin, rpID } = relyingParty(env);
666
+ const authenticationResponse = authenticationResponseJson(body.response);
667
+ const verification = await verify({
668
+ response: authenticationResponse,
669
+ expectedChallenge: flow.challenge,
670
+ expectedOrigin: origin,
671
+ expectedRPID: rpID,
672
+ credential,
673
+ requireUserVerification: true
674
+ });
675
+ if (!verification.verified || !verification.authenticationInfo.userVerified)
676
+ return Response.json({ error: "Passkey verification failed." }, { status: 403 });
677
+ const authenticatedAt = new Date().toISOString();
678
+ const updated =
679
+ await env.DB.prepare(`UPDATE passkey_credentials SET counter = ?, device_type = ?, backed_up = ?, last_used_at = ?
680
+ WHERE credential_id = ? AND counter = ?`)
681
+ .bind(
682
+ verification.authenticationInfo.newCounter,
683
+ verification.authenticationInfo.credentialDeviceType,
684
+ verification.authenticationInfo.credentialBackedUp ? 1 : 0,
685
+ authenticatedAt,
686
+ row.credential_id,
687
+ row.counter
688
+ )
689
+ .run();
690
+ if (updated.meta.changes !== 1)
691
+ return Response.json({ error: "This passkey assertion was already used." }, { status: 409 });
692
+ if (flowKind === "mcp") {
693
+ if (flow.payload_json === null)
694
+ throw new OAuthError("invalid_request", {
695
+ description: "MCP authorization state is missing"
696
+ });
697
+ const payload = AUTH_FLOW_PAYLOAD_SCHEMA.parse(JSON.parse(flow.payload_json));
698
+ if (payload.auth === undefined)
699
+ throw new OAuthError("invalid_request", {
700
+ description: "MCP authorization state is missing"
701
+ });
702
+ const auth = bindAuthorizationResource(authRequest(payload.auth), mcpResource(env));
703
+ const granted = scopes(auth);
704
+ const { redirectTo } = await env.OAUTH_PROVIDER.completeAuthorization({
705
+ request: auth,
706
+ userId: PRINCIPAL_ID,
707
+ metadata: { identity: "passkey" },
708
+ scope: granted,
709
+ props: {
710
+ workspaceId: WORKSPACE_ID,
711
+ principalId: PRINCIPAL_ID,
712
+ clientId: auth.clientId,
713
+ scopes: granted,
714
+ authenticatedAt,
715
+ credentialId: row.credential_id
716
+ }
717
+ });
718
+ return Response.json({ redirectTo });
719
+ }
720
+ const sessionId = crypto.randomUUID();
721
+ const session = {
722
+ principalId: PRINCIPAL_ID,
723
+ workspaceId: WORKSPACE_ID,
724
+ credentialId: row.credential_id,
725
+ authenticatedAt,
726
+ createdAt: authenticatedAt
727
+ } satisfies SessionRecord;
728
+ await env.OAUTH_KV.put(await sessionStorageKey(sessionId), JSON.stringify(session), {
729
+ expirationTtl: 86_400
730
+ });
731
+ return Response.json(
732
+ { redirectTo: "/app" },
733
+ {
734
+ headers: {
735
+ "set-cookie": `wm_web_session=${sessionId}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=86400`
736
+ }
737
+ }
738
+ );
739
+ }
740
+
741
+ async function sessionStorageKey(sessionId: string): Promise<string> {
742
+ return `${SESSION_PREFIX}${await sha256(sessionId)}`;
743
+ }
744
+
745
+ function cookieValue(request: Request, name: string): string | null {
746
+ const cookie = (request.headers.get("cookie") ?? "")
747
+ .split(";")
748
+ .map((part) => part.trim())
749
+ .find((part) => part.startsWith(`${name}=`));
750
+ return cookie === undefined ? null : cookie.slice(name.length + 1);
751
+ }
752
+
753
+ export async function productionWebOwner(request: Request, env: Env): Promise<OwnerContext | null> {
754
+ const sessionId = cookieValue(request, "wm_web_session");
755
+ if (sessionId === null) return null;
756
+ const raw = await env.OAUTH_KV.get(await sessionStorageKey(sessionId), "json");
757
+ const parsed = z
758
+ .object({
759
+ principalId: z.string(),
760
+ workspaceId: z.string(),
761
+ credentialId: z.string(),
762
+ authenticatedAt: z.string(),
763
+ createdAt: z.string()
764
+ })
765
+ .safeParse(raw);
766
+ if (
767
+ !parsed.success ||
768
+ parsed.data.principalId !== PRINCIPAL_ID ||
769
+ parsed.data.workspaceId !== WORKSPACE_ID
770
+ )
771
+ return null;
772
+ const credential = await env.DB.prepare(
773
+ "SELECT 1 AS present FROM passkey_credentials WHERE credential_id = ? AND principal_id = ?"
774
+ )
775
+ .bind(parsed.data.credentialId, PRINCIPAL_ID)
776
+ .first<{ present: number }>();
777
+ if (credential === null) return null;
778
+ return {
779
+ ...actor(),
780
+ role: "owner",
781
+ reauthenticatedAt: parsed.data.authenticatedAt,
782
+ credentialId: parsed.data.credentialId
783
+ };
784
+ }
785
+
786
+ export async function endProductionWebSession(request: Request, env: Env): Promise<void> {
787
+ const sessionId = cookieValue(request, "wm_web_session");
788
+ if (sessionId !== null) await env.OAUTH_KV.delete(await sessionStorageKey(sessionId));
789
+ }
790
+
791
+ export async function listProductionWebSessions(
792
+ request: Request,
793
+ env: Env,
794
+ principalId: string
795
+ ): Promise<WebSessionSummary[]> {
796
+ const currentId = cookieValue(request, "wm_web_session");
797
+ const currentKey = currentId === null ? null : await sessionStorageKey(currentId);
798
+ const keys = await env.OAUTH_KV.list({ prefix: SESSION_PREFIX, limit: 100 });
799
+ const sessions = await Promise.all(
800
+ keys.keys.map(async ({ name }) => {
801
+ const raw = await env.OAUTH_KV.get(name, "json");
802
+ const parsed = z
803
+ .object({
804
+ principalId: z.string(),
805
+ workspaceId: z.string(),
806
+ authenticatedAt: z.string(),
807
+ createdAt: z.string()
808
+ })
809
+ .safeParse(raw);
810
+ if (!parsed.success || parsed.data.principalId !== principalId) return null;
811
+ return {
812
+ sessionRef: name.slice(SESSION_PREFIX.length),
813
+ authenticatedAt: parsed.data.authenticatedAt,
814
+ createdAt: parsed.data.createdAt,
815
+ current: name === currentKey
816
+ } satisfies WebSessionSummary;
817
+ })
818
+ );
819
+ return sessions
820
+ .filter((session): session is WebSessionSummary => session !== null)
821
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
822
+ }
823
+
824
+ async function revokeAllProductionWebSessions(env: Pick<Env, "OAUTH_KV">): Promise<void> {
825
+ let cursor: string | undefined;
826
+ do {
827
+ const page = await env.OAUTH_KV.list({
828
+ prefix: SESSION_PREFIX,
829
+ limit: 100,
830
+ ...(cursor === undefined ? {} : { cursor })
831
+ });
832
+ await Promise.all(page.keys.map(async ({ name }) => env.OAUTH_KV.delete(name)));
833
+ cursor = page.list_complete ? undefined : page.cursor;
834
+ } while (cursor !== undefined);
835
+ }
836
+
837
+ export async function revokeProductionSessionsForCredential(
838
+ env: Env,
839
+ principalId: string,
840
+ credentialId: string
841
+ ): Promise<void> {
842
+ let cursor: string | undefined;
843
+ do {
844
+ const page = await env.OAUTH_KV.list({
845
+ prefix: SESSION_PREFIX,
846
+ limit: 100,
847
+ ...(cursor === undefined ? {} : { cursor })
848
+ });
849
+ await Promise.all(
850
+ page.keys.map(async ({ name }) => {
851
+ const raw = await env.OAUTH_KV.get(name, "json");
852
+ const parsed = z
853
+ .object({ principalId: z.string(), credentialId: z.string().optional() })
854
+ .safeParse(raw);
855
+ if (
856
+ parsed.success &&
857
+ parsed.data.principalId === principalId &&
858
+ (parsed.data.credentialId === credentialId || parsed.data.credentialId === undefined)
859
+ ) {
860
+ await env.OAUTH_KV.delete(name);
861
+ }
862
+ })
863
+ );
864
+ cursor = page.list_complete ? undefined : page.cursor;
865
+ } while (cursor !== undefined);
866
+ }
867
+
868
+ export async function revokeProductionSessionsForCredentialBestEffort(
869
+ env: Env,
870
+ principalId: string,
871
+ credentialId: string
872
+ ): Promise<boolean> {
873
+ try {
874
+ await revokeProductionSessionsForCredential(env, principalId, credentialId);
875
+ return true;
876
+ } catch {
877
+ return false;
878
+ }
879
+ }
880
+
881
+ async function revokeAllUserGrants(env: Pick<Env, "OAUTH_PROVIDER">): Promise<void> {
882
+ let cursor: string | undefined;
883
+ do {
884
+ const page = await env.OAUTH_PROVIDER.listUserGrants(PRINCIPAL_ID, {
885
+ limit: 100,
886
+ ...(cursor === undefined ? {} : { cursor })
887
+ });
888
+ await Promise.all(
889
+ page.items.map(async (grant) => env.OAUTH_PROVIDER.revokeGrant(grant.id, PRINCIPAL_ID))
890
+ );
891
+ cursor = page.cursor;
892
+ } while (cursor !== undefined);
893
+ }
894
+
895
+ export async function revokeProductionWebSession(
896
+ env: Env,
897
+ principalId: string,
898
+ sessionRef: string
899
+ ): Promise<void> {
900
+ if (!/^[a-f0-9]{64}$/u.test(sessionRef))
901
+ throw new DomainError("validation_failed", "Invalid session reference");
902
+ const key = `${SESSION_PREFIX}${sessionRef}`;
903
+ const raw = await env.OAUTH_KV.get(key, "json");
904
+ const parsed = z.object({ principalId: z.string() }).safeParse(raw);
905
+ if (!parsed.success || parsed.data.principalId !== principalId)
906
+ throw new DomainError("not_found", "Browser session not found");
907
+ await env.OAUTH_KV.delete(key);
908
+ }