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.
- package/README.md +82 -27
- 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,79 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Typed extension SLOTS: the seam between the core client and any add-on bundle.
|
|
5
|
+
*
|
|
6
|
+
* The core ships an empty slot set ({}), so no core component statically imports an add-on module —
|
|
7
|
+
* the built core bundle therefore contains zero add-on code. An add-on build fills these slots at its
|
|
8
|
+
* composition root.
|
|
9
|
+
*
|
|
10
|
+
* Slot arg types are intentionally minimal + local: never import an add-on type here, or the seam
|
|
11
|
+
* leaks back into the core. Loose shapes keep the contract stable while add-ons supply the real widgets.
|
|
12
|
+
*/
|
|
13
|
+
export interface ClientSlots {
|
|
14
|
+
/** MessageInput: extra controls rendered beside the input. Arg = an opaque per-pane handle. */
|
|
15
|
+
inputAdornments?: (ctx: unknown) => ReactNode;
|
|
16
|
+
/** ConfigPanel: extra settings sub-sections. Arg = { getToken, sessionId }. */
|
|
17
|
+
settingsSections?: (ctx: { getToken: () => Promise<string | null>; sessionId?: string }) => ReactNode;
|
|
18
|
+
/** ChatView: extra renderers appended to a tool block. Arg = { block (the tool_use), result (the
|
|
19
|
+
* paired tool_result output, if any), screens }. An add-on can reconstruct a resource handle from
|
|
20
|
+
* the result. Core interprets none of it. */
|
|
21
|
+
toolRenderers?: (ctx: { block: unknown; result?: unknown; screens?: Map<string, string[]> }) => ReactNode;
|
|
22
|
+
/** ChatView: renderer for a NON-CORE (extension) message block — any block whose `type` the core
|
|
23
|
+
* doesn't recognize is handed here verbatim. Core renders such blocks as nothing on its own. */
|
|
24
|
+
extensionBlocks?: (ctx: { block: unknown; onImageClick?: (src: string) => void }) => ReactNode;
|
|
25
|
+
/** ConversationPane: a headless per-conversation controller, mounted with the pane and unmounted
|
|
26
|
+
* with it (its own effects are the conversation lifecycle hook). It may register an opaque input
|
|
27
|
+
* handle + per-send options, drive the composer, inject messages, and set status items — the seam
|
|
28
|
+
* an add-on uses to bind per-conversation resources. Core ships none, so nothing extra runs. */
|
|
29
|
+
conversationController?: (ctx: ConversationControllerCtx) => ReactNode;
|
|
30
|
+
/** ChatView + ConversationPane: inline status chips for background items. Arg = the item list. */
|
|
31
|
+
statusChips?: (items: unknown[]) => ReactNode;
|
|
32
|
+
/** ChatView (question card): subscribe external interactions → selection. Returns a cleanup. */
|
|
33
|
+
onCardInteraction?: (api: {
|
|
34
|
+
id: string;
|
|
35
|
+
questions: { question: string }[];
|
|
36
|
+
onAnswers: (answers: Record<string, string | string[]>) => void;
|
|
37
|
+
}) => (() => void) | void;
|
|
38
|
+
/** Sidebar: extra footer content. */
|
|
39
|
+
sidebarExtras?: () => ReactNode;
|
|
40
|
+
/** LoginPage: extra pre-login content. */
|
|
41
|
+
loginExtras?: () => ReactNode;
|
|
42
|
+
/** App header: extra action buttons. */
|
|
43
|
+
headerActions?: () => ReactNode;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Context handed to a {@link ClientSlots.conversationController}. Every field is generic — the core
|
|
48
|
+
* never names what an add-on does with them. The add-on's controller renders as
|
|
49
|
+
* a headless component, so its React effects run for the pane's lifetime.
|
|
50
|
+
*/
|
|
51
|
+
export interface ConversationControllerCtx {
|
|
52
|
+
sessionId: string | undefined;
|
|
53
|
+
busy: boolean;
|
|
54
|
+
/** the shared agent socket (for add-on events that aren't in the core event union). */
|
|
55
|
+
socket: unknown;
|
|
56
|
+
getToken: () => Promise<string | null>;
|
|
57
|
+
/** register an opaque per-pane handle forwarded to {@link ClientSlots.inputAdornments}. */
|
|
58
|
+
setInputCtx: (handle: unknown) => void;
|
|
59
|
+
/** register a provider of opaque send options merged into every send from this pane (read at send
|
|
60
|
+
* time). The core passes the bag straight to the server without interpreting it. */
|
|
61
|
+
setSendOptions: (get: (() => Record<string, unknown> | undefined) | undefined) => void;
|
|
62
|
+
/** set the composer text without stealing focus (e.g. live external transcription). */
|
|
63
|
+
setInputText: (text: string) => void;
|
|
64
|
+
/** send from this pane programmatically. `options` is the opaque server bag; `meta.echo === false`
|
|
65
|
+
* suppresses the user bubble (an agent-first / synthetic opener). */
|
|
66
|
+
send: (text: string, options?: Record<string, unknown>, meta?: { echo?: boolean }) => void;
|
|
67
|
+
/** append an assistant message (core or extension blocks) to this pane's transcript. */
|
|
68
|
+
appendAssistant: (blocks: Array<{ type: string; [key: string]: unknown }>) => void;
|
|
69
|
+
/** replace this pane's live status items (rendered via {@link ClientSlots.statusChips}). */
|
|
70
|
+
setStatusItems: (items: unknown[]) => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const SlotsContext = createContext<ClientSlots>({});
|
|
74
|
+
|
|
75
|
+
export const SlotsProvider = SlotsContext.Provider;
|
|
76
|
+
|
|
77
|
+
export function useSlots(): ClientSlots {
|
|
78
|
+
return useContext(SlotsContext);
|
|
79
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const LEGACY_PREFIX = 'unclaw:';
|
|
2
|
+
const PREFIX = 'shraga:';
|
|
3
|
+
|
|
4
|
+
// Deliberately NOT migrated: the legacy `unclaw_auth_token` key (underscore, so it falls outside
|
|
5
|
+
// LEGACY_PREFIX by design — not an oversight).
|
|
6
|
+
// - It's a LOCAL-auth-only key on both sides (`shraga_token` here is read only in the local
|
|
7
|
+
// branch of useAuth; Firebase mode uses the SDK's own persistence, which this never touches).
|
|
8
|
+
// - The two token formats differ and are signed with different secrets. This app's local token
|
|
9
|
+
// is `sha_<sig>.<base64url(email:exp)>` and `decodeLocalEmail` assumes that 4-char prefix,
|
|
10
|
+
// while verifyLocalToken hard-rejects anything not starting with `sha_`.
|
|
11
|
+
// Carrying it over would seat a token this server rejects into the "I'm logged in" slot — a broken
|
|
12
|
+
// half-authed state until the first 401 — instead of a clean login prompt. Re-login is correct.
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One-time rename of legacy `unclaw:*` localStorage keys to `shraga:*`.
|
|
16
|
+
*
|
|
17
|
+
* UI prefs (sidebar, drafts, artifact panel) are keyed by prefix, so a plain rename in code would
|
|
18
|
+
* silently reset every existing user. Migrating by prefix — rather than an explicit key list —
|
|
19
|
+
* also covers dynamic keys like `unclaw:draft:<id>`.
|
|
20
|
+
*
|
|
21
|
+
* Runs before first render. Existing canonical keys always win, so this can never clobber newer
|
|
22
|
+
* state, and re-running is a no-op.
|
|
23
|
+
*/
|
|
24
|
+
export function migrateLegacyStorageKeys(): void {
|
|
25
|
+
try {
|
|
26
|
+
const legacy = Object.keys(localStorage).filter(k => k.startsWith(LEGACY_PREFIX));
|
|
27
|
+
for (const oldKey of legacy) {
|
|
28
|
+
const newKey = PREFIX + oldKey.slice(LEGACY_PREFIX.length);
|
|
29
|
+
if (localStorage.getItem(newKey) === null) {
|
|
30
|
+
const v = localStorage.getItem(oldKey);
|
|
31
|
+
if (v !== null) localStorage.setItem(newKey, v);
|
|
32
|
+
}
|
|
33
|
+
localStorage.removeItem(oldKey);
|
|
34
|
+
}
|
|
35
|
+
} catch (e) {
|
|
36
|
+
// Storage can throw (disabled/full/private mode). A failed migration only costs reset prefs.
|
|
37
|
+
console.warn('[storage] legacy key migration skipped:', e instanceof Error ? e.message : String(e));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { clsx, type ClassValue } from 'clsx';
|
|
2
|
+
import { twMerge } from 'tailwind-merge';
|
|
3
|
+
|
|
4
|
+
export function cn(...inputs: ClassValue[]) {
|
|
5
|
+
return twMerge(clsx(inputs));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// crypto.randomUUID requires a secure context (HTTPS/localhost); fall back for plain HTTP
|
|
9
|
+
export function randomUUID(): string {
|
|
10
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
11
|
+
return crypto.randomUUID();
|
|
12
|
+
}
|
|
13
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
14
|
+
const r = (Math.random() * 16) | 0;
|
|
15
|
+
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Same-origin compare tolerant of malformed URLs (used by push tap-routing + instance dedup).
|
|
20
|
+
export function sameOrigin(a: string, b: string): boolean {
|
|
21
|
+
try {
|
|
22
|
+
return new URL(a).origin === new URL(b).origin;
|
|
23
|
+
} catch {
|
|
24
|
+
return a === b;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react';
|
|
2
|
+
import type { AgentSocket } from '@/lib/ws';
|
|
3
|
+
import type { ConnectionStatus } from '@/hooks/useAgentSocket';
|
|
4
|
+
|
|
5
|
+
export interface AgentConfig {
|
|
6
|
+
engine?: string;
|
|
7
|
+
model?: string;
|
|
8
|
+
permissionMode?: string;
|
|
9
|
+
maxTurns?: number;
|
|
10
|
+
allowedTools?: string[];
|
|
11
|
+
systemPrompt?: string;
|
|
12
|
+
thinking?: 'adaptive' | 'enabled' | 'disabled';
|
|
13
|
+
effort?: 'low' | 'medium' | 'high' | 'max';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Shell-stable values shared with every {@link ConversationPane}, plus the minimal session
|
|
18
|
+
* actions a pane needs (adopt a new session id, rename its tab, open another session). This build
|
|
19
|
+
* ships a single pane, but the context stays the prop-drill escape hatch. Add-on-only wiring (extra
|
|
20
|
+
* panes, per-conversation resources, feature flags) lives behind {@link ClientSlots}, not here.
|
|
21
|
+
*/
|
|
22
|
+
export interface WorkspaceContextValue {
|
|
23
|
+
socket: AgentSocket | null;
|
|
24
|
+
connectionStatus: ConnectionStatus;
|
|
25
|
+
getToken: () => Promise<string | null>;
|
|
26
|
+
token: string | null;
|
|
27
|
+
agentConfig: AgentConfig;
|
|
28
|
+
setAgentConfig: (c: AgentConfig) => void;
|
|
29
|
+
/** The currently focused conversation — a pane uses it to know when it has become active. */
|
|
30
|
+
activeSessionId: string | undefined;
|
|
31
|
+
skills: string[];
|
|
32
|
+
workspaceFiles: string[];
|
|
33
|
+
openSchedules: () => void;
|
|
34
|
+
/** Clear unread state for a session (called when a pane becomes the active/focused one). */
|
|
35
|
+
markRead: (sessionId: string) => void;
|
|
36
|
+
/** Nudge the sidebar/session list to refresh (new session, title change, turn finished). */
|
|
37
|
+
bumpSidebar: () => void;
|
|
38
|
+
/** A pane adopted/changed its session id (fresh chat → real id, or fork). */
|
|
39
|
+
assignSession: (nodeId: string, sessionId: string) => void;
|
|
40
|
+
/** Update a tab's display title. */
|
|
41
|
+
renameTab: (nodeId: string, title: string) => void;
|
|
42
|
+
/** Open a session in a (preview) tab — used by fork, toasts, sidebar, schedules. */
|
|
43
|
+
openSession: (sessionId: string, title?: string) => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const WorkspaceContext = createContext<WorkspaceContextValue | null>(null);
|
|
47
|
+
|
|
48
|
+
export const WorkspaceProvider = WorkspaceContext.Provider;
|
|
49
|
+
|
|
50
|
+
export function useWorkspace(): WorkspaceContextValue {
|
|
51
|
+
const ctx = useContext(WorkspaceContext);
|
|
52
|
+
if (!ctx) throw new Error('useWorkspace must be used within a WorkspaceProvider');
|
|
53
|
+
return ctx;
|
|
54
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
export type AskQuestion = {
|
|
2
|
+
question: string;
|
|
3
|
+
header: string;
|
|
4
|
+
multiSelect?: boolean;
|
|
5
|
+
options: { label: string; description: string; preview?: string }[];
|
|
6
|
+
};
|
|
7
|
+
export type QuestionAnswers = Record<string, string | string[]>;
|
|
8
|
+
|
|
9
|
+
export type ServerEvent =
|
|
10
|
+
| { type: 'auth_ok'; uid: string; email: string; buildId?: string }
|
|
11
|
+
| { type: 'auth_error'; message: string }
|
|
12
|
+
| { type: 'thinking_delta'; text: string; sessionId?: string }
|
|
13
|
+
| { type: 'text_delta'; text: string; sessionId?: string }
|
|
14
|
+
| { type: 'tool_use'; tool: string; toolUseId: string; input: unknown; sessionId?: string }
|
|
15
|
+
| { type: 'tool_use_input'; toolUseId: string; input: unknown; sessionId?: string }
|
|
16
|
+
| { type: 'tool_result'; toolUseId: string; output: string; sessionId?: string }
|
|
17
|
+
| { type: 'tool_result_image'; toolUseId: string; dataUrl: string; sessionId?: string }
|
|
18
|
+
| { type: 'permission_request'; id: string; tool: string; input: Record<string, unknown>; sessionId?: string }
|
|
19
|
+
| { type: 'question_request'; id: string; questions: AskQuestion[]; sessionId?: string }
|
|
20
|
+
| { type: 'session_id'; sessionId: string }
|
|
21
|
+
| { type: 'forked'; sourceSessionId: string; sessionId: string }
|
|
22
|
+
| { type: 'done'; sessionId: string; stopReason?: 'end_turn' | 'max_turns_reached' | (string & {}) }
|
|
23
|
+
| { type: 'model_resolved'; sessionId: string; model: string }
|
|
24
|
+
| { type: 'error'; message: string; sessionId?: string }
|
|
25
|
+
| { type: 'workspace_change'; action: 'created' | 'modified' | 'deleted'; path: string }
|
|
26
|
+
| { type: 'disconnected' }
|
|
27
|
+
| { type: 'reconnected' }
|
|
28
|
+
| { type: 'schedule:updated'; schedule: unknown }
|
|
29
|
+
| { type: 'schedule:deleted'; id: string }
|
|
30
|
+
| { type: 'schedule:fired'; scheduleId: string }
|
|
31
|
+
| { type: 'schedule:run_started'; scheduleId: string; sessionId: string; at: number }
|
|
32
|
+
| { type: 'schedule:run_finished'; scheduleId: string; sessionId: string; summary: unknown }
|
|
33
|
+
| { type: 'session_messages_changed'; sessionId: string }
|
|
34
|
+
| { type: 'session_title_updated'; sessionId: string; title: string }
|
|
35
|
+
| { type: 'directives'; directives: { model?: string; turns?: number; thinking?: string; engine?: string } }
|
|
36
|
+
| { type: 'session_busy'; sessionId: string; busy: boolean }
|
|
37
|
+
| { type: 'session_stream'; sessionId: string; event: { type: 'thinking_delta'; text: string } | { type: 'text_delta'; text: string } | { type: 'tool_use'; tool: string; toolUseId: string; input: unknown } | { type: 'tool_use_input'; toolUseId: string; input: unknown } | { type: 'tool_result'; toolUseId: string; output: string } }
|
|
38
|
+
| { type: 'artifact'; id: string; sessionId: string; title: string; dimensions: [number, number]; version: number }
|
|
39
|
+
| { type: 'server_restarting' }
|
|
40
|
+
| { type: 'unread'; sessionId: string; count: number; preview: string; source: 'response' | 'proactive' | 'schedule'; title?: string }
|
|
41
|
+
| { type: 'unread_sync'; sessions: Record<string, { count: number; preview: string; since: number; lastAt: number; source: string; title?: string }> }
|
|
42
|
+
| { type: 'unread_cleared'; sessionId: string }
|
|
43
|
+
| { type: 'pty_list_changed'; sessionId: string }
|
|
44
|
+
| { type: 'workspace_layout_changed' }
|
|
45
|
+
| { type: 'stats'; sample: { t: number; cpu: number; mem: number; load: number } };
|
|
46
|
+
|
|
47
|
+
type Listener = (event: ServerEvent) => void;
|
|
48
|
+
|
|
49
|
+
export class AgentSocket {
|
|
50
|
+
private ws: WebSocket | null = null;
|
|
51
|
+
private listeners = new Set<Listener>();
|
|
52
|
+
private tokenProvider: () => Promise<string | null> = async () => null;
|
|
53
|
+
private intentionalClose = false;
|
|
54
|
+
private reconnecting = false;
|
|
55
|
+
private authRetries = 0;
|
|
56
|
+
private pendingMessage: object | null = null;
|
|
57
|
+
private connectAttempts = 0;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param tokenProvider returns a *fresh* token on every call. Called on each
|
|
61
|
+
* (re)connect so an expired Firebase ID token is auto-refreshed instead of a
|
|
62
|
+
* stale one being resent forever.
|
|
63
|
+
*/
|
|
64
|
+
connect(tokenProvider: () => Promise<string | null>) {
|
|
65
|
+
this.tokenProvider = tokenProvider;
|
|
66
|
+
this.intentionalClose = false;
|
|
67
|
+
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
68
|
+
const url = `${proto}://${location.host}/ws`;
|
|
69
|
+
console.log('[ws] connecting…');
|
|
70
|
+
this.ws = new WebSocket(url);
|
|
71
|
+
|
|
72
|
+
this.ws.onopen = async () => {
|
|
73
|
+
this.connectAttempts = 0;
|
|
74
|
+
let token: string | null = null;
|
|
75
|
+
try {
|
|
76
|
+
token = await this.tokenProvider();
|
|
77
|
+
} catch (err) {
|
|
78
|
+
console.warn('[ws] token fetch failed', err);
|
|
79
|
+
}
|
|
80
|
+
if (this.ws?.readyState !== WebSocket.OPEN) return;
|
|
81
|
+
console.log('[ws] open, sending auth');
|
|
82
|
+
this.ws.send(JSON.stringify({ type: 'auth', token }));
|
|
83
|
+
if (this.reconnecting) {
|
|
84
|
+
this.reconnecting = false;
|
|
85
|
+
this.listeners.forEach((l) => l({ type: 'reconnected' }));
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
this.ws.onmessage = (e) => {
|
|
90
|
+
try {
|
|
91
|
+
const data = JSON.parse(e.data) as ServerEvent;
|
|
92
|
+
if (data.type !== 'text_delta') console.log('[ws] ←', data.type);
|
|
93
|
+
if (data.type === 'auth_ok') {
|
|
94
|
+
this.authRetries = 0;
|
|
95
|
+
this.flushPending();
|
|
96
|
+
}
|
|
97
|
+
if (data.type === 'auth_error') {
|
|
98
|
+
// Permanent failures (no fresh token can fix them) must STOP retrying — else the client
|
|
99
|
+
// hammers a reconnect every few seconds forever, flooding the server log. Two cases:
|
|
100
|
+
// - whitelist rejection (this account isn't allowed), and
|
|
101
|
+
// - token audience mismatch (the client is signed into a DIFFERENT Firebase project than the
|
|
102
|
+
// server expects — refreshing the token re-mints the same wrong `aud`, never converging).
|
|
103
|
+
// Everything else (expired token, server restarting during deploy) is transient → keep retrying.
|
|
104
|
+
const msg = data.message?.toLowerCase() ?? '';
|
|
105
|
+
const isPermanent = msg.includes('whitelist') || msg.includes('audience mismatch') || msg.includes('sign out and sign in');
|
|
106
|
+
if (isPermanent) {
|
|
107
|
+
this.intentionalClose = true;
|
|
108
|
+
} else {
|
|
109
|
+
this.authRetries++;
|
|
110
|
+
console.log(`[ws] auth failed (attempt ${this.authRetries}), will retry with fresh token`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
this.listeners.forEach((l) => l(data));
|
|
114
|
+
} catch (err) {
|
|
115
|
+
console.warn('[ws] bad frame', err);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
this.ws.onclose = (e) => {
|
|
120
|
+
console.log(`[ws] closed code=${e.code} intentional=${this.intentionalClose}`);
|
|
121
|
+
if (!this.intentionalClose) {
|
|
122
|
+
this.reconnecting = true;
|
|
123
|
+
this.connectAttempts++;
|
|
124
|
+
this.listeners.forEach((l) => l({ type: 'disconnected' }));
|
|
125
|
+
const base = this.authRetries > 0 ? Math.min(2000 * this.authRetries, 10000) : Math.min(1000 * 2 ** this.connectAttempts, 30000);
|
|
126
|
+
const jitter = Math.random() * 1000;
|
|
127
|
+
const delay = base + jitter;
|
|
128
|
+
console.log(`[ws] reconnecting in ${(delay / 1000).toFixed(1)}s (attempt ${this.connectAttempts})`);
|
|
129
|
+
setTimeout(() => this.connect(this.tokenProvider), delay);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
this.ws.onerror = (e) => console.warn('[ws] error', e);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private flushPending() {
|
|
137
|
+
if (this.pendingMessage) {
|
|
138
|
+
const msg = this.pendingMessage;
|
|
139
|
+
this.pendingMessage = null;
|
|
140
|
+
console.log('[ws] flushing pending message after reconnect');
|
|
141
|
+
if (!this.send(msg)) {
|
|
142
|
+
this.pendingMessage = msg;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
disconnect() {
|
|
148
|
+
this.intentionalClose = true;
|
|
149
|
+
this.ws?.close();
|
|
150
|
+
this.ws = null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
send(msg: object): boolean {
|
|
154
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
155
|
+
console.log('[ws] →', (msg as any).type);
|
|
156
|
+
this.ws.send(JSON.stringify(msg));
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
console.warn('[ws] send failed, readyState=', this.ws?.readyState);
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
sendOrQueue(msg: object): boolean {
|
|
164
|
+
if (this.send(msg)) return true;
|
|
165
|
+
this.pendingMessage = msg;
|
|
166
|
+
console.log('[ws] message queued for reconnect');
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
cancel(sessionId?: string) {
|
|
171
|
+
this.send({ type: 'cancel', sessionId });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
steer(text: string, sessionId?: string) {
|
|
175
|
+
this.send({ type: 'steer', text, sessionId });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
respondPermission(id: string, allow: boolean, allowAll?: boolean) {
|
|
179
|
+
this.send({ type: 'permission_response', id, allow, allowAll });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
respondQuestion(id: string, answers: QuestionAnswers) {
|
|
183
|
+
this.send({ type: 'question_response', id, answers });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
sendPresence(sessionId: string | undefined, focused: boolean) {
|
|
187
|
+
this.send({ type: 'presence', sessionId: sessionId ?? null, focused });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Page-Visibility presence for push suppression: server skips notifying the screen in view. */
|
|
191
|
+
sendClientPresence(sessionId: string | undefined, visible: boolean) {
|
|
192
|
+
this.send({ type: 'client_presence', sessionId: sessionId ?? null, visible });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
markRead(sessionId: string) {
|
|
196
|
+
this.send({ type: 'mark_read', sessionId });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
on(listener: Listener) {
|
|
200
|
+
this.listeners.add(listener);
|
|
201
|
+
return () => this.listeners.delete(listener);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { StrictMode } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import './globals.css';
|
|
4
|
+
import { App } from './App';
|
|
5
|
+
import { migrateLegacyStorageKeys } from './lib/storage';
|
|
6
|
+
|
|
7
|
+
// Must run before first render — components read their prefs during initial state setup.
|
|
8
|
+
migrateLegacyStorageKeys();
|
|
9
|
+
|
|
10
|
+
createRoot(document.getElementById('root')!).render(
|
|
11
|
+
<StrictMode>
|
|
12
|
+
<App />
|
|
13
|
+
</StrictMode>
|
|
14
|
+
);
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// stdio↔HTTP bridge for MCP (Streamable HTTP transport, maintains session ID)
|
|
3
|
+
// Generic: MCP_URL + MCP_API_KEY, or legacy: UNCLAW_URL + UNCLAW_API_KEY
|
|
4
|
+
const baseUrl = (process.env.MCP_URL || process.env.UNCLAW_URL || 'http://localhost:3033').replace(/\/$/, '');
|
|
5
|
+
const mcpPath = process.env.MCP_PATH || '/mcp';
|
|
6
|
+
const apiKey = process.env.MCP_API_KEY || process.env.UNCLAW_API_KEY;
|
|
7
|
+
if (!apiKey) { console.error('[mcp-bridge] MCP_API_KEY (or UNCLAW_API_KEY) is required'); process.exit(1); }
|
|
8
|
+
|
|
9
|
+
let sessionId: string | null = null;
|
|
10
|
+
|
|
11
|
+
async function sendMessage(message: any): Promise<void> {
|
|
12
|
+
const headers: Record<string, string> = {
|
|
13
|
+
'Content-Type': 'application/json',
|
|
14
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
15
|
+
'Accept': 'application/json, text/event-stream',
|
|
16
|
+
};
|
|
17
|
+
if (sessionId) headers['mcp-session-id'] = sessionId;
|
|
18
|
+
|
|
19
|
+
const res = await fetch(`${baseUrl}${mcpPath}`, {
|
|
20
|
+
method: 'POST',
|
|
21
|
+
headers,
|
|
22
|
+
body: JSON.stringify(message),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Capture session ID from server on initialize
|
|
26
|
+
const newSession = res.headers.get('mcp-session-id');
|
|
27
|
+
if (newSession) sessionId = newSession;
|
|
28
|
+
|
|
29
|
+
if (res.status === 204) return;
|
|
30
|
+
|
|
31
|
+
const contentType = res.headers.get('content-type') || '';
|
|
32
|
+
|
|
33
|
+
if (contentType.includes('text/event-stream')) {
|
|
34
|
+
// SSE: parse and forward each data line
|
|
35
|
+
const text = await res.text();
|
|
36
|
+
for (const line of text.split('\n')) {
|
|
37
|
+
if (line.startsWith('data: ')) {
|
|
38
|
+
const data = line.slice(6).trim();
|
|
39
|
+
if (data && data !== '[DONE]') {
|
|
40
|
+
process.stdout.write(data + '\n');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
} else {
|
|
45
|
+
const body = await res.text();
|
|
46
|
+
if (body) process.stdout.write(body + '\n');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
import { createInterface } from 'node:readline';
|
|
51
|
+
const rl = createInterface({ input: process.stdin, terminal: false });
|
|
52
|
+
let buffer = '';
|
|
53
|
+
|
|
54
|
+
rl.on('line', async (line) => {
|
|
55
|
+
buffer += line;
|
|
56
|
+
let message: any;
|
|
57
|
+
try { message = JSON.parse(buffer); } catch { return; }
|
|
58
|
+
buffer = '';
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
await sendMessage(message);
|
|
62
|
+
} catch (e: any) {
|
|
63
|
+
const err = {
|
|
64
|
+
jsonrpc: '2.0',
|
|
65
|
+
id: message?.id ?? null,
|
|
66
|
+
error: { code: -32603, message: `Bridge error: ${e.message}` },
|
|
67
|
+
};
|
|
68
|
+
process.stdout.write(JSON.stringify(err) + '\n');
|
|
69
|
+
}
|
|
70
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { summarizeConversations } from '../server/conversation-summarizer.ts';
|
|
2
|
+
|
|
3
|
+
const force = process.argv.includes('--force');
|
|
4
|
+
const result = await summarizeConversations({ force });
|
|
5
|
+
console.log(`Done: ${result.summarized} summarized, ${result.tracesGenerated} traces, ${result.skipped} skipped, ${result.errors} errors`);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Honest typecheck. Runs `tsc --noEmit` over the whole project (client + server;
|
|
4
|
+
* see tsconfig.json "include": ["src", ...]).
|
|
5
|
+
*
|
|
6
|
+
* A fresh consumer who `bun install`s the PUBLISHED deps gets compiled `.d.ts`
|
|
7
|
+
* (skipped by skipLibCheck) and sees only the project's own errors — nothing to
|
|
8
|
+
* filter. In this repo's local dev, sibling libs are SYMLINKED into node_modules
|
|
9
|
+
* as raw `.ts` source (relink-libs.sh), so tsc follows the import graph into that
|
|
10
|
+
* source and reports errors that no real consumer ever hits. tsc can't be told to
|
|
11
|
+
* skip `.ts` under node_modules (skipLibCheck only skips `.d.ts`), and `exclude`
|
|
12
|
+
* doesn't stop import-following — so we strip exactly those dep-source lines here.
|
|
13
|
+
*
|
|
14
|
+
* This never hides a shraga error: everything that isn't a `node_modules/` or
|
|
15
|
+
* `../sibling` source path is passed through, and the exit code is non-zero if any
|
|
16
|
+
* such line remains. It only removes noise from linked dependency source.
|
|
17
|
+
*/
|
|
18
|
+
import { spawnSync } from 'node:child_process';
|
|
19
|
+
|
|
20
|
+
const res = spawnSync('tsc', ['--noEmit', '--pretty', 'false'], {
|
|
21
|
+
encoding: 'utf8',
|
|
22
|
+
shell: true,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const out = (res.stdout || '') + (res.stderr || '');
|
|
26
|
+
const lines = out.split('\n');
|
|
27
|
+
|
|
28
|
+
// A tsc error line begins with a file path. Dep-source noise resolves either to
|
|
29
|
+
// `node_modules/...` (linked raw-.ts packages) or to a `../sibling` repo path.
|
|
30
|
+
const isDepSourceNoise = (line: string) =>
|
|
31
|
+
/^(?:\.\.[\\/]|node_modules[\\/])/.test(line) ||
|
|
32
|
+
/[\\/]node_modules[\\/]/.test(line.split('(')[0] ?? '');
|
|
33
|
+
|
|
34
|
+
const kept = lines.filter((l) => !isDepSourceNoise(l));
|
|
35
|
+
const errors = kept.filter((l) => /error TS\d+:/.test(l));
|
|
36
|
+
|
|
37
|
+
process.stdout.write(kept.join('\n'));
|
|
38
|
+
if (errors.length > 0) {
|
|
39
|
+
console.error(`\n${errors.length} project type error(s).`);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
console.log('\nTypecheck clean (project sources).');
|
|
43
|
+
process.exit(0);
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { mkdirSync, readdirSync, readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { dataPath } from './paths.ts';
|
|
4
|
+
|
|
5
|
+
const AGENTS_DIR = dataPath('agents');
|
|
6
|
+
const DEFAULTS_DIR = path.resolve(import.meta.dirname, '../../defaults/agents');
|
|
7
|
+
|
|
8
|
+
export interface AgentDef {
|
|
9
|
+
description: string;
|
|
10
|
+
prompt: string;
|
|
11
|
+
model?: 'sonnet' | 'opus' | 'haiku' | 'inherit';
|
|
12
|
+
tools?: string[];
|
|
13
|
+
maxTurns?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function parseFrontmatter(content: string): { meta: Record<string, string>; body: string } {
|
|
17
|
+
if (!content.startsWith('---')) return { meta: {}, body: content };
|
|
18
|
+
const end = content.indexOf('---', 3);
|
|
19
|
+
if (end < 0) return { meta: {}, body: content };
|
|
20
|
+
const yaml = content.slice(3, end).trim();
|
|
21
|
+
const body = content.slice(end + 3).trim();
|
|
22
|
+
const meta: Record<string, string> = {};
|
|
23
|
+
for (const line of yaml.split('\n')) {
|
|
24
|
+
const [k, ...rest] = line.split(':');
|
|
25
|
+
if (k && rest.length) meta[k.trim()] = rest.join(':').trim();
|
|
26
|
+
}
|
|
27
|
+
return { meta, body };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function loadAgents(): Record<string, AgentDef> {
|
|
31
|
+
mkdirSync(AGENTS_DIR, { recursive: true });
|
|
32
|
+
|
|
33
|
+
const agents: Record<string, AgentDef> = {};
|
|
34
|
+
const dirs = [DEFAULTS_DIR, AGENTS_DIR];
|
|
35
|
+
|
|
36
|
+
for (const dir of dirs) {
|
|
37
|
+
if (!existsSync(dir)) continue;
|
|
38
|
+
for (const f of readdirSync(dir).filter(f => f.endsWith('.md'))) {
|
|
39
|
+
const name = f.slice(0, -3);
|
|
40
|
+
const content = readFileSync(path.join(dir, f), 'utf-8');
|
|
41
|
+
const { meta, body } = parseFrontmatter(content);
|
|
42
|
+
|
|
43
|
+
agents[name] = {
|
|
44
|
+
description: meta.description || name,
|
|
45
|
+
prompt: body,
|
|
46
|
+
model: (meta.model as AgentDef['model']) || undefined,
|
|
47
|
+
tools: meta.tools ? meta.tools.split(',').map(s => s.trim()) : undefined,
|
|
48
|
+
maxTurns: meta['max-turns'] ? parseInt(meta['max-turns'], 10) : undefined,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return agents;
|
|
54
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
3
|
+
import { dataPath } from './paths.ts';
|
|
4
|
+
|
|
5
|
+
const KEYS_PATH = dataPath('api-keys.json');
|
|
6
|
+
|
|
7
|
+
export interface ApiKey {
|
|
8
|
+
id: string;
|
|
9
|
+
key: string;
|
|
10
|
+
label: string;
|
|
11
|
+
uid: string;
|
|
12
|
+
email: string;
|
|
13
|
+
createdAt: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function load(): ApiKey[] {
|
|
17
|
+
if (!existsSync(KEYS_PATH)) return [];
|
|
18
|
+
try { return JSON.parse(readFileSync(KEYS_PATH, 'utf-8')); } catch { return []; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function save(keys: ApiKey[]) {
|
|
22
|
+
mkdirSync(dataPath(''), { recursive: true });
|
|
23
|
+
writeFileSync(KEYS_PATH, JSON.stringify(keys, null, 2));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createApiKey(uid: string, email: string, label: string): ApiKey {
|
|
27
|
+
const keys = load();
|
|
28
|
+
const entry: ApiKey = {
|
|
29
|
+
id: randomBytes(8).toString('hex'),
|
|
30
|
+
key: `uck_${randomBytes(32).toString('hex')}`,
|
|
31
|
+
label,
|
|
32
|
+
uid,
|
|
33
|
+
email,
|
|
34
|
+
createdAt: Date.now(),
|
|
35
|
+
};
|
|
36
|
+
keys.push(entry);
|
|
37
|
+
save(keys);
|
|
38
|
+
return entry;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function deleteApiKey(id: string, callerUid: string, isOwner: boolean): 'ok' | 'not_found' | 'forbidden' {
|
|
42
|
+
const keys = load();
|
|
43
|
+
const idx = keys.findIndex(k => k.id === id);
|
|
44
|
+
if (idx < 0) return 'not_found';
|
|
45
|
+
if (keys[idx].uid !== callerUid && !isOwner) return 'forbidden';
|
|
46
|
+
keys.splice(idx, 1);
|
|
47
|
+
save(keys);
|
|
48
|
+
return 'ok';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function listApiKeys() {
|
|
52
|
+
return load().map(({ key, ...rest }) => ({ ...rest, keyPreview: `${key.slice(0, 8)}…` }));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function validateApiKey(key: string): { uid: string; email: string } | null {
|
|
56
|
+
const keys = load();
|
|
57
|
+
for (const k of keys) {
|
|
58
|
+
if (k.key.length === key.length && timingSafeEqual(Buffer.from(k.key), Buffer.from(key))) {
|
|
59
|
+
return { uid: k.uid, email: k.email };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|