harper-knowledge 0.1.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 (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +276 -0
  3. package/config.yaml +17 -0
  4. package/dist/core/embeddings.d.ts +29 -0
  5. package/dist/core/embeddings.js +199 -0
  6. package/dist/core/entries.d.ts +85 -0
  7. package/dist/core/entries.js +235 -0
  8. package/dist/core/history.d.ts +30 -0
  9. package/dist/core/history.js +119 -0
  10. package/dist/core/search.d.ts +23 -0
  11. package/dist/core/search.js +306 -0
  12. package/dist/core/tags.d.ts +32 -0
  13. package/dist/core/tags.js +76 -0
  14. package/dist/core/triage.d.ts +55 -0
  15. package/dist/core/triage.js +126 -0
  16. package/dist/http-utils.d.ts +37 -0
  17. package/dist/http-utils.js +132 -0
  18. package/dist/index.d.ts +21 -0
  19. package/dist/index.js +76 -0
  20. package/dist/mcp/server.d.ts +24 -0
  21. package/dist/mcp/server.js +124 -0
  22. package/dist/mcp/tools.d.ts +13 -0
  23. package/dist/mcp/tools.js +497 -0
  24. package/dist/oauth/authorize.d.ts +27 -0
  25. package/dist/oauth/authorize.js +438 -0
  26. package/dist/oauth/github.d.ts +28 -0
  27. package/dist/oauth/github.js +62 -0
  28. package/dist/oauth/keys.d.ts +33 -0
  29. package/dist/oauth/keys.js +100 -0
  30. package/dist/oauth/metadata.d.ts +21 -0
  31. package/dist/oauth/metadata.js +55 -0
  32. package/dist/oauth/middleware.d.ts +22 -0
  33. package/dist/oauth/middleware.js +64 -0
  34. package/dist/oauth/register.d.ts +14 -0
  35. package/dist/oauth/register.js +83 -0
  36. package/dist/oauth/token.d.ts +15 -0
  37. package/dist/oauth/token.js +178 -0
  38. package/dist/oauth/validate.d.ts +30 -0
  39. package/dist/oauth/validate.js +52 -0
  40. package/dist/resources/HistoryResource.d.ts +38 -0
  41. package/dist/resources/HistoryResource.js +38 -0
  42. package/dist/resources/KnowledgeEntryResource.d.ts +64 -0
  43. package/dist/resources/KnowledgeEntryResource.js +157 -0
  44. package/dist/resources/QueryLogResource.d.ts +20 -0
  45. package/dist/resources/QueryLogResource.js +57 -0
  46. package/dist/resources/ServiceKeyResource.d.ts +51 -0
  47. package/dist/resources/ServiceKeyResource.js +132 -0
  48. package/dist/resources/TagResource.d.ts +25 -0
  49. package/dist/resources/TagResource.js +32 -0
  50. package/dist/resources/TriageResource.d.ts +51 -0
  51. package/dist/resources/TriageResource.js +107 -0
  52. package/dist/types.d.ts +317 -0
  53. package/dist/types.js +7 -0
  54. package/dist/webhooks/datadog.d.ts +26 -0
  55. package/dist/webhooks/datadog.js +120 -0
  56. package/dist/webhooks/github.d.ts +24 -0
  57. package/dist/webhooks/github.js +167 -0
  58. package/dist/webhooks/middleware.d.ts +14 -0
  59. package/dist/webhooks/middleware.js +161 -0
  60. package/dist/webhooks/types.d.ts +17 -0
  61. package/dist/webhooks/types.js +4 -0
  62. package/package.json +72 -0
  63. package/schema/knowledge.graphql +134 -0
  64. package/web/index.html +735 -0
  65. package/web/js/app.js +461 -0
  66. package/web/js/detail.js +223 -0
  67. package/web/js/editor.js +303 -0
  68. package/web/js/search.js +238 -0
  69. package/web/js/triage.js +305 -0
@@ -0,0 +1,438 @@
1
+ /**
2
+ * OAuth Authorization Endpoint
3
+ *
4
+ * GET /oauth/authorize — MCP OAuth 2.1 authorization endpoint.
5
+ *
6
+ * Shows a login page with GitHub as the primary auth method and a
7
+ * subtle link to fall back to Harper credentials. If the user has an
8
+ * active session (from a prior GitHub login), issues an auth code
9
+ * immediately.
10
+ *
11
+ * Org membership is checked against the ALLOWED_GITHUB_ORGS env var
12
+ * for GitHub logins. Harper credential logins bypass org checks.
13
+ */
14
+ import crypto from "node:crypto";
15
+ import { readBody, parseFormBody } from "../http-utils.js";
16
+ import { checkOrgMembership } from "./github.js";
17
+ /**
18
+ * Handle GET /oauth/authorize
19
+ *
20
+ * Three modes:
21
+ * 1. Returning from GitHub login (`pending` param) — complete authorization.
22
+ * 2. User already has a session — issue auth code directly.
23
+ * 3. First visit — show login page with GitHub button + Harper credentials.
24
+ */
25
+ export async function handleAuthorizeGet(request) {
26
+ const url = new URL(request.url || request.pathname || "/", `http://${request.host || "localhost"}`);
27
+ const pendingId = url.searchParams.get("pending");
28
+ // Returning from GitHub login — complete the authorization
29
+ if (pendingId) {
30
+ return handleAuthorizeComplete(request, pendingId);
31
+ }
32
+ // First visit — extract and validate OAuth params
33
+ const params = extractParams(url.searchParams);
34
+ const validation = await validateAuthorizeParams(params);
35
+ if (validation.error) {
36
+ return errorPage(validation.error);
37
+ }
38
+ // If user already has a session from @harperfast/oauth, issue code directly
39
+ const session = request.session;
40
+ if (session?.user && session?.oauth?.accessToken) {
41
+ return issueAuthCodeGitHub(request, params, session);
42
+ }
43
+ // Show login page
44
+ return loginPage(params);
45
+ }
46
+ /**
47
+ * Handle POST /oauth/authorize — Harper credential login.
48
+ */
49
+ export async function handleAuthorizePost(request) {
50
+ let form;
51
+ try {
52
+ const rawBody = await readBody(request);
53
+ form = parseFormBody(rawBody);
54
+ }
55
+ catch {
56
+ return errorPage("Invalid request body");
57
+ }
58
+ const username = form.username || "";
59
+ const password = form.password || "";
60
+ // Reconstruct the OAuth params from the hidden form fields
61
+ const params = {
62
+ client_id: form.client_id || "",
63
+ redirect_uri: form.redirect_uri || "",
64
+ response_type: form.response_type || "code",
65
+ state: form.state || "",
66
+ code_challenge: form.code_challenge || "",
67
+ code_challenge_method: form.code_challenge_method || "S256",
68
+ scope: form.scope || "mcp:read mcp:write",
69
+ };
70
+ // Re-validate OAuth params
71
+ const validation = await validateAuthorizeParams(params);
72
+ if (validation.error) {
73
+ return errorPage(validation.error);
74
+ }
75
+ if (!username || !password) {
76
+ return loginPage(params, "Username and password are required.");
77
+ }
78
+ // Validate credentials against Harper Operations API
79
+ const valid = await validateHarperCredentials(username, password);
80
+ if (!valid) {
81
+ return loginPage(params, "Invalid username or password.");
82
+ }
83
+ // Issue authorization code for Harper user
84
+ const code = crypto.randomUUID();
85
+ await databases.kb.OAuthCode.put({
86
+ id: code,
87
+ clientId: params.client_id,
88
+ userId: `harper:${username}`,
89
+ scope: params.scope,
90
+ codeChallenge: params.code_challenge,
91
+ codeChallengeMethod: params.code_challenge_method,
92
+ redirectUri: params.redirect_uri,
93
+ type: "code",
94
+ });
95
+ const redirectUrl = new URL(params.redirect_uri);
96
+ redirectUrl.searchParams.set("code", code);
97
+ redirectUrl.searchParams.set("state", params.state);
98
+ logger?.info?.(`OAuth code issued for Harper user ${username}, client ${params.client_id}`);
99
+ return new Response(null, {
100
+ status: 302,
101
+ headers: { Location: redirectUrl.toString() },
102
+ });
103
+ }
104
+ /**
105
+ * Complete authorization after the user returns from GitHub login.
106
+ */
107
+ async function handleAuthorizeComplete(request, pendingId) {
108
+ const session = request.session;
109
+ if (!session?.user) {
110
+ return errorPage("Authentication required. Please try again.");
111
+ }
112
+ // Look up the stored OAuth params
113
+ const pending = await databases.kb.OAuthCode.get(pendingId);
114
+ if (!pending || pending.type !== "pending") {
115
+ return errorPage("Authorization session expired. Please try again.");
116
+ }
117
+ // Delete the pending record (one-time use)
118
+ await databases.kb.OAuthCode.delete(pendingId);
119
+ // Check org membership using the GitHub access token from the session
120
+ const accessToken = session?.oauth?.accessToken;
121
+ if (!accessToken) {
122
+ return errorPage("No GitHub access token in session. Please try logging in again.");
123
+ }
124
+ const authorized = await checkOrgMembership(accessToken);
125
+ if (!authorized) {
126
+ const username = session.oauthUser?.username || session.user;
127
+ logger?.warn?.(`OAuth denied: user ${username} not in allowed orgs`);
128
+ return errorPage(`User ${username} is not a member of an authorized GitHub organization.`);
129
+ }
130
+ // Issue authorization code
131
+ const code = crypto.randomUUID();
132
+ const mcpClientState = pending.userId;
133
+ const username = session.oauthUser?.username || session.user;
134
+ await databases.kb.OAuthCode.put({
135
+ id: code,
136
+ clientId: pending.clientId,
137
+ userId: `github:${username}`,
138
+ scope: pending.scope,
139
+ codeChallenge: pending.codeChallenge,
140
+ codeChallengeMethod: pending.codeChallengeMethod,
141
+ redirectUri: pending.redirectUri,
142
+ type: "code",
143
+ });
144
+ const redirectUrl = new URL(pending.redirectUri);
145
+ redirectUrl.searchParams.set("code", code);
146
+ redirectUrl.searchParams.set("state", mcpClientState);
147
+ logger?.info?.(`OAuth code issued for user ${username}, client ${pending.clientId}`);
148
+ return new Response(null, {
149
+ status: 302,
150
+ headers: { Location: redirectUrl.toString() },
151
+ });
152
+ }
153
+ /**
154
+ * Issue an auth code immediately (user already has a GitHub session).
155
+ */
156
+ async function issueAuthCodeGitHub(_request, params, session) {
157
+ const accessToken = session.oauth?.accessToken;
158
+ if (!accessToken) {
159
+ return errorPage("No GitHub access token in session. Please try logging in again.");
160
+ }
161
+ const authorized = await checkOrgMembership(accessToken);
162
+ if (!authorized) {
163
+ const username = session.oauthUser?.username || session.user;
164
+ logger?.warn?.(`OAuth denied: user ${username} not in allowed orgs`);
165
+ return errorPage(`User ${username} is not a member of an authorized GitHub organization.`);
166
+ }
167
+ const code = crypto.randomUUID();
168
+ const username = session.oauthUser?.username || session.user;
169
+ await databases.kb.OAuthCode.put({
170
+ id: code,
171
+ clientId: params.client_id,
172
+ userId: `github:${username}`,
173
+ scope: params.scope,
174
+ codeChallenge: params.code_challenge,
175
+ codeChallengeMethod: params.code_challenge_method,
176
+ redirectUri: params.redirect_uri,
177
+ type: "code",
178
+ });
179
+ const redirectUrl = new URL(params.redirect_uri);
180
+ redirectUrl.searchParams.set("code", code);
181
+ redirectUrl.searchParams.set("state", params.state);
182
+ logger?.info?.(`OAuth code issued for user ${username}, client ${params.client_id}`);
183
+ return new Response(null, {
184
+ status: 302,
185
+ headers: { Location: redirectUrl.toString() },
186
+ });
187
+ }
188
+ /**
189
+ * Validate credentials against Harper's Operations API (localhost:9925).
190
+ */
191
+ async function validateHarperCredentials(username, password) {
192
+ try {
193
+ const response = await fetch("http://localhost:9925/", {
194
+ method: "POST",
195
+ headers: {
196
+ "Content-Type": "application/json",
197
+ Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`,
198
+ },
199
+ body: JSON.stringify({ operation: "user_info" }),
200
+ });
201
+ return response.ok;
202
+ }
203
+ catch (error) {
204
+ logger?.error?.("Harper credential validation failed:", error.message);
205
+ return false;
206
+ }
207
+ }
208
+ function extractParams(searchParams) {
209
+ return {
210
+ client_id: searchParams.get("client_id") || "",
211
+ redirect_uri: searchParams.get("redirect_uri") || "",
212
+ response_type: searchParams.get("response_type") || "",
213
+ state: searchParams.get("state") || "",
214
+ code_challenge: searchParams.get("code_challenge") || "",
215
+ code_challenge_method: searchParams.get("code_challenge_method") || "",
216
+ scope: searchParams.get("scope") || "mcp:read mcp:write",
217
+ };
218
+ }
219
+ async function validateAuthorizeParams(params) {
220
+ if (!params.client_id) {
221
+ return { error: "Missing required parameter: client_id" };
222
+ }
223
+ if (!params.redirect_uri) {
224
+ return { error: "Missing required parameter: redirect_uri" };
225
+ }
226
+ if (params.response_type !== "code") {
227
+ return { error: 'response_type must be "code"' };
228
+ }
229
+ if (!params.state) {
230
+ return { error: "Missing required parameter: state" };
231
+ }
232
+ if (!params.code_challenge) {
233
+ return {
234
+ error: "Missing required parameter: code_challenge (PKCE is required)",
235
+ };
236
+ }
237
+ if (params.code_challenge_method !== "S256") {
238
+ return { error: 'code_challenge_method must be "S256"' };
239
+ }
240
+ // Validate client exists
241
+ const client = await databases.kb.OAuthClient.get(params.client_id);
242
+ if (!client) {
243
+ return { error: "Unknown client_id" };
244
+ }
245
+ // Validate redirect_uri matches registration (exact match)
246
+ const registeredUris = client.redirectUris;
247
+ if (!registeredUris || !registeredUris.includes(params.redirect_uri)) {
248
+ return {
249
+ error: "redirect_uri does not match any registered URI for this client",
250
+ };
251
+ }
252
+ return {};
253
+ }
254
+ /**
255
+ * Build the GitHub login redirect URL, stashing OAuth params in the DB.
256
+ */
257
+ async function buildGitHubLoginUrl(params) {
258
+ const pendingId = crypto.randomUUID();
259
+ await databases.kb.OAuthCode.put({
260
+ id: pendingId,
261
+ clientId: params.client_id,
262
+ userId: params.state,
263
+ scope: params.scope,
264
+ codeChallenge: params.code_challenge,
265
+ codeChallengeMethod: params.code_challenge_method,
266
+ redirectUri: params.redirect_uri,
267
+ type: "pending",
268
+ });
269
+ const returnPath = `/oauth/authorize?pending=${pendingId}`;
270
+ return `/oauth/github/login?redirect=${encodeURIComponent(returnPath)}`;
271
+ }
272
+ /**
273
+ * Render the login page.
274
+ */
275
+ async function loginPage(params, errorMsg) {
276
+ const githubLoginUrl = await buildGitHubLoginUrl(params);
277
+ const clientName = (await databases.kb.OAuthClient.get(params.client_id))
278
+ ?.clientName || params.client_id;
279
+ const errorHtml = errorMsg
280
+ ? `<div class="error-msg">${escapeHtml(errorMsg)}</div>`
281
+ : "";
282
+ const html = `<!DOCTYPE html>
283
+ <html lang="en">
284
+ <head>
285
+ <meta charset="utf-8">
286
+ <meta name="viewport" content="width=device-width, initial-scale=1">
287
+ <title>Authorize — Harper Knowledge</title>
288
+ <style>
289
+ * { box-sizing: border-box; margin: 0; padding: 0; }
290
+ body {
291
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
292
+ background: #0f1117; color: #e1e4e8;
293
+ display: flex; align-items: center; justify-content: center;
294
+ min-height: 100vh; padding: 1rem;
295
+ }
296
+ .card {
297
+ background: #1c1f26; border: 1px solid #2d333b; border-radius: 12px;
298
+ padding: 2rem; width: 100%; max-width: 380px;
299
+ }
300
+ h1 { font-size: 1.15rem; font-weight: 600; margin-bottom: 0.25rem; }
301
+ .subtitle { color: #8b949e; font-size: 0.85rem; margin-bottom: 1.5rem; }
302
+ .client-name { color: #c9d1d9; font-weight: 500; }
303
+ .github-btn {
304
+ display: flex; align-items: center; justify-content: center; gap: 0.5rem;
305
+ width: 100%; padding: 0.7rem 1rem;
306
+ background: #238636; color: #fff; border: none; border-radius: 6px;
307
+ font-size: 0.95rem; font-weight: 500; cursor: pointer;
308
+ text-decoration: none; transition: background 0.15s;
309
+ }
310
+ .github-btn:hover { background: #2ea043; }
311
+ .github-btn svg { width: 20px; height: 20px; fill: currentColor; }
312
+ .divider {
313
+ display: flex; align-items: center; gap: 0.75rem;
314
+ margin: 1.25rem 0; color: #484f58; font-size: 0.8rem;
315
+ }
316
+ .divider::before, .divider::after {
317
+ content: ''; flex: 1; height: 1px; background: #2d333b;
318
+ }
319
+ .cred-toggle {
320
+ display: block; width: 100%; text-align: center;
321
+ color: #484f58; font-size: 0.8rem; background: none; border: none;
322
+ cursor: pointer; padding: 0.25rem; transition: color 0.15s;
323
+ }
324
+ .cred-toggle:hover { color: #8b949e; }
325
+ .cred-form { display: none; margin-top: 1rem; }
326
+ .cred-form.visible { display: block; }
327
+ label {
328
+ display: block; color: #8b949e; font-size: 0.8rem; margin-bottom: 0.25rem;
329
+ }
330
+ input[type="text"], input[type="password"] {
331
+ width: 100%; padding: 0.5rem 0.6rem;
332
+ background: #0d1117; border: 1px solid #2d333b; border-radius: 6px;
333
+ color: #e1e4e8; font-size: 0.9rem; margin-bottom: 0.75rem;
334
+ outline: none; transition: border-color 0.15s;
335
+ }
336
+ input:focus { border-color: #58a6ff; }
337
+ .submit-btn {
338
+ width: 100%; padding: 0.6rem 1rem;
339
+ background: #21262d; color: #c9d1d9; border: 1px solid #363b42;
340
+ border-radius: 6px; font-size: 0.9rem; cursor: pointer;
341
+ transition: background 0.15s, border-color 0.15s;
342
+ }
343
+ .submit-btn:hover { background: #30363d; border-color: #484f58; }
344
+ .error-msg {
345
+ background: #3d1214; border: 1px solid #da3633; border-radius: 6px;
346
+ color: #f85149; font-size: 0.85rem; padding: 0.5rem 0.75rem;
347
+ margin-bottom: 1rem;
348
+ }
349
+ </style>
350
+ </head>
351
+ <body>
352
+ <div class="card">
353
+ <h1>Authorize</h1>
354
+ <p class="subtitle"><span class="client-name">${escapeHtml(clientName)}</span> wants to access Harper Knowledge</p>
355
+ ${errorHtml}
356
+ <a href="${escapeAttr(githubLoginUrl)}" class="github-btn">
357
+ <svg viewBox="0 0 16 16"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
358
+ Sign in with GitHub
359
+ </a>
360
+ <div class="divider">or</div>
361
+ <button type="button" class="cred-toggle" onclick="document.querySelector('.cred-form').classList.toggle('visible');this.style.display='none'">
362
+ Sign in with Harper credentials
363
+ </button>
364
+ <form method="POST" action="/oauth/authorize" class="cred-form${errorMsg ? " visible" : ""}">
365
+ <input type="hidden" name="client_id" value="${escapeAttr(params.client_id)}">
366
+ <input type="hidden" name="redirect_uri" value="${escapeAttr(params.redirect_uri)}">
367
+ <input type="hidden" name="response_type" value="${escapeAttr(params.response_type)}">
368
+ <input type="hidden" name="state" value="${escapeAttr(params.state)}">
369
+ <input type="hidden" name="code_challenge" value="${escapeAttr(params.code_challenge)}">
370
+ <input type="hidden" name="code_challenge_method" value="${escapeAttr(params.code_challenge_method)}">
371
+ <input type="hidden" name="scope" value="${escapeAttr(params.scope)}">
372
+ <label for="username">Username</label>
373
+ <input type="text" id="username" name="username" autocomplete="username" required>
374
+ <label for="password">Password</label>
375
+ <input type="password" id="password" name="password" autocomplete="current-password" required>
376
+ <button type="submit" class="submit-btn">Sign in</button>
377
+ </form>
378
+ </div>
379
+ </body>
380
+ </html>`;
381
+ return new Response(html, {
382
+ status: errorMsg ? 400 : 200,
383
+ headers: {
384
+ "Content-Type": "text/html; charset=utf-8",
385
+ "X-Frame-Options": "DENY",
386
+ "X-Content-Type-Options": "nosniff",
387
+ "Referrer-Policy": "no-referrer",
388
+ "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'",
389
+ },
390
+ });
391
+ }
392
+ function errorPage(message) {
393
+ const html = `<!DOCTYPE html>
394
+ <html lang="en">
395
+ <head>
396
+ <meta charset="utf-8">
397
+ <title>Authorization Error</title>
398
+ <style>
399
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
400
+ background: #0f1117; color: #e1e4e8;
401
+ display: flex; align-items: center; justify-content: center; min-height: 100vh; }
402
+ .error { background: #1c1f26; border: 1px solid #da3633; border-radius: 12px;
403
+ padding: 2rem; max-width: 420px; }
404
+ h1 { color: #f85149; font-size: 1.1rem; margin-bottom: 0.5rem; }
405
+ </style>
406
+ </head>
407
+ <body>
408
+ <div class="error">
409
+ <h1>Authorization Error</h1>
410
+ <p>${escapeHtml(message)}</p>
411
+ </div>
412
+ </body>
413
+ </html>`;
414
+ return new Response(html, {
415
+ status: 400,
416
+ headers: {
417
+ "Content-Type": "text/html; charset=utf-8",
418
+ "X-Frame-Options": "DENY",
419
+ "X-Content-Type-Options": "nosniff",
420
+ "Referrer-Policy": "no-referrer",
421
+ "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'",
422
+ },
423
+ });
424
+ }
425
+ function escapeHtml(s) {
426
+ return s
427
+ .replace(/&/g, "&amp;")
428
+ .replace(/</g, "&lt;")
429
+ .replace(/>/g, "&gt;")
430
+ .replace(/"/g, "&quot;");
431
+ }
432
+ function escapeAttr(s) {
433
+ return s
434
+ .replace(/&/g, "&amp;")
435
+ .replace(/"/g, "&quot;")
436
+ .replace(/</g, "&lt;")
437
+ .replace(/>/g, "&gt;");
438
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * GitHub Org Membership Check
3
+ *
4
+ * Checks if a GitHub user belongs to an allowed organization.
5
+ * The actual GitHub OAuth login is handled by @harperfast/oauth —
6
+ * this module only does the org allowlist check using the session's
7
+ * GitHub access token.
8
+ *
9
+ * Configuration:
10
+ * ALLOWED_GITHUB_ORGS — Comma-separated list of allowed GitHub org logins.
11
+ * Empty/unset = deny all (secure by default).
12
+ * "*" = allow any authenticated user.
13
+ */
14
+ /**
15
+ * Parse the ALLOWED_GITHUB_ORGS environment variable.
16
+ */
17
+ export declare function getAllowedOrgs(): string[];
18
+ /**
19
+ * Check if a GitHub user belongs to an allowed org.
20
+ *
21
+ * Uses the GitHub access token from the @harperfast/oauth session
22
+ * to query the GitHub API for the user's org memberships.
23
+ *
24
+ * - Empty/unset ALLOWED_GITHUB_ORGS → deny all
25
+ * - ALLOWED_GITHUB_ORGS="*" → allow any authenticated user
26
+ * - ALLOWED_GITHUB_ORGS="org1,org2" → allow members of listed orgs
27
+ */
28
+ export declare function checkOrgMembership(accessToken: string): Promise<boolean>;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * GitHub Org Membership Check
3
+ *
4
+ * Checks if a GitHub user belongs to an allowed organization.
5
+ * The actual GitHub OAuth login is handled by @harperfast/oauth —
6
+ * this module only does the org allowlist check using the session's
7
+ * GitHub access token.
8
+ *
9
+ * Configuration:
10
+ * ALLOWED_GITHUB_ORGS — Comma-separated list of allowed GitHub org logins.
11
+ * Empty/unset = deny all (secure by default).
12
+ * "*" = allow any authenticated user.
13
+ */
14
+ const GITHUB_API_URL = "https://api.github.com";
15
+ /**
16
+ * Parse the ALLOWED_GITHUB_ORGS environment variable.
17
+ */
18
+ export function getAllowedOrgs() {
19
+ const orgsEnv = process.env.ALLOWED_GITHUB_ORGS || "";
20
+ return orgsEnv
21
+ .split(",")
22
+ .map((s) => s.trim())
23
+ .filter(Boolean);
24
+ }
25
+ /**
26
+ * Check if a GitHub user belongs to an allowed org.
27
+ *
28
+ * Uses the GitHub access token from the @harperfast/oauth session
29
+ * to query the GitHub API for the user's org memberships.
30
+ *
31
+ * - Empty/unset ALLOWED_GITHUB_ORGS → deny all
32
+ * - ALLOWED_GITHUB_ORGS="*" → allow any authenticated user
33
+ * - ALLOWED_GITHUB_ORGS="org1,org2" → allow members of listed orgs
34
+ */
35
+ export async function checkOrgMembership(accessToken) {
36
+ const allowedOrgs = getAllowedOrgs();
37
+ if (allowedOrgs.length === 0) {
38
+ return false;
39
+ }
40
+ if (allowedOrgs.includes("*")) {
41
+ return true;
42
+ }
43
+ try {
44
+ const response = await fetch(`${GITHUB_API_URL}/user/orgs`, {
45
+ headers: {
46
+ Authorization: `Bearer ${accessToken}`,
47
+ Accept: "application/vnd.github+json",
48
+ },
49
+ });
50
+ if (!response.ok) {
51
+ logger?.error?.("Failed to fetch GitHub user orgs");
52
+ return false;
53
+ }
54
+ const orgs = (await response.json());
55
+ const userOrgLogins = orgs.map((o) => o.login.toLowerCase());
56
+ return allowedOrgs.some((allowed) => userOrgLogins.includes(allowed.toLowerCase()));
57
+ }
58
+ catch (error) {
59
+ logger?.error?.("GitHub org membership check failed:", error.message);
60
+ return false;
61
+ }
62
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * OAuth RSA Key Management
3
+ *
4
+ * Generates, stores, loads, and caches an RS256 key pair for signing JWT
5
+ * access tokens. The key is stored in the OAuthSigningKey table so all
6
+ * Harper worker threads share the same key.
7
+ */
8
+ import { type JWK } from "jose";
9
+ /**
10
+ * Ensure a signing key exists. Loads from DB or generates a new one.
11
+ * Called once during handleApplication() on each worker thread.
12
+ */
13
+ export declare function ensureSigningKey(): Promise<void>;
14
+ /**
15
+ * Get the private key for signing JWTs.
16
+ * Lazily initializes if not yet loaded.
17
+ */
18
+ export declare function getPrivateKey(): Promise<CryptoKey>;
19
+ /**
20
+ * Get the public key JWK for token verification and JWKS endpoint.
21
+ * Lazily initializes if not yet loaded.
22
+ */
23
+ export declare function getPublicKeyJwk(): Promise<JWK>;
24
+ /**
25
+ * Get the key ID for the JWT header.
26
+ */
27
+ export declare function getKeyId(): string;
28
+ /**
29
+ * Get the JWKS response body for the /oauth/jwks endpoint.
30
+ */
31
+ export declare function getJwks(): Promise<{
32
+ keys: JWK[];
33
+ }>;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * OAuth RSA Key Management
3
+ *
4
+ * Generates, stores, loads, and caches an RS256 key pair for signing JWT
5
+ * access tokens. The key is stored in the OAuthSigningKey table so all
6
+ * Harper worker threads share the same key.
7
+ */
8
+ import { generateKeyPair, exportJWK, importJWK } from "jose";
9
+ const KEY_ID = "primary";
10
+ let cachedPrivateKey = null;
11
+ let cachedPublicKeyJwk = null;
12
+ /**
13
+ * Ensure a signing key exists. Loads from DB or generates a new one.
14
+ * Called once during handleApplication() on each worker thread.
15
+ */
16
+ export async function ensureSigningKey() {
17
+ const existing = await databases.kb.OAuthSigningKey.get(KEY_ID);
18
+ if (existing && existing.publicKeyJwk && existing.privateKeyJwk) {
19
+ cachedPublicKeyJwk = toJwk(existing.publicKeyJwk);
20
+ cachedPrivateKey = (await importJWK(toJwk(existing.privateKeyJwk), "RS256"));
21
+ logger?.info?.("OAuth signing key loaded from database");
22
+ return;
23
+ }
24
+ // Generate a new RSA key pair
25
+ const { publicKey, privateKey } = await generateKeyPair("RS256", {
26
+ extractable: true,
27
+ });
28
+ const pubJwk = await exportJWK(publicKey);
29
+ const privJwk = await exportJWK(privateKey);
30
+ pubJwk.kid = KEY_ID;
31
+ pubJwk.use = "sig";
32
+ pubJwk.alg = "RS256";
33
+ try {
34
+ await databases.kb.OAuthSigningKey.put({
35
+ id: KEY_ID,
36
+ publicKeyJwk: JSON.stringify(pubJwk),
37
+ privateKeyJwk: JSON.stringify(privJwk),
38
+ algorithm: "RS256",
39
+ });
40
+ }
41
+ catch {
42
+ // Another worker may have created the key concurrently — reload
43
+ const reloaded = await databases.kb.OAuthSigningKey.get(KEY_ID);
44
+ if (reloaded && reloaded.publicKeyJwk && reloaded.privateKeyJwk) {
45
+ cachedPublicKeyJwk = toJwk(reloaded.publicKeyJwk);
46
+ cachedPrivateKey = (await importJWK(toJwk(reloaded.privateKeyJwk), "RS256"));
47
+ logger?.info?.("OAuth signing key loaded after concurrent creation");
48
+ return;
49
+ }
50
+ throw new Error("Failed to create or load OAuth signing key");
51
+ }
52
+ cachedPrivateKey = privateKey;
53
+ cachedPublicKeyJwk = pubJwk;
54
+ logger?.info?.("OAuth signing key generated and stored");
55
+ }
56
+ /**
57
+ * Get the private key for signing JWTs.
58
+ * Lazily initializes if not yet loaded.
59
+ */
60
+ export async function getPrivateKey() {
61
+ if (!cachedPrivateKey) {
62
+ await ensureSigningKey();
63
+ }
64
+ return cachedPrivateKey;
65
+ }
66
+ /**
67
+ * Get the public key JWK for token verification and JWKS endpoint.
68
+ * Lazily initializes if not yet loaded.
69
+ */
70
+ export async function getPublicKeyJwk() {
71
+ if (!cachedPublicKeyJwk) {
72
+ await ensureSigningKey();
73
+ }
74
+ return cachedPublicKeyJwk;
75
+ }
76
+ /**
77
+ * Get the key ID for the JWT header.
78
+ */
79
+ export function getKeyId() {
80
+ return KEY_ID;
81
+ }
82
+ /**
83
+ * Get the JWKS response body for the /oauth/jwks endpoint.
84
+ */
85
+ export async function getJwks() {
86
+ return { keys: [await getPublicKeyJwk()] };
87
+ }
88
+ /**
89
+ * Normalize a value from Harper's table into a JWK object.
90
+ * Harper's `Any` column type may return the JWK as a string or wrapped object.
91
+ */
92
+ function toJwk(value) {
93
+ if (typeof value === "string") {
94
+ return JSON.parse(value);
95
+ }
96
+ if (value && typeof value === "object") {
97
+ return value;
98
+ }
99
+ throw new Error(`Cannot convert ${typeof value} to JWK`);
100
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * OAuth Metadata Endpoints
3
+ *
4
+ * Serves RFC 9728 Protected Resource Metadata and RFC 8414 Authorization
5
+ * Server Metadata at their well-known URLs.
6
+ */
7
+ import type { HarperRequest } from "../types.ts";
8
+ /**
9
+ * Handle GET /.well-known/oauth-protected-resource
10
+ *
11
+ * RFC 9728 — tells MCP clients where to find the authorization server
12
+ * and what scopes are supported.
13
+ */
14
+ export declare function handleProtectedResourceMetadata(request: HarperRequest): Response;
15
+ /**
16
+ * Handle GET /.well-known/oauth-authorization-server
17
+ *
18
+ * RFC 8414 — describes the authorization server's capabilities,
19
+ * endpoints, and supported grant types.
20
+ */
21
+ export declare function handleAuthServerMetadata(request: HarperRequest): Response;