shraga 0.0.3 → 0.1.2

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 (180) hide show
  1. package/README.md +82 -27
  2. package/defaults/agents/summarizer.md +16 -0
  3. package/defaults/agents/trace-extractor.md +84 -0
  4. package/defaults/bin/claude +45 -0
  5. package/defaults/bin/claude-revive +17 -0
  6. package/defaults/extensions/README.md +70 -0
  7. package/defaults/extensions/selftest.ext.ts +43 -0
  8. package/defaults/extensions/stripe-webhook.ext.ts +58 -0
  9. package/defaults/gmail-triage-prompt.md +42 -0
  10. package/defaults/scripts/README +4 -0
  11. package/defaults/scripts/agent-once.ts +67 -0
  12. package/defaults/scripts/backfill-slack-usernames.ts +82 -0
  13. package/defaults/scripts/notifier-throttle.ts +44 -0
  14. package/defaults/scripts/summarize-conversations.ts +5 -0
  15. package/defaults/shraga.config.ts +29 -0
  16. package/defaults/skills/add-skill.md +14 -0
  17. package/defaults/skills/artifacts.md +116 -0
  18. package/defaults/skills/code-review.md +26 -0
  19. package/defaults/skills/communications.md +54 -0
  20. package/defaults/skills/context-audit.md +87 -0
  21. package/defaults/skills/debug.md +10 -0
  22. package/defaults/skills/garden.md +179 -0
  23. package/defaults/skills/github-contributor.md +35 -0
  24. package/defaults/skills/identity.md +30 -0
  25. package/defaults/skills/mcp-server.md +62 -0
  26. package/defaults/skills/mcps-sync.md +105 -0
  27. package/defaults/skills/plan.md +9 -0
  28. package/defaults/skills/platform.md +177 -0
  29. package/defaults/skills/reconcile.md +239 -0
  30. package/defaults/skills/scheduler.md +192 -0
  31. package/defaults/skills/self-aware.md +136 -0
  32. package/defaults/skills/shraga-know.md +333 -0
  33. package/defaults/skills/stripe.md +55 -0
  34. package/defaults/skills/write-tests.md +10 -0
  35. package/defaults/skills-defaults.json +1 -0
  36. package/defaults/system-prompt.md +46 -0
  37. package/defaults/workspace/context.md +28 -0
  38. package/defaults/workspace.md +50 -0
  39. package/defaults/zdotdir/.gitignore +8 -0
  40. package/defaults/zdotdir/.zlogin +3 -0
  41. package/defaults/zdotdir/.zprofile +1 -0
  42. package/defaults/zdotdir/.zshenv +4 -0
  43. package/defaults/zdotdir/.zshrc +3 -0
  44. package/dist/client/assets/index-BoHttkMt.js +1940 -0
  45. package/dist/client/assets/index-DdibEb2O.css +10 -0
  46. package/dist/client/index.html +22 -0
  47. package/package.json +59 -14
  48. package/src/cli.ts +71 -46
  49. package/src/client/App.tsx +510 -0
  50. package/src/client/components/ArtifactCard.tsx +26 -0
  51. package/src/client/components/ArtifactPanel.tsx +138 -0
  52. package/src/client/components/AuthedImage.tsx +85 -0
  53. package/src/client/components/AutocompleteTextarea.tsx +149 -0
  54. package/src/client/components/ChatView.tsx +866 -0
  55. package/src/client/components/CliAuthConsent.tsx +98 -0
  56. package/src/client/components/ConfigPanel.tsx +328 -0
  57. package/src/client/components/ConversationHeader.tsx +156 -0
  58. package/src/client/components/ConversationPane.tsx +277 -0
  59. package/src/client/components/LoginPage.tsx +81 -0
  60. package/src/client/components/MachineStats.tsx +77 -0
  61. package/src/client/components/McpManager.tsx +209 -0
  62. package/src/client/components/MessageInput.tsx +263 -0
  63. package/src/client/components/OAuthConsent.tsx +103 -0
  64. package/src/client/components/SchedulesManager.tsx +99 -0
  65. package/src/client/components/Sidebar.tsx +235 -0
  66. package/src/client/components/SkillsManager.tsx +280 -0
  67. package/src/client/components/SmartChart.tsx +167 -0
  68. package/src/client/components/Toast.tsx +54 -0
  69. package/src/client/components/WorkspaceTree.tsx +313 -0
  70. package/src/client/components/ZoomableImage.tsx +123 -0
  71. package/src/client/components/artifact-presets.ts +10 -0
  72. package/src/client/components/schedules/ScheduleEditor.tsx +264 -0
  73. package/src/client/components/schedules/ScheduleList.tsx +271 -0
  74. package/src/client/components/ui/accordion.tsx +50 -0
  75. package/src/client/components/ui/button.tsx +43 -0
  76. package/src/client/components/ui/dialog.tsx +82 -0
  77. package/src/client/components/ui/input.tsx +19 -0
  78. package/src/client/components/ui/scroll-area.tsx +39 -0
  79. package/src/client/components/ui/textarea.tsx +18 -0
  80. package/src/client/globals.css +51 -0
  81. package/src/client/hooks/useAgentSocket.ts +79 -0
  82. package/src/client/hooks/useArtifacts.ts +89 -0
  83. package/src/client/hooks/useAuth.ts +127 -0
  84. package/src/client/hooks/useConversation.ts +412 -0
  85. package/src/client/hooks/useDarkMode.ts +57 -0
  86. package/src/client/hooks/useIsMobile.ts +23 -0
  87. package/src/client/hooks/usePush.ts +127 -0
  88. package/src/client/hooks/useSchedules.ts +73 -0
  89. package/src/client/hooks/useUnread.ts +238 -0
  90. package/src/client/lib/desktopAttention.ts +75 -0
  91. package/src/client/lib/firebase.ts +32 -0
  92. package/src/client/lib/googleAuthNative.ts +94 -0
  93. package/src/client/lib/native.ts +43 -0
  94. package/src/client/lib/schedule-types.ts +34 -0
  95. package/src/client/lib/sessionApi.ts +58 -0
  96. package/src/client/lib/slots.tsx +79 -0
  97. package/src/client/lib/storage.ts +39 -0
  98. package/src/client/lib/utils.ts +26 -0
  99. package/src/client/lib/workspaceContext.tsx +54 -0
  100. package/src/client/lib/ws.ts +203 -0
  101. package/src/client/main.tsx +14 -0
  102. package/src/mcp-stdio-bridge.ts +70 -0
  103. package/src/scripts/summarize-conversations.ts +5 -0
  104. package/src/scripts/typecheck.ts +43 -0
  105. package/src/server/agents.ts +54 -0
  106. package/src/server/api-keys.ts +63 -0
  107. package/src/server/artifacts/artifacts.export.ts +85 -0
  108. package/src/server/artifacts/artifacts.handler.ts +93 -0
  109. package/src/server/artifacts/artifacts.routes.ts +43 -0
  110. package/src/server/artifacts/artifacts.service.ts +100 -0
  111. package/src/server/artifacts/artifacts.types.ts +31 -0
  112. package/src/server/auth.ts +262 -0
  113. package/src/server/claude.ts +394 -0
  114. package/src/server/commands.ts +21 -0
  115. package/src/server/contacts.ts +177 -0
  116. package/src/server/conversation-summarizer.ts +204 -0
  117. package/src/server/data-sync.ts +664 -0
  118. package/src/server/directives.ts +91 -0
  119. package/src/server/engine/claude-code.ts +514 -0
  120. package/src/server/engine/index.ts +41 -0
  121. package/src/server/engine/registry.ts +21 -0
  122. package/src/server/engine/shared.ts +47 -0
  123. package/src/server/engine/types.ts +48 -0
  124. package/src/server/env-resolve.ts +71 -0
  125. package/src/server/env-sanitize.ts +9 -0
  126. package/src/server/events/bus.ts +29 -0
  127. package/src/server/events/dispatcher.ts +48 -0
  128. package/src/server/events/routes.ts +19 -0
  129. package/src/server/events/types.ts +9 -0
  130. package/src/server/extensions.ts +101 -0
  131. package/src/server/features.ts +109 -0
  132. package/src/server/file-inject.ts +45 -0
  133. package/src/server/hooks.ts +142 -0
  134. package/src/server/idempotency.ts +25 -0
  135. package/src/server/index.ts +1715 -0
  136. package/src/server/integrity-audit.ts +132 -0
  137. package/src/server/mcp-catalog.ts +70 -0
  138. package/src/server/mcp-oauth.ts +198 -0
  139. package/src/server/mcp-progress.ts +45 -0
  140. package/src/server/mcp-server.ts +456 -0
  141. package/src/server/mcp-sidecar.ts +87 -0
  142. package/src/server/mcp.ts +291 -0
  143. package/src/server/model-aliases.ts +76 -0
  144. package/src/server/paths.ts +24 -0
  145. package/src/server/polls.ts +175 -0
  146. package/src/server/push/apns.ts +113 -0
  147. package/src/server/push/fcm.ts +108 -0
  148. package/src/server/push/push.ts +66 -0
  149. package/src/server/push/store.ts +84 -0
  150. package/src/server/push/triggers.ts +99 -0
  151. package/src/server/scheduler/builtins.ts +157 -0
  152. package/src/server/scheduler/engine.ts +432 -0
  153. package/src/server/scheduler/index.ts +4 -0
  154. package/src/server/scheduler/runner.ts +334 -0
  155. package/src/server/scheduler/storage.ts +98 -0
  156. package/src/server/scheduler/timing.ts +70 -0
  157. package/src/server/scheduler/types.ts +62 -0
  158. package/src/server/sdk-utils.ts +45 -0
  159. package/src/server/seed.ts +174 -0
  160. package/src/server/session-bus.ts +18 -0
  161. package/src/server/sessions.ts +559 -0
  162. package/src/server/shraga-config.ts +167 -0
  163. package/src/server/skills.ts +372 -0
  164. package/src/server/slack/api.ts +37 -0
  165. package/src/server/slack/bot.ts +391 -0
  166. package/src/server/slack/context-cache.ts +42 -0
  167. package/src/server/slack/feature.ts +59 -0
  168. package/src/server/slack/mention-rewrite.ts +59 -0
  169. package/src/server/slack/oauth.ts +102 -0
  170. package/src/server/slack/questions.ts +112 -0
  171. package/src/server/slack/sessions.ts +139 -0
  172. package/src/server/stats.ts +106 -0
  173. package/src/server/summarize.ts +11 -0
  174. package/src/server/turn-context.ts +61 -0
  175. package/src/server/unclaw-config.ts +19 -0
  176. package/src/server/unread.ts +79 -0
  177. package/src/server/user-context.ts +33 -0
  178. package/src/server/vendor-sync.ts +52 -0
  179. package/src/server/voice-provider.ts +74 -0
  180. package/src/server/workspace.ts +249 -0
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Data integrity audit — compares current data/ against a git reference.
3
+ * Detects: missing files, truncated content, degraded JSON arrays/objects, invalid JSON.
4
+ *
5
+ * CLI: bun run src/server/integrity-audit.ts [git-ref] [data-dir]
6
+ * API: import { audit } from './integrity-audit.ts'
7
+ * Remote: run `bun run src/server/integrity-audit.ts` from $APP_DIR on the target host.
8
+ *
9
+ * When no ref is given, picks the most recent "auto: sync" commit as baseline.
10
+ * Exit code 1 if issues found.
11
+ */
12
+
13
+ import { execSync } from 'node:child_process';
14
+ import { resolve } from 'node:path';
15
+ import { readdirSync } from 'node:fs';
16
+
17
+ // Self-contained: resolve DATA_DIR without importing paths.ts so the script works standalone.
18
+ // Guard (mirrors paths.ts): never silently fall back to bare ./data when named env dirs (data-*)
19
+ // exist — that targets a stale, env-less data/ folder. An explicit [data-dir]/DATA_DIR overrides.
20
+ function resolveDataDir(): string {
21
+ if (process.argv[3]) return process.argv[3];
22
+ if (process.env.DATA_DIR) return process.env.DATA_DIR;
23
+ const root = process.cwd();
24
+ if (readdirSync(root).some((f) => f.startsWith('data-')))
25
+ throw new Error('DATA_DIR not set but named data dirs exist (data-*). Pass [data-dir] or set DATA_DIR.');
26
+ return resolve(root, 'data');
27
+ }
28
+ const DATA_DIR = resolveDataDir();
29
+
30
+ const SIZE_RATIO = 0.5;
31
+ const COUNT_SLACK = 2;
32
+ const STRUCTURAL_JSON = ['schedules.json', 'contacts.json', 'skills-defaults.json', 'api-keys.json'];
33
+
34
+ function git(cmd: string): string {
35
+ return execSync(`git -C "${DATA_DIR}" ${cmd}`, { encoding: 'utf-8' }).trim();
36
+ }
37
+
38
+ function gitShow(ref: string, file: string): string | null {
39
+ try { return execSync(`git -C "${DATA_DIR}" show ${ref}:${file}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }); }
40
+ catch { return null; }
41
+ }
42
+
43
+ function jsonEntryCount(text: string): number | null {
44
+ try {
45
+ const v = JSON.parse(text);
46
+ if (Array.isArray(v)) return v.length;
47
+ if (v && typeof v === 'object') return Object.keys(v).length;
48
+ return null;
49
+ } catch { return null; }
50
+ }
51
+
52
+ export interface Issue {
53
+ file: string;
54
+ kind: 'missing' | 'truncated' | 'degraded' | 'invalid-json';
55
+ detail: string;
56
+ }
57
+
58
+ export function audit(ref: string): Issue[] {
59
+ const issues: Issue[] = [];
60
+ const lsBlobs = (r: string) => git(`ls-tree -r ${r}`)
61
+ .split('\n').filter(Boolean)
62
+ .filter(l => !l.startsWith('160000 ')) // skip submodule gitlinks
63
+ .map(l => l.split('\t')[1]);
64
+ const refFiles = lsBlobs(ref);
65
+ const curSet = new Set(lsBlobs('HEAD'));
66
+
67
+ for (const f of refFiles) {
68
+ if (!curSet.has(f)) {
69
+ issues.push({ file: f, kind: 'missing', detail: 'in reference but not HEAD' });
70
+ continue;
71
+ }
72
+ const refContent = gitShow(ref, f);
73
+ const curContent = gitShow('HEAD', f);
74
+ if (!refContent || !curContent) continue;
75
+
76
+ // Size check
77
+ if (refContent.length > 200 && curContent.length < refContent.length * SIZE_RATIO) {
78
+ issues.push({ file: f, kind: 'truncated', detail: `${refContent.length}B → ${curContent.length}B (${Math.round(curContent.length / refContent.length * 100)}%)` });
79
+ }
80
+
81
+ // Structural JSON check
82
+ if (STRUCTURAL_JSON.includes(f)) {
83
+ const refCount = jsonEntryCount(refContent);
84
+ const curCount = jsonEntryCount(curContent);
85
+ if (refCount !== null && curCount !== null && curCount < refCount - COUNT_SLACK) {
86
+ issues.push({ file: f, kind: 'degraded', detail: `${refCount} → ${curCount} entries` });
87
+ }
88
+ }
89
+ }
90
+
91
+ // Invalid JSON check on all current .json files
92
+ for (const f of curSet) {
93
+ if (!f.endsWith('.json')) continue;
94
+ const content = gitShow('HEAD', f);
95
+ if (!content) continue;
96
+ try { JSON.parse(content); }
97
+ catch { issues.push({ file: f, kind: 'invalid-json', detail: 'parse error' }); }
98
+ }
99
+
100
+ return issues;
101
+ }
102
+
103
+ export function findBaselineRef(): string {
104
+ try {
105
+ const log = git('log --oneline -50 --format=%H\\ %s');
106
+ for (const line of log.split('\n')) {
107
+ const [hash, ...rest] = line.split(' ');
108
+ const msg = rest.join(' ');
109
+ if (msg.startsWith('auto: sync')) return hash;
110
+ }
111
+ } catch { /* no git history */ }
112
+ return 'HEAD~1';
113
+ }
114
+
115
+ // ── CLI ────────────────────────────────────────────────────────────────────
116
+
117
+ if (import.meta.main) {
118
+ const ref = process.argv[2] || findBaselineRef();
119
+ console.log(`data-audit: HEAD vs ${ref.slice(0, 8)} (${DATA_DIR})\n`);
120
+
121
+ const issues = audit(ref);
122
+ if (!issues.length) {
123
+ console.log('✓ No issues found');
124
+ process.exit(0);
125
+ }
126
+
127
+ console.log(`⚠ ${issues.length} issue(s):\n`);
128
+ for (const { kind, file, detail } of issues) {
129
+ console.log(` ${kind.padEnd(12)} ${file} — ${detail}`);
130
+ }
131
+ process.exit(1);
132
+ }
@@ -0,0 +1,70 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { DATA_DIR } from './paths.ts';
4
+
5
+ /**
6
+ * Disk-persisted MCP catalog + failure-cooldown (survives restarts, TTL-stamped).
7
+ *
8
+ * Two concerns, one file (`.mcp-catalog.json` in DATA_DIR):
9
+ * - `catalog` — discovered tool specs, keyed by the lib's config hash. Implements the sync
10
+ * get/set `McpCatalogStore` shape an add-on engine's MCP mounter expects, so a cold
11
+ * boot reads specs from disk instead of reconnecting every server.
12
+ * - `cooldown` — servers that failed/timed-out to mount, keyed by server name. Persisted so a
13
+ * known-down server (e.g. mcp-mac) isn't re-probed (10s) on the first boot after a
14
+ * restart. Both self-heal via TTL.
15
+ */
16
+ interface CatalogEntry { specs: any[]; exp: number }
17
+ interface DiskState { catalog: Record<string, CatalogEntry>; cooldown: Record<string, number> }
18
+
19
+ export class FileMcpCatalog {
20
+ private file = path.join(DATA_DIR, '.mcp-catalog.json');
21
+ private catalog = new Map<string, CatalogEntry>();
22
+ private cooldown = new Map<string, number>();
23
+
24
+ constructor(private catalogTtlMs = 24 * 60 * 60_000) {
25
+ try {
26
+ if (existsSync(this.file)) {
27
+ const raw = JSON.parse(readFileSync(this.file, 'utf-8')) as Partial<DiskState>;
28
+ for (const [k, v] of Object.entries(raw.catalog ?? {})) this.catalog.set(k, v);
29
+ for (const [k, v] of Object.entries(raw.cooldown ?? {})) this.cooldown.set(k, v);
30
+ }
31
+ } catch (e) { console.warn('[mcp-catalog] load failed:', (e as Error).message); }
32
+ }
33
+
34
+ // ── McpCatalogStore (specs) ──────────────────────────────────────────────
35
+ get(key: string): any[] | null {
36
+ const e = this.catalog.get(key);
37
+ if (!e) return null;
38
+ if (e.exp < Date.now()) { this.catalog.delete(key); return null; }
39
+ return e.specs;
40
+ }
41
+ set(key: string, specs: any[]): void {
42
+ this.catalog.set(key, { specs, exp: Date.now() + this.catalogTtlMs });
43
+ this.flush();
44
+ }
45
+
46
+ // ── Failure cooldown (server name → cooldown-until) ──────────────────────
47
+ /** Currently-cooling server names (expired entries pruned). */
48
+ cooledServers(): Set<string> {
49
+ const now = Date.now();
50
+ const out = new Set<string>();
51
+ for (const [name, until] of this.cooldown) {
52
+ if (until > now) out.add(name);
53
+ else this.cooldown.delete(name);
54
+ }
55
+ return out;
56
+ }
57
+ cool(name: string, untilMs: number): void { this.cooldown.set(name, untilMs); this.flush(); }
58
+ clearCool(name: string): void { if (this.cooldown.delete(name)) this.flush(); }
59
+
60
+ private flush(): void {
61
+ try {
62
+ mkdirSync(DATA_DIR, { recursive: true });
63
+ const state: DiskState = { catalog: Object.fromEntries(this.catalog), cooldown: Object.fromEntries(this.cooldown) };
64
+ writeFileSync(this.file, JSON.stringify(state));
65
+ } catch (e) { console.warn('[mcp-catalog] flush failed:', (e as Error).message); }
66
+ }
67
+ }
68
+
69
+ /** Shared singleton — used by an add-on engine's MCP mount path + its boot warm-up. */
70
+ export const fileMcpCatalog = new FileMcpCatalog();
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Embedded OAuth 2.1 Authorization Server for the MCP endpoint (`/mcp`).
3
+ *
4
+ * Lets claude.ai (and any MCP client following the MCP Authorization spec) connect to the
5
+ * remote MCP server via the standard discovery → DCR → authorize → token handshake, instead
6
+ * of a static `uck_` API key.
7
+ *
8
+ * Provider-agnostic by design: the only place a user proves identity is `/oauth/authorize/consent`,
9
+ * which is guarded by `requireAuth` — the same seam that handles Firebase today and email-password
10
+ * later. Access/refresh tokens are stateless HMAC tokens (see auth.ts:signMcpToken). No DB:
11
+ * clients live in a flat JSON file, auth codes in a short-TTL in-memory map.
12
+ */
13
+ import type { Express, Request, Response, NextFunction } from 'express';
14
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
15
+ import { randomBytes, createHash } from 'node:crypto';
16
+ import { requireAuth, signMcpToken, verifyMcpToken, type AuthUser } from './auth.ts';
17
+ import { dataPath } from './paths.ts';
18
+
19
+ const ACCESS_TTL = 3600; // 1h
20
+ const REFRESH_TTL = 60 * 60 * 24 * 30; // 30d
21
+ const CODE_TTL_MS = 60_000; // 1min
22
+
23
+ const CLIENTS_PATH = () => dataPath('oauth-clients.json');
24
+
25
+ interface OAuthClient {
26
+ client_id: string;
27
+ redirect_uris: string[];
28
+ client_name?: string;
29
+ created_at: number;
30
+ }
31
+
32
+ function loadClients(): Record<string, OAuthClient> {
33
+ const p = CLIENTS_PATH();
34
+ if (!existsSync(p)) return {};
35
+ try { return JSON.parse(readFileSync(p, 'utf8')); }
36
+ catch (e) { console.warn('[mcp-oauth] failed to parse oauth-clients.json:', e); return {}; }
37
+ }
38
+
39
+ function saveClients(clients: Record<string, OAuthClient>) {
40
+ writeFileSync(CLIENTS_PATH(), JSON.stringify(clients, null, 2));
41
+ }
42
+
43
+ interface AuthCode {
44
+ uid: string;
45
+ email: string;
46
+ clientId: string;
47
+ redirectUri: string;
48
+ challenge: string; // PKCE code_challenge (S256)
49
+ resource?: string;
50
+ expiresAt: number;
51
+ }
52
+ const codes = new Map<string, AuthCode>();
53
+
54
+ function pruneCodes() {
55
+ const now = Date.now();
56
+ for (const [k, v] of codes) if (v.expiresAt < now) codes.delete(k);
57
+ }
58
+
59
+ function baseUrl(req: Request): string {
60
+ const proto = (req.get('x-forwarded-proto') || req.protocol).split(',')[0];
61
+ return `${proto}://${req.get('host')}`;
62
+ }
63
+
64
+ function oauthCors(req: Request, res: Response, next: NextFunction) {
65
+ res.setHeader('Access-Control-Allow-Origin', '*');
66
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
67
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
68
+ if (req.method === 'OPTIONS') return void res.sendStatus(204);
69
+ next();
70
+ }
71
+
72
+ function pkceVerify(verifier: string, challenge: string): boolean {
73
+ const computed = createHash('sha256').update(verifier).digest('base64url');
74
+ return computed === challenge;
75
+ }
76
+
77
+ export function registerMcpOAuthRoutes(app: Express) {
78
+ // ── Discovery: Protected Resource Metadata (RFC 9728) ───────────────────────
79
+ const prm = (req: Request, res: Response) => {
80
+ const base = baseUrl(req);
81
+ res.json({
82
+ resource: `${base}/mcp`,
83
+ authorization_servers: [base],
84
+ bearer_methods_supported: ['header'],
85
+ });
86
+ };
87
+ app.get('/.well-known/oauth-protected-resource', oauthCors, prm);
88
+ app.get('/.well-known/oauth-protected-resource/mcp', oauthCors, prm); // path-suffixed probe
89
+
90
+ // ── Discovery: Authorization Server Metadata (RFC 8414) ─────────────────────
91
+ app.get('/.well-known/oauth-authorization-server', oauthCors, (req: Request, res: Response) => {
92
+ const base = baseUrl(req);
93
+ res.json({
94
+ issuer: base,
95
+ authorization_endpoint: `${base}/oauth/authorize`,
96
+ token_endpoint: `${base}/oauth/token`,
97
+ registration_endpoint: `${base}/oauth/register`,
98
+ response_types_supported: ['code'],
99
+ grant_types_supported: ['authorization_code', 'refresh_token'],
100
+ code_challenge_methods_supported: ['S256'],
101
+ token_endpoint_auth_methods_supported: ['none'],
102
+ scopes_supported: ['mcp'],
103
+ });
104
+ });
105
+
106
+ // ── Dynamic Client Registration (RFC 7591) ──────────────────────────────────
107
+ app.post('/oauth/register', oauthCors, (req: Request, res: Response) => {
108
+ const body = (req.body ?? {}) as { redirect_uris?: string[]; client_name?: string };
109
+ const redirect_uris = Array.isArray(body.redirect_uris) ? body.redirect_uris.filter((u) => typeof u === 'string') : [];
110
+ if (redirect_uris.length === 0) {
111
+ return void res.status(400).json({ error: 'invalid_redirect_uri', error_description: 'redirect_uris required' });
112
+ }
113
+ const clients = loadClients();
114
+ const client_id = `mcpc_${randomBytes(16).toString('hex')}`;
115
+ const client: OAuthClient = { client_id, redirect_uris, client_name: body.client_name, created_at: Date.now() };
116
+ clients[client_id] = client;
117
+ saveClients(clients);
118
+ console.log(`[mcp-oauth] registered client ${client_id} (${body.client_name ?? 'unnamed'})`);
119
+ res.status(201).json({
120
+ client_id,
121
+ redirect_uris,
122
+ client_name: body.client_name,
123
+ token_endpoint_auth_method: 'none',
124
+ grant_types: ['authorization_code', 'refresh_token'],
125
+ response_types: ['code'],
126
+ client_id_issued_at: Math.floor(Date.now() / 1000),
127
+ });
128
+ });
129
+
130
+ // NOTE: GET /oauth/authorize is intentionally NOT handled here — it falls through to the SPA
131
+ // (index.html), which renders the consent screen and POSTs to /oauth/authorize/consent below.
132
+
133
+ // ── Consent → issue authorization code (identity via requireAuth seam) ───────
134
+ app.post('/oauth/authorize/consent', oauthCors, requireAuth, (req: Request, res: Response) => {
135
+ const user = (req as any).user as AuthUser;
136
+ const { client_id, redirect_uri, code_challenge, code_challenge_method, resource } = (req.body ?? {}) as Record<string, string>;
137
+ if (!client_id || !redirect_uri || !code_challenge) {
138
+ return void res.status(400).json({ error: 'invalid_request', error_description: 'client_id, redirect_uri, code_challenge required' });
139
+ }
140
+ if (code_challenge_method && code_challenge_method !== 'S256') {
141
+ return void res.status(400).json({ error: 'invalid_request', error_description: 'only S256 PKCE supported' });
142
+ }
143
+ const client = loadClients()[client_id];
144
+ if (!client) return void res.status(400).json({ error: 'invalid_client' });
145
+ if (!client.redirect_uris.includes(redirect_uri)) {
146
+ return void res.status(400).json({ error: 'invalid_redirect_uri', error_description: 'redirect_uri not registered for client' });
147
+ }
148
+ pruneCodes();
149
+ const code = randomBytes(32).toString('hex');
150
+ codes.set(code, {
151
+ uid: user.uid, email: user.email, clientId: client_id, redirectUri: redirect_uri,
152
+ challenge: code_challenge, resource, expiresAt: Date.now() + CODE_TTL_MS,
153
+ });
154
+ console.log(`[mcp-oauth] issued auth code for ${user.email} → client ${client_id}`);
155
+ res.json({ code });
156
+ });
157
+
158
+ // ── Token endpoint ──────────────────────────────────────────────────────────
159
+ app.post('/oauth/token', oauthCors, (req: Request, res: Response) => {
160
+ const b = (req.body ?? {}) as Record<string, string>;
161
+ const grant = b.grant_type;
162
+
163
+ if (grant === 'authorization_code') {
164
+ pruneCodes();
165
+ const entry = b.code ? codes.get(b.code) : undefined;
166
+ if (!entry) return void res.status(400).json({ error: 'invalid_grant', error_description: 'unknown or expired code' });
167
+ codes.delete(b.code); // single-use
168
+ if (entry.redirectUri !== b.redirect_uri) return void res.status(400).json({ error: 'invalid_grant', error_description: 'redirect_uri mismatch' });
169
+ if (entry.clientId !== b.client_id) return void res.status(400).json({ error: 'invalid_grant', error_description: 'client_id mismatch' });
170
+ if (!b.code_verifier || !pkceVerify(b.code_verifier, entry.challenge)) {
171
+ return void res.status(400).json({ error: 'invalid_grant', error_description: 'PKCE verification failed' });
172
+ }
173
+ return void res.json({
174
+ access_token: signMcpToken(entry.uid, entry.email, 'access', ACCESS_TTL),
175
+ token_type: 'Bearer',
176
+ expires_in: ACCESS_TTL,
177
+ refresh_token: signMcpToken(entry.uid, entry.email, 'refresh', REFRESH_TTL),
178
+ scope: 'mcp',
179
+ });
180
+ }
181
+
182
+ if (grant === 'refresh_token') {
183
+ const id = b.refresh_token ? verifyMcpToken(b.refresh_token) : null;
184
+ if (!id || id.kind !== 'refresh') return void res.status(400).json({ error: 'invalid_grant', error_description: 'invalid refresh_token' });
185
+ return void res.json({
186
+ access_token: signMcpToken(id.uid, id.email, 'access', ACCESS_TTL),
187
+ token_type: 'Bearer',
188
+ expires_in: ACCESS_TTL,
189
+ refresh_token: signMcpToken(id.uid, id.email, 'refresh', REFRESH_TTL),
190
+ scope: 'mcp',
191
+ });
192
+ }
193
+
194
+ res.status(400).json({ error: 'unsupported_grant_type' });
195
+ });
196
+
197
+ console.log('[mcp-oauth] OAuth routes mounted (.well-known, /oauth/register, /oauth/authorize/consent, /oauth/token)');
198
+ }
@@ -0,0 +1,45 @@
1
+ import type { McpProgressReporter } from 'edge.libx.js/build/main.js';
2
+ import type { WsEvent } from './claude.ts';
3
+
4
+ const THROTTLE_MS = 1000;
5
+
6
+ /**
7
+ * Map agent stream events to MCP progress via `report` (from captureMcpProgress()).
8
+ * Throttles to ~1/s; terminal events (done/error) always flush. `report` is a no-op
9
+ * when the client didn't request progress, so this is always safe to wire up.
10
+ */
11
+ export function makeProgressEmitter(report: McpProgressReporter): (ev: WsEvent) => void {
12
+ let lastSentAt = 0;
13
+ return (ev) => {
14
+ const message = eventToMessage(ev);
15
+ if (!message) return;
16
+ const terminal = ev.type === 'done' || ev.type === 'error';
17
+ const now = Date.now();
18
+ if (!terminal && now - lastSentAt < THROTTLE_MS) return;
19
+ lastSentAt = now;
20
+ report(message);
21
+ };
22
+ }
23
+
24
+ function eventToMessage(ev: WsEvent): string {
25
+ switch (ev.type) {
26
+ case 'thinking_delta':
27
+ return prefixed('💭', ev.text);
28
+ case 'text_delta':
29
+ return prefixed('💬', ev.text);
30
+ case 'tool_use':
31
+ return `🔧 ${ev.tool}`;
32
+ case 'done':
33
+ return '✓ done';
34
+ case 'error':
35
+ return prefixed('⚠️', ev.message) || '⚠️ error';
36
+ default:
37
+ return '';
38
+ }
39
+ }
40
+
41
+ /** First non-empty line of `v`, clipped to 80 chars, with an icon — or '' if blank. */
42
+ function prefixed(icon: string, v: unknown): string {
43
+ const line = (String(v ?? '').trim().split('\n').find((l) => l.trim()) ?? '').slice(0, 80);
44
+ return line ? `${icon} ${line}` : '';
45
+ }