Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@ src/
email-login.ts # Magic link email delivery
notifications/ # Web Push (VAPID keys, subscriptions, triggers)
channels/
slack.ts # Slack Socket Mode (primary channel, owner access control)
slack.ts # Slack Socket Mode (primary channel, owner access control, lifecycle metrics)
slack-metrics.ts # Phase 8a: prom-client metrics for Socket Mode lifecycle + dispatch
slack-channel-factory.ts # Routes between Socket Mode + HTTP receiver; AllowedSecretNamesMirror lives here
telegram.ts # Telegram via Telegraf
email.ts # IMAP/SMTP via ImapFlow + Nodemailer
webhook.ts # HTTP webhooks with HMAC-SHA256
Expand Down Expand Up @@ -132,7 +134,7 @@ src/
tools.ts # phantom_create_page, phantom_generate_login
login-page.ts # Login page HTML
core/
server.ts # Bun.serve() HTTP server, /health, /trigger, /webhook, /ui
server.ts # Bun.serve() HTTP server, /health, /metrics, /trigger, /webhook, /ui
db/
schema.ts # SQLite migrations (7 total)
connection.ts # Database connection
Expand All @@ -154,6 +156,21 @@ After each session: EvolutionEngine runs 6-step reflection pipeline -> 5-gate va

MCP flow: External client -> /mcp endpoint -> bearer auth -> MCP Server -> tool execution (some route through AgentRuntime for full Opus brain).

## Observability

The Bun.serve() HTTP server exposes a Prometheus `/metrics` endpoint (Phase 8a, R7 dated 2026-04-30). Unauthenticated, matching the existing `/health` precedent: per-tenant isolation comes from the per-tenant URL behind Caddy, not per-route auth.

Metric families exposed today (Slack Socket Mode lifecycle):

- `phantom_slack_socket_state{state="connecting|authenticated|connected|reconnecting|disconnecting|disconnected|error"}` (gauge). Exactly one series is 1.0 at any instant; the rest are 0.0. Alerting watches `1 - max_over_time(phantom_slack_socket_state{state="connected"}[1m]) > 0` for "the tenant is offline".
- `phantom_slack_socket_reconnects_total` (counter). Bolt's auto-reconnect is on by default; this measures "the network wobbled". Alert at sustained >5/min.
- `phantom_slack_socket_connection_seconds` (histogram). Lifetime of a single Socket Mode connection from connect to disconnect. p99 should hold above 1 hour.
- `phantom_slack_event_dispatch_seconds{event_type=...}` (histogram). End-to-end Bolt middleware time. Slack's ack deadline is 3 seconds; alert on p99 > 2.5s.

The metrics module owns a private `prom-client` Registry (no global registry pollution). Adding more channels (Telegram, email) means adding a sibling registry and merging at request time in `core/server.ts`. Future cross-channel metrics generalize via Phase 17 polish.

Cross-repo invariant: `slack-channel-factory.ts` exports a frozen `AllowedSecretNamesMirror` array (`slack_bot_token`, `slack_app_token`, `slack_gateway_signing_secret`). The same names MUST appear in phantomd's `internal/secrets/types.go` `AllowedSecretNames` map. Drift breaks tenant boot with HTTP 404 (the gateway maps `ErrInvalidName` to 404 to defeat name enumeration). The factory test pins the mirror against its `SECRET_RESPONSES` test fixture; phantomd's `TestIsAllowedName_AcceptsSlackAppToken` (and the existing `*_AcceptsSlackGatewaySigningSecret`) pin the symmetric assertion.

## Key Design Decisions

**Qdrant over LanceDB:** WAL durability with crash recovery. Native hybrid search (dense + BM25 sparse vectors). Named vectors for separate embedding spaces. Mmap mode for low memory. TypeScript REST client works with Bun (no NAPI addon risk).
Expand Down
9 changes: 9 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"imapflow": "^1.2.18",
"nodemailer": "^8.0.4",
"playwright": "1.59.1",
"prom-client": "^15.1.3",
"resend": "^6.9.4",
"telegraf": "^4.16.3",
"yaml": "^2.6.0",
Expand Down
65 changes: 59 additions & 6 deletions src/channels/__tests__/slack-channel-factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { ChannelsConfig } from "../../config/schemas.ts";
const mockApp = mock(() => ({
event: () => {},
action: () => {},
use: () => {},
client: {
auth: { test: () => Promise.resolve({ user_id: "U_BOT" }) },
chat: { postMessage: () => Promise.resolve({ ts: "1.0" }), update: () => Promise.resolve({ ok: true }) },
Expand All @@ -21,12 +22,23 @@ const mockReceiver = {
stop: () => Promise.resolve(),
};

// Phase 8a: Socket Mode receiver mock. The constructor returns an object
// whose `client.on(...)` is a no-op for routing tests; lifecycle metric
// behaviour is exercised in slack-metrics.test.ts and the SlackChannel
// suite, not here.
const mockSocketModeReceiver = mock(() => ({
client: { on: () => {} },
}));

mock.module("@slack/bolt", () => ({
App: mockApp,
ExpressReceiver: mock(() => mockReceiver),
SocketModeReceiver: mockSocketModeReceiver,
}));

const { createSlackChannel, readSlackTransportFromEnv } = await import("../slack-channel-factory.ts");
const { createSlackChannel, readSlackTransportFromEnv, AllowedSecretNamesMirror } = await import(
"../slack-channel-factory.ts"
);
const { SlackChannel } = await import("../slack.ts");
const { SlackHttpChannel } = await import("../slack-http-receiver.ts");

Expand All @@ -48,16 +60,23 @@ const HTTP_IDENTITY = {
},
};

// Cross-repo invariant (audit Finding 1, dated 2026-04-25): the names
// "slack_bot_token" and "slack_gateway_signing_secret" must appear in
// phantomd's internal/secrets/types.go AllowedSecretNames map. Any drift
// between phantom and phantomd breaks SLACK_TRANSPORT=http boot with HTTP
// 404. This map is the SINGLE source of truth for the http-mode tests
// Cross-repo invariant: the names below must appear in phantomd's
// internal/secrets/types.go AllowedSecretNames map. Any drift between
// phantom and phantomd breaks tenant boot with HTTP 404 (the gateway maps
// ErrInvalidName to 404 to avoid name enumeration).
//
// Audit Finding 1 (2026-04-25) added slack_bot_token + slack_gateway_signing_secret.
// Phase 8a (R7 dated 2026-04-30) added slack_app_token for Socket Mode
// self-installed agent #2+ tenants. The phantomd side ships in PR #28
// (TestIsAllowedName_AcceptsSlackAppToken pins the symmetric assertion).
//
// This fixture is the SINGLE source of truth for the http-mode tests
// below; makeSecretFetcher() throws fail-loud on any name not listed
// here, so a future production-side rename that misses one repo will
// fail this test suite immediately instead of silently shipping a 404.
const SECRET_RESPONSES: Record<string, string> = {
slack_bot_token: "xoxb-from-metadata",
slack_app_token: "xapp-1-from-metadata",
slack_gateway_signing_secret: "0123456789abcdef".repeat(4),
};

Expand Down Expand Up @@ -245,3 +264,37 @@ describe("createSlackChannel", () => {
await expect(fetcher.get("totally_made_up")).rejects.toThrow(/AllowedSecretNames/);
});
});

// Phase 8a (R7 2026-04-30): pin the cross-repo invariant for the new
// slack_app_token entry. AllowedSecretNamesMirror is the phantom-side
// authoritative list; phantomd's TestIsAllowedName_AcceptsSlackAppToken is
// the matching assertion in the symmetric position. If a future contributor
// removes the entry on either side without the matching edit, both test
// suites fail-loud.
describe("AllowedSecretNamesMirror", () => {
test("includes slack_bot_token", () => {
expect(AllowedSecretNamesMirror).toContain("slack_bot_token");
});

test("includes slack_app_token (Phase 8a Socket Mode)", () => {
expect(AllowedSecretNamesMirror).toContain("slack_app_token");
});

test("includes slack_gateway_signing_secret (audit F1, HTTP receiver)", () => {
expect(AllowedSecretNamesMirror).toContain("slack_gateway_signing_secret");
});

test("matches the SECRET_RESPONSES test fixture set", () => {
// SECRET_RESPONSES is the test fixture for makeSecretFetcher; its
// keys must equal AllowedSecretNamesMirror. A drift here means the
// production code can fetch a name the test fixture rejects (or
// vice versa), and the audit-F1 fail-loud guard breaks down.
const fixtureKeys = Object.keys(SECRET_RESPONSES).sort();
const mirror = [...AllowedSecretNamesMirror].sort();
expect(fixtureKeys).toEqual(mirror);
});

test("entries are frozen (Object.freeze) so a runtime mutation is loud", () => {
expect(Object.isFrozen(AllowedSecretNamesMirror)).toBe(true);
});
});
11 changes: 11 additions & 0 deletions src/channels/__tests__/slack-http-receiver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,19 @@ const MockApp = mock((opts: { receiver?: { init?: (app: unknown) => void } }) =>
return app;
});

// Phase 8a: SlackChannel (Socket Mode) now imports SocketModeReceiver from
// @slack/bolt. Even though this test exercises only the HTTP receiver, the
// module-mock layer is process-scoped under bun: a partial mock here
// shadows the real export for any other test file that loads slack.ts
// later. We mock SocketModeReceiver as a no-op constructor so the cross-
// suite loader stays consistent.
const mockSocketModeReceiver = mock(() => ({
client: { on: () => {} },
}));

mock.module("@slack/bolt", () => ({
App: MockApp,
SocketModeReceiver: mockSocketModeReceiver,
}));

// Import the channel AFTER the module mock so the constructor uses our doubles.
Expand Down
Loading
Loading