shraga 0.1.2 → 0.1.4

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.
@@ -457,7 +457,7 @@ export class ClaudeCodeEngine implements AgentEngine {
457
457
  }
458
458
 
459
459
  const pending = pendingToolUses.get(String(block.tool_use_id));
460
- if (pending?.tool === 'mcp__mcp-slack__post_slack_message') {
460
+ if (pending?.tool === 'mcp__mcp-slack-use__post_slack_message') {
461
461
  try {
462
462
  const parsed = typeof output === 'string' ? JSON.parse(output) : output;
463
463
  const ts = parsed?.ts || parsed?.preview?.ts;
@@ -470,7 +470,7 @@ export class ClaudeCodeEngine implements AgentEngine {
470
470
  }
471
471
  } catch (err) { console.warn('[claude] Failed to track proactive message:', (err as Error).message); }
472
472
  }
473
- if (pending?.tool === 'mcp__mcp-slack__post_slack_poll') {
473
+ if (pending?.tool === 'mcp__mcp-slack-use__post_slack_poll') {
474
474
  try {
475
475
  const parsed = typeof output === 'string' ? JSON.parse(output) : output;
476
476
  const body = ((pending.input as any)?.body ?? {}) as Record<string, any>;
@@ -4,24 +4,40 @@
4
4
  // call emitEvent(); the dispatcher subscribes and routes matching events to the
5
5
  // scheduler's execution path. Deliberately minimal: no plugin registry, no
6
6
  // lifecycle — just emit + subscribe. Adding a source = one emitEvent() call.
7
- import type { ShragaEvent } from './types.ts';
7
+ //
8
+ // TYPING: `ShragaEventMap` (types.ts) maps a source → its payload type. A source
9
+ // listed there gets a checked payload; anything else falls back to `unknown`, so
10
+ // ad-hoc emitters keep compiling. Add-ons register their source via declaration
11
+ // merging (see types.ts).
12
+ import type { ShragaEvent, PayloadOf } from './types.ts';
8
13
 
9
14
  type Listener = (evt: ShragaEvent) => void;
10
15
 
11
16
  const listeners = new Set<Listener>();
12
17
 
13
- /** Subscribe to all events. Returns an unsubscribe fn. */
18
+ /** Subscribe to ALL events. Returns an unsubscribe fn. The dispatcher uses this. */
14
19
  export function subscribeEvents(fn: Listener): () => void {
15
20
  listeners.add(fn);
16
21
  return () => listeners.delete(fn);
17
22
  }
18
23
 
24
+ /** Subscribe to a SINGLE source with a typed payload. Thin filter over
25
+ * subscribeEvents — convenience + typing for the common "I only care about X" case. */
26
+ export function subscribeEvent<K extends string>(
27
+ source: K,
28
+ handler: (payload: PayloadOf<K>, evt: ShragaEvent<K>) => void,
29
+ ): () => void {
30
+ return subscribeEvents((evt) => {
31
+ if (evt.source === source) handler(evt.payload as PayloadOf<K>, evt as ShragaEvent<K>);
32
+ });
33
+ }
34
+
19
35
  /** Publish an event. Listeners are invoked synchronously; a throwing listener is
20
36
  * logged and skipped so one bad subscriber can't sink the emit. */
21
- export function emitEvent(source: string, payload: unknown, opts?: { id?: string }): ShragaEvent {
22
- const evt: ShragaEvent = { source, payload, id: opts?.id, at: Date.now() };
37
+ export function emitEvent<K extends string>(source: K, payload: PayloadOf<K>, opts?: { id?: string }): ShragaEvent<K> {
38
+ const evt: ShragaEvent<K> = { source, payload, id: opts?.id, at: Date.now() };
23
39
  for (const fn of listeners) {
24
- try { fn(evt); } catch (err) {
40
+ try { fn(evt as ShragaEvent); } catch (err) {
25
41
  console.error(`[events] listener threw for source "${source}":`, err);
26
42
  }
27
43
  }
@@ -1,9 +1,32 @@
1
1
  /** A single event flowing through the bus. `source` routes it to matching
2
2
  * `event`-trigger schedules; `payload` is matched against their `match` filter and
3
3
  * injected into the run. `id` (optional) dedupes retried webhook deliveries. */
4
- export interface ShragaEvent {
5
- source: string;
6
- payload: unknown;
4
+ export interface ShragaEvent<K extends string = string> {
5
+ source: K;
6
+ payload: PayloadOf<K>;
7
7
  id?: string;
8
8
  at: number;
9
9
  }
10
+
11
+ /** Extensible registry of source → payload types. Add-ons augment it via
12
+ * declaration merging so a known source gets a typed payload, e.g.:
13
+ *
14
+ * declare module '../events/types.ts' {
15
+ * interface ShragaEventMap { stripe: Stripe.Event }
16
+ * }
17
+ *
18
+ * Sources NOT in the map still work — they fall back to `unknown` (see PayloadOf),
19
+ * so untyped/ad-hoc emitters keep compiling. */
20
+ export interface ShragaEventMap {
21
+ // Core sources shipped by shraga itself. Add-ons merge in their own.
22
+ 'schedule.finished': {
23
+ scheduleId: string;
24
+ name?: string;
25
+ status?: string;
26
+ sessionId?: string;
27
+ error?: string;
28
+ };
29
+ }
30
+
31
+ /** Payload type for a source: the mapped type if registered, else `unknown`. */
32
+ export type PayloadOf<K extends string> = K extends keyof ShragaEventMap ? ShragaEventMap[K] : unknown;
@@ -0,0 +1,64 @@
1
+ // Generic verified-webhook → event bridge.
2
+ //
3
+ // A vendor webhook can't present shraga auth (it carries the vendor's OWN
4
+ // signature), so it can't hit the auth-gated POST /api/events/:source. Instead
5
+ // declare it as data: registerWebhook(app, { source, verify }) mounts a PUBLIC
6
+ // POST /api/webhooks/<source>, and on a passing `verify` calls emitEvent(source).
7
+ //
8
+ // The ONLY per-vendor piece is `verify` — dispatch, dedup, and typing are generic.
9
+ // The raw request bytes (needed for HMAC) are read from req.rawBody, which the
10
+ // global express.json({ verify }) in index.ts stashes for every request.
11
+ import type { IRouter, Request, Response } from 'express';
12
+ import { emitEvent } from './bus.ts';
13
+ import type { PayloadOf } from './types.ts';
14
+
15
+ export interface WebhookOptions<K extends string> {
16
+ /** Event source name → the bus source + `/api/webhooks/<source>` path. */
17
+ source: K;
18
+ /** Verify the vendor's signature over the RAW body. Return any FALSY value
19
+ * (false/undefined/null/'') to reject (400). Return `true` to accept
20
+ * (payload = normalize?.(req) ?? parsed body), or return the payload object
21
+ * directly. Receives the raw bytes for HMAC. */
22
+ verify: (req: Request, raw: string) => boolean | PayloadOf<K>;
23
+ /** Optional payload shaper when `verify` returns `true` (defaults to req.body). */
24
+ normalize?: (req: Request) => PayloadOf<K>;
25
+ /** Optional dedup id extractor (retried deliveries). Defaults to body.id. */
26
+ eventId?: (payload: PayloadOf<K>, req: Request) => string | undefined;
27
+ /** Override the mount path (default `/api/webhooks/<source>`). */
28
+ path?: string;
29
+ }
30
+
31
+ function rawBodyOf(req: Request): string {
32
+ const buf = (req as unknown as { rawBody?: unknown }).rawBody;
33
+ return buf instanceof Buffer ? buf.toString('utf8') : '';
34
+ }
35
+
36
+ /** Mount a public webhook route that verifies the vendor's signature, then emits a
37
+ * typed event. Returns the mounted path. */
38
+ export function registerWebhook<K extends string>(router: IRouter, opts: WebhookOptions<K>): string {
39
+ const path = opts.path ?? `/api/webhooks/${opts.source}`;
40
+ router.post(path, (req: Request, res: Response) => {
41
+ const raw = rawBodyOf(req);
42
+ let result: boolean | PayloadOf<K>;
43
+ try {
44
+ result = opts.verify(req, raw);
45
+ } catch (err) {
46
+ console.error(`[webhook] verify threw for "${opts.source}":`, err);
47
+ return res.status(400).json({ error: 'verification failed' });
48
+ }
49
+ // Reject on ANY falsy result — a `verify` written as "return the payload, or
50
+ // nothing on failure" yields undefined/null/''/0 on a bad signature; those must
51
+ // NOT fall through and emit an unauthenticated event.
52
+ if (!result) return res.status(400).json({ error: 'bad signature' });
53
+
54
+ const payload: PayloadOf<K> = result === true ? (opts.normalize?.(req) ?? (req.body as PayloadOf<K>)) : result;
55
+ const id = opts.eventId
56
+ ? opts.eventId(payload, req)
57
+ : ((payload as { id?: unknown } | null)?.id != null ? String((payload as { id?: unknown }).id) : undefined);
58
+
59
+ emitEvent(opts.source, payload, { id });
60
+ res.json({ received: true });
61
+ });
62
+ console.log(`[webhook] mounted POST ${path} → emitEvent("${opts.source}")`);
63
+ return path;
64
+ }
@@ -22,6 +22,7 @@ import { pathToFileURL } from 'node:url';
22
22
  import { dataPath } from './paths.ts';
23
23
  import { requireAuth } from './auth.ts';
24
24
  import { emitEvent } from './events/bus.ts';
25
+ import { registerWebhook, type WebhookOptions } from './events/webhook.ts';
25
26
 
26
27
  export interface ExtensionContext {
27
28
  /** Resolve a path inside the active data dir (e.g. ctx.dataPath('github-app.json')). */
@@ -35,11 +36,38 @@ export interface ExtensionContext {
35
36
  /** Publish an event onto the bus → fires matching `event`-trigger schedules.
36
37
  * Use after verifying a vendor webhook's own signature. */
37
38
  emitEvent: (source: string, payload: unknown, opts?: { id?: string }) => void;
39
+ /** Declare a vendor webhook as data: mounts a PUBLIC POST /api/webhooks/<source>
40
+ * that runs `verify` (the only per-vendor piece) → emitEvent. Returns the path. */
41
+ registerWebhook: <K extends string>(opts: WebhookOptions<K>) => string;
38
42
  }
39
43
 
44
+ /** A programmatic extension: the same shape as a `*.ext.ts` file's default export. */
45
+ export type ExtRegisterFn = (router: ExpressRouter, ctx: ExtensionContext) => void | Promise<void>;
46
+
40
47
  const loaded = new Set<string>();
41
48
  let extRouter: ExpressRouter | null = null;
42
49
  let ctx: ExtensionContext | null = null;
50
+ const pendingProgrammatic: ExtRegisterFn[] = [];
51
+
52
+ async function runProgrammatic(fn: ExtRegisterFn): Promise<void> {
53
+ try {
54
+ await fn(extRouter!, ctx!);
55
+ console.log('[extensions] mounted programmatic extension');
56
+ } catch (err) {
57
+ console.error('[extensions] programmatic register failed:', (err as Error)?.stack || err);
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Programmatic equivalent of dropping a `data/extensions/*.ext.ts` file. Funnels through the SAME
63
+ * persistent Router + ExtensionContext (mounted before the SPA catch-all), so a webhook/callback
64
+ * registered this way is reachable exactly like a file-based one. If called before loadExtensions()
65
+ * (the usual case — createShraga queues these pre-boot), it runs when the router is ready.
66
+ */
67
+ export async function registerExtension(fn: ExtRegisterFn): Promise<void> {
68
+ if (extRouter && ctx) return runProgrammatic(fn);
69
+ pendingProgrammatic.push(fn);
70
+ }
43
71
 
44
72
  async function mountFile(dir: string, f: string): Promise<void> {
45
73
  if (loaded.has(f) || !extRouter || !ctx) return;
@@ -77,8 +105,14 @@ export async function loadExtensions(app: Express): Promise<void> {
77
105
  log: (...a) => console.log('[extensions]', ...a),
78
106
  app,
79
107
  emitEvent,
108
+ // Mount on the extension Router (before the SPA catch-all) so hot-loaded
109
+ // webhooks are reachable too — never on `app` directly.
110
+ registerWebhook: (opts) => registerWebhook(extRouter!, opts),
80
111
  };
81
112
 
113
+ // Flush programmatic extensions first (they mount ahead of file-based drop-ins, before the catch-all).
114
+ for (const fn of pendingProgrammatic.splice(0)) await runProgrammatic(fn);
115
+
82
116
  const dir = path.resolve(dataPath('extensions'));
83
117
  await scan(dir);
84
118
 
@@ -62,7 +62,7 @@ const SLACK_MENTION_FIELDS: Record<string, string[]> = {
62
62
  const resolveSlackMentions: HookCallback = async (input) => {
63
63
  if (input.hook_event_name !== 'PreToolUse') return {};
64
64
  const { tool_name, tool_input } = input as PreToolUseHookInput;
65
- const m = /^mcp__mcp-slack__(post_slack_\w+)$/.exec(tool_name);
65
+ const m = /^mcp__mcp-slack-use__(post_slack_\w+)$/.exec(tool_name);
66
66
  if (!m) return {};
67
67
  const fields = SLACK_MENTION_FIELDS[m[1]];
68
68
  if (!fields) return {};
@@ -135,7 +135,7 @@ export function buildHooks(): Partial<Record<HookEvent, HookCallbackMatcher[]>>
135
135
  return {
136
136
  PreToolUse: [
137
137
  { matcher: 'Bash', hooks: [forceBackgroundForScripts] },
138
- { matcher: 'mcp__mcp-slack__post_slack_.*', hooks: [resolveSlackMentions] },
138
+ { matcher: 'mcp__mcp-slack-use__post_slack_.*', hooks: [resolveSlackMentions] },
139
139
  { matcher: 'mcp__mcp-firebase-(?:prod|lab)__get_db.*', hooks: [guardFirebaseReads] },
140
140
  ],
141
141
  };