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.
- package/LICENSE +21 -0
- package/README.md +111 -0
- package/dist/npm-cli/scripts/cli-options.js +29 -0
- package/dist/npm-cli/scripts/cli.js +79 -0
- package/dist/npm-cli/scripts/client-tools.js +57 -0
- package/dist/npm-cli/scripts/deployment-record.js +72 -0
- package/dist/npm-cli/scripts/dev.js +49 -0
- package/dist/npm-cli/scripts/lifecycle-runtime.js +38 -0
- package/dist/npm-cli/scripts/package-root.js +15 -0
- package/dist/npm-cli/scripts/passkeys.js +197 -0
- package/dist/npm-cli/scripts/setup.js +523 -0
- package/dist/npm-cli/scripts/status.js +49 -0
- package/dist/npm-cli/scripts/uninstall.js +220 -0
- package/dist/npm-cli/scripts/upgrade.js +301 -0
- package/dist/npm-cli/src/version.js +2 -0
- package/dist/web/assets/index-CjnFBnXp.css +1 -0
- package/dist/web/assets/index-buhGyrxi.js +72 -0
- package/dist/web/index.html +14 -0
- package/migrations/0001_initial.sql +297 -0
- package/migrations/0002_passkeys.sql +30 -0
- package/migrations/0003_passkey_management.sql +38 -0
- package/migrations/0004_credential_bound_registration_tokens.sql +18 -0
- package/package.json +89 -0
- package/release-manifest.json +22 -0
- package/skills/wikimemory-ingest/SKILL.md +24 -0
- package/skills/wikimemory-ingest/agents/openai.yaml +4 -0
- package/skills/wikimemory-install/SKILL.md +81 -0
- package/skills/wikimemory-install/agents/openai.yaml +4 -0
- package/skills/wikimemory-lint/SKILL.md +18 -0
- package/skills/wikimemory-lint/agents/openai.yaml +4 -0
- package/skills/wikimemory-recall/SKILL.md +20 -0
- package/skills/wikimemory-recall/agents/openai.yaml +4 -0
- package/src/auth/local.ts +131 -0
- package/src/auth/passkey-api.ts +100 -0
- package/src/auth/passkey-management.ts +150 -0
- package/src/auth/passkey.ts +908 -0
- package/src/auth/props.ts +96 -0
- package/src/auth/resource.ts +36 -0
- package/src/domain/crypto.ts +27 -0
- package/src/domain/errors.ts +32 -0
- package/src/domain/export-service.ts +363 -0
- package/src/domain/guards.ts +9 -0
- package/src/domain/memory-service.ts +1092 -0
- package/src/domain/secret-scanner.ts +45 -0
- package/src/domain/text-chunk.ts +24 -0
- package/src/domain/types.ts +169 -0
- package/src/env.ts +20 -0
- package/src/index.ts +138 -0
- package/src/mcp/schemas.ts +117 -0
- package/src/mcp/server.ts +506 -0
- package/src/version.ts +2 -0
- 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
|
+
}
|