shraga 0.0.3 → 0.1.1
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/README.md +82 -23
- package/defaults/agents/summarizer.md +16 -0
- package/defaults/agents/trace-extractor.md +84 -0
- package/defaults/bin/claude +45 -0
- package/defaults/bin/claude-revive +17 -0
- package/defaults/extensions/README.md +70 -0
- package/defaults/extensions/selftest.ext.ts +43 -0
- package/defaults/extensions/stripe-webhook.ext.ts +58 -0
- package/defaults/gmail-triage-prompt.md +42 -0
- package/defaults/scripts/README +4 -0
- package/defaults/scripts/agent-once.ts +67 -0
- package/defaults/scripts/backfill-slack-usernames.ts +82 -0
- package/defaults/scripts/notifier-throttle.ts +44 -0
- package/defaults/scripts/summarize-conversations.ts +5 -0
- package/defaults/shraga.config.ts +29 -0
- package/defaults/skills/add-skill.md +14 -0
- package/defaults/skills/artifacts.md +116 -0
- package/defaults/skills/code-review.md +26 -0
- package/defaults/skills/communications.md +54 -0
- package/defaults/skills/context-audit.md +87 -0
- package/defaults/skills/debug.md +10 -0
- package/defaults/skills/garden.md +179 -0
- package/defaults/skills/github-contributor.md +35 -0
- package/defaults/skills/identity.md +30 -0
- package/defaults/skills/mcp-server.md +62 -0
- package/defaults/skills/mcps-sync.md +105 -0
- package/defaults/skills/plan.md +9 -0
- package/defaults/skills/platform.md +177 -0
- package/defaults/skills/reconcile.md +239 -0
- package/defaults/skills/scheduler.md +192 -0
- package/defaults/skills/self-aware.md +136 -0
- package/defaults/skills/shraga-know.md +333 -0
- package/defaults/skills/stripe.md +55 -0
- package/defaults/skills/write-tests.md +10 -0
- package/defaults/skills-defaults.json +1 -0
- package/defaults/system-prompt.md +46 -0
- package/defaults/workspace/context.md +28 -0
- package/defaults/workspace.md +50 -0
- package/defaults/zdotdir/.gitignore +8 -0
- package/defaults/zdotdir/.zlogin +3 -0
- package/defaults/zdotdir/.zprofile +1 -0
- package/defaults/zdotdir/.zshenv +4 -0
- package/defaults/zdotdir/.zshrc +3 -0
- package/dist/client/assets/index-BoHttkMt.js +1940 -0
- package/dist/client/assets/index-DdibEb2O.css +10 -0
- package/dist/client/index.html +22 -0
- package/package.json +59 -14
- package/src/cli.ts +71 -46
- package/src/client/App.tsx +510 -0
- package/src/client/components/ArtifactCard.tsx +26 -0
- package/src/client/components/ArtifactPanel.tsx +138 -0
- package/src/client/components/AuthedImage.tsx +85 -0
- package/src/client/components/AutocompleteTextarea.tsx +149 -0
- package/src/client/components/ChatView.tsx +866 -0
- package/src/client/components/CliAuthConsent.tsx +98 -0
- package/src/client/components/ConfigPanel.tsx +328 -0
- package/src/client/components/ConversationHeader.tsx +156 -0
- package/src/client/components/ConversationPane.tsx +277 -0
- package/src/client/components/LoginPage.tsx +81 -0
- package/src/client/components/MachineStats.tsx +77 -0
- package/src/client/components/McpManager.tsx +209 -0
- package/src/client/components/MessageInput.tsx +263 -0
- package/src/client/components/OAuthConsent.tsx +103 -0
- package/src/client/components/SchedulesManager.tsx +99 -0
- package/src/client/components/Sidebar.tsx +235 -0
- package/src/client/components/SkillsManager.tsx +280 -0
- package/src/client/components/SmartChart.tsx +167 -0
- package/src/client/components/Toast.tsx +54 -0
- package/src/client/components/WorkspaceTree.tsx +313 -0
- package/src/client/components/ZoomableImage.tsx +123 -0
- package/src/client/components/artifact-presets.ts +10 -0
- package/src/client/components/schedules/ScheduleEditor.tsx +264 -0
- package/src/client/components/schedules/ScheduleList.tsx +271 -0
- package/src/client/components/ui/accordion.tsx +50 -0
- package/src/client/components/ui/button.tsx +43 -0
- package/src/client/components/ui/dialog.tsx +82 -0
- package/src/client/components/ui/input.tsx +19 -0
- package/src/client/components/ui/scroll-area.tsx +39 -0
- package/src/client/components/ui/textarea.tsx +18 -0
- package/src/client/globals.css +51 -0
- package/src/client/hooks/useAgentSocket.ts +79 -0
- package/src/client/hooks/useArtifacts.ts +89 -0
- package/src/client/hooks/useAuth.ts +127 -0
- package/src/client/hooks/useConversation.ts +412 -0
- package/src/client/hooks/useDarkMode.ts +57 -0
- package/src/client/hooks/useIsMobile.ts +23 -0
- package/src/client/hooks/usePush.ts +127 -0
- package/src/client/hooks/useSchedules.ts +73 -0
- package/src/client/hooks/useUnread.ts +238 -0
- package/src/client/lib/desktopAttention.ts +75 -0
- package/src/client/lib/firebase.ts +32 -0
- package/src/client/lib/googleAuthNative.ts +94 -0
- package/src/client/lib/native.ts +43 -0
- package/src/client/lib/schedule-types.ts +34 -0
- package/src/client/lib/sessionApi.ts +58 -0
- package/src/client/lib/slots.tsx +79 -0
- package/src/client/lib/storage.ts +39 -0
- package/src/client/lib/utils.ts +26 -0
- package/src/client/lib/workspaceContext.tsx +54 -0
- package/src/client/lib/ws.ts +203 -0
- package/src/client/main.tsx +14 -0
- package/src/mcp-stdio-bridge.ts +70 -0
- package/src/scripts/summarize-conversations.ts +5 -0
- package/src/scripts/typecheck.ts +43 -0
- package/src/server/agents.ts +54 -0
- package/src/server/api-keys.ts +63 -0
- package/src/server/artifacts/artifacts.export.ts +85 -0
- package/src/server/artifacts/artifacts.handler.ts +93 -0
- package/src/server/artifacts/artifacts.routes.ts +43 -0
- package/src/server/artifacts/artifacts.service.ts +100 -0
- package/src/server/artifacts/artifacts.types.ts +31 -0
- package/src/server/auth.ts +262 -0
- package/src/server/claude.ts +394 -0
- package/src/server/commands.ts +21 -0
- package/src/server/contacts.ts +177 -0
- package/src/server/conversation-summarizer.ts +204 -0
- package/src/server/data-sync.ts +664 -0
- package/src/server/directives.ts +91 -0
- package/src/server/engine/claude-code.ts +514 -0
- package/src/server/engine/index.ts +41 -0
- package/src/server/engine/registry.ts +21 -0
- package/src/server/engine/shared.ts +47 -0
- package/src/server/engine/types.ts +48 -0
- package/src/server/env-resolve.ts +71 -0
- package/src/server/env-sanitize.ts +9 -0
- package/src/server/events/bus.ts +29 -0
- package/src/server/events/dispatcher.ts +48 -0
- package/src/server/events/routes.ts +19 -0
- package/src/server/events/types.ts +9 -0
- package/src/server/extensions.ts +101 -0
- package/src/server/features.ts +109 -0
- package/src/server/file-inject.ts +45 -0
- package/src/server/hooks.ts +142 -0
- package/src/server/idempotency.ts +25 -0
- package/src/server/index.ts +1715 -0
- package/src/server/integrity-audit.ts +132 -0
- package/src/server/mcp-catalog.ts +70 -0
- package/src/server/mcp-oauth.ts +198 -0
- package/src/server/mcp-progress.ts +45 -0
- package/src/server/mcp-server.ts +456 -0
- package/src/server/mcp-sidecar.ts +87 -0
- package/src/server/mcp.ts +291 -0
- package/src/server/model-aliases.ts +76 -0
- package/src/server/paths.ts +24 -0
- package/src/server/polls.ts +175 -0
- package/src/server/push/apns.ts +113 -0
- package/src/server/push/fcm.ts +108 -0
- package/src/server/push/push.ts +66 -0
- package/src/server/push/store.ts +84 -0
- package/src/server/push/triggers.ts +99 -0
- package/src/server/scheduler/builtins.ts +157 -0
- package/src/server/scheduler/engine.ts +432 -0
- package/src/server/scheduler/index.ts +4 -0
- package/src/server/scheduler/runner.ts +334 -0
- package/src/server/scheduler/storage.ts +98 -0
- package/src/server/scheduler/timing.ts +70 -0
- package/src/server/scheduler/types.ts +62 -0
- package/src/server/sdk-utils.ts +45 -0
- package/src/server/seed.ts +174 -0
- package/src/server/session-bus.ts +18 -0
- package/src/server/sessions.ts +559 -0
- package/src/server/shraga-config.ts +167 -0
- package/src/server/skills.ts +372 -0
- package/src/server/slack/api.ts +37 -0
- package/src/server/slack/bot.ts +391 -0
- package/src/server/slack/context-cache.ts +42 -0
- package/src/server/slack/feature.ts +59 -0
- package/src/server/slack/mention-rewrite.ts +59 -0
- package/src/server/slack/oauth.ts +102 -0
- package/src/server/slack/questions.ts +112 -0
- package/src/server/slack/sessions.ts +139 -0
- package/src/server/stats.ts +106 -0
- package/src/server/summarize.ts +11 -0
- package/src/server/turn-context.ts +61 -0
- package/src/server/unclaw-config.ts +19 -0
- package/src/server/unread.ts +79 -0
- package/src/server/user-context.ts +33 -0
- package/src/server/vendor-sync.ts +52 -0
- package/src/server/voice-provider.ts +74 -0
- package/src/server/workspace.ts +249 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { dataPath } from '../paths.ts';
|
|
4
|
+
|
|
5
|
+
const PREFIX = '[artifacts:export]';
|
|
6
|
+
|
|
7
|
+
let browserInstance: any = null;
|
|
8
|
+
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
9
|
+
const IDLE_TIMEOUT = 60_000;
|
|
10
|
+
|
|
11
|
+
async function getBrowser() {
|
|
12
|
+
if (browserInstance) {
|
|
13
|
+
resetIdleTimer();
|
|
14
|
+
return browserInstance;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let puppeteer: any;
|
|
18
|
+
try {
|
|
19
|
+
puppeteer = await import('puppeteer');
|
|
20
|
+
} catch {
|
|
21
|
+
throw new Error('puppeteer not installed. Run: bun add puppeteer');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
console.log(PREFIX, 'launching browser');
|
|
25
|
+
browserInstance = await puppeteer.default.launch({
|
|
26
|
+
headless: true,
|
|
27
|
+
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
browserInstance.on('disconnected', () => { browserInstance = null; });
|
|
31
|
+
resetIdleTimer();
|
|
32
|
+
return browserInstance;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function resetIdleTimer() {
|
|
36
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
37
|
+
idleTimer = setTimeout(async () => {
|
|
38
|
+
if (browserInstance) {
|
|
39
|
+
console.log(PREFIX, 'closing idle browser');
|
|
40
|
+
await browserInstance.close().catch(() => {});
|
|
41
|
+
browserInstance = null;
|
|
42
|
+
}
|
|
43
|
+
}, IDLE_TIMEOUT);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function exportToPng(
|
|
47
|
+
html: string,
|
|
48
|
+
dimensions: [number, number],
|
|
49
|
+
): Promise<Buffer> {
|
|
50
|
+
const [width, height] = dimensions;
|
|
51
|
+
const browser = await getBrowser();
|
|
52
|
+
const page = await browser.newPage();
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
await page.setViewport({ width, height, deviceScaleFactor: 2 });
|
|
56
|
+
await page.setContent(html, { waitUntil: 'networkidle0', timeout: 15_000 });
|
|
57
|
+
const png = await page.screenshot({ type: 'png', clip: { x: 0, y: 0, width, height } });
|
|
58
|
+
return Buffer.from(png);
|
|
59
|
+
} finally {
|
|
60
|
+
await page.close();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function exportAndSave(
|
|
65
|
+
sessionId: string,
|
|
66
|
+
artifactId: string,
|
|
67
|
+
html: string,
|
|
68
|
+
dimensions: [number, number],
|
|
69
|
+
): Promise<string> {
|
|
70
|
+
const png = await exportToPng(html, dimensions);
|
|
71
|
+
const dir = dataPath(`sessions/${sessionId}/artifacts`);
|
|
72
|
+
mkdirSync(dir, { recursive: true });
|
|
73
|
+
const filePath = path.join(dir, `${artifactId}.png`);
|
|
74
|
+
writeFileSync(filePath, png);
|
|
75
|
+
console.log(PREFIX, `saved ${filePath} (${png.length} bytes)`);
|
|
76
|
+
return filePath;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function closeBrowser() {
|
|
80
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
81
|
+
if (browserInstance) {
|
|
82
|
+
await browserInstance.close().catch(() => {});
|
|
83
|
+
browserInstance = null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { createArtifact, updateArtifact, findByFilePath } from './artifacts.service.ts';
|
|
3
|
+
|
|
4
|
+
const PREFIX = '[artifacts:handler]';
|
|
5
|
+
const ARTIFACT_META_RE = /<!--\s*artifact:\s*(\{[^}]+\})\s*-->/;
|
|
6
|
+
|
|
7
|
+
interface ArtifactMeta {
|
|
8
|
+
title?: string;
|
|
9
|
+
dimensions?: [number, number];
|
|
10
|
+
id?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function parseMeta(html: string): ArtifactMeta | null {
|
|
14
|
+
const match = html.match(ARTIFACT_META_RE);
|
|
15
|
+
if (!match) return null;
|
|
16
|
+
try { return JSON.parse(match[1]); }
|
|
17
|
+
catch (err) { console.error(PREFIX, 'failed to parse artifact meta:', err); return null; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function emitEvent(sessionId: string, a: { id: string; title: string; dimensions: [number, number]; version: number }) {
|
|
21
|
+
return { type: 'artifact', id: a.id, sessionId, title: a.title, dimensions: a.dimensions, version: a.version };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Intercepts Write and Edit tool calls to detect artifact creation/updates.
|
|
26
|
+
* - Write: full content available in input, parsed directly
|
|
27
|
+
* - Edit: file already modified on disk by SDK; re-read to detect artifact + refresh
|
|
28
|
+
*/
|
|
29
|
+
export function handleArtifactToolUse(sessionId: string, tool: string, input: unknown): object | null {
|
|
30
|
+
if (tool === 'Write') return handleWrite(sessionId, input);
|
|
31
|
+
if (tool === 'Edit') return handleEdit(sessionId, input);
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function handleWrite(sessionId: string, input: unknown): object | null {
|
|
36
|
+
const inp = input as { file_path?: string; content?: string };
|
|
37
|
+
if (!inp.file_path || !inp.content) return null;
|
|
38
|
+
|
|
39
|
+
const meta = parseMeta(inp.content);
|
|
40
|
+
if (!meta) return null;
|
|
41
|
+
|
|
42
|
+
const title = meta.title || 'Untitled';
|
|
43
|
+
const dimensions = meta.dimensions || [1080, 1080];
|
|
44
|
+
|
|
45
|
+
if (meta.id) {
|
|
46
|
+
const updated = updateArtifact(sessionId, meta.id, { html: inp.content, title, dimensions });
|
|
47
|
+
if (updated) return emitEvent(sessionId, updated);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const artifact = createArtifact(sessionId, { title, html: inp.content, dimensions, filePath: inp.file_path });
|
|
51
|
+
return emitEvent(sessionId, artifact);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function handleEdit(sessionId: string, input: unknown): object | null {
|
|
55
|
+
const inp = input as { file_path?: string };
|
|
56
|
+
if (!inp.file_path) return null;
|
|
57
|
+
|
|
58
|
+
// Check if this file path belongs to a known artifact
|
|
59
|
+
const existing = findByFilePath(sessionId, inp.file_path);
|
|
60
|
+
if (!existing) {
|
|
61
|
+
// Could be an Edit on a file that happens to have artifact meta (rare)
|
|
62
|
+
// We'd need the file to already exist on disk — SDK applies the edit before we see it
|
|
63
|
+
if (!existsSync(inp.file_path)) return null;
|
|
64
|
+
try {
|
|
65
|
+
const html = readFileSync(inp.file_path, 'utf-8');
|
|
66
|
+
const meta = parseMeta(html);
|
|
67
|
+
if (!meta) return null;
|
|
68
|
+
const artifact = createArtifact(sessionId, {
|
|
69
|
+
title: meta.title || 'Untitled',
|
|
70
|
+
html,
|
|
71
|
+
dimensions: meta.dimensions || [1080, 1080],
|
|
72
|
+
filePath: inp.file_path,
|
|
73
|
+
});
|
|
74
|
+
return emitEvent(sessionId, artifact);
|
|
75
|
+
} catch { return null; }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Known artifact — re-read from disk to get updated content
|
|
79
|
+
if (!existsSync(inp.file_path)) return null;
|
|
80
|
+
try {
|
|
81
|
+
const html = readFileSync(inp.file_path, 'utf-8');
|
|
82
|
+
const meta = parseMeta(html);
|
|
83
|
+
const updated = updateArtifact(sessionId, existing.id, {
|
|
84
|
+
html,
|
|
85
|
+
title: meta?.title || existing.title,
|
|
86
|
+
dimensions: meta?.dimensions || existing.dimensions,
|
|
87
|
+
});
|
|
88
|
+
if (updated) return emitEvent(sessionId, updated);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
console.error(PREFIX, 'failed to re-read edited artifact:', err);
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Router, type Request } from 'express';
|
|
2
|
+
import { requireAuth } from '../auth.ts';
|
|
3
|
+
import { getArtifact, getArtifactHtml, listArtifacts } from './artifacts.service.ts';
|
|
4
|
+
import { exportToPng } from './artifacts.export.ts';
|
|
5
|
+
|
|
6
|
+
const PREFIX = '[artifacts:http]';
|
|
7
|
+
|
|
8
|
+
export const artifactsRouter = Router();
|
|
9
|
+
|
|
10
|
+
artifactsRouter.get('/api/artifacts/:sid', requireAuth, (req: Request<{ sid: string }>, res) => {
|
|
11
|
+
const artifacts = listArtifacts(req.params.sid);
|
|
12
|
+
res.json(artifacts);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// No auth — served as iframe src (session IDs are unguessable UUIDs)
|
|
16
|
+
artifactsRouter.get('/api/artifacts/:sid/:id', (req: Request<{ sid: string; id: string }>, res) => {
|
|
17
|
+
const html = getArtifactHtml(req.params.sid, req.params.id);
|
|
18
|
+
if (!html) return res.sendStatus(404);
|
|
19
|
+
res.type('html').send(html);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
artifactsRouter.get('/api/artifacts/:sid/:id/meta', requireAuth, (req: Request<{ sid: string; id: string }>, res) => {
|
|
23
|
+
const artifact = getArtifact(req.params.sid, req.params.id);
|
|
24
|
+
if (!artifact) return res.sendStatus(404);
|
|
25
|
+
res.json(artifact.meta);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
artifactsRouter.post('/api/artifacts/:sid/:id/export', requireAuth, async (req: Request<{ sid: string; id: string }>, res) => {
|
|
29
|
+
const { sid, id } = req.params;
|
|
30
|
+
const artifact = getArtifact(sid, id);
|
|
31
|
+
if (!artifact) return res.sendStatus(404);
|
|
32
|
+
|
|
33
|
+
const dimensions: [number, number] = req.body?.dimensions ?? artifact.meta.dimensions;
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const png = await exportToPng(artifact.html, dimensions);
|
|
37
|
+
console.log(PREFIX, `exported ${id} → ${png.length} bytes`);
|
|
38
|
+
res.type('png').send(png);
|
|
39
|
+
} catch (err: any) {
|
|
40
|
+
console.error(PREFIX, 'export failed:', err.message);
|
|
41
|
+
res.status(500).json({ error: err.message });
|
|
42
|
+
}
|
|
43
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { dataPath } from '../paths.ts';
|
|
4
|
+
import type { Artifact, ArtifactIndex } from './artifacts.types.ts';
|
|
5
|
+
|
|
6
|
+
const PREFIX = '[artifacts]';
|
|
7
|
+
|
|
8
|
+
function artifactsDir(sessionId: string): string {
|
|
9
|
+
return dataPath(`sessions/${sessionId}/artifacts`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function indexPath(sessionId: string): string {
|
|
13
|
+
return path.join(artifactsDir(sessionId), '_index.json');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function htmlPath(sessionId: string, id: string): string {
|
|
17
|
+
return path.join(artifactsDir(sessionId), `${id}.html`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function loadIndex(sessionId: string): ArtifactIndex {
|
|
21
|
+
const p = indexPath(sessionId);
|
|
22
|
+
if (!existsSync(p)) return { artifacts: [] };
|
|
23
|
+
try { return JSON.parse(readFileSync(p, 'utf-8')); }
|
|
24
|
+
catch { return { artifacts: [] }; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function saveIndex(sessionId: string, index: ArtifactIndex): void {
|
|
28
|
+
const dir = artifactsDir(sessionId);
|
|
29
|
+
mkdirSync(dir, { recursive: true });
|
|
30
|
+
writeFileSync(indexPath(sessionId), JSON.stringify(index, null, 2));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function generateId(): string {
|
|
34
|
+
return `art_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createArtifact(sessionId: string, opts: { title: string; html: string; dimensions?: [number, number]; filePath?: string }): Artifact {
|
|
38
|
+
const id = generateId();
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
const artifact: Artifact = {
|
|
41
|
+
id,
|
|
42
|
+
sessionId,
|
|
43
|
+
title: opts.title,
|
|
44
|
+
dimensions: opts.dimensions ?? [1080, 1080],
|
|
45
|
+
version: 1,
|
|
46
|
+
createdAt: now,
|
|
47
|
+
updatedAt: now,
|
|
48
|
+
filePath: opts.filePath,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const dir = artifactsDir(sessionId);
|
|
52
|
+
mkdirSync(dir, { recursive: true });
|
|
53
|
+
writeFileSync(htmlPath(sessionId, id), opts.html, 'utf-8');
|
|
54
|
+
|
|
55
|
+
const index = loadIndex(sessionId);
|
|
56
|
+
index.artifacts.push(artifact);
|
|
57
|
+
saveIndex(sessionId, index);
|
|
58
|
+
|
|
59
|
+
console.log(PREFIX, `created ${id} "${opts.title}" [${artifact.dimensions.join('x')}]`);
|
|
60
|
+
return artifact;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function updateArtifact(sessionId: string, id: string, opts: { html?: string; title?: string; dimensions?: [number, number] }): Artifact | null {
|
|
64
|
+
const index = loadIndex(sessionId);
|
|
65
|
+
const artifact = index.artifacts.find(a => a.id === id);
|
|
66
|
+
if (!artifact) return null;
|
|
67
|
+
|
|
68
|
+
if (opts.html) writeFileSync(htmlPath(sessionId, id), opts.html, 'utf-8');
|
|
69
|
+
if (opts.title) artifact.title = opts.title;
|
|
70
|
+
if (opts.dimensions) artifact.dimensions = opts.dimensions;
|
|
71
|
+
artifact.version++;
|
|
72
|
+
artifact.updatedAt = Date.now();
|
|
73
|
+
|
|
74
|
+
saveIndex(sessionId, index);
|
|
75
|
+
console.log(PREFIX, `updated ${id} v${artifact.version}`);
|
|
76
|
+
return artifact;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function getArtifact(sessionId: string, id: string): { meta: Artifact; html: string } | null {
|
|
80
|
+
const index = loadIndex(sessionId);
|
|
81
|
+
const meta = index.artifacts.find(a => a.id === id);
|
|
82
|
+
if (!meta) return null;
|
|
83
|
+
const hp = htmlPath(sessionId, id);
|
|
84
|
+
if (!existsSync(hp)) return null;
|
|
85
|
+
return { meta, html: readFileSync(hp, 'utf-8') };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function listArtifacts(sessionId: string): Artifact[] {
|
|
89
|
+
return loadIndex(sessionId).artifacts;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function findByFilePath(sessionId: string, filePath: string): Artifact | null {
|
|
93
|
+
return loadIndex(sessionId).artifacts.find(a => a.filePath === filePath) ?? null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function getArtifactHtml(sessionId: string, id: string): string | null {
|
|
97
|
+
const hp = htmlPath(sessionId, id);
|
|
98
|
+
if (!existsSync(hp)) return null;
|
|
99
|
+
return readFileSync(hp, 'utf-8');
|
|
100
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface Artifact {
|
|
2
|
+
id: string;
|
|
3
|
+
sessionId: string;
|
|
4
|
+
title: string;
|
|
5
|
+
dimensions: [number, number];
|
|
6
|
+
version: number;
|
|
7
|
+
createdAt: number;
|
|
8
|
+
updatedAt: number;
|
|
9
|
+
/** Original file path the agent wrote to (for Edit detection) */
|
|
10
|
+
filePath?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ArtifactIndex {
|
|
14
|
+
artifacts: Artifact[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const DIMENSION_PRESETS: Record<string, [number, number]> = {
|
|
18
|
+
'fb-feed': [1080, 1080],
|
|
19
|
+
'fb-story': [1080, 1920],
|
|
20
|
+
'fb-landscape': [1200, 628],
|
|
21
|
+
'ig-square': [1080, 1080],
|
|
22
|
+
'ig-story': [1080, 1920],
|
|
23
|
+
'ig-landscape': [1080, 566],
|
|
24
|
+
'banner-leaderboard': [728, 90],
|
|
25
|
+
'banner-medium': [300, 250],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export class ArtifactOptions {
|
|
29
|
+
tailwindVersion = '3.4';
|
|
30
|
+
defaultDimensions: [number, number] = [1080, 1080];
|
|
31
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { randomBytes, timingSafeEqual, createHmac, scryptSync } from 'node:crypto';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import type { Request, Response, NextFunction } from 'express';
|
|
5
|
+
|
|
6
|
+
// JwtHelper is not exported from the main edge.libx.js entry, import from build directly
|
|
7
|
+
import { JwtHelper } from 'edge.libx.js/build/helpers/jwt.js';
|
|
8
|
+
|
|
9
|
+
// Cache Google JWKS keys — avoids ~300ms fetch per token verification
|
|
10
|
+
const _jwksCache = { keys: new Map<string, any>(), expiresAt: 0 };
|
|
11
|
+
const _jwksInflight = new Map<string, Promise<any>>();
|
|
12
|
+
const JWKS_TTL = 3600_000;
|
|
13
|
+
// getGooglePublicKey is declared private on JwtHelper; we monkey-patch it (JS allows this
|
|
14
|
+
// at runtime). Bracket access asserts the type without altering behavior.
|
|
15
|
+
const _origGetKey = (JwtHelper as any)['getGooglePublicKey'].bind(JwtHelper);
|
|
16
|
+
(JwtHelper as any)['getGooglePublicKey'] = async function (kid: string) {
|
|
17
|
+
if (_jwksCache.keys.has(kid) && Date.now() < _jwksCache.expiresAt) return _jwksCache.keys.get(kid);
|
|
18
|
+
if (_jwksInflight.has(kid)) return _jwksInflight.get(kid);
|
|
19
|
+
const p = _origGetKey(kid).then((key: any) => {
|
|
20
|
+
if (key) { _jwksCache.keys.set(kid, key); _jwksCache.expiresAt = Date.now() + JWKS_TTL; }
|
|
21
|
+
_jwksInflight.delete(kid);
|
|
22
|
+
return key;
|
|
23
|
+
}, (err: any) => {
|
|
24
|
+
_jwksInflight.delete(kid);
|
|
25
|
+
throw err;
|
|
26
|
+
});
|
|
27
|
+
_jwksInflight.set(kid, p);
|
|
28
|
+
return p;
|
|
29
|
+
};
|
|
30
|
+
import { dataPath } from './paths.ts';
|
|
31
|
+
import { validateApiKey } from './api-keys.ts';
|
|
32
|
+
|
|
33
|
+
/** Server secret for signing scoped internal tokens — stable per startup. */
|
|
34
|
+
const INTERNAL_SECRET = process.env.INTERNAL_API_TOKEN || randomBytes(32).toString('hex');
|
|
35
|
+
process.env.INTERNAL_API_TOKEN = INTERNAL_SECRET;
|
|
36
|
+
const tmpDir = join(dataPath('..'), '.tmp');
|
|
37
|
+
try { mkdirSync(tmpDir, { recursive: true }); writeFileSync(join(tmpDir, '.internal-token'), INTERNAL_SECRET); } catch (e: any) { console.error('[auth] failed to write .internal-token:', e.message); }
|
|
38
|
+
|
|
39
|
+
/** Sign a scoped internal token embedding user identity. Agent subprocess uses this as INTERNAL_API_TOKEN — single env var carries both auth + identity. */
|
|
40
|
+
export function signInternalToken(uid: string, email: string): string {
|
|
41
|
+
const payload = `${uid}:${email}`;
|
|
42
|
+
const sig = createHmac('sha256', INTERNAL_SECRET).update(payload).digest('hex');
|
|
43
|
+
return `${sig}:${payload}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Verify and extract user identity from a scoped internal token. Falls back to legacy global token check. */
|
|
47
|
+
function verifyInternalToken(token: string): { uid: string; email: string } | null {
|
|
48
|
+
const firstColon = token.indexOf(':');
|
|
49
|
+
if (firstColon === 64) {
|
|
50
|
+
const sig = token.slice(0, 64);
|
|
51
|
+
const payload = token.slice(65);
|
|
52
|
+
const expected = createHmac('sha256', INTERNAL_SECRET).update(payload).digest('hex');
|
|
53
|
+
if (sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
|
|
54
|
+
const sepIdx = payload.indexOf(':');
|
|
55
|
+
if (sepIdx > 0) return { uid: payload.slice(0, sepIdx), email: payload.slice(sepIdx + 1) };
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
// Legacy: plain global token (backwards compat for old agents / direct curl)
|
|
60
|
+
if (token.length === INTERNAL_SECRET.length && timingSafeEqual(Buffer.from(token), Buffer.from(INTERNAL_SECRET))) {
|
|
61
|
+
return { uid: 'agent-internal', email: 'agent@internal' };
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Persisted secret for signing MCP OAuth tokens. Unlike INTERNAL_SECRET (random per boot
|
|
68
|
+
* unless INTERNAL_API_TOKEN is set), this is stored in data/ so issued access/refresh tokens
|
|
69
|
+
* survive restarts/deploys — otherwise every restart would force claude.ai users to re-auth.
|
|
70
|
+
*/
|
|
71
|
+
let mcpSecret: string | null = null;
|
|
72
|
+
function getMcpSecret(): string {
|
|
73
|
+
if (mcpSecret) return mcpSecret;
|
|
74
|
+
const p = dataPath('.mcp-oauth-secret');
|
|
75
|
+
try {
|
|
76
|
+
if (existsSync(p)) {
|
|
77
|
+
const s = readFileSync(p, 'utf8').trim();
|
|
78
|
+
if (s) return (mcpSecret = s);
|
|
79
|
+
}
|
|
80
|
+
mcpSecret = randomBytes(32).toString('hex');
|
|
81
|
+
writeFileSync(p, mcpSecret, { mode: 0o600 });
|
|
82
|
+
} catch (e: any) {
|
|
83
|
+
console.error('[auth] mcp-oauth secret persist failed, falling back to INTERNAL_SECRET:', e.message);
|
|
84
|
+
mcpSecret = INTERNAL_SECRET;
|
|
85
|
+
}
|
|
86
|
+
return mcpSecret;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Stateless OAuth access/refresh tokens for the MCP endpoint, signed with a persisted secret.
|
|
91
|
+
* Provider-agnostic: identity is established upstream by requireAuth (Firebase today,
|
|
92
|
+
* email-password later) — this only carries the resulting {uid, email}. No storage needed.
|
|
93
|
+
*/
|
|
94
|
+
export function signMcpToken(uid: string, email: string, kind: 'access' | 'refresh' = 'access', ttlSec = 3600): string {
|
|
95
|
+
const exp = Math.floor(Date.now() / 1000) + ttlSec;
|
|
96
|
+
const payload = `mcp:${kind}:${uid}:${email}:${exp}`;
|
|
97
|
+
const sig = createHmac('sha256', getMcpSecret()).update(payload).digest('hex');
|
|
98
|
+
return `mcp_${sig}.${Buffer.from(payload).toString('base64url')}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function verifyMcpToken(token: string): { uid: string; email: string; kind: 'access' | 'refresh' } | null {
|
|
102
|
+
if (!token.startsWith('mcp_')) return null;
|
|
103
|
+
const body = token.slice(4);
|
|
104
|
+
const dot = body.indexOf('.');
|
|
105
|
+
if (dot < 0) return null;
|
|
106
|
+
const sig = body.slice(0, dot);
|
|
107
|
+
let payload: string;
|
|
108
|
+
try { payload = Buffer.from(body.slice(dot + 1), 'base64url').toString(); } catch { return null; }
|
|
109
|
+
const expected = createHmac('sha256', getMcpSecret()).update(payload).digest('hex');
|
|
110
|
+
if (sig.length !== expected.length || !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
|
|
111
|
+
const parts = payload.split(':'); // mcp:kind:uid:email:exp (uid/email never contain ':')
|
|
112
|
+
if (parts[0] !== 'mcp' || parts.length < 5) return null;
|
|
113
|
+
const exp = Number(parts[parts.length - 1]);
|
|
114
|
+
if (!exp || Math.floor(Date.now() / 1000) > exp) return null;
|
|
115
|
+
return { kind: parts[1] as 'access' | 'refresh', uid: parts[2], email: parts.slice(3, parts.length - 1).join(':') };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const WHITELIST_PATH = dataPath('whitelist.json');
|
|
119
|
+
|
|
120
|
+
function loadWhitelist(): string[] {
|
|
121
|
+
if (!existsSync(WHITELIST_PATH)) return [];
|
|
122
|
+
try {
|
|
123
|
+
return JSON.parse(readFileSync(WHITELIST_PATH, 'utf-8'));
|
|
124
|
+
} catch {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface AuthUser {
|
|
130
|
+
uid: string;
|
|
131
|
+
email: string;
|
|
132
|
+
/** Owners can see all sessions/schedules across users (view-only bypass — mutations still restricted to owner of record). */
|
|
133
|
+
isOwner: boolean;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Owners from env: OWNERS="email1,email2" (case-insensitive). */
|
|
137
|
+
function loadOwners(): string[] {
|
|
138
|
+
return (process.env.OWNERS ?? '')
|
|
139
|
+
.split(',')
|
|
140
|
+
.map((s) => s.trim().toLowerCase())
|
|
141
|
+
.filter(Boolean);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isOwnerEmail(email?: string): boolean {
|
|
145
|
+
// Emailless tokens (e.g. anonymous Firebase users) are never owners — guard the .toLowerCase() crash.
|
|
146
|
+
return !!email && loadOwners().includes(email.toLowerCase());
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function verifyToken(token: string): Promise<AuthUser> {
|
|
150
|
+
const projectId = JSON.parse(process.env.FIREBASE_CONFIG_PROD ?? process.env.VITE_FIREBASE_CONFIG_PROD ?? '{}').projectId;
|
|
151
|
+
if (!projectId) throw new Error('FIREBASE_CONFIG_PROD not set or missing projectId');
|
|
152
|
+
let payload: any;
|
|
153
|
+
try {
|
|
154
|
+
payload = await JwtHelper.verifyFirebaseToken(token, projectId);
|
|
155
|
+
} catch (err: any) {
|
|
156
|
+
const msg = typeof err === 'string' ? err : err?.message ?? String(err);
|
|
157
|
+
if (msg.includes('aud')) throw new Error(`Token audience mismatch (expected ${projectId}). Please sign out and sign in again.`);
|
|
158
|
+
throw new Error(msg);
|
|
159
|
+
}
|
|
160
|
+
const whitelist = loadWhitelist();
|
|
161
|
+
if (whitelist.length > 0 && !whitelist.includes(payload.email)) {
|
|
162
|
+
throw new Error('User not in whitelist');
|
|
163
|
+
}
|
|
164
|
+
return { uid: payload.user_id || payload.sub, email: payload.email, isOwner: isOwnerEmail(payload.email) };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Pluggable auth provider ──────────────────────────────────────────────────
|
|
168
|
+
// Default = 'local' (username/password, zero external deps — a dev can install and run
|
|
169
|
+
// with no Firebase). Set AUTH_PROVIDER=firebase (an optional add-on) to verify Firebase
|
|
170
|
+
// ID tokens instead. The provider only turns a bearer token into an AuthUser; api-key and
|
|
171
|
+
// internal-token auth are provider-agnostic.
|
|
172
|
+
export const AUTH_PROVIDER = (process.env.AUTH_PROVIDER || 'local').toLowerCase();
|
|
173
|
+
|
|
174
|
+
interface LocalUser { email: string; salt: string; hash: string }
|
|
175
|
+
const USERS_PATH = () => dataPath('users.json');
|
|
176
|
+
function loadLocalUsers(): LocalUser[] {
|
|
177
|
+
try { return existsSync(USERS_PATH()) ? JSON.parse(readFileSync(USERS_PATH(), 'utf-8')) : []; }
|
|
178
|
+
catch (e: any) { console.error('[auth] users.json unreadable:', e.message); return []; }
|
|
179
|
+
}
|
|
180
|
+
function hashPw(password: string, salt = randomBytes(16).toString('hex')) {
|
|
181
|
+
return { salt, hash: scryptSync(password, salt, 64).toString('hex') };
|
|
182
|
+
}
|
|
183
|
+
/** Create a local user (used by `shraga user add` and first-run /api/auth/register). */
|
|
184
|
+
export function addLocalUser(email: string, password: string): void {
|
|
185
|
+
const users = loadLocalUsers();
|
|
186
|
+
if (users.some((u) => u.email === email)) throw new Error(`user ${email} already exists`);
|
|
187
|
+
users.push({ email, ...hashPw(password) });
|
|
188
|
+
writeFileSync(USERS_PATH(), JSON.stringify(users, null, 2));
|
|
189
|
+
}
|
|
190
|
+
export function localUserCount(): number { return loadLocalUsers().length; }
|
|
191
|
+
|
|
192
|
+
// Persisted secret so local session tokens survive restarts.
|
|
193
|
+
let _localSecret: string | null = null;
|
|
194
|
+
function localSecret(): string {
|
|
195
|
+
if (_localSecret) return _localSecret;
|
|
196
|
+
const p = dataPath('.local-auth-secret');
|
|
197
|
+
try {
|
|
198
|
+
if (existsSync(p)) return (_localSecret = readFileSync(p, 'utf8').trim());
|
|
199
|
+
_localSecret = randomBytes(32).toString('hex');
|
|
200
|
+
writeFileSync(p, _localSecret, { mode: 0o600 });
|
|
201
|
+
} catch { _localSecret = INTERNAL_SECRET; }
|
|
202
|
+
return _localSecret;
|
|
203
|
+
}
|
|
204
|
+
/** Issue a signed local session token: sha_<sig>.<base64url(email:exp)>. */
|
|
205
|
+
export function localLogin(email: string, password: string): string | null {
|
|
206
|
+
const rec = loadLocalUsers().find((u) => u.email === email);
|
|
207
|
+
if (!rec) return null;
|
|
208
|
+
const { hash } = hashPw(password, rec.salt);
|
|
209
|
+
if (hash.length !== rec.hash.length || !timingSafeEqual(Buffer.from(hash), Buffer.from(rec.hash))) return null;
|
|
210
|
+
const exp = Math.floor(Date.now() / 1000) + 30 * 24 * 3600;
|
|
211
|
+
const payload = `${email}:${exp}`;
|
|
212
|
+
const sig = createHmac('sha256', localSecret()).update(payload).digest('hex');
|
|
213
|
+
return `sha_${sig}.${Buffer.from(payload).toString('base64url')}`;
|
|
214
|
+
}
|
|
215
|
+
function verifyLocalToken(token: string): AuthUser {
|
|
216
|
+
if (!token.startsWith('sha_')) throw new Error('Invalid session token');
|
|
217
|
+
const body = token.slice(4);
|
|
218
|
+
const dot = body.indexOf('.');
|
|
219
|
+
if (dot < 0) throw new Error('Malformed token');
|
|
220
|
+
const sig = body.slice(0, dot);
|
|
221
|
+
const payload = Buffer.from(body.slice(dot + 1), 'base64url').toString();
|
|
222
|
+
const expected = createHmac('sha256', localSecret()).update(payload).digest('hex');
|
|
223
|
+
if (sig.length !== expected.length || !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) throw new Error('Bad token signature');
|
|
224
|
+
const sep = payload.lastIndexOf(':');
|
|
225
|
+
const email = payload.slice(0, sep);
|
|
226
|
+
if (Math.floor(Date.now() / 1000) > Number(payload.slice(sep + 1))) throw new Error('Token expired — sign in again');
|
|
227
|
+
return { uid: email, email, isOwner: isOwnerEmail(email) };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Dispatch bearer verification to the active provider. */
|
|
231
|
+
export async function verifyBearer(token: string): Promise<AuthUser> {
|
|
232
|
+
return AUTH_PROVIDER === 'firebase' ? verifyToken(token) : verifyLocalToken(token);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export async function requireAuth(req: Request, res: Response, next: NextFunction) {
|
|
236
|
+
const internalToken = req.headers['x-internal-token'] as string | undefined;
|
|
237
|
+
if (internalToken) {
|
|
238
|
+
const identity = verifyInternalToken(internalToken);
|
|
239
|
+
if (identity) {
|
|
240
|
+
(req as any).user = { uid: identity.uid, email: identity.email, isOwner: isOwnerEmail(identity.email) } as AuthUser;
|
|
241
|
+
return next();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const token = req.headers.authorization?.replace('Bearer ', '') || (req.query.token as string);
|
|
246
|
+
if (!token) return void res.status(401).json({ error: 'Missing token' });
|
|
247
|
+
|
|
248
|
+
// API key auth (uck_…)
|
|
249
|
+
if (token.startsWith('uck_')) {
|
|
250
|
+
const identity = validateApiKey(token);
|
|
251
|
+
if (!identity) return void res.status(401).json({ error: 'Invalid API key' });
|
|
252
|
+
(req as any).user = { uid: identity.uid, email: identity.email, isOwner: isOwnerEmail(identity.email) } as AuthUser;
|
|
253
|
+
return next();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
(req as any).user = await verifyBearer(token);
|
|
258
|
+
next();
|
|
259
|
+
} catch (err: any) {
|
|
260
|
+
return void res.status(err?.message?.includes('whitelist') ? 403 : 401).json({ error: err.message });
|
|
261
|
+
}
|
|
262
|
+
}
|