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
package/src/web/app.ts ADDED
@@ -0,0 +1,299 @@
1
+ import { ensureLocalOwner, localOwnerContext } from "../auth/local";
2
+ import {
3
+ endProductionWebSession,
4
+ listProductionWebSessions,
5
+ productionWebOwner,
6
+ revokeProductionSessionsForCredentialBestEffort,
7
+ revokeProductionWebSession
8
+ } from "../auth/passkey";
9
+ import {
10
+ createRegistrationToken,
11
+ listPasskeys,
12
+ PASSKEY_OWNER_ID,
13
+ requireRecentPasskeyAuthentication,
14
+ revokePasskey
15
+ } from "../auth/passkey-management";
16
+ import { DomainError } from "../domain/errors";
17
+ import { ExportService } from "../domain/export-service";
18
+ import { MemoryService } from "../domain/memory-service";
19
+ import type { OwnerContext } from "../domain/types";
20
+ import type { Env } from "../env";
21
+
22
+ function cookieValue(request: Request, name: string): string | null {
23
+ const part = (request.headers.get("cookie") ?? "")
24
+ .split(";")
25
+ .map((value) => value.trim())
26
+ .find((value) => value.startsWith(`${name}=`));
27
+ return part === undefined ? null : part.slice(name.length + 1);
28
+ }
29
+
30
+ function requireSameOrigin(request: Request): void {
31
+ const origin = request.headers.get("origin");
32
+ if (origin !== new URL(request.url).origin)
33
+ throw new DomainError("forbidden", "Cross-origin mutation denied");
34
+ }
35
+
36
+ async function owner(request: Request, env: Env): Promise<OwnerContext | null> {
37
+ if (env.APP_ENV === "local") {
38
+ if (cookieValue(request, "wm_local_web") !== "owner") return null;
39
+ await ensureLocalOwner(env);
40
+ return localOwnerContext();
41
+ }
42
+ return await productionWebOwner(request, env);
43
+ }
44
+
45
+ function jsonError(error: unknown): Response {
46
+ if (!(error instanceof DomainError)) throw error;
47
+ const status =
48
+ error.code === "not_found"
49
+ ? 404
50
+ : error.code === "forbidden"
51
+ ? 403
52
+ : error.code === "revision_conflict" ||
53
+ error.code === "already_exists" ||
54
+ error.code === "idempotency_mismatch"
55
+ ? 409
56
+ : error.code === "reauthentication_required"
57
+ ? 401
58
+ : error.code === "limit_exceeded"
59
+ ? 413
60
+ : error.code === "internal_error"
61
+ ? 500
62
+ : 400;
63
+ return Response.json({ error: error.code, message: error.message }, { status });
64
+ }
65
+
66
+ async function manage(request: Request, env: Env, context: OwnerContext): Promise<Response> {
67
+ const grants = await env.OAUTH_PROVIDER.listUserGrants(context.principalId, { limit: 100 });
68
+ const clients = await Promise.all(
69
+ grants.items.map(async (grant) => {
70
+ const client = await env.OAUTH_PROVIDER.lookupClient(grant.clientId);
71
+ return {
72
+ id: grant.id,
73
+ clientId: grant.clientId,
74
+ clientName: client?.clientName ?? grant.clientId,
75
+ scope: grant.scope,
76
+ createdAt: new Date(grant.createdAt * 1000).toISOString()
77
+ };
78
+ })
79
+ );
80
+ return Response.json({
81
+ passkeys: env.APP_ENV === "production" ? await listPasskeys(env.DB) : [],
82
+ clients,
83
+ sessions:
84
+ env.APP_ENV === "production"
85
+ ? await listProductionWebSessions(request, env, context.principalId)
86
+ : []
87
+ });
88
+ }
89
+
90
+ async function exportResponse(
91
+ env: Env,
92
+ context: OwnerContext,
93
+ format: "jsonl" | "md"
94
+ ): Promise<Response> {
95
+ const service = new ExportService(env.DB);
96
+ const content =
97
+ format === "jsonl" ? await service.jsonl(context) : await service.markdown(context);
98
+ const date = new Date().toISOString().slice(0, 10);
99
+ return new Response(content, {
100
+ headers: {
101
+ "content-type":
102
+ format === "jsonl" ? "application/x-ndjson; charset=utf-8" : "text/markdown; charset=utf-8",
103
+ "content-disposition": `attachment; filename="wikimemory-${date}.${format}"`,
104
+ "cache-control": "no-store",
105
+ "x-content-type-options": "nosniff"
106
+ }
107
+ });
108
+ }
109
+
110
+ export async function handleWebApi(request: Request, env: Env): Promise<Response> {
111
+ const url = new URL(request.url);
112
+ try {
113
+ if (url.pathname === "/api/app/login" && request.method === "POST" && env.APP_ENV === "local") {
114
+ requireSameOrigin(request);
115
+ await ensureLocalOwner(env);
116
+ return Response.json(
117
+ { ok: true },
118
+ {
119
+ headers: {
120
+ "set-cookie": "wm_local_web=owner; HttpOnly; Path=/; SameSite=Strict; Max-Age=86400"
121
+ }
122
+ }
123
+ );
124
+ }
125
+ const context = await owner(request, env);
126
+ if (url.pathname === "/api/app/session" && request.method === "GET")
127
+ return Response.json({
128
+ authenticated: context !== null,
129
+ environment: env.APP_ENV,
130
+ ...(context === null ? { loginUrl: "/app/login" } : {})
131
+ });
132
+ if (context === null)
133
+ return Response.json({ error: "unauthenticated", loginUrl: "/app/login" }, { status: 401 });
134
+ const service = new MemoryService(env.DB);
135
+ if (url.pathname === "/api/app/documents" && request.method === "GET")
136
+ return Response.json({ items: await service.index(context, { limit: 100 }) });
137
+ if (url.pathname === "/api/app/search" && request.method === "GET")
138
+ return Response.json({
139
+ hits: await service.recall(context, (url.searchParams.get("q") ?? "").trim(), 20)
140
+ });
141
+ if (url.pathname === "/api/app/recent" && request.method === "GET") {
142
+ const rows = await env.DB.prepare(
143
+ `SELECT d.slug, r.id revision_id, r.revision_number, r.created_at, r.reason FROM revisions r JOIN documents d ON d.id = r.doc_id WHERE r.workspace_id = ? ORDER BY r.created_at DESC, r.id DESC LIMIT 100`
144
+ )
145
+ .bind(context.workspaceId)
146
+ .all<{
147
+ slug: string;
148
+ revision_id: string;
149
+ revision_number: number;
150
+ created_at: string;
151
+ reason: string;
152
+ }>();
153
+ return Response.json({ revisions: rows.results });
154
+ }
155
+ if (url.pathname === "/api/app/manage" && request.method === "GET")
156
+ return await manage(request, env, context);
157
+ if (url.pathname === "/api/app/export.jsonl" && request.method === "GET")
158
+ return await exportResponse(env, context, "jsonl");
159
+ if (url.pathname === "/api/app/export.md" && request.method === "GET")
160
+ return await exportResponse(env, context, "md");
161
+ if (url.pathname === "/api/app/logout" && request.method === "POST") {
162
+ requireSameOrigin(request);
163
+ if (env.APP_ENV === "production") await endProductionWebSession(request, env);
164
+ return Response.json(
165
+ { ok: true },
166
+ {
167
+ headers: {
168
+ "set-cookie": `${env.APP_ENV === "production" ? "wm_web_session" : "wm_local_web"}=; HttpOnly; ${env.APP_ENV === "production" ? "Secure; " : ""}Path=/; SameSite=Lax; Max-Age=0`
169
+ }
170
+ }
171
+ );
172
+ }
173
+ if (request.method === "POST" || request.method === "DELETE") requireSameOrigin(request);
174
+ if (
175
+ url.pathname === "/api/app/passkeys" &&
176
+ request.method === "POST" &&
177
+ env.APP_ENV === "production"
178
+ ) {
179
+ requireRecentPasskeyAuthentication(context.reauthenticatedAt);
180
+ const body: unknown = await request.json();
181
+ if (
182
+ typeof body !== "object" ||
183
+ body === null ||
184
+ !("label" in body) ||
185
+ typeof body.label !== "string"
186
+ )
187
+ throw new DomainError("validation_failed", "Passkey name is missing");
188
+ if (context.credentialId === undefined)
189
+ throw new DomainError("forbidden", "The authorizing passkey is no longer valid");
190
+ const token = await createRegistrationToken(env.DB, body.label, context.credentialId);
191
+ return Response.json({
192
+ registrationUrl: `${new URL("/passkeys/add", request.url).toString()}#${encodeURIComponent(token.rawToken)}`,
193
+ expiresAt: token.expiresAt
194
+ });
195
+ }
196
+ if (
197
+ url.pathname === "/api/app/passkeys" &&
198
+ request.method === "DELETE" &&
199
+ env.APP_ENV === "production"
200
+ ) {
201
+ requireRecentPasskeyAuthentication(context.reauthenticatedAt);
202
+ const body: unknown = await request.json();
203
+ if (
204
+ typeof body !== "object" ||
205
+ body === null ||
206
+ !("credentialRef" in body) ||
207
+ typeof body.credentialRef !== "string"
208
+ )
209
+ throw new DomainError("validation_failed", "Passkey reference is missing");
210
+ const credentialId = await revokePasskey(env.DB, body.credentialRef);
211
+ const sessionCleanupComplete = await revokeProductionSessionsForCredentialBestEffort(
212
+ env,
213
+ PASSKEY_OWNER_ID,
214
+ credentialId
215
+ );
216
+ return Response.json({ revoked: body.credentialRef, sessionCleanupComplete });
217
+ }
218
+ if (url.pathname === "/api/app/grants" && request.method === "DELETE") {
219
+ const body: unknown = await request.json();
220
+ if (
221
+ typeof body !== "object" ||
222
+ body === null ||
223
+ !("grantId" in body) ||
224
+ typeof body.grantId !== "string"
225
+ )
226
+ throw new DomainError("validation_failed", "Grant ID is missing");
227
+ await env.OAUTH_PROVIDER.revokeGrant(body.grantId, context.principalId);
228
+ return Response.json({ revoked: body.grantId });
229
+ }
230
+ if (
231
+ url.pathname === "/api/app/sessions" &&
232
+ request.method === "DELETE" &&
233
+ env.APP_ENV === "production"
234
+ ) {
235
+ const body: unknown = await request.json();
236
+ if (
237
+ typeof body !== "object" ||
238
+ body === null ||
239
+ !("sessionRef" in body) ||
240
+ typeof body.sessionRef !== "string"
241
+ )
242
+ throw new DomainError("validation_failed", "Session reference is missing");
243
+ await revokeProductionWebSession(env, context.principalId, body.sessionRef);
244
+ return Response.json({ revoked: body.sessionRef });
245
+ }
246
+ if (url.pathname.startsWith("/api/app/docs/")) {
247
+ const path = url.pathname.slice(14).split("/");
248
+ const slug = decodeURIComponent(path[0] ?? "");
249
+ const action = path[1];
250
+ if (request.method === "GET" && action === undefined) {
251
+ const revisionId = url.searchParams.get("revision") ?? undefined;
252
+ const [document, current, history] = await Promise.all([
253
+ service.get(context, slug, revisionId),
254
+ revisionId === undefined ? null : service.get(context, slug),
255
+ service.history(context, slug, 25)
256
+ ]);
257
+ return Response.json({ document, current, history });
258
+ }
259
+ const body: unknown = await request.json();
260
+ if (typeof body !== "object" || body === null)
261
+ throw new DomainError("validation_failed", "Request body is missing");
262
+ if (
263
+ request.method === "POST" &&
264
+ action === "restore" &&
265
+ "targetRevisionId" in body &&
266
+ "expectedRevisionId" in body &&
267
+ typeof body.targetRevisionId === "string" &&
268
+ typeof body.expectedRevisionId === "string"
269
+ ) {
270
+ return Response.json(
271
+ await service.restore(context, {
272
+ operationId: crypto.randomUUID(),
273
+ reason: "owner web restore",
274
+ slug,
275
+ targetRevisionId: body.targetRevisionId,
276
+ expectedRevisionId: body.expectedRevisionId
277
+ })
278
+ );
279
+ }
280
+ if (
281
+ request.method === "POST" &&
282
+ action === "purge-authorize" &&
283
+ "confirmation" in body &&
284
+ typeof body.confirmation === "string"
285
+ )
286
+ return Response.json(await service.authorizePurge(context, slug, body.confirmation));
287
+ if (
288
+ request.method === "POST" &&
289
+ action === "purge-apply" &&
290
+ "authorizationId" in body &&
291
+ typeof body.authorizationId === "string"
292
+ )
293
+ return Response.json(await service.purge(context, body.authorizationId, slug));
294
+ }
295
+ return Response.json({ error: "not_found" }, { status: 404 });
296
+ } catch (error) {
297
+ return jsonError(error);
298
+ }
299
+ }