diff --git a/.env.example b/.env.example index dcd6a48..8c6e5fb 100644 --- a/.env.example +++ b/.env.example @@ -23,7 +23,7 @@ OP_SERVICE_ACCOUNT_TOKEN= # Existing local encrypted vault files remain readable; no vault-writing API is provided. BELL_VAULT_FILE=.bell/vault.enc.json BELL_VAULT_MASTER_KEY= -# Personal 1Password Agentic Autofill for private Linq senders. See docs/onepassword.md. +# Personal 1Password Agentic Autofill for private Photon senders. See docs/onepassword.md. # Keep disabled until Stagehand and 1Password can load together in a hosted session. ONEPASSWORD_ENABLED=false # Apply migrations/002_onepassword.sql and use a session-pooled PostgreSQL connection. @@ -35,26 +35,39 @@ ONEPASSWORD_CLIENT_SECRET= ONEPASSWORD_TOKEN_ENCRYPTION_KEY= # Leave unset until hosted extension support is fixed. Setting this disables reusable Contexts. BROWSERBASE_1PASSWORD_EXTENSION_ID= -# Optional Linq messaging. Keep tokens and your personal number in .env.local. -LINQ_API_KEY= -LINQ_WEBHOOK_SECRET= -# Your Linq receiving line in E.164 format, starting with + and country code. -BELL_LINQ_PHONE_NUMBER= -# Comma-separated E.164 numbers. Empty accepts nobody. -BELL_LINQ_ALLOWED_SENDERS= +# Optional Photon iMessage channel (Advanced gRPC). Set BELL_PHOTON_ENABLED=1 to run it. +# Builds work with it disabled. Keep secrets in .env.local. +BELL_PHOTON_ENABLED=false +# Stable Cloud project credentials for the managed iMessage line. +IMESSAGE_PROJECT_ID= +IMESSAGE_PROJECT_SECRET= +# The exact dedicated receiving line in E.164 format. Shared lines are rejected at startup. +BELL_PHOTON_PHONE_NUMBER= +# Comma-separated E.164 private allowlist. Empty accepts nobody and fails startup. +BELL_PHOTON_ALLOWED_SENDERS= +# Optional comma-separated iMessage email handles; empty denies email-origin input. +BELL_PHOTON_ALLOWED_EMAIL_SENDERS= +# Comma-separated admitted group chat GUIDs; empty keeps group ingress closed. +BELL_PHOTON_ALLOWED_GROUPS= +# Internal dispatch secret shared between the web app and the transport worker. +# At least 32 high-entropy bytes; never a provider token. +BELL_PHOTON_INTERNAL_SECRET= +# Private Eve origin the worker calls (loopback in production, e.g. http://127.0.0.1:4274). +# In development set the deterministic `eve dev` port or use EVE_BASE_URL's origin. +BELL_PHOTON_INTERNAL_ORIGIN=http://127.0.0.1:4274 # Baselayer sandbox. The principal must be current in the API key's organization. BASELAYER_API_KEY= BASELAYER_PRINCIPAL_REF= BASELAYER_ALLOWED_ORIGINS=https://bell-stack-demo-site.vercel.app -# Optional database-free Link sandbox for one verified Linq principal. +# Optional database-free Link sandbox for one verified Photon principal. # Inject the access token into the process environment; do not save it in this file. LINK_SANDBOX=1 LINK_SANDBOX_ACCESS_TOKEN= LINK_SANDBOX_PRINCIPAL_ID= -# Optional real Link OAuth for allowlisted Linq senders (LINK_SANDBOX=0). +# Optional real Link OAuth for allowlisted Photon senders (LINK_SANDBOX=0). # Register /api/link/oauth/callback with your Link OAuth client. # Apply migrations/001_link_oauth.sql before connecting a wallet. See docs/link.md. DATABASE_URL= diff --git a/.gitignore b/.gitignore index f5feab4..8c41cc3 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ coverage/ AGENT.md AGENTS.md CLAUDE.md +.photon-worker/ diff --git a/README.md b/README.md index f7457b7..eb70839 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ -# BELL +# BELL · Photon

BELL

-[BELL](https://www.browserbase.com/blog/bell-personal-assistant-stack) is a reference architecture and sample app for building personal AI assistants that execute tasks on your behalf. +This Photon edition of [BELL](https://www.browserbase.com/blog/bell-personal-assistant-stack) is a reference architecture and sample app for building personal AI assistants that execute tasks on your behalf, with native iMessage powered by Photon. - Browserbase: Browser + Stagehand - Eve by Vercel: Agent Framework - Link by Stripe: Payments -- Linq: Messaging Infrastruture +- Photon: iMessage Infrastructure (Stable cloud, Advanced gRPC) - 1Password: Authentication - Baselayer: Identity @@ -17,8 +17,8 @@ Requires Node.js 24, pnpm 10.33.4, and OpenAI and Browserbase API keys. ```sh -git clone https://github.com/browserbase/bell-stack.git -cd bell-stack +git clone https://github.com/tecxbro/bell-stack-photon.git +cd bell-stack-photon pnpm install --frozen-lockfile cp .env.example .env ``` @@ -36,8 +36,8 @@ pnpm dev | --- | --- | --- | | Browserbase + Stagehand | Browser sessions, automation, saved Contexts, and human handoff | [Browser runtime](docs/browser-runtime.md) | | Eve by Vercel | Agent configuration, tools, conversation state, and channels | [Eve in Bell](docs/repository.md#eve-by-vercel) | -| Linq | iMessage and SMS through a signed webhook and sender allowlist | [Messaging setup](docs/linq.md) | -| Link by Stripe | Wallet authorization, spend approval, and checkout through Linq | [Sandbox and wallet setup](docs/link.md) | +| Photon | Native iMessage over a durable Advanced gRPC channel with a supervised transport worker | [Messaging setup](docs/photon.md) · [Capabilities](docs/photon-capabilities.md) | +| Link by Stripe | Wallet authorization, spend approval, and checkout through Photon | [Sandbox and wallet setup](docs/link.md) | | 1Password | Operator credentials and OTP; personal Agentic Autofill is disabled by default | [Operator access](docs/browser-runtime.md#credentials-and-otp) · [Personal access status](docs/onepassword.md) | | Baselayer | Verified access to approved merchants using short-lived credentials | [Merchant access](docs/baselayer.md) | diff --git a/agent/agent.ts b/agent/agent.ts index 05bd7cc..771f20a 100644 --- a/agent/agent.ts +++ b/agent/agent.ts @@ -12,5 +12,12 @@ export default defineAgent({ "pg", ], }, + experimental: { + workflow: { + // Keep finished-run data at the World default so the Photon output + // projector and incident recovery can re-read completed sessions. + retention: "default", + }, + }, limits: { sessionTimeoutMs: 24 * 60 * 60 * 1_000 }, }); diff --git a/agent/channels/linq.ts b/agent/channels/linq.ts deleted file mode 100644 index fe3a44c..0000000 --- a/agent/channels/linq.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { linqBrowserEvents, requiredEnvironmentValue } from "../lib/linq"; -import { defaultLinqAuth, linqChannel, type LinqChannelConfig } from "eve/channels/linq"; -import { z } from "zod"; - -const phoneNumber = z.string().regex(/^\+[1-9]\d{1,14}$/); -const inboundMessage = z.object({ - direction: z.literal("inbound"), - chat: z.object({ - is_group: z.literal(false), - owner_handle: z.object({ handle: phoneNumber }), - }), - sender_handle: z.object({ handle: phoneNumber, is_me: z.literal(false) }), -}); - -type LinqMessage = Parameters>[1]; - -export function isAllowedBellMessage(message: Pick): boolean { - if (message.author.isBot) return false; - const incoming = inboundMessage.safeParse(message.raw); - const receivingNumber = phoneNumber.safeParse(process.env.BELL_LINQ_PHONE_NUMBER?.trim()); - const allowedSenders = z.array(phoneNumber).nonempty().safeParse( - (process.env.BELL_LINQ_ALLOWED_SENDERS ?? "").split(",").map((value) => value.trim()), - ); - - return incoming.success && receivingNumber.success && allowedSenders.success - && incoming.data.chat.owner_handle.handle === receivingNumber.data - && allowedSenders.data.includes(incoming.data.sender_handle.handle); -} - -export default linqChannel({ - events: linqBrowserEvents, - userName: "Bell", - credentials: { - apiKey: () => requiredEnvironmentValue("LINQ_API_KEY"), - signingSecret: () => requiredEnvironmentValue("LINQ_WEBHOOK_SECRET"), - }, - turnPolicy: "steer", - onMessage(_context, message) { - if (!isAllowedBellMessage(message)) return null; - return { - auth: defaultLinqAuth(message), - context: [ - "This request arrived through Bell's private Linq iMessage or SMS channel. " - + "Reply concisely in this chat. Use the same browser for follow-up requests. " - + "For a human-only step, send the human_handoff tool's link and wait for a reply.", - ], - }; - }, -}); diff --git a/agent/channels/photon.ts b/agent/channels/photon.ts new file mode 100644 index 0000000..31a868d --- /dev/null +++ b/agent/channels/photon.ts @@ -0,0 +1,664 @@ +import { + defineChannel, + GET, + POST, + SessionStrandedError, + type RouteHandlerArgs, +} from "eve/channels"; +import type { SessionAuthContext } from "eve/context"; + +import { + loadPhotonConfig, + PHOTON_DISPATCH_PATH, + PHOTON_EVENTS_PATH, + PHOTON_HEALTH_PATH, + PHOTON_RETIRE_PATH, + PhotonConfigError, + type PhotonConfig, +} from "../../server/photon/config.js"; +import { + dispatchRequestSchema, + eventsReadRequestSchema, + retireRequestSchema, + type PhotonDispatchResult, + type PhotonEventEnvelope, + type PhotonEventsReadResult, + type PhotonHealthReport, + type PhotonRetireResult, +} from "../../server/photon/contracts.js"; +import { + bindingIdFor, + canonicalTupleJson, + dispatchKeyFor, + eveAddressFor, + handleKey, + intentIdFor, + isPhotonAuth, + photonPrincipalId, + sha256Hex, + type CanonicalHandle, +} from "../../server/photon/identity.js"; +import { + admitEvent, + applyMembershipDelta, + classifyScope, + toConversationState, +} from "../../server/photon/policy.js"; +import { + type ConversationRow, + type InboxRow, + type InteractionRow, + type PhotonStore, +} from "../../server/photon/store.js"; +import { + PHOTON_ATTR_AUDIENCE, + PHOTON_ATTR_BINDING, + PHOTON_ATTR_CHAT, + PHOTON_ATTR_SOURCE, +} from "../lib/messaging-auth"; +import { + describeInboundMessage, + photonAttribute, + photonBrowserEvents, + photonContextStrings, + photonDispatchAuth, + postSessionNotice, + verifyInternalAuth, + type PhotonChannelContext, +} from "../lib/photon"; +import { getPhotonStore } from "../lib/photon-store"; +import { isSessionExpired, registerSessionDeadline } from "../lib/session-lifetime"; + +/** + * Bell's raw Advanced gRPC channel. All routes are private: they mount + * verbatim on the loopback Eve origin, are never proxied through the public + * Next origin (withEve rewrites only `/eve/v1/*`), and require the separate + * high-entropy internal secret. Request bodies carry inbox references only — + * identity, authorization and message content always come from the durable + * channel store, never from payload claims. + */ + +const MAX_BODY_BYTES = 16 * 1024; +const MAX_EVENTS_PER_READ = 5_000; + +interface PhotonRuntime { + readonly config: PhotonConfig; + readonly store: PhotonStore; +} + +let cachedRuntime: PhotonRuntime | undefined; + +/** + * Lazily loads config and opens the channel store. `loadPhotonConfig` + * returns null when the channel is disabled and throws PhotonConfigError on + * malformed configuration — both reach the route handlers, never module + * import time, so `eve build` works without provider credentials. + */ +function photonRuntime(): PhotonRuntime | null { + const config = loadPhotonConfig(); + if (!config) return null; + if (cachedRuntime?.config.databaseUrl === config.databaseUrl) return cachedRuntime; + cachedRuntime = { config, store: getPhotonStore(config) }; + return cachedRuntime; +} + +function json(payload: unknown, status = 200): Response { + return Response.json(payload, { status }); +} + +async function readJsonBody(req: Request): Promise { + try { + return await req.json(); + } catch { + return undefined; + } +} + +/** + * Shared route prelude: resolves runtime config, enforces the internal + * bearer credential and (for JSON routes) content type and size bounds. + * Returns the runtime, or the failure response to send immediately. + */ +function routePrelude( + req: Request, + options: { readonly json: boolean }, +): { readonly runtime: PhotonRuntime } | { readonly response: Response } { + let runtime: PhotonRuntime | null; + try { + runtime = photonRuntime(); + } catch (error) { + if (error instanceof PhotonConfigError) + return { response: json({ status: "retryable", reason: "photon_misconfigured" }, 503) }; + throw error; + } + if (!runtime) return { response: json({ status: "retryable", reason: "photon_disabled" }, 503) }; + if (!verifyInternalAuth(req, runtime.config.internalSecret)) + return { response: json({ status: "rejected", reason: "unauthorized" }, 401) }; + if (options.json) { + const contentType = req.headers.get("content-type") ?? ""; + if (!contentType.toLowerCase().includes("application/json")) + return { response: json({ status: "rejected", reason: "content_type_not_json" }, 415) }; + const declared = Number(req.headers.get("content-length") ?? ""); + if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) + return { response: json({ status: "rejected", reason: "payload_too_large" }, 413) }; + } + return { runtime }; +} + +/** + * Applies a state-only event to the local projection without starting a + * model turn. Membership deltas apply only onto a populated baseline — an + * empty roster means the roster was never hydrated, and applying one delta + * there would wrongly deny the other admitted members. + */ +async function applyProjectionState( + store: PhotonStore, + row: InboxRow, + envelope: PhotonEventEnvelope, + updatesMembership: boolean, + conversation: ConversationRow | undefined, +): Promise { + const event = envelope.event; + const chatGuid = envelope.chatGuid ?? row.chatGuid ?? conversation?.chatGuid; + if (event.family === "group" && chatGuid) { + if (event.type === "displayNameChanged" || event.type === "iconChanged" + || event.type === "iconRemoved") { + await store.upsertResource(row.sourceId, "chat", chatGuid, { + displayName: event.type === "displayNameChanged" ? event.displayName : undefined, + icon: event.type === "iconChanged" ? "custom" : "none", + sequence: envelope.sequence, + }); + } + if (updatesMembership && conversation && conversation.membership.length > 0) { + const next = applyMembershipDelta(conversation.membership, event); + if (next) await store.updateMembership(conversation.bindingId, next); + } + } + if (event.family === "message" && event.type === "unsent") { + await store.upsertResource(row.sourceId, "message", event.messageGuid, + { retractedAt: event.retractedAt, sequence: envelope.sequence }, { tombstone: true }); + } + if (event.family === "message" && event.type === "edited") { + await store.upsertResource(row.sourceId, "message", event.messageGuid, { + content: event.content, editedAt: event.editedAt, sequence: envelope.sequence, + }); + } + if (event.family === "poll") { + await store.upsertResource(row.sourceId, "poll", event.pollMessageGuid, { + type: event.type, delta: event.delta, sequence: envelope.sequence, + }); + } +} + +type InputResponseDraft = + | { readonly requestId: string; readonly text: string } + | { readonly requestId: string; readonly optionId: string }; + +interface PromptResponseMapping { + readonly allowFreeform?: boolean; + readonly options?: readonly { readonly id?: unknown; readonly label?: unknown }[]; +} + +/** + * Maps an inbound text reply onto an open pending prompt interaction. The + * pending row carries the Eve request id in `requestRef` and, for select + * prompts, the presented option ids/labels in `responseMapping`. Strict + * selects with no matching option and expired prompts are left alone — + * their event falls through to a normal `steer` send. + */ +function mapPromptResponse( + interaction: InteractionRow, + text: string, +): InputResponseDraft | undefined { + const mapping = interaction.responseMapping as PromptResponseMapping | undefined; + const normalized = text.toLowerCase(); + for (const option of mapping?.options ?? []) { + const id = typeof option?.id === "string" ? option.id : undefined; + const label = typeof option?.label === "string" ? option.label : undefined; + if (id && (normalized === id.toLowerCase() || (label !== undefined && normalized === label.toLowerCase()))) + return { requestId: interaction.requestRef, optionId: id }; + } + if (mapping?.options?.length && mapping.allowFreeform === false) return undefined; + return { requestId: interaction.requestRef, text }; +} + +async function pendingPromptResponse( + store: PhotonStore, + sourceId: string, + chatGuid: string, + actor: CanonicalHandle, + text: string, +): Promise<{ readonly interactionId: string; readonly response: InputResponseDraft } | undefined> { + const trimmed = text.trim(); + if (!trimmed) return undefined; + const now = Date.now(); + const actorKey = handleKey(actor); + for (const interaction of await store.openInteractions(sourceId, chatGuid)) { + if (interaction.kind !== "prompt") continue; + if (interaction.permittedActor && interaction.permittedActor !== actorKey) continue; + if (interaction.expiresAt && interaction.expiresAt.getTime() <= now) continue; + const response = mapPromptResponse(interaction, trimmed); + if (response) return { interactionId: interaction.interactionId, response }; + } + return undefined; +} + +function conversationTitle(scope: "direct" | "group", actor: CanonicalHandle, chatGuid: string): string { + return scope === "group" ? `Photon group ${chatGuid}` : `Photon DM ${handleKey(actor)}`; +} + +/** + * Creates or resolves the canonical session for one continuation address. + * When a stranded session still owns the address, it is retired first — + * once — then creation is retried so uncertain retries never cross into a + * replacement session unnoticed. + */ +async function createConversationSession( + args: RouteHandlerArgs, + eveAddress: string, + auth: SessionAuthContext, + title: string, +) { + try { + return await args.from(eveAddress).create({ auth, initiatorAuth: auth, title }); + } catch (error) { + if (!(error instanceof SessionStrandedError)) throw error; + await args.from(eveAddress).reset({ reason: "stranded_address_owner" }); + return await args.from(eveAddress).create({ auth, initiatorAuth: auth, title }); + } +} + +/** + * Terminally resets one bound session and marks the store's deadline and + * conversation rows retired. `SessionStrandedError` and `no_active_session` + * both mean the session can no longer execute — the durable rows are still + * marked so nothing reopens it. + */ +async function retireBoundSession( + args: RouteHandlerArgs, + store: PhotonStore, + eveSessionId: string, + bindingId: string | undefined, + reason: string, +): Promise<"reset" | "gone"> { + let outcome: "reset" | "gone" = "gone"; + try { + const result = await args.attachSession(eveSessionId).reset({ reason }); + outcome = result.status === "reset" ? "reset" : "gone"; + } catch (error) { + if (!(error instanceof SessionStrandedError)) throw error; + } + await store.markDeadlineRetired(eveSessionId, reason); + if (bindingId) await store.retireConversation(bindingId); + return outcome; +} + +async function handleDispatch(req: Request, args: RouteHandlerArgs): Promise { + const gate = routePrelude(req, { json: true }); + if ("response" in gate) return gate.response; + try { + return await dispatchInboxRow(req, gate.runtime, args); + } catch (error) { + console.error("Photon dispatch route failed:", error instanceof Error ? error.message : error); + return json({ status: "retryable", reason: "internal_error" } satisfies PhotonDispatchResult, 500); + } +} + +async function dispatchInboxRow( + req: Request, + runtime: PhotonRuntime, + args: RouteHandlerArgs, +): Promise { + const body = await readJsonBody(req); + const parsed = body === undefined ? undefined : dispatchRequestSchema.safeParse(body); + if (!parsed?.success) + return json({ status: "rejected", reason: "invalid_request" } satisfies PhotonDispatchResult, 400); + const { inboxId } = parsed.data; + const store = runtime.store; + + const row = await store.getInboxRow(inboxId); + if (!row || row.disposition === "pending") + return json({ status: "rejected", reason: row ? "inbox_pending_adjudication" : "unknown_inbox" }); + if (row.tombstone || !row.envelope) + return json({ status: "rejected", reason: "no_envelope" }); + if (row.disposition === "denied" || row.disposition === "quarantined" || row.disposition === "tombstone") + return json({ status: "rejected", reason: `disposition_${row.disposition}` }); + + const envelope = row.envelope; + const chatGuid = envelope.chatGuid ?? row.chatGuid; + const existing = chatGuid ? await store.getConversation(row.sourceId, chatGuid) : undefined; + const derivedScope = classifyScope(runtime.config, existing, chatGuid); + + // Defense in depth: admission is re-verified against current conversation + // state even though the worker pre-adjudicated the row. A fresh denial + // rejects; either side saying state-only applies projection state without + // a model turn; only an admitted row on both checks dispatches. + const decision = admitEvent(runtime.config, envelope, toConversationState(existing, derivedScope)); + if (decision.disposition === "state_only" || row.disposition === "state_only") { + await applyProjectionState(store, row, envelope, decision.updatesMembership === true, existing); + await store.markInboxProcessing(inboxId, "settled"); + return json({ status: "state_applied" } satisfies PhotonDispatchResult); + } + if (decision.disposition !== "admitted") + return json({ status: "rejected", reason: decision.reason } satisfies PhotonDispatchResult); + + const event = envelope.event; + const actor = decision.actor; + if (!actor || !chatGuid || event.family !== "message" || event.type !== "received") + return json({ status: "rejected", reason: "actor_or_chat_unverified" }); + const scope = decision.scope ?? existing?.scope ?? derivedScope; + + // A retired binding starts a fresh generation with a new immutable + // initiating principal; its membership roster carries forward because it + // describes the chat, not the retired task. + const conversation = existing?.retiredAt ? undefined : existing; + const generation = existing?.retiredAt ? existing.generation + 1 : existing?.generation ?? 1; + const bindingId = conversation?.bindingId ?? bindingIdFor(row.sourceId, chatGuid, generation); + const bound = conversation ?? await store.upsertConversation({ + bindingId, + sourceId: row.sourceId, + chatGuid, + generation, + scope, + eveAddress: eveAddressFor(bindingId), + initiatingPrincipal: photonPrincipalId(runtime.config.projectId, actor), + membership: existing?.membership.length ? existing.membership : undefined, + }); + + // An expired absolute deadline retires the bound session rather than + // reopening finished sensitive work for a late-arriving event. + if (bound.eveSessionId && await isSessionExpired(store, bound.eveSessionId)) { + await retireBoundSession(args, store, bound.eveSessionId, bound.bindingId, "session_deadline_expired"); + return json({ status: "rejected", reason: "session_deadline_expired" } satisfies PhotonDispatchResult); + } + + const auth = photonDispatchAuth({ + projectId: runtime.config.projectId, + actor, + scope, + chatGuid, + sourceId: row.sourceId, + bindingId: bound.bindingId, + }); + + // Resolve the canonical session before the first send; retries then go + // through the fixed `attachSession` handle and can never drift into a + // replacement session. + let sessionId = bound.eveSessionId; + if (!sessionId) { + const created = await createConversationSession( + args, bound.eveAddress, auth, conversationTitle(scope, actor, chatGuid), + ); + sessionId = created.session.id; + await store.bindEveSession(bound.bindingId, sessionId); + } + + const text = describeInboundMessage(event.message); + const respondCandidate = bound.eveSessionId + ? await pendingPromptResponse(store, row.sourceId, chatGuid, actor, text) + : undefined; + const action = respondCandidate ? "respond" : "send"; + const dispatchKey = dispatchKeyFor(row.sourceId, row.logEpoch, row.sequence, action); + // The action is part of the key, so an accepted delivery under the sibling + // action (e.g. a send accepted before a prompt existed) already settles + // this inbox row — a retry must not deliver the same event twice. + const sibling = await store.getIntentByKey( + dispatchKeyFor(row.sourceId, row.logEpoch, row.sequence, action === "send" ? "respond" : "send"), + ); + if (sibling?.status === "accepted") + return json({ + status: "duplicate", + sessionId: sibling.eveSessionId ?? sessionId, + deliveryId: sibling.deliveryId, + } satisfies PhotonDispatchResult); + const intent = await store.upsertIntent({ + intentId: intentIdFor(dispatchKey), + sourceId: row.sourceId, + logEpoch: row.logEpoch, + sequence: row.sequence, + action, + eveSessionId: sessionId, + dispatchKey, + inputDigest: sha256Hex(canonicalTupleJson( + respondCandidate ? ["respond", respondCandidate.response] : ["send", text], + )), + }); + if (intent.status === "accepted") + return json({ + status: "duplicate", + sessionId: intent.eveSessionId ?? sessionId, + deliveryId: intent.deliveryId, + } satisfies PhotonDispatchResult); + if (intent.status === "rejected") + return json({ status: "rejected", reason: "intent_rejected" } satisfies PhotonDispatchResult); + // Ambiguous or quarantined intents are reconciled by the worker — never + // blind-replayed, which would duplicate an effect that may have landed. + if (intent.status === "ambiguous" || intent.status === "quarantined") + return json({ status: "retryable", reason: `intent_${intent.status}` }); + + const session = args.attachSession(sessionId); + try { + const result = respondCandidate + ? await session.respond([respondCandidate.response], { + auth, + idempotencyKey: dispatchKey, + context: photonContextStrings(scope), + }) + : await session.send(text, { + auth, + idempotencyKey: dispatchKey, + context: photonContextStrings(scope), + turnPolicy: "steer", + }); + if (result.status !== "accepted") { + // session_not_active requires reconciliation; a retry must not slip + // the same event into a different address-resolved session. + await store.updateIntent(intent.intentId, { + status: "ambiguous", lastError: "session_not_active", + }); + return json({ status: "retryable", reason: "session_not_active" } satisfies PhotonDispatchResult); + } + if (respondCandidate) + await store.resolveInteraction( + respondCandidate.interactionId, handleKey(actor), { ...respondCandidate.response }, + ); + await store.updateIntent(intent.intentId, { + status: "accepted", eveSessionId: sessionId, deliveryId: result.deliveryId, + }); + // The absolute 24h deadline starts at the first durably accepted turn; + // the insert is a no-op once registered so retries never extend it. + await registerSessionDeadline(store, sessionId, bound.bindingId); + await store.markInboxProcessing(inboxId, "dispatched"); + await store.touchConversation(bound.bindingId); + return json({ status: "dispatched", sessionId, deliveryId: result.deliveryId } satisfies PhotonDispatchResult); + } catch (error) { + if (error instanceof SessionStrandedError) { + await store.updateIntent(intent.intentId, { + status: "ambiguous", lastError: "session_stranded", + }); + return json({ status: "retryable", reason: "session_stranded" } satisfies PhotonDispatchResult); + } + throw error; + } +} + +async function handleEvents(req: Request, args: RouteHandlerArgs): Promise { + const gate = routePrelude(req, { json: true }); + if ("response" in gate) return gate.response; + try { + return await readSessionEvents(req, gate.runtime, args); + } catch (error) { + console.error("Photon events route failed:", error instanceof Error ? error.message : error); + return json({ status: "rejected", reason: "internal_error" }, 500); + } +} + +async function readSessionEvents( + req: Request, + runtime: PhotonRuntime, + args: RouteHandlerArgs, +): Promise { + const body = await readJsonBody(req); + const parsed = body === undefined ? undefined : eventsReadRequestSchema.safeParse(body); + if (!parsed?.success) return json({ status: "rejected", reason: "invalid_request" }, 400); + const { sessionId, startIndex } = parsed.data; + + // The projector may read event streams only for photon-bound sessions. + const bound = await runtime.store.findConversationBySession(sessionId); + if (!bound) return json({ status: "rejected", reason: "session_not_bound" }, 403); + + const session = args.attachSession(sessionId); + try { + const stream = await session.getEventStream({ startIndex, follow: false }); + const events: unknown[] = []; + const reader = stream.getReader(); + try { + while (events.length < MAX_EVENTS_PER_READ) { + const { done, value } = await reader.read(); + if (done) break; + events.push(value); + } + if (events.length === MAX_EVENTS_PER_READ) + await reader.cancel().catch(() => undefined); + } finally { + reader.releaseLock(); + } + const tailIndex = await session.getStreamTailIndex(); + return json({ events, tailIndex } satisfies PhotonEventsReadResult); + } catch (error) { + if (error instanceof SessionStrandedError) + return json({ events: [], tailIndex: startIndex, stranded: true } satisfies PhotonEventsReadResult); + throw error; + } +} + +async function handleRetire(req: Request, args: RouteHandlerArgs): Promise { + const gate = routePrelude(req, { json: true }); + if ("response" in gate) return gate.response; + try { + return await retireSession(req, gate.runtime, args); + } catch (error) { + console.error("Photon retire route failed:", error instanceof Error ? error.message : error); + return json({ status: "retryable", reason: "internal_error" } satisfies PhotonRetireResult, 500); + } +} + +async function retireSession( + req: Request, + runtime: PhotonRuntime, + args: RouteHandlerArgs, +): Promise { + const body = await readJsonBody(req); + const parsed = body === undefined ? undefined : retireRequestSchema.safeParse(body); + if (!parsed?.success) return json({ status: "rejected", reason: "invalid_request" }, 400); + const { eveSessionId, reason } = parsed.data; + + const [conversation, deadline] = await Promise.all([ + runtime.store.findConversationBySession(eveSessionId), + runtime.store.getDeadline(eveSessionId), + ]); + if (!conversation && !deadline) + return json({ status: "already_retired" } satisfies PhotonRetireResult); + if (deadline?.retiredAt && (!conversation || conversation.retiredAt)) + return json({ status: "already_retired" } satisfies PhotonRetireResult); + + const outcome = await retireBoundSession( + args, runtime.store, eveSessionId, conversation?.bindingId, reason, + ); + return json({ + status: outcome === "reset" ? "retired" : "already_retired", + } satisfies PhotonRetireResult); +} + +async function handleHealth(req: Request): Promise { + const gate = routePrelude(req, { json: false }); + if ("response" in gate) { + // The health probe reports disabled/misconfigured as a failing check, + // not as a retryable dispatch result. + return json({ ok: false } satisfies PhotonHealthReport, gate.response.status); + } + try { + return await photonHealth(gate.runtime); + } catch (error) { + console.error("Photon health route failed:", error instanceof Error ? error.message : error); + return json({ ok: false } satisfies PhotonHealthReport, 503); + } +} + +async function photonHealth(runtime: PhotonRuntime): Promise { + const source = await runtime.store.getLatestSource(runtime.config.projectId); + if (!source) return json({ ok: false } satisfies PhotonHealthReport); + const [inbox, commands] = await Promise.all([ + runtime.store.inboxStats(source.sourceId), + runtime.store.commandStats(source.sourceId), + ]); + const leaseExpiresAt = source.leaseExpiresAt?.toISOString(); + const rawStreams = source.health && typeof source.health === "object" + ? source.health["streams"] : undefined; + const streams = rawStreams && typeof rawStreams === "object" && !Array.isArray(rawStreams) + ? rawStreams as Record + : undefined; + const alive = source.status === "active" && !!leaseExpiresAt && Date.parse(leaseExpiresAt) > Date.now(); + return json({ + ok: alive, + worker: { + alive, + leaseEpoch: source.leaseEpoch, + leaseExpiresAt, + streams, + captureCursor: source.captureCursor ?? undefined, + inboxLagSeconds: inbox.oldestPendingAgeSec ?? undefined, + outboxPending: commands.queued + commands.inFlight, + ambiguous: commands.ambiguous, + }, + } satisfies PhotonHealthReport); +} + +export default defineChannel({ + turnPolicy: "steer", + audience: () => "private", + context(_state, session): PhotonChannelContext { + return { + thread: { + post: async (text: string) => { + const runtime = photonRuntime(); + if (runtime) await postSessionNotice(runtime.store, session.id, text); + }, + }, + }; + }, + events: photonBrowserEvents, + async receive(input, ctx) { + const auth = input.auth; + if (!auth || !isPhotonAuth(auth)) + throw new Error("The photon channel only accepts verified photon session auth."); + // Group audiences never accept a cross-channel handoff: a private + // credential continuation belongs in the verified private conversation. + if (photonAttribute(auth, PHOTON_ATTR_AUDIENCE) === "group") + throw new Error("Group photon sessions cannot receive cross-channel handoffs."); + const bindingId = photonAttribute(auth, PHOTON_ATTR_BINDING); + const targetBinding = typeof input.target?.bindingId === "string" + ? input.target.bindingId + : bindingId; + if (!targetBinding || (bindingId && bindingId !== targetBinding)) + throw new Error("A photon handoff can only continue the session's own bound conversation."); + const runtime = photonRuntime(); + if (!runtime) throw new Error("The photon channel is disabled."); + const conversation = await runtime.store.getConversationById(targetBinding); + if (!conversation || conversation.retiredAt) + throw new Error("No active photon conversation binding for the requested target."); + if (photonAttribute(auth, PHOTON_ATTR_SOURCE) !== conversation.sourceId + || photonAttribute(auth, PHOTON_ATTR_CHAT) !== conversation.chatGuid) + throw new Error("Photon auth attributes do not match the requested conversation binding."); + return ctx.from(conversation.eveAddress).send(input.message, { + auth, + turnPolicy: "steer", + }); + }, + routes: [ + POST(PHOTON_DISPATCH_PATH, handleDispatch), + POST(PHOTON_EVENTS_PATH, handleEvents), + POST(PHOTON_RETIRE_PATH, handleRetire), + GET(PHOTON_HEALTH_PATH, handleHealth), + ], +}); diff --git a/agent/instructions.md b/agent/instructions.md index 56ea2dc..39695cc 100644 --- a/agent/instructions.md +++ b/agent/instructions.md @@ -5,9 +5,9 @@ Use the optional vault tools to resolve credentials on the server, never through Use human_handoff for OTP, passkeys, and other human-only steps, and wait for the user's reply. Never print raw browser session identifiers, connection URLs, passwords, or merchant credentials. A denied page is a denial. Report observed results, and take a screenshot after a requested demo. -Link wallet and checkout tools are available to authenticated, allowlisted Linq senders. +Link wallet and checkout tools are available to verified Photon iMessage senders in private chats. To connect a wallet, call link__retrieve_user_info and let Eve send the OAuth consent link. -Never request OAuth tokens or client credentials in chat. The web demo cannot access Linq wallets. +Never request OAuth tokens or client credentials in chat. The web demo and group chats cannot access wallets. Financial insights and Visa are not enabled. Use link_disconnect to revoke the sender's wallet grant. For a user-requested purchase, follow Bell's Link create-payment-credential skill. Confirm the @@ -37,4 +37,6 @@ one request in the existing browser. An ordinary run/goto has no credential. Eac navigation needs a fresh proof. After access, call snapshot and screenshot to verify the result. Do not claim a successful mint or merchant access unless the tool result confirms it. -For private Linq senders, use `onepassword_connect`, `onepassword_status`, `onepassword_login`, and `onepassword_disconnect` for personal 1Password access. If status is disabled, explain that personal 1Password login is not available yet; do not offer a consent link or fall back to the operator vault. Never use the operator vault tools for a messaging sender. Connecting is separate from approving a website login. Use the exact login URL and a distinct expected success path on the same origin. A verified destination is not proof of account identity. The first version supports single-step sign-in only. Approval links are sent through Eve authorization; do not ask for tokens, integration keys, passwords, or OTPs in chat. If a private fill fails, start a new conversation rather than inspecting the blocked browser. Personal browser mode does not support reusable Contexts. +For private Photon senders, use `onepassword_connect`, `onepassword_status`, `onepassword_login`, and `onepassword_disconnect` for personal 1Password access. If status is disabled, explain that personal 1Password login is not available yet; do not offer a consent link or fall back to the operator vault. Never use the operator vault tools for a messaging sender. Connecting is separate from approving a website login. Use the exact login URL and a distinct expected success path on the same origin. A verified destination is not proof of account identity. The first version supports single-step sign-in only. Approval links are sent through Eve authorization; do not ask for tokens, integration keys, passwords, or OTPs in chat. If a private fill fails, start a new conversation rather than inspecting the blocked browser. Personal browser mode does not support reusable Contexts. + +Photon messaging tools (`photon_message`, `photon_media`, `photon_reaction`, `photon_poll`, `photon_app`, `photon_chat`, `photon_group`, `photon_history`, `photon_address`, `photon_location`, `photon_contact`) queue durable provider commands; a queued receipt is not proof the device rendered anything. Report provider acknowledgements, failures, and ambiguous outcomes exactly. Reactions, stickers, edits, unsends, polls, and mini-app cards are visible chat actions — confirm intent before using them in a group, and never use them to impersonate approvals. Location requests and address checks are private-conversation features; in a group, redirect sensitive asks to the private chat. The sender who started a group task owns its browser and approvals for the life of that task. diff --git a/agent/lib/browser-runtime.ts b/agent/lib/browser-runtime.ts index 6f9839e..4f0d45e 100644 --- a/agent/lib/browser-runtime.ts +++ b/agent/lib/browser-runtime.ts @@ -1,16 +1,27 @@ import { personalBrowserEnabled, withPersonalBrowserLock } from "../../server/onepassword/browser-policy"; import { createAgentContext, + createIsolatedAgentContext, ensureAgentContext, isDraftAgentContext, listAgentContexts, promoteAgentContext, + releaseIsolatedContext, selectAgentContext, } from "../../server/browser/agent-contexts.js"; import { BrowserSession, CaptchaHandoffError } from "../../server/browser/browser-session.js"; +import { getContextMetadataStore, isOwnedGroupContext } from "../../server/browser/context-metadata-store.js"; import { withBrowserPrivacyLock } from "../../server/browser/privacy-lock"; import type { BrowserProxyLocation, ConversationState, TraceEvent } from "../../server/browser/types.js"; +import type { ToolContext } from "eve/tools"; import { runWithCancellation } from "./cancellation"; +import { + browserScope, + cancellationClosesBrowser, + resolveGroupBinding, + type BrowserScope, + type GroupBrowserBinding, +} from "./group-binding"; import { applyCaptchaTransition, closeTaskBrowser, @@ -24,11 +35,31 @@ import { type BrowserOperation = (browser: BrowserSession) => Promise; +export { browserScope, type BrowserScope } from "./group-binding"; + +/** + * A session id or the tool context itself. Passing `ctx` is the preferred + * form: the scope is derived from the verified messaging principal so group + * browser tasks stay bound to their initiating member. + */ +type BrowserTarget = string | Pick; + +function resolveBrowserTarget( + target: BrowserTarget, + scope?: BrowserScope, +): { sessionId: string; scope?: BrowserScope } { + return typeof target === "string" + ? { sessionId: target, scope } + : { sessionId: target.session.id, scope: scope ?? browserScope(target) }; +} + interface BrowserRuntime { browser: BrowserSession; state: ConversationState; idleTimer?: ReturnType; closing?: Promise; + /** Verified principal that owns this isolated group runtime, when bound. */ + isolatedOwnerId?: string; } export interface BrowserToolOutput { @@ -42,22 +73,64 @@ export interface BrowserToolOutput { } const runtimes = new Map(); +const runtimeBuilds = new Map>(); const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1_000; -function getRuntime(sessionId: string): BrowserRuntime { - const current = runtimes.get(sessionId); - if (current) return current; +/** Group binding/staleness rules live in agent/lib/group-binding.ts. */ +const hasRuntime = (sessionId: string): boolean => runtimes.has(sessionId); - const saved = taskState.get(); - const durable = reconcileCaptchaAfterRestart(saved, new Date().toISOString()); - if (durable !== saved) taskState.update(() => durable); - const state: ConversationState = { +/* ------------------------------------------------------------------ */ +/* Launch serialization + isolated environment */ +/* ------------------------------------------------------------------ */ + +/** + * Browserbase launches read shared-Context and personal-extension fallbacks + * straight from process.env. Group sessions must never see either, so their + * launches run under a mask. Every launch is serialized so a masked group + * launch cannot leak into (or be interrupted by) another session's start. + */ +const ISOLATED_BROWSER_ENV = ["BROWSERBASE_CONTEXT_ID", "BROWSERBASE_1PASSWORD_EXTENSION_ID"] as const; +let browserLaunchQueue: Promise = Promise.resolve(); + +function withMaskedBrowserEnv(operation: () => T): T { + const saved = ISOLATED_BROWSER_ENV.map((name) => [name, process.env[name]] as const); + for (const [name] of saved) delete process.env[name]; + try { + return operation(); + } finally { + for (const [name, value] of saved) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + } +} + +function launchIsolatedBrowser(start: () => Promise): Promise { + const launch = browserLaunchQueue.then(() => withMaskedBrowserEnv(start)); + browserLaunchQueue = launch.then( + () => undefined, + () => undefined, + ); + return launch; +} + +/** Synchronous mask for task-state projection paths that read env fallbacks. */ +function withIsolatedEnv(runtime: BrowserRuntime, operation: () => T): T { + return runtime.isolatedOwnerId === undefined ? operation() : withMaskedBrowserEnv(operation); +} + +function createInitialState( + sessionId: string, + durable: ReturnType, + contextId = durable.browser.contextId, +): ConversationState { + return { id: sessionId, traces: [], browser: { provider: durable.browser.sessionId ? "browserbase" : "not-started", status: "idle", - contextId: durable.browser.contextId, + contextId, contextStatus: durable.browser.contextStatus, proxyLocation: durable.browser.proxyLocation, sessionId: durable.browser.sessionId, @@ -65,31 +138,130 @@ function getRuntime(sessionId: string): BrowserRuntime { title: durable.browser.title, }, }; - const runtime = { +} + +function buildRuntime(sessionId: string, state: ConversationState): BrowserRuntime { + return { state, browser: createRuntimeBrowser( sessionId, state, - durable.browser.contextId, - durable.browser.proxyLocation, + state.browser.contextId, + state.browser.proxyLocation, ), }; +} + +function buildIsolatedRuntime( + sessionId: string, + state: ConversationState, + ownerId: string, +): BrowserRuntime { + const browser = new BrowserSession(state, { + // Never inherit a shared/saved Context. A persisted Context id is honored + // only after it is confirmed as this session's own isolated draft — that + // check runs in ensureRuntime below. + contextId: state.browser.contextId, + proxyLocation: state.browser.proxyLocation, + captcha: taskState.get().captcha, + ensureContext: async (currentContextId) => { + if (currentContextId) { + // The isolated Context id was restored or created by this runtime; + // reuse it for the life of this task. Never consult env fallbacks. + return { id: currentContextId, status: "draft" as const }; + } + const context = await createIsolatedAgentContext(sessionId, ownerId); + state.browser.contextId = context.id; + state.browser.contextStatus = "draft"; + taskState.update((current) => recordTaskBrowserContext(current, context.id, "draft")); + return { id: context.id, status: "draft" as const }; + }, + }); + const start = browser.start.bind(browser); + browser.start = () => launchIsolatedBrowser(start); + return { + state, + isolatedOwnerId: ownerId, + browser, + }; +} + +/** + * Creates (or returns) the session runtime. Group sessions get an isolated + * runtime: fresh per-task Context, no shared-Context or extension env + * fallbacks, no saved-Context inheritance. A runtime created before the group + * binding was known is closed and rebuilt so no shared state can leak in. + */ +async function ensureRuntime(sessionId: string, group?: GroupBrowserBinding): Promise { + const current = runtimes.get(sessionId); + if (current && (!group || current.isolatedOwnerId)) return current; + let build = runtimeBuilds.get(sessionId); + const next = (build ?? Promise.resolve()).then(async () => { + const existing = runtimes.get(sessionId); + if (existing && (!group || existing.isolatedOwnerId)) return existing; + if (existing) { + runtimes.delete(sessionId); + await existing.browser.close().catch(() => undefined); + } + + const saved = taskState.get(); + const durable = reconcileCaptchaAfterRestart(saved, new Date().toISOString()); + if (durable !== saved) taskState.update(() => durable); + let contextId = durable.browser.contextId; + if (group) { + // Restore a Context only when the metadata row proves it is this + // session's own isolated group draft — never a shared or personal one. + const row = contextId ? (await getContextMetadataStore().read()).contexts[contextId] : undefined; + if (!isOwnedGroupContext(row, sessionId)) contextId = undefined; + } + const state = createInitialState(sessionId, durable, contextId); + const runtime = group + ? buildIsolatedRuntime(sessionId, state, group.ownerId) + : buildRuntime(sessionId, state); + runtimes.set(sessionId, runtime); + return runtime; + }); + build = next; + runtimeBuilds.set(sessionId, build); + try { + return await next; + } finally { + if (runtimeBuilds.get(sessionId) === build) runtimeBuilds.delete(sessionId); + } +} + +/** Legacy synchronous path for callers that cannot carry a scope (cleanup). */ +function getRuntime(sessionId: string): BrowserRuntime { + const current = runtimes.get(sessionId); + if (current) return current; + + const saved = taskState.get(); + const durable = reconcileCaptchaAfterRestart(saved, new Date().toISOString()); + if (durable !== saved) taskState.update(() => durable); + const runtime = buildRuntime(sessionId, createInitialState(sessionId, durable)); runtimes.set(sessionId, runtime); return runtime; } export async function runBrowserOperation( - sessionId: string, + target: BrowserTarget, operation: BrowserOperation, includeScreenshot = false, + scope?: BrowserScope, ): Promise> { - return withBrowserPrivacyLock(sessionId, - () => withPersonalBrowserLock(sessionId, () => runBrowserOperationUnlocked(sessionId, operation, includeScreenshot)), + const resolved = resolveBrowserTarget(target, scope); + const group = await resolveGroupBinding(resolved.sessionId, resolved.scope, hasRuntime); + if (group) + // Group tasks skip the personal-1Password and payment-privacy locks: every + // credential path is denied upstream, and no shared lock state may attach. + return runBrowserOperationUnlocked(resolved.sessionId, operation, includeScreenshot, group); + return withBrowserPrivacyLock(resolved.sessionId, + () => withPersonalBrowserLock(resolved.sessionId, () => runBrowserOperationUnlocked(resolved.sessionId, operation, includeScreenshot)), undefined, Boolean(taskState.get().browser.sessionId)); } -async function runBrowserOperationUnlocked(sessionId: string, operation: BrowserOperation, includeScreenshot: boolean): Promise> { - const runtime = getRuntime(sessionId); +async function runBrowserOperationUnlocked(sessionId: string, operation: BrowserOperation, includeScreenshot: boolean, group?: GroupBrowserBinding): Promise> { + const runtime = await ensureRuntime(sessionId, group); scheduleIdleClose(sessionId, runtime); const traceStart = runtime.state.traces.length; let settled = false; @@ -137,17 +309,40 @@ async function runBrowserOperationUnlocked(sessionId: string, operation: Brow function commitCaptchaQueue(runtime: BrowserRuntime): void { const transitions = runtime.browser.drainCaptchaTransitions(); if (!transitions.length) return; - taskState.update((current) => { - let next = current; - for (const transition of transitions) next = applyCaptchaTransition(next, transition); - return updateTaskBrowser(next, runtime.state.browser); - }); + withIsolatedEnv(runtime, () => + taskState.update((current) => { + let next = current; + for (const transition of transitions) next = applyCaptchaTransition(next, transition); + return updateTaskBrowser(next, runtime.state.browser); + }), + ); } export async function saveBrowserRuntime(sessionId: string): Promise { const runtime = runtimes.get(sessionId); if (runtime) - taskState.update((current) => updateTaskBrowser(current, runtime.state.browser)); + withIsolatedEnv(runtime, () => + taskState.update((current) => updateTaskBrowser(current, runtime.state.browser)), + ); +} + +/** Deletes this session's isolated group Context, if one was attached. */ +async function releaseGroupContext(sessionId: string, runtime?: BrowserRuntime): Promise { + const bound = + runtime?.isolatedOwnerId !== undefined + || (await getContextMetadataStore().groupBrowserTask(sessionId)) !== undefined; + if (!bound) return; + const contextId = runtime?.state.browser.contextId ?? taskState.get().browser.contextId; + if (contextId) { + try { + await releaseIsolatedContext(contextId, sessionId); + } catch (error) { + // Keep the metadata row: the draft TTL sweep or a retry will remove it. + console.error(JSON.stringify({ event: "browser.group.context_release_failed", error: String(error) })); + } + } + // The task is over: a later task must bind a verified owner of its own. + await getContextMetadataStore().unbindGroupBrowserTask(sessionId); } export async function closeBrowserRuntime(sessionId: string): Promise { @@ -160,21 +355,34 @@ export async function closeBrowserRuntime(sessionId: string): Promise { if (runtime.idleTimer) clearTimeout(runtime.idleTimer); runtime.closing ??= runtime.browser.close().then(() => { runtimes.delete(sessionId); - taskState.update((current) => - closeTaskBrowser(updateTaskBrowser(current, runtime.state.browser)), + withIsolatedEnv(runtime, () => + taskState.update((current) => + closeTaskBrowser(updateTaskBrowser(current, runtime.state.browser)), + ), ); }).finally(() => { runtime.closing = undefined; }); - return runtime.closing; + await runtime.closing; + // A group Context is fresh per task and must never outlive the browser. + await releaseGroupContext(sessionId, runtime); + return; } taskState.update(closeTaskBrowser); + await releaseGroupContext(sessionId, undefined); } export async function runCancelableBrowserWork( - sessionId: string, + target: BrowserTarget, signal: AbortSignal, operation: () => Promise, + scope?: BrowserScope, ): Promise { - return runWithCancellation(signal, operation, () => closeBrowserRuntime(sessionId)); + const resolved = resolveBrowserTarget(target, scope); + return runWithCancellation(signal, operation, async () => { + // In a group-bound session only the bound owner may cancel the task's + // browser; another member's cancellation must not release it. + if (await cancellationClosesBrowser(resolved.sessionId, resolved.scope)) + await closeBrowserRuntime(resolved.sessionId); + }); } export function hasBrowserRuntime(sessionId: string): boolean { @@ -185,16 +393,25 @@ export function resumeBrowserAfterHumanInput(sessionId: string): void { runtimes.get(sessionId)?.browser.resumeAfterHumanInput(); } +/** Reusable Context storage is shared — group sessions may never reach it. */ +async function assertSharedContextsAllowed(sessionId: string, scope?: BrowserScope): Promise { + if (await resolveGroupBinding(sessionId, scope, hasRuntime)) + throw new Error("Reusable browser Contexts are not available in a group conversation."); +} + export async function createBrowserContext( - sessionId: string, + target: BrowserTarget, name: string, signal?: AbortSignal, + scope?: BrowserScope, ): Promise<{ id: string; name: string; selected: true; migratedCookieCount: number; }> { + const { sessionId, scope: resolvedScope } = resolveBrowserTarget(target, scope); + await assertSharedContextsAllowed(sessionId, resolvedScope); signal?.throwIfAborted(); const durable = taskState.get(); const runtime = @@ -253,9 +470,12 @@ export async function createBrowserContext( } export async function selectBrowserContext( - sessionId: string, + target: BrowserTarget, contextId: string, + scope?: BrowserScope, ): Promise<{ id: string; name: string; selected: true }> { + const { sessionId, scope: resolvedScope } = resolveBrowserTarget(target, scope); + await assertSharedContextsAllowed(sessionId, resolvedScope); const runtime = runtimes.get(sessionId); if (runtime?.browser.hasStarted() || taskState.get().browser.sessionId) { throw new Error("Save or close the current browser before you select another Context."); @@ -278,15 +498,18 @@ export async function selectBrowserContext( } export async function saveBrowserContext( - sessionId: string, + target: BrowserTarget, name?: string, signal?: AbortSignal, + scope?: BrowserScope, ): Promise<{ id: string; name: string; status: "saved"; migratedCookieCount: number; }> { + const { sessionId, scope: resolvedScope } = resolveBrowserTarget(target, scope); + await assertSharedContextsAllowed(sessionId, resolvedScope); signal?.throwIfAborted(); let contextId = taskState.get().browser.contextId; let migratedCookieCount = 0; diff --git a/agent/lib/group-binding.ts b/agent/lib/group-binding.ts new file mode 100644 index 0000000..fc44297 --- /dev/null +++ b/agent/lib/group-binding.ts @@ -0,0 +1,183 @@ +import { + getContextMetadataStore, + type ContextMetadataFile, +} from "../../server/browser/context-metadata-store.js"; +import { photonEnabled } from "../../server/photon/config.js"; +import { + type ConversationRow, + type PhotonStore, +} from "../../server/photon/store.js"; +import type { ToolContext } from "eve/tools"; +import { + verifiedMessagingPrincipal, + type MessagingAudience, +} from "./messaging-auth"; +import { getPhotonStore } from "./photon-store"; +import { taskState } from "./task-state"; + +/** + * Who may drive a group browser task. Derived from the verified messaging + * principal of the current tool call; `audience` is the durable + * conversation scope and `ownerId` the verified `photon:` principal id. + * Sessions with no verified principal (web, eve, operator) carry no scope + * and keep the existing shared behavior. + */ +export interface BrowserScope { + readonly audience: MessagingAudience; + readonly ownerId: string; + readonly bindingId?: string; +} + +/** Derives the browser scope for a tool call, or `undefined` for non-messaging sessions. */ +export function browserScope(ctx: Pick): BrowserScope | undefined { + const verified = verifiedMessagingPrincipal(ctx.session.auth?.current); + if (!verified) return undefined; + return { + audience: verified.audience, + ownerId: verified.principal.id, + bindingId: verified.bindingId, + }; +} + +export interface GroupBrowserBinding { + ownerId: string; + bindingId?: string; + membershipVersion?: number; +} + +/** + * The durable conversation lookup is only needed (and only configured) when + * the Photon channel is enabled. Web/eve/operator sessions never query it. + * The store is the process-wide shared pool (agent/lib/photon-store.ts). + */ +function conversationStore(): PhotonStore | undefined { + const databaseUrl = process.env.DATABASE_URL; + if (!photonEnabled() || !databaseUrl) return undefined; + return getPhotonStore({ databaseUrl }); +} + +async function findConversation(sessionId: string): Promise { + const store = conversationStore(); + if (!store) return undefined; + try { + return await store.findConversationBySession(sessionId); + } catch (error) { + console.error(JSON.stringify({ event: "browser.group.conversation_lookup_failed", error: String(error) })); + return undefined; + } +} + +/** A pending credential/human step that a membership bump must not silently complete. */ +function pendingSensitiveStep(): boolean { + const record = taskState.get(); + return record.status === "waiting_for_user" || record.status === "solving_captcha"; +} + +type PersistedGroupTask = NonNullable[string]; + +/** One merged view of the three places group evidence can live. */ +interface GroupState { + /** Whether any group signal binds this session's browser work. */ + readonly isGroup: boolean; + /** The durable conversation's initiating principal wins over the persisted task owner. */ + readonly ownerId?: string; + readonly persisted?: PersistedGroupTask; + readonly conversation?: ConversationRow; +} + +/** + * The single resolution both group policies share: merge the live tool-call + * scope, the persisted task binding, and — whenever any group signal exists + * (or `forceLookup`, e.g. before a runtime exists to mask one) — the durable + * conversation row. `resolveGroupBinding` and `cancellationClosesBrowser` + * are each a small policy over this state, never parallel re-derivations. + */ +async function resolveGroupState( + sessionId: string, + scope: BrowserScope | undefined, + forceLookup = false, +): Promise { + const persisted = await getContextMetadataStore().groupBrowserTask(sessionId); + const conversation = + forceLookup || persisted !== undefined || scope?.audience === "group" + ? await findConversation(sessionId) + : undefined; + return { + isGroup: + scope?.audience === "group" || persisted !== undefined || conversation?.scope === "group", + ownerId: conversation?.initiatingPrincipal ?? persisted?.ownerId, + persisted, + conversation, + }; +} + +/** + * Resolves whether this session's browser work belongs to a group member and + * enforces the group contract: + * + * - Only a verified group principal may drive a group-bound session. + * - The owner is `conversation.initiatingPrincipal` when the durable + * conversation provides it, else the first verified member to start browser + * work. A different member can never take over, snapshot or release it. + * - A conversation membershipVersion bump since the task bound makes pending + * sensitive approvals stale: they fail and must be re-asserted. + * + * `hasRuntime` tells whether the session already has a live browser runtime — + * a group session's first unscoped call is still detected before one exists. + * Returns the binding for group sessions, `undefined` for everything else. + */ +export async function resolveGroupBinding( + sessionId: string, + scope: BrowserScope | undefined, + hasRuntime: (sessionId: string) => boolean, +): Promise { + const metadata = getContextMetadataStore(); + // Consult the durable conversation whenever we have a group signal, and once + // opportunistically per runtime so a group session's first unscoped call is + // still detected before a shared runtime can be created. + const { isGroup, persisted, conversation, ownerId: boundOwner } = await resolveGroupState( + sessionId, scope, !hasRuntime(sessionId), + ); + if (!isGroup) return undefined; + + if (scope?.audience !== "group") + throw new Error("This browser task belongs to a group member. Continue it from the verified group conversation."); + const ownerId = boundOwner ?? scope.ownerId; + if (scope.ownerId !== ownerId) + throw new Error("Only the person who started this browser task may use it."); + + if ( + conversation && persisted?.membershipVersion !== undefined + && conversation.membershipVersion !== persisted.membershipVersion + ) { + await metadata.bindGroupBrowserTask(sessionId, { + ownerId, + bindingId: conversation.bindingId, + membershipVersion: conversation.membershipVersion, + }); + if (pendingSensitiveStep()) + throw new Error("The group membership changed. The person who started this browser task must restart the pending step."); + } else if ( + !persisted || persisted.ownerId !== ownerId + || (conversation !== undefined && persisted.membershipVersion !== conversation.membershipVersion) + ) { + await metadata.bindGroupBrowserTask(sessionId, { + ownerId, + bindingId: conversation?.bindingId ?? persisted?.bindingId ?? scope.bindingId, + membershipVersion: conversation?.membershipVersion, + }); + } + return { + ownerId, + bindingId: conversation?.bindingId ?? persisted?.bindingId ?? scope.bindingId, + membershipVersion: conversation?.membershipVersion ?? persisted?.membershipVersion, + }; +} + +/** Whether a caller's cancellation may close a group-bound browser task. */ +export async function cancellationClosesBrowser(sessionId: string, scope?: BrowserScope): Promise { + const { isGroup, ownerId } = await resolveGroupState(sessionId, scope); + if (!isGroup) return true; + // With no bound owner there is no task to protect yet. + return !ownerId || (scope?.audience === "group" && scope.ownerId === ownerId); +} diff --git a/agent/lib/link-auth.ts b/agent/lib/link-auth.ts index 0cb863a..8ff66ba 100644 --- a/agent/lib/link-auth.ts +++ b/agent/lib/link-auth.ts @@ -2,6 +2,7 @@ import { defineInteractiveAuthorization, isConnectionAuthorizationRequiredError, import type { ToolContext } from "eve/tools"; import { getLinkOAuth, linkOwner, safeLinkCall, type LinkResume } from "../../server/link/oauth"; import { assertSandboxRequest, isLinkSandbox, sandboxToken } from "../../server/link/sandbox"; +import { messagingPrincipal } from "./messaging-auth"; export function createLinkAuth(service = getLinkOAuth) { const hosted = () => { @@ -32,9 +33,12 @@ export function createLinkAuth(service = getLinkOAuth) { export const linkAuth = createLinkAuth(); export function linkPrincipal(ctx: ToolContext): ConnectionPrincipal { - const auth = ctx.session.auth.current; - const principal: ConnectionPrincipal = auth?.principalType === "user" && auth.authenticator === "linq-message" - ? { type: "user", id: auth.principalId, issuer: auth.issuer } + // Link is private-only: only a verified direct-message Photon principal maps + // to a wallet owner. Group sessions and any other caller resolve to + // { type: "app" }, which fails linkOwner — deliberate fail-closed. + const verified = messagingPrincipal(ctx); + const principal: ConnectionPrincipal = verified?.audience === "direct" + ? verified.principal : { type: "app" }; linkOwner(principal); return principal; diff --git a/agent/lib/linq.ts b/agent/lib/linq.ts deleted file mode 100644 index 9462924..0000000 --- a/agent/lib/linq.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { LinqChannelConfig } from "eve/channels/linq"; -import { browserChannelEvents } from "./browser-runtime"; - -export const linqBrowserEvents: NonNullable = { - ...browserChannelEvents, - async "session.failed"(event, channel) { - try { - await browserChannelEvents["session.failed"](event); - } finally { - await channel.thread?.post("This session could not recover from an error. Start a new conversation to continue."); - } - }, -}; - -export function requiredEnvironmentValue(name: string): string { - const value = process.env[name]?.trim(); - if (!value) throw new Error(`${name} is required for the Linq channel.`); - return value; -} diff --git a/agent/lib/messaging-auth.ts b/agent/lib/messaging-auth.ts new file mode 100644 index 0000000..c3590ec --- /dev/null +++ b/agent/lib/messaging-auth.ts @@ -0,0 +1,95 @@ +import type { ConnectionPrincipal } from "eve/connections"; +import type { SessionAuthContext } from "eve/context"; +import type { ToolContext } from "eve/tools"; + +import { + PHOTON_ISSUER, + isVerifiedPhotonAuth, +} from "../../server/photon/principal.js"; + +/** + * One shared verified-messaging/private-audience predicate for the private + * Photon channel. Link, personal 1Password, vault guards and sensitive + * tools must all evaluate the same contract instead of re-deriving it. + * The principal contract itself lives in server/photon/principal.ts — the + * single source every verifier shares. + */ + +/** Session auth attribute keys stamped by the channel's dispatch route. */ +export const PHOTON_ATTR_AUDIENCE = "photon:audience"; +export const PHOTON_ATTR_CHAT = "photon:chat"; +export const PHOTON_ATTR_SOURCE = "photon:source"; +export const PHOTON_ATTR_BINDING = "photon:binding"; + +export type MessagingAudience = "direct" | "group"; + +export interface VerifiedMessagingPrincipal { + readonly principal: Extract; + readonly audience: MessagingAudience; + /** Provider chat GUID bound to this session, when stamped. */ + readonly chatGuid?: string; + /** Enrolled provider source identity, when stamped. */ + readonly sourceId?: string; + /** Durable conversation binding row identity, when stamped. */ + readonly bindingId?: string; + /** The verified handle the principal maps to (`phone:+...` / `email:...`). */ + readonly subject?: string; +} + +/** Reads a single-valued photon session auth attribute. */ +export function photonAttribute( + auth: Pick, + key: string, +): string | undefined { + const value = auth.attributes[key]; + const result = Array.isArray(value) ? value[0] : value; + return typeof result === "string" && result.length > 0 ? result : undefined; +} + +function audienceOf(auth: SessionAuthContext): MessagingAudience | undefined { + const audience = photonAttribute(auth, PHOTON_ATTR_AUDIENCE); + return audience === "direct" || audience === "group" ? audience : undefined; +} + +/** + * Returns the verified Photon messaging principal for the current session + * auth, or `null` for every non-Photon or malformed identity. Issuer and + * principal shape are both checked; the audience attribute must classify + * the conversation as direct or group. + */ +export function verifiedMessagingPrincipal( + auth: SessionAuthContext | null | undefined, +): VerifiedMessagingPrincipal | null { + if (!isVerifiedPhotonAuth(auth)) return null; + const audience = audienceOf(auth); + if (!audience) return null; + return { + principal: { type: "user", id: auth.principalId, issuer: PHOTON_ISSUER }, + audience, + chatGuid: photonAttribute(auth, PHOTON_ATTR_CHAT), + sourceId: photonAttribute(auth, PHOTON_ATTR_SOURCE), + bindingId: photonAttribute(auth, PHOTON_ATTR_BINDING), + subject: auth.subject, + }; +} + +/** The verified principal for a tool call, or `null` when not messaging. */ +export function messagingPrincipal(ctx: Pick): VerifiedMessagingPrincipal | null { + return verifiedMessagingPrincipal(ctx.session.auth?.current); +} + +/** + * The immutable owner allowed to complete a sensitive pending action in the + * current conversation. Group sessions bind the first initiating principal; + * a different participant's message never transfers that ownership. + */ +export function assertInitiatingOwner( + ctx: ToolContext, + initiatingPrincipal: string | undefined, +): VerifiedMessagingPrincipal { + const verified = messagingPrincipal(ctx); + if (!verified) throw new Error("A verified messaging principal is required."); + if (initiatingPrincipal && verified.principal.id !== initiatingPrincipal) + throw new Error("Only the user who started this action may complete it."); + return verified; +} diff --git a/agent/lib/onepassword-auth.ts b/agent/lib/onepassword-auth.ts index c69ec26..fa77384 100644 --- a/agent/lib/onepassword-auth.ts +++ b/agent/lib/onepassword-auth.ts @@ -2,10 +2,15 @@ import { defineInteractiveAuthorization, type ConnectionPrincipal } from "eve/co import type { ToolContext } from "eve/tools"; import { getOnePassword, type Resume } from "../../server/onepassword/oauth"; import { failed, ownerOf } from "../../server/onepassword/config"; +import { messagingPrincipal } from "./messaging-auth"; export function onePasswordPrincipal(ctx: ToolContext): ConnectionPrincipal { - const auth = ctx.session.auth.current; - const principal: ConnectionPrincipal = auth?.principalType === "user" && auth.authenticator === "linq-message" ? { type: "user", id: auth.principalId, issuer: auth.issuer } : { type: "app" }; + // Personal 1Password is private-only: group sessions and other callers fall + // through to { type: "app" }, which fails ownerOf — deliberate fail-closed. + const verified = messagingPrincipal(ctx); + const principal: ConnectionPrincipal = verified?.audience === "direct" + ? verified.principal + : { type: "app" }; ownerOf(principal); return principal; } diff --git a/agent/lib/photon-store.ts b/agent/lib/photon-store.ts new file mode 100644 index 0000000..f9130d0 --- /dev/null +++ b/agent/lib/photon-store.ts @@ -0,0 +1,25 @@ +import type { PhotonConfig } from "../../server/photon/config.js"; +import { createPhotonStore, type PhotonStore } from "../../server/photon/store.js"; + +const pools = new Map(); + +/** + * One process-wide channel store per database, shared by every consumer in + * this process — tool reads/writes, browser-runtime conversation lookups, + * and channel bookkeeping all go through the same connection pool that + * fronts the durable tables the transport worker drains. The map never + * evicts: a `databaseUrl` rotation leaks the old pool for the process's + * life, which is acceptable because config rotation happens at restart — + * not something a long-lived pool should chase. + */ +export function getPhotonStore(config: Pick): PhotonStore { + let store = pools.get(config.databaseUrl); + if (!store) { + store = createPhotonStore(config.databaseUrl); + store.pool.on("error", () => { + console.error("Photon channel store connection failed."); + }); + pools.set(config.databaseUrl, store); + } + return store; +} diff --git a/agent/lib/photon-tools.ts b/agent/lib/photon-tools.ts new file mode 100644 index 0000000..51e54bb --- /dev/null +++ b/agent/lib/photon-tools.ts @@ -0,0 +1,678 @@ +import { randomUUID } from "node:crypto"; + +import type { ToolContext } from "eve/tools"; +import { z } from "zod"; + +import { loadPhotonConfig, type PhotonConfig } from "../../server/photon/config.js"; +import { + commandArgsSchemas, + isReadOperation, + validateCommandArgs, + type AttachmentInfo, + type PhotonCommand, + type PhotonCommandArgs, + type PhotonCommandOp, + type PhotonCommandResult, + type customizedMiniAppSchema, +} from "../../server/photon/contracts.js"; +import { sanitizeFileName } from "../../server/photon/filename.js"; +import { canonicalTupleJson, semanticKeyFor, sha256Hex } from "../../server/photon/identity.js"; +import { + type ConversationRow, + type PhotonDb, + type PhotonStore, + type ResourceKind, +} from "../../server/photon/store.js"; +import { messagingPrincipal, type VerifiedMessagingPrincipal } from "./messaging-auth"; +import { getPhotonStore } from "./photon-store"; + +/** + * Shared plumbing for the Photon agent tools. Each tool verifies the + * messaging principal and channel configuration, resolves the durable + * conversation binding, validates arguments against the frozen command + * contracts, and appends a typed PhotonCommand to the outbox. The transport + * worker is the only process that talks to the provider — tools never call + * the gRPC SDK and never see provider credentials. + */ + +/** How long a tool waits for an acknowledged/failed command before reporting pending. */ +export const COMMAND_RESULT_TIMEOUT_MS = 8_000; +const RESULT_POLL_INTERVAL_MS = 250; + +export interface PhotonToolContext { + readonly principal: VerifiedMessagingPrincipal; + readonly config: PhotonConfig; + readonly store: PhotonStore; +} + +/** + * Verified messaging principal plus enabled channel configuration. Throws a + * descriptive error when the session is not a Photon messaging session or + * the channel is disabled so tools degrade cleanly outside the channel. + */ +export function photonContext(ctx: ToolContext): PhotonToolContext { + const principal = messagingPrincipal(ctx); + if (!principal) throw new Error("This tool requires a Photon messaging session."); + const config = loadPhotonConfig(); + if (!config) throw new Error("Photon channel is not enabled."); + return { principal, config, store: getPhotonStore(config) }; +} + +/** + * Resolves the durable conversation bound to this session. The stamped + * binding id wins; a chat+source attribute pair is the compatibility path + * and the Eve session mapping is the last fallback. A missing or retired + * binding is fatal — tools must not guess a destination. + */ +export async function resolveConversation( + store: PhotonStore, + principal: VerifiedMessagingPrincipal, + sessionId?: string, +): Promise { + let conversation = principal.bindingId + ? await store.getConversationById(principal.bindingId) + : principal.chatGuid && principal.sourceId + ? await store.getConversation(principal.sourceId, principal.chatGuid) + : undefined; + conversation ??= sessionId ? await store.findConversationBySession(sessionId) : undefined; + if (!conversation || conversation.retiredAt) + throw new Error("The conversation bound to this session is unavailable. Ask the user to start a new conversation."); + if (conversation.scope !== principal.audience) + throw new Error("The conversation binding does not match this session's audience."); + return conversation; +} + +export interface PhotonScope extends PhotonToolContext { + readonly conversation: ConversationRow; + readonly sessionId: string; + readonly toolCallId: string; +} + +/** photonContext plus the bound conversation — the normal tool entrypoint. */ +export async function photonScope(ctx: ToolContext): Promise { + const context = photonContext(ctx); + const conversation = await resolveConversation(context.store, context.principal, ctx.session.id); + return { ...context, conversation, sessionId: ctx.session.id, toolCallId: ctx.callId }; +} + +/** The verified principal must belong to the private direct conversation. */ +export function assertPrivateAudience(ctx: ToolContext): VerifiedMessagingPrincipal { + const principal = messagingPrincipal(ctx); + if (!principal) throw new Error("This tool requires a Photon messaging session."); + if (principal.audience !== "direct") + throw new Error("This action requires the private conversation. Continue in a direct message instead."); + return principal; +} + +/** The verified principal must belong to an admitted group conversation. */ +export function assertGroupAudience(ctx: ToolContext): VerifiedMessagingPrincipal { + const principal = messagingPrincipal(ctx); + if (!principal) throw new Error("This tool requires a Photon messaging session."); + if (principal.audience !== "group") + throw new Error("This action requires an admitted group conversation."); + return principal; +} + +/* ------------------------------------------------------------------ */ +/* Outbound commands */ +/* ------------------------------------------------------------------ */ + +/** + * Command args with `chat` omitted: chat-scoped operations always bind to + * the resolved conversation server-side, so a model can never steer a + * command into a different provider chat. + */ +export type BoundCommandArgs = Omit, "chat">; + +export interface EnqueuePhotonOptions { + /** Extra routing/correlation metadata stored with the command row. */ + readonly target?: Record; + /** Deterministic dedupe key override; defaults to a per-tool-call key. */ + readonly semanticKey?: string; + /** Command ids that must settle before the worker executes this one. */ + readonly dependsOn?: readonly string[]; + /** ISO timestamp after which the command expires instead of executing. */ + readonly expiresAt?: string; +} + +/** + * Validates arguments against the frozen operation contract, binds the + * command to the verified principal and conversation, and appends it to the + * durable outbox. Accepts either a ToolContext (resolving the scope itself) + * or a pre-resolved PhotonScope so a tool can reuse its binding. + */ +export async function enqueuePhotonCommand( + ctx: ToolContext | PhotonScope, + operation: Op, + args: BoundCommandArgs, + opts: EnqueuePhotonOptions = {}, +): Promise<{ commandId: string; deduplicated: boolean }> { + const scope = "conversation" in ctx ? ctx : await photonScope(ctx); + const schema = commandArgsSchemas[operation] as z.ZodObject; + const bound = "chat" in schema.shape ? { ...args, chat: scope.conversation.chatGuid } : args; + let parsed: PhotonCommandArgs; + try { + parsed = validateCommandArgs(operation, bound); + } catch (error) { + if (error instanceof z.ZodError) { + // Zod issue messages name expectations, not supplied values, so this + // stays free of message bodies and other sensitive content. + const detail = error.issues + .slice(0, 4) + .map((issue) => `${issue.path.join(".") || "args"}: ${issue.message}`) + .join("; "); + throw new Error(`Invalid ${operation} arguments: ${detail}`); + } + throw error; + } + const commandId = randomUUID(); + const command: PhotonCommand = { + commandId, + operation, + args: parsed, + argsDigest: sha256Hex(canonicalTupleJson([parsed])), + initiatorPrincipal: scope.principal.principal.id, + audience: scope.principal.audience, + sourceId: scope.conversation.sourceId, + chatGuid: scope.conversation.chatGuid, + target: opts.target ?? (isReadOperation(operation) ? { requestId: commandId } : undefined), + semanticKey: + opts.semanticKey ?? + semanticKeyFor({ + sessionId: scope.sessionId, + outputKind: operation, + toolCallId: scope.toolCallId, + commandId, + }), + dependsOn: opts.dependsOn ?? [], + expiresAt: opts.expiresAt, + }; + return scope.store.enqueueCommand(command); +} + +/** + * Enqueues a fire-and-forget mutation command and returns the standard + * agent-facing `{ status: "queued", commandId, deduplicated }` receipt. + */ +export async function queuePhotonCommand( + ctx: ToolContext | PhotonScope, + operation: Op, + args: BoundCommandArgs, + opts: EnqueuePhotonOptions = {}, +): Promise<{ status: "queued"; commandId: string; deduplicated: boolean }> { + const { commandId, deduplicated } = await enqueuePhotonCommand(ctx, operation, args, opts); + return { status: "queued", commandId, deduplicated }; +} + +/** + * Enqueues a command and waits up to `COMMAND_RESULT_TIMEOUT_MS` for the + * worker to settle it. + */ +export async function runPhotonCommand( + scope: PhotonScope, + operation: Op, + args: BoundCommandArgs, + signal?: AbortSignal, + opts: EnqueuePhotonOptions = {}, +): Promise> { + const { commandId } = await enqueuePhotonCommand(scope, operation, args, opts); + return awaitCommandResult(scope.store, commandId, operation, undefined, signal); +} + +/* ------------------------------------------------------------------ */ +/* Command results */ +/* ------------------------------------------------------------------ */ + +/** + * A settled (or still-running) durable command. `result` is typed by the + * operation the caller enqueued — the executor's emitted shape lives in + * `PhotonCommandResultMap`, so projections never read provider fields off + * the receipt root. + */ +export type PhotonCommandReceipt = + | { + readonly status: "acknowledged"; + readonly commandId: string; + readonly result?: PhotonCommandResult; + readonly providerReceipt?: unknown; + } + | { readonly status: "ambiguous"; readonly commandId: string; readonly reason?: string } + | { readonly status: "failed"; readonly commandId: string; readonly errorCode?: string } + | { readonly status: "cancelled" | "expired"; readonly commandId: string } + | { readonly status: "pending"; readonly commandId: string }; + +/** + * Polls the durable command row until it settles or the timeout expires. + * Reads ride the outbox for durability, and the Eve process shares Postgres + * with the worker, so the result lands in the same command row. A timeout + * returns `pending` — the caller can retry or check later without losing + * the durable command identity. `operation` is the caller-declared op: it + * types the returned `result` and must match the op that was enqueued — + * awaiting a command row under the wrong op is a caller bug and throws. + */ +export async function awaitCommandResult( + store: PhotonDb, + commandId: string, + operation: Op, + timeoutMs = COMMAND_RESULT_TIMEOUT_MS, + signal?: AbortSignal, +): Promise> { + const deadline = Date.now() + timeoutMs; + for (;;) { + signal?.throwIfAborted(); + const row = await store.getCommand(commandId); + if (row && row.operation !== operation) + throw new Error(`Command ${commandId} is ${row.operation}, not ${operation}.`); + switch (row?.status) { + case "acknowledged": + return { + status: "acknowledged", + commandId, + result: row.result as PhotonCommandResult | undefined, + providerReceipt: row.providerReceipt, + }; + case "ambiguous": + return { status: "ambiguous", commandId, reason: row.errorDetail ?? row.errorCode }; + case "failed": + return { status: "failed", commandId, errorCode: row.errorCode }; + case "cancelled": + return { status: "cancelled", commandId }; + case "expired": + return { status: "expired", commandId }; + default: + break; + } + if (Date.now() >= deadline) return { status: "pending", commandId }; + await new Promise((resolve) => setTimeout(resolve, RESULT_POLL_INTERVAL_MS)); + } +} + +/* ------------------------------------------------------------------ */ +/* Artifacts and media */ +/* ------------------------------------------------------------------ */ + +/** Model-supplied file bytes accepted by media-bearing tools. */ +export const modelFileInputSchema = z + .object({ + dataUrl: z.string().min(16).max(140_000_000).optional(), + base64: z.string().min(4).max(140_000_000).optional(), + mediaType: z.string().min(3).max(255).optional(), + fileName: z.string().min(1).max(255).optional(), + }) + .strict() + .refine((value) => (value.dataUrl === undefined) !== (value.base64 === undefined), { + message: "exactly one of dataUrl or base64 is required", + }); +export type ModelFileInput = z.infer; + + +function decodeBase64Bytes(value: string, limit: number, field: string): Uint8Array { + const compact = value.replace(/\s+/g, ""); + // base64 expands to 3 bytes per 4 chars — reject on encoded length first. + if (compact.length > Math.ceil(limit / 3) * 4) + throw new Error(`${field} exceeds the ${limit}-byte limit once decoded.`); + if (compact.length === 0 || compact.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(compact)) + throw new Error(`${field} is not valid base64.`); + return Buffer.from(compact, "base64"); +} + +/** + * Decodes a base64 or base64 `data:` URL payload, bounded by the artifact + * budget before touching the bytes. The media type is taken from the data + * URL when the caller did not supply one. + */ +export function decodeMediaInput( + input: ModelFileInput, + maxBytes: number, +): { data: Uint8Array; mediaType: string } { + let data: Uint8Array; + let mediaType = input.mediaType; + if (input.dataUrl !== undefined) { + const match = /^data:([^;,]+)?;base64,(.+)$/s.exec(input.dataUrl); + if (!match) throw new Error("dataUrl must be a base64 data: URL such as data:image/png;base64,...."); + mediaType = mediaType ?? match[1]; + data = decodeBase64Bytes(match[2], maxBytes, "dataUrl"); + } else if (input.base64 !== undefined) { + data = decodeBase64Bytes(input.base64, maxBytes, "base64"); + } else { + throw new Error("File bytes are required as base64 or a base64 data: URL."); + } + if (!mediaType) throw new Error("mediaType is required when it is not carried by the data: URL."); + return { data, mediaType }; +} + +export interface StagedPhotonFile { + readonly artifactId: string; + readonly byteSize: number; + readonly mediaType: string; + readonly fileName?: string; +} + +/** + * Stages bounded bytes into the durable artifact store so a worker command + * (upload, card image, background, icon) can resolve them later. Returns the + * artifact identity used in args and dependsOn chains. + */ +export async function uploadModelFile( + scope: PhotonScope, + input: { data: Uint8Array; mediaType: string; fileName?: string; attachmentGuid?: string }, +): Promise { + if (input.data.byteLength === 0) throw new Error("The supplied file is empty."); + if (input.data.byteLength > scope.config.worker.maxArtifactBytes) + throw new Error(`The supplied file exceeds the ${scope.config.worker.maxArtifactBytes}-byte artifact limit.`); + const fileName = input.fileName ? sanitizeFileName(input.fileName) : undefined; + const artifactId = await scope.store.putArtifact({ + sourceId: scope.conversation.sourceId, + chatGuid: scope.conversation.chatGuid, + attachmentGuid: input.attachmentGuid, + fileName, + mediaType: input.mediaType, + data: input.data, + }); + return { + artifactId, + byteSize: input.data.byteLength, + mediaType: input.mediaType, + fileName, + }; +} + +/** Metadata of a staged artifact without loading its bytes. */ +export interface StagedArtifactRef { + readonly artifactId: string; + readonly attachmentGuid?: string; + readonly byteSize: number; + readonly mediaType: string; + readonly fileName?: string; +} + +/** + * Finds the newest artifact staged for a provider attachment GUID in this + * conversation — metadata only, through the store facade. + */ +export async function findStagedArtifact( + scope: PhotonScope, + attachmentGuid: string, +): Promise { + return scope.store.findArtifactByAttachment( + scope.conversation.sourceId, + scope.conversation.chatGuid, + attachmentGuid, + ); +} + +/** Whether an artifact row belongs to this conversation's source and chat. */ +export function artifactInConversation( + scope: PhotonScope, + artifact: { sourceId: string; chatGuid: string } | undefined, +): artifact is { sourceId: string; chatGuid: string } { + return ( + artifact !== undefined && + artifact.sourceId === scope.conversation.sourceId && + artifact.chatGuid === scope.conversation.chatGuid + ); +} + +/** Enqueues one logical provider upload per staged artifact (deduplicated). */ +export async function enqueueArtifactUpload( + scope: PhotonScope, + input: { artifactId: string; fileName: string; companionArtifactId?: string }, +): Promise<{ commandId: string; deduplicated: boolean }> { + return enqueuePhotonCommand( + scope, + "attachments.upload", + { + fileName: sanitizeFileName(input.fileName), + artifactId: input.artifactId, + companionArtifactId: input.companionArtifactId, + }, + { + semanticKey: semanticKeyFor({ + sessionId: scope.sessionId, + outputKind: "attachments.upload", + partId: input.artifactId, + }), + }, + ); +} + +/** + * Extracts the provider attachment GUID from a command result. Only the + * `attachments.upload` member of the result map carries `attachmentGuid`; + * status probes on other ops simply resolve `undefined`. + */ +export function uploadedAttachmentGuid( + result: PhotonCommandResult | undefined, +): string | undefined { + return result && "attachmentGuid" in result ? result.attachmentGuid : undefined; +} + +export type ResolvedAttachment = + | { readonly resolved: true; readonly attachmentGuid: string; readonly dependsOn: readonly string[] } + | { readonly resolved: false; readonly receipt: PhotonCommandReceipt }; + +/** + * Ensures provider-side bytes exist for an outgoing attachment. A supplied + * attachmentGuid is used as-is; a staged artifact is uploaded once through + * the deduplicated upload command and awaited so the send can chain on the + * returned GUID; inline bytes are staged first. An unsettled upload returns + * `resolved: false` so the tool can report pending without double-sending. + */ +export async function resolveAttachmentGuid( + scope: PhotonScope, + input: { attachmentGuid?: string; artifactId?: string; file?: ModelFileInput }, + signal?: AbortSignal, +): Promise { + if (input.attachmentGuid) { + await assertTargetInConversation(scope, "attachment", input.attachmentGuid, "The attachment"); + return { resolved: true, attachmentGuid: input.attachmentGuid, dependsOn: [] }; + } + let artifactId = input.artifactId; + if (!artifactId && input.file) { + const decoded = decodeMediaInput(input.file, scope.config.worker.maxArtifactBytes); + const staged = await uploadModelFile(scope, { + data: decoded.data, + mediaType: decoded.mediaType, + fileName: sanitizeFileName(input.file.fileName), + }); + artifactId = staged.artifactId; + } + if (!artifactId) + throw new Error("An attachmentGuid, artifactId, or inline file is required."); + const artifact = await scope.store.getArtifact(artifactId); + if (!artifactInConversation(scope, artifact)) + throw new Error("The referenced media artifact is unavailable in this conversation."); + if (artifact.attachmentGuid) + return { resolved: true, attachmentGuid: artifact.attachmentGuid, dependsOn: [] }; + const upload = await enqueueArtifactUpload(scope, { + artifactId, + fileName: artifact.fileName ?? "attachment", + }); + const receipt = await awaitCommandResult( + scope.store, + upload.commandId, + "attachments.upload", + undefined, + signal, + ); + const guid = receipt.status === "acknowledged" ? receipt.result?.attachmentGuid : undefined; + if (receipt.status === "acknowledged" && guid) + return { resolved: true, attachmentGuid: guid, dependsOn: [upload.commandId] }; + return { resolved: false, receipt }; +} + +/** + * Stages inline file bytes (when supplied) or validates an existing staged + * artifactId against the conversation scope and an allowed media-type set. + */ +export async function resolveStagedImageArtifact( + scope: PhotonScope, + input: { artifactId?: string; file?: ModelFileInput }, + options: { + readonly allowedMediaTypes: ReadonlySet; + readonly fallbackFileName: string; + readonly typeError: string; + readonly missingError: string; + }, +): Promise { + let artifactId = input.artifactId; + if (input.file) { + const decoded = decodeMediaInput(input.file, scope.config.worker.maxArtifactBytes); + if (!options.allowedMediaTypes.has(decoded.mediaType.toLowerCase())) + throw new Error(options.typeError); + artifactId = ( + await uploadModelFile(scope, { + data: decoded.data, + mediaType: decoded.mediaType, + fileName: sanitizeFileName(input.file.fileName, options.fallbackFileName), + }) + ).artifactId; + } + if (!artifactId) throw new Error("An artifactId or file is required."); + const artifact = await scope.store.getArtifact(artifactId); + if (!artifactInConversation(scope, artifact)) throw new Error(options.missingError); + if (!options.allowedMediaTypes.has(artifact.mediaType.toLowerCase())) + throw new Error(options.typeError); + return artifactId; +} + +/** Standard tool output when a dependent media upload has not settled yet. */ +export function uploadPendingResponse(receipt: PhotonCommandReceipt, label: string) { + return { + status: receipt.status, + commandId: receipt.commandId, + stage: "upload" as const, + detail: + receipt.status === "pending" || receipt.status === "ambiguous" + ? `The ${label} upload is still in flight. Retry once it is acknowledged.` + : `The ${label} upload did not complete.`, + }; +} + +/** + * The provider's card layout rules beyond the schema: at least one primary + * text slot or an image is required (summary alone is insufficient), image + * and imageTitle must be supplied together, and imageSubtitle requires + * image. + */ +export function assertValidCardLayout(layout: { + caption?: string; + subcaption?: string; + trailingCaption?: string; + trailingSubcaption?: string; + image?: string; + imageTitle?: string; + imageSubtitle?: string; +}): void { + const hasPrimaryText = [ + layout.caption, + layout.subcaption, + layout.trailingCaption, + layout.trailingSubcaption, + ].some((value) => typeof value === "string" && value.length > 0); + if (!hasPrimaryText && layout.image === undefined) + throw new Error( + "The card layout needs a caption, subcaption, trailingCaption, trailingSubcaption, or image.", + ); + if (layout.image !== undefined && layout.imageTitle === undefined) + throw new Error("layout.image and layout.imageTitle must be supplied together."); + if (layout.image === undefined && layout.imageTitle !== undefined) + throw new Error("layout.imageTitle requires layout.image."); + if (layout.imageSubtitle !== undefined && layout.image === undefined) + throw new Error("layout.imageSubtitle requires layout.image."); +} + +/** + * A card layout image is a staged artifactId, not raw bytes — the worker + * resolves it into the JPEG payload the card primitive requires. The + * artifact must belong to this conversation and carry an image/jpeg type. + */ +export async function assertCardImageArtifact( + scope: PhotonScope, + layout: { image?: string }, +): Promise { + if (layout.image === undefined) return; + const artifact = await scope.store.getArtifact(layout.image); + if (!artifactInConversation(scope, artifact)) + throw new Error("The card image artifact is unavailable in this conversation."); + if (artifact.mediaType !== "image/jpeg") + throw new Error("The card image must be a staged JPEG artifact (mediaType image/jpeg)."); +} + +/** Validates and sends a customized mini-app card, awaiting the card session. */ +export async function sendMiniAppCard( + scope: PhotonScope, + card: z.infer, + signal?: AbortSignal, +): Promise> { + assertValidCardLayout(card.layout); + await assertCardImageArtifact(scope, card.layout); + return runPhotonCommand(scope, "messages.sendCustomizedMiniApp", { message: card }, signal); +} + +/* ------------------------------------------------------------------ */ +/* Target and payload safety */ +/* ------------------------------------------------------------------ */ + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +/** + * Verifies a message/poll/attachment GUID belongs to this conversation + * before a tool targets it. Group sessions fail closed on unknown or + * unverifiable resources; the operator DM may still reference resources the + * worker has not projected yet, since the account is its own boundary. + */ +export async function assertTargetInConversation( + scope: PhotonScope, + kind: ResourceKind, + guid: string, + label: string, +): Promise { + const resource = await scope.store.getResource(scope.conversation.sourceId, kind, guid); + const group = scope.principal.audience === "group"; + if (!resource || resource.tombstone) { + if (group) throw new Error(`${label} is not tracked in this conversation.`); + return; + } + const data = resource.data; + const single = nonEmptyString(data.chatGuid) ?? nonEmptyString(data.chat_guid); + const list = Array.isArray(data.chatGuids) + ? data.chatGuids.filter((value): value is string => typeof value === "string") + : []; + if (single !== undefined || list.length > 0) { + if (single === scope.conversation.chatGuid || list.includes(scope.conversation.chatGuid)) return; + throw new Error(`${label} belongs to a different conversation.`); + } + if (group) throw new Error(`${label} cannot be verified against this conversation.`); +} + +/* ------------------------------------------------------------------ */ +/* Compact JSON projections */ +/* ------------------------------------------------------------------ */ + +export function isoTimestamp(value: Date | string | null | undefined): string | undefined { + if (value instanceof Date) return value.toISOString(); + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +/** Bounded, agent-facing projection of provider AttachmentInfo. */ +export function projectAttachmentInfo( + info: AttachmentInfo | undefined, +): Record | undefined { + if (!info) return undefined; + return { + guid: info.guid, + fileName: info.fileName, + mimeType: info.mimeType, + uti: info.uti, + totalBytes: info.totalBytes, + transferState: info.transferState, + isHidden: info.isHidden, + isOutgoing: info.isOutgoing, + isSticker: info.isSticker, + companionKind: info.companionKind, + originalGuid: info.originalGuid, + }; +} diff --git a/agent/lib/photon.ts b/agent/lib/photon.ts new file mode 100644 index 0000000..6469d93 --- /dev/null +++ b/agent/lib/photon.ts @@ -0,0 +1,153 @@ +import { createHash, timingSafeEqual } from "node:crypto"; + +import type { ChannelEvents } from "eve/channels"; +import type { SessionAuthContext } from "eve/context"; + +import type { Message, PhotonCommand } from "../../server/photon/contracts.js"; +import { + canonicalTupleJson, + photonSessionAuth, + semanticKeyFor, + sha256Hex, + type CanonicalHandle, +} from "../../server/photon/identity.js"; +import type { PhotonDb } from "../../server/photon/store.js"; +import { browserChannelEvents } from "./browser-runtime"; +import { + PHOTON_ATTR_AUDIENCE, + PHOTON_ATTR_BINDING, + PHOTON_ATTR_CHAT, + PHOTON_ATTR_SOURCE, +} from "./messaging-auth"; + +export { photonAttribute } from "./messaging-auth"; + +/** + * Timing-safe check for `authorization: Bearer ` on the + * private dispatch routes. Both sides are hashed before comparison so a + * length difference never short-circuits the compare early. + */ +export function verifyInternalAuth(req: Request, secret: string): boolean { + const header = req.headers.get("authorization")?.trim() ?? ""; + const presented = header.toLowerCase().startsWith("bearer ") + ? header.slice("bearer ".length).trim() + : ""; + if (!presented) return false; + return timingSafeEqual( + createHash("sha256").update(presented, "utf8").digest(), + createHash("sha256").update(secret, "utf8").digest(), + ); +} + +/** + * Builds the Eve session principal for one verified inbound event actor. + * Identity is always derived from the stored envelope, never from request + * payload claims; the attributes stamp the application-owned audience scope + * that `verifiedMessagingPrincipal` later requires. + */ +export function photonDispatchAuth(input: { + readonly projectId: string; + readonly actor: CanonicalHandle; + readonly scope: "direct" | "group"; + readonly chatGuid: string; + readonly sourceId: string; + readonly bindingId: string; +}): SessionAuthContext { + return photonSessionAuth(input.projectId, input.actor, { + [PHOTON_ATTR_AUDIENCE]: input.scope, + [PHOTON_ATTR_CHAT]: input.chatGuid, + [PHOTON_ATTR_SOURCE]: input.sourceId, + [PHOTON_ATTR_BINDING]: input.bindingId, + }); +} + +/** + * Renders the model-facing text for an inbound message. Non-text content + * becomes explicit file/app placeholders — raw provider JSON never reaches + * the model, and attachment-only messages still produce usable input. + */ +export function describeInboundMessage(message: Message): string { + const parts: string[] = []; + if (message.subject) parts.push(`Subject: ${message.subject}`); + const text = message.content.text?.trim(); + if (text) parts.push(text); + for (const attachment of message.content.attachments) { + const name = attachment.fileName?.trim() || attachment.mimeType || "unnamed file"; + parts.push(`[Attachment: ${name}${message.isAudioMessage ? " (voice note)" : ""}]`); + } + if (message.content.miniApp) parts.push("[iMessage app card]"); + if (parts.length === 0) parts.push("[Message with no readable content]"); + return parts.join("\n"); +} + +/** Channel context strings persisted ahead of the delivered message. */ +export function photonContextStrings(scope: "direct" | "group"): readonly string[] { + if (scope === "group") { + return [ + "This request arrived through an admitted iMessage group chat on Bell's Photon channel. " + + "Reply concisely in this chat. Never share personal credentials, private handoff links, " + + "wallet or vault contents in a group. When a request needs a private credential flow, " + + "tell the user to continue in their private conversation.", + ]; + } + return [ + "This request arrived through Bell's private Photon iMessage channel. " + + "Reply concisely in this chat. Use the same browser for follow-up requests. " + + "For a human-only step, send the human_handoff tool's link and wait for a reply.", + ]; +} + +/** + * Channel context handed to photon `events` handlers. `thread.post` is the + * only user-visible surface: it records a lifecycle notice through the + * durable outbound command path, so a callback failure cannot silently lose + * the message — the worker's outbox loop delivers it later. + */ +export interface PhotonChannelContext { + readonly thread?: { + readonly post: (text: string) => Promise; + }; +} + +/** + * Enqueues a conversation-bound lifecycle notice (session failure and + * similar) as a durable `messages.sendText` command. Repeating the same + * notice text for one session collapses to one command. Best effort: enqueue + * failures are swallowed because the durable-event projector is the + * authoritative output repair path. + */ +export async function postSessionNotice( + db: PhotonDb, + sessionId: string, + text: string, +): Promise { + const conversation = await db.findConversationBySession(sessionId); + if (!conversation || conversation.retiredAt) return; + const noticeKey = sha256Hex(canonicalTupleJson(["v1", text])).slice(0, 24); + const command: PhotonCommand = { + commandId: `photon-cmd:notice:${sessionId}:${noticeKey}`, + operation: "messages.sendText", + args: { chat: conversation.chatGuid, text }, + argsDigest: sha256Hex(canonicalTupleJson(["v1", text])), + initiatorPrincipal: conversation.initiatingPrincipal, + audience: conversation.scope, + sourceId: conversation.sourceId, + chatGuid: conversation.chatGuid, + semanticKey: semanticKeyFor({ sessionId, outputKind: "lifecycle_notice", partId: noticeKey }), + dependsOn: [], + }; + await db.enqueueCommand(command).catch(() => undefined); +} + +export const photonBrowserEvents: ChannelEvents = { + ...browserChannelEvents, + async "session.failed"(event, channel) { + try { + await browserChannelEvents["session.failed"](event); + } finally { + await channel.thread?.post( + "This session could not recover from an error. Start a new conversation to continue.", + ); + } + }, +}; diff --git a/agent/lib/session-lifetime.ts b/agent/lib/session-lifetime.ts new file mode 100644 index 0000000..dce7768 --- /dev/null +++ b/agent/lib/session-lifetime.ts @@ -0,0 +1,43 @@ +import type { PhotonDb } from "../../server/photon/store.js"; + +/** + * Persisted absolute session deadline policy. + * + * Eve 0.82 redefined `sessionTimeoutMs` as an idle timeout that resets on + * every turn. Bell's pre-upgrade contract was a 24-hour maximum conversation + * lifetime, so this module keeps an absolute deadline in the channel store: + * it is written once on the first admitted turn (`ON CONFLICT DO NOTHING`), + * survives restarts, and is enforced by the dispatch route before each send + * and by the worker's retirement sweep through `/internal/photon/retire`. + */ +export const SESSION_ABSOLUTE_LIFETIME_MS = 24 * 60 * 60 * 1_000; + +/** + * Records the absolute deadline for a bound Eve session. The insert is + * idempotent: the first admitted turn owns the deadline so follow-up turns + * never extend the maximum lifetime. + */ +export async function registerSessionDeadline( + db: PhotonDb, + sessionId: string, + bindingId?: string, + now: Date = new Date(), +): Promise { + const deadlineAt = new Date(now.getTime() + SESSION_ABSOLUTE_LIFETIME_MS); + await db.registerDeadline(sessionId, bindingId, deadlineAt); + return deadlineAt; +} + +/** + * Whether the session's persisted absolute deadline has passed and the + * session has not already been retired. Sessions without a deadline row are + * not expired: the deadline is registered on the first admitted turn. + */ +export async function isSessionExpired( + db: PhotonDb, + sessionId: string, + now: Date = new Date(), +): Promise { + const deadline = await db.getDeadline(sessionId); + return !!deadline && !deadline.retiredAt && now.getTime() > deadline.deadlineAt.getTime(); +} diff --git a/agent/tools/agent.ts b/agent/tools/agent.ts index a908e44..0c9d653 100644 --- a/agent/tools/agent.ts +++ b/agent/tools/agent.ts @@ -1,2 +1,9 @@ import { disableTool } from "eve/tools"; + +// Delegation stays disabled: Eve's defineState does not propagate the +// server-verified Photon root conversation, audience and initiating-principal +// binding into subagents. Until a delegation path carries that binding it must +// fail closed — a subagent without it can never fall back to web or operator +// privileges just because its immediate channel differs from the root Photon +// channel. export default disableTool(); diff --git a/agent/tools/link_disconnect.ts b/agent/tools/link_disconnect.ts index d63f778..f907cf3 100644 --- a/agent/tools/link_disconnect.ts +++ b/agent/tools/link_disconnect.ts @@ -6,7 +6,7 @@ import { linkPrincipal } from "../lib/link-auth"; import { isLinkSandbox } from "../../server/link/sandbox"; export default defineTool({ - description: "Disconnect this Linq sender's Link wallet and revoke its OAuth grant. If revocation fails, access stays blocked; retry this tool to finish disconnecting. This does not cancel merchant orders or subscriptions.", + description: "Disconnect this messaging sender's Link wallet and revoke its OAuth grant. If revocation fails, access stays blocked; retry this tool to finish disconnecting. This does not cancel merchant orders or subscriptions.", inputSchema: z.object({}), approval: always(), async execute(_input, ctx) { diff --git a/agent/tools/photon_address.ts b/agent/tools/photon_address.ts new file mode 100644 index 0000000..31baea1 --- /dev/null +++ b/agent/tools/photon_address.ts @@ -0,0 +1,88 @@ +import { defineTool, toolOutput } from "eve/tools"; +import { z } from "zod"; + +import { + assertPrivateAudience, + photonScope, + runPhotonCommand, +} from "../lib/photon-tools"; + +const address = z.string().min(3).max(320); + +const inputSchema = z.discriminatedUnion("action", [ + z.object({ action: z.literal("get"), address }).strict(), + z.object({ action: z.literal("isIMessageAvailable"), address }).strict(), + z.object({ action: z.literal("isFocusSilenced"), address }).strict(), +]); + +export default defineTool({ + description: + "Preflight an address on the account — private conversation only. get returns the normalized address, nullable country, and reported services (iMessage/SMS/RCS/unknown); a missing address comes back as a structured not-found. isIMessageAvailable is a current-availability preflight, not a delivery guarantee — false includes inability to confirm, and a later send can still fail. isFocusSilenced returns the reported live Focus/silence state with its freshness; it is a point-in-time snapshot, not a persistent preference or permission to send Notify Anyway. Each action waits up to ~8s and returns 'acknowledged' with the provider verdict, 'failed' with errorCode, or 'pending' with commandId.", + inputSchema, + async execute(input, ctx) { + assertPrivateAudience(ctx); + const scope = await photonScope(ctx); + switch (input.action) { + case "get": { + const receipt = await runPhotonCommand( + scope, + "addresses.get", + { address: input.address }, + ctx.abortSignal, + ); + const result = receipt.status === "acknowledged" ? receipt.result : undefined; + return { + status: receipt.status, + commandId: receipt.commandId, + found: result?.found, + address: + result === undefined + ? undefined + : result.found + ? { + address: result.info.address, + country: result.info.country, + services: [...result.info.services], + } + : { address: result.address, country: null, services: [] }, + errorCode: receipt.status === "failed" ? receipt.errorCode : undefined, + }; + } + case "isIMessageAvailable": { + const receipt = await runPhotonCommand( + scope, + "addresses.isIMessageAvailable", + { address: input.address }, + ctx.abortSignal, + ); + const result = receipt.status === "acknowledged" ? receipt.result : undefined; + return { + status: receipt.status, + commandId: receipt.commandId, + available: result?.available, + note: "Availability preflight only — not a delivery guarantee.", + errorCode: receipt.status === "failed" ? receipt.errorCode : undefined, + }; + } + case "isFocusSilenced": { + const receipt = await runPhotonCommand( + scope, + "addresses.isFocusSilenced", + { address: input.address }, + ctx.abortSignal, + ); + const result = receipt.status === "acknowledged" ? receipt.result : undefined; + return { + status: receipt.status, + commandId: receipt.commandId, + silenced: result?.silenced, + note: "Live Focus snapshot — not a persistent preference or permission for Notify Anyway.", + errorCode: receipt.status === "failed" ? receipt.errorCode : undefined, + }; + } + } + }, + toModelOutput(output) { + return toolOutput.json(output); + }, +}); diff --git a/agent/tools/photon_app.ts b/agent/tools/photon_app.ts new file mode 100644 index 0000000..770b29d --- /dev/null +++ b/agent/tools/photon_app.ts @@ -0,0 +1,97 @@ +import { defineTool, toolOutput } from "eve/tools"; +import { z } from "zod"; + +import { + customizedMiniAppSchema, + miniAppCardSessionSchema, +} from "../../server/photon/contracts.js"; +import { + assertCardImageArtifact, + assertValidCardLayout, + photonScope, + runPhotonCommand, + sendMiniAppCard, +} from "../lib/photon-tools"; + +const inputSchema = z.discriminatedUnion("action", [ + z + .object({ + action: z.literal("send"), + card: customizedMiniAppSchema, + }) + .strict(), + z + .object({ + action: z.literal("update"), + messageGuid: z.string().min(1).max(512), + card: customizedMiniAppSchema, + }) + .strict(), +]); + +export default defineTool({ + description: + "Send or update a customized iMessage mini-app card in the current conversation. send takes the native card object: appName, extensionBundleId (no colon), teamId (10 uppercase alphanumerics), an absolute http(s) url delivered to the installed extension, optional appStoreId, optional live:true for the recipient's installed extension UI (absent extension renders the static fallback — send success does not prove live rendering), and layout {caption, subcaption, trailingCaption, trailingSubcaption, imageTitle, imageSubtitle, summary, image}; image is a staged JPEG artifactId from photon_media upload and requires imageTitle. send waits up to ~8s and returns the card session {chatGuid, messageGuid, sessionId, targetMessageGuid}. update replaces a card in place using the messageGuid returned by an earlier send — the durable session is resolved server-side, and the call fails when no mini-app session is persisted for that message.", + inputSchema, + async execute(input, ctx) { + const scope = await photonScope(ctx); + switch (input.action) { + case "send": { + const receipt = await sendMiniAppCard(scope, input.card, ctx.abortSignal); + const result = receipt.status === "acknowledged" ? receipt.result : undefined; + return { + status: receipt.status, + commandId: receipt.commandId, + session: result?.miniAppCardSession, + messageGuid: result?.message.guid, + }; + } + case "update": { + assertValidCardLayout(input.card.layout); + await assertCardImageArtifact(scope, input.card.layout); + // The model holds a card's message guid (or the sessionId from a + // send's session object) — either resolves to the authoritative + // session the executor persisted, keyed by sessionId. + const index = await scope.store.getResource( + scope.conversation.sourceId, + "miniapp_session_by_message", + input.messageGuid, + ); + const indexedSessionId = + index && !index.tombstone && typeof index.data.sessionId === "string" && index.data.sessionId.length > 0 + ? index.data.sessionId + : undefined; + const resource = await scope.store.getResource( + scope.conversation.sourceId, + "miniapp_session", + indexedSessionId ?? input.messageGuid, + ); + const parsed = + resource && !resource.tombstone + ? miniAppCardSessionSchema.safeParse(resource.data.session) + : undefined; + if (!parsed?.success) + throw new Error( + "No mini-app session is persisted for that message. Only cards sent through this channel can be updated.", + ); + if (parsed.data.chatGuid !== scope.conversation.chatGuid) + throw new Error("The stored mini-app session belongs to a different conversation."); + const receipt = await runPhotonCommand( + scope, + "messages.updateCustomizedMiniApp", + { session: parsed.data, message: input.card }, + ctx.abortSignal, + ); + const result = receipt.status === "acknowledged" ? receipt.result : undefined; + return { + status: receipt.status, + commandId: receipt.commandId, + session: result?.miniAppCardSession, + }; + } + } + }, + toModelOutput(output) { + return toolOutput.json(output); + }, +}); diff --git a/agent/tools/photon_chat.ts b/agent/tools/photon_chat.ts new file mode 100644 index 0000000..6532f6e --- /dev/null +++ b/agent/tools/photon_chat.ts @@ -0,0 +1,180 @@ +import { defineTool, toolOutput } from "eve/tools"; +import { z } from "zod"; + +import { + backgroundPresetSchema, + effectSchema, + type Chat, +} from "../../server/photon/contracts.js"; +import { + assertPrivateAudience, + modelFileInputSchema, + photonScope, + queuePhotonCommand, + resolveStagedImageArtifact, + runPhotonCommand, +} from "../lib/photon-tools"; + +const BACKGROUND_MEDIA_TYPES = new Set([ + "image/jpeg", + "image/png", + "image/heic", + "image/heif", +]); +const TYPING_COMMAND_TTL_MS = 90_000; + +const address = z.string().min(3).max(320); + +const inputSchema = z.discriminatedUnion("action", [ + z.object({ action: z.literal("get") }).strict(), + z + .object({ + action: z.literal("count"), + includeArchived: z.boolean().optional(), + }) + .strict(), + z.object({ action: z.literal("markRead") }).strict(), + z + .object({ + action: z.literal("setTyping"), + isTyping: z.boolean(), + }) + .strict(), + z.object({ action: z.literal("shareContactInfo") }).strict(), + z.object({ action: z.literal("hasBackground") }).strict(), + z + .object({ + action: z.literal("setBackground"), + preset: backgroundPresetSchema.optional(), + artifactId: z.string().min(1).max(256).optional(), + file: modelFileInputSchema.optional(), + }) + .strict() + .refine( + (value) => + [value.preset, value.artifactId, value.file].filter((entry) => entry !== undefined) + .length === 1, + { message: "exactly one of preset, artifactId, or file is required" }, + ), + z.object({ action: z.literal("removeBackground") }).strict(), + z + .object({ + action: z.literal("create"), + addresses: z.array(address).min(1).max(100), + message: z.string().max(64_000).optional(), + subject: z.string().max(1024).optional(), + effect: effectSchema.optional(), + }) + .strict(), +]); + +/** Compact projection of a provider Chat snapshot for the model. */ +function projectChat(chat: Chat | undefined): Record | undefined { + if (!chat) return undefined; + return { + guid: chat.guid, + displayName: chat.displayName, + isGroup: chat.isGroup === true, + service: chat.service, + isArchived: chat.isArchived === true, + isFiltered: chat.isFiltered === true, + unreadCount: chat.unreadCount, + participants: (chat.participants ?? []).map((participant) => ({ + address: participant.address, + service: participant.service, + country: participant.country, + })), + }; +} + +export default defineTool({ + description: + "Inspect and manage the current iMessage conversation. get returns chat identity, participants, service and unread state; count totals chats on the account (optionally including archived). markRead marks every unread message in this chat read — it is chat-wide, not per-message. setTyping shows or clears the typing indicator; typing commands expire quickly and are never replayed. shareContactInfo sends the account's own native contact card. hasBackground reports whether a custom background is set; setBackground applies one of the 24 named presets or a JPEG/PNG/HEIC image (staged artifactId or inline file bytes); removeBackground clears it — a successful set means submitted, not that every device rendered it. create opens a new DM (one address) or group (two or more) with an optional opening message and is restricted to the private conversation. Mutations return {status:'queued', commandId}; reads and create wait up to ~8s and return 'acknowledged' data or 'pending'.", + inputSchema, + async execute(input, ctx) { + if (input.action === "create") assertPrivateAudience(ctx); + const scope = await photonScope(ctx); + switch (input.action) { + case "get": { + const receipt = await runPhotonCommand(scope, "chats.get", {}, ctx.abortSignal); + return { + status: receipt.status, + commandId: receipt.commandId, + chat: receipt.status === "acknowledged" ? projectChat(receipt.result) : undefined, + errorCode: receipt.status === "failed" ? receipt.errorCode : undefined, + }; + } + case "count": { + const receipt = await runPhotonCommand( + scope, + "chats.count", + { includeArchived: input.includeArchived }, + ctx.abortSignal, + ); + return { + status: receipt.status, + commandId: receipt.commandId, + count: receipt.status === "acknowledged" ? receipt.result?.count : undefined, + }; + } + case "markRead": + return queuePhotonCommand(scope, "chats.markRead", {}); + case "setTyping": + return queuePhotonCommand( + scope, + "chats.setTyping", + { isTyping: input.isTyping }, + { expiresAt: new Date(Date.now() + TYPING_COMMAND_TTL_MS).toISOString() }, + ); + case "shareContactInfo": + return queuePhotonCommand(scope, "chats.shareContactInfo", {}); + case "hasBackground": { + const receipt = await runPhotonCommand(scope, "chats.hasBackground", {}, ctx.abortSignal); + return { + status: receipt.status, + commandId: receipt.commandId, + hasBackground: + receipt.status === "acknowledged" ? receipt.result?.hasBackground : undefined, + errorCode: receipt.status === "failed" ? receipt.errorCode : undefined, + }; + } + case "setBackground": { + if (input.preset !== undefined) + return queuePhotonCommand(scope, "chats.setBackground", { preset: input.preset }); + const artifactId = await resolveStagedImageArtifact(scope, input, { + allowedMediaTypes: BACKGROUND_MEDIA_TYPES, + fallbackFileName: "background", + typeError: "Chat backgrounds must be JPEG, PNG, HEIC, or HEIF images.", + missingError: "The referenced background artifact is unavailable in this conversation.", + }); + return queuePhotonCommand(scope, "chats.setBackground", { artifactId }); + } + case "removeBackground": + return queuePhotonCommand(scope, "chats.removeBackground", {}); + case "create": { + const receipt = await runPhotonCommand( + scope, + "chats.create", + { + addresses: input.addresses, + message: input.message, + subject: input.subject, + effect: input.effect, + }, + ctx.abortSignal, + ); + const result = receipt.status === "acknowledged" ? receipt.result : undefined; + return { + status: receipt.status, + commandId: receipt.commandId, + chat: projectChat(result?.chat), + initialMessageGuid: result?.initialMessageGuid, + errorCode: receipt.status === "failed" ? receipt.errorCode : undefined, + }; + } + } + }, + toModelOutput(output) { + return toolOutput.json(output); + }, +}); diff --git a/agent/tools/photon_contact.ts b/agent/tools/photon_contact.ts new file mode 100644 index 0000000..dd12e68 --- /dev/null +++ b/agent/tools/photon_contact.ts @@ -0,0 +1,84 @@ +import { defineTool, toolOutput } from "eve/tools"; +import { z } from "zod"; + +import { sanitizeFileName } from "../../server/photon/filename.js"; +import { + photonScope, + queuePhotonCommand, + resolveAttachmentGuid, + uploadModelFile, + uploadPendingResponse, +} from "../lib/photon-tools"; + +const VCARD_MAX_BYTES = 64 * 1024; + +const inputSchema = z.discriminatedUnion("action", [ + z.object({ action: z.literal("shareSelfCard") }).strict(), + z + .object({ + action: z.literal("sendVCard"), + vcard: z.string().min(16).max(VCARD_MAX_BYTES), + }) + .strict(), +]); + +/** + * Minimal structural validation for a vCard payload: BEGIN/END wrappers, a + * 3.0 or 4.0 VERSION, and a formatted-name (FN) property. The provider and + * recipient clients handle the rest of the grammar. + */ +function assertValidVCard(vcard: string): void { + const trimmed = vcard.trim(); + if (Buffer.byteLength(trimmed, "utf8") > VCARD_MAX_BYTES) + throw new Error("The vCard exceeds the 64 KiB limit."); + if (!/^BEGIN:VCARD[\r\n]/i.test(trimmed) || !/[\r\n]END:VCARD\s*$/i.test(trimmed)) + throw new Error("The vCard must be wrapped in BEGIN:VCARD and END:VCARD."); + if (!/[\r\n]VERSION:(3\.0|4\.0)[\r\n]/i.test(trimmed)) + throw new Error("The vCard must declare VERSION:3.0 or VERSION:4.0."); + if (!/[\r\n]FN[;:]/i.test(trimmed)) + throw new Error("The vCard must include an FN (formatted name) property."); +} + +/** Best-effort display filename derived from the card's FN property. */ +function vcardFileName(vcard: string): string { + const match = /[\r\n]FN[^:]*:([^\r\n]+)/i.exec(vcard); + const name = match?.[1]?.trim(); + return sanitizeFileName(name ? `${name}.vcf` : undefined, "contact.vcf"); +} + +export default defineTool({ + description: + "Share contact cards in the current iMessage conversation. shareSelfCard sends the account's own native card — its fields are chosen by the account configuration, not by this tool, so never promise specific content; only send it when the user asked for contact details. sendVCard uploads a caller-supplied vCard string (BEGIN:VCARD…END:VCARD with VERSION:3.0 or 4.0 and an FN property, up to 64 KiB) as a .vcf attachment through the durable upload+send chain — use it for arbitrary contacts the user provided. shareSelfCard returns {status:'queued', commandId}; sendVCard waits for the bounded upload first and returns 'queued', 'pending', or the upload's failure status — retry a 'pending' result rather than resending blindly.", + inputSchema, + async execute(input, ctx) { + const scope = await photonScope(ctx); + switch (input.action) { + case "shareSelfCard": + return queuePhotonCommand(scope, "chats.shareContactInfo", {}); + case "sendVCard": { + assertValidVCard(input.vcard); + const staged = await uploadModelFile(scope, { + data: Buffer.from(input.vcard.trim(), "utf8"), + mediaType: "text/vcard", + fileName: vcardFileName(input.vcard), + }); + const resolved = await resolveAttachmentGuid( + scope, + { artifactId: staged.artifactId }, + ctx.abortSignal, + ); + if (!resolved.resolved) + return uploadPendingResponse(resolved.receipt, "contact-card"); + return queuePhotonCommand( + scope, + "messages.sendAttachment", + { attachmentGuid: resolved.attachmentGuid }, + { dependsOn: resolved.dependsOn }, + ); + } + } + }, + toModelOutput(output) { + return toolOutput.json(output); + }, +}); diff --git a/agent/tools/photon_group.ts b/agent/tools/photon_group.ts new file mode 100644 index 0000000..813c465 --- /dev/null +++ b/agent/tools/photon_group.ts @@ -0,0 +1,130 @@ +import { defineTool, toolOutput, type ToolContext } from "eve/tools"; +import { z } from "zod"; + +import type { ConversationRow } from "../../server/photon/store.js"; +import { + artifactInConversation, + assertGroupAudience, + modelFileInputSchema, + photonScope, + queuePhotonCommand, + resolveStagedImageArtifact, + runPhotonCommand, +} from "../lib/photon-tools"; +import { assertInitiatingOwner } from "../lib/messaging-auth"; + +const GROUP_ICON_MEDIA_TYPES = new Set([ + "image/png", + "image/jpeg", + "image/gif", + "image/heic", + "image/heif", +]); + +const address = z.string().min(3).max(320); + +const inputSchema = z.discriminatedUnion("action", [ + z + .object({ + action: z.literal("setDisplayName"), + name: z + .string() + .min(1) + .max(1024) + .refine((value) => value.trim().length > 0, "name must contain non-whitespace content"), + }) + .strict(), + z + .object({ + action: z.literal("addParticipants"), + addresses: z.array(address).min(1).max(100), + }) + .strict(), + z + .object({ + action: z.literal("removeParticipants"), + addresses: z.array(address).min(1).max(100), + }) + .strict(), + z.object({ action: z.literal("leave") }).strict(), + z + .object({ + action: z.literal("setIcon"), + artifactId: z.string().min(1).max(256).optional(), + file: modelFileInputSchema.optional(), + }) + .strict() + .refine((value) => (value.artifactId === undefined) !== (value.file === undefined), { + message: "exactly one of artifactId or file is required", + }), + z.object({ action: z.literal("getIcon") }).strict(), + z.object({ action: z.literal("removeIcon") }).strict(), +]); + +/** + * Group mutations are restricted to the participant who initiated this + * bound task; any admitted member may read the icon. + */ +function assertMutationOwner(ctx: ToolContext, conversation: ConversationRow): void { + assertInitiatingOwner(ctx, conversation.initiatingPrincipal); +} + +export default defineTool({ + description: + "Manage the admitted group conversation this session is bound to. setDisplayName renames the group (nonempty), addParticipants and removeParticipants change membership (full phone/email addresses, subject to Apple/account permission), leave removes the account — another participant must re-invite it afterwards. setIcon applies a PNG/JPEG/GIF/HEIC image (staged artifactId or inline file bytes), getIcon returns the current icon as a staged artifact reference, removeIcon clears it. Mutations are restricted to the initiating owner of this conversation; getIcon is available to the admitted audience. Targets are always the bound group — cross-chat group GUIDs are rejected. Mutations return {status:'queued', commandId}; getIcon waits up to ~8s and returns 'acknowledged' with {icon:{artifactId,mediaType,byteSize}} or icon:null when the group has no custom icon, or 'pending'.", + inputSchema, + async execute(input, ctx) { + assertGroupAudience(ctx); + const scope = await photonScope(ctx); + if (input.action !== "getIcon") assertMutationOwner(ctx, scope.conversation); + switch (input.action) { + case "setDisplayName": + return queuePhotonCommand(scope, "groups.setDisplayName", { name: input.name.trim() }); + case "addParticipants": + return queuePhotonCommand(scope, "groups.addParticipants", { addresses: input.addresses }); + case "removeParticipants": + return queuePhotonCommand(scope, "groups.removeParticipants", { addresses: input.addresses }); + case "leave": + return queuePhotonCommand(scope, "groups.leave", {}); + case "setIcon": { + const artifactId = await resolveStagedImageArtifact(scope, input, { + allowedMediaTypes: GROUP_ICON_MEDIA_TYPES, + fallbackFileName: "group-icon", + typeError: "Group icons must be PNG, JPEG, GIF, HEIC, or HEIF images.", + missingError: "The referenced icon artifact is unavailable in this conversation.", + }); + return queuePhotonCommand(scope, "groups.setIcon", { artifactId }); + } + case "getIcon": { + const receipt = await runPhotonCommand(scope, "groups.getIcon", {}, ctx.abortSignal); + if (receipt.status === "failed" && receipt.errorCode && /not.?found/i.test(receipt.errorCode)) + return { status: "acknowledged", commandId: receipt.commandId, icon: null }; + if (receipt.status !== "acknowledged") + return { status: receipt.status, commandId: receipt.commandId }; + const result = receipt.result; + // The executor already staged the icon bytes into this + // conversation's artifact store — the result is the artifact + // reference itself, so there is nothing to re-stage. + if (!result || "icon" in result) + return { status: "acknowledged", commandId: receipt.commandId, icon: null }; + const artifact = await scope.store.getArtifact(result.artifactId); + if (!artifactInConversation(scope, artifact)) + throw new Error("The group icon artifact is unavailable in this conversation."); + return { + status: "acknowledged", + commandId: receipt.commandId, + icon: { + artifactId: result.artifactId, + mediaType: result.mimeType, + byteSize: result.byteSize, + }, + }; + } + case "removeIcon": + return queuePhotonCommand(scope, "groups.removeIcon", {}); + } + }, + toModelOutput(output) { + return toolOutput.json(output); + }, +}); diff --git a/agent/tools/photon_history.ts b/agent/tools/photon_history.ts new file mode 100644 index 0000000..6d9195e --- /dev/null +++ b/agent/tools/photon_history.ts @@ -0,0 +1,190 @@ +import { defineTool, toolOutput } from "eve/tools"; +import { z } from "zod"; + +import { + messageListFilterSchema, + type Message, + type MessageListPage, +} from "../../server/photon/contracts.js"; +import { + artifactInConversation, + assertPrivateAudience, + assertTargetInConversation, + isoTimestamp, + photonScope, + projectAttachmentInfo, + runPhotonCommand, +} from "../lib/photon-tools"; + +const TEXT_EXCERPT_LIMIT = 500; + +const inputSchema = z.discriminatedUnion("action", [ + z + .object({ + action: z.literal("get"), + messageGuid: z.string().min(1).max(512), + }) + .strict(), + z + .object({ + action: z.literal("listInChat"), + filter: messageListFilterSchema.optional(), + }) + .strict(), + z + .object({ + action: z.literal("listRecent"), + filter: messageListFilterSchema.optional(), + }) + .strict(), + z + .object({ + action: z.literal("getEmbeddedMedia"), + messageGuid: z.string().min(1).max(512), + }) + .strict(), +]); + +/** Bounded agent-facing projection of a provider Message snapshot. */ +function summarizeMessage(message: Message | undefined): Record | undefined { + if (!message) return undefined; + const content = message.content; + const text = content?.text; + return { + guid: message.guid, + sender: message.sender?.address ?? (message.isFromMe ? "self" : undefined), + isFromMe: message.isFromMe, + text: + text === undefined + ? undefined + : text.length > TEXT_EXCERPT_LIMIT + ? `${text.slice(0, TEXT_EXCERPT_LIMIT)}…` + : text, + subject: message.subject, + attachments: (content?.attachments ?? []) + .map(projectAttachmentInfo) + .filter((entry) => entry !== undefined), + mentions: (content?.mentions ?? []).map((mention) => ({ + address: mention.address, + start: mention.start, + length: mention.length, + })), + partCount: message.partCount, + replyTargetGuid: message.replyTargetGuid, + threadOriginatorGuid: message.threadOriginatorGuid, + isAudioMessage: message.isAudioMessage, + isSent: message.isSent, + isDelivered: message.isDelivered, + isArchived: message.isArchived, + itemType: message.itemType, + dateCreated: isoTimestamp(message.dateCreated), + dateDelivered: isoTimestamp(message.dateDelivered), + dateRead: isoTimestamp(message.dateRead), + appliedReactions: (message.appliedReactions ?? []).map((applied) => ({ + kind: applied.reaction?.kind, + emoji: applied.reaction && "emoji" in applied.reaction ? applied.reaction.emoji : undefined, + sender: applied.sender?.address, + })), + placedStickers: message.placedStickers?.length || undefined, + dataDetectorResultsPresent: message.dataDetectorResultsPresent, + }; +} + +function summarizePage(page: MessageListPage | undefined): Record { + const messages = Array.isArray(page) ? page : (page?.messages ?? []); + return { + messages: messages.map(summarizeMessage), + nextPageToken: Array.isArray(page) ? undefined : page?.nextPageToken, + }; +} + +export default defineTool({ + description: + "Read message history for the current iMessage conversation. get hydrates one message snapshot by GUID (attachments metadata, reply links, delivery/read state); listInChat pages this conversation's history with an optional filter {after,before,isFromMe,isRead,pageSize 1-100,pageToken} — pass the returned nextPageToken back as filter.pageToken to continue. listRecent pages account-wide history and is restricted to the private conversation. getEmbeddedMedia returns Digital Touch/handwritten media bytes for a message as a staged artifact reference rather than inline data. Results are compact summaries (guid, sender, bounded text excerpt, attachment refs, timestamps, reaction state) — never raw provider payloads. Each action waits up to ~8s and returns 'acknowledged' with data, 'failed' with errorCode, or 'pending' with commandId to retry later.", + inputSchema, + async execute(input, ctx) { + if (input.action === "listRecent") assertPrivateAudience(ctx); + const scope = await photonScope(ctx); + switch (input.action) { + case "get": { + await assertTargetInConversation(scope, "message", input.messageGuid, "The message"); + const receipt = await runPhotonCommand( + scope, + "messages.get", + { messageGuid: input.messageGuid }, + ctx.abortSignal, + ); + return { + status: receipt.status, + commandId: receipt.commandId, + message: + receipt.status === "acknowledged" ? summarizeMessage(receipt.result) : undefined, + errorCode: receipt.status === "failed" ? receipt.errorCode : undefined, + }; + } + case "listInChat": { + const receipt = await runPhotonCommand( + scope, + "messages.listInChat", + { filter: input.filter }, + ctx.abortSignal, + ); + return { + status: receipt.status, + commandId: receipt.commandId, + ...(receipt.status === "acknowledged" ? summarizePage(receipt.result) : {}), + errorCode: receipt.status === "failed" ? receipt.errorCode : undefined, + }; + } + case "listRecent": { + const receipt = await runPhotonCommand( + scope, + "messages.listRecent", + { filter: input.filter }, + ctx.abortSignal, + ); + return { + status: receipt.status, + commandId: receipt.commandId, + ...(receipt.status === "acknowledged" ? summarizePage(receipt.result) : {}), + errorCode: receipt.status === "failed" ? receipt.errorCode : undefined, + }; + } + case "getEmbeddedMedia": { + await assertTargetInConversation(scope, "message", input.messageGuid, "The message"); + const receipt = await runPhotonCommand( + scope, + "messages.getEmbeddedMedia", + { messageGuid: input.messageGuid }, + ctx.abortSignal, + ); + if (receipt.status !== "acknowledged") + return { + status: receipt.status, + commandId: receipt.commandId, + errorCode: receipt.status === "failed" ? receipt.errorCode : undefined, + }; + // The executor already staged the media bytes into this + // conversation's artifact store — the result is the artifact + // reference itself, so there is nothing to re-stage. + const result = receipt.result; + if (!result) return { status: "acknowledged", commandId: receipt.commandId, media: null }; + const artifact = await scope.store.getArtifact(result.artifactId); + if (!artifactInConversation(scope, artifact)) + throw new Error("The embedded media artifact is unavailable in this conversation."); + return { + status: "acknowledged", + commandId: receipt.commandId, + media: { + artifactId: result.artifactId, + mediaType: result.mediaType, + byteSize: result.byteSize, + }, + }; + } + } + }, + toModelOutput(output) { + return toolOutput.json(output); + }, +}); diff --git a/agent/tools/photon_location.ts b/agent/tools/photon_location.ts new file mode 100644 index 0000000..b0803d6 --- /dev/null +++ b/agent/tools/photon_location.ts @@ -0,0 +1,117 @@ +import { defineTool, toolOutput } from "eve/tools"; +import { z } from "zod"; + +import type { SharedFriendLocation } from "../../server/photon/contracts.js"; +import { + assertPrivateAudience, + isoTimestamp, + photonScope, + queuePhotonCommand, + runPhotonCommand, +} from "../lib/photon-tools"; + +const address = z.string().min(3).max(320); + +const inputSchema = z.discriminatedUnion("action", [ + z.object({ action: z.literal("request"), address }).strict(), + z.object({ action: z.literal("list") }).strict(), + z.object({ action: z.literal("get"), address }).strict(), + z + .object({ + action: z.literal("watch"), + watchAction: z.enum(["start", "stop"]), + address: address.optional(), + }) + .strict(), +]); + +/** Compact projection of a SharedFriendLocation snapshot. */ +function projectLocation( + location: SharedFriendLocation | undefined, +): Record | undefined { + if (!location) return undefined; + return { + address: location.address, + name: location.name, + latitude: location.latitude, + longitude: location.longitude, + accuracy: location.accuracy, + locationType: location.locationType, + isLocatingInProgress: location.isLocatingInProgress, + locationTimestamp: isoTimestamp(location.locationTimestamp), + expiresAt: isoTimestamp(location.expiresAt), + longAddress: location.longAddress, + shortAddress: location.shortAddress, + }; +} + +export default defineTool({ + description: + "Find My location operations — private conversation only. request sends the visible native sharing-request card for an existing participant's address; success is a submitted request, never acceptance of sharing. list returns the friend-location snapshots currently visible to the account (empty is valid); get reads the latest snapshot for one address (not sharing returns not-found). watch with watchAction start/stop registers or removes a bounded live-watch at the Bell subscription layer — the registry is capped, watches never replay missed updates, and stopping a watch does not affect the person's Apple location sharing. Snapshots carry freshness fields (locationTimestamp, expiresAt); treat a gap after reconnect as missing data. request and reads wait up to ~8s and return 'acknowledged' results or 'pending'; watch returns {status:'queued', commandId}.", + inputSchema, + async execute(input, ctx) { + assertPrivateAudience(ctx); + const scope = await photonScope(ctx); + switch (input.action) { + case "request": { + const receipt = await runPhotonCommand( + scope, + "locations.request", + { address: input.address }, + ctx.abortSignal, + ); + const result = receipt.status === "acknowledged" ? receipt.result : undefined; + return { + status: receipt.status, + commandId: receipt.commandId, + request: + result === undefined + ? undefined + : { + status: result.status, + reason: result.reason, + messageGuid: result.messageGuid, + address: result.address, + }, + note: "A successful request is not acceptance of location sharing.", + errorCode: receipt.status === "failed" ? receipt.errorCode : undefined, + }; + } + case "list": { + const receipt = await runPhotonCommand(scope, "locations.list", {}, ctx.abortSignal); + const locations = + receipt.status === "acknowledged" ? [...(receipt.result?.locations ?? [])] : []; + return { + status: receipt.status, + commandId: receipt.commandId, + locations: locations.map(projectLocation).filter((entry) => entry !== undefined), + errorCode: receipt.status === "failed" ? receipt.errorCode : undefined, + }; + } + case "get": { + const receipt = await runPhotonCommand( + scope, + "locations.get", + { address: input.address }, + ctx.abortSignal, + ); + const result = receipt.status === "acknowledged" ? receipt.result : undefined; + return { + status: receipt.status, + commandId: receipt.commandId, + found: result?.found, + location: result?.found === true ? projectLocation(result.location) : undefined, + errorCode: receipt.status === "failed" ? receipt.errorCode : undefined, + }; + } + case "watch": + return queuePhotonCommand(scope, "locations.watch", { + action: input.watchAction, + address: input.address, + }); + } + }, + toModelOutput(output) { + return toolOutput.json(output); + }, +}); diff --git a/agent/tools/photon_media.ts b/agent/tools/photon_media.ts new file mode 100644 index 0000000..aa337c3 --- /dev/null +++ b/agent/tools/photon_media.ts @@ -0,0 +1,162 @@ +import { defineTool, toolOutput } from "eve/tools"; +import { z } from "zod"; + +import { sanitizeFileName } from "../../server/photon/filename.js"; +import { + assertTargetInConversation, + awaitCommandResult, + decodeMediaInput, + enqueueArtifactUpload, + findStagedArtifact, + modelFileInputSchema, + photonScope, + projectAttachmentInfo, + runPhotonCommand, + uploadModelFile, + uploadedAttachmentGuid, +} from "../lib/photon-tools"; + +const companionInputSchema = z + .object({ + dataUrl: z.string().min(16).max(140_000_000).optional(), + base64: z.string().min(4).max(140_000_000).optional(), + mediaType: z.string().min(3).max(255).optional(), + }) + .strict() + .refine((value) => (value.dataUrl === undefined) !== (value.base64 === undefined), { + message: "exactly one of dataUrl or base64 is required", + }); + +const inputSchema = z.discriminatedUnion("action", [ + z + .object({ + action: z.literal("upload"), + file: modelFileInputSchema, + companion: companionInputSchema.optional(), + }) + .strict(), + z + .object({ + action: z.literal("download"), + attachmentGuid: z.string().min(1).max(512), + }) + .strict(), + z + .object({ + action: z.literal("status"), + commandId: z.string().min(8).max(128), + }) + .strict(), +]); + +export default defineTool({ + description: + "Stage and inspect iMessage media. upload stores base64/dataUrl bytes as a durable artifact (add a companion video for a Live Photo pair), queues the provider upload, waits up to ~8s, and returns the artifactId plus the provider attachmentGuid once acknowledged — pass either to photon_message sendAttachment or photon_reaction placeSticker. download takes an inbound attachmentGuid, reports its provider metadata (filename, MIME type, size, transferState: pending/transferring/failed/finished/unknown) and any staged artifact reference for the bytes. Byte content stays in the artifact store; the tool never returns file payloads inline. status re-checks a previously returned commandId (e.g. a pending upload) and returns its outcome including the attachmentGuid once acknowledged. If an upload or read is still running the result is {status:'pending', commandId} — retry or check later rather than resending.", + inputSchema, + async execute(input, ctx) { + const scope = await photonScope(ctx); + switch (input.action) { + case "upload": { + const decoded = decodeMediaInput(input.file, scope.config.worker.maxArtifactBytes); + const staged = await uploadModelFile(scope, { + data: decoded.data, + mediaType: decoded.mediaType, + fileName: sanitizeFileName(input.file.fileName), + }); + let companionArtifactId: string | undefined; + if (input.companion) { + const companion = decodeMediaInput( + { ...input.companion, mediaType: input.companion.mediaType ?? "video/quicktime" }, + scope.config.worker.maxArtifactBytes, + ); + companionArtifactId = ( + await uploadModelFile(scope, { + data: companion.data, + mediaType: companion.mediaType, + fileName: "live-photo-companion.mov", + }) + ).artifactId; + } + const upload = await enqueueArtifactUpload(scope, { + artifactId: staged.artifactId, + fileName: staged.fileName ?? "attachment", + companionArtifactId, + }); + const receipt = await awaitCommandResult( + scope.store, + upload.commandId, + "attachments.upload", + undefined, + ctx.abortSignal, + ); + const attachmentGuid = + receipt.status === "acknowledged" ? receipt.result?.attachmentGuid : undefined; + return { + status: receipt.status, + commandId: upload.commandId, + artifactId: staged.artifactId, + companionArtifactId, + attachmentGuid, + byteSize: staged.byteSize, + mediaType: staged.mediaType, + fileName: staged.fileName, + }; + } + case "download": { + await assertTargetInConversation(scope, "attachment", input.attachmentGuid, "The attachment"); + const staged = await findStagedArtifact(scope, input.attachmentGuid); + const receipt = await runPhotonCommand( + scope, + "attachments.get", + { attachmentGuid: input.attachmentGuid }, + ctx.abortSignal, + ); + return { + status: receipt.status, + commandId: receipt.commandId, + attachment: + receipt.status === "acknowledged" + ? (projectAttachmentInfo(receipt.result) ?? null) + : undefined, + errorCode: receipt.status === "failed" ? receipt.errorCode : undefined, + artifact: staged + ? { + artifactId: staged.artifactId, + byteSize: staged.byteSize, + mediaType: staged.mediaType, + fileName: staged.fileName, + } + : undefined, + stagedBytes: staged !== undefined, + }; + } + case "status": { + const row = await scope.store.getCommand(input.commandId); + if ( + !row || + row.sourceId !== scope.conversation.sourceId || + row.chatGuid !== scope.conversation.chatGuid + ) + throw new Error("That command is not available in this conversation."); + const receipt = await awaitCommandResult( + scope.store, + input.commandId, + row.operation, + 1_000, + ctx.abortSignal, + ); + const attachmentGuid = + receipt.status === "acknowledged" ? uploadedAttachmentGuid(receipt.result) : undefined; + return { + status: receipt.status, + commandId: input.commandId, + attachmentGuid, + errorCode: receipt.status === "failed" ? receipt.errorCode : undefined, + }; + } + } + }, + toModelOutput(output) { + return toolOutput.json(output); + }, +}); diff --git a/agent/tools/photon_message.ts b/agent/tools/photon_message.ts new file mode 100644 index 0000000..3eaee5a --- /dev/null +++ b/agent/tools/photon_message.ts @@ -0,0 +1,183 @@ +import { defineTool, toolOutput } from "eve/tools"; +import { z } from "zod"; + +import { + customizedMiniAppSchema, + effectSchema, + messagePartSchema, + replyTargetSchema, + textEffectSchema, + textFormatSchema, +} from "../../server/photon/contracts.js"; +import { + assertTargetInConversation, + modelFileInputSchema, + photonScope, + queuePhotonCommand, + resolveAttachmentGuid, + sendMiniAppCard, + uploadPendingResponse, +} from "../lib/photon-tools"; + +const guid = z.string().min(1).max(512); +const partIndex = z.number().int().min(0).max(255); + +const inputSchema = z.discriminatedUnion("action", [ + z + .object({ + action: z.literal("send"), + text: z + .string() + .min(1) + .max(64_000) + .refine((value) => value.trim().length > 0, "text must contain non-whitespace content"), + subject: z.string().max(1024).optional(), + effect: effectSchema.optional(), + enableDataDetection: z.boolean().optional(), + enableLinkPreview: z.boolean().optional(), + formatting: z.array(z.union([textFormatSchema, textEffectSchema])).max(64).optional(), + replyTo: replyTargetSchema.optional(), + }) + .strict(), + z + .object({ + action: z.literal("sendMultipart"), + parts: z.array(messagePartSchema).min(1).max(64), + subject: z.string().max(1024).optional(), + effect: effectSchema.optional(), + replyTo: replyTargetSchema.optional(), + enableDataDetection: z.boolean().optional(), + }) + .strict() + .refine( + (value) => + value.parts.every((part) => { + const hasText = part.text !== undefined || part.mentionedAddress !== undefined; + const hasAttachment = part.attachmentGuid !== undefined; + return hasText !== hasAttachment; + }), + { message: "each part must be exactly one of a text/mention part or an attachment part" }, + ), + z + .object({ + action: z.literal("sendAttachment"), + attachmentGuid: guid.optional(), + artifactId: z.string().min(1).max(256).optional(), + file: modelFileInputSchema.optional(), + isAudioMessage: z.boolean().optional(), + effect: effectSchema.optional(), + replyTo: replyTargetSchema.optional(), + }) + .strict() + .refine( + (value) => + [value.attachmentGuid, value.artifactId, value.file].filter((entry) => entry !== undefined) + .length === 1, + { message: "exactly one of attachmentGuid, artifactId, or file is required" }, + ), + z + .object({ + action: z.literal("edit"), + messageGuid: guid, + newText: z.string().min(1).max(64_000), + backwardCompatText: z.string().max(64_000).optional(), + partIndex: partIndex.optional(), + }) + .strict(), + z + .object({ + action: z.literal("unsend"), + messageGuid: guid, + partIndex: partIndex.optional(), + }) + .strict(), + z + .object({ + action: z.literal("sendMiniApp"), + card: customizedMiniAppSchema, + }) + .strict(), + z + .object({ + action: z.literal("notifySilenced"), + messageGuid: guid, + }) + .strict(), +]); + +export default defineTool({ + description: + "Send, edit, or retract iMessage content in the current conversation. Actions: send (plain text with optional subject, bubble/screen effect, UTF-16 formatting ranges, link preview, data detection, replyTo threading), sendMultipart (ordered text/mention/attachment bubbles), sendAttachment (a previously uploaded attachmentGuid, a staged artifactId from photon_media upload, or inline base64/dataUrl file bytes; set isAudioMessage for a native voice note), sendMiniApp (a customized iMessage app card — photon_app also sends and updates cards), edit and unsend (own messages within Apple's 15-minute/2-minute windows), notifySilenced (Apple's Notify Anyway — only when the user asked for urgent delivery). Every send is durable: the tool returns {status:'queued', commandId} once the outbox accepts it, 'acknowledged' with the provider result for card sends, or 'pending' while a required upload is still in flight — never claim delivery before the provider acknowledges it.", + inputSchema, + async execute(input, ctx) { + const scope = await photonScope(ctx); + switch (input.action) { + case "send": + return queuePhotonCommand(scope, "messages.sendText", { + text: input.text, + subject: input.subject, + effect: input.effect, + enableDataDetection: input.enableDataDetection, + enableLinkPreview: input.enableLinkPreview, + formatting: input.formatting, + replyTo: input.replyTo, + }); + case "sendMultipart": + return queuePhotonCommand(scope, "messages.sendMultipart", { + parts: input.parts, + subject: input.subject, + effect: input.effect, + replyTo: input.replyTo, + enableDataDetection: input.enableDataDetection, + }); + case "sendAttachment": { + const resolved = await resolveAttachmentGuid(scope, input, ctx.abortSignal); + if (!resolved.resolved) + return uploadPendingResponse(resolved.receipt, "media"); + return queuePhotonCommand( + scope, + "messages.sendAttachment", + { + attachmentGuid: resolved.attachmentGuid, + isAudioMessage: input.isAudioMessage, + effect: input.effect, + replyTo: input.replyTo, + }, + { dependsOn: resolved.dependsOn }, + ); + } + case "edit": + await assertTargetInConversation(scope, "message", input.messageGuid, "The target message"); + return queuePhotonCommand(scope, "messages.edit", { + messageGuid: input.messageGuid, + newText: input.newText, + backwardCompatText: input.backwardCompatText, + partIndex: input.partIndex, + }); + case "unsend": + await assertTargetInConversation(scope, "message", input.messageGuid, "The target message"); + return queuePhotonCommand(scope, "messages.unsend", { + messageGuid: input.messageGuid, + partIndex: input.partIndex, + }); + case "sendMiniApp": { + const receipt = await sendMiniAppCard(scope, input.card, ctx.abortSignal); + const result = receipt.status === "acknowledged" ? receipt.result : undefined; + return { + status: receipt.status, + commandId: receipt.commandId, + session: result?.miniAppCardSession, + errorCode: receipt.status === "failed" ? receipt.errorCode : undefined, + }; + } + case "notifySilenced": + await assertTargetInConversation(scope, "message", input.messageGuid, "The target message"); + return queuePhotonCommand(scope, "messages.notifySilenced", { + messageGuid: input.messageGuid, + }); + } + }, + toModelOutput(output) { + return toolOutput.json(output); + }, +}); diff --git a/agent/tools/photon_poll.ts b/agent/tools/photon_poll.ts new file mode 100644 index 0000000..927be14 --- /dev/null +++ b/agent/tools/photon_poll.ts @@ -0,0 +1,131 @@ +import { defineTool, toolOutput } from "eve/tools"; +import { z } from "zod"; + +import type { Poll } from "../../server/photon/contracts.js"; +import { + assertTargetInConversation, + photonScope, + queuePhotonCommand, + runPhotonCommand, +} from "../lib/photon-tools"; + +const pollGuid = z.string().min(1).max(512); + +const inputSchema = z.discriminatedUnion("action", [ + z + .object({ + action: z.literal("create"), + title: z + .string() + .min(1) + .max(2048) + .refine((value) => value.trim().length > 0, "title must contain non-whitespace content"), + choices: z + .array( + z + .string() + .min(1) + .max(1024) + .refine((value) => value.trim().length > 0, "choice must contain non-whitespace content"), + ) + .min(2) + .max(100), + }) + .strict(), + z.object({ action: z.literal("get"), pollMessageGuid: pollGuid }).strict(), + z + .object({ + action: z.literal("vote"), + pollMessageGuid: pollGuid, + optionIdentifier: z.string().min(1).max(512), + }) + .strict(), + z.object({ action: z.literal("unvote"), pollMessageGuid: pollGuid }).strict(), + z + .object({ + action: z.literal("addOption"), + pollMessageGuid: pollGuid, + text: z + .string() + .min(1) + .max(1024) + .refine((value) => value.trim().length > 0, "option text must contain non-whitespace content"), + }) + .strict(), +]); + +/** Compact projection of a provider Poll snapshot for the model. */ +function projectPoll(poll: Poll | undefined): Record | undefined { + if (!poll) return undefined; + return { + pollMessageGuid: poll.pollMessageGuid, + title: poll.title, + chatGuid: poll.chatGuid, + options: (poll.options ?? []).map((option) => ({ + optionIdentifier: option.optionIdentifier, + text: option.text, + creatorHandle: option.creatorHandle, + })), + votes: (poll.votes ?? []).map((vote) => ({ + optionIdentifier: vote.optionIdentifier, + participant: vote.participant?.address, + })), + }; +} + +export default defineTool({ + description: + "Create and act on native iMessage polls in the current conversation. create sends a poll with a nonempty title and two or more nonempty choices; it waits up to ~8s and returns the poll state including every stable optionIdentifier — record those ids, never a label or list position. get reads current options and all reported participant votes. vote casts or changes the account's vote on one optionIdentifier at a time (repeat the call for additional votes; identical labels on different options are distinct identifiers). unvote removes the account's own vote. addOption appends a new nonempty option and the provider returns its fresh identifier. Mutations return {status:'queued', commandId}; reads and create return 'acknowledged' with poll state or 'pending' while still in flight.", + inputSchema, + async execute(input, ctx) { + const scope = await photonScope(ctx); + if (input.action !== "create") + await assertTargetInConversation(scope, "poll", input.pollMessageGuid, "The poll"); + switch (input.action) { + case "create": { + const receipt = await runPhotonCommand( + scope, + "polls.create", + { title: input.title, choices: input.choices }, + ctx.abortSignal, + ); + return { + status: receipt.status, + commandId: receipt.commandId, + poll: receipt.status === "acknowledged" ? projectPoll(receipt.result) : undefined, + }; + } + case "get": { + const receipt = await runPhotonCommand( + scope, + "polls.get", + { pollMessageGuid: input.pollMessageGuid }, + ctx.abortSignal, + ); + return { + status: receipt.status, + commandId: receipt.commandId, + poll: receipt.status === "acknowledged" ? projectPoll(receipt.result) : undefined, + errorCode: receipt.status === "failed" ? receipt.errorCode : undefined, + }; + } + case "vote": + return queuePhotonCommand(scope, "polls.vote", { + pollMessageGuid: input.pollMessageGuid, + optionIdentifier: input.optionIdentifier, + }); + case "unvote": + return queuePhotonCommand(scope, "polls.unvote", { + pollMessageGuid: input.pollMessageGuid, + }); + case "addOption": + return queuePhotonCommand(scope, "polls.addOption", { + pollMessageGuid: input.pollMessageGuid, + text: input.text, + }); + } + }, + toModelOutput(output) { + return toolOutput.json(output); + }, +}); diff --git a/agent/tools/photon_reaction.ts b/agent/tools/photon_reaction.ts new file mode 100644 index 0000000..c15aba7 --- /dev/null +++ b/agent/tools/photon_reaction.ts @@ -0,0 +1,101 @@ +import { defineTool, toolOutput } from "eve/tools"; +import { z } from "zod"; + +import { + settableReactionSchema, + stickerPlacementSchema, +} from "../../server/photon/contracts.js"; +import { + assertTargetInConversation, + modelFileInputSchema, + photonScope, + queuePhotonCommand, + resolveAttachmentGuid, + uploadPendingResponse, +} from "../lib/photon-tools"; + +const guid = z.string().min(1).max(512); + +const inputSchema = z.discriminatedUnion("action", [ + z + .object({ + action: z.literal("set"), + messageGuid: guid, + reaction: settableReactionSchema, + partIndex: z.number().int().min(0).max(255).optional(), + }) + .strict(), + z + .object({ + action: z.literal("remove"), + messageGuid: guid, + reaction: settableReactionSchema, + partIndex: z.number().int().min(0).max(255).optional(), + }) + .strict(), + z + .object({ + action: z.literal("placeSticker"), + messageGuid: guid, + stickerAttachmentGuid: guid.optional(), + artifactId: z.string().min(1).max(256).optional(), + file: modelFileInputSchema.optional(), + placement: stickerPlacementSchema, + partIndex: z.number().int().min(0).max(255).optional(), + }) + .strict() + .refine( + (value) => + [value.stickerAttachmentGuid, value.artifactId, value.file].filter( + (entry) => entry !== undefined, + ).length === 1, + { message: "exactly one of stickerAttachmentGuid, artifactId, or file is required" }, + ), +]); + +export default defineTool({ + description: + "Add or remove a tapback, or place a sticker, on a message in the current conversation. set/remove take a native reaction (love, like, dislike, laugh, emphasize, question) or {customEmoji:'…'}; partIndex targets one bubble of a multipart message. placeSticker puts an uploaded sticker image onto a specific bubble: supply stickerAttachmentGuid (a provider GUID from photon_media upload), a staged artifactId, or inline file bytes, plus placement {x,y} in Apple's normalized bubble coordinates (0.5,0.5 is centered — not screen pixels) with optional scale, rotation, and width. There is no sticker-removal API. Returns {status:'queued', commandId} once the durable command is accepted, or 'pending' while a sticker upload is still in flight.", + inputSchema, + async execute(input, ctx) { + const scope = await photonScope(ctx); + await assertTargetInConversation(scope, "message", input.messageGuid, "The target message"); + switch (input.action) { + case "set": + case "remove": + return queuePhotonCommand(scope, "messages.setReaction", { + messageGuid: input.messageGuid, + reaction: input.reaction, + isSet: input.action === "set", + partIndex: input.partIndex, + }); + case "placeSticker": { + const resolved = await resolveAttachmentGuid( + scope, + { + attachmentGuid: input.stickerAttachmentGuid, + artifactId: input.artifactId, + file: input.file, + }, + ctx.abortSignal, + ); + if (!resolved.resolved) + return uploadPendingResponse(resolved.receipt, "sticker"); + return queuePhotonCommand( + scope, + "messages.placeSticker", + { + messageGuid: input.messageGuid, + stickerAttachmentGuid: resolved.attachmentGuid, + placement: input.placement, + partIndex: input.partIndex, + }, + { dependsOn: resolved.dependsOn }, + ); + } + } + }, + toModelOutput(output) { + return toolOutput.json(output); + }, +}); diff --git a/agent/tools/vault_list_logins.ts b/agent/tools/vault_list_logins.ts index 0e711b4..c5d9bb9 100644 --- a/agent/tools/vault_list_logins.ts +++ b/agent/tools/vault_list_logins.ts @@ -3,13 +3,16 @@ import { z } from "zod"; import { onePasswordVault } from "../../server/browser/vault/onepassword.js"; import { nativeVault } from "../../server/browser/vault/native.js"; +import { messagingPrincipal } from "../lib/messaging-auth"; export default defineTool({ description: "List safe login metadata and opaque item IDs from configured vaults. No secret values are returned. Use the returned provider and IDs, and choose only an item whose allowedHosts contains the current page host.", inputSchema: z.object({ provider: z.enum(["native", "onepassword"]).optional() }), async execute({ provider }, ctx) { - if (ctx.session.auth?.current?.authenticator === "linq-message") throw new Error("Shared operator vaults are not available over iMessage. Use onepassword_login for personal credentials."); + // Shared operator vaults are denied to every messaging principal — direct + // and group audiences alike. + if (messagingPrincipal(ctx)) throw new Error("Shared operator vaults are not available over iMessage. Use onepassword_login for personal credentials."); const items = []; if (provider === "native" || (!provider && process.env.BELL_VAULT_MASTER_KEY?.trim())) items.push(...await nativeVault.list()); diff --git a/agent/tools/vault_login.ts b/agent/tools/vault_login.ts index 3a95ca0..ce7b7a2 100644 --- a/agent/tools/vault_login.ts +++ b/agent/tools/vault_login.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { onePasswordVault } from "../../server/browser/vault/onepassword.js"; import { nativeVault } from "../../server/browser/vault/native.js"; import { runBrowserOperation, runCancelableBrowserWork } from "../lib/browser-runtime"; +import { messagingPrincipal } from "../lib/messaging-auth"; import { taskState } from "../lib/task-state"; const selector = z.string().min(1).max(500); @@ -23,14 +24,16 @@ export default defineTool({ totpFlow: z.enum(["same_form", "after_submit"]).default("same_form"), }), async execute(input, ctx) { - if (ctx.session.auth?.current?.authenticator === "linq-message") throw new Error("Shared operator vaults are not available over iMessage. Use onepassword_login for personal credentials."); + // Shared operator vaults are denied to every messaging principal — direct + // and group audiences alike. + if (messagingPrincipal(ctx)) throw new Error("Shared operator vaults are not available over iMessage. Use onepassword_login for personal credentials."); const readLogin = () => { if (input.provider === "native") return nativeVault.login(input.itemId); if (!input.vaultId) throw new Error("A vault ID is required for a 1Password login."); return onePasswordVault.login(input.vaultId, input.itemId); }; - const output = await runCancelableBrowserWork(ctx.session.id, ctx.abortSignal, () => - runBrowserOperation(ctx.session.id, async (browser) => { + const output = await runCancelableBrowserWork(ctx, ctx.abortSignal, () => + runBrowserOperation(ctx, async (browser) => { const login = await readLogin(); ctx.abortSignal.throwIfAborted(); return browser.secureLogin({ diff --git a/docs/baselayer.md b/docs/baselayer.md index 1ade8e0..b032538 100644 --- a/docs/baselayer.md +++ b/docs/baselayer.md @@ -2,7 +2,7 @@ Bell's `merchant_access` tool mints an L2 credential and presents it to a compatible merchant inside the conversation's existing Browserbase browser. The same tool is available from the -web interface and a Linq chat. Each conversation keeps its own browser. +web interface and a Photon chat. Each conversation keeps its own browser. ```text Bell -> merchant KYA profile -> Baselayer mint -> merchant nonce @@ -22,7 +22,7 @@ STAGEHAND_BROWSER=browserbase ``` Keep the OpenAI and Browserbase credentials already used by Bell. For the phone demo, also keep -the Linq configuration from [the messaging guide](linq.md). Restart Bell and start a new +the Photon configuration from [the messaging guide](photon.md). Restart Bell and start a new conversation after changing browser logging settings. The browser runtime refuses credential presentation if the browser session started with logging enabled. @@ -42,7 +42,7 @@ reference. Do not substitute an unrelated identity just to make a mint pass. ## Test the flow -With `pnpm dev` running, keep the same web conversation or text the same Linq chat: +With `pnpm dev` running, keep the same web conversation or text the same Photon chat: 1. "Open https://bell-stack-demo-site.vercel.app/protected with ordinary navigation and report the result." Expect `401 CREDENTIAL_MISSING`. @@ -80,7 +80,7 @@ the agent integration; the merchant verifier is deployed separately. Best Buy remains an ordinary browsing example. Do not add it to the credential flow until its Baselayer merchant support and required scope are confirmed. Changing a webhook target to Best -Buy does not configure merchant verification; Linq webhooks still point to Bell. +Buy does not configure merchant verification; the Photon channel still points to Bell. ## Troubleshooting and verification limits diff --git a/docs/browser-runtime.md b/docs/browser-runtime.md index f0c57bf..23c0b1a 100644 --- a/docs/browser-runtime.md +++ b/docs/browser-runtime.md @@ -86,7 +86,7 @@ Personal iMessage [1Password Agentic Autofill](onepassword.md) is disabled by de hosted extension support and live verification. Keep `ONEPASSWORD_ENABLED=false` and `BROWSERBASE_1PASSWORD_EXTENSION_ID` unset. The staged implementation uses per-sender encrypted OAuth connections and explicit approval in the 1Password app. Operator SDK/CLI vault tools -are not available over Linq. Personal mode disables reusable Contexts; each conversation +are not available over Photon messaging. Personal mode disables reusable Contexts; each conversation keeps a private browser, and failed fills require a new conversation. Existing encrypted local vaults remain readable through the `native` provider. No vault writer diff --git a/docs/link.md b/docs/link.md index 8a1c401..6b7b71b 100644 --- a/docs/link.md +++ b/docs/link.md @@ -1,14 +1,14 @@ -# Link by Stripe: sandbox and wallet OAuth over Linq +# Link by Stripe: sandbox and wallet OAuth over Photon -Link by Stripe provides wallet authorization, spend approval, and checkout. Linq carries the +Link by Stripe provides wallet authorization, spend approval, and checkout. Photon carries the messages; Eve by Vercel runs the agent and authorization flow. These are separate integrations. ## Sandbox: no database or saved wallet credentials Run a single long-lived Eve process with `LINK_SANDBOX=1`. Inject `LINK_SANDBOX_ACCESS_TOKEN` into its environment and set `LINK_SANDBOX_PRINCIPAL_ID` to the -verified operator's Linq principal (`linq:` from the signed Linq event, -not a phone number). Keep the Linq receiving-number and sender allowlist checks enabled. +verified operator's Photon principal (`photon:` from the verified event identity, +not a phone number). Keep the Photon receiving-line and sender allowlist checks enabled. The token is obtained through the operator's Link application and is never requested in chat. Bell does not write it to a file, database, workflow state, or model output. Do not save it in `.env` or commit it. No `DATABASE_URL`, migrations, OAuth client, or encryption key is needed @@ -37,7 +37,7 @@ retain their own records; Bell's no-wallet-storage rule does not control those a ## Real Link: operator-owned OAuth and storage -Bell connects a wallet to the verified sender of a private Linq message. The user texts Bell, +Bell connects a wallet to the verified sender of a private Photon message. The user texts Bell, opens Link's consent page, and returns to the same conversation. Eve suspends and resumes the tool call. There is no Bell login, static wallet token, or wallet access from the web demo. @@ -47,7 +47,7 @@ The Eve catalog's static-token quick start is not Bell's configuration. ## Configure real Link -1. Complete [Linq setup](linq.md), including signed webhooks and the trusted-number allowlist. +1. Complete [Photon setup](photon.md), including the managed line and sender allowlist. Retain the private-operator policy: saved browser Contexts and vaults are still shared. 2. [Register a Link OAuth client](https://docs.stripe.com/agentic-commerce/link-cli/oauth). Register exactly `https:///api/link/oauth/callback`. Register every deployment @@ -130,7 +130,7 @@ Disconnect does not cancel orders or merchant subscriptions or undo an already s ## Real Link persistence and recovery -Grants use `(issuer, principal ID)` from verified Linq authentication. They survive conversation +Grants use `(issuer, principal ID)` from verified Photon authentication. They survive conversation changes and restarts and cannot be selected through model-supplied IDs. Different senders have separate grants, even for the same operator. A web conversation or second phone is not paired automatically with an existing wallet. @@ -204,6 +204,6 @@ against intercepted synthetic merchant pages. Those tests submit fixture forms, For an operator-run live smoke test, text “List my Link payment methods,” complete consent, and verify the response arrives in the same chat. Send a follow-up to confirm grant reuse, then -disconnect and confirm the next request needs consent. This uses real Linq and Link services. +disconnect and confirm the next request needs consent. This uses real Photon and Link services. Live checkout needs a separately specified merchant, item, and budget; automated tests and the wallet smoke test do not submit purchases. diff --git a/docs/linq.md b/docs/linq.md deleted file mode 100644 index bf05f3c..0000000 --- a/docs/linq.md +++ /dev/null @@ -1,113 +0,0 @@ -# Text Bell through Linq - -Configure Bell with a receiving phone number from your own Linq account. -Linq carries iMessage and SMS. [Link by Stripe wallet OAuth and checkout](link.md) use this channel's -verified sender identity and require separate configuration. - -```text -Your phone -> Linq -> Bell /eve/v1/linq -> Eve conversation - | - Bell browser tools + Browserbase browser - | -Your phone <- Linq <- Bell's reply in the original chat -``` - -One Linq chat keeps one Eve conversation and browser across follow-up messages. It does not -automatically join an existing web chat. Browser, vault, and handoff tools run in the same agent -process. Baselayer credentials are not required for ordinary browsing. - -## Configure the line - -Use Bell's deployed HTTPS origin directly. The intended flow is -`Linq -> Bell -> Browserbase`, with handoff links served by that same Bell origin. -No tunnel process or Cloudflare-specific application setup is required. - -Hosted browser launches request `browserSettings: { verified: true }` and managed -proxies on every attempt. Verified Browsers provide browser identity for websites -Bell visits. The receiving webhook and handoff pages are served by Bell, so they -still need a reachable HTTPS application host. See -[Browserbase Verified Browsers](https://www.browserbase.com/verified). - -1. In Bell's ignored `.env.local`, set these server-only values: - - ```dotenv - LINQ_API_KEY= - LINQ_WEBHOOK_SECRET= - BELL_LINQ_PHONE_NUMBER= - BELL_LINQ_ALLOWED_SENDERS= - ``` - - Keep the existing OpenAI and Browserbase settings. The receiving line and your personal - sending number are different settings. An empty or invalid sender list accepts nobody. - For this single-operator demo, allow only the operator's numbers because saved vault and - browser Context data are shared. Group chats and iMessage email handles are not supported - by this phone-only policy. Bell uses `BELL_LINQ_ALLOWED_SENDERS`; the former - `BELL_LINQ_ALLOWED_USER_IDS` setting has no effect. - -2. Deploy Bell behind your chosen HTTPS application origin. Protect the web UI, - session-control routes, and any administration endpoints with application authentication. - Route Linq requests directly to `/eve/v1/linq`, where Eve verifies the signing secret; - an interactive login screen must not intercept the webhook. Handoff routes validate - their encrypted, expiring token. A local `pnpm dev` instance remains on port **4320**; - Linq and a phone cannot reach its loopback address. - -3. In the [Linq dashboard](https://dashboard.linqapp.com/api-tooling), create a webhook subscription: - - | Setting | Value | - | --- | --- | - | Target URL | `https:///eve/v1/linq?version=2026-02-03` | - | Event | `message.received` | - | Phone-number filter | Your `BELL_LINQ_PHONE_NUMBER` receiving line | - - Save the subscription's signing secret as `LINQ_WEBHOOK_SECRET`, then restart Bell. The target - is the Bell application. Best Buy is the website Bell visits, so do not enter it as the webhook - URL. The payload version supplies the receiving line and direct/group-chat fields Bell checks. - Bell verifies these fields again even when the subscription has a phone-number filter. - Replies use the inbound Linq chat and therefore the same receiving line. - - See Linq's [subscription API](https://docs.linqapp.com/channel/imessage/api/resources/webhook_subscriptions/methods/create/) - and [signed webhook guide](https://docs.linqapp.com/channel/imessage/guides/webhooks/). - -4. For optional human handoff, set `BELL_PUBLIC_URL` to Bell's reachable origin and - `BELL_HANDOFF_SECRET` to a random secret of at least 32 characters. A localhost handoff - link works only on the computer running Bell; a phone needs the deployed HTTPS origin. - Keep handoff links private: they grant browser control. Token expiry prevents reopening - the link but does not revoke an already-open Live View; end the browser session when done. - -5. Use persistent storage for Bell's Context metadata and any existing local vault file on a single-instance - host, or configure durable shared storage before a multi-instance/serverless deployment. - Personal [1Password Agentic Autofill](onepassword.md) is disabled by default pending hosted - extension support and live verification. Its implementation uses per-sender OAuth and private - browsers without reusable Contexts. Operator service-account and desktop vaults are not - exposed to Linq senders. Keep provider credentials in the server secret store. - -## Demo from your phone - -Text your configured Linq line from an allowed personal number: - -1. "Open https://www.bestbuy.com and tell me the homepage title." -2. "Take a screenshot of that page." -3. "Tell me the current page URL without navigating again." - -Bell should visit the site in Browserbase and reply in the same Linq chat. Follow-ups reuse -that chat's browser. Report what the browser actually sees; merchant pages can vary by region -or present a challenge. This demo does not log in, add items to a cart, or purchase anything. - -Personal saved logins are unavailable while [1Password Agentic Autofill](onepassword.md) is disabled. -Keep `ONEPASSWORD_ENABLED=false` and `BROWSERBASE_1PASSWORD_EXTENSION_ID` unset until the guide's -hosted extension and live verification requirements are met. The staged personal flow supports -single-step sign-in only; OTP and passkeys are not implemented. Do not send passwords or OTPs -as chat messages. - -## Verification boundary - -`pnpm test` sends signed HTTP requests through the installed Eve/Linq adapter and checks dispatch -to the agent. Provider calls and Eve dispatch are mocked, so the suite sends no texts and starts -no model or browser. It covers valid direct messages, same-chat routing, missing/tampered/stale -signatures, wrong lines or senders, groups, bot echoes, outbound messages, and invalid settings. - -A live phone round trip also needs the actual Linq credentials, reachable webhook, allowed sender, -and Browserbase/OpenAI credentials. Verified browser mode alone does not establish inbound delivery. -For Baselayer-protected merchant access, follow [the separate sandbox flow](baselayer.md). -It needs a compatible merchant and a current verified principal. For wallet access and checkout, -follow [the Link guide](link.md). diff --git a/docs/onepassword.md b/docs/onepassword.md index d2df52e..9361940 100644 --- a/docs/onepassword.md +++ b/docs/onepassword.md @@ -1,6 +1,6 @@ # Personal 1Password through Bell -This integration connects an allowlisted private Linq sender to their personal 1Password account. It uses Agentic Autofill in the 1Password browser extension. The operator SDK/CLI vault tools remain separate and are unavailable to Linq senders. +This integration connects an allowlisted private Photon sender to their personal 1Password account. It uses Agentic Autofill in the 1Password browser extension. The operator SDK/CLI vault tools remain separate and are unavailable to Photon senders. ## Current status: disabled pending hosted extension support @@ -32,7 +32,7 @@ Before changing the flag to `true`, the platform and SDK must support loading bo 3. Register the OAuth client through a 1Password Business account. The connecting test user needs a separate US Individual or Families account and the supported desktop app/extension. Follow the current [Browserbase setup guide](https://docs.browserbase.com/integrations/1password/agentic-autofill) for partner access and extension packaging. A regular service-account token is not a replacement for this client or extension. 4. Keep the public callback and `/eve/v1/connections/.../callback/...` paths reachable through Vercel protection without adding query parameters to the registered redirect. The callbacks use server-issued, short-lived capabilities; do not make unrelated application routes public. -5. Keep Linq signature verification, receiving-line configuration, and the sender allowlist enabled. Groups are deliberately rejected. The web operator login does not establish an individual iMessage identity and cannot select someone else's personal account. +5. Keep Photon line verification and the sender allowlist enabled. Groups are rejected unless explicitly allowlisted. The web operator login does not establish an individual iMessage identity and cannot select someone else's personal account. 6. Start new conversations for the rollout. Remove `BROWSERBASE_CONTEXT_ID` when personal-browser mode is enabled. Bell disables reusable Context creation, selection, saving, and cookie export in this mode. Existing contexts are not deleted, and their cookies are not migrated into personal sessions. Disable the old `ONEPASSWORD_OAUTH_STATE_SECRET`; the new record encryption key replaces its use. Setting the extension upload ID changes browser launch configuration. Stagehand 4 also needs its own extension. Bell requires Stagehand initialization and the 1Password runtime API to succeed in the same session; failures stop the operation and do not fall back to reading passwords through the SDK. The local synthetic test verifies coexistence in Chrome. Verify the actual hosted extension combination before enabling this on a public deployment; an upload ID alone is not proof of compatibility. @@ -49,7 +49,7 @@ The first implementation handles a single login per request and one pending requ ## Ownership and recovery -The connection owner comes from Eve's signature-verified Linq principal, never a model argument or caller-supplied phone number. AES-GCM binds the encrypted record to that owner. The integration key is validated against the OAuth client, API host, account, and user path. A reconnect with an omitted key preserves the stored key and requires matching provider user identity. If identity cannot be established, Bell refuses to activate the connection. +The connection owner comes from the verified Photon messaging principal, never a model argument or caller-supplied phone number. AES-GCM binds the encrypted record to that owner. The integration key is validated against the OAuth client, API host, account, and user path. A reconnect with an omitted key preserves the stored key and requires matching provider user identity. If identity cannot be established, Bell refuses to activate the connection. The callback captures the integration key from the fragment, posts it through a same-origin, browser-bound endpoint, saves it before code exchange, and clears the browser URL. Nothing stores the key in localStorage, sessionStorage, model output, or an environment variable. See [1Password's storage contract](https://www.1password.dev/agentic-autofill/partners/keys-and-tokens). diff --git a/docs/photon-capabilities.md b/docs/photon-capabilities.md new file mode 100644 index 0000000..3f175e7 --- /dev/null +++ b/docs/photon-capabilities.md @@ -0,0 +1,228 @@ +# Photon iMessage channel capabilities + +Bell's iMessage transport runs on Photon Advanced gRPC (`@photon-ai/advanced-imessage`, +Stable Cloud). This document is the operator-facing reference for what the +channel can do, how it is secured and how it is operated. All names below are +grounded in `server/photon/contracts.ts` and the SDK surface; nothing is +invented. + +## Architecture + +Three components cooperate: + +- **Worker** (`server/photon/worker.ts`, `server/photon/events.ts`, + `server/photon/normalize.ts`): holds the authenticated Advanced gRPC event + subscription, normalises provider events into the frozen + `PhotonNormalizedEvent` contract and durably captures them in the inbox + tables (`migrations/004_photon.sql`) before any Eve work begins. +- **Channel** (`agent/channels/photon.ts`): the Eve channel adapter. It + receives admitted events through the private internal dispatch route and + exposes Bell's agent loop, tools and fixed-session policy. +- **Outbox** (`server/photon/outbox.ts` + command ledger): every outbound + provider call is queued as a durable command row with an idempotency key, + claimed by a worker, executed against the SDK and reconciled to a terminal + or retryable state. Nothing reaches the provider without a ledger row. + +The worker never runs a model loop, browser automation or a parallel memory: +Bell remains the only agent runtime. The worker maintains connections, +persists events, executes queued operations and recovers failures. + +Internal routes, all bound to `BELL_PHOTON_INTERNAL_ORIGIN` and authenticated +with `BELL_PHOTON_INTERNAL_SECRET` (≥ 32 bytes, never public): + +| Route | Purpose | +| --- | --- | +| `POST /internal/photon/dispatch` | Deliver an admitted event to its fixed Eve session | +| `POST /internal/photon/events` | Provider/event-plane ingress hook | +| `GET /internal/photon/health` | Worker and lease liveness | +| `POST /internal/photon/retire` | Retire a conversation on its absolute deadline | + +## Command catalog + +The durable command path exposes **44 enqueueable operations** +(`commandArgsSchemas`), grouped by SDK resource. The broader SDK surface also +includes streaming and lifecycle methods (subscriptions, attachment download +streams, catch-up replays) that the worker consumes directly; they are never +queued as commands. Argument validation is the authoritative +`validateCommandArgs` schema set. + +Keyed operations (`KEYED_OPERATIONS`) carry `providerKeyFor` as the provider +`clientMessageId` and must never be blind-retried after a transport failure: +the outbox marks them `ambiguous` for reconciliation instead. Read operations +(`READ_OPERATIONS`, marked **R**) are side-effect-free and may retry freely. +`locations.watch` is a Bell-scoped subscription control, not a raw RPC +passthrough. + +### Messages (14) + +`messages.sendText`, `messages.sendAttachment`, `messages.sendMultipart`, +`messages.sendCustomizedMiniApp`, `messages.updateCustomizedMiniApp`, +`messages.edit`, `messages.unsend`, `messages.setReaction`, +`messages.placeSticker`, `messages.notifySilenced` (all keyed) plus +`messages.get`, `messages.listInChat`, `messages.listRecent`, +`messages.getEmbeddedMedia` (all **R**). + +### Attachments (2) + +`attachments.upload` (artifact-backed, optional Live Photo companion) and +`attachments.get` (**R**). Downloads are bounded by +`BELL_PHOTON_MAX_ARTIFACT_BYTES` and always fail closed before producing a +partial artifact. + +### Chats (9) + +`chats.create` (keyed), `chats.get` (**R**), `chats.count` (**R**), +`chats.markRead`, `chats.setTyping`, `chats.shareContactInfo`, +`chats.hasBackground` (**R**), `chats.setBackground` (exactly one of `preset` +or `artifactId`), `chats.removeBackground`. + +### Groups (7) + +`groups.setDisplayName`, `groups.addParticipants`, `groups.removeParticipants`, +`groups.leave`, `groups.setIcon`, `groups.removeIcon` (all keyed), +`groups.getIcon` (**R**). + +### Polls (5) + +`polls.create` (2–100 choices), `polls.vote` (non-empty `optionIdentifier`), +`polls.unvote`, `polls.addOption` (all keyed), `polls.get` (**R**). + +### Addresses (3) + +`addresses.get`, `addresses.isIMessageAvailable`, `addresses.isFocusSilenced` +(all **R**). + +### Locations (4) + +`locations.request` (keyed), `locations.list` (**R**), `locations.get` (**R**), +`locations.watch` (`start`/`stop` subscription control). + +## Inbound event catalog + +The worker normalises all **24 event variants** of +`PhotonNormalizedEvent`: + +| Family | Variants | +| --- | --- | +| `message` (7) | `received`, `edited`, `read`, `unsent`, `reactionAdded`, `reactionRemoved`, `stickerPlaced` | +| `chat` (5) | `backgroundChanged`, `backgroundRemoved`, `markedRead`, `archived`, `unarchived` | +| `group` (6) | `displayNameChanged`, `participantAdded`, `participantRemoved`, `participantLeft`, `iconChanged`, `iconRemoved` | +| `poll` (4) | `created`, `optionAdded`, `voted`, `unvoted` | +| `location` (1) | `updated` | +| `catchup` (1) | `complete` | + +Every event carries an envelope (`PhotonEventEnvelope`) with a durable +`(sourceId, logEpoch, sequence)` cursor, the actor (when known), the chat +target and a `live`/`catchup` source kind. Replays preserve metadata so cold +restarts resume from `maxSequenceNum` without re-dispatching deduplicated +events. + +## Audience and security model + +- **Private conversations**: only handles on + `BELL_PHOTON_ALLOWED_SENDERS`/`BELL_PHOTON_ALLOWED_EMAIL_SENDERS` (normalised + E.164/lowercase email) are admitted. Anything else is denied before Eve sees + it. +- **Group conversations**: the group guid must be on + `BELL_PHOTON_ALLOWED_GROUPS`. Actor membership is enforced when known; + non-member actors degrade to state-only events that update projection + without authorization. The initiating principal is immutable for the + lifetime of the conversation. +- **Private continuation**: every sensitive capability (Link checkout, personal + 1Password, operator vault, reusable browser context) requires a direct-scope + audience. Group-scoped principals can never reach them; the verified + `photon:` principal (`photon-imessage` authenticator, `photon` issuer, + `photon:audience` attribute) is the only identity Bell trusts. +- **Self-origin echoes** are admitted as state-only so the outbox reconciles + its own sends rather than triggering a second dispatch. +- **Missing actors** can never authorize tools or pending interactions; a + replayed or unattributed vote resolves nothing. + +## Environment reference + +Required when `BELL_PHOTON_ENABLED` is set: + +| Variable | Contract | +| --- | --- | +| `BELL_PHOTON_ENABLED` | Feature flag; absent/`0`/`false` returns `null` config with no provider dependency | +| `IMESSAGE_PROJECT_ID` | Stable Cloud project id | +| `IMESSAGE_PROJECT_SECRET` | Project credential, server-only | +| `BELL_PHOTON_PHONE_NUMBER` | Dedicated receiving line in strict E.164 | +| `BELL_PHOTON_ALLOWED_SENDERS` | Non-empty E.164 sender allowlist | +| `BELL_PHOTON_INTERNAL_SECRET` | ≥ 32 byte secret authenticating internal routes | +| `BELL_PHOTON_INTERNAL_ORIGIN` | Bare `http(s)://` origin: no path, query, hash or credentials | +| `DATABASE_URL` | Postgres for the durable tables | + +Optional: `BELL_PHOTON_ALLOWED_EMAIL_SENDERS`, +`BELL_PHOTON_ALLOWED_GROUPS` (empty keeps group ingress closed) and +`BELL_PUBLIC_URL`. + +Worker tuning (all bounded with safe defaults): `BELL_PHOTON_LEASE_MS`, +`BELL_PHOTON_HEARTBEAT_STALE_MS`, `BELL_PHOTON_COMMAND_BATCH`, +`BELL_PHOTON_DISPATCH_BATCH`, `BELL_PHOTON_COMMAND_POLL_MS`, +`BELL_PHOTON_DISPATCH_POLL_MS`, `BELL_PHOTON_PROJECTION_POLL_MS`, +`BELL_PHOTON_RECONCILIATION_MS`, `BELL_PHOTON_SHUTDOWN_MS`, +`BELL_PHOTON_MAX_ARTIFACT_BYTES`. + +The token URL is derived as +`https://spectrum.photon.codes/projects/{projectId}/imessage/tokens`. + +## Deployment and rollout + +1. Apply `migrations/004_photon.sql` to create the durable Photon tables. +2. Deploy with `BELL_PHOTON_ENABLED` unset to keep the messaging channel + disabled while preparing the Photon credentials and sender allowlist. +3. Set the Photon environment, enable `BELL_PHOTON_ENABLED`, restart the app + and start the worker. Confirm `/internal/photon/health` shows a held lease + and a live stream. +4. Keep `BELL_PHOTON_INTERNAL_ORIGIN` and the internal secret consistent + between the app and worker. Retain the database across restarts to preserve + outbox commands, inbox cursors and durable deadlines. +5. To pause messaging, unset `BELL_PHOTON_ENABLED` and restart the app and + worker. Queued commands remain in the database; resuming processing still + respects their deadlines and reconciliation rules. + +## Limitations + +- **Provider acceptance is not device rendering.** A `clientMessageId` + acknowledgement proves Photon accepted the send; observed recipient-device + rendering is a separate verification state that requires a live device. +- **Effects, stickers, mini-app cards and backgrounds** degrade by recipient + capability; `backwardCompatText` on `messages.edit` is the only fallback + Bell controls. +- **Group membership is observed state**: until the stream reports members, + unknown actors are treated conservatively as non-members. +- **Poll and location fidelity** depends on Photon's Stable surface; votes + from unsupported clients arrive as state, not resolvable interactions. + +## Recovery runbook + +- **Inbox integrity conflict**: identical `(sourceId, logEpoch, sequence)` + with a different payload hash is an integrity conflict, never an overwrite. + Quarantine and inspect the source log before replaying. +- **Ambiguous command**: a transport failure on a keyed operation leaves the + command `ambiguous`. Reconcile by reading provider state + (`messages.get`/`polls.get`) before deciding to retry; do not re-enqueue + blindly. +- **Cold restart**: the worker resumes from the per-chat `maxSequenceNum` + cursor and replays catch-up events with `sourceKind: "catchup"` before + activating live dispatch. +- **Retired session**: late events for a retired binding follow the rejected + path; absolute deadlines survive restart and are retired only through + `/internal/photon/retire`, never by idle cancellation. +- **Interaction expiry**: `expireInteractions` sweeps stale pending rows; + one-time resolution is durable, so replayed votes cannot re-fire. + +## Live verification TODO + +Recorded prerequisites that need real credentials or devices (not part of the +automated suite): + +- TODO(live): authenticate against Stable Cloud with the production + `IMESSAGE_PROJECT_ID`/`SECRET` and confirm stream attach. +- TODO(live): send a keyed `messages.sendText` to a real device and observe + rendering plus the self-echo reconciliation. +- TODO(live): exercise `chats.setBackground`, `polls.*` and + `locations.request` against a real group to confirm fidelity. +- TODO(live): confirm `attachments.upload` with a Live Photo companion pair + renders on device. diff --git a/docs/photon.md b/docs/photon.md new file mode 100644 index 0000000..74eee0c --- /dev/null +++ b/docs/photon.md @@ -0,0 +1,134 @@ +# Text Bell through Photon + +Configure Bell with a dedicated iMessage line from a Photon Stable cloud project. +Photon carries native iMessage — text, attachments, reactions, edits, unsends, stickers, +effects, groups, polls, mini-app cards, and shared locations — over a persistent Advanced +gRPC channel. [Link by Stripe wallet OAuth and checkout](link.md) use this channel's +verified sender identity and require separate configuration. + +```text +Your phone -> Photon Stable cloud -> photon-worker (gRPC streams) + | inbox + policy + POST /internal/photon/dispatch (Eve server, loopback only) + | + Bell browser tools + Browserbase browser + | Eve session event projection +Your phone <- Photon <- photon-worker outbox <- durable commands +``` + +One provider chat GUID keeps one Eve conversation and browser across follow-up messages. +The worker process owns provider connectivity; the Eve agent never talks to Photon and +never sees provider credentials. Browser, vault, and handoff tools run in the same agent +process as before. Baselayer credentials are not required for ordinary browsing. + +## Components + +- **`agent/channels/photon.ts`** — the Eve channel. Mounts `/internal/photon/*` routes on + the Eve server only; the public Next surface never sees them (withEve proxies only + `/eve/v1/**`). Routes authenticate every call with `BELL_PHOTON_INTERNAL_SECRET`. +- **`server/photon/worker.ts` + `scripts/photon-worker.ts`** — the transport worker. + Maintains four unfiltered subscription streams (messages, chats, groups, polls), + separate location watches, heartbeat/reconnect supervision, periodic catch-up replay, + durable inbox/outbox, dispatch intents, projection, and the session-deadline sweep. +- **`scripts/run-bell.mjs`** — the supervisor (`pnpm start:bell`) that runs Next and the + worker together and propagates signals. +- **PostgreSQL** — sources/leases, inbox, conversation bindings, dispatch intents, + command outbox, resources, artifacts, pending interactions, projector offsets, and + absolute session deadlines. Apply `migrations/004_photon.sql`. + +## Configure the channel + +Use a Photon Stable cloud project with a dedicated iMessage line (Advanced plan, gRPC). +Shared-line projects are rejected at startup. + +1. In Bell's ignored `.env.local`, set these server-only values: + + ```dotenv + BELL_PHOTON_ENABLED=true + IMESSAGE_PROJECT_ID= + IMESSAGE_PROJECT_SECRET= + BELL_PHOTON_PHONE_NUMBER= + BELL_PHOTON_ALLOWED_SENDERS= + BELL_PHOTON_INTERNAL_SECRET= + BELL_PHOTON_INTERNAL_ORIGIN= + DATABASE_URL= + ``` + + Optional: `BELL_PHOTON_ALLOWED_EMAIL_SENDERS` (iMessage email handles, empty = denied), + `BELL_PHOTON_ALLOWED_GROUPS` (provider group chat GUIDs, empty = groups closed), + `BELL_PUBLIC_URL` (handoff/OAuth callbacks), and the bounded worker tuning knobs in + `.env.example`. Keep the existing OpenAI and Browserbase settings. + + Sender, email, and group allowlists are fail-closed: an empty list admits nobody. + A dedicated number is verified against the project's managed instances at startup; + the configured line is never silently substituted. + +2. The gRPC address is derived from the token endpoint's per-instance map and used + verbatim (`host:port`, TLS). Bell mints short-lived per-instance credentials from + `POST {endpoint}/projects/{projectId}/imessage/tokens`; the project secret stays in + the worker process and never reaches Eve, tools, or model context. + +3. Internal origin: point `BELL_PHOTON_INTERNAL_ORIGIN` at the Eve server's direct + address, not the Next app. `pnpm start:bell` binds the Eve build on loopback; the + worker POSTs `/internal/photon/dispatch`, `/events`, and `/retire` there with the + shared secret. A public proxy must never expose `/internal/*`. + +4. For optional human handoff, set `BELL_PUBLIC_URL` and `BELL_HANDOFF_SECRET` as + before. Handoff links still grant browser control; keep them private. + +5. Apply the schema and start everything: + + ```sh + psql "$DATABASE_URL" -f migrations/004_photon.sql + pnpm build && pnpm start:bell + ``` + + The worker re-applies the migration idempotently on boot, so a fresh database works + without the manual step. + +## Identity and audience + +Every inbound event maps to a verified principal: +`photon:` + sha256 of the canonical `["v1", project, handle kind, handle]` tuple. +Phone handles must normalize to E.164; email handles are lowercased. The receiving line +is excluded from the hash, so the same verified handle keeps one wallet owner across +line changes. Conversations bind to `(source, provider chat GUID, generation)` — never +derived from sender numbers. + +Direct chats admit only `BELL_PHOTON_ALLOWED_SENDERS`/`BELL_PHOTON_ALLOWED_EMAIL_SENDERS` +handles. Group chats admit only allowlisted chat GUIDs; the first verified speaker owns +the session's browser and approvals for the life of the task. Group sessions never reach +personal Link wallets, 1Password vaults, reusable browser Contexts, or private handoff +links — sensitive work redirects to the private conversation. Link and 1Password approval +flows remain authoritative; reactions, polls, and messages cannot impersonate them. + +## Demo from your phone + +Text your configured Photon line from an allowed personal number: + +1. "Open https://www.bestbuy.com and tell me the homepage title." +2. "Take a screenshot of that page." +3. "Tell me the current page URL without navigating again." + +Bell replies in the same iMessage conversation and reuses its browser. Provider +acknowledgement means the cloud accepted the command; it is not proof a device rendered +the message — Bell reports receipts, failures, and ambiguous outcomes exactly. + +## Rollout and rollback + +Independent switches keep the rollout reversible: + +- `BELL_PHOTON_ENABLED` — intake + worker + channel (off = channel absent, worker exits). +- Worker flags gate model dispatch and outbound mutations separately in code paths + (`dispatchAction: "none"` is state-only; the outbox can be drained before pausing). +- Safe intake stays durable while dispatch/mutations are disabled: captured events and + queued commands persist in PostgreSQL and resume after re-enable. + +## Verification boundary + +`pnpm test` exercises normalization, admission policy, dispatch/outbox state machines, +media bounds, interactions, group isolation, lifecycle deadlines, and recovery against +fixtures and stubbed seams — no provider traffic, no model, no browser. Live delivery +requires real Photon credentials, the enrolled line, and designated test devices; see +[provider regression checks](provider-tests.md). For wallet access, follow +[the Link guide](link.md). diff --git a/docs/provider-tests.md b/docs/provider-tests.md index da82281..d4ef4ec 100644 --- a/docs/provider-tests.md +++ b/docs/provider-tests.md @@ -36,3 +36,24 @@ BELL_LIVE_CONTEXT=1 STAGEHAND_BROWSER=browserbase pnpm exec vitest run tests/bro This writes a synthetic example.com cookie, releases the writer, starts a reader with the same Context, and checks the restored cookie. It then releases both browsers and deletes the temporary Context. Cleanup failures must fail the check rather than silently leaving resources. + +## Photon channel (live provider) + +Live Photon checks need the real `IMESSAGE_PROJECT_ID`/`IMESSAGE_PROJECT_SECRET`, an +enrolled dedicated line matching `BELL_PHOTON_PHONE_NUMBER`, a sender on +`BELL_PHOTON_ALLOWED_SENDERS` with a designated test device, PostgreSQL, and the worker +running against an isolated test World. With the environment loaded: + +1. Start `pnpm start:bell` and confirm `GET $BELL_PHOTON_INTERNAL_ORIGIN/internal/photon/health` + reports the worker alive with fresh stream beats. +2. From the test device, text the line: the event lands in `bell_photon_inbox`, dispatches + to a new Eve session, and the reply appears through the outbox. +3. Send an edit, unsend, reaction, and sticker; confirm they persist as `state_only` + projections and never start turns. +4. Kill the worker mid-send, restart it, and confirm catch-up replays the gap and the + outbox does not double-send (provider receipts reconcile via `clientMessageId`). +5. Run a live group check only against a GUID in `BELL_PHOTON_ALLOWED_GROUPS` with the + test membership enrolled. + +Provider acknowledgement is not device rendering — record observed device behavior in +the release evidence, not just worker logs. diff --git a/docs/repository.md b/docs/repository.md index f77d120..bd9b812 100644 --- a/docs/repository.md +++ b/docs/repository.md @@ -2,13 +2,17 @@ Bell lives in [browserbase/bell-stack](https://github.com/browserbase/bell-stack). One Next.js application serves the web interface and integrations. One Eve by Vercel agent owns the web -and Linq conversations; both channels use Bell's browser runtime directly. +and Photon conversations; both channels use Bell's browser runtime directly. A separate supervised +transport worker maintains the Photon Advanced gRPC streams and executes Bell's durable command queue. ```text agent/ Eve agent, tools, hooks, runtime, and channels app/ Bell web interface, integration APIs, and handoff routes server/browser/ Browser sessions, Context metadata, credentials, and handoff +server/photon/ Photon transport worker: streams, inbox, dispatch, projection, outbox lib/browser/ Browser URL and CAPTCHA helpers +migrations/ PostgreSQL schema migrations, including the Photon channel tables +scripts/ Runtime launchers, including the Photon worker and supervisor tests/browser/ Browser runtime regression tests and local fixtures package.json Application dependencies and commands pnpm-lock.yaml One dependency resolution tree @@ -19,8 +23,10 @@ pnpm-lock.yaml One dependency resolution tree [Eve by Vercel](https://eve.dev/docs) runs Bell's agent, conversation state, tools, and channels. Configure the model and session limits in [`agent/agent.ts`](../agent/agent.ts), behavior in [`agent/instructions.md`](../agent/instructions.md), and tools in [`agent/tools/`](../agent/tools/). -The [web channel](../agent/channels/eve.ts) and [Linq channel](../agent/channels/linq.ts) use the -same agent with separate conversations. [Hooks](../agent/hooks/) maintain task state and browser +The [web channel](../agent/channels/eve.ts) and [Photon channel](../agent/channels/photon.ts) use the +same agent with separate conversations. The Photon channel exposes private internal routes the +transport worker calls; it never receives provider traffic directly. +[Hooks](../agent/hooks/) maintain task state and browser lifecycle; channel callbacks also handle terminal-session cleanup. Restart Bell after runtime edits. ## Integration implementation @@ -29,14 +35,14 @@ lifecycle; channel callbacks also handle terminal-session cleanup. Restart Bell | --- | --- | --- | | Browserbase + Stagehand | [Browser runtime](../agent/lib/browser-runtime.ts), [session management](../server/browser/browser-session.ts) | [Browser configuration](browser-runtime.md) | | Eve by Vercel | [Agent](../agent/agent.ts), [channels](../agent/channels/), [hooks](../agent/hooks/) | [Eve in Bell](#eve-by-vercel) | -| Linq | [Channel and sender policy](../agent/channels/linq.ts) | [Messaging setup](linq.md) | +| Photon | [Eve channel](../agent/channels/photon.ts), [transport worker](../server/photon/worker.ts), [sender policy](../server/photon/policy.ts) | [Messaging setup](photon.md) · [Capabilities](photon-capabilities.md) | | Link by Stripe | [Eve extension and tools](../agent/extensions/link/), [authorization](../agent/lib/link-auth.ts), [server implementation](../server/link/) | [Sandbox and wallet setup](link.md) | | 1Password | [Operator SDK/CLI access](../server/browser/vault/onepassword.ts), [personal authorization](../agent/lib/onepassword-auth.ts), [personal server implementation](../server/onepassword/) | [Operator credentials](browser-runtime.md#credentials-and-otp), [personal access status](onepassword.md) | | Baselayer | [Agent tool](../agent/tools/merchant_access.ts), [credential minting](../server/baselayer.ts), [merchant verification](../server/verified-merchant.ts) | [Merchant access](baselayer.md) | Personal 1Password Agentic Autofill is implemented but disabled by default pending hosted extension support and live verification. Keep `ONEPASSWORD_ENABLED=false` and the extension ID unset as -described in its guide. Operator SDK/CLI access remains separate and is unavailable over Linq. +described in its guide. Operator SDK/CLI access remains separate and is unavailable over Photon messaging. ## Development @@ -66,7 +72,7 @@ the Browserbase copyright and license terms covering the browser source. ## Browser retirement -The HTTP and Linq channels release the hosted browser on terminal session events, including +The HTTP and Photon channels release the hosted browser on terminal session events, including when cleanup runs in a fresh worker. Eve 0.79.1 skips global stream hooks for completion outside a turn, so cleanup uses channel callbacks. A failed release retains the browser ID and reaches Eve's error logging; failed callbacks are not automatically retried. Starting a new conversation diff --git a/eslint.config.mjs b/eslint.config.mjs index 508e31b..ed882c6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,6 +3,6 @@ import typescript from "eslint-config-next/typescript"; const config = [ ...next, ...typescript, - { ignores: ["**/.next/**", "**/.eve/**", "**/.output/**", "**/next-env.d.ts", "**/dist/**", "**/node_modules/**"] }, + { ignores: ["**/.next/**", "**/.eve/**", "**/.output/**", "**/.photon-worker/**", "**/next-env.d.ts", "**/dist/**", "**/node_modules/**"] }, ]; export default config; diff --git a/migrations/004_photon.sql b/migrations/004_photon.sql new file mode 100644 index 0000000..f230067 --- /dev/null +++ b/migrations/004_photon.sql @@ -0,0 +1,215 @@ +BEGIN; + +-- Bell <-> Photon Advanced gRPC channel durability. +-- Provider sequences are stored losslessly as text; never as a JS-unsafe number. + +-- One row per enrolled provider source: (project, provider instance/account, line). +-- The lease columns elect a single active subscription owner per source. +CREATE TABLE IF NOT EXISTS bell_photon_sources ( + source_id text PRIMARY KEY, + project_id text NOT NULL, + instance_id text NOT NULL, + account_scope text NOT NULL, + phone_number text NOT NULL, + endpoint text NOT NULL, + log_epoch text NOT NULL, + activation_boundary text, + capture_cursor text, + coverage jsonb NOT NULL DEFAULT '{}', + owner_token text, + lease_epoch bigint NOT NULL DEFAULT 0, + lease_expires_at timestamptz, + credential_expires_at timestamptz, + health jsonb NOT NULL DEFAULT '{}', + status text NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'active', 'suspended', 'retired')), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- Every captured provider event, or a minimal tombstone for denied/non-triggering input. +CREATE TABLE IF NOT EXISTS bell_photon_inbox ( + source_id text NOT NULL REFERENCES bell_photon_sources (source_id), + log_epoch text NOT NULL, + sequence text NOT NULL, + sequence_num numeric(40, 0) NOT NULL, + payload_hash text NOT NULL, + envelope jsonb, + tombstone boolean NOT NULL DEFAULT false, + event_family text, + event_type text, + chat_guid text, + actor text, + target_guid text, + is_from_me boolean, + occurred_at timestamptz, + received_at timestamptz NOT NULL DEFAULT now(), + source_kind text NOT NULL CHECK (source_kind IN ('live', 'catchup')), + disposition text NOT NULL DEFAULT 'pending' + CHECK (disposition IN ('pending', 'admitted', 'state_only', 'denied', 'quarantined', 'tombstone')), + processing_state text NOT NULL DEFAULT 'captured' + CHECK (processing_state IN ('captured', 'dispatched', 'settled', 'failed', 'quarantined')), + attempts integer NOT NULL DEFAULT 0, + last_error text, + PRIMARY KEY (source_id, log_epoch, sequence) +); +CREATE INDEX IF NOT EXISTS bell_photon_inbox_pending + ON bell_photon_inbox (source_id, disposition, processing_state, sequence_num); +CREATE INDEX IF NOT EXISTS bell_photon_inbox_chat + ON bell_photon_inbox (source_id, chat_guid, sequence_num); + +-- Canonical conversation binding: (source, provider chat GUID, generation) -> Eve session. +CREATE TABLE IF NOT EXISTS bell_photon_conversations ( + binding_id text PRIMARY KEY, + source_id text NOT NULL REFERENCES bell_photon_sources (source_id), + chat_guid text NOT NULL, + generation integer NOT NULL DEFAULT 1, + scope text NOT NULL CHECK (scope IN ('direct', 'group')), + eve_address text NOT NULL, + eve_session_id text, + initiating_principal text, + membership_version integer NOT NULL DEFAULT 0, + membership jsonb NOT NULL DEFAULT '[]', + last_turn_at timestamptz, + retired_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (source_id, chat_guid, generation) +); + +-- Dispatch intents: one inbox action -> one durable Eve delivery attempt ledger row. +CREATE TABLE IF NOT EXISTS bell_photon_dispatch_intents ( + intent_id text PRIMARY KEY, + source_id text NOT NULL, + log_epoch text NOT NULL, + sequence text NOT NULL, + action text NOT NULL CHECK (action IN ('send', 'respond', 'state_apply')), + eve_session_id text, + dispatch_key text NOT NULL, + input_digest text NOT NULL, + status text NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'accepted', 'ambiguous', 'rejected', 'quarantined')), + delivery_id text, + attempts integer NOT NULL DEFAULT 0, + last_error text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (dispatch_key), + FOREIGN KEY (source_id, log_epoch, sequence) + REFERENCES bell_photon_inbox (source_id, log_epoch, sequence) +); + +-- Durable outbound command queue: replies, notices, rich tools, proactive sends. +CREATE TABLE IF NOT EXISTS bell_photon_commands ( + command_id text PRIMARY KEY, + operation text NOT NULL, + args jsonb NOT NULL, + args_digest text NOT NULL, + initiator_principal text, + audience text CHECK (audience IN ('direct', 'group', 'system')), + source_id text NOT NULL, + chat_guid text NOT NULL, + target jsonb, + provider_key text, + semantic_key text NOT NULL UNIQUE, + depends_on text[] NOT NULL DEFAULT '{}', + status text NOT NULL DEFAULT 'queued' + CHECK (status IN ('queued', 'in_flight', 'acknowledged', 'ambiguous', 'failed', 'cancelled', 'expired')), + attempt_count integer NOT NULL DEFAULT 0, + next_attempt_at timestamptz NOT NULL DEFAULT now(), + claimed_by text, + claim_expires_at timestamptz, + result jsonb, + provider_receipt jsonb, + error_code text, + error_detail text, + expires_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS bell_photon_commands_queue + ON bell_photon_commands (status, next_attempt_at); +CREATE INDEX IF NOT EXISTS bell_photon_commands_chat + ON bell_photon_commands (source_id, chat_guid, created_at); + +-- Durable provider resources: messages, attachments, polls, card sessions, +-- chats, and the message-guid -> session index used by mini-app card updates. +-- The kind list is governed by the ResourceKind union in server/photon/store.ts; +-- an inline CHECK duplicated it and already diverged once, so the table carries +-- no constraint and the legacy CHECK is dropped below. +CREATE TABLE IF NOT EXISTS bell_photon_resources ( + source_id text NOT NULL REFERENCES bell_photon_sources (source_id), + kind text NOT NULL, + resource_id text NOT NULL, + data jsonb NOT NULL DEFAULT '{}', + revision integer NOT NULL DEFAULT 1, + tombstone boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (source_id, kind, resource_id) +); + +-- Repair databases created while kind carried an inline CHECK list: the +-- constraint predates miniapp_session_by_message and rejects card sends. +ALTER TABLE bell_photon_resources + DROP CONSTRAINT IF EXISTS bell_photon_resources_kind_check; + +-- Bounded downloaded/uploaded artifact bytes for model staging and sends. +CREATE TABLE IF NOT EXISTS bell_photon_artifacts ( + artifact_id text PRIMARY KEY, + source_id text NOT NULL REFERENCES bell_photon_sources (source_id), + chat_guid text NOT NULL, + attachment_guid text, + file_name text, + media_type text NOT NULL, + byte_size bigint NOT NULL, + data bytea NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- Pending interactions: Eve input requests / auth attempts that an inbound event may resolve. +CREATE TABLE IF NOT EXISTS bell_photon_pending_interactions ( + interaction_id text PRIMARY KEY, + source_id text NOT NULL REFERENCES bell_photon_sources (source_id), + binding_id text, + chat_guid text NOT NULL, + generation integer NOT NULL DEFAULT 1, + kind text NOT NULL CHECK (kind IN ('prompt', 'authorization', 'poll', 'reaction_contract')), + request_ref text NOT NULL, + permitted_actor text, + target jsonb NOT NULL DEFAULT '{}', + response_mapping jsonb NOT NULL DEFAULT '{}', + status text NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'resolved', 'expired', 'cancelled')), + expires_at timestamptz, + resolved_actor text, + resolved_at timestamptz, + resolution jsonb, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS bell_photon_pending_open + ON bell_photon_pending_interactions (source_id, chat_guid, status); + +-- Output projector offsets: per bound Eve session, durable stream position + materialized keys. +CREATE TABLE IF NOT EXISTS bell_photon_projector_offsets ( + source_id text NOT NULL REFERENCES bell_photon_sources (source_id), + eve_session_id text NOT NULL, + stream_position bigint NOT NULL DEFAULT 0, + materialized_keys jsonb NOT NULL DEFAULT '[]', + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (source_id, eve_session_id) +); + +-- Absolute session deadlines preserving Bell's 24-hour maximum conversation lifetime. +CREATE TABLE IF NOT EXISTS bell_photon_session_deadlines ( + eve_session_id text PRIMARY KEY, + binding_id text, + deadline_at timestamptz NOT NULL, + retired_at timestamptz, + retirement_reason text, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS bell_photon_deadlines_due + ON bell_photon_session_deadlines (deadline_at) WHERE retired_at IS NULL; + +COMMIT; diff --git a/package.json b/package.json index 9d283d3..b30fd19 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,11 @@ "scripts": { "dev": "next dev --hostname 127.0.0.1 --port 4320", "build:agent": "eve build --skip-sandbox-prewarm && node scripts/package-browser-assets.mjs", + "build:worker": "tsc -p tsconfig.photon-worker.json", "build": "pnpm build:agent && next build", + "worker": "node .photon-worker/scripts/photon-worker.js", "start": "next start --hostname 127.0.0.1 --port 4320", + "start:bell": "node scripts/run-bell.mjs", "test": "vitest run", "repro:stagehand-extensions": "node --env-file-if-exists=.env --env-file-if-exists=.env.local scripts/repro-stagehand-extensions.mjs", "typecheck": "tsc --noEmit", @@ -25,10 +28,14 @@ "@ai-sdk/openai": "4.0.46", "@browserbasehq/sdk": "2.18.0", "@browserbasehq/stagehand": "4.3.0", + "@grpc/grpc-js": "^1.14.6", + "@photon-ai/advanced-imessage": "2.4.0", "@stripe/link-integrations-eve": "0.3.4", "ai": "7.0.137", - "eve": "0.79.1", + "eve": "0.82.0", "next": "16.4.3", + "nice-grpc": "^2.1.18", + "nice-grpc-common": "^2.0.4", "pg": "8.23.1", "playwright-core": "1.62.1", "react": "19.3.0", @@ -47,7 +54,7 @@ }, "repository": { "type": "git", - "url": "https://github.com/browserbase/bell-stack.git" + "url": "https://github.com/tecxbro/bell-stack-photon.git" }, "license": "MIT", "pnpm": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e71bd2c..6df91ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,18 +25,30 @@ importers: '@browserbasehq/stagehand': specifier: 4.3.0 version: 4.3.0 + '@grpc/grpc-js': + specifier: ^1.14.6 + version: 1.14.6 + '@photon-ai/advanced-imessage': + specifier: 2.4.0 + version: 2.4.0(@grpc/grpc-js@1.14.6)(nice-grpc-common@2.0.4)(nice-grpc@2.1.18) '@stripe/link-integrations-eve': specifier: 0.3.4 - version: 0.3.4(eve@0.79.1(@opentelemetry/api@1.9.1)(ai@7.0.137(zod@4.6.7))(microsandbox@0.5.10)) + version: 0.3.4(eve@0.82.0(@opentelemetry/api@1.9.1)(ai@7.0.137(zod@4.6.7))(microsandbox@0.5.10)) ai: specifier: 7.0.137 version: 7.0.137(zod@4.6.7) eve: - specifier: 0.79.1 - version: 0.79.1(@opentelemetry/api@1.9.1)(ai@7.0.137(zod@4.6.7))(microsandbox@0.5.10) + specifier: 0.82.0 + version: 0.82.0(@opentelemetry/api@1.9.1)(ai@7.0.137(zod@4.6.7))(microsandbox@0.5.10) next: specifier: 16.4.3 version: 16.4.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@24.19.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + nice-grpc: + specifier: ^2.1.18 + version: 2.1.18 + nice-grpc-common: + specifier: ^2.0.4 + version: 2.0.4 pg: specifier: 8.23.1 version: 8.23.1 @@ -192,6 +204,9 @@ packages: resolution: {integrity: sha512-LDiAvJKkpcm2I/NP1B94DC/0hnjJ0NL7kjSKDeyXXyehISKuNRjwOpA2r3PXwmxmMz/o6DMFF4TJcYlsrMWXoA==} engines: {node: '>=22.18.0'} + '@bufbuild/protobuf@2.16.0': + resolution: {integrity: sha512-FWa0sPlqYGJgpTs6OxBRcL/AW4JT0OIO+W1ajerluoTVh8CV0B7XM1qsNCRgnkSCkRCgOICKHRQlY17jrsm0+Q==} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -404,6 +419,15 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@grpc/grpc-js@1.14.6': + resolution: {integrity: sha512-SgC/B3/VZ8lTyOZ4Cwk6f1skWhmkNAaVfiX90qd5u1rRXGKk1//8FTUEN9GvOUq7rfWIqAYkA3CoyumYVpOS6g==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.8.1': + resolution: {integrity: sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==} + engines: {node: '>=6'} + hasBin: true + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -602,6 +626,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@napi-rs/wasm-runtime@1.2.5': resolution: {integrity: sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} @@ -700,6 +727,48 @@ packages: '@oxc-project/types@0.153.0': resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==} + '@photon-ai/advanced-imessage@2.4.0': + resolution: {integrity: sha512-Z9L+YwmGwH/Wspsc6pp7z7s91uZLWsUj07NdzJ/9vhQmd36kR22NIZmyzzdpn6kXbw2faLZbaJdujDOuOjF57A==} + engines: {node: '>=18.17'} + peerDependencies: + '@grpc/grpc-js': ^1.12.6 + nice-grpc: ^2.1.11 + nice-grpc-common: ^2.0.2 + peerDependenciesMeta: + '@grpc/grpc-js': + optional: true + nice-grpc: + optional: true + nice-grpc-common: + optional: true + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.2': + resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==} + '@rolldown/binding-android-arm-eabi@1.2.13': resolution: {integrity: sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1091,6 +1160,9 @@ packages: '@workflow/serde@4.1.0': resolution: {integrity: sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==} + abort-controller-x@0.5.0: + resolution: {integrity: sha512-yTt9CI0x+nRfX6BFMenEGP8ooPvErGH6AbFz20C2IeOLIlDsrw/VHpgne3GsCEuTA410IiFiaLVFKmgM4bKEPQ==} + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -1118,6 +1190,10 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -1245,6 +1321,10 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1346,6 +1426,9 @@ packages: electron-to-chromium@1.5.451: resolution: {integrity: sha512-vUNKPrUzw8ByEMOxloq0a71iD/mPuPxVkbDm3G5YhnxTzOw/I6qAg2MUS0cYj/c267lWmw26yW4Vv7foxTxPLg==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} @@ -1529,15 +1612,15 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - eve@0.79.1: - resolution: {integrity: sha512-7hbpSi6mCqsrq3W7C9Y8Z2vTny01hunaNilEFj2qy/DkotztaW+KD4BNwut3oz2iSQ8yryyl46XhOspQ6PmZOg==} + eve@0.82.0: + resolution: {integrity: sha512-HQqjGebljxuO0EYX0XkQaY3/lHmn/oaFe8ggPAkcNIgVW9elyH7x+NjhABAqCO+Tz4azhurq87KeZzbRphJosA==} engines: {node: '>=24'} hasBin: true peerDependencies: '@opentelemetry/api': ^1.0.0 ai: ^7.0.137 braintrust: ^3.0.0 - chat: ^4.41.0 + chat: ^4.42.0 dd-trace: ^6.13.0 just-bash: ^3.1.0 microsandbox: ^0.5.0 @@ -1652,6 +1735,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -1813,6 +1900,10 @@ packages: resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} engines: {node: '>= 0.4'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-generator-function@1.1.2: resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} engines: {node: '>= 0.4'} @@ -2012,9 +2103,15 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -2100,6 +2197,12 @@ packages: nf3@0.3.24: resolution: {integrity: sha512-HxLK4bo+5jNsEETZp4w3tJblHOA9MCBY14IN9nZJJV8JDxt9yNIYxuBuLMjTAR5GFa3HL61+8VQDUrXv3/C8fw==} + nice-grpc-common@2.0.4: + resolution: {integrity: sha512-gOEXlD6ShXZMZ8k+49wm/bA2j1+3IKbEFV9WYREJcvZV4kM5L1KkILYTX9VYBzZF2OuYCe1wp8c82bQkvR0fHw==} + + nice-grpc@2.1.18: + resolution: {integrity: sha512-m1if7HuVIhhVo/obvLgoSco2tj46zfeIEKkavlAnAsjklBzmFthvYX/uoKZmMKIwpVJllYpj+HVFRb+qGxibrA==} + nitro@3.0.260903-beta: resolution: {integrity: sha512-54gANPi62O8rfMvepiJUVuIzEIinYfpeHbebywlLxvVTgIYXDwSvpaU9Id+0sJOBjBx0wC1/CVYXJkH7SY+l0g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2285,6 +2388,10 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + protobufjs@7.6.6: + resolution: {integrity: sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==} + engines: {node: '>=12.0.0'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -2312,6 +2419,10 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -2441,6 +2552,10 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + string.prototype.includes@2.0.1: resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} engines: {node: '>= 0.4'} @@ -2464,6 +2579,10 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -2521,6 +2640,9 @@ packages: peerDependencies: typescript: '>=4.8.4' + ts-error@1.0.6: + resolution: {integrity: sha512-tLJxacIQUM82IR7JO1UUkKlYuUTmoY9HBJAmNWFzheSlDS5SPMcNIepejHJa4BpPQLAcbRhRf3GDJzyj6rbKvA==} + tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} @@ -2719,13 +2841,29 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -2907,6 +3045,8 @@ snapshots: transitivePeerDependencies: - encoding + '@bufbuild/protobuf@2.16.0': {} + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -3057,6 +3197,18 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@grpc/grpc-js@1.14.6': + dependencies: + '@grpc/proto-loader': 0.8.1 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.8.1': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.6.6 + yargs: 17.7.3 + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -3199,6 +3351,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.0 + '@js-sdsl/ordered-map@4.4.2': {} + '@napi-rs/wasm-runtime@1.2.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -3264,6 +3418,34 @@ snapshots: '@oxc-project/types@0.153.0': {} + '@photon-ai/advanced-imessage@2.4.0(@grpc/grpc-js@1.14.6)(nice-grpc-common@2.0.4)(nice-grpc@2.1.18)': + dependencies: + '@bufbuild/protobuf': 2.16.0 + optionalDependencies: + '@grpc/grpc-js': 1.14.6 + nice-grpc: 2.1.18 + nice-grpc-common: 2.0.4 + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.2': {} + '@rolldown/binding-android-arm-eabi@1.2.13': optional: true @@ -3315,10 +3497,10 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@stripe/link-integrations-eve@0.3.4(eve@0.79.1(@opentelemetry/api@1.9.1)(ai@7.0.137(zod@4.6.7))(microsandbox@0.5.10))': + '@stripe/link-integrations-eve@0.3.4(eve@0.82.0(@opentelemetry/api@1.9.1)(ai@7.0.137(zod@4.6.7))(microsandbox@0.5.10))': dependencies: '@stripe/link-sdk': 0.14.2 - eve: 0.79.1(@opentelemetry/api@1.9.1)(ai@7.0.137(zod@4.6.7))(microsandbox@0.5.10) + eve: 0.82.0(@opentelemetry/api@1.9.1)(ai@7.0.137(zod@4.6.7))(microsandbox@0.5.10) zod: 4.6.7 '@stripe/link-sdk@0.14.2': @@ -3589,6 +3771,8 @@ snapshots: '@workflow/serde@4.1.0': {} + abort-controller-x@0.5.0: {} + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -3617,6 +3801,8 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-regex@5.0.1: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 @@ -3765,6 +3951,12 @@ snapshots: client-only@0.0.1: {} + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -3853,6 +4045,8 @@ snapshots: electron-to-chromium@1.5.451: {} + emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} env-runner@0.2.3: @@ -4216,7 +4410,7 @@ snapshots: esutils@2.0.3: {} - eve@0.79.1(@opentelemetry/api@1.9.1)(ai@7.0.137(zod@4.6.7))(microsandbox@0.5.10): + eve@0.82.0(@opentelemetry/api@1.9.1)(ai@7.0.137(zod@4.6.7))(microsandbox@0.5.10): dependencies: ai: 7.0.137(zod@4.6.7) nitro: 3.0.260903-beta @@ -4317,6 +4511,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -4479,6 +4675,8 @@ snapshots: dependencies: call-bound: 1.0.4 + is-fullwidth-code-point@3.0.0: {} + is-generator-function@1.1.2: dependencies: call-bound: 1.0.4 @@ -4651,8 +4849,12 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.camelcase@4.3.0: {} + lodash.merge@4.6.2: {} + long@5.3.2: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -4733,6 +4935,16 @@ snapshots: nf3@0.3.24: {} + nice-grpc-common@2.0.4: + dependencies: + ts-error: 1.0.6 + + nice-grpc@2.1.18: + dependencies: + '@grpc/grpc-js': 1.14.6 + abort-controller-x: 0.5.0 + nice-grpc-common: 2.0.4 + nitro@3.0.260903-beta: dependencies: consola: 3.4.2 @@ -4921,6 +5133,20 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + protobufjs@7.6.6: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.2 + '@types/node': 24.19.0 + long: 5.3.2 + punycode@2.3.1: {} queue-microtask@1.2.3: {} @@ -4954,6 +5180,8 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + require-directory@2.1.1: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -5132,6 +5360,12 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + string.prototype.includes@2.0.1: dependencies: call-bind: 1.0.9 @@ -5183,6 +5417,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.2 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + strip-bom@3.0.0: {} strip-json-comments@3.1.1: {} @@ -5221,6 +5459,8 @@ snapshots: dependencies: typescript: 6.0.3 + ts-error@1.0.6: {} + tsconfig-paths@3.15.0: dependencies: '@types/json5': 0.0.29 @@ -5438,10 +5678,30 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + xtend@4.0.2: {} + y18n@5.0.8: {} + yallist@3.1.1: {} + yargs-parser@21.1.1: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + yocto-queue@0.1.0: {} zod-validation-error@4.0.2(zod@4.6.7): diff --git a/scripts/photon-worker.ts b/scripts/photon-worker.ts new file mode 100644 index 0000000..b4dcd39 --- /dev/null +++ b/scripts/photon-worker.ts @@ -0,0 +1,49 @@ +#!/usr/bin/env node +/** + * Photon transport worker entrypoint. Exits 0 quietly when the channel is + * disabled so supervisors can launch it unconditionally; exits nonzero on + * startup failure or involuntary termination (lost lease, expired managed + * credentials) so the supervisor restarts it. + */ +import { PhotonConfigError, loadPhotonConfig, photonEnabled } from "../server/photon/config.js"; +import { startPhotonWorker } from "../server/photon/worker.js"; + +function log(entry: Record): void { + console.log(JSON.stringify({ ts: new Date().toISOString(), ...entry })); +} + +async function main(): Promise { + if (!photonEnabled()) { + log({ level: "info", msg: "photon_worker_disabled", note: "BELL_PHOTON_ENABLED is not set; exiting." }); + return; + } + const config = loadPhotonConfig(); + if (!config) { + log({ level: "info", msg: "photon_worker_disabled" }); + return; + } + const worker = await startPhotonWorker(config, { log }); + + let signal: NodeJS.Signals | undefined; + const onSignal = (received: NodeJS.Signals): void => { + if (signal) return; + signal = received; + log({ level: "info", msg: "photon_worker_signal", signal }); + void worker.stop(); + }; + process.on("SIGINT", onSignal); + process.on("SIGTERM", onSignal); + + const outcome = await worker.done; + if (outcome.fatal) { + log({ level: "error", msg: "photon_worker_fatal", fatal: outcome.fatal }); + process.exitCode = 1; + } +} + +main().catch((error) => { + if (error instanceof PhotonConfigError) + log({ level: "error", msg: "photon_config_error", setting: error.setting, error: error.message }); + else log({ level: "error", msg: "photon_worker_start_failed", error: error instanceof Error ? error.message : String(error) }); + process.exitCode = 1; +}); diff --git a/scripts/run-bell.mjs b/scripts/run-bell.mjs new file mode 100644 index 0000000..d1dea45 --- /dev/null +++ b/scripts/run-bell.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env node +/** + * Bell launcher: supervises Next (127.0.0.1:4320 via `pnpm dev`) and, when + * BELL_PHOTON_ENABLED is set, the compiled Photon transport worker + * (.photon-worker/scripts/photon-worker.js — build it with + * `pnpm build:worker`). With the flag unset only Next starts. + * + * SIGINT/SIGTERM forward to both children. The launcher exits when either + * child exits, propagating that child's status so the supervisor sees real + * failures. + */ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +const WORKER_ENTRY = join(".photon-worker", "scripts", "photon-worker.js"); + +function photonEnabled() { + const value = (process.env.BELL_PHOTON_ENABLED ?? "").trim().toLowerCase(); + return value === "1" || value === "true" || value === "yes"; +} + +const children = new Map(); +let exiting = false; + +function shutdown(signal) { + if (exiting) return; + exiting = true; + for (const child of children.values()) { + if (!child.killed) child.kill(signal); + } +} + +function onChildExit(name, code, signal) { + if (exiting) return; + exiting = true; + // Bring down the sibling, then exit with this child's real status. + for (const [otherName, child] of children) { + if (otherName !== name && !child.killed) child.kill("SIGTERM"); + } + const exitCode = code ?? (signal ? 1 : 0); + process.exitCode = code === null && signal ? 1 : exitCode; +} + +function startChild(name, command, args) { + const child = spawn(command, args, { + env: process.env, + stdio: "inherit", + shell: process.platform === "win32", + }); + children.set(name, child); + child.on("exit", (code, signal) => onChildExit(name, code, signal)); + child.on("error", (error) => { + console.error(`[run-bell] ${name} failed to start: ${error.message}`); + onChildExit(name, 1, null); + }); + return child; +} + +startChild("next", "pnpm", ["dev"]); + +if (photonEnabled()) { + if (!existsSync(WORKER_ENTRY)) { + console.error( + `[run-bell] BELL_PHOTON_ENABLED is set but ${WORKER_ENTRY} is missing.\n` + + "[run-bell] Build the worker first: pnpm build:worker", + ); + shutdown("SIGTERM"); + process.exitCode = 1; + } else { + startChild("photon-worker", process.execPath, [ + "--env-file-if-exists=.env", + "--env-file-if-exists=.env.local", + WORKER_ENTRY, + ]); + } +} + +process.on("SIGINT", () => shutdown("SIGINT")); +process.on("SIGTERM", () => shutdown("SIGTERM")); diff --git a/server/browser/agent-contexts.ts b/server/browser/agent-contexts.ts index 909c436..ff9a9b4 100644 --- a/server/browser/agent-contexts.ts +++ b/server/browser/agent-contexts.ts @@ -1,7 +1,7 @@ import { requireReusableContexts } from "../onepassword/browser-policy"; import Browserbase from "@browserbasehq/sdk"; -import { getContextMetadataStore } from "./context-metadata-store.js"; +import { getContextMetadataStore, isOwnedGroupContext, isReusableContext } from "./context-metadata-store.js"; export interface AgentContextRecord { id: string; @@ -17,15 +17,12 @@ export async function listAgentContexts(): Promise { requireReusableContexts(); const sdk = browserbase(); const metadata = getContextMetadataStore(); - const local = await metadata.read(); const configuredId = process.env.BROWSERBASE_CONTEXT_ID; - if (configuredId && !local.contexts[configuredId]) { + if (configuredId && !(await metadata.read()).contexts[configuredId]) await metadata.add(configuredId, "Configured Context"); - return listAgentContexts(); - } + const local = await metadata.listReusable(); const records = await Promise.all( Object.entries(local.contexts).map(async ([id, item]) => { - if (item.status === "draft") return undefined; try { const remote = await sdk.contexts.retrieve(id); return { @@ -64,6 +61,51 @@ export async function createAgentContext(name: string): Promise { + const name = `Bell group ${shortSessionId(ownerSessionId)}`; + const remote = await browserbase().contexts.create({ + name, + ...(process.env.BROWSERBASE_PROJECT_ID + ? { projectId: process.env.BROWSERBASE_PROJECT_ID } + : {}), + }); + const expiresAt = new Date(Date.now() + draftTtlMs()); + await getContextMetadataStore().addDraft(remote.id, name, ownerSessionId, expiresAt, new Date(), { ownerId, audience: "group" }); + return { + id: remote.id, + name, + selected: false, + writerActive: false, + status: "draft", + }; +} + +/** + * Deletes an isolated group Context remotely and drops its metadata row, but + * only when the row is confirmed group-owned by this session. Returns false + * without touching shared/foreign Contexts. + */ +export async function releaseIsolatedContext( + id: string, + ownerSessionId: string, +): Promise { + const metadata = getContextMetadataStore(); + const row = (await metadata.read()).contexts[id]; + if (!isOwnedGroupContext(row, ownerSessionId)) return false; + await deleteContext(id); + await metadata.remove(id); + return true; +} + export async function ensureAgentContext( ownerSessionId: string, currentContextId?: string, @@ -83,6 +125,8 @@ export async function ensureAgentContext( ) { await deleteContext(currentContextId); await metadata.remove(currentContextId); + } else if (!isReusableContext(current)) { + // Isolated group Contexts are never reused outside their owning task. } else { try { const remote = await sdk.contexts.retrieve(currentContextId); @@ -127,11 +171,11 @@ export async function promoteAgentContext(id: string, name?: string): Promise { requireReusableContexts(); validateId(id); + const metadata = getContextMetadataStore(); + await metadata.assertReusable(id); await browserbase().contexts.retrieve(id); - await getContextMetadataStore().select(id); + await metadata.select(id); } async function deleteExpiredDraftContexts(now: Date): Promise { @@ -165,15 +211,15 @@ async function deleteExpiredDraftContexts(now: Date): Promise { item.status === "draft" && Boolean(expiresAt) && Date.parse(expiresAt ?? "") <= now.getTime() ); }); - for (const [id] of expired) { + await Promise.all(expired.map(async ([id]) => { try { await deleteContext(id); } catch { // Keep the record so another request can retry deleting the remote data. - continue; + return; } await metadata.remove(id); - } + })); } async function deleteContext(id: string): Promise { @@ -197,10 +243,14 @@ function shortSessionId(value: string): string { return safe.slice(-12) || new Date().toISOString().slice(0, 10); } +let cachedBrowserbase: { apiKey: string; sdk: Browserbase } | undefined; + function browserbase(): Browserbase { const apiKey = process.env.BROWSERBASE_API_KEY; if (!apiKey) throw new Error("Browserbase is not configured."); - return new Browserbase({ apiKey }); + if (cachedBrowserbase?.apiKey !== apiKey) + cachedBrowserbase = { apiKey, sdk: new Browserbase({ apiKey }) }; + return cachedBrowserbase.sdk; } function validateName(value: string): string { diff --git a/server/browser/context-metadata-store.ts b/server/browser/context-metadata-store.ts index 1e3a51f..1ac99d9 100644 --- a/server/browser/context-metadata-store.ts +++ b/server/browser/context-metadata-store.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; @@ -12,11 +13,51 @@ const metadataSchema = z.object({ status: z.enum(["draft", "saved"]).optional(), ownerSessionId: z.string().optional(), expiresAt: z.iso.datetime().optional(), + /** Verified principal id of the Context owner (isolated sessions only). */ + ownerId: z.string().optional(), + /** Conversation audience the Context belongs to; "group" is never reusable. */ + audience: z.enum(["direct", "group"]).optional(), })), + /** + * Immutable initiating-principal bindings for group browser tasks. The first + * verified group member to start browser work (or the durable conversation's + * initiatingPrincipal when known) owns the task until its browser closes. + */ + groupBrowserTasks: z.record(z.string(), z.object({ + ownerId: z.string(), + audience: z.literal("group"), + bindingId: z.string().optional(), + membershipVersion: z.number().int().nonnegative().optional(), + boundAt: z.iso.datetime(), + updatedAt: z.iso.datetime(), + })).optional(), }); export type ContextMetadataFile = z.infer; +/** + * Whether a stored Context row may be reused outside the task that owns it. + * Group-isolated rows are the only non-reusable audience; drafts remain + * reusable by their owning session until expiry. + */ +export function isReusableContext( + row: ContextMetadataFile["contexts"][string] | undefined, +): boolean { + return row?.audience !== "group"; +} + +/** + * Whether a stored Context row is the group-isolated Context owned by this + * session — the restore/release guard; a foreign or shared Context never + * satisfies it. + */ +export function isOwnedGroupContext( + row: ContextMetadataFile["contexts"][string] | undefined, + ownerSessionId: string, +): boolean { + return row?.audience === "group" && row.ownerSessionId === ownerSessionId; +} + // All instances in this process share the read/modify/write queue for a path. const pendingWrites = new Map>(); @@ -42,6 +83,33 @@ export class ContextMetadataStore { } } + /** + * Every Context a caller may list, select or promote — group-isolated and + * draft rows are excluded so call sites never re-test the invariant. + */ + async listReusable(): Promise { + const local = await this.read(); + return { + ...local, + contexts: Object.fromEntries( + Object.entries(local.contexts).filter( + ([, row]) => row.audience !== "group" && row.status !== "draft", + ), + ), + }; + } + + /** + * The row for `id`, throwing when it is group-isolated — guards that must + * never proceed against a foreign group task's Context share this check. + */ + async assertReusable(id: string): Promise { + const row = (await this.read()).contexts[id]; + if (row?.audience === "group") + throw new Error("This Context belongs to an isolated group task."); + return row; + } + async add(id: string, name: string, now = new Date()): Promise { await this.update((current) => { const at = now.toISOString(); @@ -56,6 +124,7 @@ export class ContextMetadataStore { ownerSessionId: string, expiresAt: Date, now = new Date(), + isolation?: { ownerId?: string; audience?: "direct" | "group" }, ): Promise { await this.update((current) => { const at = now.toISOString(); @@ -66,6 +135,8 @@ export class ContextMetadataStore { status: "draft", ownerSessionId, expiresAt: expiresAt.toISOString(), + ...(isolation?.ownerId ? { ownerId: isolation.ownerId } : {}), + ...(isolation?.audience ? { audience: isolation.audience } : {}), }; }); } @@ -108,6 +179,50 @@ export class ContextMetadataStore { }); } + /** The recorded owner of a group browser task, when one was bound. */ + async groupBrowserTask( + sessionId: string, + ): Promise[string] | undefined> { + if (!existsSync(this.filePath)) return undefined; + return (await this.read()).groupBrowserTasks?.[sessionId]; + } + + /** + * Binds (or refreshes) the immutable owner of a group browser task. The + * original boundAt is preserved; ownerId, bindingId and membershipVersion + * update only when new values are supplied. + */ + async bindGroupBrowserTask( + sessionId: string, + fields: { ownerId: string; bindingId?: string; membershipVersion?: number }, + now = new Date(), + ): Promise { + await this.update((current) => { + current.groupBrowserTasks ??= {}; + const existing = current.groupBrowserTasks[sessionId]; + const at = now.toISOString(); + current.groupBrowserTasks[sessionId] = { + ownerId: fields.ownerId, + audience: "group", + bindingId: fields.bindingId ?? existing?.bindingId, + membershipVersion: fields.membershipVersion ?? existing?.membershipVersion, + boundAt: existing?.boundAt ?? at, + updatedAt: at, + }; + }); + } + + /** + * Releases the binding when a group browser task closes so a later task can + * bind an explicitly verified owner again (a new task/session generation). + */ + async unbindGroupBrowserTask(sessionId: string): Promise { + await this.update((current) => { + if (!current.groupBrowserTasks?.[sessionId]) return false; + delete current.groupBrowserTasks[sessionId]; + }); + } + private async update(mutate: (value: ContextMetadataFile) => void | false): Promise { const operation = (pendingWrites.get(this.filePath) ?? Promise.resolve()).then(async () => { const current = await this.read(); diff --git a/server/link/oauth.ts b/server/link/oauth.ts index 15e0701..1f21ab8 100644 --- a/server/link/oauth.ts +++ b/server/link/oauth.ts @@ -5,6 +5,7 @@ import { type ConnectionPrincipal, } from "eve/connections"; import { z } from "zod"; +import { isVerifiedPhotonPrincipal } from "../photon/principal"; import { hash, linkConfig, LinkSetupError, seal, unseal, type LinkConfig } from "./config"; import { createLinkStore, ownerKey, type Grant, type LinkOwner, type LinkStore, type OwnerStore } from "./store"; @@ -25,8 +26,10 @@ function failed(reason: string): never { } export function linkOwner(principal: ConnectionPrincipal): LinkOwner { - if (principal.type !== "user" || principal.issuer !== "linq" - || !principal.id.startsWith("linq:") || principal.id.length <= 5 || principal.id.length > 512) + // Photon principals are `photon:` + sha256 of the verified canonical handle + // tuple. Grants persisted under retired provider owners stay orphaned: every + // wallet requires fresh consent under the new owner identity. + if (!isVerifiedPhotonPrincipal(principal)) return failed("principal_required"); return { issuer: principal.issuer, id: principal.id }; } diff --git a/server/link/sandbox.ts b/server/link/sandbox.ts index 78c73a5..19aac7b 100644 --- a/server/link/sandbox.ts +++ b/server/link/sandbox.ts @@ -1,14 +1,16 @@ import { createHash, randomUUID } from "node:crypto"; import type { ConnectionPrincipal } from "eve/connections"; +import { isVerifiedPhotonPrincipal } from "../photon/principal"; + const requests = new Set(); const run = randomUUID(); export const isLinkSandbox = () => process.env.LINK_SANDBOX === "1"; export function sandboxToken(principal: ConnectionPrincipal): { token: string } { const token = process.env.LINK_SANDBOX_ACCESS_TOKEN?.trim(); - if (principal.type !== "user" || principal.issuer !== "linq" - || !principal.id.startsWith("linq:") || principal.id !== process.env.LINK_SANDBOX_PRINCIPAL_ID?.trim()) + if (!isVerifiedPhotonPrincipal(principal) + || principal.id !== process.env.LINK_SANDBOX_PRINCIPAL_ID?.trim()) throw new Error("This sender is not the configured Link sandbox operator."); if (!token) throw new Error("Inject LINK_SANDBOX_ACCESS_TOKEN into the sandbox process."); return { token }; diff --git a/server/onepassword/config.ts b/server/onepassword/config.ts index 2e05981..090afa0 100644 --- a/server/onepassword/config.ts +++ b/server/onepassword/config.ts @@ -1,6 +1,8 @@ import { ConnectionAuthorizationFailedError, type ConnectionPrincipal } from "eve/connections"; import { z } from "zod"; +import { isVerifiedPhotonPrincipal } from "../photon/principal"; + export const scopes = "brokered-credentials:request-access brokered-credentials:read"; export interface Config { origin: string; redirectUri: string; clientId: string; clientSecret: string; databaseUrl: string; encryptionKey: Buffer } export function failed(reason: string): never { @@ -11,7 +13,8 @@ export function requirePersonalConnections(): void { if (!personalConnectionsEnabled()) failed("personal_1password_disabled"); } export function ownerOf(principal: ConnectionPrincipal): string { - if (principal.type !== "user" || principal.issuer !== "linq" || !/^linq:.{1,507}$/.test(principal.id)) return failed("authenticated_sender_required"); + // Retired provider owner rows stay orphaned; consent is fresh per Photon principal. + if (!isVerifiedPhotonPrincipal(principal)) return failed("authenticated_sender_required"); return JSON.stringify([principal.issuer, principal.id]); } export function configuration(): Config { diff --git a/server/photon/client.ts b/server/photon/client.ts new file mode 100644 index 0000000..d577da0 --- /dev/null +++ b/server/photon/client.ts @@ -0,0 +1,150 @@ +import { createGrpcClient, type AdvancedIMessage } from "@photon-ai/advanced-imessage/grpc"; +import { z } from "zod"; + +import { photonTokenUrl, type PhotonConfig } from "./config.js"; +import { sourceIdFor } from "./identity.js"; + +/** + * Managed credential discovery and gRPC client lifecycle for the pinned + * Advanced iMessage SDK. Project credentials mint short-lived managed line + * credentials; the project secret is never used as a line bearer token. + */ + +export class PhotonProviderError extends Error { + readonly code: string; + readonly retryable: boolean; + constructor(code: string, message: string, retryable = false) { + super(message); + this.name = "PhotonProviderError"; + this.code = code; + this.retryable = retryable; + } +} + +export interface LineCredentials { + /** Provider instance / account scope for the enrolled source. */ + readonly instanceId: string; + /** Actual dedicated phone number in E.164. */ + readonly phoneNumber: string; + /** gRPC endpoint for this instance, e.g. `.imsg.photon.codes:443`. */ + readonly endpoint: string; + /** Bearer token for this instance only. */ + readonly token: string; + /** When the managed credentials expire (ms since epoch). */ + readonly expiresAt: number; +} + +const tokenResponseSchema = z.object({ + auth: z.record(z.string(), z.string()).optional(), + numbers: z.record(z.string(), z.array(z.string())).optional(), + token: z.string().optional(), + expiresIn: z.number().int().positive().max(365 * 24 * 60 * 60), +}).strict(); + +const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]); + +/** + * Fetches managed line credentials from the Stable cloud token endpoint and + * selects the instance that serves the configured dedicated line. Fails + * closed when only an incompatible shared assignment is available — a + * discovered shared number is never substituted for the configured line. + */ +export async function discoverLineCredentials( + config: PhotonConfig, + fetcher: typeof fetch = fetch, +): Promise { + const url = photonTokenUrl(config.projectId); + let response: Response; + try { + response = await fetcher(url, { + method: "POST", + signal: AbortSignal.timeout(15_000), + headers: { + authorization: `Basic ${Buffer.from(`${config.projectId}:${config.projectSecret}`, "utf8").toString("base64")}`, + "content-type": "application/json", + }, + body: "{}", + }); + } catch (error) { + throw new PhotonProviderError( + "token_request_failed", + `Managed iMessage credential request failed: ${error instanceof Error ? error.message : String(error)}`, + true, + ); + } + if (!response.ok) { + throw new PhotonProviderError( + response.status === 401 || response.status === 403 ? "token_unauthorized" : "token_rejected", + `Managed iMessage credential request rejected with status ${response.status}.`, + RETRYABLE_STATUS.has(response.status), + ); + } + const parsed = tokenResponseSchema.safeParse(await response.json().catch(() => null)); + if (!parsed.success) { + throw new PhotonProviderError("token_response_invalid", "Managed iMessage credential response had an unexpected shape."); + } + const body = parsed.data; + if (!body.auth || !body.numbers) { + throw new PhotonProviderError( + "dedicated_line_required", + "The project returned a shared-line credential. A dedicated line is required for this channel.", + ); + } + const matches = Object.entries(body.numbers).filter( + ([instanceId, numbers]) => numbers.includes(config.phoneNumber) && typeof body.auth?.[instanceId] === "string", + ); + if (matches.length !== 1) { + throw new PhotonProviderError( + "configured_line_unavailable", + `No discovered instance serves the configured dedicated line ${config.phoneNumber}; found ${matches.length} candidates.`, + ); + } + const [instanceId] = matches[0]; + return { + instanceId, + phoneNumber: config.phoneNumber, + endpoint: `${instanceId}.imsg.photon.codes:443`, + token: body.auth[instanceId], + expiresAt: Date.now() + body.expiresIn * 1_000, + }; +} + +export interface PhotonConnection { + readonly client: AdvancedIMessage; + readonly credentials: LineCredentials; + /** Stable enrolled-source identity for this credential set. */ + readonly sourceId: string; +} + +/** + * Opens the gRPC client for one credential set. The token getter is a live + * closure so the SDK's per-RPC token reads always see the freshest managed + * credential; the worker replaces the whole connection on renewal instead. + */ +export function connectPhoton( + credentials: LineCredentials, + config: PhotonConfig, + tokenRef: { current: string } = { current: credentials.token }, +): PhotonConnection { + const client = createGrpcClient({ + address: credentials.endpoint, + token: async () => tokenRef.current, + tls: true, + // Bell assigns explicit idempotency keys; a random per-call header would + // duplicate visible sends on application-level retries. + autoIdempotency: false, + retry: { maxAttempts: 3, initialDelay: 250, maxDelay: 5_000 }, + timeout: 20_000, + channelOptions: { + "grpc.max_send_message_length": 100 * 1024 * 1024, + "grpc.max_receive_message_length": 100 * 1024 * 1024, + "grpc.keepalive_time_ms": 20_000, + "grpc.keepalive_timeout_ms": 10_000, + }, + }); + return { + client, + credentials, + sourceId: sourceIdFor(config.projectId, credentials.instanceId, credentials.phoneNumber), + }; +} diff --git a/server/photon/commands/conversations.ts b/server/photon/commands/conversations.ts new file mode 100644 index 0000000..ac6ea81 --- /dev/null +++ b/server/photon/commands/conversations.ts @@ -0,0 +1,230 @@ +import { + IMessageError, + type AdvancedIMessage, + type BackgroundPreset, + type Chat, +} from "@photon-ai/advanced-imessage/grpc"; + +import type { + PhotonCommand, + PhotonCommandDeps, + PhotonCommandExecutor, + PhotonCommandOp, +} from "../contracts.js"; +import { CommandValidationError, commandProviderKey, executeOp, mapEffect } from "./shared.js"; +import { handleKey, normalizeHandle } from "../identity.js"; +import type { PhotonDb } from "../store.js"; + +/** + * chats.* and groups.* executors. There is deliberately no archive/unarchive + * write: the provider exposes those as inbound observations only. Group + * mutations use native group GUIDs and the line's own permissions. + */ + +async function persistChat(store: PhotonDb, sourceId: string, chat: Chat): Promise { + await store.upsertResource(sourceId, "chat", chat.guid, { + displayName: chat.displayName, + groupId: chat.groupId ?? null, + isArchived: chat.isArchived, + isGroup: chat.isGroup, + participants: chat.participants.map((participant) => { + const handle = normalizeHandle(participant.address); + return { + address: participant.address, + key: handle ? handleKey(handle) : null, + service: participant.service, + }; + }), + service: chat.service, + snapshotAt: new Date().toISOString(), + }); +} + +/** Group-icon uploads are bitmap formats Messages accepts. */ +const ICON_MEDIA_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/heic", "image/heif"]); + +async function requireArtifact( + deps: PhotonCommandDeps, + artifactId: string, +): Promise<{ data: Uint8Array; fileName?: string; mediaType: string }> { + const artifact = await deps.artifacts.get(artifactId); + if (!artifact) + throw new CommandValidationError("artifact_not_found", `Artifact ${artifactId} does not exist.`); + return artifact; +} + +/** + * Unkeyed mutations reconcile against `chats.get`: a landed change is + * detected by desired state, an absent one is safe to retry, and a chat that + * cannot be read stays ambiguous. + */ +function chatReconcile( + check: (chat: Chat) => boolean, +): (command: PhotonCommand, deps: PhotonCommandDeps) => Promise { + return async (command, deps) => { + const client = deps.client.raw as AdvancedIMessage; + try { + return check(await client.chats.get(command.chatGuid)); + } catch { + return undefined; + } + }; +} + +async function hasBackgroundReconcile( + command: PhotonCommand, + deps: PhotonCommandDeps, + expected: boolean, +): Promise { + const client = deps.client.raw as AdvancedIMessage; + try { + return (await client.chats.hasBackground(command.chatGuid)) === expected; + } catch { + return undefined; + } +} + +export function conversationExecutors( + store: PhotonDb, +): Partial> { + return { + "chats.create": executeOp("chats.create", async (client, args, command) => { + const created = await client.chats.create([...args.addresses], { + clientMessageId: commandProviderKey(command), + effect: mapEffect(args.effect), + message: args.message, + subject: args.subject, + }); + await persistChat(store, command.sourceId, created.chat); + return { + result: { chat: created.chat, initialMessageGuid: created.initialMessage?.guid }, + providerReceipt: { chatGuid: created.chat.guid }, + }; + }), + + "chats.get": executeOp("chats.get", async (client, args, command) => { + const chat = await client.chats.get(args.chat); + await persistChat(store, command.sourceId, chat); + return { result: chat }; + }), + + "chats.count": executeOp("chats.count", async (client, args) => { + const count = await client.chats.count({ includeArchived: args.includeArchived }); + return { result: { count } }; + }), + + // Unkeyed: unreadCount is the provider's own desired-state check, so a + // landed markRead reconciles to acknowledged instead of duplicating. + "chats.markRead": executeOp("chats.markRead", async (client, args) => { + await client.chats.markRead(args.chat); + return { result: { marked: true } }; + }, chatReconcile((chat) => chat.unreadCount === 0)), + + // Typing state is ephemeral by definition: an uncertain outcome has no + // durable effect to reconcile, so it settles as applied. + "chats.setTyping": executeOp("chats.setTyping", async (client, args) => { + await client.chats.setTyping(args.chat, args.isTyping); + return { result: { isTyping: args.isTyping } }; + }, () => Promise.resolve(true)), + + // No read verifies the shared contact card; uncertainty stays ambiguous. + "chats.shareContactInfo": executeOp("chats.shareContactInfo", async (client, args) => { + await client.chats.shareContactInfo(args.chat); + return { result: { shared: true } }; + }), + + "chats.hasBackground": executeOp("chats.hasBackground", async (client, args) => { + const hasBackground = await client.chats.hasBackground(args.chat); + return { result: { hasBackground } }; + }), + + "chats.setBackground": executeOp("chats.setBackground", async (client, args, _command, deps) => { + let background: Uint8Array | BackgroundPreset; + if (args.preset !== undefined) { + background = args.preset; + } else if (args.artifactId !== undefined) { + const artifact = await requireArtifact(deps, args.artifactId); + if (!artifact.mediaType.toLowerCase().startsWith("image/")) + throw new CommandValidationError("invalid_background", "Chat background artifact must be an image."); + background = artifact.data; + } else { + throw new CommandValidationError("invalid_args", "Exactly one of preset or artifactId is required."); + } + await client.chats.setBackground(args.chat, background); + return { result: { set: true } }; + }, (command, deps) => hasBackgroundReconcile(command, deps, true)), + + "chats.removeBackground": executeOp("chats.removeBackground", async (client, args) => { + await client.chats.removeBackground(args.chat); + return { result: { removed: true } }; + }, (command, deps) => hasBackgroundReconcile(command, deps, false)), + + "groups.setDisplayName": executeOp("groups.setDisplayName", async (client, args, command) => { + const chat = await client.groups.setDisplayName(args.chat, args.name, { + clientMessageId: commandProviderKey(command), + }); + await persistChat(store, command.sourceId, chat); + return { result: chat }; + }), + + "groups.addParticipants": executeOp("groups.addParticipants", async (client, args, command) => { + const chat = await client.groups.addParticipants(args.chat, [...args.addresses], { + clientMessageId: commandProviderKey(command), + }); + await persistChat(store, command.sourceId, chat); + return { result: chat }; + }), + + "groups.removeParticipants": executeOp("groups.removeParticipants", async (client, args, command) => { + const chat = await client.groups.removeParticipants(args.chat, [...args.addresses], { + clientMessageId: commandProviderKey(command), + }); + await persistChat(store, command.sourceId, chat); + return { result: chat }; + }), + + "groups.leave": executeOp("groups.leave", async (client, args, command) => { + await client.groups.leave(args.chat, { clientMessageId: commandProviderKey(command) }); + // Leaving retires this binding's inbound scope; a later generation is + // only admitted through a fresh explicit invite. + const binding = await store.getConversation(command.sourceId, args.chat); + if (binding) await store.retireConversation(binding.bindingId); + return { result: { left: true } }; + }), + + "groups.setIcon": executeOp("groups.setIcon", async (client, args, command, deps) => { + const artifact = await requireArtifact(deps, args.artifactId); + if (!ICON_MEDIA_TYPES.has(artifact.mediaType.toLowerCase())) + throw new CommandValidationError( + "invalid_icon", + `Group icon must be PNG, JPEG, GIF, HEIC or HEIF; got ${artifact.mediaType}.`, + ); + await client.groups.setIcon(args.chat, artifact.data, { + clientMessageId: commandProviderKey(command), + }); + return { result: { set: true } }; + }), + + "groups.getIcon": executeOp("groups.getIcon", async (client, args, _command, deps) => { + try { + const icon = await client.groups.getIcon(args.chat); + const artifactId = await deps.artifacts.put({ + chatGuid: args.chat, + fileName: `group-icon-${args.chat.slice(-12)}`, + mediaType: icon.mimeType, + data: icon.data, + }); + return { result: { artifactId, mimeType: icon.mimeType, byteSize: icon.data.byteLength } }; + } catch (error) { + if (error instanceof IMessageError && error.code === "groupIconNotFound") + return { result: { icon: null } }; + throw error; + } + }), + + "groups.removeIcon": executeOp("groups.removeIcon", async (client, args, command) => { + await client.groups.removeIcon(args.chat, { clientMessageId: commandProviderKey(command) }); + return { result: { removed: true } }; + }), + }; +} diff --git a/server/photon/commands/interactions.ts b/server/photon/commands/interactions.ts new file mode 100644 index 0000000..7f482da --- /dev/null +++ b/server/photon/commands/interactions.ts @@ -0,0 +1,88 @@ +import type { MiniAppCardSession, Poll } from "@photon-ai/advanced-imessage/grpc"; + +import type { PhotonCommandExecutor, PhotonCommandOp } from "../contracts.js"; +import type { PhotonDb } from "../store.js"; +import { CommandValidationError, commandProviderKey, executeOp } from "./shared.js"; + +/** + * Interaction executors: native polls and customized mini-app card sessions. + * Poll snapshots are persisted verbatim — a genuinely titleless poll keeps an + * empty title and duplicate option labels keep their distinct option + * identifiers, matching the provider's own model. + */ + +function persistPoll(store: PhotonDb, sourceId: string, poll: Poll): Promise { + return store.upsertResource(sourceId, "poll", poll.pollMessageGuid, { poll }); +} + +/** + * Resolves the authoritative card session for a mini-app update. The stored + * session wins over caller-supplied fields because it is the snapshot Bell + * persisted at send time; either way the session's chat must match the + * command's authorized chat scope. + */ +export async function resolveMiniAppSession( + store: PhotonDb, + sourceId: string, + provided: MiniAppCardSession, + chatGuid: string, +): Promise { + if (provided.chatGuid !== chatGuid) + throw new CommandValidationError( + "session_scope", + "Mini-app session belongs to a different chat than this command's scope.", + ); + const stored = await store.getResource(sourceId, "miniapp_session", provided.sessionId); + const session = (stored?.data as { session?: MiniAppCardSession } | undefined)?.session; + if (!session) return provided; + if (session.chatGuid !== chatGuid) + throw new CommandValidationError( + "session_scope", + "Stored mini-app session belongs to a different chat than this command's scope.", + ); + return session; +} + +export function interactionExecutors( + store: PhotonDb, +): Partial> { + return { + "polls.create": executeOp("polls.create", async (client, args, command) => { + const poll = await client.polls.create(args.chat, args.title, [...args.choices], { + clientMessageId: commandProviderKey(command), + }); + await persistPoll(store, command.sourceId, poll); + return { result: poll, providerReceipt: { pollMessageGuid: poll.pollMessageGuid } }; + }), + + "polls.get": executeOp("polls.get", async (client, args, command) => { + const poll = await client.polls.get(args.pollMessageGuid); + await persistPoll(store, command.sourceId, poll); + return { result: poll }; + }), + + "polls.vote": executeOp("polls.vote", async (client, args, command) => { + const poll = await client.polls.vote(args.pollMessageGuid, args.optionIdentifier, { + clientMessageId: commandProviderKey(command), + }); + await persistPoll(store, command.sourceId, poll); + return { result: poll }; + }), + + "polls.unvote": executeOp("polls.unvote", async (client, args, command) => { + const poll = await client.polls.unvote(args.pollMessageGuid, { + clientMessageId: commandProviderKey(command), + }); + await persistPoll(store, command.sourceId, poll); + return { result: poll }; + }), + + "polls.addOption": executeOp("polls.addOption", async (client, args, command) => { + const poll = await client.polls.addOption(args.pollMessageGuid, args.text, { + clientMessageId: commandProviderKey(command), + }); + await persistPoll(store, command.sourceId, poll); + return { result: poll }; + }), + }; +} diff --git a/server/photon/commands/locations.ts b/server/photon/commands/locations.ts new file mode 100644 index 0000000..f2df540 --- /dev/null +++ b/server/photon/commands/locations.ts @@ -0,0 +1,84 @@ +import { IMessageError } from "@photon-ai/advanced-imessage/grpc"; + +import type { PhotonCommandExecutor, PhotonCommandOp } from "../contracts.js"; +import { CommandValidationError, commandProviderKey, executeOp } from "./shared.js"; + +/** + * addresses.* and locations.* executors. Location request and watch are + * explicit-consent operations: both require a verified principal in direct + * audience because they disclose the managed line's own position or start a + * live subscription. + */ + +export interface LocationWatchControl { + setLocationWatch(address: string | undefined, start: boolean): Promise; +} + +export function locationExecutors( + watches: LocationWatchControl, +): Partial> { + const requireDirectPrincipal = (audience: string | undefined, principal: string | undefined): void => { + if (audience !== "direct" || !principal) + throw new CommandValidationError( + "consent_required", + "Location request/watch requires a verified principal in a direct conversation.", + ); + }; + + return { + "addresses.get": executeOp("addresses.get", async (client, args) => { + try { + const info = await client.addresses.get(args.address); + return { result: { found: true, info } }; + } catch (error) { + if (error instanceof IMessageError && error.code === "addressNotFound") + return { result: { found: false, address: args.address } }; + throw error; + } + }), + + "addresses.isIMessageAvailable": executeOp("addresses.isIMessageAvailable", async (client, args) => { + const available = await client.addresses.isIMessageAvailable(args.address); + return { result: { address: args.address, available } }; + }), + + "addresses.isFocusSilenced": executeOp("addresses.isFocusSilenced", async (client, args) => { + const silenced = await client.addresses.isFocusSilenced(args.address); + return { result: { address: args.address, silenced } }; + }), + + "locations.request": executeOp("locations.request", async (client, args, command) => { + requireDirectPrincipal(command.audience, command.initiatorPrincipal); + const receipt = await client.locations.request(args.chat, args.address, { + clientMessageId: commandProviderKey(command), + }); + return { result: receipt, providerReceipt: receipt }; + }), + + "locations.list": executeOp("locations.list", async (client) => { + const locations = await client.locations.list(); + return { result: { locations } }; + }), + + "locations.get": executeOp("locations.get", async (client, args) => { + try { + const location = await client.locations.get(args.address); + return { result: { found: true, location } }; + } catch (error) { + if (error instanceof IMessageError && error.code === "sharedFriendLocationNotFound") + return { result: { found: false, address: args.address } }; + throw error; + } + }), + + // Bell-scoped watch control: starts/stops our subscription only. Stopping + // never touches the person's Apple-side Find My sharing. + "locations.watch": executeOp("locations.watch", async (_client, args, command) => { + requireDirectPrincipal(command.audience, command.initiatorPrincipal); + const changed = await watches.setLocationWatch(args.address, args.action === "start"); + if (args.action === "start" && !changed) + throw new CommandValidationError("watch_limit", "Too many active location watches."); + return { result: { address: args.address ?? null, watching: args.action === "start" } }; + }), + }; +} diff --git a/server/photon/commands/media.ts b/server/photon/commands/media.ts new file mode 100644 index 0000000..6fb2a2d --- /dev/null +++ b/server/photon/commands/media.ts @@ -0,0 +1,37 @@ +import type { + PhotonCommandExecutor, + PhotonCommandOp, +} from "../contracts.js"; +import type { PhotonDb } from "../store.js"; +import { uploadArtifact } from "../media.js"; +import { executeOp } from "./shared.js"; + +/** + * attachments.* executors. Uploads are retry-safe despite carrying no + * provider key: a duplicated attempt only orphans an unused provider-side + * GUID, which the reconcile hook proves safe to repeat. + */ +export function mediaExecutors( + store: PhotonDb, +): Partial> { + return { + "attachments.upload": executeOp("attachments.upload", async (client, args, command) => { + const uploaded = await uploadArtifact(client, store, command.sourceId, { + artifactId: args.artifactId, + companionArtifactId: args.companionArtifactId, + fileName: args.fileName, + }); + return { + result: uploaded, + providerReceipt: { attachmentGuid: uploaded.attachmentGuid }, + }; + }, // A retried upload cannot duplicate a visible send — only an orphan GUID. + () => Promise.resolve(false)), + + "attachments.get": executeOp("attachments.get", async (client, args, command) => { + const info = await client.attachments.get(args.attachmentGuid); + await store.upsertResource(command.sourceId, "attachment", info.guid, { info }); + return { result: info }; + }), + }; +} diff --git a/server/photon/commands/messages.ts b/server/photon/commands/messages.ts new file mode 100644 index 0000000..6ec1690 --- /dev/null +++ b/server/photon/commands/messages.ts @@ -0,0 +1,272 @@ +import type { + CustomizedMiniAppMessage, + Message, + MessageListFilter, + MiniAppCardSession, +} from "@photon-ai/advanced-imessage/grpc"; + +import type { + PhotonCommandDeps, + PhotonCommandOp, + PhotonCommandExecutor, +} from "../contracts.js"; +import type { PhotonDb } from "../store.js"; +import { resolveMiniAppSession } from "./interactions.js"; +import { + CommandValidationError, + commandProviderKey, + executeOp, + mapEffect, + mapFormatting, + mapParts, + mapReaction, +} from "./shared.js"; + +/** + * messages.* executors: sends, edits, unsends, reactions, stickers, Notify + * Anyway, mini-app cards, history reads and embedded media. Every keyed + * write carries the command's deterministic provider key. + */ + +function messageReceipt(message: Message | undefined): Record | undefined { + return message ? { messageGuid: message.guid, chatGuids: [...message.chatGuids] } : undefined; +} + +async function persistSentMessage(store: PhotonDb, sourceId: string, message: Message): Promise { + await store.upsertResource(sourceId, "message", message.guid, { + chatGuids: [...message.chatGuids], + sentByBell: true, + subject: message.subject ?? null, + partCount: message.partCount ?? null, + sentAt: new Date().toISOString(), + }); +} + +async function sentMessageResult(store: PhotonDb, sourceId: string, message: Message) { + await persistSentMessage(store, sourceId, message); + return { result: message, providerReceipt: messageReceipt(message) }; +} + +async function resolveMiniAppLayoutImage( + deps: PhotonCommandDeps, + imageArtifactId: string | undefined, +): Promise { + if (imageArtifactId === undefined) return undefined; + const artifact = await deps.artifacts.get(imageArtifactId); + if (!artifact) + throw new CommandValidationError("artifact_not_found", `Layout image artifact ${imageArtifactId} does not exist.`); + if (artifact.mediaType.toLowerCase() !== "image/jpeg") + throw new CommandValidationError("invalid_layout", "Mini-app card images must be JPEG."); + return artifact.data; +} + +function listFilter(filter: { + after?: string; before?: string; isFromMe?: boolean; isRead?: boolean; + pageSize?: number; pageToken?: string; +} | undefined): MessageListFilter | undefined { + if (!filter) return undefined; + return { + after: filter.after ? new Date(filter.after) : undefined, + before: filter.before ? new Date(filter.before) : undefined, + isFromMe: filter.isFromMe, + isRead: filter.isRead, + pageSize: filter.pageSize, + pageToken: filter.pageToken, + }; +} + +function miniAppMessage(input: { + appName: string; appStoreId?: number; extensionBundleId: string; teamId: string; + url: string; live?: boolean; + layout: { + caption?: string; subcaption?: string; trailingCaption?: string; + trailingSubcaption?: string; image?: string; imageTitle?: string; + imageSubtitle?: string; summary?: string; + }; +}, image: Uint8Array | undefined): CustomizedMiniAppMessage { + const layout = input.layout; + const hasPrimaryText = !!(layout.caption || layout.subcaption || layout.trailingCaption || layout.trailingSubcaption); + if (!hasPrimaryText && image === undefined) + throw new CommandValidationError( + "invalid_layout", + "A mini-app card needs at least one primary text slot or an image.", + ); + return { + appName: input.appName, + appStoreId: input.appStoreId, + extensionBundleId: input.extensionBundleId, + teamId: input.teamId, + url: input.url, + live: input.live, + layout: { + caption: layout.caption, + subcaption: layout.subcaption, + trailingCaption: layout.trailingCaption, + trailingSubcaption: layout.trailingSubcaption, + image, + imageTitle: layout.imageTitle, + imageSubtitle: layout.imageSubtitle, + summary: layout.summary, + }, + }; +} + +async function persistMiniAppSession( + store: PhotonDb, + sourceId: string, + session: MiniAppCardSession, +): Promise { + const updatedAt = new Date().toISOString(); + await store.upsertResource(sourceId, "miniapp_session", session.sessionId, { + session, updatedAt, + }); + // The tool layer references cards by the guids the model sees — the sent + // card message and the bubble updates replace — so index both back to the + // authoritative session record. + for (const messageGuid of new Set([session.messageGuid, session.targetMessageGuid])) + await store.upsertResource(sourceId, "miniapp_session_by_message", messageGuid, { + sessionId: session.sessionId, + }); +} + +export function messageExecutors( + store: PhotonDb, +): Partial> { + return { + "messages.sendText": executeOp("messages.sendText", async (client, args, command) => { + const message = await client.messages.sendText(args.chat, args.text, { + clientMessageId: commandProviderKey(command), + effect: mapEffect(args.effect), + enableDataDetection: args.enableDataDetection, + enableLinkPreview: args.enableLinkPreview, + formatting: mapFormatting(args.formatting), + replyTo: args.replyTo, + subject: args.subject, + }); + return sentMessageResult(store, command.sourceId, message); + }), + + "messages.sendAttachment": executeOp("messages.sendAttachment", async (client, args, command) => { + const message = await client.messages.sendAttachment(args.chat, args.attachmentGuid, { + clientMessageId: commandProviderKey(command), + effect: mapEffect(args.effect), + isAudioMessage: args.isAudioMessage, + replyTo: args.replyTo, + }); + return sentMessageResult(store, command.sourceId, message); + }), + + "messages.sendMultipart": executeOp("messages.sendMultipart", async (client, args, command) => { + const message = await client.messages.sendMultipart(args.chat, mapParts(args.parts), { + clientMessageId: commandProviderKey(command), + effect: mapEffect(args.effect), + replyTo: args.replyTo, + subject: args.subject, + enableDataDetection: args.enableDataDetection, + }); + return sentMessageResult(store, command.sourceId, message); + }), + + "messages.sendCustomizedMiniApp": executeOp("messages.sendCustomizedMiniApp", async (client, args, command, deps) => { + const image = await resolveMiniAppLayoutImage(deps, args.message.layout.image); + const result = await client.messages.sendCustomizedMiniApp( + args.chat, + miniAppMessage(args.message, image), + { clientMessageId: commandProviderKey(command) }, + ); + await persistMiniAppSession(store, command.sourceId, result.miniAppCardSession); + await persistSentMessage(store, command.sourceId, result); + return { + result: { message: result, miniAppCardSession: result.miniAppCardSession }, + providerReceipt: { messageGuid: result.guid, sessionId: result.miniAppCardSession.sessionId }, + }; + }), + + "messages.updateCustomizedMiniApp": executeOp("messages.updateCustomizedMiniApp", async (client, args, command, deps) => { + const session = await resolveMiniAppSession(store, command.sourceId, args.session, command.chatGuid); + const image = await resolveMiniAppLayoutImage(deps, args.message.layout.image); + const result = await client.messages.updateCustomizedMiniApp( + session, + miniAppMessage(args.message, image), + { clientMessageId: commandProviderKey(command) }, + ); + await persistMiniAppSession(store, command.sourceId, result.miniAppCardSession); + return { + result: { message: result, miniAppCardSession: result.miniAppCardSession }, + providerReceipt: { messageGuid: result.guid, sessionId: result.miniAppCardSession.sessionId }, + }; + }), + + "messages.edit": executeOp("messages.edit", async (client, args, command) => { + const message = await client.messages.edit(args.chat, args.messageGuid, args.newText, { + clientMessageId: commandProviderKey(command), + backwardCompatText: args.backwardCompatText, + partIndex: args.partIndex, + }); + return { result: message, providerReceipt: messageReceipt(message) }; + }), + + "messages.unsend": executeOp("messages.unsend", async (client, args, command) => { + await client.messages.unsend(args.chat, args.messageGuid, { + clientMessageId: commandProviderKey(command), + partIndex: args.partIndex, + }); + await store.upsertResource(command.sourceId, "message", args.messageGuid, + { unsentAt: new Date().toISOString() }, { tombstone: true }); + return { result: { unsent: true } }; + }), + + "messages.setReaction": executeOp("messages.setReaction", async (client, args, command) => { + const message = await client.messages.setReaction( + args.chat, args.messageGuid, mapReaction(args.reaction), args.isSet, + { clientMessageId: commandProviderKey(command), partIndex: args.partIndex }, + ); + return { result: message, providerReceipt: messageReceipt(message) }; + }), + + "messages.placeSticker": executeOp("messages.placeSticker", async (client, args, command) => { + const message = await client.messages.placeSticker( + args.chat, args.messageGuid, args.stickerAttachmentGuid, args.placement, + { clientMessageId: commandProviderKey(command), partIndex: args.partIndex }, + ); + return { result: message, providerReceipt: messageReceipt(message) }; + }), + + "messages.notifySilenced": executeOp("messages.notifySilenced", async (client, args, command) => { + await client.messages.notifySilenced(args.chat, args.messageGuid, { + clientMessageId: commandProviderKey(command), + }); + return { result: { notified: true } }; + }), + + "messages.get": executeOp("messages.get", async (client, args, command) => { + const message = await client.messages.get(args.messageGuid); + await store.upsertResource(command.sourceId, "message", message.guid, { snapshot: message }); + return { result: message }; + }), + + "messages.listInChat": executeOp("messages.listInChat", async (client, args) => { + const page = await client.messages.listInChat(args.chat, listFilter(args.filter)); + return { result: page }; + }), + + "messages.listRecent": executeOp("messages.listRecent", async (client, args) => { + const page = await client.messages.listRecent(listFilter(args.filter)); + return { result: page }; + }), + + "messages.getEmbeddedMedia": executeOp("messages.getEmbeddedMedia", async (client, args, command, deps) => { + const media = await client.messages.getEmbeddedMedia(args.chat, args.messageGuid); + const artifactId = await deps.artifacts.put({ + chatGuid: args.chat, + fileName: `embedded-${args.messageGuid}`, + mediaType: media.mimeType, + data: media.data, + }); + await store.upsertResource(command.sourceId, "attachment", `embedded:${args.messageGuid}`, { + artifactId, mediaType: media.mimeType, messageGuid: args.messageGuid, + }); + return { result: { artifactId, mediaType: media.mimeType, byteSize: media.data.byteLength } }; + }), + }; +} diff --git a/server/photon/commands/shared.ts b/server/photon/commands/shared.ts new file mode 100644 index 0000000..59f7b21 --- /dev/null +++ b/server/photon/commands/shared.ts @@ -0,0 +1,197 @@ +import { + IMessageError, + MessageEffect, + type AdvancedIMessage, + type MessagePart, + type SettableMessageReaction, + type TextEffect, + type TextFormatInput, +} from "@photon-ai/advanced-imessage/grpc"; +import type { z } from "zod"; + +import { + KEYED_OPERATIONS, + commandArgsSchemas, + isReadOperation, + type CommandExecution, + type PhotonCommand, + type PhotonCommandArgs, + type PhotonCommandDeps, + type PhotonCommandExecutor, + type PhotonCommandOp, + type PhotonCommandResult, + type effectSchema, + type messagePartSchema, + type settableReactionSchema, + type textEffectSchema, + type textFormatSchema, +} from "../contracts.js"; +import { providerKeyFor } from "../identity.js"; + +/** + * Shared executor plumbing: SDK client unwrap, deterministic provider keys, + * option mapping, and the retry/ambiguity classifier every operation uses. + */ + +export function sdkClient(deps: PhotonCommandDeps): AdvancedIMessage { + return deps.client.raw as AdvancedIMessage; +} + +/** + * The stable `clientMessageId` for this logical command. Once a key has been + * persisted on the command row it is reused verbatim — the provider dedups + * keyed writes by this value, so retries never duplicate a landed send. The + * outbox injects the persisted key into the command before execution; the + * first-attempt derivation matches `providerKeyFor(commandId, 1)`. + */ +export function commandProviderKey(command: PhotonCommand): string { + return command.providerKey ?? providerKeyFor(command.commandId, 1); +} + +/* ---------------------------- option mapping ---------------------------- */ + +const EFFECTS = MessageEffect as unknown as Record; + +export function mapEffect(effect: z.infer | undefined): MessageEffect | undefined { + return effect === undefined ? undefined : EFFECTS[effect]; +} + +export function mapReaction( + reaction: z.infer, +): SettableMessageReaction { + if (typeof reaction === "string") return { kind: reaction }; + return { kind: "emoji", emoji: reaction.customEmoji }; +} + +export function mapFormatting( + formatting: readonly (z.infer | z.infer)[] | undefined, +): TextFormatInput[] | undefined { + if (!formatting) return undefined; + const out: TextFormatInput[] = []; + for (const range of formatting) { + if ("effect" in range) { + out.push({ type: "effect", start: range.start, length: range.length, effect: range.effect as TextEffect }); + continue; + } + if (range.bold) out.push({ type: "bold", start: range.start, length: range.length }); + if (range.italic) out.push({ type: "italic", start: range.start, length: range.length }); + if (range.underline) out.push({ type: "underline", start: range.start, length: range.length }); + if (range.strikethrough) out.push({ type: "strikethrough", start: range.start, length: range.length }); + } + return out; +} + +export function mapParts(parts: readonly z.infer[]): MessagePart[] { + return parts.map((part) => ({ + text: part.text, + mentionedAddress: part.mentionedAddress, + formatting: mapFormatting(part.formatting), + attachmentGuid: part.attachmentGuid, + attachmentName: part.attachmentName, + bubbleIndex: part.bubbleIndex, + })); +} + +/* --------------------------- error classification --------------------------- */ + +/** + * Post-failure reconcile hook: reads provider state to learn whether an + * uncertain write actually landed. `true` applied, `false` proven absent + * (safe to retry), `undefined` still unknown. + */ +export type ReconcileFn = ( + command: PhotonCommand, + deps: PhotonCommandDeps, +) => Promise; + +interface OpResult { + readonly result?: PhotonCommandResult; + readonly providerReceipt?: unknown; +} + +/** + * A Bell-side validation/policy failure inside an executor — always terminal + * (`failed`), never retried and never ambiguous. + */ +export class CommandValidationError extends Error { + readonly code: string; + constructor(code: string, message: string) { + super(message); + this.name = "CommandValidationError"; + this.code = code; + } +} + +async function reconcileOrAmbiguous( + code: string, + detail: string, + command: PhotonCommand, + deps: PhotonCommandDeps, + reconcile: ReconcileFn | undefined, +): Promise> { + if (reconcile) { + const state = await reconcile(command, deps).catch(() => undefined); + // The "reconciled" marker rides in providerReceipt so the typed result + // shape is never polluted by a synthetic value no executor produced. + if (state === true) return { kind: "acknowledged", providerReceipt: { reconciled: true } }; + if (state === false) + return { kind: "retryable", errorCode: code, errorDetail: `${detail} (reconcile: not applied)` }; + } + // No safe read exists: report uncertainty rather than blind-retrying a + // possibly-applied visible side effect (PRD §8.2). + return { kind: "ambiguous", reason: detail }; +} + +async function classify( + command: PhotonCommand, + deps: PhotonCommandDeps, + op: Op, + error: unknown, + reconcile: ReconcileFn | undefined, +): Promise> { + const detail = error instanceof Error ? error.message : String(error); + const keyed = KEYED_OPERATIONS.has(op); + const read = isReadOperation(op); + + if (error instanceof CommandValidationError) + return { kind: "failed", errorCode: error.code, errorDetail: detail }; + if (error instanceof IMessageError) { + // Managed-credential expiry can be healed by the worker's refresh loop. + if (error.code === "tokenExpired" || error.code === "unauthenticated") + return { kind: "retryable", errorCode: error.code, errorDetail: detail, retryAfterMs: 5_000 }; + if (error.retryable) { + // Reads may always retry. Keyed writes retry with the same persisted + // provider key, so a landed first attempt dedups server-side. + if (read || keyed) + return { kind: "retryable", errorCode: error.code, errorDetail: detail, retryAfterMs: error.retryAfter }; + return reconcileOrAmbiguous(error.code, detail, command, deps, reconcile); + } + return { kind: "failed", errorCode: error.code, errorDetail: detail }; + } + if (read || keyed) return { kind: "retryable", errorCode: "transport_error", errorDetail: detail }; + return reconcileOrAmbiguous("transport_error", detail, command, deps, reconcile); +} + +/** + * Builds one executor: parses args, runs the SDK call, persists nothing on + * failure, and classifies errors into the outbox retry/ambiguity contract. + */ +export function executeOp( + op: Op, + run: ( + client: AdvancedIMessage, + args: PhotonCommandArgs, + command: PhotonCommand, + deps: PhotonCommandDeps, + ) => Promise>, + reconcile?: ReconcileFn, +): PhotonCommandExecutor { + return async (command, deps) => { + const args = commandArgsSchemas[op].parse(command.args) as PhotonCommandArgs; + try { + return { kind: "acknowledged", ...(await run(sdkClient(deps), args, command, deps)) }; + } catch (error) { + return classify(command, deps, op, error, reconcile); + } + }; +} diff --git a/server/photon/config.ts b/server/photon/config.ts new file mode 100644 index 0000000..5c489b9 --- /dev/null +++ b/server/photon/config.ts @@ -0,0 +1,183 @@ +import { z } from "zod"; + +export const E164_PATTERN = /^\+[1-9]\d{1,14}$/; +export const IMESSAGE_TOKEN_URL = + "https://spectrum.photon.codes/projects/{projectId}/imessage/tokens"; +export const PHOTON_DISPATCH_PATH = "/internal/photon/dispatch"; +export const PHOTON_EVENTS_PATH = "/internal/photon/events"; +export const PHOTON_HEALTH_PATH = "/internal/photon/health"; +export const PHOTON_RETIRE_PATH = "/internal/photon/retire"; + +export class PhotonConfigError extends Error { + readonly setting: string; + constructor(setting: string, message: string) { + super(`${setting}: ${message}`); + this.name = "PhotonConfigError"; + this.setting = setting; + } +} + +export interface PhotonConfig { + /** Stable Cloud project identifier (IMESSAGE_PROJECT_ID). */ + readonly projectId: string; + /** Stable Cloud project credential, server-only (IMESSAGE_PROJECT_SECRET). */ + readonly projectSecret: string; + /** Exact configured dedicated receiving number in E.164. */ + readonly phoneNumber: string; + /** Nonempty E.164 allowlist for private-operator mode. */ + readonly allowedSenders: readonly string[]; + /** Separately configured iMessage email handles; empty denies email-origin input. */ + readonly allowedEmailSenders: readonly string[]; + /** Explicit admitted group chat GUIDs; empty keeps group ingress closed. */ + readonly allowedGroups: readonly string[]; + /** Internal dispatch authentication secret (never a provider bearer token). */ + readonly internalSecret: string; + /** Fixed private/loopback Eve origin for the internal routes. */ + readonly internalOrigin: string; + /** PostgreSQL connection for the channel store. */ + readonly databaseUrl: string; + /** Public origin for authorized handoff and OAuth callbacks. */ + readonly publicUrl?: string; + /** Worker tuning, bounded and with safe defaults. */ + readonly worker: { + readonly leaseMs: number; + readonly heartbeatStaleMs: number; + readonly commandBatchSize: number; + readonly dispatchBatchSize: number; + readonly commandPollMs: number; + readonly dispatchPollMs: number; + readonly projectionPollMs: number; + readonly reconciliationMs: number; + readonly shutdownMs: number; + readonly maxArtifactBytes: number; + }; +} + +export function photonEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + const value = env.BELL_PHOTON_ENABLED?.trim().toLowerCase(); + return value === "1" || value === "true" || value === "yes"; +} + +function required(env: NodeJS.ProcessEnv, name: string): string { + const value = env[name]?.trim(); + if (!value) throw new PhotonConfigError(name, "is required when BELL_PHOTON_ENABLED is set."); + return value; +} + +function optional(env: NodeJS.ProcessEnv, name: string): string | undefined { + const value = env[name]?.trim(); + return value || undefined; +} + +function e164List(env: NodeJS.ProcessEnv, name: string): string[] { + const raw = env[name]?.trim() ?? ""; + if (!raw) return []; + const values = raw.split(",").map((value) => value.trim()).filter(Boolean); + for (const value of values) { + if (!E164_PATTERN.test(value)) + throw new PhotonConfigError(name, `"${value}" is not a valid E.164 number.`); + } + return [...new Set(values)]; +} + +function stringList(env: NodeJS.ProcessEnv, name: string): string[] { + const raw = env[name]?.trim() ?? ""; + if (!raw) return []; + return [...new Set(raw.split(",").map((value) => value.trim()).filter(Boolean))]; +} + +const emailListSchema = z.array(z.email()); +function emailList(env: NodeJS.ProcessEnv, name: string): string[] { + const values = stringList(env, name); + const parsed = emailListSchema.safeParse(values.map((value) => value.toLowerCase())); + if (!parsed.success) + throw new PhotonConfigError(name, "contains a handle that is not a valid email address."); + return parsed.data; +} + +function boundedMs( + env: NodeJS.ProcessEnv, + name: string, + fallback: number, + min: number, + max: number, +): number { + const raw = env[name]?.trim(); + if (!raw) return fallback; + const value = Number(raw); + if (!Number.isFinite(value) || value < min || value > max) + throw new PhotonConfigError(name, `must be a number between ${min} and ${max} (ms).`); + return Math.floor(value); +} + +function boundedBytes(env: NodeJS.ProcessEnv, name: string, fallback: number): number { + const raw = env[name]?.trim(); + if (!raw) return fallback; + const value = Number(raw); + if (!Number.isFinite(value) || value < 1024 || value > 100 * 1024 * 1024) + throw new PhotonConfigError(name, "must be a byte size between 1024 and 104857600."); + return Math.floor(value); +} + +function privateOrigin(env: NodeJS.ProcessEnv, name: string): string { + const raw = required(env, name); + let url: URL; + try { + url = new URL(raw); + } catch { + throw new PhotonConfigError(name, "must be an absolute http(s) origin."); + } + if (url.protocol !== "http:" && url.protocol !== "https:") + throw new PhotonConfigError(name, "must use the http or https protocol."); + if (url.pathname !== "/" || url.search || url.hash || url.username || url.password) + throw new PhotonConfigError(name, "must be a bare origin without path, query, or credentials."); + return url.origin; +} + +/** + * Parses the Photon environment contract. Returns `null` when the channel is + * disabled so builds and tests can run without provider credentials. Throws + * PhotonConfigError for malformed configuration so startup fails closed. + */ +export function loadPhotonConfig(env: NodeJS.ProcessEnv = process.env): PhotonConfig | null { + if (!photonEnabled(env)) return null; + const phoneNumber = required(env, "BELL_PHOTON_PHONE_NUMBER"); + if (!E164_PATTERN.test(phoneNumber)) + throw new PhotonConfigError("BELL_PHOTON_PHONE_NUMBER", "must be the dedicated line in E.164 format."); + const allowedSenders = e164List(env, "BELL_PHOTON_ALLOWED_SENDERS"); + if (allowedSenders.length === 0) + throw new PhotonConfigError("BELL_PHOTON_ALLOWED_SENDERS", "must contain at least one E.164 sender."); + const internalSecret = required(env, "BELL_PHOTON_INTERNAL_SECRET"); + if (Buffer.byteLength(internalSecret) < 32) + throw new PhotonConfigError("BELL_PHOTON_INTERNAL_SECRET", "must be at least 32 bytes and high entropy."); + return { + projectId: required(env, "IMESSAGE_PROJECT_ID"), + projectSecret: required(env, "IMESSAGE_PROJECT_SECRET"), + phoneNumber, + allowedSenders, + allowedEmailSenders: emailList(env, "BELL_PHOTON_ALLOWED_EMAIL_SENDERS"), + allowedGroups: stringList(env, "BELL_PHOTON_ALLOWED_GROUPS"), + internalSecret, + internalOrigin: privateOrigin(env, "BELL_PHOTON_INTERNAL_ORIGIN"), + databaseUrl: required(env, "DATABASE_URL"), + publicUrl: optional(env, "BELL_PUBLIC_URL"), + worker: { + leaseMs: boundedMs(env, "BELL_PHOTON_LEASE_MS", 30_000, 5_000, 120_000), + heartbeatStaleMs: boundedMs(env, "BELL_PHOTON_HEARTBEAT_STALE_MS", 90_000, 15_000, 600_000), + commandBatchSize: Math.min(50, Math.max(1, boundedMs(env, "BELL_PHOTON_COMMAND_BATCH", 8, 1, 50))), + dispatchBatchSize: Math.min(50, Math.max(1, boundedMs(env, "BELL_PHOTON_DISPATCH_BATCH", 8, 1, 50))), + commandPollMs: boundedMs(env, "BELL_PHOTON_COMMAND_POLL_MS", 500, 50, 30_000), + dispatchPollMs: boundedMs(env, "BELL_PHOTON_DISPATCH_POLL_MS", 500, 50, 30_000), + projectionPollMs: boundedMs(env, "BELL_PHOTON_PROJECTION_POLL_MS", 1_000, 100, 60_000), + reconciliationMs: boundedMs(env, "BELL_PHOTON_RECONCILIATION_MS", 300_000, 30_000, 3_600_000), + shutdownMs: boundedMs(env, "BELL_PHOTON_SHUTDOWN_MS", 15_000, 1_000, 120_000), + maxArtifactBytes: boundedBytes(env, "BELL_PHOTON_MAX_ARTIFACT_BYTES", 24 * 1024 * 1024), + }, + }; +} + +export function photonTokenUrl(projectId: string): string { + if (!/^[A-Za-z0-9_-]{1,128}$/.test(projectId)) + throw new PhotonConfigError("IMESSAGE_PROJECT_ID", "has an unexpected shape."); + return IMESSAGE_TOKEN_URL.replace("{projectId}", encodeURIComponent(projectId)); +} diff --git a/server/photon/contracts.ts b/server/photon/contracts.ts new file mode 100644 index 0000000..e9e9c17 --- /dev/null +++ b/server/photon/contracts.ts @@ -0,0 +1,622 @@ +import type { + AttachmentInfo, + Chat, + CatchUpEvent, + GroupChange, + LiveEvent, + LocationRequestReceipt, + Message, + MessageListPage, + MessageReaction, + MiniAppCardSession, + MiniAppMessageResult, + MultiServiceAddressInfo, + Poll, + PollChangeDelta, + SharedFriendLocation, + SingleServiceAddressInfo, + StickerPlacement, +} from "@photon-ai/advanced-imessage/grpc"; +import { z } from "zod"; + +import type { CanonicalHandle } from "./identity.js"; + +/** + * Versioned internal event/command contract shared by the transport worker, + * the Eve channel routes, and the agent tools. Payloads import the pinned + * SDK's public types rather than copying incompatible definitions. + */ + +export const ENVELOPE_VERSION = 1; +export const NORMALIZATION_VERSION = 1; + +/* ------------------------------------------------------------------ */ +/* Inbound normalized events */ +/* ------------------------------------------------------------------ */ + +export type PhotonEventFamily = "message" | "chat" | "group" | "poll" | "location" | "catchup"; + +/** Every documented inbound variant normalized to a stable internal union. */ +export type PhotonNormalizedEvent = + | { readonly family: "message"; readonly type: "received"; readonly message: Message } + | { readonly family: "message"; readonly type: "edited"; readonly messageGuid: string; readonly content: Message["content"]; readonly editedAt: string } + | { readonly family: "message"; readonly type: "read"; readonly messageGuid: string; readonly readAt: string } + | { readonly family: "message"; readonly type: "unsent"; readonly messageGuid: string; readonly retractedAt: string } + | { readonly family: "message"; readonly type: "reactionAdded"; readonly messageGuid: string; readonly reaction: MessageReaction; readonly targetPartIndex?: number } + | { readonly family: "message"; readonly type: "reactionRemoved"; readonly messageGuid: string; readonly reaction: MessageReaction; readonly targetPartIndex?: number } + | { readonly family: "message"; readonly type: "stickerPlaced"; readonly messageGuid: string; readonly sticker?: Message["placedStickers"][number]["sticker"]; readonly placement?: StickerPlacement; readonly targetPartIndex?: number } + | { readonly family: "chat"; readonly type: "backgroundChanged" } + | { readonly family: "chat"; readonly type: "backgroundRemoved" } + | { readonly family: "chat"; readonly type: "markedRead" } + | { readonly family: "chat"; readonly type: "archived" } + | { readonly family: "chat"; readonly type: "unarchived" } + | { readonly family: "group"; readonly type: "displayNameChanged"; readonly displayName: string } + | { readonly family: "group"; readonly type: "participantAdded"; readonly participant: SingleServiceAddressInfo } + | { readonly family: "group"; readonly type: "participantRemoved"; readonly participant: SingleServiceAddressInfo } + | { readonly family: "group"; readonly type: "participantLeft"; readonly participant: SingleServiceAddressInfo } + | { readonly family: "group"; readonly type: "iconChanged" } + | { readonly family: "group"; readonly type: "iconRemoved" } + | { readonly family: "poll"; readonly type: "created"; readonly pollMessageGuid: string; readonly delta: Extract } + | { readonly family: "poll"; readonly type: "optionAdded"; readonly pollMessageGuid: string; readonly delta: Extract } + | { readonly family: "poll"; readonly type: "voted"; readonly pollMessageGuid: string; readonly delta: Extract } + | { readonly family: "poll"; readonly type: "unvoted"; readonly pollMessageGuid: string; readonly delta: Extract } + | { readonly family: "location"; readonly type: "updated"; readonly location: SharedFriendLocation; readonly sourceSequence: string } + | { readonly family: "catchup"; readonly type: "complete"; readonly headSequence: string }; + +export type PhotonEventType = PhotonNormalizedEvent["type"]; + +/** The durable internal envelope written to the inbox for admitted events. */ +export interface PhotonEventEnvelope { + readonly version: typeof ENVELOPE_VERSION; + /** Provider sequence stored losslessly as a decimal string. */ + readonly sequence: string; + readonly chatGuid?: string; + readonly isFromMe?: boolean; + /** Provider timestamp (ISO) when supplied. */ + readonly occurredAt?: string; + /** Bell receipt timestamp (ISO). */ + readonly receivedAt: string; + readonly sourceKind: "live" | "catchup"; + /** Canonical acting handle when the event supplies one (`phone:`/`email:`). */ + readonly actor?: string; + readonly event: PhotonNormalizedEvent; +} + +/** Admission outcome decided by policy before dispatch. */ +export type AdmissionDisposition = "admitted" | "state_only" | "denied" | "quarantined" | "tombstone"; + +export interface AdmissionDecision { + readonly disposition: AdmissionDisposition; + /** Machine-readable reason for diagnostics; never contains message bodies. */ + readonly reason: string; + /** Event family should start/steer an agent turn. */ + readonly dispatchAction: "send" | "respond" | "state_apply" | "none"; + /** Canonical actor when verified; absent actor means state refresh only. */ + readonly actor?: CanonicalHandle; + /** Conversation scope when determinable. */ + readonly scope?: "direct" | "group"; + /** Whether the event updates membership/audience state before dispatch. */ + readonly updatesMembership?: boolean; +} + +/** What the envelope is classified as for recovery accounting. */ +export type RecoveryClass = "old_history" | "duplicate" | "live" | "recovered_unresolved"; + +/* ------------------------------------------------------------------ */ +/* Internal dispatch route contract */ +/* ------------------------------------------------------------------ */ + +/** POST /internal/photon/dispatch — bounded request body. */ +export const dispatchRequestSchema = z.object({ + inboxId: z.object({ + sourceId: z.string().min(1).max(256), + logEpoch: z.string().min(1).max(128), + sequence: z.string().min(1).max(64), + }).strict(), + correlationId: z.string().min(8).max(128), +}).strict(); +export type PhotonDispatchRequest = z.infer; + +/** POST /internal/photon/dispatch — the route's exact response contract. */ +export const dispatchResultSchema = z.discriminatedUnion("status", [ + z.object({ + status: z.literal("dispatched"), + sessionId: z.string().min(1), + deliveryId: z.string().optional(), + }).strict(), + z.object({ + status: z.literal("duplicate"), + sessionId: z.string().optional(), + deliveryId: z.string().optional(), + }).strict(), + z.object({ status: z.literal("state_applied") }).strict(), + z.object({ status: z.literal("rejected"), reason: z.string() }).strict(), + z.object({ status: z.literal("retryable"), reason: z.string() }).strict(), +]); +export type PhotonDispatchResult = z.infer; + +/** POST /internal/photon/events — bounded projector read request. */ +export const eventsReadRequestSchema = z.object({ + sessionId: z.string().min(1).max(256), + startIndex: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER), +}).strict(); +export type PhotonEventsReadRequest = z.infer; + +export interface PhotonEventsReadResult { + readonly events: readonly unknown[]; + readonly tailIndex: number; + readonly stranded?: boolean; +} + +/** POST /internal/photon/retire — bounded absolute-deadline retirement. */ +export const retireRequestSchema = z.object({ + eveSessionId: z.string().min(1).max(256), + reason: z.string().min(1).max(256), +}).strict(); +export type PhotonRetireRequest = z.infer; +export type PhotonRetireResult = + | { readonly status: "retired" } + | { readonly status: "already_retired" } + | { readonly status: "retryable"; readonly reason: string }; + +/** GET /internal/photon/health — worker-readiness probe payload. */ +export interface PhotonHealthReport { + readonly ok: boolean; + readonly worker?: { + readonly alive: boolean; + readonly leaseEpoch?: number; + readonly leaseExpiresAt?: string; + readonly streams?: Record; + readonly captureCursor?: string; + readonly inboxLagSeconds?: number; + readonly outboxPending?: number; + readonly ambiguous?: number; + }; +} + +/* ------------------------------------------------------------------ */ +/* Outbound command catalog */ +/* ------------------------------------------------------------------ */ + +const nonEmpty = z.string().min(1).max(64_000); +const guid = z.string().min(1).max(512); +const address = z.string().min(3).max(320); +const utf16Offset = z.number().int().min(0).max(64_000); + +export const replyTargetSchema = z.union([ + guid, + z.object({ guid, partIndex: z.number().int().min(0).max(255) }).strict(), +]); +export type ReplyTargetInput = z.infer; + +export const textFormatSchema = z.object({ + start: utf16Offset, + length: z.number().int().min(1).max(64_000), + bold: z.boolean().optional(), + italic: z.boolean().optional(), + underline: z.boolean().optional(), + strikethrough: z.boolean().optional(), +}).strict(); +export const textEffectSchema = z.object({ + type: z.literal("effect"), + start: utf16Offset, + length: z.number().int().min(1).max(64_000), + effect: z.enum(["big", "small", "shake", "nod", "explode", "ripple", "bloom", "jitter"]), +}).strict(); +export const messageEffectSchema = z.enum(["slam", "loud", "gentle", "invisible"]); +export const screenEffectSchema = z.enum([ + "confetti", "fireworks", "balloons", "heart", "lasers", "celebration", "sparkles", "spotlight", "echo", +]); +export const effectSchema = z.union([messageEffectSchema, screenEffectSchema]); + +export const backgroundPresetSchema = z.enum([ + "skyDusk", "skyHaze", "skySunset", "skyClear", "skySunrise", "skyDawn", + "waterLight", "waterDark", + "auroraGreen", "auroraPurple", "auroraPink", + "colorMango", "colorIce", "colorPlum", "colorDeepSea", "colorGreenApple", + "colorCherry", "colorBubblegum", "colorTangerine", "colorMagenta", "colorLime", + "colorSilver", "colorCarbon", "colorStone", +]); + +export const messagePartSchema = z.object({ + text: z.string().max(64_000).optional(), + mentionedAddress: address.optional(), + formatting: z.array(z.union([textFormatSchema, textEffectSchema])).optional(), + attachmentGuid: guid.optional(), + attachmentName: z.string().max(1024).optional(), + bubbleIndex: z.number().int().min(0).max(255).optional(), +}).strict(); +export type MessagePartInput = z.infer; + +export const miniAppLayoutSchema = z.object({ + caption: z.string().max(1024).optional(), + subcaption: z.string().max(1024).optional(), + trailingCaption: z.string().max(1024).optional(), + trailingSubcaption: z.string().max(1024).optional(), + image: z.string().max(256).optional(), + imageTitle: z.string().max(1024).optional(), + imageSubtitle: z.string().max(1024).optional(), + summary: z.string().max(2048).optional(), +}).strict(); +export const customizedMiniAppSchema = z.object({ + appName: z.string().min(1).max(256), + appStoreId: z.number().int().positive().optional(), + extensionBundleId: z.string().min(1).max(256).refine((v) => !v.includes(":"), "bundle ID must not contain a colon"), + teamId: z.string().regex(/^[A-Z0-9]{10}$/, "team ID must be 10 uppercase alphanumeric characters"), + url: z.url({ protocol: /^https?$/ }), + layout: miniAppLayoutSchema, + live: z.boolean().optional(), +}).strict(); +export const miniAppCardSessionSchema = z.object({ + chatGuid: guid, + messageGuid: guid, + sessionId: z.string().min(1).max(512), + targetMessageGuid: guid, +}).strict(); + +export const messageListFilterSchema = z.object({ + after: z.iso.datetime().optional(), + before: z.iso.datetime().optional(), + isFromMe: z.boolean().optional(), + isRead: z.boolean().optional(), + pageSize: z.number().int().min(1).max(100).optional(), + pageToken: z.string().max(4096).optional(), +}).strict(); + +export const stickerPlacementSchema = z.object({ + x: z.number().min(0).max(1), + y: z.number().min(0).max(1), + scale: z.number().positive().max(10).optional(), + rotation: z.number().optional(), + width: z.number().positive().optional(), +}).strict(); + +export const settableReactionSchema = z.union([ + z.enum(["love", "like", "dislike", "laugh", "emphasize", "question"]), + z.object({ customEmoji: z.string().min(1).max(64) }).strict(), +]); + +/** Every provider operation reachable through the durable command path. */ +export const commandArgsSchemas = { + /* messages */ + "messages.sendText": z.object({ + chat: guid, text: nonEmpty, + effect: effectSchema.optional(), + enableDataDetection: z.boolean().optional(), + enableLinkPreview: z.boolean().optional(), + formatting: z.array(z.union([textFormatSchema, textEffectSchema])).optional(), + replyTo: replyTargetSchema.optional(), + subject: z.string().max(1024).optional(), + }).strict(), + "messages.sendAttachment": z.object({ + chat: guid, attachmentGuid: guid, + effect: effectSchema.optional(), + isAudioMessage: z.boolean().optional(), + replyTo: replyTargetSchema.optional(), + }).strict(), + "messages.sendMultipart": z.object({ + chat: guid, parts: z.array(messagePartSchema).min(1).max(64), + effect: effectSchema.optional(), + replyTo: replyTargetSchema.optional(), + subject: z.string().max(1024).optional(), + enableDataDetection: z.boolean().optional(), + }).strict(), + "messages.sendCustomizedMiniApp": z.object({ + chat: guid, message: customizedMiniAppSchema, + }).strict(), + "messages.updateCustomizedMiniApp": z.object({ + session: miniAppCardSessionSchema, message: customizedMiniAppSchema, + }).strict(), + "messages.edit": z.object({ + chat: guid, messageGuid: guid, newText: nonEmpty, + backwardCompatText: z.string().max(64_000).optional(), + partIndex: z.number().int().min(0).max(255).optional(), + }).strict(), + "messages.unsend": z.object({ + chat: guid, messageGuid: guid, + partIndex: z.number().int().min(0).max(255).optional(), + }).strict(), + "messages.setReaction": z.object({ + chat: guid, messageGuid: guid, reaction: settableReactionSchema, isSet: z.boolean(), + partIndex: z.number().int().min(0).max(255).optional(), + }).strict(), + "messages.placeSticker": z.object({ + chat: guid, messageGuid: guid, stickerAttachmentGuid: guid, + placement: stickerPlacementSchema, + partIndex: z.number().int().min(0).max(255).optional(), + }).strict(), + "messages.notifySilenced": z.object({ chat: guid, messageGuid: guid }).strict(), + "messages.get": z.object({ messageGuid: guid }).strict(), + "messages.listInChat": z.object({ chat: guid, filter: messageListFilterSchema.optional() }).strict(), + "messages.listRecent": z.object({ filter: messageListFilterSchema.optional() }).strict(), + "messages.getEmbeddedMedia": z.object({ chat: guid, messageGuid: guid }).strict(), + /* attachments */ + "attachments.upload": z.object({ + fileName: z.string().min(1).max(1024), + artifactId: z.string().min(1).max(256), + companionArtifactId: z.string().min(1).max(256).optional(), + }).strict(), + "attachments.get": z.object({ attachmentGuid: guid }).strict(), + /* chats */ + "chats.create": z.object({ + addresses: z.array(address).min(1).max(100), + message: z.string().max(64_000).optional(), + subject: z.string().max(1024).optional(), + effect: effectSchema.optional(), + }).strict(), + "chats.get": z.object({ chat: guid }).strict(), + "chats.count": z.object({ includeArchived: z.boolean().optional() }).strict(), + "chats.markRead": z.object({ chat: guid }).strict(), + "chats.setTyping": z.object({ chat: guid, isTyping: z.boolean() }).strict(), + "chats.shareContactInfo": z.object({ chat: guid }).strict(), + "chats.hasBackground": z.object({ chat: guid }).strict(), + "chats.setBackground": z.object({ + chat: guid, + preset: backgroundPresetSchema.optional(), + artifactId: z.string().min(1).max(256).optional(), + }).strict().refine((v) => (v.preset === undefined) !== (v.artifactId === undefined), { + message: "exactly one of preset or artifactId is required", + }), + "chats.removeBackground": z.object({ chat: guid }).strict(), + /* groups */ + "groups.setDisplayName": z.object({ chat: guid, name: z.string().min(1).max(1024) }).strict(), + "groups.addParticipants": z.object({ chat: guid, addresses: z.array(address).min(1).max(100) }).strict(), + "groups.removeParticipants": z.object({ chat: guid, addresses: z.array(address).min(1).max(100) }).strict(), + "groups.leave": z.object({ chat: guid }).strict(), + "groups.setIcon": z.object({ chat: guid, artifactId: z.string().min(1).max(256) }).strict(), + "groups.getIcon": z.object({ chat: guid }).strict(), + "groups.removeIcon": z.object({ chat: guid }).strict(), + /* polls */ + "polls.create": z.object({ + chat: guid, title: z.string().min(1).max(2048), + choices: z.array(z.string().min(1).max(1024)).min(2).max(100), + }).strict(), + "polls.get": z.object({ pollMessageGuid: guid }).strict(), + "polls.vote": z.object({ pollMessageGuid: guid, optionIdentifier: z.string().min(1).max(512) }).strict(), + "polls.unvote": z.object({ pollMessageGuid: guid }).strict(), + "polls.addOption": z.object({ pollMessageGuid: guid, text: z.string().min(1).max(1024) }).strict(), + /* addresses */ + "addresses.get": z.object({ address }).strict(), + "addresses.isIMessageAvailable": z.object({ address }).strict(), + "addresses.isFocusSilenced": z.object({ address }).strict(), + /* locations */ + "locations.request": z.object({ chat: guid, address }).strict(), + "locations.list": z.object({}).strict(), + "locations.get": z.object({ address }).strict(), + /* Bell-scoped watch control (subscription layer, not a raw RPC passthrough) */ + "locations.watch": z.object({ address: address.optional(), action: z.enum(["start", "stop"]) }).strict(), +} as const; + +export type PhotonCommandOp = keyof typeof commandArgsSchemas; +export type PhotonCommandArgs = z.infer<(typeof commandArgsSchemas)[Op]>; + +/** Whether the operation has a public `clientMessageId`/idempotency option. */ +export const KEYED_OPERATIONS = new Set([ + "messages.sendText", "messages.sendAttachment", "messages.sendMultipart", + "messages.sendCustomizedMiniApp", "messages.updateCustomizedMiniApp", + "messages.edit", "messages.unsend", "messages.setReaction", + "messages.placeSticker", "messages.notifySilenced", + "chats.create", + "groups.setDisplayName", "groups.addParticipants", "groups.removeParticipants", + "groups.leave", "groups.setIcon", "groups.removeIcon", + "polls.create", "polls.vote", "polls.unvote", "polls.addOption", + "locations.request", +]); + +export const READ_OPERATIONS = new Set([ + "messages.get", "messages.listInChat", "messages.listRecent", "messages.getEmbeddedMedia", + "attachments.get", + "chats.get", "chats.count", "chats.hasBackground", + "groups.getIcon", + "polls.get", + "addresses.get", "addresses.isIMessageAvailable", "addresses.isFocusSilenced", + "locations.list", "locations.get", +]); + +export function isReadOperation(op: PhotonCommandOp): boolean { + return READ_OPERATIONS.has(op); +} + +export function validateCommandArgs( + op: Op, + args: unknown, +): PhotonCommandArgs { + return commandArgsSchemas[op].parse(args) as PhotonCommandArgs; +} + +/* ------------------------------------------------------------------ */ +/* Command result contract */ +/* ------------------------------------------------------------------ */ + +/** + * The provider upload receipt for one staged artifact. `attachmentGuid` is + * what message sends, sticker placements, and reaction attachments accept. + */ +export interface PhotonUploadResult { + readonly attachmentGuid: string; + readonly transferState: string; + readonly totalBytes: number; + readonly companion?: { + readonly fileName: string; + readonly mimeType: string; + readonly totalBytes: number; + }; +} + +/** + * Provider bytes staged into the conversation-scoped artifact store. The + * command receipt carries the artifact reference and media metadata — + * never the bytes themselves. + */ +export interface StagedMediaResult { + readonly artifactId: string; + readonly mediaType: string; + readonly byteSize: number; +} + +/** A fetched group icon staged into the artifact store. */ +export interface GroupIconResult { + readonly artifactId: string; + readonly mimeType: string; + readonly byteSize: number; +} + +/** Mini-app card send/update: the card message plus its durable session. */ +export interface MiniAppCommandResult { + readonly message: MiniAppMessageResult; + readonly miniAppCardSession: MiniAppCardSession; +} + +/** Address-record lookup: absent addresses come back as a typed miss. */ +export type AddressLookupResult = + | { readonly found: true; readonly info: MultiServiceAddressInfo } + | { readonly found: false; readonly address: string }; + +/** Friend-location lookup: absent locations come back as a typed miss. */ +export type LocationLookupResult = + | { readonly found: true; readonly location: SharedFriendLocation } + | { readonly found: false; readonly address: string }; + +/** + * The canonical executor→tool result contract. `executeOp(op)` emits the + * shape declared here; `awaitCommandResult(op)` hands the same shape back + * to the tool layer, so projections read `receipt.result` — never provider + * fields off the receipt root. Values are JSON-round-tripped provider and + * artifact projections; bytes never cross this boundary. Command-style + * writes still acknowledge without a result body when the provider returns + * none, so every entry also tolerates `undefined`. + */ +export interface PhotonCommandResultMap { + "messages.sendText": Message; + "messages.sendAttachment": Message; + "messages.sendMultipart": Message; + "messages.sendCustomizedMiniApp": MiniAppCommandResult; + "messages.updateCustomizedMiniApp": MiniAppCommandResult; + "messages.edit": Message; + "messages.unsend": { readonly unsent: true }; + "messages.setReaction": Message; + "messages.placeSticker": Message; + "messages.notifySilenced": { readonly notified: true }; + "messages.get": Message; + "messages.listInChat": MessageListPage; + "messages.listRecent": MessageListPage; + "messages.getEmbeddedMedia": StagedMediaResult; + "attachments.upload": PhotonUploadResult; + "attachments.get": AttachmentInfo; + "chats.create": { readonly chat: Chat; readonly initialMessageGuid?: string }; + "chats.get": Chat; + "chats.count": { readonly count: number }; + "chats.markRead": { readonly marked: true }; + "chats.setTyping": { readonly isTyping: boolean }; + "chats.shareContactInfo": { readonly shared: true }; + "chats.hasBackground": { readonly hasBackground: boolean }; + "chats.setBackground": { readonly set: true }; + "chats.removeBackground": { readonly removed: true }; + "groups.setDisplayName": Chat; + "groups.addParticipants": Chat; + "groups.removeParticipants": Chat; + "groups.leave": { readonly left: true }; + "groups.setIcon": { readonly set: true }; + "groups.getIcon": GroupIconResult | { readonly icon: null }; + "groups.removeIcon": { readonly removed: true }; + "polls.create": Poll; + "polls.get": Poll; + "polls.vote": Poll; + "polls.unvote": Poll; + "polls.addOption": Poll; + "addresses.get": AddressLookupResult; + "addresses.isIMessageAvailable": { readonly address: string; readonly available: boolean }; + "addresses.isFocusSilenced": { readonly address: string; readonly silenced: boolean }; + "locations.request": LocationRequestReceipt; + "locations.list": { readonly locations: readonly SharedFriendLocation[] }; + "locations.get": LocationLookupResult; + "locations.watch": { readonly address: string | null; readonly watching: boolean }; +} + +/** The typed result body one operation's executor emits. */ +export type PhotonCommandResult = PhotonCommandResultMap[Op]; + +/* ------------------------------------------------------------------ */ +/* Command ledger */ +/* ------------------------------------------------------------------ */ + +export type CommandStatus = + "queued" | "in_flight" | "acknowledged" | "ambiguous" | "failed" | "cancelled" | "expired"; + +export interface PhotonCommand { + readonly commandId: string; + readonly operation: Op; + readonly args: PhotonCommandArgs; + readonly argsDigest: string; + readonly initiatorPrincipal?: string; + readonly audience?: "direct" | "group" | "system"; + readonly sourceId: string; + readonly chatGuid: string; + readonly target?: Record; + readonly providerKey?: string; + readonly semanticKey: string; + readonly dependsOn: readonly string[]; + readonly expiresAt?: string; +} + +/** Outcome the command executor returns to the outbox state machine. */ +export type CommandExecution = + | { + readonly kind: "acknowledged"; + readonly result?: PhotonCommandResult; + readonly providerReceipt?: unknown; + } + | { readonly kind: "ambiguous"; readonly reason: string; readonly providerReceipt?: unknown } + | { readonly kind: "retryable"; readonly errorCode: string; readonly errorDetail?: string; readonly retryAfterMs?: number } + | { readonly kind: "failed"; readonly errorCode: string; readonly errorDetail?: string }; + +/** Executor invoked by the worker's outbox loop for one claimed command. */ +export type PhotonCommandExecutor = ( + command: PhotonCommand, + deps: PhotonCommandDeps, +) => Promise>; + +/** Narrow dependency surface the command executors are allowed to use. */ +export interface PhotonCommandDeps { + readonly client: { + sendText(chat: string, text: string, options?: Record): Promise; + raw: unknown; + }; + readonly artifacts: { + get(artifactId: string): Promise<{ data: Uint8Array; fileName?: string; mediaType: string } | undefined>; + put(input: { chatGuid: string; attachmentGuid?: string; fileName?: string; mediaType: string; data: Uint8Array }): Promise; + }; + readonly config: { readonly projectId: string }; + readonly now: () => Date; + readonly signal?: AbortSignal; +} + +/* ------------------------------------------------------------------ */ +/* Projection: Eve durable event -> outbound commands */ +/* ------------------------------------------------------------------ */ + +/** Projected output kinds derived from retained Eve session events. */ +export type ProjectedOutputKind = + | "assistant_text" | "assistant_part" | "input_request" | "authorization" + | "lifecycle_notice" | "typing" | "read_marker"; + +export interface ProjectedOutput { + readonly kind: ProjectedOutputKind; + readonly semanticKey: string; + /** Command operation to enqueue, when this projection produces one. */ + readonly operation?: PhotonCommandOp; + readonly args?: Record; + readonly payload: Record; +} + +export type { + AttachmentInfo, + Chat, + CatchUpEvent, + GroupChange, + LiveEvent, + Message, + MessageListPage, + Poll, + SharedFriendLocation, +}; diff --git a/server/photon/dispatch.ts b/server/photon/dispatch.ts new file mode 100644 index 0000000..6897885 --- /dev/null +++ b/server/photon/dispatch.ts @@ -0,0 +1,468 @@ +import { randomUUID } from "node:crypto"; + +import type { AdvancedIMessage } from "@photon-ai/advanced-imessage/grpc"; +import { z } from "zod"; + +import { + PHOTON_DISPATCH_PATH, + PHOTON_RETIRE_PATH, + type PhotonConfig, +} from "./config.js"; +import { + dispatchResultSchema, + type PhotonDispatchResult, + type PhotonEventEnvelope, +} from "./contracts.js"; +import { + bindingIdFor, + dispatchKeyFor, + eveAddressFor, + handleKey, + intentIdFor, + normalizeHandle, + parseActorKey, + photonPrincipalId, + sha256Hex, +} from "./identity.js"; +import { matchInboundToInteractions } from "./interactions.js"; +import { + admitGroup, + admitSender, + applyMembershipDelta, + classifyScope, + groupActorAuthorized, + toConversationState, +} from "./policy.js"; +import type { ConversationRow, InboxRow, IntentRow, PhotonDb } from "./store.js"; +import { stableJson } from "./normalize.js"; + +/** + * Inbox -> Eve bridge. Admitted rows dispatch in per-chat sequence order to + * the fixed internal route; state-only rows apply membership/interaction + * side effects and never open an agent turn. Every attempted delivery is a + * durable intent so a crash mid-request resumes without duplicating. + */ + +/** Bell's maximum conversation lifetime. */ +const SESSION_TTL_MS = 24 * 60 * 60 * 1000; +/** Deliveries that keep failing quarantine instead of spinning forever. */ +const MAX_INTENT_ATTEMPTS = 12; + +export interface DispatcherDeps { + readonly store: PhotonDb; + readonly config: PhotonConfig; + readonly sourceId: string; + readonly logEpoch: string; + /** Optional SDK client used to prime group membership at binding time. */ + readonly client?: AdvancedIMessage; + readonly fetcher?: typeof fetch; + readonly batchSize: number; + readonly log?: (entry: Record) => void; + readonly now?: () => Date; +} + +export interface PhotonDispatcher { + /** One scan/deliver round; returns rows that reached a terminal state. */ + runOnce(): Promise; +} + +interface PostOutcome { + readonly kind: "ok" | "retryable"; + readonly result?: PhotonDispatchResult; + readonly reason?: string; +} + +export function createDispatcher(deps: DispatcherDeps): PhotonDispatcher { + const { store, config, sourceId, logEpoch } = deps; + const fetcher = deps.fetcher ?? fetch; + const now = deps.now ?? (() => new Date()); + const log = deps.log ?? (() => undefined); + + /* ---------------------------------------------------------------- */ + /* Internal route POST */ + /* ---------------------------------------------------------------- */ + + async function postInternal(path: string, body: unknown): Promise { + let response: Response; + try { + response = await fetcher(`${config.internalOrigin}${path}`, { + method: "POST", + signal: AbortSignal.timeout(15_000), + headers: { + authorization: `Bearer ${config.internalSecret}`, + "content-type": "application/json", + }, + body: JSON.stringify(body), + }); + } catch (error) { + return { kind: "retryable", reason: error instanceof Error ? error.message : String(error) }; + } + const text = await response.text().catch(() => ""); + if (!response.ok) { + // A rejected credential means our call never reached the route's + // contract — that is a transport failure, not a content rejection. + if (response.status === 401 || response.status === 403) + return { kind: "retryable", reason: `internal route auth failed (${response.status})` }; + // 5xx and rate limits are retryable; other 4xx are durable rejections. + const retryable = response.status === 408 || response.status === 429 || response.status >= 500; + if (!retryable) + return { kind: "ok", result: { status: "rejected", reason: `internal_route_${response.status}` } }; + return { kind: "retryable", reason: `internal route returned ${response.status}` }; + } + const parsed = dispatchResultSchema.safeParse(JSON.parse(text || "{}")); + if (!parsed.success) return { kind: "retryable", reason: "internal route returned an unreadable result" }; + return { kind: "ok", result: parsed.data }; + } + + /* ---------------------------------------------------------------- */ + /* Conversation binding */ + /* ---------------------------------------------------------------- */ + + /** Best-effort membership snapshot when a group binding is created. */ + async function primeGroupMembership(chatGuid: string): Promise { + if (!deps.client) return []; + try { + const chat = await deps.client.chats.get(chatGuid); + const keys: string[] = []; + for (const participant of chat.participants) { + const handle = normalizeHandle(participant.address); + if (handle) keys.push(handleKey(handle)); + } + return keys; + } catch { + return []; + } + } + + async function ensureBinding( + envelope: PhotonEventEnvelope, + existing: ConversationRow | undefined, + ): Promise { + const chatGuid = envelope.chatGuid; + if (!chatGuid) return undefined; + if (existing && !existing.retiredAt) return existing; + // A retired binding's next inbound starts a fresh generation so stale + // session state can never alias the new conversation. Scope classifies + // the chat, not the retired generation — the prior row still counts as + // group evidence for the rebinding. + const scope = classifyScope(config, existing, chatGuid); + const generation = (existing?.generation ?? 0) + 1; + const bindingId = bindingIdFor(sourceId, chatGuid, generation); + const handle = parseActorKey(envelope.actor); + const initiatingPrincipal = handle ? photonPrincipalId(config.projectId, handle) : undefined; + const membership = scope === "group" ? await primeGroupMembership(chatGuid) : []; + return store.upsertConversation({ + bindingId, sourceId, chatGuid, generation, scope, + eveAddress: eveAddressFor(bindingId), + initiatingPrincipal, + membership, + }); + } + + /** Membership side effects for group state events, in sequence order. */ + async function applyMembership( + envelope: PhotonEventEnvelope, + binding: ConversationRow | undefined, + ): Promise { + if (!binding) return; + const next = applyMembershipDelta(binding.membership, envelope.event); + if (next) await store.updateMembership(binding.bindingId, next); + } + + /* ---------------------------------------------------------------- */ + /* Row processing */ + /* ---------------------------------------------------------------- */ + + async function quarantine(row: InboxRow, reason: string): Promise { + await store.updateDisposition(row, "quarantined", reason); + await store.markInboxProcessing(row, "quarantined", reason); + log({ level: "warn", msg: "photon_inbox_quarantined", sequence: row.sequence, reason }); + } + + /** + * Re-verifies authorization at dispatch time. Admission ran at capture; + * membership can change between the two, and a retired binding must never + * deliver again. + */ + function dispatchGate( + row: InboxRow, + binding: ConversationRow | undefined, + ): string | undefined { + const chatGuid = row.chatGuid; + if (!chatGuid) return undefined; + if (binding?.retiredAt) return "conversation_retired"; + const scope = classifyScope(config, binding, chatGuid); + const handle = parseActorKey(row.actor); + if (scope === "group") { + if (!admitGroup(config, chatGuid)) return "group_not_admitted"; + const state = toConversationState(binding, "group")!; + if (!groupActorAuthorized(state, handle)) return "actor_not_in_membership"; + } else if (!admitSender(config, handle)) { + // Allowlists are re-checked at delivery time: config may have + // narrowed since the event was admitted. + return "sender_not_allowed"; + } + return undefined; + } + + function intentAction(stateOnly: boolean, contractResolved: boolean): IntentRow["action"] { + // A resolved durable contract is an input resolution for Eve regardless + // of how the event otherwise classifies. + if (contractResolved) return "respond"; + return stateOnly ? "state_apply" : "send"; + } + + async function deliverIntent(intent: IntentRow): Promise { + return postInternal(PHOTON_DISPATCH_PATH, { + inboxId: { sourceId: intent.sourceId, logEpoch: intent.logEpoch, sequence: intent.sequence }, + correlationId: randomUUID(), + }); + } + + async function settleIntent(intent: IntentRow, outcome: PostOutcome, row?: InboxRow): Promise { + if (outcome.kind === "retryable") { + if (intent.attempts + 1 >= MAX_INTENT_ATTEMPTS) { + await store.updateIntent(intent.intentId, { status: "ambiguous", lastError: outcome.reason }); + if (row) await store.markInboxProcessing(row, "failed", "delivery exhausted; outcome ambiguous"); + } else { + await store.updateIntent(intent.intentId, { lastError: outcome.reason }); + // The failed attempt is recorded on the inbox row too so delivery + // telemetry survives between the row's captured and settled states. + if (row) await store.markInboxProcessing(row, "captured", outcome.reason); + } + return false; + } + const result = outcome.result; + if (!result) return false; + switch (result.status) { + case "dispatched": + case "duplicate": { + const patch = { + status: "accepted" as const, + eveSessionId: "sessionId" in result ? result.sessionId : undefined, + deliveryId: result.deliveryId, + }; + await store.updateIntent(intent.intentId, patch); + if (result.status === "dispatched" && result.sessionId) { + const binding = row?.chatGuid + ? await store.getConversation(sourceId, row.chatGuid) + : undefined; + if (binding && binding.eveSessionId !== result.sessionId) { + await store.bindEveSession(binding.bindingId, result.sessionId); + await store.registerDeadline( + result.sessionId, binding.bindingId, new Date(now().getTime() + SESSION_TTL_MS), + ); + } + if (binding) await store.touchConversation(binding.bindingId); + } + if (row) await store.markInboxProcessing(row, "dispatched"); + return true; + } + case "state_applied": + await store.updateIntent(intent.intentId, { status: "accepted" }); + if (row) await store.markInboxProcessing(row, "settled"); + return true; + case "rejected": + await store.updateIntent(intent.intentId, { status: "rejected", lastError: result.reason }); + if (row) await quarantine(row, result.reason ?? "rejected"); + return true; + case "retryable": + await store.updateIntent(intent.intentId, { lastError: result.reason }); + // The failed attempt is recorded on the inbox row too so delivery + // telemetry survives between the row's captured and settled states. + if (row) await store.markInboxProcessing(row, "captured", result.reason); + return false; + } + } + + /** Replay below the activation boundary hydrates state, never replies. */ + function belowActivationBoundary(row: InboxRow, boundary: string | undefined): boolean { + if (boundary === undefined) return false; + try { + return BigInt(row.sequenceNum) <= BigInt(boundary); + } catch { + return false; + } + } + + async function processRow(row: InboxRow, boundary: string | undefined): Promise { + const envelope = row.envelope; + // Admitted rows replayed from before the activation boundary downgrade + // to state-only: they hydrate bindings and membership but must never + // open a fresh agent turn. + const stateOnly = row.disposition === "state_only" + || (row.disposition === "admitted" && belowActivationBoundary(row, boundary)); + // A state-only row with no envelope (location update, denied-adjacent + // tombstone) still settles: its durable record is already the state. + if (stateOnly && !envelope) { + await store.markInboxProcessing(row, "settled", "no_envelope"); + return true; + } + if (!envelope) { + await quarantine(row, "missing_envelope"); + return true; + } + + const existing = row.chatGuid + ? await store.getConversation(sourceId, row.chatGuid) + : undefined; + const binding = row.disposition === "admitted" + ? await ensureBinding(envelope, existing) + : existing; + + if (stateOnly) { + await applyMembership(envelope, binding ?? existing); + } else { + const gated = dispatchGate(row, binding); + if (gated) { + await quarantine(row, gated); + return true; + } + } + + const matched = await matchInboundToInteractions( + store, sourceId, envelope, toConversationState(binding ?? existing), + ); + const action = intentAction(stateOnly, matched.contractResolved); + const dispatchKey = dispatchKeyFor(sourceId, logEpoch, row.sequence, action); + const inputDigest = sha256Hex(stableJson([action, row.payloadHash, binding?.bindingId ?? null])); + const intent = (await store.getIntentByKey(dispatchKey)) + ?? await store.upsertIntent({ + intentId: intentIdFor(dispatchKey), + sourceId, logEpoch, sequence: row.sequence, action, + eveSessionId: binding?.eveSessionId, + dispatchKey, inputDigest, + }); + if (intent.status !== "pending") { + // A settled intent means a previous delivery attempt already reached + // a terminal state — the row just follows it. + if (intent.status === "accepted") + await store.markInboxProcessing(row, action === "state_apply" ? "settled" : "dispatched"); + else await quarantine(row, `intent_${intent.status}`); + return true; + } + const outcome = await deliverIntent(intent); + return settleIntent(intent, outcome, row); + } + + /* ---------------------------------------------------------------- */ + + async function runOnce(): Promise { + const boundary = await store.activationBoundary(sourceId); + // Chat scopes (null = chat-less state rows) with any dispatchable row. + // Rows inside one scope keep provider sequence order; the scopes + // themselves are independent, so they run in parallel — a stalled chat + // (one postInternal can burn a 15s timeout) never blocks the others. + const scopes = await store.dispatchableChatScopes(sourceId, deps.batchSize); + const perScope = await Promise.all(scopes.map(async (chatGuid) => { + let settled = 0; + try { + for (const row of await store.dispatchableForChat(sourceId, chatGuid, deps.batchSize)) { + if (await processRow(row, boundary)) settled++; + else break; // undelivered head preserves this chat's ordering + } + } catch (error) { + log({ + level: "error", msg: "photon_dispatch_chat_failed", chatGuid, + error: error instanceof Error ? error.message : String(error), + }); + } + return settled; + })); + // Retry deliveries whose intents outlived a crashed attempt — the same + // independent-work shape, so the same bounded-parallel structure. + const retried = await Promise.all( + (await store.pendingIntents(sourceId, deps.batchSize)).map(async (intent) => { + try { + const row = await store.getInboxRow(intent); + const outcome = await deliverIntent(intent); + return await settleIntent(intent, outcome, row); + } catch (error) { + log({ + level: "error", msg: "photon_intent_retry_failed", intentId: intent.intentId, + error: error instanceof Error ? error.message : String(error), + }); + return false; + } + }), + ); + return perScope.reduce((total, n) => total + n, 0) + + retried.filter(Boolean).length; + } + + return { runOnce }; +} + +/** Retire one due Eve session via the internal route; used by the deadline sweep. */ +export async function retireViaInternalRoute( + config: PhotonConfig, + eveSessionId: string, + reason: string, + fetcher: typeof fetch = fetch, +): Promise<"retired" | "already_retired" | "retryable"> { + try { + const response = await fetcher(`${config.internalOrigin}${PHOTON_RETIRE_PATH}`, { + method: "POST", + signal: AbortSignal.timeout(15_000), + headers: { + authorization: `Bearer ${config.internalSecret}`, + "content-type": "application/json", + }, + body: JSON.stringify({ eveSessionId, reason }), + }); + if (!response.ok) { + // A non-success status means the session was NOT retired — a rejected + // credential (401/403) or other 4xx must keep the deadline open so a + // misconfiguration alarms through retries instead of silently + // dropping the deadline while the session keeps living. + console.error(JSON.stringify({ + event: "photon.retire_rejected", eveSessionId, status: response.status, + })); + return "retryable"; + } + const parsed = z.object({ status: z.enum(["retired", "already_retired", "retryable"]) }) + .safeParse(await response.json().catch(() => null)); + return parsed.success ? parsed.data.status : "retryable"; + } catch (error) { + // Same alarm story as a rejected call: the deadline stays open and every + // sweep logs — a permanently failing route surfaces instead of silently + // leaking the session. + console.error(JSON.stringify({ + event: "photon.retire_failed", + eveSessionId, + error: error instanceof Error ? error.message : String(error), + })); + return "retryable"; + } +} + +/** + * Bounded retirement sweep: every due deadline goes through the private + * retire route (never a bare cancellation), then the deadline and its + * binding are marked retired. Retirements are independent — they run in + * parallel within the batch so one hung route cannot serialize the sweep's + * timeout budget. Retryable route outcomes leave the deadline open for the + * next sweep. + */ +export async function retireDueSessions( + db: PhotonDb, + config: PhotonConfig, + options?: { fetch?: typeof fetch; now?: () => Date; batchSize?: number }, +): Promise<{ retired: number; retried: number }> { + const fetcher = options?.fetch ?? fetch; + const now = options?.now ?? (() => new Date()); + const batchSize = options?.batchSize ?? 64; + const outcomes = await Promise.all( + (await db.dueDeadlines(now(), batchSize)).map(async (deadline) => { + const result = await retireViaInternalRoute( + config, deadline.eveSessionId, "absolute_deadline", fetcher, + ); + if (result === "retryable") return false; + await db.markDeadlineRetired(deadline.eveSessionId, "absolute_deadline"); + if (deadline.bindingId) await db.retireConversation(deadline.bindingId); + return true; + }), + ); + const retired = outcomes.filter(Boolean).length; + return { retired, retried: outcomes.length - retired }; +} diff --git a/server/photon/events.ts b/server/photon/events.ts new file mode 100644 index 0000000..0f3a863 --- /dev/null +++ b/server/photon/events.ts @@ -0,0 +1,440 @@ +import type { + AdvancedIMessage, + CatchUpEvent, + LiveEvent, + SharedFriendLocationUpdated, + TypedEventStream, +} from "@photon-ai/advanced-imessage/grpc"; + +import type { PhotonConfig } from "./config.js"; +import type { PhotonEventEnvelope } from "./contracts.js"; +import { + NormalizationError, + computePayloadHash, + eventTargetGuid, + normalizeLiveEvent, +} from "./normalize.js"; +import { admitEvent, toConversationState } from "./policy.js"; +import type { PhotonStore } from "./store.js"; + +/** + * Transport supervisor for ONE enrolled source. Owns the four unfiltered + * durable event streams, concurrent + periodic catch-up replay, bounded + * live location watches, heartbeat staleness detection, and reconnect + * supervision. Persistence-only: policy runs at capture, dispatch and + * outbound effects live elsewhere. + */ + +export interface StreamBeat { + readonly lastBeat?: string; + readonly lastEvent?: string; + readonly ok: boolean; + readonly restarts: number; +} + +export interface TransportHealth { + readonly streams: Record; + readonly lastCatchUpAt?: string; + readonly catchUpHeadSequence?: string; + readonly integrityConflicts: number; + readonly normalizationFailures: number; + readonly tombstoned: number; + readonly locationWatches: readonly string[]; +} + +export interface PhotonTransport { + readonly sourceId: string; + health(): TransportHealth; + /** Serialized catch-up replay from the durable watermark. */ + runCatchUp(): Promise; + /** Start/stop a live location watch (bounded set, outside the durable log). */ + setLocationWatch(address: string | undefined, start: boolean): Promise; + stop(): Promise; +} + +export interface TransportDeps { + readonly client: AdvancedIMessage; + readonly store: PhotonStore; + readonly config: PhotonConfig; + readonly sourceId: string; + readonly logEpoch: string; + readonly log?: (entry: Record) => void; + readonly now?: () => Date; +} + +type StreamEvent = LiveEvent | CatchUpEvent | SharedFriendLocationUpdated; +type AnyStream = TypedEventStream; + +const DURABLE_STREAMS = ["messages", "chats", "groups", "polls"] as const; +type DurableStreamName = (typeof DURABLE_STREAMS)[number]; + +/** Bound on simultaneous live location watches (Bell subscription layer). */ +const MAX_LOCATION_WATCHES = 32; +const MAX_BACKOFF_MS = 30_000; + +function sleep(ms: number): Promise { + // Unref'd: a pending backoff must never hold the process open past stop(). + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + timer.unref?.(); + }); +} + +function backoff(failures: number): number { + const exp = Math.min(MAX_BACKOFF_MS, 1_000 * 2 ** Math.min(failures, 6)); + return Math.floor(exp * (0.5 + Math.random() * 0.5)); +} + +/** Location envelopes live outside the durable sequence domain. */ +function inboxSequenceNum(envelope: PhotonEventEnvelope): string { + return envelope.event.family === "location" ? "-1" : envelope.sequence; +} + +export function startTransport(deps: TransportDeps): PhotonTransport { + const { client, store, config, sourceId, logEpoch } = deps; + const now = deps.now ?? (() => new Date()); + const log = deps.log ?? ((entry) => console.log(JSON.stringify(entry))); + + let stopped = false; + const beats = new Map(); + const liveStreams = new Map(); + const tasks: Promise[] = []; + const locationWatches = new Map(); + let integrityConflicts = 0; + let normalizationFailures = 0; + let tombstoned = 0; + let lastCatchUpAt: string | undefined; + let catchUpHeadSequence: string | undefined; + let catchUpChain: Promise = Promise.resolve(); + + /* ---------------------------------------------------------------- */ + /* Ingest */ + /* ---------------------------------------------------------------- */ + + function beat(name: string, kind: "beat" | "event" | "open"): void { + const entry = beats.get(name) ?? { ok: false, restarts: 0, openedAt: 0 }; + const t = now().getTime(); + if (kind === "beat") entry.lastBeat = t; + else if (kind === "event") entry.lastEvent = t; + else { entry.openedAt = t; entry.ok = true; } + beats.set(name, entry); + } + + /** Minimal sequence tombstone so unknown/broken variants leave no gap. */ + async function captureTombstone( + raw: StreamEvent, + sourceKind: "live" | "catchup", + reason: string, + ): Promise { + const sequence = typeof raw === "object" && raw !== null && "sequence" in raw + && typeof raw.sequence === "number" && Number.isSafeInteger(raw.sequence) && raw.sequence >= 0 + ? String(raw.sequence) + : undefined; + if (!sequence) { + normalizationFailures++; + log({ level: "warn", msg: "photon_event_unkeyable", reason }); + return; + } + try { + const capture = await store.captureEvent({ + sourceId, logEpoch, sequence, sequenceNum: sequence, + payloadHash: `tombstone:${sequence}:${reason}`, + tombstone: true, + eventType: typeof raw === "object" && raw !== null && "type" in raw ? String(raw.type) : "unknown", + chatGuid: typeof raw === "object" && raw !== null && "chatGuid" in raw ? String(raw.chatGuid) : undefined, + sourceKind, + disposition: "tombstone", + }); + if (capture.kind === "inserted") { + tombstoned++; + await store.markInboxProcessing(capture.ref, "settled", reason); + } + } catch (error) { + log({ level: "error", msg: "photon_tombstone_failed", sequence, error: String(error) }); + } + } + + async function ingest(raw: StreamEvent, sourceKind: "live" | "catchup"): Promise { + const receivedAt = now().toISOString(); + let envelope: PhotonEventEnvelope | null; + try { + envelope = normalizeLiveEvent(raw, sourceKind, receivedAt); + } catch (error) { + if (error instanceof NormalizationError) { + normalizationFailures++; + await captureTombstone(raw, sourceKind, error.detail); + return; + } + throw error; + } + if (!envelope) { + await captureTombstone(raw, sourceKind, "unrecognized_variant"); + return; + } + // Replay control markers are consumed by the catch-up loop, never stored + // in the inbox (they would collide with real sequences). + if (envelope.event.family === "catchup") return; + + const conversation = envelope.chatGuid + ? await store.getConversation(sourceId, envelope.chatGuid) + : undefined; + const decision = admitEvent(config, envelope, toConversationState(conversation)); + const denied = decision.disposition === "denied" || decision.disposition === "quarantined"; + const capture = await store.captureEvent({ + sourceId, + logEpoch, + sequence: envelope.sequence, + sequenceNum: inboxSequenceNum(envelope), + payloadHash: computePayloadHash(envelope), + // Policy-denied events persist as minimal tombstones; rejected users' + // message bodies are never written. + envelope: denied ? undefined : envelope, + tombstone: denied, + eventFamily: envelope.event.family, + eventType: envelope.event.type, + chatGuid: envelope.chatGuid, + actor: envelope.actor, + targetGuid: eventTargetGuid(envelope), + isFromMe: envelope.isFromMe, + occurredAt: envelope.occurredAt, + sourceKind, + disposition: decision.disposition, + }); + if (capture.kind === "integrity_conflict") { + integrityConflicts++; + log({ + level: "error", msg: "photon_integrity_conflict", sequence: envelope.sequence, + existingHash: capture.existingHash, + }); + return; + } + if (capture.kind === "duplicate") return; + if (denied) await store.markInboxProcessing(capture.ref, "settled", decision.reason); + } + + /* ---------------------------------------------------------------- */ + /* Durable catch-up */ + /* ---------------------------------------------------------------- */ + + function safeSince(watermark: string | undefined): number | undefined { + if (watermark === undefined) return undefined; + const value = Number(watermark); + if (!Number.isSafeInteger(value)) + throw new NormalizationError(`capture cursor ${watermark} exceeds the safe sequence domain.`); + return value; + } + + async function doCatchUp(): Promise { + const watermark = await store.maxSequenceNum(sourceId, logEpoch); + const since = safeSince(watermark); + const stream = client.events.catchUp(since); + liveStreams.set("catchup", stream); + beat("catchup", "open"); + let headSequence: string | undefined; + let replayed = 0; + try { + for await (const event of stream) { + if (stopped) break; + beat("catchup", "event"); + if (typeof event === "object" && event !== null && event.type === "catchup.complete") { + const normalized = normalizeLiveEvent(event, "catchup", now().toISOString()); + if (normalized?.event.family === "catchup") headSequence = normalized.event.headSequence; + continue; + } + await ingest(event, "catchup"); + replayed++; + } + // The cursor may only advance across catch-up coverage after every + // returned record is durably stored (PRD §7.2). + if (headSequence !== undefined && !stopped) { + await store.updateCaptureCursor(sourceId, headSequence, { + lastCatchUpAt: now().toISOString(), + replayed, + since: watermark ?? null, + }); + // First activation boundary: retained history before it hydrates + // state but never triggers replies (idempotent — first write wins). + await store.setActivationBoundary(sourceId, headSequence); + catchUpHeadSequence = headSequence; + lastCatchUpAt = now().toISOString(); + } + } finally { + const catchupBeat = beats.get("catchup"); + if (catchupBeat) catchupBeat.ok = false; + liveStreams.delete("catchup"); + await stream.close().catch(() => undefined); + } + } + + /** Serializes catch-up runs; reconnects and the periodic sweep share it. */ + function runCatchUp(): Promise { + if (stopped) return Promise.resolve(); + const next = catchUpChain.then(doCatchUp, doCatchUp); + catchUpChain = next.then(() => undefined, () => undefined); + return next; + } + + /* ---------------------------------------------------------------- */ + /* Stream supervision */ + /* ---------------------------------------------------------------- */ + + function openDurableStream(name: DurableStreamName): AnyStream { + const onHeartbeat = () => beat(name, "beat"); + switch (name) { + case "messages": return client.messages.subscribeEvents({ onHeartbeat }); + case "chats": return client.chats.subscribeEvents({ onHeartbeat }); + case "groups": return client.groups.subscribeEvents({ onHeartbeat }); + case "polls": return client.polls.subscribeEvents({ onHeartbeat }); + } + } + + async function supervise(name: DurableStreamName): Promise { + let failures = 0; + while (!stopped) { + try { + // Replay first: the live subscription accepts no replay cursor, so + // every reconnect must re-establish coverage through catch-up. + await runCatchUp(); + if (stopped) break; + const stream = openDurableStream(name); + liveStreams.set(name, stream); + beat(name, "open"); + failures = 0; + for await (const event of stream) { + if (stopped) break; + beat(name, "event"); + await ingest(event, "live"); + } + } catch (error) { + failures++; + log({ + level: "warn", msg: "photon_stream_failed", stream: name, + error: error instanceof Error ? error.message : String(error), + }); + } finally { + const entry = beats.get(name); + if (entry) entry.ok = false; + liveStreams.delete(name); + } + if (stopped) break; + const entry = beats.get(name); + if (entry) entry.restarts++; + await sleep(backoff(failures)); + } + } + + /* ---------------------------------------------------------------- */ + /* Location watches (separate sequence space, live only) */ + /* ---------------------------------------------------------------- */ + + async function watchLocation(key: string, address: string | undefined): Promise { + let failures = 0; + const name = `location:${key}`; + while (!stopped && locationWatches.has(key)) { + try { + const stream = client.locations.watch(address, { onHeartbeat: () => beat(name, "beat") }); + const watch = locationWatches.get(key); + if (!watch) { await stream.close(); break; } + watch.stream = stream; + beat(name, "open"); + failures = 0; + for await (const update of stream) { + if (stopped || !locationWatches.has(key)) break; + beat(name, "event"); + await ingest(update, "live"); + } + } catch (error) { + failures++; + log({ + level: "warn", msg: "photon_location_watch_failed", address: address ?? "account", + error: error instanceof Error ? error.message : String(error), + }); + } + if (stopped || !locationWatches.has(key)) break; + await sleep(backoff(failures)); + } + } + + async function setLocationWatch(address: string | undefined, start: boolean): Promise { + const key = address ?? ""; + if (!start) { + const watch = locationWatches.get(key); + if (!watch) return false; + locationWatches.delete(key); + await watch.stream?.close().catch(() => undefined); + return true; + } + if (locationWatches.has(key)) return true; + if (locationWatches.size >= MAX_LOCATION_WATCHES) return false; + locationWatches.set(key, { address }); + tasks.push(watchLocation(key, address)); + return true; + } + + /* ---------------------------------------------------------------- */ + /* Heartbeat watchdog */ + /* ---------------------------------------------------------------- */ + + const watchdog = setInterval(() => { + if (stopped) return; + const t = now().getTime(); + for (const [name, stream] of liveStreams) { + if (name === "catchup") continue; + const entry = beats.get(name); + const lastActivity = Math.max(entry?.lastBeat ?? 0, entry?.lastEvent ?? 0, entry?.openedAt ?? 0); + if (lastActivity > 0 && t - lastActivity > config.worker.heartbeatStaleMs) { + log({ level: "warn", msg: "photon_stream_stale", stream: name }); + stream.close().catch(() => undefined); + } + } + }, Math.max(1_000, Math.floor(config.worker.heartbeatStaleMs / 4))); + watchdog.unref?.(); + + for (const name of DURABLE_STREAMS) { + beats.set(name, { ok: false, restarts: 0, openedAt: 0 }); + tasks.push(supervise(name)); + } + // Initial replay runs concurrently with the live streams per PRD §7.2. + tasks.push(runCatchUp().catch((error) => { + log({ level: "error", msg: "photon_initial_catchup_failed", error: String(error) }); + })); + + /* ---------------------------------------------------------------- */ + + return { + sourceId, + health() { + const streams: Record = {}; + for (const [name, entry] of beats) { + streams[name] = { + lastBeat: entry.lastBeat ? new Date(entry.lastBeat).toISOString() : undefined, + lastEvent: entry.lastEvent ? new Date(entry.lastEvent).toISOString() : undefined, + ok: entry.ok, + restarts: entry.restarts, + }; + } + return { + streams, + lastCatchUpAt, + catchUpHeadSequence, + integrityConflicts, + normalizationFailures, + tombstoned, + locationWatches: [...locationWatches.keys()], + }; + }, + runCatchUp, + setLocationWatch, + async stop() { + if (stopped) return; + stopped = true; + clearInterval(watchdog); + const closers = [...liveStreams.values()].map((stream) => stream.close().catch(() => undefined)); + for (const watch of locationWatches.values()) + closers.push(watch.stream?.close().catch(() => undefined) ?? Promise.resolve()); + locationWatches.clear(); + await Promise.allSettled(closers); + await Promise.allSettled(tasks); + }, + }; +} diff --git a/server/photon/filename.ts b/server/photon/filename.ts new file mode 100644 index 0000000..a7fc403 --- /dev/null +++ b/server/photon/filename.ts @@ -0,0 +1,16 @@ +/** + * The one filename sanitizer shared by the media pipeline and the tool + * layer. Provider- and model-supplied filenames are metadata, never paths: + * separators are dropped to the basename, `..` segments and all control + * bytes are stripped before a name ever reaches a disk path, artifact row + * or log line. Bounded to the upload contract's 1024-char limit. + */ +export function sanitizeFileName(name: string | undefined, fallback = "attachment"): string { + const base = (name ?? "").split(/[/\\]/).pop() ?? ""; + const cleaned = base + .replace(/\.{2,}/g, "_") + .replace(/[\x00-\x1f\x7f]/g, "") + .trim() + .slice(0, 1024); + return cleaned.length > 0 ? cleaned : fallback; +} diff --git a/server/photon/identity.ts b/server/photon/identity.ts new file mode 100644 index 0000000..3ce7723 --- /dev/null +++ b/server/photon/identity.ts @@ -0,0 +1,162 @@ +import type { SessionAuthContext } from "eve/context"; + +import { E164_PATTERN } from "./config.js"; +import { + canonicalTupleJson, + IDENTITY_VERSION, + PHOTON_AUTHENTICATOR, + PHOTON_ISSUER, + PHOTON_PRINCIPAL_TYPE, + sha256Hex, +} from "./principal.js"; + +/* The principal-verification contract lives in principal.ts — a leaf module + * the Next.js bundle can also reach (no `.js` relative imports inside). + * Re-exported here so every Node-side consumer keeps one import site. */ +export { + canonicalTupleJson, + IDENTITY_VERSION, + isPhotonAuth, + isVerifiedPhotonAuth, + isVerifiedPhotonPrincipal, + PHOTON_AUTHENTICATOR, + PHOTON_ISSUER, + PHOTON_PRINCIPAL_PATTERN, + PHOTON_PRINCIPAL_TYPE, + sha256Hex, +} from "./principal.js"; + +/** Canonical handle identities used throughout the channel. */ +export type CanonicalHandle = + | { readonly kind: "phone"; readonly value: string } + | { readonly kind: "email"; readonly value: string }; + +/** + * Normalizes a provider handle to Bell's canonical identity form. Phone + * handles must be valid E.164. Email handles are lowercased and trimmed; + * the provider treats them case-insensitively for delivery. Returns `null` + * for anything else — never fabricate a required identity field. + */ +export function normalizeHandle(raw: string | null | undefined): CanonicalHandle | null { + const value = raw?.trim(); + if (!value) return null; + if (E164_PATTERN.test(value)) return { kind: "phone", value }; + if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) && value.length <= 320) + return { kind: "email", value: value.toLowerCase() }; + return null; +} + +export function handleKey(handle: CanonicalHandle): string { + return `${handle.kind}:${handle.value}`; +} + +/** + * Inverse of {@link handleKey}: parses a `phone:+...` or `email:...` actor key + * back into a verified CanonicalHandle, or `null` when absent or malformed. + */ +export function parseActorKey(actor: string | null | undefined): CanonicalHandle | null { + if (!actor) return null; + const separator = actor.indexOf(":"); + if (separator <= 0) return null; + const kind = actor.slice(0, separator); + const handle = normalizeHandle(actor.slice(separator + 1)); + return handle && handle.kind === kind ? handle : null; +} + +/** + * Bell's raw-SDK principal identity: `photon:` + sha256 of the versioned + * canonical tuple (project, handle kind, canonical handle). The receiving + * line is deliberately excluded so moving a verified handle between + * explicitly admitted lines in one project keeps one wallet owner. + */ +export function photonPrincipalId(projectId: string, handle: CanonicalHandle): string { + return `photon:${sha256Hex(canonicalTupleJson([IDENTITY_VERSION, projectId, handle.kind, handle.value]))}`; +} + +/** Builds the Eve session principal for a verified Photon event actor. */ +export function photonSessionAuth( + projectId: string, + handle: CanonicalHandle, + attributes: Readonly> = {}, +): SessionAuthContext { + return { + attributes, + authenticator: PHOTON_AUTHENTICATOR, + issuer: PHOTON_ISSUER, + principalId: photonPrincipalId(projectId, handle), + principalType: PHOTON_PRINCIPAL_TYPE, + subject: handleKey(handle), + }; +} + +/** Stable source binding identity for one enrolled (project, instance, line). */ +export function sourceIdFor(projectId: string, instanceId: string, phoneNumber: string): string { + return `photon-src:${sha256Hex(canonicalTupleJson([IDENTITY_VERSION, projectId, instanceId, phoneNumber]))}`; +} + +/** + * Canonical conversation identity: versioned tuple of source, provider chat + * GUID and generation. Chats are never reconstructed from a sender number. + */ +export function conversationKey(sourceId: string, chatGuid: string, generation: number): string { + return canonicalTupleJson([IDENTITY_VERSION, sourceId, chatGuid, generation]); +} + +export function bindingIdFor(sourceId: string, chatGuid: string, generation: number): string { + return `photon-bind:${sha256Hex(conversationKey(sourceId, chatGuid, generation))}`; +} + +/** Channel-local continuation address for `from(address)` operations. */ +export function eveAddressFor(bindingId: string): string { + return `photon:${bindingId}`; +} + +/** Deterministic dispatch key within Eve's 256-character idempotency limit. */ +export function dispatchKeyFor( + sourceId: string, + logEpoch: string, + sequence: string, + action: string, +): string { + const key = `photon:${action}:${sourceId}:${logEpoch}:${sequence}`; + return key.length <= 256 ? key : `photon:${action}:${sha256Hex(key)}`; +} + +/** Durable intent identity for one dispatch key (the key itself dedups). */ +export function intentIdFor(dispatchKey: string): string { + return `intent:${sha256Hex(dispatchKey)}`; +} + +/** Deterministic provider idempotency key (`clientMessageId`) for a command. */ +export function providerKeyFor(commandId: string, attempt: number): string { + return `bell:${commandId}:${attempt}`; +} + +/** + * Semantic output key for an outgoing command derived from logical identity: + * session, turn, step, output kind/part, request and tool-call identities. + */ +export function semanticKeyFor(parts: { + readonly sessionId?: string; + readonly turnId?: string; + readonly step?: string | number; + readonly outputKind: string; + readonly partId?: string; + readonly requestId?: string; + readonly toolCallId?: string; + readonly commandId?: string; +}): string { + const raw = canonicalTupleJson([ + "v1", + parts.sessionId ?? null, + parts.turnId ?? null, + parts.step ?? null, + parts.outputKind, + parts.partId ?? null, + parts.requestId ?? null, + parts.toolCallId ?? null, + parts.commandId ?? null, + ]); + const key = `photon-out:${raw}`; + return key.length <= 512 ? key : `photon-out:${sha256Hex(raw)}`; +} diff --git a/server/photon/interactions.ts b/server/photon/interactions.ts new file mode 100644 index 0000000..bd1cdd8 --- /dev/null +++ b/server/photon/interactions.ts @@ -0,0 +1,159 @@ +import type { PhotonEventEnvelope } from "./contracts.js"; +import { handleKey, normalizeHandle } from "./identity.js"; +import type { ConversationState } from "./policy.js"; +import type { + InteractionRow, + PhotonDb, +} from "./store.js"; + +/** + * Durable pending-interaction correlation. An inbound event may resolve an + * open poll contract, reaction contract, or prompt exactly once, and only + * when the event's verified actor matches the contract's permitted actor. + * A missing or mismatched actor leaves the event state-only — it can never + * authorize an action (PRD §13, T-A05/T-M02). + */ + +export interface InteractionMatch { + /** Open interactions this event consumed (one-time resolution). */ + readonly resolved: readonly InteractionRow[]; + /** Whether a prompt/authorization input was resolved by this reply. */ + readonly promptResolved: boolean; + /** Any durable contract (poll, reaction, prompt) resolved by this event. */ + readonly contractResolved: boolean; + /** Eve requestRef of the resolved prompt input, when one resolved. */ + readonly promptRequestRef?: string; +} + +const NO_MATCH: InteractionMatch = { resolved: [], promptResolved: false, contractResolved: false }; + +/** The verified acting handle for interaction purposes. */ +function eventActor(envelope: PhotonEventEnvelope): string | undefined { + const event = envelope.event; + if (event.family === "message" && event.type === "received") { + const sender = normalizeHandle(event.message.sender?.address); + if (sender) return handleKey(sender); + } + return envelope.actor; +} + +function generationMatches(interaction: InteractionRow, conversation?: ConversationState): boolean { + return conversation === undefined || interaction.generation === conversation.generation; +} + +function actorPermitted(interaction: InteractionRow, actor: string | undefined): boolean { + if (!actor) return false; + return interaction.permittedActor === undefined || interaction.permittedActor === actor; +} + +/** + * Finds open interactions in the event's chat that this admitted/state_only + * event resolves, and settles each once. Poll votes match poll contracts by + * `pollMessageGuid`; reactions match reaction contracts by target message; + * an admitted text reply resolves an open prompt. Authorization requests + * are deliberately NOT resolved here — they settle through their own + * callback path (a chat reply is never an OAuth consent). + */ +export async function matchInboundToInteractions( + db: PhotonDb, + sourceId: string, + envelope: PhotonEventEnvelope, + conversation?: ConversationState, + now: Date = new Date(), +): Promise { + const chatGuid = envelope.chatGuid; + if (!chatGuid) return NO_MATCH; + const actor = eventActor(envelope); + // A missing actor can refresh state but can never resolve a contract. + if (!actor) return NO_MATCH; + const open = await db.openInteractions(sourceId, chatGuid); + const resolved: InteractionRow[] = []; + let promptResolved = false; + let promptRequestRef: string | undefined; + + for (const interaction of open) { + // An expired contract stays pending until the sweep, but it must never + // resolve — a late reply can never authorize a stale prompt. + if (interaction.expiresAt && interaction.expiresAt.getTime() <= now.getTime()) continue; + if (!generationMatches(interaction, conversation)) continue; + if (!actorPermitted(interaction, actor)) continue; + const resolution = resolutionFor(interaction, envelope, actor); + if (!resolution) continue; + if (interaction.kind === "prompt") { + // Prompt rows settle in the dispatch route only after session.respond + // is accepted so a transient delivery failure can still retry. + promptResolved = true; + promptRequestRef = interaction.requestRef; + continue; + } + const didResolve = await db.resolveInteraction(interaction.interactionId, actor, resolution); + if (didResolve) resolved.push(interaction); + } + return { + resolved, + promptResolved, + contractResolved: promptResolved || resolved.length > 0, + promptRequestRef, + }; +} + +function resolutionFor( + interaction: InteractionRow, + envelope: PhotonEventEnvelope, + actor: string | undefined, +): Record | undefined { + const event = envelope.event; + const target = interaction.target ?? {}; + const mapping = interaction.responseMapping ?? {}; + + if (interaction.kind === "poll") { + if (event.family !== "poll" || event.type !== "voted" && event.type !== "unvoted") return undefined; + if (target.pollMessageGuid !== event.pollMessageGuid) return undefined; + if (event.type === "voted") { + const allowed = mapping.optionIdentifiers; + if (Array.isArray(allowed) && allowed.length > 0 + && !allowed.includes(event.delta.optionIdentifier)) return undefined; + } + return { + kind: "poll", vote: event.type, + optionIdentifier: event.delta.optionIdentifier, + pollMessageGuid: event.pollMessageGuid, + inboxSequence: envelope.sequence, + }; + } + + if (interaction.kind === "reaction_contract") { + if (event.family !== "message" || event.type !== "reactionAdded" && event.type !== "reactionRemoved") return undefined; + if (target.messageGuid !== event.messageGuid) return undefined; + if (event.type === "reactionRemoved") return { kind: "reaction_contract", removed: true, inboxSequence: envelope.sequence }; + const allowed = mapping.reactions; + if (Array.isArray(allowed) && allowed.length > 0 + && !allowed.includes(event.reaction.kind) && !allowed.includes(event.reaction.emoji ?? "")) return undefined; + return { + kind: "reaction_contract", + reaction: event.reaction, + messageGuid: event.messageGuid, + targetPartIndex: event.targetPartIndex ?? null, + inboxSequence: envelope.sequence, + }; + } + + if (interaction.kind === "prompt") { + if (event.family !== "message" || event.type !== "received") return undefined; + return { + kind: "prompt", + requestRef: interaction.requestRef, + messageGuid: event.message.guid, + inboxSequence: envelope.sequence, + actor: actor ?? null, + }; + } + + // authorization interactions never resolve from an inbound event. + return undefined; +} + +/** Expires open interactions past their deadline; returns the count. */ +export async function expireSweep(db: PhotonDb, now: Date): Promise { + return db.expireInteractions(now); +} diff --git a/server/photon/media.ts b/server/photon/media.ts new file mode 100644 index 0000000..452138c --- /dev/null +++ b/server/photon/media.ts @@ -0,0 +1,218 @@ +import { createHash } from "node:crypto"; + +import type { + AdvancedIMessage, + AttachmentInfo, + AttachmentInput, + CompanionInfo, + DownloadAttachmentChunk, + UploadAttachmentResult, +} from "@photon-ai/advanced-imessage/grpc"; + +import type { PhotonUploadResult } from "./contracts.js"; +import type { PhotonDb } from "./store.js"; + +/** + * Scoped artifact IO: bounded attachment downloads and validated uploads + * with Live Photo companion pairing. A failed or interrupted stream never + * produces a partial artifact. The attachments.* command executors live in + * commands/media.ts with the other ops. + */ + +export class MediaError extends Error { + readonly code: string; + readonly retryable: boolean; + constructor(code: string, message: string, retryable = false) { + super(message); + this.name = "MediaError"; + this.code = code; + this.retryable = retryable; + } +} + +/** + * Conservative outbound media allowlist: documented image/video/audio types + * plus PDF, vCard and plain text. Anything else fails closed — untrusted + * inbound payloads never choose server-side media types. + */ +const UPLOAD_ALLOWED_PREFIXES = ["image/", "video/", "audio/"]; +const UPLOAD_ALLOWED_TYPES = new Set([ + "application/pdf", + "application/zip", + "text/plain", + "text/vcard", + "text/x-vcard", +]); + +export function assertUploadMediaType(mediaType: string): void { + const normalized = mediaType.trim().toLowerCase(); + if (UPLOAD_ALLOWED_PREFIXES.some((prefix) => normalized.startsWith(prefix))) return; + if (UPLOAD_ALLOWED_TYPES.has(normalized)) return; + throw new MediaError("media_type_not_allowed", `Media type "${mediaType}" is not in the outbound allowlist.`); +} + +/** vCard payloads must actually be vCards — shape checked, never executed. */ +function assertValidVCard(fileName: string, data: Uint8Array): void { + const text = Buffer.from(data.subarray(0, 4096)).toString("utf8"); + if (!text.includes("BEGIN:VCARD") || !text.includes("END:VCARD")) + throw new MediaError("invalid_vcard", `File "${fileName}" is not a well-formed vCard.`); +} + +function isVCard(fileName: string | undefined, mediaType: string): boolean { + const type = mediaType.toLowerCase(); + return type === "text/vcard" || type === "text/x-vcard" || !!fileName?.toLowerCase().endsWith(".vcf"); +} + +/** Maximum combined primary+companion bytes for one unary upload frame. */ +const MAX_UPLOAD_FRAME_BYTES = 90 * 1024 * 1024; + +/** Raw-bytes digest so caller-supplied checksums match byte-for-byte. */ +function sha256Bytes(data: Uint8Array): string { + return createHash("sha256").update(data).digest("hex"); +} + +export interface CollectedDownload { + readonly info: AttachmentInfo; + readonly companionInfo?: CompanionInfo; + readonly primary: Uint8Array; + readonly companion?: Uint8Array; + readonly sha256: string; +} + +/** + * Pure bounded stream collector: the header frame carries metadata, primary + * and companion chunks accumulate separately, and the declared header size + * plus every accumulated byte fail closed past their budgets. Only a fully + * consumed stream yields bytes — an interrupted one throws rather than + * returning a truncated payload. When `expectedSha256` is supplied the + * collected primary must match it exactly. + */ +export async function collectDownload( + stream: AsyncIterable & { close?: () => Promise }, + options: { maxBytes: number; maxCompanionBytes?: number; expectedSha256?: string }, +): Promise { + const maxBytes = options.maxBytes; + const maxCompanionBytes = options.maxCompanionBytes ?? maxBytes; + let info: AttachmentInfo | undefined; + let companionInfo: CompanionInfo | undefined; + const primaryChunks: Buffer[] = []; + const companionChunks: Buffer[] = []; + let primaryBytes = 0; + let companionBytes = 0; + try { + for await (const chunk of stream) { + if (chunk.type === "header") { + if (info) throw new MediaError("download_protocol_error", "Duplicate download header frame."); + info = chunk.info; + companionInfo = chunk.companionInfo; + if (info.totalBytes > maxBytes) + throw new MediaError("attachment_too_large", `Attachment declares ${info.totalBytes} bytes, over the ${maxBytes} byte bound.`); + if (companionInfo && companionInfo.totalBytes > maxCompanionBytes) + throw new MediaError("companion_too_large", `Companion declares ${companionInfo.totalBytes} bytes, over the ${maxCompanionBytes} byte bound.`); + } else if (chunk.type === "primaryChunk") { + primaryBytes += chunk.data.byteLength; + if (primaryBytes > maxBytes) + throw new MediaError("attachment_too_large", `Attachment exceeds the ${maxBytes} byte bound.`); + primaryChunks.push(Buffer.from(chunk.data)); + } else if (chunk.type === "companionChunk") { + companionBytes += chunk.data.byteLength; + if (companionBytes > maxCompanionBytes) + throw new MediaError("companion_too_large", `Attachment companion exceeds the ${maxCompanionBytes} byte bound.`); + companionChunks.push(Buffer.from(chunk.data)); + } + } + } catch (error) { + await stream.close?.().catch(() => undefined); + if (error instanceof MediaError) throw error; + throw new MediaError( + "download_interrupted", + `Attachment download was interrupted: ${error instanceof Error ? error.message : String(error)}`, + true, + ); + } + if (!info) + throw new MediaError("download_protocol_error", "Download stream ended before its header frame.", true); + + const primary = new Uint8Array(Buffer.concat(primaryChunks)); + const sha256 = sha256Bytes(primary); + if (options.expectedSha256 !== undefined && sha256 !== options.expectedSha256) + throw new MediaError("checksum_mismatch", "Attachment sha256 does not match the expected checksum."); + const companion = companionChunks.length > 0 + ? new Uint8Array(Buffer.concat(companionChunks)) + : undefined; + return { info, companionInfo, primary, companion, sha256 }; +} + +/** The provider upload receipt — the typed `attachments.upload` result. */ +export type UploadedArtifact = PhotonUploadResult; + +/** + * Maps artifact bytes to the provider upload shape. A Live Photo companion + * (video/*) pairs with its primary (image/*); only the primary GUID is ever + * sent in a message. + */ +export function buildUploadInput(input: { + fileName: string; + data: Uint8Array; + companion?: Uint8Array; +}): AttachmentInput { + return { + fileName: input.fileName, + data: input.data, + companion: input.companion !== undefined ? { data: input.companion } : undefined, + }; +} + +/** + * Reads validated artifact bytes and uploads them through the provider. + * A companion artifact pairs a Live Photo still (image/*) with its motion + * video (video/*); only the primary GUID is sent later. + */ +export async function uploadArtifact( + client: AdvancedIMessage, + store: PhotonDb, + sourceId: string, + args: { artifactId: string; companionArtifactId?: string; fileName: string }, +): Promise { + const primary = await store.getArtifact(args.artifactId); + if (!primary) throw new MediaError("artifact_not_found", `Artifact ${args.artifactId} does not exist.`); + if (primary.sourceId !== sourceId) + throw new MediaError("artifact_scope", "Artifact belongs to a different source."); + assertUploadMediaType(primary.mediaType); + if (isVCard(args.fileName, primary.mediaType)) assertValidVCard(args.fileName, primary.data); + + let totalBytes = primary.data.byteLength; + let companionInput: { data: Uint8Array } | undefined; + if (args.companionArtifactId) { + const companion = await store.getArtifact(args.companionArtifactId); + if (!companion) throw new MediaError("artifact_not_found", `Companion artifact ${args.companionArtifactId} does not exist.`); + if (!primary.mediaType.toLowerCase().startsWith("image/")) + throw new MediaError("invalid_live_photo", "A Live Photo companion requires an image primary artifact."); + if (!companion.mediaType.toLowerCase().startsWith("video/")) + throw new MediaError("invalid_live_photo", "A Live Photo companion artifact must be video."); + totalBytes += companion.data.byteLength; + companionInput = { data: Buffer.from(companion.data) }; + } + if (totalBytes > MAX_UPLOAD_FRAME_BYTES) + throw new MediaError("upload_too_large", `Combined upload of ${totalBytes} bytes exceeds the unary upload bound.`); + + const result: UploadAttachmentResult = await client.attachments.upload( + buildUploadInput({ + fileName: args.fileName, + data: primary.data, + companion: companionInput?.data, + }), + ); + const uploaded: UploadedArtifact = { + attachmentGuid: result.attachment.guid, + transferState: result.attachment.transferState, + totalBytes: result.attachment.totalBytes, + companion: result.companion, + }; + await store.upsertResource(sourceId, "attachment", result.attachment.guid, { + info: result.attachment, sourceArtifactId: args.artifactId, + companionArtifactId: args.companionArtifactId ?? null, + uploadedAt: new Date().toISOString(), + }); + return uploaded; +} diff --git a/server/photon/normalize.ts b/server/photon/normalize.ts new file mode 100644 index 0000000..105f3e7 --- /dev/null +++ b/server/photon/normalize.ts @@ -0,0 +1,310 @@ +import type { + CatchUpEvent, + LiveEvent, + Message, + MessageReaction, + SharedFriendLocationUpdated, + SingleServiceAddressInfo, + StickerPlacement, +} from "@photon-ai/advanced-imessage/grpc"; + +import { + ENVELOPE_VERSION, + type PhotonEventEnvelope, + type PhotonNormalizedEvent, +} from "./contracts.js"; +import { handleKey, normalizeHandle, sha256Hex } from "./identity.js"; + +/** + * Exhaustive mapping of the pinned SDK's typed events into Bell's versioned + * normalized union. Pure functions only — persistence lives in events.ts. + * + * Unknown or future variants return `null` so the transport can persist a + * sequence tombstone instead of losing coverage. Structurally invalid input + * (including a non-safe-integer provider sequence) raises NormalizationError + * so ingestion fails closed rather than truncating losslessly-stored data. + */ +export class NormalizationError extends Error { + readonly detail: string; + constructor(detail: string) { + super(`Photon event normalization failed: ${detail}`); + this.name = "NormalizationError"; + this.detail = detail; + } +} + +/** Provider sequences must remain exact; an unsafe number is a hard failure. */ +function providerSequence(value: unknown, field = "sequence"): string { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) + throw new NormalizationError(`${field} is not a safe nonnegative integer (${String(value)}).`); + return String(value); +} + +function isoOf(value: unknown, field: string): string { + if (!(value instanceof Date) || Number.isNaN(value.getTime())) + throw new NormalizationError(`${field} is not a valid Date (${String(value)}).`); + return value.toISOString(); +} + +function optionalIso(value: unknown): string | undefined { + return value instanceof Date && !Number.isNaN(value.getTime()) ? value.toISOString() : undefined; +} + +function actorKeyOf(actor: SingleServiceAddressInfo | undefined): string | undefined { + const handle = normalizeHandle(actor?.address); + return handle ? handleKey(handle) : undefined; +} + +function requireField(value: T | null | undefined, field: string): T { + if (value === null || value === undefined) throw new NormalizationError(`missing ${field}.`); + return value; +} + +interface EventContextLike { + readonly actor?: SingleServiceAddressInfo; + readonly chatGuid: string; + readonly isFromMe: boolean; + readonly occurredAt: Date; +} + +function envelope( + ctx: EventContextLike | undefined, + sequence: string, + event: PhotonNormalizedEvent, + sourceKind: "live" | "catchup", + receivedAt: string, +): PhotonEventEnvelope { + return { + version: ENVELOPE_VERSION, + sequence, + chatGuid: ctx?.chatGuid, + isFromMe: ctx?.isFromMe, + occurredAt: optionalIso(ctx?.occurredAt), + receivedAt, + sourceKind, + actor: actorKeyOf(ctx?.actor), + event, + }; +} + +const CHAT_EVENT_TYPES = new Set([ + "backgroundChanged", "backgroundRemoved", "markedRead", "archived", "unarchived", +]); + +function isLocationUpdate(raw: unknown): raw is SharedFriendLocationUpdated { + return typeof raw === "object" && raw !== null + && "sourceSequence" in raw && "location" in raw && !("type" in raw); +} + +/** + * Maps one SDK event to a normalized envelope. `SharedFriendLocationUpdated` + * (locations.watch) lives outside the durable log: its sequence is the + * separate watch `sourceSequence` namespaced `loc:` so it can never collide + * with durable-log sequences in the inbox primary key. + */ +export function normalizeLiveEvent( + raw: LiveEvent | CatchUpEvent | SharedFriendLocationUpdated, + sourceKind: "live" | "catchup", + receivedAt: string, +): PhotonEventEnvelope | null { + if (isLocationUpdate(raw)) { + const sourceSequence = providerSequence(raw.sourceSequence, "sourceSequence"); + return envelope(undefined, `loc:${sourceSequence}`, { + family: "location", + type: "updated", + location: raw.location, + sourceSequence, + }, sourceKind, receivedAt); + } + + const typed = raw as LiveEvent | CatchUpEvent; + if (typeof typed !== "object" || typed === null || typeof typed.type !== "string") return null; + + if (typed.type === "catchup.complete") { + return envelope(undefined, providerSequence(typed.headSequence, "headSequence"), { + family: "catchup", + type: "complete", + headSequence: providerSequence(typed.headSequence, "headSequence"), + }, sourceKind, receivedAt); + } + + const ctx = typed as unknown as EventContextLike & { readonly type: string; readonly sequence: number }; + const sequence = providerSequence(ctx.sequence); + requireField(ctx.chatGuid, "chatGuid"); + + if (ctx.type.startsWith("message.")) { + const subtype = ctx.type.slice("message.".length); + const event = messageEvent(ctx, subtype); + return event === null ? null : envelope(ctx, sequence, event, sourceKind, receivedAt); + } + if (ctx.type.startsWith("chat.")) { + const subtype = ctx.type.slice("chat.".length); + if (!CHAT_EVENT_TYPES.has(subtype)) return null; + return envelope(ctx, sequence, { + family: "chat", type: subtype, + } as PhotonNormalizedEvent, sourceKind, receivedAt); + } + if (ctx.type === "group.changed") { + const change = (ctx as { change?: { type: string } }).change; + const event = groupEvent(change); + return event === null ? null : envelope(ctx, sequence, event, sourceKind, receivedAt); + } + if (ctx.type === "poll.changed") { + const event = pollEvent(ctx); + return event === null ? null : envelope(ctx, sequence, event, sourceKind, receivedAt); + } + return null; +} + +function messageEvent( + ctx: EventContextLike & { readonly type: string }, + subtype: string, +): PhotonNormalizedEvent | null { + const raw = ctx as unknown as Record; + switch (subtype) { + case "received": + return { + family: "message", type: "received", + message: requireField(raw.message, "message") as Message, + }; + case "edited": + return { + family: "message", type: "edited", + messageGuid: requireField(raw.messageGuid, "messageGuid") as string, + content: raw.content as Message["content"], + editedAt: isoOf(raw.editedAt, "editedAt"), + }; + case "read": + return { + family: "message", type: "read", + messageGuid: requireField(raw.messageGuid, "messageGuid") as string, + readAt: isoOf(raw.readAt, "readAt"), + }; + case "unsent": + return { + family: "message", type: "unsent", + messageGuid: requireField(raw.messageGuid, "messageGuid") as string, + retractedAt: isoOf(raw.retractedAt, "retractedAt"), + }; + case "reactionAdded": + case "reactionRemoved": + return { + family: "message", type: subtype, + messageGuid: requireField(raw.messageGuid, "messageGuid") as string, + reaction: requireField(raw.reaction, "reaction") as MessageReaction, + targetPartIndex: raw.targetPartIndex as number | undefined, + }; + case "stickerPlaced": + return { + family: "message", type: "stickerPlaced", + messageGuid: requireField(raw.messageGuid, "messageGuid") as string, + sticker: raw.sticker as Message["placedStickers"][number]["sticker"], + placement: raw.placement as StickerPlacement | undefined, + targetPartIndex: raw.targetPartIndex as number | undefined, + }; + default: + return null; + } +} + +function groupEvent(change: { type: string } | undefined): PhotonNormalizedEvent | null { + if (!change || typeof change.type !== "string") return null; + switch (change.type) { + case "displayNameChanged": + return { + family: "group", type: "displayNameChanged", + displayName: (change as { displayName?: string }).displayName ?? "", + }; + case "participantAdded": + case "participantRemoved": + case "participantLeft": { + const participant = (change as { participant?: SingleServiceAddressInfo }).participant; + if (!participant) throw new NormalizationError(`group.${change.type} missing participant.`); + return { family: "group", type: change.type, participant }; + } + case "iconChanged": + case "iconRemoved": + return { family: "group", type: change.type }; + default: + return null; + } +} + +function pollEvent(ctx: EventContextLike & { readonly type: string }): PhotonNormalizedEvent | null { + const raw = ctx as unknown as { pollMessageGuid?: string; delta?: { type: string } }; + const delta = requireField(raw.delta, "delta"); + const pollMessageGuid = requireField(raw.pollMessageGuid, "pollMessageGuid"); + switch (delta.type) { + case "created": + case "optionAdded": + case "voted": + case "unvoted": + return { + family: "poll", type: delta.type, pollMessageGuid, delta, + } as PhotonNormalizedEvent; + default: + return null; + } +} + +/* ------------------------------------------------------------------ */ +/* Canonical payload hashing */ +/* ------------------------------------------------------------------ */ + +/** + * Deterministic JSON serialization: object keys sorted, Date -> ISO, + * Uint8Array -> tagged base64, undefined object members dropped. Used for + * the inbox integrity hash so the same provider payload always hashes + * identically regardless of transport source or receipt metadata. (This is + * the deep serializer; identity.ts's `canonicalTupleJson` is the shallow + * identity-tuple form.) + */ +export function stableJson(value: unknown): string { + if (value === null || value === undefined) return "null"; + if (value instanceof Date) return JSON.stringify(value.toISOString()); + if (value instanceof Uint8Array) + return `{"$bytes":"${Buffer.from(value).toString("base64")}"}`; + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; + if (typeof value === "object") { + const entries = Object.entries(value as Record) + .filter(([, v]) => v !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([k, v]) => `${JSON.stringify(k)}:${stableJson(v)}`); + return `{${entries.join(",")}}`; + } + return JSON.stringify(value); +} + +/** + * sha256 of the canonical provider event payload — family, type, context + * identifiers and the full normalized event. Bell's live/catch-up label, + * receipt timestamp, lease and normalization metadata are deliberately + * excluded so the same provider event hashes identically on live delivery + * and replay (per PRD §7.2). + */ +export function computePayloadHash(envelope: PhotonEventEnvelope): string { + return sha256Hex(stableJson([ + envelope.version, + envelope.event.family, + envelope.event.type, + envelope.chatGuid ?? null, + envelope.actor ?? null, + envelope.isFromMe ?? null, + envelope.occurredAt ?? null, + envelope.event, + ])); +} + +/** Native target identifier for inbox indexing and ordering diagnostics. */ +export function eventTargetGuid(envelope: PhotonEventEnvelope): string | undefined { + const event = envelope.event; + switch (event.family) { + case "message": + if (event.type === "received") return event.message.guid; + return "messageGuid" in event ? event.messageGuid : undefined; + case "poll": + return event.pollMessageGuid; + default: + return undefined; + } +} diff --git a/server/photon/outbox.ts b/server/photon/outbox.ts new file mode 100644 index 0000000..43ba9eb --- /dev/null +++ b/server/photon/outbox.ts @@ -0,0 +1,239 @@ +import { ZodError } from "zod"; + +import { + KEYED_OPERATIONS, + isReadOperation, + type CommandExecution, + type PhotonCommand, + type PhotonCommandDeps, + type PhotonCommandExecutor, + type PhotonCommandOp, +} from "./contracts.js"; +import { providerKeyFor } from "./identity.js"; +import { conversationExecutors } from "./commands/conversations.js"; +import { interactionExecutors } from "./commands/interactions.js"; +import { locationExecutors, type LocationWatchControl } from "./commands/locations.js"; +import { mediaExecutors } from "./commands/media.js"; +import { messageExecutors } from "./commands/messages.js"; +import type { CommandRow, PhotonDb } from "./store.js"; + +/** + * The durable command outbox. Claims with the store's fencing primitives, + * orders work through explicit dependencies, routes each operation to its + * executor, and settles exactly one terminal status per attempt. A retryable + * error re-queues with backoff; an uncertain unkeyed write settles + * `ambiguous` rather than risking a duplicate visible send. + */ + +const DEFAULT_MAX_ATTEMPTS = 8; +const DEPENDENCY_WAIT_MS = 2_000; +const FOREIGN_RELEASE_MS = 5_000; + +export interface OutboxDeps { + readonly store: PhotonDb; + readonly workerId: string; + /** Commands belonging to other sources are released unclaimed. */ + readonly sourceId: string; + readonly commandDeps: PhotonCommandDeps; + readonly watchControl: LocationWatchControl; + readonly claimMs: number; + readonly batchSize: number; + readonly maxAttempts?: number; + /** Per-operation executors merged over the built-in registry (tests). */ + readonly executors?: Partial>; + readonly log?: (entry: Record) => void; + readonly now?: () => Date; +} + +export interface PhotonOutbox { + /** Claims and executes one batch; returns how many commands ran. */ + runOnce(): Promise; + readonly executors: Readonly>>; +} + +function toCommand(row: CommandRow): PhotonCommand { + return { + commandId: row.commandId, + operation: row.operation, + args: row.args as PhotonCommand["args"], + argsDigest: row.argsDigest, + initiatorPrincipal: row.initiatorPrincipal, + audience: row.audience, + sourceId: row.sourceId, + chatGuid: row.chatGuid, + target: row.target, + providerKey: row.providerKey, + semanticKey: row.semanticKey, + dependsOn: row.dependsOn, + expiresAt: row.expiresAt?.toISOString(), + }; +} + +function retryBackoff(attempt: number): number { + const exp = Math.min(60_000, 1_000 * 2 ** Math.min(attempt, 6)); + return Math.floor(exp * (0.5 + Math.random() * 0.5)); +} + +export function createOutbox(deps: OutboxDeps): PhotonOutbox { + const { store } = deps; + const now = deps.now ?? (() => new Date()); + const log = deps.log ?? (() => undefined); + const maxAttempts = deps.maxAttempts ?? DEFAULT_MAX_ATTEMPTS; + const executors: Partial> = { + ...messageExecutors(store), + ...mediaExecutors(store), + ...conversationExecutors(store), + ...interactionExecutors(store), + ...locationExecutors(deps.watchControl), + ...deps.executors, + }; + + /** Settles the claimed row with the execution's one terminal outcome. */ + async function settle(row: CommandRow, execution: CommandExecution, providerKey?: string): Promise { + const patch = { providerKey }; + switch (execution.kind) { + case "acknowledged": + await store.completeCommand(row.commandId, "acknowledged", { + ...patch, result: execution.result, providerReceipt: execution.providerReceipt, + }); + return; + case "ambiguous": + await store.completeCommand(row.commandId, "ambiguous", { + ...patch, providerReceipt: execution.providerReceipt, errorDetail: execution.reason, + }); + return; + case "failed": + await store.completeCommand(row.commandId, "failed", { + ...patch, errorCode: execution.errorCode, errorDetail: execution.errorDetail, + }); + return; + case "retryable": { + const read = isReadOperation(row.operation); + if (row.attemptCount >= maxAttempts) { + // Reads exhaust into a terminal failure; writes stay ambiguous + // because provider acceptance was never disproven. + await store.completeCommand( + row.commandId, + read ? "failed" : "ambiguous", + { ...patch, errorCode: "retry_exhausted", errorDetail: execution.errorDetail }, + ); + return; + } + const delay = execution.retryAfterMs ?? retryBackoff(row.attemptCount); + await store.completeCommand(row.commandId, "queued", { + ...patch, + errorCode: execution.errorCode, + errorDetail: execution.errorDetail, + nextAttemptAt: new Date(now().getTime() + delay), + }); + return; + } + } + } + + /** + * Verifies every dependency settled in a good state before this command + * may run. Returns a settle outcome, or `undefined` when it may execute. + */ + async function dependencyGate(row: CommandRow): Promise { + for (const depId of row.dependsOn) { + const dep = await store.getCommand(depId); + if (!dep) return { kind: "failed", errorCode: "dependency_missing", errorDetail: `Dependency ${depId} does not exist.` }; + switch (dep.status) { + case "acknowledged": + continue; + case "queued": + case "in_flight": + return "wait"; + case "ambiguous": + return { kind: "ambiguous", reason: `Dependency ${depId} settled ambiguous.` }; + default: + return { kind: "failed", errorCode: "dependency_failed", errorDetail: `Dependency ${depId} ended ${dep.status}.` }; + } + } + return undefined; + } + + async function runRow(row: CommandRow): Promise { + if (row.sourceId !== deps.sourceId) { + // A source we do not own: release the claim so its worker picks it up. + await store.completeCommand(row.commandId, "queued", { + nextAttemptAt: new Date(now().getTime() + FOREIGN_RELEASE_MS), + }); + return; + } + // Provider keys are per-command constants: a reclaimed or retried row + // must reuse the identical clientMessageId so the provider deduplicates + // a landed attempt instead of double-sending it. + const providerKey = KEYED_OPERATIONS.has(row.operation) + ? row.providerKey ?? providerKeyFor(row.commandId, 1) + : undefined; + + if (row.expiresAt !== undefined && row.expiresAt.getTime() <= now().getTime()) { + await store.completeCommand(row.commandId, "expired", { providerKey, errorDetail: "Command expired before execution." }); + return; + } + const gate = await dependencyGate(row); + if (gate === "wait") { + await store.completeCommand(row.commandId, "queued", { + nextAttemptAt: new Date(now().getTime() + DEPENDENCY_WAIT_MS), + }); + return; + } + if (gate !== undefined) { + await settle(row, gate, providerKey); + return; + } + const executor = executors[row.operation]; + if (!executor) { + await settle(row, { kind: "failed", errorCode: "unknown_operation", errorDetail: `No executor for ${row.operation}.` }, providerKey); + return; + } + let execution: CommandExecution; + try { + const command = toCommand(row); + // The executor sees the same provider key the row will settle with, so + // keyed writes always carry a stable clientMessageId. + execution = await executor( + providerKey !== undefined ? { ...command, providerKey } : command, + deps.commandDeps, + ); + } catch (error) { + if (error instanceof ZodError) { + execution = { + kind: "failed", + errorCode: "invalid_args", + errorDetail: error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ").slice(0, 1_000), + }; + } else { + log({ + level: "error", msg: "photon_executor_threw", op: row.operation, + error: error instanceof Error ? error.message : String(error), + }); + execution = { kind: "failed", errorCode: "executor_error", errorDetail: error instanceof Error ? error.message : String(error) }; + } + } + await settle(row, execution, providerKey); + } + + return { + executors, + async runOnce() { + const claimed = await store.claimCommands(deps.workerId, deps.batchSize, deps.claimMs); + let ran = 0; + // Sequential inside the batch: dependents observe earlier completions. + for (const row of claimed) { + try { + await runRow(row); + ran++; + } catch (error) { + log({ + level: "error", msg: "photon_outbox_row_failed", commandId: row.commandId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + return ran; + }, + }; +} diff --git a/server/photon/policy.ts b/server/photon/policy.ts new file mode 100644 index 0000000..3bfa251 --- /dev/null +++ b/server/photon/policy.ts @@ -0,0 +1,196 @@ +import type { PhotonConfig } from "./config.js"; +import type { AdmissionDecision, PhotonEventEnvelope, PhotonNormalizedEvent } from "./contracts.js"; +import { handleKey, normalizeHandle, parseActorKey, type CanonicalHandle } from "./identity.js"; + +/** + * Event admission and authorization policy. Pure functions over the verified + * configuration and durable conversation state — no I/O, fully testable. + */ + +export interface ConversationState { + readonly bindingId?: string; + readonly scope: "direct" | "group"; + readonly generation: number; + readonly membership: readonly string[]; + readonly membershipVersion: number; + readonly initiatingPrincipal?: string; + readonly retiredAt?: string; +} + +export function toConversationState( + row: ConversationState | undefined, + fallbackScope?: ConversationScope, +): ConversationState | undefined { + if (!row && !fallbackScope) return undefined; + return { + bindingId: row?.bindingId, + scope: row?.scope ?? fallbackScope ?? "direct", + generation: row?.generation ?? 1, + membership: row?.membership ?? [], + membershipVersion: row?.membershipVersion ?? 0, + initiatingPrincipal: row?.initiatingPrincipal, + retiredAt: row?.retiredAt, + }; +} + +/** + * Computes the next group membership roster for a participant delta event, + * or `undefined` when the event is not a membership delta or leaves the + * roster unchanged. + */ +export function applyMembershipDelta( + membership: readonly string[], + event: PhotonNormalizedEvent, +): string[] | undefined { + if ( + event.family !== "group" || + (event.type !== "participantAdded" && + event.type !== "participantRemoved" && + event.type !== "participantLeft") + ) + return undefined; + const handle = normalizeHandle(event.participant?.address); + if (!handle) return undefined; + const key = handleKey(handle); + if (event.type === "participantAdded") { + return membership.includes(key) ? undefined : [...membership, key]; + } + return membership.includes(key) ? membership.filter((member) => member !== key) : undefined; +} + +export function admitSender(config: PhotonConfig, handle: CanonicalHandle | null): boolean { + if (!handle) return false; + if (handle.kind === "phone") return config.allowedSenders.includes(handle.value); + return config.allowedEmailSenders.includes(handle.value); +} + +export function admitGroup(config: PhotonConfig, chatGuid: string | undefined): boolean { + return !!chatGuid && config.allowedGroups.includes(chatGuid); +} + +export type ConversationScope = "direct" | "group"; + +/** + * The single scope classification every layer shares. A chat is a group + * when EITHER the durable binding says so OR its guid is admitted via + * `allowedGroups` — group evidence always wins, direct only when neither + * applies. The precedence is deliberately asymmetric: a stored "direct" + * binding can never demote a chat the allowlist now governs as a group, + * and a retired binding's scope still describes the chat for its next + * generation. Admission, dispatch gating and the internal route must all + * resolve scope through this function — never re-spell the precedence. + */ +export function classifyScope( + config: PhotonConfig, + conversation: Pick | undefined, + chatGuid: string | undefined, +): ConversationScope { + if (conversation?.scope === "group") return "group"; + return admitGroup(config, chatGuid) ? "group" : "direct"; +} + +function denied(reason: string, updatesMembership = false): AdmissionDecision { + return { disposition: "denied", reason, dispatchAction: "none", updatesMembership }; +} + +function stateOnly(reason: string, updatesMembership = false): AdmissionDecision { + return { disposition: "state_only", reason, dispatchAction: "state_apply", updatesMembership }; +} + +/** + * Decides the disposition of one normalized event. Self-origin events never + * start agent turns; they may still update outbound reconciliation state. + * An event without a verified actor can update state but cannot authorize + * a tool call or pending interaction. + */ +export function admitEvent( + config: PhotonConfig, + envelope: PhotonEventEnvelope, + conversation?: ConversationState, +): AdmissionDecision { + const event = envelope.event; + const chatGuid = envelope.chatGuid; + const isGroup = classifyScope(config, conversation, chatGuid) === "group"; + + // A bound group conversation whose chat lost admission is denied; the + // allowlisted-guid-on-direct-conversation conflict is unreachable by + // construction now that classifyScope is the single classifier. + if (isGroup && !admitGroup(config, chatGuid)) return denied("group_not_admitted"); + + const actor = parseActorKey(envelope.actor); + + switch (event.family) { + case "message": { + if (event.type === "received") { + const message = event.message; + if (message.isSystemMessage || message.isServiceMessage || message.itemType !== "normal") + return denied("system_message"); + if (envelope.isFromMe || message.isFromMe || message.isAutoReply) + return stateOnly("self_origin_echo"); + // A present destinationCallerId must agree with the verified managed + // line; a foreign caller ID means the event crossed account scope. + if (message.destinationCallerId !== undefined) { + const caller = normalizeHandle(message.destinationCallerId); + if (!caller || caller.kind !== "phone" || caller.value !== config.phoneNumber) + return denied("destination_caller_mismatch"); + } + const sender = normalizeHandle(message.sender?.address) ?? actor; + if (!sender) return denied("sender_unverified"); + if (isGroup) { + if (conversation?.membership.length && !conversation.membership.includes(handleKey(sender))) + return denied("actor_not_in_membership"); + return { disposition: "admitted", reason: "group_message", dispatchAction: "send", actor: sender, scope: "group" }; + } + if (!admitSender(config, sender)) return denied("sender_not_allowed"); + return { disposition: "admitted", reason: "direct_message", dispatchAction: "send", actor: sender, scope: "direct" }; + } + if (event.type === "edited" || event.type === "unsent" || event.type === "read" + || event.type === "reactionAdded" || event.type === "reactionRemoved" + || event.type === "stickerPlaced") { + if (envelope.isFromMe) return stateOnly("self_origin_mutation"); + if (isGroup) { + if (actor && conversation?.membership.length && !conversation.membership.includes(handleKey(actor))) + return stateOnly("non_member_actor_state_only", true); + return stateOnly(`group_${event.type}`); + } + if (actor && !admitSender(config, actor)) return stateOnly("actor_not_allowed"); + return stateOnly(`direct_${event.type}`); + } + return denied("unsupported_message_variant"); + } + case "chat": + return stateOnly(`chat_${event.type}`); + case "group": { + if (event.type === "participantAdded" || event.type === "participantRemoved" + || event.type === "participantLeft" || event.type === "displayNameChanged" + || event.type === "iconChanged" || event.type === "iconRemoved") + return stateOnly(`group_${event.type}`, true); + return stateOnly("group_changed"); + } + case "poll": + if (event.type === "created" || event.type === "optionAdded") + return stateOnly(`poll_${event.type}`); + // Votes may resolve an explicit pending contract; the default stays state-only. + return stateOnly(`poll_${event.type}`); + case "location": + return stateOnly("location_update"); + case "catchup": + return stateOnly("catchup_complete_marker"); + default: + return denied("unknown_family"); + } +} + +/** Whether an admitted group actor may authorize sensitive tool execution. */ +export function groupActorAuthorized( + conversation: ConversationState, + actor: CanonicalHandle | null, +): boolean { + if (!actor) return false; + return conversation.membership.length === 0 || conversation.membership.includes(handleKey(actor)); +} + +/** Sensitive operations must run in the verified private direct conversation. */ +export function requiresPrivateContinuation(scope: "direct" | "group" | undefined): boolean { + return scope !== "direct"; +} diff --git a/server/photon/principal.ts b/server/photon/principal.ts new file mode 100644 index 0000000..f017c95 --- /dev/null +++ b/server/photon/principal.ts @@ -0,0 +1,68 @@ +import { createHash } from "node:crypto"; + +import type { SessionAuthContext } from "eve/context"; + +/** + * The one principal-id contract. Verified Photon principals are `photon:` + + * a hex digest — every consumer of a caller-supplied principal (link + * ownership, credential vaults, message auth) shares this check so the + * format can never silently diverge between verifiers. + * + * This module is intentionally import-free (no `.js` relative imports): the + * Next.js bundle reaches it through server/link and server/onepassword, and + * Turbopack does not remap `.js` extensions to `.ts` sources. Keep it a leaf. + */ + +export const PHOTON_AUTHENTICATOR = "photon-imessage"; +export const PHOTON_ISSUER = "photon"; +export const PHOTON_PRINCIPAL_TYPE = "user"; +export const IDENTITY_VERSION = "v1"; + +export const PHOTON_PRINCIPAL_PATTERN = /^photon:[0-9a-f]{8,128}$/; + +export function isVerifiedPhotonPrincipal( + principal: { readonly type: string; readonly issuer?: string; readonly id?: string }, +): principal is { readonly type: "user"; readonly issuer: string; readonly id: string } { + return ( + principal.type === PHOTON_PRINCIPAL_TYPE + && principal.issuer === PHOTON_ISSUER + && typeof principal.id === "string" + && PHOTON_PRINCIPAL_PATTERN.test(principal.id) + ); +} + +export function isPhotonAuth( + auth: Pick | null | undefined, +): boolean { + return auth?.authenticator === PHOTON_AUTHENTICATOR && auth.issuer === PHOTON_ISSUER; +} + +/** + * The full session-auth check — the `SessionAuthContext`-shaped variant of + * `isVerifiedPhotonPrincipal` above. Field names differ (principalType / + * principalId) but the contract is identical, and it adds the authenticator + * the principal shape alone cannot assert. + */ +export function isVerifiedPhotonAuth( + auth: SessionAuthContext | null | undefined, +): auth is SessionAuthContext { + return ( + auth?.principalType === PHOTON_PRINCIPAL_TYPE + && auth.authenticator === PHOTON_AUTHENTICATOR + && auth.issuer === PHOTON_ISSUER + && PHOTON_PRINCIPAL_PATTERN.test(auth.principalId) + ); +} + +/** + * Shallow identity-tuple serializer — array order and scalar values only. + * For deep canonical serialization of provider payloads see `stableJson` + * in normalize.ts; the names are deliberately distinct. + */ +export function canonicalTupleJson(value: readonly unknown[]): string { + return JSON.stringify(value); +} + +export function sha256Hex(value: string): string { + return createHash("sha256").update(value, "utf8").digest("hex"); +} diff --git a/server/photon/projection.ts b/server/photon/projection.ts new file mode 100644 index 0000000..e473cf2 --- /dev/null +++ b/server/photon/projection.ts @@ -0,0 +1,430 @@ +import { randomUUID } from "node:crypto"; + +import { z } from "zod"; + +import { PHOTON_EVENTS_PATH, type PhotonConfig } from "./config.js"; +import type { PhotonCommand, ProjectedOutput, ProjectedOutputKind } from "./contracts.js"; +import { semanticKeyFor, sha256Hex } from "./identity.js"; +import { stableJson } from "./normalize.js"; +import type { ConversationRow, PhotonStore } from "./store.js"; + +/** + * Eve -> provider projector. Reads each bound session's durable event stream + * through the internal route, converts output events into commands, and + * advances the projector offset atomically with the enqueue so a crash can + * never double-materialize a visible send. + */ + +/** A single text bubble stays readable below this bound; longer sends chunk. */ +const MAX_TEXT_CHARS = 4_000; +/** Retained materialized-key window for projector offset bookkeeping. */ +const MATERIALIZED_WINDOW = 256; +/** Prompt interactions outlive neither the session nor the 24h deadline. */ +const PROMPT_TTL_MS = 24 * 60 * 60 * 1000; + +const eventsReadSchema = z.object({ + events: z.array(z.unknown()), + tailIndex: z.number().int().min(0), + stranded: z.boolean().optional(), +}).strict(); + +/** Minimal Eve stream-event shape the projector consumes (defensive parse). */ +export interface EveStreamEvent { + readonly type: string; + readonly data?: Record; + readonly meta?: { readonly id?: string; readonly at?: string }; +} + +const INPUT_REQUEST_KINDS = new Set(["question", "session-limit", "tool-approval"]); + +function isEveEvent(value: unknown): value is EveStreamEvent { + return typeof value === "object" && value !== null + && typeof (value as { type?: unknown }).type === "string"; +} + +export interface ProjectorDeps { + readonly store: PhotonStore; + readonly config: PhotonConfig; + readonly sourceId: string; + readonly fetcher?: typeof fetch; + readonly batchSize?: number; + readonly log?: (entry: Record) => void; + readonly now?: () => Date; +} + +export interface PhotonProjector { + /** One scan/materialize round over every bound session. */ + runOnce(): Promise; +} + +export interface PromptInsert { + readonly requestRef: string; + readonly kind: "prompt" | "authorization"; + readonly permittedActor?: string; + readonly target: Record; + readonly responseMapping: Record; +} + +export interface ProjectionContext { + readonly sessionId: string; + readonly conversation: ConversationRow; + /** Command ids enqueued this batch, for dependsOn chaining. */ + lastCommandId?: string; + /** Turns that already emitted a typing-on marker. */ + readonly typingOn: Set; + readonly materialized: string[]; + readonly registerPrompts: PromptInsert[]; +} + +function commandIdFor(semanticKey: string): string { + return `cmd:${sha256Hex(semanticKey).slice(0, 48)}`; +} + +function makeOutput( + ctx: ProjectionContext, + input: { + kind: ProjectedOutputKind; + operation?: PhotonCommand["operation"]; + args?: Record; + turnId?: string; + step?: string | number; + partId?: string; + requestId?: string; + payload?: Record; + }, +): ProjectedOutput { + const semanticKey = semanticKeyFor({ + sessionId: ctx.sessionId, + turnId: input.turnId, + step: input.step, + outputKind: input.kind, + partId: input.partId, + requestId: input.requestId, + }); + return { + kind: input.kind, + semanticKey, + operation: input.operation, + args: input.args, + payload: input.payload ?? {}, + }; +} + +function textOutput( + ctx: ProjectionContext, + text: string, + turnId: string | undefined, + step: number | undefined, +): ProjectedOutput[] { + const trimmed = text.trim(); + if (!trimmed) return []; + const chat = ctx.conversation.chatGuid; + if (trimmed.length <= MAX_TEXT_CHARS) { + return [makeOutput(ctx, { + kind: "assistant_text", operation: "messages.sendText", + args: { chat, text: trimmed }, turnId, step, + payload: { text: trimmed }, + })]; + } + // Long assistant output chunks into one ordered multipart send. + const parts: string[] = []; + for (let at = 0; at < trimmed.length; at += MAX_TEXT_CHARS) + parts.push(trimmed.slice(at, at + MAX_TEXT_CHARS)); + return [makeOutput(ctx, { + kind: "assistant_part", operation: "messages.sendMultipart", + args: { chat, parts: parts.map((text) => ({ text })) }, turnId, step, + payload: { partCount: parts.length }, + })]; +} + +/** Typing markers deduplicate per turn: one on, one off at the boundary. */ +function typingOutput( + ctx: ProjectionContext, + turnId: string, + isTyping: boolean, +): ProjectedOutput[] { + if (isTyping) { + if (ctx.typingOn.has(turnId)) return []; + ctx.typingOn.add(turnId); + } else { + ctx.typingOn.delete(turnId); + } + return [makeOutput(ctx, { + kind: "typing", operation: "chats.setTyping", + args: { chat: ctx.conversation.chatGuid, isTyping }, turnId, + // on/off must key distinctly or the second marker dedups away. + partId: isTyping ? "on" : "off", + payload: { isTyping }, + })]; +} + +function groupNeedsPrivateNotice(scope: "direct" | "group"): boolean { + return scope !== "direct"; +} + +/** + * Converts one Eve event into projected outputs. Returns `[]` for events + * that carry no visible or command-shaped effect. + */ +export function projectEvent(ctx: ProjectionContext, event: EveStreamEvent): ProjectedOutput[] { + const data = event.data ?? {}; + const turnId = typeof data.turnId === "string" ? data.turnId : undefined; + const step = typeof data.stepIndex === "number" ? data.stepIndex : undefined; + const chat = ctx.conversation.chatGuid; + const scope = ctx.conversation.scope; + + switch (event.type) { + case "message.completed": { + const message = typeof data.message === "string" ? data.message : ""; + return textOutput(ctx, message, turnId, step); + } + case "turn.started": + return [ + // The turn consuming the input is the delivery-observed point where + // the inbound message can be marked read. + makeOutput(ctx, { + kind: "read_marker", operation: "chats.markRead", + args: { chat }, turnId, payload: {}, + }), + ...typingOutput(ctx, turnId ?? "", true), + ]; + case "message.appended": + return typingOutput(ctx, turnId ?? "", true); + case "turn.completed": + case "turn.failed": + case "turn.cancelled": + case "turn.waiting": + case "session.waiting": { + const outputs = typingOutput(ctx, turnId ?? "", false); + if (event.type === "turn.failed") { + outputs.push(...textOutput(ctx, "[Bell] The last step failed — try again.", turnId, step) + .map((out) => ({ ...out, kind: "lifecycle_notice" as const }))); + } + return outputs; + } + case "session.failed": { + const outputs = typingOutput(ctx, turnId ?? "", false); + outputs.push(...textOutput(ctx, "[Bell] This conversation ended unexpectedly.", turnId, step) + .map((out) => ({ ...out, kind: "lifecycle_notice" as const }))); + return outputs; + } + case "session.completed": + return typingOutput(ctx, turnId ?? "", false); + case "input.requested": { + const requests = Array.isArray(data.requests) ? data.requests : []; + const outputs: ProjectedOutput[] = []; + for (const request of requests) { + if (typeof request !== "object" || request === null) continue; + const req = request as Record; + const kind = typeof req.kind === "string" ? req.kind : ""; + if (!INPUT_REQUEST_KINDS.has(kind)) continue; + const requestId = typeof req.requestId === "string" ? req.requestId : undefined; + const prompt = typeof req.prompt === "string" ? req.prompt : ""; + const options = Array.isArray(req.options) + ? req.options.filter((o): o is Record => typeof o === "object" && o !== null) + : []; + const lines = options + .map((o, i) => `${i + 1}. ${typeof o.label === "string" ? o.label : String(o.id ?? i + 1)}`) + .join("\n"); + const body = lines ? `${prompt}\n\n${lines}\n\nReply with your choice.` : prompt; + if (kind === "tool-approval" && groupNeedsPrivateNotice(scope)) { + // A group chat never receives a credential/approval flow: the + // instruction points at the private continuation instead. + outputs.push(...textOutput( + ctx, "[Bell] This request needs approval — please continue in a private message.", + turnId, step, + ).map((out) => ({ ...out, kind: "lifecycle_notice" as const }))); + continue; + } + outputs.push(...textOutput(ctx, body, turnId, step) + .map((out) => ({ + ...out, + kind: kind === "tool-approval" ? "authorization" as const : "input_request" as const, + }))); + // The durable prompt row lets the next inbound reply resolve this + // request exactly once (see interactions.ts). Even tool-approval + // registers as a plain prompt: it is Eve's HITL gate, not an OAuth + // consent, and only ever delivers in a verified direct scope. + ctx.registerPrompts.push({ + requestRef: requestId ?? `req:${randomUUID()}`, + kind: "prompt", + target: { requestId: requestId ?? null, inputKind: kind }, + responseMapping: { + options: options.map((o, i) => ({ + id: typeof o.id === "string" && o.id.length > 0 ? o.id : String(i + 1), + label: typeof o.label === "string" && o.label.length > 0 ? o.label : String(o.id ?? i + 1), + })), + optionIds: options.map((o) => String(o.id ?? "")), + labels: options.map((o) => String(o.label ?? "")), + }, + }); + } + return outputs; + } + case "authorization.required": { + const name = typeof data.name === "string" ? data.name : "a service"; + const description = typeof data.description === "string" ? data.description : ""; + const webhookUrl = typeof data.webhookUrl === "string" ? data.webhookUrl : undefined; + if (scope !== "direct") { + // Private handoff URLs and credential flows never enter a group. + return textOutput( + ctx, "[Bell] This request needs a private sign-in — please message me directly.", + turnId, step, + ).map((out) => ({ ...out, kind: "lifecycle_notice" as const })); + } + const body = webhookUrl + ? `[Bell] Sign-in required for ${name}: ${description}\n\n${webhookUrl}` + : `[Bell] Sign-in required for ${name}: ${description}`; + return textOutput(ctx, body, turnId, step) + .map((out) => ({ ...out, kind: "authorization" as const })); + } + default: + return []; + } +} + +export function createProjector(deps: ProjectorDeps): PhotonProjector { + const { store, config, sourceId } = deps; + const fetcher = deps.fetcher ?? fetch; + const now = deps.now ?? (() => new Date()); + const log = deps.log ?? (() => undefined); + const batchSize = deps.batchSize ?? 32; + + async function readEvents(sessionId: string, startIndex: number) { + try { + const response = await fetcher(`${config.internalOrigin}${PHOTON_EVENTS_PATH}`, { + method: "POST", + signal: AbortSignal.timeout(15_000), + headers: { + authorization: `Bearer ${config.internalSecret}`, + "content-type": "application/json", + }, + body: JSON.stringify({ sessionId, startIndex }), + }); + if (!response.ok) return undefined; + const parsed = eventsReadSchema.safeParse(await response.json().catch(() => null)); + return parsed.success ? parsed.data : undefined; + } catch { + return undefined; + } + } + + async function processConversation(conversation: ConversationRow): Promise { + const sessionId = conversation.eveSessionId; + if (!sessionId) return 0; + const offset = await store.getProjectorOffset(sourceId, sessionId); + const startIndex = offset?.streamPosition ?? 0; + const page = await readEvents(sessionId, startIndex); + if (!page) return 0; + if (page.stranded) { + // The session's durable stream is gone; the binding retires so the + // next inbound starts a fresh generation. + await store.retireConversation(conversation.bindingId); + await store.markDeadlineRetired(sessionId, "session_stranded"); + return 0; + } + + const ctx: ProjectionContext = { + sessionId, + conversation, + typingOn: new Set(), + materialized: [], + registerPrompts: [], + }; + const outputs: ProjectedOutput[] = []; + for (const raw of page.events) { + if (!isEveEvent(raw)) { + log({ level: "warn", msg: "photon_projector_skipped_event", sessionId }); + continue; + } + for (const output of projectEvent(ctx, raw)) { + outputs.push(output); + ctx.materialized.push(output.semanticKey); + } + } + if (outputs.length === 0 && ctx.materialized.length === 0 && page.tailIndex === startIndex) + return 0; + + const commandIds: string[] = []; + await store.withTransaction(async (db) => { + for (const output of outputs) { + if (!output.operation || !output.args) continue; + const argsDigest = sha256Hex(stableJson([output.operation, output.args])); + // A prior materialization may already own this semantic key: same + // digest chains onto it; different args would poison the tx, so the + // output is dropped and logged instead of aborting the batch. + const existing = await db.getCommandBySemanticKey(output.semanticKey); + if (existing) { + if (existing.argsDigest !== argsDigest) { + log({ + level: "warn", msg: "photon_semantic_key_conflict", + semanticKey: output.semanticKey, operation: output.operation, + }); + continue; + } + ctx.lastCommandId = existing.commandId; + continue; + } + const command: PhotonCommand = { + commandId: commandIdFor(output.semanticKey), + operation: output.operation, + args: output.args as PhotonCommand["args"], + argsDigest, + initiatorPrincipal: conversation.initiatingPrincipal, + audience: conversation.scope, + sourceId, + chatGuid: conversation.chatGuid, + semanticKey: output.semanticKey, + dependsOn: ctx.lastCommandId ? [ctx.lastCommandId] : [], + }; + const enqueued = await db.enqueueCommand(command); + ctx.lastCommandId = enqueued.commandId; + commandIds.push(enqueued.commandId); + } + for (const prompt of ctx.registerPrompts) { + await db.createInteraction({ + sourceId, + bindingId: conversation.bindingId, + chatGuid: conversation.chatGuid, + generation: conversation.generation, + kind: prompt.kind, + requestRef: prompt.requestRef, + permittedActor: prompt.permittedActor, + target: prompt.target, + responseMapping: prompt.responseMapping, + expiresAt: new Date(now().getTime() + PROMPT_TTL_MS), + }); + } + const materializedKeys = [ + ...(offset?.materializedKeys ?? []), + ...ctx.materialized, + ].slice(-MATERIALIZED_WINDOW); + await db.advanceProjector({ + sourceId, + eveSessionId: sessionId, + streamPosition: page.tailIndex, + materializedKeys, + }); + }); + return commandIds.length; + } + + return { + async runOnce() { + let enqueued = 0; + const conversations = (await store.activeConversations(sourceId)).slice(0, batchSize); + for (const conversation of conversations) { + try { + enqueued += await processConversation(conversation); + } catch (error) { + log({ + level: "error", msg: "photon_projector_failed", + bindingId: conversation.bindingId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + return enqueued; + }, + }; +} diff --git a/server/photon/store.ts b/server/photon/store.ts new file mode 100644 index 0000000..798a414 --- /dev/null +++ b/server/photon/store.ts @@ -0,0 +1,440 @@ +import { Pool, type PoolClient } from "pg"; + +import type { + AdmissionDisposition, + CommandStatus, + PhotonCommand, + PhotonCommandOp, + PhotonEventEnvelope, +} from "./contracts.js"; +import { createCommands } from "./store/commands.js"; +import { createConversations } from "./store/conversations.js"; +import { createInbox } from "./store/inbox.js"; +import { createInteractions } from "./store/interactions.js"; +import { createResources } from "./store/resources.js"; +import { createSessions } from "./store/sessions.js"; +import { createSources } from "./store/sources.js"; + +/** + * Typed facade over the Photon channel tables. Persistence is separate from + * transport: callers pass already-normalized envelopes and commands; the + * store enforces deduplication, claim fencing and ordering in SQL. The + * implementation is split by aggregate under ./store/ — this facade keeps + * the contract and row shapes in one place. + */ +export interface PhotonStore { + readonly pool: Pool; + withTransaction(work: (db: PhotonDb) => Promise): Promise; + close(): Promise; + + /* sources */ + enrollSource(input: SourceEnrollment): Promise; + getSource(sourceId: string): Promise; + /** Most recently touched source of a project (health-route probe). */ + getLatestSource(projectId: string): Promise; + setSourceStatus(sourceId: string, status: SourceStatus): Promise; + updateCaptureCursor(sourceId: string, cursor: string, coverage?: Record): Promise; + acquireLease(sourceId: string, ownerToken: string, leaseMs: number): Promise; + renewLease(sourceId: string, ownerToken: string, epoch: number, leaseMs: number): Promise; + releaseLease(sourceId: string, ownerToken: string, epoch: number): Promise; + updateSourceHealth(sourceId: string, health: Record): Promise; + updateCredentialExpiry(sourceId: string, expiresAt: Date): Promise; + + /* inbox */ + captureEvent(input: InboxInsert): Promise; + getInboxRow(ref: InboxRef): Promise; + updateDisposition(ref: InboxRef, disposition: AdmissionDisposition, lastError?: string): Promise; + markInboxProcessing(ref: InboxRef, state: InboxProcessing, lastError?: string): Promise; + /** + * Chat scopes (chat_guid, or null for chat-less state events) holding + * captured admitted/state_only inbox rows, oldest pending row first. + */ + dispatchableChatScopes(sourceId: string, limit: number): Promise<(string | null)[]>; + /** + * Captured admitted/state_only rows for one scope in provider order. + * A null chatGuid covers chat-less rows (state_only by construction). + */ + dispatchableForChat(sourceId: string, chatGuid: string | null, limit: number): Promise; + maxSequenceNum(sourceId: string, logEpoch: string): Promise; + activationBoundary(sourceId: string): Promise; + setActivationBoundary(sourceId: string, boundary: string): Promise; + inboxStats(sourceId: string): Promise<{ pending: number; quarantined: number; oldestPendingAgeSec: number | null }>; + + /* conversations */ + getConversation(sourceId: string, chatGuid: string): Promise; + getConversationById(bindingId: string): Promise; + upsertConversation(input: ConversationUpsert): Promise; + bindEveSession(bindingId: string, eveSessionId: string): Promise; + updateMembership(bindingId: string, membership: readonly string[]): Promise; + touchConversation(bindingId: string): Promise; + retireConversation(bindingId: string): Promise; + /** All non-retired bindings for a source (used by projection and deadline sweep). */ + activeConversations(sourceId: string): Promise; + findConversationBySession(eveSessionId: string): Promise; + + /* dispatch intents */ + upsertIntent(input: IntentInsert): Promise; + getIntentByKey(dispatchKey: string): Promise; + updateIntent(intentId: string, patch: IntentPatch): Promise; + pendingIntents(sourceId: string, limit: number): Promise; + + /* commands */ + enqueueCommand(command: PhotonCommand): Promise<{ commandId: string; deduplicated: boolean }>; + getCommand(commandId: string): Promise; + getCommandBySemanticKey(semanticKey: string): Promise; + claimCommands(workerId: string, limit: number, claimMs: number): Promise; + completeCommand(commandId: string, status: CommandStatus, patch: CommandPatch): Promise; + commandStats(sourceId: string): Promise<{ queued: number; inFlight: number; ambiguous: number }>; + + /* resources */ + upsertResource(sourceId: string, kind: ResourceKind, resourceId: string, data: Record, opts?: { tombstone?: boolean; expectedRevision?: number }): Promise; + getResource(sourceId: string, kind: ResourceKind, resourceId: string): Promise; + + /* artifacts */ + putArtifact(input: ArtifactInsert): Promise; + getArtifact(artifactId: string): Promise; + /** Newest artifact staged for a provider attachment in one conversation — metadata only. */ + findArtifactByAttachment(sourceId: string, chatGuid: string, attachmentGuid: string): Promise; + + /* pending interactions */ + createInteraction(input: InteractionInsert): Promise; + openInteractions(sourceId: string, chatGuid: string): Promise; + resolveInteraction(interactionId: string, actor: string, resolution: Record): Promise; + expireInteractions(now: Date): Promise; + + /* projector offsets */ + getProjectorOffset(sourceId: string, eveSessionId: string): Promise; + advanceProjector(input: ProjectorAdvance): Promise; + + /* session deadlines */ + registerDeadline(eveSessionId: string, bindingId: string | undefined, deadlineAt: Date): Promise; + dueDeadlines(now: Date, limit: number): Promise; + markDeadlineRetired(eveSessionId: string, reason: string): Promise; + getDeadline(eveSessionId: string): Promise; +} + +export type PhotonDb = Omit; + +/* ------------------------------------------------------------------ */ +/* Row types */ +/* ------------------------------------------------------------------ */ + +export type SourceStatus = "pending" | "active" | "suspended" | "retired"; + +export interface SourceRow { + sourceId: string; + projectId: string; + instanceId: string; + accountScope: string; + phoneNumber: string; + endpoint: string; + logEpoch: string; + activationBoundary?: string; + captureCursor?: string; + coverage: Record; + ownerToken?: string; + leaseEpoch: number; + leaseExpiresAt?: Date; + credentialExpiresAt?: Date; + health: Record; + status: SourceStatus; +} + +export interface SourceEnrollment { + sourceId: string; + projectId: string; + instanceId: string; + accountScope: string; + phoneNumber: string; + endpoint: string; + logEpoch: string; +} + +export type LeaseResult = + | { readonly acquired: true; readonly epoch: number } + | { readonly acquired: false; readonly owner?: string }; + +export interface InboxRef { + sourceId: string; + logEpoch: string; + sequence: string; +} + +export interface InboxInsert extends InboxRef { + sequenceNum: string; + payloadHash: string; + envelope?: PhotonEventEnvelope; + tombstone?: boolean; + eventFamily?: string; + eventType?: string; + chatGuid?: string; + actor?: string; + targetGuid?: string; + isFromMe?: boolean; + occurredAt?: string; + sourceKind: "live" | "catchup"; + disposition?: AdmissionDisposition; +} + +export type InboxProcessing = "captured" | "dispatched" | "settled" | "failed" | "quarantined"; + +export type InboxCapture = + | { readonly kind: "inserted"; readonly ref: InboxRef } + | { readonly kind: "duplicate"; readonly ref: InboxRef } + | { readonly kind: "integrity_conflict"; readonly ref: InboxRef; readonly existingHash: string }; + +export interface InboxRow extends InboxRef { + sequenceNum: string; + payloadHash: string; + envelope?: PhotonEventEnvelope; + tombstone: boolean; + eventFamily?: string; + eventType?: string; + chatGuid?: string; + actor?: string; + targetGuid?: string; + isFromMe?: boolean; + occurredAt?: string; + receivedAt: string; + sourceKind: "live" | "catchup"; + disposition: AdmissionDisposition | "pending"; + processingState: InboxProcessing; + attempts: number; + lastError?: string; +} + +export interface ConversationUpsert { + bindingId: string; + sourceId: string; + chatGuid: string; + generation: number; + scope: "direct" | "group"; + eveAddress: string; + eveSessionId?: string; + initiatingPrincipal?: string; + membership?: readonly string[]; +} + +export interface ConversationRow { + bindingId: string; + sourceId: string; + chatGuid: string; + generation: number; + scope: "direct" | "group"; + eveAddress: string; + eveSessionId?: string; + initiatingPrincipal?: string; + membershipVersion: number; + membership: string[]; + lastTurnAt?: string; + retiredAt?: string; + createdAt: string; +} + +export interface IntentInsert extends InboxRef { + intentId: string; + action: "send" | "respond" | "state_apply"; + eveSessionId?: string; + dispatchKey: string; + inputDigest: string; +} + +export interface IntentPatch { + status?: "pending" | "accepted" | "ambiguous" | "rejected" | "quarantined"; + eveSessionId?: string; + deliveryId?: string; + lastError?: string; +} + +export interface IntentRow extends IntentInsert { + status: "pending" | "accepted" | "ambiguous" | "rejected" | "quarantined"; + deliveryId?: string; + attempts: number; + lastError?: string; + createdAt: string; +} + +export interface CommandRow { + commandId: string; + operation: PhotonCommandOp; + args: Record; + argsDigest: string; + initiatorPrincipal?: string; + audience?: "direct" | "group" | "system"; + sourceId: string; + chatGuid: string; + target?: Record; + providerKey?: string; + semanticKey: string; + dependsOn: string[]; + status: CommandStatus; + attemptCount: number; + nextAttemptAt: Date; + claimedBy?: string; + result?: unknown; + providerReceipt?: unknown; + errorCode?: string; + errorDetail?: string; + expiresAt?: Date; + createdAt: Date; +} + +export interface CommandPatch { + result?: unknown; + providerReceipt?: unknown; + errorCode?: string; + errorDetail?: string; + nextAttemptAt?: Date; + providerKey?: string; + /** + * Unlike the COALESCE'd fields above, claimed_by is always overwritten: + * settling a command releases the claim so a row is never left attributed + * to a worker that no longer owns it. + */ + claimedBy?: string; +} + +/** + * The canonical resource-kind list — deliberately the only one. The table + * carries no CHECK constraint (a duplicated list already diverged once), so + * this union is the contract every upsertResource/getResource call site is + * type-checked against. `miniapp_session_by_message` is the message-guid -> + * card-session index the tool layer updates through. Raw bytes live in + * bell_photon_artifacts via putArtifact, never in a resource row. + */ +export type ResourceKind = + | "message" + | "attachment" + | "poll" + | "miniapp_session" + | "miniapp_session_by_message" + | "chat"; + +export interface ResourceRow { + sourceId: string; + kind: ResourceKind; + resourceId: string; + data: Record; + revision: number; + tombstone: boolean; + updatedAt: Date; +} + +export interface ArtifactInsert { + sourceId: string; + chatGuid: string; + attachmentGuid?: string; + fileName?: string; + mediaType: string; + data: Uint8Array; +} + +/** Artifact metadata without loading the stored bytes. */ +export interface ArtifactRef { + artifactId: string; + sourceId: string; + chatGuid: string; + attachmentGuid?: string; + fileName?: string; + mediaType: string; + byteSize: number; +} + +export interface ArtifactRow extends ArtifactInsert { + artifactId: string; + byteSize: number; + createdAt: Date; +} + +export interface InteractionInsert { + sourceId: string; + bindingId?: string; + chatGuid: string; + generation?: number; + kind: "prompt" | "authorization" | "poll" | "reaction_contract"; + requestRef: string; + permittedActor?: string; + target?: Record; + responseMapping?: Record; + expiresAt?: Date; +} + +export interface InteractionRow extends InteractionInsert { + interactionId: string; + status: "pending" | "resolved" | "expired" | "cancelled"; + resolvedActor?: string; + resolvedAt?: Date; + resolution?: Record; + createdAt: Date; +} + +export interface ProjectorAdvance { + sourceId: string; + eveSessionId: string; + streamPosition: number; + materializedKeys: readonly string[]; +} + +export interface ProjectorRow extends ProjectorAdvance { + updatedAt: Date; +} + +export interface DeadlineRow { + eveSessionId: string; + bindingId?: string; + deadlineAt: Date; + retiredAt?: Date; + retirementReason?: string; +} + +/* ------------------------------------------------------------------ */ +/* Implementation */ +/* ------------------------------------------------------------------ */ + +export function createPhotonStore(databaseUrl: string, poolSize = 6): PhotonStore { + const pool = new Pool({ connectionString: databaseUrl, max: poolSize }); + + function build(client: PoolClient | Pool): PhotonDb { + return { + ...createSources(client), + ...createInbox(client), + ...createConversations(client), + ...createCommands(client), + ...createResources(client), + ...createInteractions(client), + ...createSessions(client), + }; + } + + const facade: PhotonStore = { + pool, + ...build(pool), + async withTransaction(work) { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const result = await work(build(client)); + await client.query("COMMIT"); + return result; + } catch (error) { + await client.query("ROLLBACK").catch(() => undefined); + throw error; + } finally { + client.release(); + } + }, + async close() { + await pool.end(); + }, + }; + return facade; +} + +export class PhotonStoreError extends Error { + readonly code: string; + constructor(code: string, message: string) { + super(message); + this.name = "PhotonStoreError"; + this.code = code; + } +} diff --git a/server/photon/store/commands.ts b/server/photon/store/commands.ts new file mode 100644 index 0000000..a6e3f78 --- /dev/null +++ b/server/photon/store/commands.ts @@ -0,0 +1,125 @@ +import { PhotonStoreError, type CommandRow, type PhotonDb } from "../store.js"; +import { + isUniqueViolation, + mapCommand, + runQuery, + type CommandDbRow, + type DbClient, +} from "./rows.js"; + +/** + * Command ledger — durable agent→worker requests keyed by semantic key for + * idempotent enqueue, with claim fencing for the transport worker. + */ +export function createCommands(client: DbClient): Pick< + PhotonDb, + | "enqueueCommand" + | "getCommand" + | "getCommandBySemanticKey" + | "claimCommands" + | "completeCommand" + | "commandStats" +> { + async function getCommandBySemanticKey(semanticKey: string): Promise { + const result = await runQuery(client, + `SELECT * FROM bell_photon_commands WHERE semantic_key = $1`, [semanticKey]); + return result[0] ? mapCommand(result[0]) : undefined; + } + + return { + async enqueueCommand(command) { + try { + await runQuery(client, + `INSERT INTO bell_photon_commands + (command_id, operation, args, args_digest, initiator_principal, audience, + source_id, chat_guid, target, provider_key, semantic_key, depends_on, expires_at) + VALUES ($1,$2,$3::jsonb,$4,$5,$6,$7,$8,$9::jsonb,$10,$11,$12,$13)`, + [command.commandId, command.operation, JSON.stringify(command.args), + command.argsDigest, command.initiatorPrincipal ?? null, command.audience ?? null, + command.sourceId, command.chatGuid, + command.target ? JSON.stringify(command.target) : null, + command.providerKey ?? null, command.semanticKey, [...command.dependsOn], + command.expiresAt ?? null]); + return { commandId: command.commandId, deduplicated: false }; + } catch (error) { + if (isUniqueViolation(error)) { + const existing = await getCommandBySemanticKey(command.semanticKey); + if (existing) { + if (existing.argsDigest !== command.argsDigest) + throw new PhotonStoreError( + "semantic_key_conflict", + `semantic_key ${command.semanticKey} already exists with different arguments.`, + ); + return { commandId: existing.commandId, deduplicated: true }; + } + } + throw error; + } + }, + + async getCommand(commandId) { + const result = await runQuery(client, + `SELECT * FROM bell_photon_commands WHERE command_id = $1`, [commandId]); + return result[0] ? mapCommand(result[0]) : undefined; + }, + + getCommandBySemanticKey, + + async claimCommands(workerId, limit, claimMs) { + const result = await runQuery(client, + `WITH claimed AS ( + SELECT command_id FROM bell_photon_commands + WHERE status = 'queued' AND next_attempt_at <= now() + ORDER BY created_at ASC + LIMIT $2 + FOR UPDATE SKIP LOCKED + ) + UPDATE bell_photon_commands c + SET status = 'in_flight', claimed_by = $1, + claim_expires_at = now() + make_interval(secs => $3::float8 / 1000), + attempt_count = attempt_count + 1, updated_at = now() + FROM claimed WHERE c.command_id = claimed.command_id + RETURNING c.*`, + [workerId, limit, claimMs]); + return result.map(mapCommand); + }, + + async completeCommand(commandId, status, patch) { + await runQuery(client, + `UPDATE bell_photon_commands SET + status = $2, + result = COALESCE($3::jsonb, result), + provider_receipt = COALESCE($4::jsonb, provider_receipt), + error_code = COALESCE($5, error_code), + error_detail = COALESCE($6, error_detail), + next_attempt_at = COALESCE($7, next_attempt_at), + provider_key = COALESCE($8, provider_key), + claimed_by = $9, + updated_at = now() + WHERE command_id = $1`, + // claimed_by is deliberately not COALESCE'd: every settle releases + // the claim (see CommandPatch). + [commandId, status, + patch.result === undefined ? null : JSON.stringify(patch.result), + patch.providerReceipt === undefined ? null : JSON.stringify(patch.providerReceipt), + patch.errorCode ?? null, patch.errorDetail ?? null, + patch.nextAttemptAt ?? null, patch.providerKey ?? null, + patch.claimedBy ?? null]); + }, + + async commandStats(sourceId) { + const result = await runQuery<{ queued: string; in_flight: string; ambiguous: string }>(client, + `SELECT + count(*) FILTER (WHERE status = 'queued') AS queued, + count(*) FILTER (WHERE status = 'in_flight') AS in_flight, + count(*) FILTER (WHERE status = 'ambiguous') AS ambiguous + FROM bell_photon_commands WHERE source_id = $1`, [sourceId]); + const row = result[0]; + return { + queued: Number(row?.queued ?? 0), + inFlight: Number(row?.in_flight ?? 0), + ambiguous: Number(row?.ambiguous ?? 0), + }; + }, + }; +} diff --git a/server/photon/store/conversations.ts b/server/photon/store/conversations.ts new file mode 100644 index 0000000..87c4d75 --- /dev/null +++ b/server/photon/store/conversations.ts @@ -0,0 +1,94 @@ +import type { PhotonDb } from "../store.js"; +import { mapConversation, runQuery, type ConversationDbRow, type DbClient } from "./rows.js"; + +/** + * Conversation bindings — one durable binding row per (source, chat, + * generation) carrying the Eve session, scope, initiating principal and + * group membership snapshot. + */ +export function createConversations(client: DbClient): Pick< + PhotonDb, + | "getConversation" + | "getConversationById" + | "upsertConversation" + | "bindEveSession" + | "updateMembership" + | "touchConversation" + | "retireConversation" + | "activeConversations" + | "findConversationBySession" +> { + return { + async getConversation(sourceId, chatGuid) { + const result = await runQuery(client, + `SELECT * FROM bell_photon_conversations + WHERE source_id = $1 AND chat_guid = $2 + ORDER BY generation DESC LIMIT 1`, [sourceId, chatGuid]); + return result[0] ? mapConversation(result[0]) : undefined; + }, + + async getConversationById(bindingId) { + const result = await runQuery(client, + `SELECT * FROM bell_photon_conversations WHERE binding_id = $1`, [bindingId]); + return result[0] ? mapConversation(result[0]) : undefined; + }, + + async upsertConversation(input) { + const result = await runQuery(client, + `INSERT INTO bell_photon_conversations + (binding_id, source_id, chat_guid, generation, scope, eve_address, + eve_session_id, initiating_principal, membership) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb) + ON CONFLICT (binding_id) DO UPDATE SET + membership = COALESCE(EXCLUDED.membership, bell_photon_conversations.membership), + updated_at = now() + RETURNING *`, + [input.bindingId, input.sourceId, input.chatGuid, input.generation, input.scope, + input.eveAddress, input.eveSessionId ?? null, input.initiatingPrincipal ?? null, + input.membership ? JSON.stringify(input.membership) : null]); + return mapConversation(result[0]); + }, + + async bindEveSession(bindingId, eveSessionId) { + await runQuery(client, + `UPDATE bell_photon_conversations + SET eve_session_id = $2, updated_at = now() WHERE binding_id = $1`, + [bindingId, eveSessionId]); + }, + + async updateMembership(bindingId, membership) { + await runQuery(client, + `UPDATE bell_photon_conversations + SET membership = $2::jsonb, membership_version = membership_version + 1, + updated_at = now() + WHERE binding_id = $1`, + [bindingId, JSON.stringify(membership)]); + }, + + async touchConversation(bindingId) { + await runQuery(client, + `UPDATE bell_photon_conversations SET last_turn_at = now(), updated_at = now() + WHERE binding_id = $1`, [bindingId]); + }, + + async retireConversation(bindingId) { + await runQuery(client, + `UPDATE bell_photon_conversations SET retired_at = now(), updated_at = now() + WHERE binding_id = $1 AND retired_at IS NULL`, [bindingId]); + }, + + async activeConversations(sourceId) { + const result = await runQuery(client, + `SELECT * FROM bell_photon_conversations + WHERE source_id = $1 AND retired_at IS NULL + ORDER BY created_at ASC`, [sourceId]); + return result.map(mapConversation); + }, + + async findConversationBySession(eveSessionId) { + const result = await runQuery(client, + `SELECT * FROM bell_photon_conversations WHERE eve_session_id = $1`, [eveSessionId]); + return result[0] ? mapConversation(result[0]) : undefined; + }, + }; +} diff --git a/server/photon/store/inbox.ts b/server/photon/store/inbox.ts new file mode 100644 index 0000000..2f0ecfc --- /dev/null +++ b/server/photon/store/inbox.ts @@ -0,0 +1,188 @@ +import type { + InboxRow, + IntentInsert, + IntentPatch, + IntentRow, + PhotonDb, +} from "../store.js"; +import { + isUniqueViolation, + mapInbox, + mapIntent, + runQuery, + type DbClient, + type InboxDbRow, + type IntentDbRow, +} from "./rows.js"; + +/** + * Durable inbox + dispatch intents. The inbox captures normalized provider + * events exactly once per (source, epoch, sequence); intents hold one + * durable delivery record per dispatchable row so a crash mid-request + * resumes without duplicating. + */ +export function createInbox(client: DbClient): Pick< + PhotonDb, + | "captureEvent" + | "getInboxRow" + | "updateDisposition" + | "markInboxProcessing" + | "dispatchableChatScopes" + | "dispatchableForChat" + | "maxSequenceNum" + | "inboxStats" + | "upsertIntent" + | "getIntentByKey" + | "updateIntent" + | "pendingIntents" +> { + async function getInboxRow(ref: { + sourceId: string; logEpoch: string; sequence: string; + }): Promise { + const result = await runQuery(client, + `SELECT * FROM bell_photon_inbox + WHERE source_id = $1 AND log_epoch = $2 AND sequence = $3`, + [ref.sourceId, ref.logEpoch, ref.sequence]); + return result[0] ? mapInbox(result[0]) : undefined; + } + + return { + async captureEvent(input) { + const ref = { sourceId: input.sourceId, logEpoch: input.logEpoch, sequence: input.sequence }; + try { + await runQuery(client, + `INSERT INTO bell_photon_inbox + (source_id, log_epoch, sequence, sequence_num, payload_hash, envelope, tombstone, + event_family, event_type, chat_guid, actor, target_guid, is_from_me, + occurred_at, source_kind, disposition) + VALUES ($1,$2,$3,$4::numeric,$5,$6::jsonb,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)`, + [input.sourceId, input.logEpoch, input.sequence, input.sequenceNum, + input.payloadHash, input.envelope ? JSON.stringify(input.envelope) : null, + input.tombstone ?? false, input.eventFamily ?? null, input.eventType ?? null, + input.chatGuid ?? null, input.actor ?? null, input.targetGuid ?? null, + input.isFromMe ?? null, input.occurredAt ?? null, input.sourceKind, + input.disposition ?? "pending"]); + return { kind: "inserted", ref }; + } catch (error) { + if (!isUniqueViolation(error)) throw error; + } + const existing = await getInboxRow(ref); + if (existing && existing.payloadHash !== input.payloadHash) + return { kind: "integrity_conflict", ref, existingHash: existing.payloadHash }; + return { kind: "duplicate", ref }; + }, + + getInboxRow, + + async updateDisposition(ref, disposition, lastError) { + await runQuery(client, + `UPDATE bell_photon_inbox SET disposition = $4, last_error = COALESCE($5, last_error) + WHERE source_id = $1 AND log_epoch = $2 AND sequence = $3`, + [ref.sourceId, ref.logEpoch, ref.sequence, disposition, lastError ?? null]); + }, + + async markInboxProcessing(ref, state, lastError) { + await runQuery(client, + `UPDATE bell_photon_inbox + SET processing_state = $4, attempts = attempts + 1, + last_error = COALESCE($5, last_error) + WHERE source_id = $1 AND log_epoch = $2 AND sequence = $3`, + [ref.sourceId, ref.logEpoch, ref.sequence, state, lastError ?? null]); + }, + + async dispatchableChatScopes(sourceId, limit) { + const result = await runQuery<{ chat_guid: string | null }>(client, + `SELECT chat_guid, min(sequence_num) AS first_seq FROM bell_photon_inbox + WHERE source_id = $1 + AND disposition IN ('admitted', 'state_only') AND processing_state = 'captured' + GROUP BY chat_guid ORDER BY first_seq ASC NULLS LAST LIMIT $2`, + [sourceId, limit]); + return result.map((row) => row.chat_guid); + }, + + async dispatchableForChat(sourceId, chatGuid, limit) { + const result = chatGuid === null + ? await runQuery(client, + `SELECT * FROM bell_photon_inbox + WHERE source_id = $1 AND chat_guid IS NULL + AND disposition = 'state_only' AND processing_state = 'captured' + ORDER BY sequence_num ASC LIMIT $2`, + [sourceId, limit]) + : await runQuery(client, + `SELECT * FROM bell_photon_inbox + WHERE source_id = $1 AND chat_guid = $2 + AND disposition IN ('admitted', 'state_only') AND processing_state = 'captured' + ORDER BY sequence_num ASC LIMIT $3`, + [sourceId, chatGuid, limit]); + return result.map(mapInbox); + }, + + async maxSequenceNum(sourceId, logEpoch) { + const result = await runQuery<{ max: string | null }>(client, + `SELECT max(sequence_num)::text AS max FROM bell_photon_inbox + WHERE source_id = $1 AND log_epoch = $2`, + [sourceId, logEpoch]); + return result[0]?.max ?? undefined; + }, + + async inboxStats(sourceId) { + const result = await runQuery<{ pending: string; quarantined: string; oldest: string | null }>(client, + `SELECT + count(*) FILTER (WHERE disposition = 'admitted' AND processing_state = 'captured') AS pending, + count(*) FILTER (WHERE processing_state = 'quarantined') AS quarantined, + EXTRACT(EPOCH FROM (now() - min(received_at) FILTER (WHERE disposition = 'admitted' AND processing_state = 'captured'))) AS oldest + FROM bell_photon_inbox WHERE source_id = $1`, [sourceId]); + const row = result[0]; + return { + pending: Number(row?.pending ?? 0), + quarantined: Number(row?.quarantined ?? 0), + oldestPendingAgeSec: row?.oldest === null || row?.oldest === undefined ? null : Number(row.oldest), + }; + }, + + /* --------------------------- dispatch intents ------------------------ */ + async upsertIntent(input: IntentInsert) { + const result = await runQuery(client, + `INSERT INTO bell_photon_dispatch_intents + (intent_id, source_id, log_epoch, sequence, action, eve_session_id, + dispatch_key, input_digest) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8) + ON CONFLICT (dispatch_key) DO UPDATE SET intent_id = bell_photon_dispatch_intents.intent_id + RETURNING *`, + [input.intentId, input.sourceId, input.logEpoch, input.sequence, input.action, + input.eveSessionId ?? null, input.dispatchKey, input.inputDigest]); + return mapIntent(result[0]); + }, + + async getIntentByKey(dispatchKey) { + const result = await runQuery(client, + `SELECT * FROM bell_photon_dispatch_intents WHERE dispatch_key = $1`, [dispatchKey]); + return result[0] ? mapIntent(result[0]) : undefined; + }, + + async updateIntent(intentId, patch: IntentPatch) { + await runQuery(client, + `UPDATE bell_photon_dispatch_intents SET + status = COALESCE($2, status), + eve_session_id = COALESCE($3, eve_session_id), + delivery_id = COALESCE($4, delivery_id), + attempts = attempts + 1, + last_error = COALESCE($5, last_error), + updated_at = now() + WHERE intent_id = $1`, + [intentId, patch.status ?? null, patch.eveSessionId ?? null, + patch.deliveryId ?? null, patch.lastError ?? null]); + }, + + async pendingIntents(sourceId, limit): Promise { + const result = await runQuery(client, + `SELECT i.* FROM bell_photon_dispatch_intents i + JOIN bell_photon_inbox b + ON b.source_id = i.source_id AND b.log_epoch = i.log_epoch AND b.sequence = i.sequence + WHERE i.source_id = $1 AND i.status = 'pending' + ORDER BY b.sequence_num ASC LIMIT $2`, + [sourceId, limit]); + return result.map(mapIntent); + }, + }; +} diff --git a/server/photon/store/interactions.ts b/server/photon/store/interactions.ts new file mode 100644 index 0000000..584beaa --- /dev/null +++ b/server/photon/store/interactions.ts @@ -0,0 +1,65 @@ +import { sha256Hex } from "../identity.js"; +import type { PhotonDb } from "../store.js"; +import { + mapInteraction, + runQuery, + type DbClient, + type InteractionDbRow, +} from "./rows.js"; + +/** + * Pending interactions — Eve input requests / authorization prompts an + * inbound provider event may resolve. Interaction ids are deterministic so + * a replayed upsert is a no-op. + */ +export function createInteractions(client: DbClient): Pick< + PhotonDb, + | "createInteraction" + | "openInteractions" + | "resolveInteraction" + | "expireInteractions" +> { + return { + async createInteraction(input) { + const interactionId = `int:${sha256Hex(JSON.stringify([input.sourceId, input.chatGuid, input.requestRef, input.kind]))}`; + await runQuery(client, + `INSERT INTO bell_photon_pending_interactions + (interaction_id, source_id, binding_id, chat_guid, generation, kind, request_ref, + permitted_actor, target, response_mapping, expires_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10::jsonb,$11) + ON CONFLICT (interaction_id) DO NOTHING`, + [interactionId, input.sourceId, input.bindingId ?? null, input.chatGuid, + input.generation ?? 1, input.kind, input.requestRef, input.permittedActor ?? null, + JSON.stringify(input.target ?? {}), JSON.stringify(input.responseMapping ?? {}), + input.expiresAt ?? null]); + return interactionId; + }, + + async openInteractions(sourceId, chatGuid) { + const result = await runQuery(client, + `SELECT * FROM bell_photon_pending_interactions + WHERE source_id = $1 AND chat_guid = $2 AND status = 'pending' + ORDER BY created_at ASC`, [sourceId, chatGuid]); + return result.map(mapInteraction); + }, + + async resolveInteraction(interactionId, actor, resolution) { + const result = await runQuery(client, + `UPDATE bell_photon_pending_interactions + SET status = 'resolved', resolved_actor = $2, resolved_at = now(), resolution = $3::jsonb + WHERE interaction_id = $1 AND status = 'pending' + RETURNING interaction_id`, + [interactionId, actor, JSON.stringify(resolution)]); + return result.length === 1; + }, + + async expireInteractions(now) { + const result = await runQuery(client, + `UPDATE bell_photon_pending_interactions + SET status = 'expired' + WHERE status = 'pending' AND expires_at IS NOT NULL AND expires_at <= $1 + RETURNING interaction_id`, [now]); + return result.length; + }, + }; +} diff --git a/server/photon/store/resources.ts b/server/photon/store/resources.ts new file mode 100644 index 0000000..cc7a6f6 --- /dev/null +++ b/server/photon/store/resources.ts @@ -0,0 +1,77 @@ +import { sha256Hex } from "../identity.js"; +import type { ArtifactRef, PhotonDb } from "../store.js"; +import { + mapArtifact, + mapArtifactRef, + mapResource, + runQuery, + type ArtifactDataDbRow, + type ArtifactDbRow, + type DbClient, + type ResourceDbRow, +} from "./rows.js"; + +/** + * Revisioned provider resources (messages, polls, mini-app sessions, chats) + * and the conversation-scoped binary artifact store. + */ +export function createResources(client: DbClient): Pick< + PhotonDb, + | "upsertResource" + | "getResource" + | "putArtifact" + | "getArtifact" + | "findArtifactByAttachment" +> { + return { + async upsertResource(sourceId, kind, resourceId, data, opts) { + await runQuery(client, + `INSERT INTO bell_photon_resources (source_id, kind, resource_id, data, tombstone) + VALUES ($1,$2,$3,$4::jsonb,$5) + ON CONFLICT (source_id, kind, resource_id) DO UPDATE SET + data = EXCLUDED.data, tombstone = EXCLUDED.tombstone, + revision = bell_photon_resources.revision + 1, updated_at = now() + WHERE $6::integer IS NULL OR bell_photon_resources.revision = $6`, + [sourceId, kind, resourceId, JSON.stringify(data), opts?.tombstone ?? false, + opts?.expectedRevision ?? null]); + }, + + async getResource(sourceId, kind, resourceId) { + const result = await runQuery(client, + `SELECT * FROM bell_photon_resources + WHERE source_id = $1 AND kind = $2 AND resource_id = $3`, + [sourceId, kind, resourceId]); + return result[0] ? mapResource(result[0]) : undefined; + }, + + async putArtifact(input) { + const artifactId = `art:${sha256Hex(JSON.stringify([input.sourceId, input.chatGuid, Date.now(), input.data.byteLength]))}${Math.random().toString(36).slice(2, 10)}`; + await runQuery(client, + `INSERT INTO bell_photon_artifacts + (artifact_id, source_id, chat_guid, attachment_guid, file_name, media_type, byte_size, data) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, + [artifactId, input.sourceId, input.chatGuid, input.attachmentGuid ?? null, + input.fileName ?? null, input.mediaType, input.data.byteLength, Buffer.from(input.data)]); + return artifactId; + }, + + async getArtifact(artifactId) { + const result = await runQuery(client, + `SELECT artifact_id, source_id, chat_guid, attachment_guid, file_name, + media_type, byte_size, data, created_at + FROM bell_photon_artifacts WHERE artifact_id = $1`, [artifactId]); + return result[0] ? mapArtifact(result[0]) : undefined; + }, + + async findArtifactByAttachment(sourceId, chatGuid, attachmentGuid): Promise { + const result = await runQuery(client, + `SELECT artifact_id, source_id, chat_guid, attachment_guid, file_name, + media_type, byte_size, created_at + FROM bell_photon_artifacts + WHERE source_id = $1 AND chat_guid = $2 AND attachment_guid = $3 + ORDER BY created_at DESC LIMIT 1`, + [sourceId, chatGuid, attachmentGuid]); + return result[0] ? mapArtifactRef(result[0]) : undefined; + }, + }; +} diff --git a/server/photon/store/rows.ts b/server/photon/store/rows.ts new file mode 100644 index 0000000..d1aac3f --- /dev/null +++ b/server/photon/store/rows.ts @@ -0,0 +1,330 @@ +import type { PoolClient, Pool, QueryResultRow } from "pg"; + +import type { + AdmissionDisposition, + CommandStatus, + PhotonCommandOp, + PhotonEventEnvelope, +} from "../contracts.js"; +import type { + ArtifactRef, + ArtifactRow, + CommandRow, + ConversationRow, + DeadlineRow, + InboxProcessing, + InboxRow, + IntentRow, + InteractionRow, + ProjectorRow, + ResourceKind, + ResourceRow, + SourceRow, + SourceStatus, +} from "../store.js"; + +/** + * Typed persistence rows for the Photon channel tables — one interface per + * aggregate so the mappers below never see `any`. Column types follow the + * node-postgres defaults: numeric/bigint read back as text, timestamptz as + * Date, jsonb as parsed JSON, bytea as Buffer. + */ + +export type DbClient = PoolClient | Pool; + +export async function runQuery( + client: DbClient, + text: string, + values: readonly unknown[] = [], +): Promise { + return (await client.query(text, [...values])).rows; +} + +/** True for Postgres unique-violation errors (SQLSTATE 23505). */ +export function isUniqueViolation(error: unknown): boolean { + return error instanceof Error && "code" in error && error.code === "23505"; +} + +export interface SourceDbRow extends QueryResultRow { + source_id: string; + project_id: string; + instance_id: string; + account_scope: string; + phone_number: string; + endpoint: string; + log_epoch: string; + activation_boundary: string | null; + capture_cursor: string | null; + coverage: Record | null; + owner_token: string | null; + lease_epoch: string | number; + lease_expires_at: Date | null; + credential_expires_at: Date | null; + health: Record | null; + status: SourceStatus; +} + +export function mapSource(row: SourceDbRow): SourceRow { + return { + sourceId: row.source_id, projectId: row.project_id, instanceId: row.instance_id, + accountScope: row.account_scope, phoneNumber: row.phone_number, endpoint: row.endpoint, + logEpoch: row.log_epoch, + activationBoundary: row.activation_boundary ?? undefined, + captureCursor: row.capture_cursor ?? undefined, + coverage: row.coverage ?? {}, ownerToken: row.owner_token ?? undefined, + leaseEpoch: Number(row.lease_epoch), + leaseExpiresAt: row.lease_expires_at ?? undefined, + credentialExpiresAt: row.credential_expires_at ?? undefined, + health: row.health ?? {}, status: row.status, + }; +} + +export interface InboxDbRow extends QueryResultRow { + source_id: string; + log_epoch: string; + sequence: string; + sequence_num: string; + payload_hash: string; + envelope: PhotonEventEnvelope | null; + tombstone: boolean; + event_family: string | null; + event_type: string | null; + chat_guid: string | null; + actor: string | null; + target_guid: string | null; + is_from_me: boolean | null; + occurred_at: Date | string | null; + received_at: Date | string; + source_kind: "live" | "catchup"; + disposition: AdmissionDisposition | "pending"; + processing_state: InboxProcessing; + attempts: number; + last_error: string | null; +} + +export function mapInbox(row: InboxDbRow): InboxRow { + return { + sourceId: row.source_id, logEpoch: row.log_epoch, sequence: row.sequence, + sequenceNum: String(row.sequence_num), payloadHash: row.payload_hash, + envelope: row.envelope ?? undefined, tombstone: row.tombstone, + eventFamily: row.event_family ?? undefined, eventType: row.event_type ?? undefined, + chatGuid: row.chat_guid ?? undefined, actor: row.actor ?? undefined, + targetGuid: row.target_guid ?? undefined, isFromMe: row.is_from_me ?? undefined, + occurredAt: row.occurred_at ? new Date(row.occurred_at).toISOString() : undefined, + receivedAt: new Date(row.received_at).toISOString(), + sourceKind: row.source_kind, disposition: row.disposition, + processingState: row.processing_state, attempts: row.attempts, + lastError: row.last_error ?? undefined, + }; +} + +export interface ConversationDbRow extends QueryResultRow { + binding_id: string; + source_id: string; + chat_guid: string; + generation: number; + scope: "direct" | "group"; + eve_address: string; + eve_session_id: string | null; + initiating_principal: string | null; + membership_version: number; + membership: string[] | null; + last_turn_at: Date | string | null; + retired_at: Date | string | null; + created_at: Date | string; +} + +export function mapConversation(row: ConversationDbRow): ConversationRow { + return { + bindingId: row.binding_id, sourceId: row.source_id, chatGuid: row.chat_guid, + generation: row.generation, scope: row.scope, eveAddress: row.eve_address, + eveSessionId: row.eve_session_id ?? undefined, + initiatingPrincipal: row.initiating_principal ?? undefined, + membershipVersion: row.membership_version, + membership: row.membership ?? [], + lastTurnAt: row.last_turn_at ? new Date(row.last_turn_at).toISOString() : undefined, + retiredAt: row.retired_at ? new Date(row.retired_at).toISOString() : undefined, + createdAt: new Date(row.created_at).toISOString(), + }; +} + +export interface IntentDbRow extends QueryResultRow { + intent_id: string; + source_id: string; + log_epoch: string; + sequence: string; + action: "send" | "respond" | "state_apply"; + eve_session_id: string | null; + dispatch_key: string; + input_digest: string; + status: "pending" | "accepted" | "ambiguous" | "rejected" | "quarantined"; + delivery_id: string | null; + attempts: number; + last_error: string | null; + created_at: Date | string; +} + +export function mapIntent(row: IntentDbRow): IntentRow { + return { + intentId: row.intent_id, sourceId: row.source_id, logEpoch: row.log_epoch, + sequence: row.sequence, action: row.action, eveSessionId: row.eve_session_id ?? undefined, + dispatchKey: row.dispatch_key, inputDigest: row.input_digest, + status: row.status, deliveryId: row.delivery_id ?? undefined, + attempts: row.attempts, lastError: row.last_error ?? undefined, + createdAt: new Date(row.created_at).toISOString(), + }; +} + +export interface CommandDbRow extends QueryResultRow { + command_id: string; + operation: PhotonCommandOp; + args: Record | null; + args_digest: string; + initiator_principal: string | null; + audience: "direct" | "group" | "system" | null; + source_id: string; + chat_guid: string; + target: Record | null; + provider_key: string | null; + semantic_key: string; + depends_on: string[] | null; + status: CommandStatus; + attempt_count: number; + next_attempt_at: Date; + claimed_by: string | null; + result: unknown; + provider_receipt: unknown; + error_code: string | null; + error_detail: string | null; + expires_at: Date | null; + created_at: Date; +} + +export function mapCommand(row: CommandDbRow): CommandRow { + return { + commandId: row.command_id, operation: row.operation, args: row.args ?? {}, + argsDigest: row.args_digest, initiatorPrincipal: row.initiator_principal ?? undefined, + audience: row.audience ?? undefined, sourceId: row.source_id, chatGuid: row.chat_guid, + target: row.target ?? undefined, providerKey: row.provider_key ?? undefined, + semanticKey: row.semantic_key, dependsOn: row.depends_on ?? [], + status: row.status, attemptCount: row.attempt_count, + nextAttemptAt: row.next_attempt_at, claimedBy: row.claimed_by ?? undefined, + result: row.result ?? undefined, providerReceipt: row.provider_receipt ?? undefined, + errorCode: row.error_code ?? undefined, errorDetail: row.error_detail ?? undefined, + expiresAt: row.expires_at ?? undefined, createdAt: row.created_at, + }; +} + +export interface ResourceDbRow extends QueryResultRow { + source_id: string; + kind: ResourceKind; + resource_id: string; + data: Record | null; + revision: number; + tombstone: boolean; + updated_at: Date; +} + +export function mapResource(row: ResourceDbRow): ResourceRow { + return { + sourceId: row.source_id, kind: row.kind, resourceId: row.resource_id, + data: row.data ?? {}, revision: row.revision, tombstone: row.tombstone, + updatedAt: row.updated_at, + }; +} + +export interface ArtifactDbRow extends QueryResultRow { + artifact_id: string; + source_id: string; + chat_guid: string; + attachment_guid: string | null; + file_name: string | null; + media_type: string; + byte_size: number | string; + created_at: Date; +} + +export interface ArtifactDataDbRow extends ArtifactDbRow { + data: Uint8Array; +} + +export function mapArtifactRef(row: ArtifactDbRow): ArtifactRef { + return { + artifactId: row.artifact_id, + sourceId: row.source_id, + chatGuid: row.chat_guid, + attachmentGuid: row.attachment_guid ?? undefined, + fileName: row.file_name ?? undefined, + mediaType: row.media_type, + byteSize: Number(row.byte_size), + }; +} + +export function mapArtifact(row: ArtifactDataDbRow): ArtifactRow { + return { ...mapArtifactRef(row), data: row.data, createdAt: row.created_at }; +} + +export interface InteractionDbRow extends QueryResultRow { + interaction_id: string; + source_id: string; + binding_id: string | null; + chat_guid: string; + generation: number; + kind: "prompt" | "authorization" | "poll" | "reaction_contract"; + request_ref: string; + permitted_actor: string | null; + target: Record | null; + response_mapping: Record | null; + status: "pending" | "resolved" | "expired" | "cancelled"; + resolved_actor: string | null; + resolved_at: Date | null; + resolution: Record | null; + created_at: Date; +} + +export function mapInteraction(row: InteractionDbRow): InteractionRow { + return { + interactionId: row.interaction_id, sourceId: row.source_id, + bindingId: row.binding_id ?? undefined, chatGuid: row.chat_guid, + generation: row.generation, kind: row.kind, requestRef: row.request_ref, + permittedActor: row.permitted_actor ?? undefined, + target: row.target ?? {}, responseMapping: row.response_mapping ?? {}, + status: row.status, resolvedActor: row.resolved_actor ?? undefined, + resolvedAt: row.resolved_at ?? undefined, resolution: row.resolution ?? undefined, + createdAt: row.created_at, + }; +} + +export interface ProjectorDbRow extends QueryResultRow { + source_id: string; + eve_session_id: string; + stream_position: number | string; + materialized_keys: string[] | null; + updated_at: Date; +} + +export function mapProjector(row: ProjectorDbRow): ProjectorRow { + return { + sourceId: row.source_id, + eveSessionId: row.eve_session_id, + streamPosition: Number(row.stream_position), + materializedKeys: row.materialized_keys ?? [], + updatedAt: row.updated_at, + }; +} + +export interface DeadlineDbRow extends QueryResultRow { + eve_session_id: string; + binding_id: string | null; + deadline_at: Date; + retired_at: Date | null; + retirement_reason: string | null; +} + +export function mapDeadline(row: DeadlineDbRow): DeadlineRow { + return { + eveSessionId: row.eve_session_id, bindingId: row.binding_id ?? undefined, + deadlineAt: row.deadline_at, retiredAt: row.retired_at ?? undefined, + retirementReason: row.retirement_reason ?? undefined, + }; +} diff --git a/server/photon/store/sessions.ts b/server/photon/store/sessions.ts new file mode 100644 index 0000000..e05f77c --- /dev/null +++ b/server/photon/store/sessions.ts @@ -0,0 +1,75 @@ +import type { PhotonDb } from "../store.js"; +import { + mapDeadline, + mapProjector, + runQuery, + type DbClient, + type DeadlineDbRow, + type ProjectorDbRow, +} from "./rows.js"; + +/** + * Eve-side session bookkeeping: per-(source, session) projector offsets and + * the deadline table that retires stale sessions. + */ +export function createSessions(client: DbClient): Pick< + PhotonDb, + | "getProjectorOffset" + | "advanceProjector" + | "registerDeadline" + | "dueDeadlines" + | "markDeadlineRetired" + | "getDeadline" +> { + return { + async getProjectorOffset(sourceId, eveSessionId) { + const result = await runQuery(client, + `SELECT * FROM bell_photon_projector_offsets + WHERE source_id = $1 AND eve_session_id = $2`, [sourceId, eveSessionId]); + return result[0] ? mapProjector(result[0]) : undefined; + }, + + async advanceProjector(input) { + await runQuery(client, + `INSERT INTO bell_photon_projector_offsets + (source_id, eve_session_id, stream_position, materialized_keys) + VALUES ($1,$2,$3,$4::jsonb) + ON CONFLICT (source_id, eve_session_id) DO UPDATE SET + stream_position = GREATEST(bell_photon_projector_offsets.stream_position, EXCLUDED.stream_position), + materialized_keys = EXCLUDED.materialized_keys, + updated_at = now()`, + [input.sourceId, input.eveSessionId, input.streamPosition, + JSON.stringify(input.materializedKeys)]); + }, + + async registerDeadline(eveSessionId, bindingId, deadlineAt) { + await runQuery(client, + `INSERT INTO bell_photon_session_deadlines (eve_session_id, binding_id, deadline_at) + VALUES ($1,$2,$3) + ON CONFLICT (eve_session_id) DO NOTHING`, + [eveSessionId, bindingId ?? null, deadlineAt]); + }, + + async dueDeadlines(now, limit) { + const result = await runQuery(client, + `SELECT * FROM bell_photon_session_deadlines + WHERE retired_at IS NULL AND deadline_at <= $1 + ORDER BY deadline_at ASC LIMIT $2`, [now, limit]); + return result.map(mapDeadline); + }, + + async markDeadlineRetired(eveSessionId, reason) { + await runQuery(client, + `UPDATE bell_photon_session_deadlines + SET retired_at = now(), retirement_reason = $2 + WHERE eve_session_id = $1 AND retired_at IS NULL`, + [eveSessionId, reason]); + }, + + async getDeadline(eveSessionId) { + const result = await runQuery(client, + `SELECT * FROM bell_photon_session_deadlines WHERE eve_session_id = $1`, [eveSessionId]); + return result[0] ? mapDeadline(result[0]) : undefined; + }, + }; +} diff --git a/server/photon/store/sources.ts b/server/photon/store/sources.ts new file mode 100644 index 0000000..de22c10 --- /dev/null +++ b/server/photon/store/sources.ts @@ -0,0 +1,132 @@ +import type { PhotonDb, SourceRow } from "../store.js"; +import { mapSource, runQuery, type DbClient, type SourceDbRow } from "./rows.js"; + +/** + * Source enrollment, lease fencing, health and credential-expiry + * persistence. One row per enrolled (project, instance, line); the lease + * columns fence the single transport owner. + */ +export function createSources(client: DbClient): Pick< + PhotonDb, + | "enrollSource" + | "getSource" + | "getLatestSource" + | "setSourceStatus" + | "updateCaptureCursor" + | "acquireLease" + | "renewLease" + | "releaseLease" + | "updateSourceHealth" + | "updateCredentialExpiry" + | "activationBoundary" + | "setActivationBoundary" +> { + async function getSource(sourceId: string): Promise { + const result = await runQuery( + client, + `SELECT * FROM bell_photon_sources WHERE source_id = $1`, + [sourceId], + ); + return result[0] ? mapSource(result[0]) : undefined; + } + + return { + async enrollSource(input) { + const result = await runQuery(client, + `INSERT INTO bell_photon_sources + (source_id, project_id, instance_id, account_scope, phone_number, endpoint, log_epoch) + VALUES ($1,$2,$3,$4,$5,$6,$7) + ON CONFLICT (source_id) DO UPDATE SET + phone_number = EXCLUDED.phone_number, endpoint = EXCLUDED.endpoint, + updated_at = now() + RETURNING *`, + [input.sourceId, input.projectId, input.instanceId, input.accountScope, + input.phoneNumber, input.endpoint, input.logEpoch]); + return mapSource(result[0]); + }, + + getSource, + + /** Most recently touched source of a project (health-route probe). */ + async getLatestSource(projectId) { + const result = await runQuery(client, + `SELECT * FROM bell_photon_sources + WHERE project_id = $1 ORDER BY updated_at DESC LIMIT 1`, + [projectId]); + return result[0] ? mapSource(result[0]) : undefined; + }, + + async setSourceStatus(sourceId, status) { + await runQuery(client, + `UPDATE bell_photon_sources SET status = $2, updated_at = now() WHERE source_id = $1`, + [sourceId, status]); + }, + + async updateCaptureCursor(sourceId, cursor, coverage) { + await runQuery(client, + `UPDATE bell_photon_sources + SET capture_cursor = $2, coverage = COALESCE($3::jsonb, coverage), updated_at = now() + WHERE source_id = $1`, [sourceId, cursor, coverage ? JSON.stringify(coverage) : null]); + }, + + async acquireLease(sourceId, ownerToken, leaseMs) { + const result = await runQuery<{ lease_epoch: string }>(client, + `UPDATE bell_photon_sources + SET owner_token = $2, lease_epoch = lease_epoch + 1, + lease_expires_at = now() + make_interval(secs => $3::float8 / 1000), + updated_at = now() + WHERE source_id = $1 + AND (owner_token IS NULL OR owner_token = $2 OR lease_expires_at < now()) + RETURNING lease_epoch`, + [sourceId, ownerToken, leaseMs]); + if (result.length === 0) { + const current = await getSource(sourceId); + return { acquired: false, owner: current?.ownerToken } as const; + } + return { acquired: true, epoch: Number(result[0].lease_epoch) } as const; + }, + + async renewLease(sourceId, ownerToken, epoch, leaseMs) { + const result = await runQuery(client, + `UPDATE bell_photon_sources + SET lease_expires_at = now() + make_interval(secs => $4::float8 / 1000), updated_at = now() + WHERE source_id = $1 AND owner_token = $2 AND lease_epoch = $3 + RETURNING source_id`, + [sourceId, ownerToken, epoch, leaseMs]); + return result.length === 1; + }, + + async releaseLease(sourceId, ownerToken, epoch) { + await runQuery(client, + `UPDATE bell_photon_sources + SET owner_token = NULL, lease_expires_at = NULL, updated_at = now() + WHERE source_id = $1 AND owner_token = $2 AND lease_epoch = $3`, + [sourceId, ownerToken, epoch]); + }, + + async updateSourceHealth(sourceId, health) { + await runQuery(client, + `UPDATE bell_photon_sources SET health = $2::jsonb, updated_at = now() WHERE source_id = $1`, + [sourceId, JSON.stringify(health)]); + }, + + async updateCredentialExpiry(sourceId, expiresAt) { + await runQuery(client, + `UPDATE bell_photon_sources SET credential_expires_at = $2, updated_at = now() WHERE source_id = $1`, + [sourceId, expiresAt]); + }, + + async activationBoundary(sourceId) { + const result = await runQuery<{ activation_boundary: string | null }>(client, + `SELECT activation_boundary FROM bell_photon_sources WHERE source_id = $1`, [sourceId]); + return result[0]?.activation_boundary ?? undefined; + }, + + async setActivationBoundary(sourceId, boundary) { + await runQuery(client, + `UPDATE bell_photon_sources + SET activation_boundary = COALESCE(activation_boundary, $2), updated_at = now() + WHERE source_id = $1`, [sourceId, boundary]); + }, + }; +} diff --git a/server/photon/worker.ts b/server/photon/worker.ts new file mode 100644 index 0000000..3e53f4f --- /dev/null +++ b/server/photon/worker.ts @@ -0,0 +1,284 @@ +import { randomUUID } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +import type { PhotonConfig } from "./config.js"; +import { connectPhoton, discoverLineCredentials, type LineCredentials, type PhotonConnection } from "./client.js"; +import type { PhotonCommandDeps } from "./contracts.js"; +import { createDispatcher, retireDueSessions } from "./dispatch.js"; + +export { retireDueSessions } from "./dispatch.js"; +import { startTransport, type PhotonTransport } from "./events.js"; +import { expireSweep } from "./interactions.js"; +import { createOutbox } from "./outbox.js"; +import { createProjector } from "./projection.js"; +import { createPhotonStore, type PhotonStore } from "./store.js"; + +/** + * The single-owner transport worker for one enrolled source. Owns the + * durable event streams, catch-up reconciliation, dispatch, projection, + * outbox, deadline and expiry sweeps, health persistence, managed-credential + * refresh, and the source lease. Contains no model calls, no browser + * automation, and no second agent runtime. + */ + +const MIGRATION_FILE = "004_photon.sql"; +const HEALTH_INTERVAL_MS = 10_000; +const SWEEP_INTERVAL_MS = 30_000; +const CREDENTIAL_CHECK_MS = 60_000; +const CREDENTIAL_REFRESH_MARGIN_MS = 5 * 60_000; +const DEADLINE_BATCH = 32; +const LEASE_RENEW_DIVISOR = 3; + +export interface PhotonWorkerDeps { + readonly store?: PhotonStore; + readonly discoverCredentials?: typeof discoverLineCredentials; + readonly connect?: typeof connectPhoton; + readonly fetcher?: typeof fetch; + /** Migration SQL override; the worker otherwise reads migrations/004_photon.sql. */ + readonly migrationSql?: string; + readonly log?: (entry: Record) => void; + readonly now?: () => Date; +} + +export interface PhotonWorker { + /** Graceful stop: streams close, work drains, lease releases. Idempotent. */ + stop(): Promise; + /** + * Resolves when the worker fully stops. `fatal` is set when termination + * was involuntary (lost lease, expired credentials) — the supervisor + * should restart the process with a nonzero exit. + */ + readonly done: Promise<{ readonly fatal?: string }>; +} + +function sleep(ms: number): Promise { + // Unref'd: loop sleeps must never hold the process open past shutdown. + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + timer.unref?.(); + }); +} + +/** + * Locates migrations/004_photon.sql from either the source layout + * (server/photon/worker.ts), the compiled worker output + * (.photon-worker/server/photon/worker.js), or the process cwd. + */ +function migrationSql(provided: string | undefined): string { + if (provided !== undefined) return provided; + const here = dirname(fileURLToPath(import.meta.url)); + const candidates = [ + join(here, "..", "..", "migrations", MIGRATION_FILE), + join(here, "..", "..", "..", "migrations", MIGRATION_FILE), + join(process.cwd(), "migrations", MIGRATION_FILE), + ]; + for (const candidate of candidates) + if (existsSync(candidate)) return readFileSync(candidate, "utf8"); + throw new Error(`Photon migration ${MIGRATION_FILE} not found (searched ${candidates.join(", ")}).`); +} + +export async function startPhotonWorker( + config: PhotonConfig, + deps: PhotonWorkerDeps = {}, +): Promise { + const now = deps.now ?? (() => new Date()); + const log = deps.log ?? ((entry: Record) => console.log(JSON.stringify(entry))); + const store = deps.store ?? createPhotonStore(config.databaseUrl); + const discover = deps.discoverCredentials ?? discoverLineCredentials; + const connect = deps.connect ?? connectPhoton; + const fetcher = deps.fetcher ?? fetch; + + let stopping = false; + let fatal: string | undefined; + let doneResolve!: (value: { fatal?: string }) => void; + const done = new Promise<{ fatal?: string }>((resolve) => { doneResolve = resolve; }); + const tasks: Promise[] = []; + + /* 1-2. Store + idempotent migration ----------------------------------- */ + await store.pool.query(migrationSql(deps.migrationSql)); + + /* 3-4. Managed credentials + client ------------------------------------ */ + const credentials: LineCredentials = await discover(config, fetcher); + const tokenRef = { current: credentials.token }; + const connection: PhotonConnection = connect(credentials, config, tokenRef); + const client = connection.client; + + /* 5-6. Source enrollment with a stable persisted log epoch ------------- */ + const source = await store.enrollSource({ + sourceId: connection.sourceId, + projectId: config.projectId, + instanceId: credentials.instanceId, + accountScope: credentials.instanceId, + phoneNumber: credentials.phoneNumber, + endpoint: credentials.endpoint, + // First write wins: enrollSource never rewrites log_epoch, so restarts + // keep one continuous sequence domain for this enrolled source. + logEpoch: `${credentials.instanceId}:${randomUUID()}`, + }); + const sourceId = source.sourceId; + const logEpoch = source.logEpoch; + await store.updateCredentialExpiry(sourceId, new Date(credentials.expiresAt)); + + /* 7. Single-owner lease ------------------------------------------------- */ + const ownerToken = randomUUID(); + const lease = await store.acquireLease(sourceId, ownerToken, config.worker.leaseMs); + if (!lease.acquired) { + await client.close().catch(() => undefined); + await store.close(); + throw new Error(`Photon source lease is held by another owner (${lease.owner ?? "unknown"}).`); + } + const leaseEpoch = lease.epoch; + await store.setSourceStatus(sourceId, "active"); + log({ level: "info", msg: "photon_worker_started", sourceId, leaseEpoch, instance: credentials.instanceId }); + + /* Wiring --------------------------------------------------------------- */ + const transport: PhotonTransport = startTransport({ + client, store, config, sourceId, logEpoch, log, now, + }); + const commandDeps: PhotonCommandDeps = { + client: { + sendText: (chat, text, options) => + client.messages.sendText(chat, text, options as Parameters[2]), + raw: client, + }, + artifacts: { + get: async (artifactId) => { + const artifact = await store.getArtifact(artifactId); + return artifact ? { data: artifact.data, fileName: artifact.fileName, mediaType: artifact.mediaType } : undefined; + }, + put: (input) => store.putArtifact({ sourceId, ...input }), + }, + config: { projectId: config.projectId }, + now, + }; + const outbox = createOutbox({ + store, sourceId, workerId: ownerToken, commandDeps, + watchControl: transport, + // Claims outlive the slowest provider call by a wide margin; the orphan + // sweep only repossesses commands whose claim genuinely expired. + claimMs: Math.max(config.worker.leaseMs, 120_000), + batchSize: config.worker.commandBatchSize, + log, now, + }); + const dispatcher = createDispatcher({ + store, config, sourceId, logEpoch, client, fetcher, + batchSize: config.worker.dispatchBatchSize, log, now, + }); + const projector = createProjector({ store, config, sourceId, fetcher, log, now }); + + function loop(name: string, intervalMs: number, run: () => Promise): void { + tasks.push((async () => { + while (!stopping) { + try { + await run(); + } catch (error) { + if (!stopping) + log({ level: "error", msg: "photon_loop_error", loop: name, error: error instanceof Error ? error.message : String(error) }); + } + if (!stopping) await sleep(intervalMs); + } + })()); + } + + /* 8. Lease renewal: loss suspends the source and stops the worker ------ */ + loop("lease", Math.max(1_000, Math.floor(config.worker.leaseMs / LEASE_RENEW_DIVISOR)), async () => { + const renewed = await store.renewLease(sourceId, ownerToken, leaseEpoch, config.worker.leaseMs); + if (!renewed && !stopping) { + fatal = "lease_lost"; + log({ level: "error", msg: "photon_lease_lost", sourceId, leaseEpoch }); + await store.setSourceStatus(sourceId, "suspended").catch(() => undefined); + void stop(); + } + }); + + /* Loops ---------------------------------------------------------------- */ + loop("outbox", config.worker.commandPollMs, () => outbox.runOnce()); + // Requeue orphaned in_flight commands: the frozen claim query only moves + // 'queued' rows, so crash-orphaned claims return via claim_expires_at. + loop("claim-sweep", SWEEP_INTERVAL_MS, async () => { + await store.pool.query( + `UPDATE bell_photon_commands + SET status = 'queued', claimed_by = NULL, updated_at = now() + WHERE status = 'in_flight' AND claim_expires_at IS NOT NULL AND claim_expires_at < now()`, + ); + }); + loop("dispatch", config.worker.dispatchPollMs, () => dispatcher.runOnce()); + loop("projection", config.worker.projectionPollMs, () => projector.runOnce()); + loop("reconciliation", config.worker.reconciliationMs, () => transport.runCatchUp()); + loop("interaction-expiry", SWEEP_INTERVAL_MS, () => expireSweep(store, now())); + loop("deadlines", SWEEP_INTERVAL_MS, () => + retireDueSessions(store, config, { fetch: fetcher, now, batchSize: DEADLINE_BATCH })); + loop("health", HEALTH_INTERVAL_MS, async () => { + const [health, inbox, outboxStats, sourceRow] = await Promise.all([ + Promise.resolve(transport.health()), + store.inboxStats(sourceId), + store.commandStats(sourceId), + store.getSource(sourceId), + ]); + await store.updateSourceHealth(sourceId, { + alive: !stopping, + leaseEpoch, + leaseExpiresAt: sourceRow?.leaseExpiresAt?.toISOString(), + streams: health.streams, + captureCursor: sourceRow?.captureCursor, + catchUpHeadSequence: health.catchUpHeadSequence, + lastCatchUpAt: health.lastCatchUpAt, + inboxLagSeconds: inbox.oldestPendingAgeSec, + inboxPending: inbox.pending, + inboxQuarantined: inbox.quarantined, + outboxPending: outboxStats.queued + outboxStats.inFlight, + ambiguous: outboxStats.ambiguous, + integrityConflicts: health.integrityConflicts, + normalizationFailures: health.normalizationFailures, + locationWatches: health.locationWatches, + updatedAt: now().toISOString(), + }); + }); + + /* Credential refresh ---------------------------------------------------- */ + let credentialExpiresAt = credentials.expiresAt; + loop("credentials", CREDENTIAL_CHECK_MS, async () => { + const remaining = credentialExpiresAt - now().getTime(); + if (remaining > CREDENTIAL_REFRESH_MARGIN_MS) return; + try { + const refreshed = await discover(config, fetcher); + tokenRef.current = refreshed.token; + credentialExpiresAt = refreshed.expiresAt; + await store.updateCredentialExpiry(sourceId, new Date(refreshed.expiresAt)); + log({ level: "info", msg: "photon_credentials_refreshed", sourceId }); + } catch (error) { + if (credentialExpiresAt > now().getTime()) { + log({ level: "warn", msg: "photon_credential_refresh_failed", error: error instanceof Error ? error.message : String(error) }); + return; + } + // Credentials already expired and refresh keeps failing: terminate so + // the supervisor performs a full reconnect instead of a live channel + // on dead tokens. + if (!stopping) { + fatal = "credentials_expired"; + log({ level: "error", msg: "photon_credentials_expired", sourceId }); + void stop(); + } + } + }); + + /* Stop ------------------------------------------------------------------ */ + async function stop(): Promise { + if (stopping) return; + stopping = true; + // Stop accepting work; drain bounded by shutdownMs. + const drain = Promise.allSettled(tasks); + await Promise.race([drain, sleep(config.worker.shutdownMs)]); + await transport.stop().catch(() => undefined); + await store.releaseLease(sourceId, ownerToken, leaseEpoch).catch(() => undefined); + await store.setSourceStatus(sourceId, "suspended").catch(() => undefined); + await client.close().catch(() => undefined); + await store.close().catch(() => undefined); + log({ level: "info", msg: "photon_worker_stopped", sourceId, fatal }); + doneResolve({ fatal }); + } + + return { stop, done }; +} diff --git a/tests/browser/linq-environment.test.ts b/tests/browser/linq-environment.test.ts deleted file mode 100644 index b1ed884..0000000 --- a/tests/browser/linq-environment.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { requiredEnvironmentValue } from "../../agent/lib/linq"; - -afterEach(() => vi.unstubAllEnvs()); - -describe("Linq channel environment", () => { - it("loads and trims channel credentials from the environment", () => { - vi.stubEnv("LINQ_API_KEY", " linq_test_key "); - expect(requiredEnvironmentValue("LINQ_API_KEY")).toBe("linq_test_key"); - }); - - it("fails clearly when a channel credential is missing", () => { - vi.stubEnv("LINQ_WEBHOOK_SECRET", ""); - expect(() => requiredEnvironmentValue("LINQ_WEBHOOK_SECRET")).toThrow( - "LINQ_WEBHOOK_SECRET is required for the Linq channel.", - ); - }); -}); diff --git a/tests/browser/photon-environment.test.ts b/tests/browser/photon-environment.test.ts new file mode 100644 index 0000000..b4b6ccf --- /dev/null +++ b/tests/browser/photon-environment.test.ts @@ -0,0 +1,98 @@ +/** + * Photon channel environment contract. The Advanced gRPC channel parses only + * `BELL_PHOTON_*`/`IMESSAGE_*`/`DATABASE_URL` settings and fails closed with + * the variable named when the environment cannot produce a safe configuration. + */ +import { describe, expect, it } from "vitest"; + +import { + PhotonConfigError, + loadPhotonConfig, + photonEnabled, + photonTokenUrl, +} from "../../server/photon/config"; + +const VALID: NodeJS.ProcessEnv = { + NODE_ENV: "test", + BELL_PHOTON_ENABLED: "1", + IMESSAGE_PROJECT_ID: "proj-live", + IMESSAGE_PROJECT_SECRET: "project-secret-value", + BELL_PHOTON_PHONE_NUMBER: "+12025550100", + BELL_PHOTON_ALLOWED_SENDERS: "+12025550123,+12025550124", + BELL_PHOTON_ALLOWED_EMAIL_SENDERS: "operator@example.com", + BELL_PHOTON_ALLOWED_GROUPS: "iMessage;+;chat000000000000000001", + BELL_PHOTON_INTERNAL_SECRET: "internal-secret-32-bytes-minimum!!", + BELL_PHOTON_INTERNAL_ORIGIN: "http://127.0.0.1:4274", + DATABASE_URL: "postgres://db.internal:5432/bell", + BELL_PUBLIC_URL: "https://bell.example", +}; + +describe("Photon channel environment", () => { + it("parses every BELL_PHOTON_* and IMESSAGE_* setting when enabled", () => { + const config = loadPhotonConfig(VALID); + expect(config).not.toBeNull(); + expect(config).toMatchObject({ + projectId: "proj-live", + projectSecret: "project-secret-value", + phoneNumber: "+12025550100", + allowedSenders: ["+12025550123", "+12025550124"], + allowedEmailSenders: ["operator@example.com"], + allowedGroups: ["iMessage;+;chat000000000000000001"], + internalOrigin: "http://127.0.0.1:4274", + databaseUrl: "postgres://db.internal:5432/bell", + publicUrl: "https://bell.example", + }); + }); + + it("ignores unrelated provider credentials and receiving numbers", () => { + const env = { ...VALID }; + expect(loadPhotonConfig(env)).not.toBeNull(); + // Stray retired variables are ignored, never consulted for identity. + const withUnrelatedProvider = { ...VALID, LEGACY_API_KEY: "dead", LEGACY_WEBHOOK_SECRET: "dead", BELL_LEGACY_PHONE_NUMBER: "+1999" }; + const config = loadPhotonConfig(withUnrelatedProvider); + expect(config!.phoneNumber).toBe("+12025550100"); + expect(config).toEqual(loadPhotonConfig(env)); + }); + + it("returns null when disabled so builds run without provider credentials", () => { + expect(loadPhotonConfig({ ...VALID, BELL_PHOTON_ENABLED: "false" })).toBeNull(); + expect(loadPhotonConfig({ NODE_ENV: "test" })).toBeNull(); + expect(photonEnabled(VALID)).toBe(true); + }); + + it("requires the dedicated receiving number in strict E.164", () => { + expect(() => loadPhotonConfig({ ...VALID, BELL_PHOTON_PHONE_NUMBER: "12025550100" })) + .toThrowError(PhotonConfigError); + expect(() => loadPhotonConfig({ ...VALID, BELL_PHOTON_PHONE_NUMBER: "" })) + .toThrowError(/BELL_PHOTON_PHONE_NUMBER/); + }); + + it("requires a nonempty E.164 sender allowlist for private mode", () => { + expect(() => loadPhotonConfig({ ...VALID, BELL_PHOTON_ALLOWED_SENDERS: "" })) + .toThrowError(/BELL_PHOTON_ALLOWED_SENDERS/); + expect(() => loadPhotonConfig({ ...VALID, BELL_PHOTON_ALLOWED_SENDERS: "+12025550123,not-e164" })) + .toThrowError(PhotonConfigError); + }); + + it("parses the explicit group allowlist — empty keeps group ingress closed", () => { + expect(loadPhotonConfig({ ...VALID, BELL_PHOTON_ALLOWED_GROUPS: "" })!.allowedGroups).toEqual([]); + expect(loadPhotonConfig({ ...VALID, BELL_PHOTON_ALLOWED_GROUPS: " g1 , g2 " })!.allowedGroups) + .toEqual(["g1", "g2"]); + }); + + it("rejects a short internal secret and a non-bare private origin", () => { + expect(() => loadPhotonConfig({ ...VALID, BELL_PHOTON_INTERNAL_SECRET: "too-short" })) + .toThrowError(/BELL_PHOTON_INTERNAL_SECRET/); + expect(loadPhotonConfig({ ...VALID, BELL_PHOTON_INTERNAL_SECRET: "s".repeat(32) })).not.toBeNull(); + expect(() => loadPhotonConfig({ ...VALID, BELL_PHOTON_INTERNAL_ORIGIN: "http://127.0.0.1:4274/eve/v1" })) + .toThrowError(PhotonConfigError); + expect(() => loadPhotonConfig({ ...VALID, BELL_PHOTON_INTERNAL_ORIGIN: "https://public.example:443" })) + .not.toThrow(); + }); + + it("builds the Stable token URL from the project id only", () => { + expect(photonTokenUrl("proj-live")) + .toBe("https://spectrum.photon.codes/projects/proj-live/imessage/tokens"); + expect(() => photonTokenUrl("../escape")).toThrowError(PhotonConfigError); + }); +}); diff --git a/tests/browser/vault-list-tool.test.ts b/tests/browser/vault-list-tool.test.ts index 3917bcc..244e3f0 100644 --- a/tests/browser/vault-list-tool.test.ts +++ b/tests/browser/vault-list-tool.test.ts @@ -52,6 +52,15 @@ describe("configured vault discovery", () => { it("does not expose the operator's vault to an iMessage sender", async () => { mocks.onePasswordList.mockClear(); - await expect(vaultList.execute({ provider: "onepassword" }, { session: { auth: { current: { authenticator: "linq-message" } } } } as never)).rejects.toThrow("personal credentials"); + // A fully verified direct Photon principal still cannot reach shared + // operator vaults — they are personal-credential paths only. + const photonAuth = { + principalType: "user", + authenticator: "photon-imessage", + issuer: "photon", + principalId: `photon:${"a".repeat(64)}`, + attributes: { "photon:audience": "direct" }, + }; + await expect(vaultList.execute({ provider: "onepassword" }, { session: { auth: { current: photonAuth } } } as never)).rejects.toThrow("personal credentials"); expect(mocks.onePasswordList).not.toHaveBeenCalled(); }); diff --git a/tests/link-checkout.test.ts b/tests/link-checkout.test.ts index 43c1373..216f78c 100644 --- a/tests/link-checkout.test.ts +++ b/tests/link-checkout.test.ts @@ -8,7 +8,7 @@ import approval from "../agent/extensions/link/tools/request_spend_approval"; import list from "../agent/extensions/link/tools/list_spend_requests"; import disconnect from "../agent/tools/link_disconnect"; -const context = { session: { id: "linq-chat-session" }, abortSignal: new AbortController().signal } as never; +const context = { session: { id: "photon-chat-session" }, abortSignal: new AbortController().signal } as never; const purchase = { credential_type: "card" as const, amount: 4200, currency: "usd", request_approval: true, test: false, diff --git a/tests/link-config.test.ts b/tests/link-config.test.ts index ec3be6e..e7ec33f 100644 --- a/tests/link-config.test.ts +++ b/tests/link-config.test.ts @@ -3,6 +3,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { hash, linkConfig, LinkSetupError, seal, unseal } from "../server/link/config"; import { linkOwner, safeLinkCall } from "../server/link/oauth"; import { linkAuth } from "../agent/lib/link-auth"; +import { photonPrincipalId } from "../server/photon/identity"; + +// A verified private Photon messaging principal (`photon:` + 64-hex digest). +const sender = photonPrincipalId("test-project", { kind: "phone", value: "+12025550123" }); afterEach(() => vi.unstubAllEnvs()); @@ -25,13 +29,15 @@ describe("Link credential boundary", () => { for (const principal of [ { type: "app" as const }, { type: "user" as const, id: "local-dev" }, - { type: "user" as const, id: "linq:person", issuer: "web" }, - { type: "user" as const, id: "", issuer: "linq" }, + { type: "user" as const, id: sender, issuer: "web" }, + { type: "user" as const, id: "", issuer: "photon" }, + { type: "user" as const, id: "photon:not-a-digest", issuer: "photon" }, + { type: "user" as const, id: "legacy:sender", issuer: "legacy" }, ]) { expect(() => linkOwner(principal)).toThrow(); await expect(linkAuth.getToken({ principal, connection: { url: "" } })).rejects.toMatchObject({ reason: "principal_required" }); } - expect(linkOwner({ type: "user", issuer: "linq", id: "linq:sender" })).toEqual({ issuer: "linq", id: "linq:sender" }); + expect(linkOwner({ type: "user", issuer: "photon", id: sender })).toEqual({ issuer: "photon", id: sender }); }); it("does not expose arbitrary database or transport errors", async () => { diff --git a/tests/link-oauth.test.ts b/tests/link-oauth.test.ts index ea202df..dc1c9e8 100644 --- a/tests/link-oauth.test.ts +++ b/tests/link-oauth.test.ts @@ -13,8 +13,12 @@ import { AuthKey, SessionIdKey, SessionKey } from "../node_modules/eve/dist/src/ import { CallbackBaseUrlKey, PendingAuthorizationResultKey, isAuthorizationSignal } from "../node_modules/eve/dist/src/harness/authorization.js"; import { createAuthorizationExecution } from "../node_modules/eve/dist/src/runtime/connections/scoped-authorization.js"; import { resolveWorkflowCallbackBaseUrl } from "../node_modules/eve/dist/src/execution/workflow-callback-url.js"; -import type { ChannelAdapter } from "../node_modules/eve/dist/src/channel/adapter.js"; -import linq from "../agent/channels/linq"; +import { photonPrincipalId } from "../server/photon/identity"; +import { projectEvent, type ProjectionContext } from "../server/photon/projection"; +import type { ConversationRow } from "../server/photon/store"; + +// Verified private Photon messaging principal (`photon:` + sha256 digest). +const photonId = (handle: string) => photonPrincipalId("test-project", { kind: "phone", value: handle }); const databaseUrl = process.env.TEST_LINK_DATABASE_URL; @@ -51,7 +55,7 @@ describe.skipIf(!databaseUrl)("Link OAuth with PostgreSQL", () => { }); beforeEach(() => { now = Date.now(); - principal = { type: "user", issuer: "linq", id: `linq:${randomUUID()}` }; + principal = { type: "user", issuer: "photon", id: photonId("+12025550123") }; callbackUrl = `https://bell.example/eve/v1/connections/link/callback/${randomUUID()}/opaque-hook`; config = { databaseUrl: databaseUrl!, origin: "https://bell.example", redirectUri: "https://bell.example/api/link/oauth/callback", @@ -64,7 +68,7 @@ describe.skipIf(!databaseUrl)("Link OAuth with PostgreSQL", () => { function eveContext() { const sessionId = `test-session-${randomUUID()}`; - const auth = { principalType: "user" as const, principalId: principal.id, issuer: "linq", authenticator: "linq-message", attributes: {} }; + const auth = { principalType: "user" as const, principalId: principal.id, issuer: "photon", authenticator: "photon-imessage", subject: "phone:+12025550123", attributes: { "photon:audience": "direct" } }; const context = new ContextContainer(); context.set(AuthKey, auth); context.set(SessionIdKey, sessionId); @@ -124,7 +128,7 @@ describe.skipIf(!databaseUrl)("Link OAuth with PostgreSQL", () => { const { start, state } = await begin(); const params = { state, code: "code" }; await service.forward(params); - const other = { ...principal, id: `linq:${randomUUID()}` }; + const other = { ...principal, id: photonId("+12025550199") }; await expect(service.complete(other, callbackUrl, start.resume, params)).rejects.toMatchObject({ reason: "invalid_state" }); await expect(service.complete(principal, callbackUrl + "other", start.resume, params)).rejects.toMatchObject({ reason: "invalid_state" }); await expect(service.complete(principal, callbackUrl, start.resume, { ...params, code: "changed" })).rejects.toMatchObject({ reason: "invalid_state" }); @@ -322,25 +326,38 @@ describe.skipIf(!databaseUrl)("Link OAuth with PostgreSQL", () => { } }); - it.each(["development", "production"])("resumes Eve authorization in the original Linq chat with a %s callback origin", async (environment) => { + it.each(["development", "production"])("resumes Eve authorization in the original iMessage conversation with a %s callback origin", async (environment) => { vi.stubEnv("VERCEL_ENV", environment); vi.stubEnv("WORKFLOW_LOCAL_BASE_URL", "http://127.0.0.1:54321"); vi.stubEnv("VERCEL_PROJECT_PRODUCTION_URL", "bell-project.vercel.app"); const { context, sessionId } = eveContext(); const provider = createLinkAuth(() => service); const scoped = { scope: "link__retrieve_user_info", authorization: provider, connection: { url: "" } }; - const { adapter } = linq as unknown as { adapter: ChannelAdapter }; - const post = vi.fn(async () => ({ id: "consent-message" })); - const editMessage = vi.fn(); - const channel = { thread: { id: "original-linq-chat", isDM: true, post, adapter: { editMessage }, startTyping: vi.fn() }, state: { pendingAuthMessageIds: {} } }; + // Consent delivery runs through the projector: an authorization.required + // event becomes an outbound authorization message in the bound direct + // conversation; completion arrives as ordinary assistant output. + const conversation = { + bindingId: "bind-1", sourceId: "photon-src:test", chatGuid: "iMessage;-;+12025550123", + generation: 1, scope: "direct" as const, eveAddress: "eve:bind-1", + eveSessionId: sessionId, initiatingPrincipal: principal.id, + membershipVersion: 0, membership: [], createdAt: new Date().toISOString(), + } satisfies ConversationRow; + const projection: ProjectionContext = { + sessionId, conversation, typingOn: new Set(), materialized: [], registerPrompts: [], + }; await contextStorage.run(context, async () => { const execution = createAuthorizationExecution(); const signal = await execution.run(() => execution.getToken(scoped)); if (!isAuthorizationSignal(signal)) throw new Error("Expected Eve to suspend for consent."); const challenge = signal.challenges[0]; expect(new URL(challenge.hookUrl).origin).toBe(environment === "production" ? "https://bell-project.vercel.app" : "http://127.0.0.1:54321"); - await adapter["authorization.required"]!({ name: challenge.name, authorization: challenge.challenge } as never, channel as never); - expect(post).toHaveBeenCalledWith({ markdown: expect.stringContaining("https://login.link.com/auth?") }); + const authOutputs = projectEvent(projection, { + type: "authorization.required", + data: { name: challenge.name, description: "Link sign-in", webhookUrl: challenge.challenge.url, turnId: "turn-1", stepIndex: 0 }, + }); + const signIn = authOutputs.find((out) => out.kind === "authorization"); + expect(signIn?.payload?.text ?? JSON.stringify(signIn?.args)).toContain("https://login.link.com/auth?"); + expect(signIn?.args?.chat).toBe(conversation.chatGuid); const state = new URL(challenge.challenge.url!).searchParams.get("state")!; const response = await linkCallback(new Request(`${config.redirectUri}?state=${state}&code=runtime-code`), () => service); expect(response.status).toBe(303); @@ -355,8 +372,13 @@ describe.skipIf(!databaseUrl)("Link OAuth with PostgreSQL", () => { const resumed = createAuthorizationExecution(); expect(await resumed.getToken(scoped)).toMatchObject({ token: "access-first" }); expect(context.require(SessionIdKey)).toBe(sessionId); - await adapter["authorization.completed"]!({ name: challenge.name, authorization: challenge.challenge, outcome: "authorized" } as never, channel as never); - expect(editMessage).toHaveBeenCalledWith("original-linq-chat", "consent-message", { markdown: "Link connected." }); + // The completed turn's assistant output delivers the confirmation. + const completion = projectEvent(projection, { + type: "message.completed", + data: { message: "Link connected.", turnId: "turn-1", stepIndex: 1 }, + }); + expect(completion[0]?.args?.chat).toBe(conversation.chatGuid); + expect(completion[0]?.args?.text).toBe("Link connected."); expect(fetcher).toHaveBeenCalledOnce(); }); }); diff --git a/tests/link-sandbox.test.ts b/tests/link-sandbox.test.ts index 430507e..7125311 100644 --- a/tests/link-sandbox.test.ts +++ b/tests/link-sandbox.test.ts @@ -17,8 +17,8 @@ import { linkCallback } from "../server/link/callback"; import { assertSandboxRequest, rememberSandboxRequest, sandboxPurchase } from "../server/link/sandbox"; import { quarantineBrowser, withBrowserPrivacyLock } from "../server/browser/privacy-lock"; -const principal = { type: "user" as const, issuer: "linq", id: "linq:sandbox-operator" }; -const context = { session: { id: "sandbox-session", auth: { current: { principalType: "user", principalId: principal.id, issuer: "linq", authenticator: "linq-message" } } }, abortSignal: new AbortController().signal, getToken: vi.fn(), requireAuth: vi.fn() } as unknown as ToolContext; +const principal = { type: "user" as const, issuer: "photon", id: "photon:" + "a".repeat(64) }; +const context = { session: { id: "sandbox-session", auth: { current: { principalType: "user", principalId: principal.id, issuer: "photon", authenticator: "photon-imessage", subject: "phone:+12025550123", attributes: { "photon:audience": "direct" } } } }, abortSignal: new AbortController().signal, getToken: vi.fn(), requireAuth: vi.fn() } as unknown as ToolContext; const purchase = { credential_type: "card" as const, amount: 100, currency: "usd", request_approval: true, test: false, merchant_name: "Fixture", merchant_url: "https://merchant.invalid", context: "The operator requested one synthetic fixture item for exactly one dollar, including all tax and shipping, solely to test this integration.", idempotency_key: "existing-live-key" }; const metadata = (id = `spend_${randomUUID()}`) => ({ id, status: "approved", amount: 100, currency: "usd", merchant_url: purchase.merchant_url, created_at: "now", updated_at: "now" }); beforeEach(() => { @@ -60,7 +60,7 @@ describe("database-free Link sandbox", () => { }); it("rejects other senders, anonymous callers, and a missing runtime token", async () => { const execute = vi.spyOn(native.create_spend_request, "execute"); - vi.stubEnv("LINK_SANDBOX_PRINCIPAL_ID", "linq:another-sender"); + vi.stubEnv("LINK_SANDBOX_PRINCIPAL_ID", "photon:" + "b".repeat(64)); await expect(create.execute(purchase, context)).rejects.toThrow("configured Link sandbox operator"); expect(() => freshLinkContext({ ...context, session: { auth: { current: undefined } } } as never, undefined, "create")).toThrow(); vi.stubEnv("LINK_SANDBOX_PRINCIPAL_ID", principal.id); vi.stubEnv("LINK_SANDBOX_ACCESS_TOKEN", ""); diff --git a/tests/linq.test.ts b/tests/linq.test.ts deleted file mode 100644 index cea0849..0000000 --- a/tests/linq.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { createHmac, randomUUID } from "node:crypto"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import linq from "../agent/channels/linq"; -import { ContextContainer, contextStorage } from "../node_modules/eve/dist/src/context/container.js"; -import { SessionKey } from "../node_modules/eve/dist/src/context/keys.js"; -import type { ChannelAdapter } from "../node_modules/eve/dist/src/channel/adapter.js"; - -const receivingNumber = "+12025550100"; -const senderNumber = "+12025550123"; -const signingKey = Buffer.from("bell-test-key-for-webhooks-only!!"); -const route = linq.routes.find((entry) => entry.method === "POST")!; -type RouteContext = Parameters[1]; - -beforeEach(() => { - vi.stubEnv("LINQ_API_KEY", "linq-test-key"); - vi.stubEnv("LINQ_WEBHOOK_SECRET", `whsec_${signingKey.toString("base64")}`); - vi.stubEnv("BELL_LINQ_PHONE_NUMBER", receivingNumber); - vi.stubEnv("BELL_LINQ_ALLOWED_SENDERS", senderNumber); - vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { - const url = input instanceof Request ? input.url : String(input); - if (!url.startsWith("https://api.linqapp.com/api/partner/v3/chats/") || !url.endsWith("/read")) { - throw new Error(`Unexpected provider request in Linq test: ${url}`); - } - return new Response(null, { status: 204 }); - })); -}); - -afterEach(() => { - vi.unstubAllEnvs(); - vi.unstubAllGlobals(); -}); - -function message(chatId = randomUUID()) { - return { - api_version: "v3", - webhook_version: "2026-02-03", - event_type: "message.received", - event_id: randomUUID(), - created_at: new Date().toISOString(), - data: { - id: randomUUID(), - chat: { id: chatId, is_group: false, owner_handle: { handle: receivingNumber, is_me: true } }, - direction: "inbound", - sender_handle: { id: "sender-handle-id", handle: senderNumber, is_me: false }, - sent_at: new Date().toISOString(), - parts: [{ type: "text", value: "Open Best Buy and tell me the homepage title." }], - }, - }; -} - -async function deliver(event: ReturnType, options: { tamper?: boolean; age?: number; unsigned?: boolean } = {}) { - const body = JSON.stringify(event); - const timestamp = String(Math.floor(Date.now() / 1_000) - (options.age ?? 0)); - const signature = createHmac("sha256", signingKey) - .update(`${event.event_id}.${timestamp}.${body}`).digest("base64"); - const headers = new Headers({ "content-type": "application/json" }); - if (!options.unsigned) { - headers.set("webhook-id", event.event_id); - headers.set("webhook-timestamp", timestamp); - headers.set("webhook-signature", `v1,${signature}`); - } - const request = new Request("https://bell.example/eve/v1/linq", { - method: "POST", headers, body: options.tamper ? `${body} ` : body, - }); - const tasks: Promise[] = []; - const dispatch = vi.fn(async () => undefined); - // Stop at the real adapter's Eve dispatch boundary; never run a model or browser. - const from = vi.fn(() => new Proxy({}, { - get(_target, property) { - if (property === "send") return dispatch; - throw new Error(`Unexpected Eve operation: ${String(property)}`); - }, - })) as unknown as RouteContext["from"]; - const unsupported = () => { throw new Error("Unexpected Eve route operation"); }; - const response = await route.handler(request, { - from, resolveSession: unsupported, attachSession: unsupported, to: unsupported, - describe: unsupported, listSkillFiles: unsupported, readSkill: unsupported, invokeTool: unsupported, - params: {}, requestIp: null, waitUntil: (task) => { tasks.push(task); }, - }); - if (!(response instanceof Response)) throw new Error("Expected an HTTP webhook response."); - for (const task of tasks) await task; - return { response, from, dispatch }; -} - -describe("Bell's signed Linq webhook", () => { - it("dispatches a verified direct message to Bell with sender auth and chat state", async () => { - const event = message(); - const { response, from, dispatch } = await deliver(event); - expect(response.status).toBe(200); - expect(from).toHaveBeenCalledWith(expect.stringContaining(event.data.chat.id)); - expect(dispatch).toHaveBeenCalledOnce(); - expect(dispatch).toHaveBeenCalledWith(event.data.parts[0].value, expect.objectContaining({ - context: [expect.stringContaining("Bell's private Linq")], - auth: expect.objectContaining({ principalId: "linq:sender-handle-id", principalType: "user" }), - state: { thread: expect.objectContaining({ isDM: true }) }, - })); - expect(fetch).toHaveBeenCalledOnce(); - }); - - it("keeps follow-ups on the same Eve channel address with steer enabled", async () => { - const chatId = randomUUID(); - const first = await deliver(message(chatId)); - const second = await deliver(message(chatId)); - expect(first.dispatch).toHaveBeenCalledOnce(); - expect(second.dispatch).toHaveBeenCalledOnce(); - expect(first.from).toHaveBeenCalledWith(expect.any(String)); - expect(vi.mocked(first.from).mock.calls[0][0]).toBe(vi.mocked(second.from).mock.calls[0][0]); - expect(linq.turnPolicy).toBe("steer"); - }); - - it("delivers OAuth challenges and connection completion to the originating Linq thread", async () => { - const post = vi.fn(async () => ({ id: "oauth-message" })); - const editMessage = vi.fn(); - const startTyping = vi.fn(); - const channel = { - thread: { id: "original-linq-chat", isDM: true, post, adapter: { editMessage }, startTyping }, - state: { pendingAuthMessageIds: {} }, - }; - const authorization = { - displayName: "Link", url: "https://login.link.com/auth?state=opaque-state", - instructions: "Connect your wallet and return to this chat.", - }; - const { adapter } = linq as unknown as { adapter: ChannelAdapter }; - const context = new ContextContainer(); - context.set(SessionKey, { sessionId: "original-eve-session" } as never); - await contextStorage.run(context, async () => { - await adapter["authorization.required"]!( - { name: "link", authorization } as never, channel as never, - ); - expect(post).toHaveBeenCalledExactlyOnceWith({ markdown: expect.stringContaining(authorization.url) }); - expect(channel.state.pendingAuthMessageIds).toEqual({ link: "oauth-message" }); - await adapter["authorization.completed"]!( - { name: "link", authorization, outcome: "authorized" } as never, channel as never, - ); - }); - expect(editMessage).toHaveBeenCalledWith("original-linq-chat", "oauth-message", { markdown: "Link connected." }); - expect(startTyping).toHaveBeenCalledWith("Connected. Resuming..."); - expect(channel.state.pendingAuthMessageIds).toEqual({}); - }); - - it.each([ - ["missing", { unsigned: true }], - ["tampered", { tamper: true }], - ["expired", { age: 600 }], - ])("rejects a %s signature before dispatch", async (_name, options) => { - const { response, dispatch } = await deliver(message(), options); - expect(response.status).toBe(401); - expect(dispatch).not.toHaveBeenCalled(); - expect(fetch).not.toHaveBeenCalled(); - }); - - it.each([ - ["another line", (event: ReturnType) => { event.data.chat.owner_handle.handle = "+12025550199"; }], - ["another sender", (event: ReturnType) => { event.data.sender_handle.handle = "+12025550199"; }], - ["a group chat", (event: ReturnType) => { event.data.chat.is_group = true; }], - ["a bot echo", (event: ReturnType) => { event.data.sender_handle.is_me = true; }], - ["an outbound message", (event: ReturnType) => { event.data.direction = "outbound"; }], - ["an email handle", (event: ReturnType) => { event.data.sender_handle.handle = "demo@example.com"; }], - ])("ignores a signed message from %s", async (_name, change) => { - const event = message(); - change(event); - const { response, dispatch } = await deliver(event); - expect(response.status).toBe(200); - expect(dispatch).not.toHaveBeenCalled(); - expect(fetch).not.toHaveBeenCalled(); - }); - - it.each([ - ["BELL_LINQ_ALLOWED_SENDERS", ""], - ["BELL_LINQ_ALLOWED_SENDERS", `${senderNumber},invalid`], - ["BELL_LINQ_PHONE_NUMBER", ""], - ["BELL_LINQ_PHONE_NUMBER", `tel:${receivingNumber}`], - ])("fails closed when %s is %j", async (key, value) => { - vi.stubEnv(key, value); - const { dispatch } = await deliver(message()); - expect(dispatch).not.toHaveBeenCalled(); - expect(fetch).not.toHaveBeenCalled(); - }); - - it("accepts an exact phone number in a comma-separated list", async () => { - vi.stubEnv("BELL_LINQ_ALLOWED_SENDERS", ` +12025550199, ${senderNumber} `); - const { dispatch } = await deliver(message()); - expect(dispatch).toHaveBeenCalledOnce(); - }); -}); diff --git a/tests/onepassword-availability.test.ts b/tests/onepassword-availability.test.ts index 4477012..af7bbff 100644 --- a/tests/onepassword-availability.test.ts +++ b/tests/onepassword-availability.test.ts @@ -18,7 +18,7 @@ describe("personal 1Password rollout", () => { vi.stubEnv("BROWSERBASE_1PASSWORD_EXTENSION_ID", ""); vi.stubEnv("BROWSERBASE_CONTEXT_ID", ""); mocks.service.mockReturnValue({ disconnect: mocks.disconnect }); - ctx = { session: { id: "conversation", auth: { current: { principalType: "user", authenticator: "linq-message", issuer: "linq", principalId: "linq:sender" } } }, getToken: vi.fn() } as unknown as ToolContext; + ctx = { session: { id: "conversation", auth: { current: { principalType: "user", authenticator: "photon-imessage", issuer: "photon", principalId: "photon:" + "a".repeat(64), subject: "phone:+12025550123", attributes: { "photon:audience": "direct" } } } }, getToken: vi.fn() } as unknown as ToolContext; }); afterEach(() => vi.unstubAllEnvs()); it.each([undefined, "", "false", "0", "TRUE"])("blocks consent and login before side effects when enabled=%s", async enabled => { @@ -46,7 +46,7 @@ describe("personal 1Password rollout", () => { it("keeps disconnect and previously issued callback completion available", async () => { mocks.disconnect.mockResolvedValue({ status: "disconnected" }); expect(await disconnect.execute({}, ctx)).toEqual({ status: "disconnected" }); - expect(mocks.disconnect).toHaveBeenCalledWith({ type: "user", issuer: "linq", id: "linq:sender" }); + expect(mocks.disconnect).toHaveBeenCalledWith({ type: "user", issuer: "photon", id: "photon:" + "a".repeat(64) }); const capture = vi.fn().mockResolvedValue("https://bell.example/eve/v1/connections/op/callback/id/hook"); const response = await onePasswordPost(new Request("https://bell.example/api/onepassword/complete", { method: "POST", headers: { origin: "https://bell.example", "content-type": "application/json" }, body: JSON.stringify({ state: "s".repeat(43), code: "issued-code" }) }), "complete", () => ({ config: { origin: "https://bell.example" }, capture }) as never); expect(response.status).toBe(200); diff --git a/tests/onepassword-callback.test.ts b/tests/onepassword-callback.test.ts index 5ef61c2..04fde87 100644 --- a/tests/onepassword-callback.test.ts +++ b/tests/onepassword-callback.test.ts @@ -13,10 +13,12 @@ describe("personal 1Password boundaries", () => { expect(integrationIdentity(key(), "client")).toMatchObject({ accountId: "account", userId: "user" }); for (const value of ["ops_bad", key("//attacker.example/oauth-clients/client/accounts/account/users/user"), key("//api.1password.com/oauth-clients/other/accounts/account/users/user")]) expect(() => integrationIdentity(value, "client")).toThrow(); }); - it("rejects application principals and non-Linq user identities", () => { + it("rejects application principals and non-Photon user identities", () => { + const sender = "photon:" + "a".repeat(64); expect(() => ownerOf({ type: "app" })).toThrow(); - expect(() => ownerOf({ type: "user", issuer: "caller", id: "linq:user" })).toThrow(); - expect(ownerOf({ type: "user", issuer: "linq", id: "linq:user" })).toBe('["linq","linq:user"]'); + expect(() => ownerOf({ type: "user", issuer: "caller", id: sender })).toThrow(); + expect(() => ownerOf({ type: "user", issuer: "legacy", id: "legacy:user" })).toThrow(); + expect(ownerOf({ type: "user", issuer: "photon", id: sender })).toBe(`["photon","${sender}"]`); }); it("requires an exact HTTPS callback and a canonical encryption key", () => { const env = { BELL_PUBLIC_URL: "https://bell.example", ONEPASSWORD_REDIRECT_URI: "https://bell.example/api/onepassword/callback", ONEPASSWORD_TOKEN_ENCRYPTION_KEY: randomBytes(32).toString("base64"), ONEPASSWORD_CLIENT_ID: "client", ONEPASSWORD_CLIENT_SECRET: "secret", DATABASE_URL: "postgres://test" }; diff --git a/tests/onepassword-integration.test.ts b/tests/onepassword-integration.test.ts index 9d19303..f63bca5 100644 --- a/tests/onepassword-integration.test.ts +++ b/tests/onepassword-integration.test.ts @@ -39,7 +39,7 @@ describe.skipIf(!databaseUrl)("1Password OAuth and approval lifecycle with Postg beforeEach(() => { vi.stubEnv("ONEPASSWORD_ENABLED", "true"); now = Date.now(); - principal = { type: "user", issuer: "linq", id: `linq:${randomUUID()}` }; + principal = { type: "user", issuer: "photon", id: `photon:${randomBytes(32).toString("hex")}` }; config = { origin: "https://bell.example", redirectUri: "https://bell.example/api/onepassword/callback", clientId: "client", clientSecret: "secret", encryptionKey: randomBytes(32), databaseUrl: databaseUrl! }; callback = `https://bell.example/eve/v1/connections/onepassword/callback/${randomUUID()}/hook`; store = new OnePasswordStore(pool, config.encryptionKey); @@ -86,7 +86,7 @@ describe.skipIf(!databaseUrl)("1Password OAuth and approval lifecycle with Postg await expect(oauth.capture({ state: flow.state, code: "code", integrationKey: integrationKey() }, "wrong-cookie")).rejects.toThrow(); expect(fetcher).not.toHaveBeenCalled(); const response = await capture(flow), target = new URL((await response.json()).location); - await expect(oauth.complete({ ...principal, id: "linq:other" }, callback, flow.started.resume, Object.fromEntries(target.searchParams))).rejects.toThrow(); + await expect(oauth.complete({ ...principal, id: `photon:${randomBytes(32).toString("hex")}` }, callback, flow.started.resume, Object.fromEntries(target.searchParams))).rejects.toThrow(); }); it("retains the one-time key across a failed exchange and a reconnect without a fragment", async () => { const flow = await begin(); @@ -149,8 +149,8 @@ describe.skipIf(!databaseUrl)("1Password OAuth and approval lifecycle with Postg }); it("rejects ciphertext swapped between owners", async () => { await connect(); - await pool.query("INSERT INTO bell_onepassword_connections(owner,encrypted_state) SELECT $1,encrypted_state FROM bell_onepassword_connections WHERE owner=$2", [ownerOf({ ...principal, id: "linq:other" }), ownerOf(principal)]); - await expect(oauth.getToken({ ...principal, id: "linq:other" })).rejects.toThrow(); + await pool.query("INSERT INTO bell_onepassword_connections(owner,encrypted_state) SELECT $1,encrypted_state FROM bell_onepassword_connections WHERE owner=$2", [ownerOf({ ...principal, id: `photon:${randomBytes(32).toString("hex")}` }), ownerOf(principal)]); + await expect(oauth.getToken({ ...principal, id: `photon:${randomBytes(32).toString("hex")}` })).rejects.toThrow(); }); async function startLogin(check: ConstructorParameters[1]) { await connect(); @@ -242,7 +242,7 @@ describe.skipIf(!databaseUrl)("1Password OAuth and approval lifecycle with Postg }); }); it("resumes Eve in the original authenticated session without journaling provider credentials", async () => { - const context = new ContextContainer(), auth = { principalType: "user" as const, principalId: principal.id, issuer: "linq", authenticator: "linq-message", attributes: {} }; + const context = new ContextContainer(), auth = { principalType: "user" as const, principalId: principal.id, issuer: "photon", authenticator: "photon-imessage", attributes: { "photon:audience": "direct" } }; context.set(AuthKey, auth); context.set(SessionIdKey, "original-session"); context.set(SessionKey, { sessionId: "original-session", auth: { current: auth } } as never); context.set(CallbackBaseUrlKey, config.origin); const scoped = { scope: "onepassword", authorization: createOnePasswordAuth(() => oauth), connection: { url: "" } }; await contextStorage.run(context, async () => { diff --git a/tests/onepassword-tool.test.ts b/tests/onepassword-tool.test.ts index fe13d72..23d0381 100644 --- a/tests/onepassword-tool.test.ts +++ b/tests/onepassword-tool.test.ts @@ -13,7 +13,7 @@ describe("personal login tool orchestration", () => { vi.clearAllMocks(); vi.stubEnv("ONEPASSWORD_ENABLED", "true"); vi.stubEnv("BROWSERBASE_1PASSWORD_EXTENSION_ID", "synthetic-extension"); - ctx = { session: { id: "conversation", auth: { current: { principalType: "user", authenticator: "linq-message", issuer: "linq", principalId: "linq:sender" } } }, callId: "call", abortSignal: new AbortController().signal, getToken: vi.fn(async () => ({ token: "opaque-marker" })) } as unknown as ToolContext; + ctx = { session: { id: "conversation", auth: { current: { principalType: "user", authenticator: "photon-imessage", issuer: "photon", principalId: "photon:" + "a".repeat(64), subject: "phone:+12025550123", attributes: { "photon:audience": "direct" } } } }, callId: "call", abortSignal: new AbortController().signal, getToken: vi.fn(async () => ({ token: "opaque-marker" })) } as unknown as ToolContext; browser = { withHostedPage: vi.fn(async (_signal, operation) => operation({ url: async () => input.loginUrl }, "private-cdp-url", "browser-id")), close: vi.fn() }; mocks.run.mockImplementation(async (_id, operation) => ({ result: await operation(browser), workbench: { browser: { sessionId: "browser-id" } } })); mocks.extensionFill.mockResolvedValue(undefined); diff --git a/tests/photon/capabilities.test.ts b/tests/photon/capabilities.test.ts new file mode 100644 index 0000000..a619e83 --- /dev/null +++ b/tests/photon/capabilities.test.ts @@ -0,0 +1,444 @@ +/** + * Capability contract: every provider operation reachable through the durable + * command path validates its arguments with the shared zod schemas. Covers the + * PRD §10 inventory — the 50-method SDK surface is exposed as 44 enqueueable + * command ops; the remaining six are streaming/lifecycle methods consumed by + * the transport worker, never queued as commands. + */ +import { IMessageError } from "@photon-ai/advanced-imessage/grpc"; +import { describe, expect, it } from "vitest"; + +import { + KEYED_OPERATIONS, + READ_OPERATIONS, + commandArgsSchemas, + isReadOperation, + validateCommandArgs, + type PhotonCommand, + type PhotonCommandDeps, + type PhotonCommandOp, + type PhotonCommandResultMap, +} from "../../server/photon/contracts"; +import { conversationExecutors } from "../../server/photon/commands/conversations"; +import { locationExecutors } from "../../server/photon/commands/locations"; +import { inMemoryDb } from "./fixtures/harness"; + +const GUID = "iMessage;-;+12025550123"; +const MSG = "msg-guid-0001"; +const ATT = "att-guid-0001"; +const POLL = "poll-msg-guid-0001"; +const ADDR = "+12025550123"; +const ART = "art:abc123"; + +/** The complete command catalog, grouped like the SDK resource surface. */ +const EXPECTED_OPERATIONS = [ + /* messages — M01..M10 mutations + M11..M14 reads (M15 subscribes, worker-only) */ + "messages.sendText", "messages.sendAttachment", "messages.sendMultipart", + "messages.sendCustomizedMiniApp", "messages.updateCustomizedMiniApp", + "messages.edit", "messages.unsend", "messages.setReaction", + "messages.placeSticker", "messages.notifySilenced", + "messages.get", "messages.listInChat", "messages.listRecent", "messages.getEmbeddedMedia", + /* attachments — A01 upload + A02 read (A03 downloadStream is a worker stream) */ + "attachments.upload", "attachments.get", + /* chats — C01..C09 (C10 subscribes, worker-only) */ + "chats.create", "chats.get", "chats.count", "chats.markRead", "chats.setTyping", + "chats.shareContactInfo", "chats.hasBackground", "chats.setBackground", "chats.removeBackground", + /* groups — G01..G07 (G08 subscribes, worker-only) */ + "groups.setDisplayName", "groups.addParticipants", "groups.removeParticipants", + "groups.leave", "groups.setIcon", "groups.getIcon", "groups.removeIcon", + /* polls — P01..P05 (P06 subscribes, worker-only) */ + "polls.create", "polls.get", "polls.vote", "polls.unvote", "polls.addOption", + /* addresses — D01..D03 */ + "addresses.get", "addresses.isIMessageAvailable", "addresses.isFocusSilenced", + /* locations — L01..L03 + Bell-scoped watch control for L04 */ + "locations.request", "locations.list", "locations.get", "locations.watch", +].sort() as readonly PhotonCommandOp[]; + +const MINI_APP = { + appName: "Bell Cards", + extensionBundleId: "com.bell.cards.extension", + teamId: "ABCDE12345", + url: "https://bell.example/cards/1", + layout: { caption: "Your card is ready" }, +} as const; + +const MINI_APP_SESSION = { + chatGuid: GUID, + messageGuid: MSG, + sessionId: "session-uuid-1", + targetMessageGuid: "target-guid-1", +} as const; + +/** One positive argument fixture per operation. */ +const POSITIVE: Record = { + "messages.sendText": { chat: GUID, text: "On it — opening Best Buy now." }, + "messages.sendAttachment": { chat: GUID, attachmentGuid: ATT }, + "messages.sendMultipart": { chat: GUID, parts: [{ text: "first bubble" }, { attachmentGuid: ATT }] }, + "messages.sendCustomizedMiniApp": { chat: GUID, message: MINI_APP }, + "messages.updateCustomizedMiniApp": { session: MINI_APP_SESSION, message: MINI_APP }, + "messages.edit": { chat: GUID, messageGuid: MSG, newText: "corrected text" }, + "messages.unsend": { chat: GUID, messageGuid: MSG }, + "messages.setReaction": { chat: GUID, messageGuid: MSG, reaction: "love", isSet: true }, + "messages.placeSticker": { chat: GUID, messageGuid: MSG, stickerAttachmentGuid: ATT, placement: { x: 0.5, y: 0.5 } }, + "messages.notifySilenced": { chat: GUID, messageGuid: MSG }, + "messages.get": { messageGuid: MSG }, + "messages.listInChat": { chat: GUID }, + "messages.listRecent": {}, + "messages.getEmbeddedMedia": { chat: GUID, messageGuid: MSG }, + "attachments.upload": { fileName: "photo.png", artifactId: ART }, + "attachments.get": { attachmentGuid: ATT }, + "chats.create": { addresses: [ADDR] }, + "chats.get": { chat: GUID }, + "chats.count": {}, + "chats.markRead": { chat: GUID }, + "chats.setTyping": { chat: GUID, isTyping: true }, + "chats.shareContactInfo": { chat: GUID }, + "chats.hasBackground": { chat: GUID }, + "chats.setBackground": { chat: GUID, preset: "skyDusk" }, + "chats.removeBackground": { chat: GUID }, + "groups.setDisplayName": { chat: GUID, name: "Weekend crew" }, + "groups.addParticipants": { chat: GUID, addresses: [ADDR] }, + "groups.removeParticipants": { chat: GUID, addresses: [ADDR] }, + "groups.leave": { chat: GUID }, + "groups.setIcon": { chat: GUID, artifactId: ART }, + "groups.getIcon": { chat: GUID }, + "groups.removeIcon": { chat: GUID }, + "polls.create": { chat: GUID, title: "Dinner tonight?", choices: ["Sushi", "Pizza"] }, + "polls.get": { pollMessageGuid: POLL }, + "polls.vote": { pollMessageGuid: POLL, optionIdentifier: "opt-aaaa" }, + "polls.unvote": { pollMessageGuid: POLL }, + "polls.addOption": { pollMessageGuid: POLL, text: "Tacos" }, + "addresses.get": { address: ADDR }, + "addresses.isIMessageAvailable": { address: ADDR }, + "addresses.isFocusSilenced": { address: ADDR }, + "locations.request": { chat: GUID, address: ADDR }, + "locations.list": {}, + "locations.get": { address: ADDR }, + "locations.watch": { action: "start" }, +}; + +describe("command catalog completeness", () => { + it("covers exactly the 44 enqueueable ops from the 50-method inventory", () => { + const keys = Object.keys(commandArgsSchemas).sort(); + // Snapshot the entire key list so a silently dropped or invented op fails. + expect(keys).toEqual(EXPECTED_OPERATIONS); + expect(keys).toHaveLength(44); + }); + + it("classifies every op as keyed, read, or unkeyed mutation without overlap", () => { + for (const op of EXPECTED_OPERATIONS) { + const keyed = KEYED_OPERATIONS.has(op); + const read = READ_OPERATIONS.has(op); + expect(keyed && read, `${op} cannot be both keyed and read`).toBe(false); + } + // PRD §10: 15 read methods are all represented as commands. + expect(READ_OPERATIONS.size).toBe(15); + expect(KEYED_OPERATIONS.size).toBe(22); + expect(isReadOperation("messages.get")).toBe(true); + expect(isReadOperation("messages.sendText")).toBe(false); + }); + + it.each(EXPECTED_OPERATIONS)("accepts a valid argument payload for %s", (op) => { + expect(() => validateCommandArgs(op, POSITIVE[op])).not.toThrow(); + }); +}); + +/** + * Tool-level result mapping: `PhotonCommandResultMap` declares what each op's + * executor emits and what the tool layer reads off `receipt.result`. The + * sample fixtures are checked per-op at typecheck, so a contract drift fails + * `pnpm typecheck`; the runtime assertions pin the JSON field names the + * model actually sees. The six projections this pins were the confirmed + * empty-output defects — icon/media artifact ids, found/location/info + * lookups, create's initialMessageGuid, and the mini-app session. + */ +const RESULT_SAMPLES = { + "groups.getIcon": { artifactId: ART, mimeType: "image/png", byteSize: 128 }, + "messages.getEmbeddedMedia": { artifactId: ART, mediaType: "image/png", byteSize: 128 }, + "attachments.upload": { attachmentGuid: ATT, transferState: "sent", totalBytes: 128 }, + "addresses.get": { found: false, address: ADDR }, + "locations.get": { found: false, address: ADDR }, + "chats.count": { count: 2 }, + "chats.hasBackground": { hasBackground: true }, +} satisfies Partial; + +type AssertTrue = T; +/** True when any union member of the op's result carries `field`. */ +type ResultCarries = + PhotonCommandResultMap[Op] extends infer R + ? true extends (R extends unknown ? (Field extends keyof R ? true : false) : never) + ? true + : false + : never; + +/* Compile-time assertions for SDK-typed results with no writable fixture. + * Kept as a value only because `pnpm typecheck` is the enforcement; the + * `void` marks that nothing is asserted on the literal trues at runtime. */ +const RESULT_FIELD_CHECKS: [ + AssertTrue>, + AssertTrue>, + AssertTrue>, + AssertTrue>, + AssertTrue>, + AssertTrue extends { info: unknown } ? true : false>, + AssertTrue extends { location: unknown } ? true : false>, +] = [true, true, true, true, true, true, true]; +void RESULT_FIELD_CHECKS; + +/** Minimal durable command row for driving one real executor. */ +function commandFor(operation: PhotonCommandOp, args: unknown): PhotonCommand { + return { + commandId: `cmd-${operation}`, operation, + args: args as PhotonCommand["args"], argsDigest: "test-digest", + sourceId: "src-1", chatGuid: GUID, semanticKey: `sem-${operation}`, + dependsOn: [], + }; +} + +/** Executor deps whose `raw` is a per-test stub of the SDK surface. */ +function depsFor(raw: unknown): PhotonCommandDeps { + return { + client: { sendText: () => Promise.resolve(undefined), raw }, + artifacts: { + get: () => Promise.resolve(undefined), + put: () => Promise.resolve("art:unused"), + }, + config: { projectId: "test-project-0001" }, + now: () => new Date("2025-01-01T00:00:00Z"), + }; +} + +function sdkError(code: IMessageError["code"]): IMessageError { + return new IMessageError("provider says no", { code, grpcCode: 5, retryable: false }); +} + +describe("tool result contract", () => { + it("declares a result shape for every command op", () => { + const complete: [Exclude] extends [never] + ? true + : never = true; + expect(complete).toBe(true); + }); + + it("addresses.get emits the declared found and not-found shapes", async () => { + const executor = locationExecutors({ setLocationWatch: () => Promise.resolve(false) })["addresses.get"]; + if (!executor) throw new Error("addresses.get executor missing"); + + const missing = await executor( + commandFor("addresses.get", { address: ADDR }), + depsFor({ addresses: { get: () => Promise.reject(sdkError("addressNotFound")) } }), + ); + expect(missing).toEqual({ kind: "acknowledged", result: RESULT_SAMPLES["addresses.get"] }); + + const info = { handle: ADDR, isIMessage: true }; + const found = await executor( + commandFor("addresses.get", { address: ADDR }), + depsFor({ addresses: { get: () => Promise.resolve(info) } }), + ); + expect(found).toEqual({ kind: "acknowledged", result: { found: true, info } }); + }); + + it("locations.get emits the declared not-found shape", async () => { + const executor = locationExecutors({ setLocationWatch: () => Promise.resolve(false) })["locations.get"]; + if (!executor) throw new Error("locations.get executor missing"); + const missing = await executor( + commandFor("locations.get", { address: ADDR }), + depsFor({ locations: { get: () => Promise.reject(sdkError("sharedFriendLocationNotFound")) } }), + ); + expect(missing).toEqual({ kind: "acknowledged", result: RESULT_SAMPLES["locations.get"] }); + }); + + it("chats.count emits the declared shape through the real executor", async () => { + const executor = conversationExecutors(inMemoryDb())["chats.count"]; + if (!executor) throw new Error("chats.count executor missing"); + const outcome = await executor( + commandFor("chats.count", {}), + depsFor({ chats: { count: () => Promise.resolve(2) } }), + ); + expect(outcome).toEqual({ kind: "acknowledged", result: RESULT_SAMPLES["chats.count"] }); + }); +}); + +describe("text and message validation", () => { + it("rejects empty and whitespace-broken text", () => { + expect(() => validateCommandArgs("messages.sendText", { chat: GUID, text: "" })).toThrow(); + expect(() => validateCommandArgs("messages.sendText", { chat: GUID })).toThrow(); + }); + + it("accepts non-ASCII text and every documented effect", () => { + expect(validateCommandArgs("messages.sendText", { chat: GUID, text: "Réponds 🥖 テスト" }).text) + .toBe("Réponds 🥖 テスト"); + for (const effect of ["slam", "loud", "gentle", "invisible", + "confetti", "fireworks", "balloons", "heart", "lasers", "celebration", "sparkles", "spotlight", "echo"]) { + expect(() => validateCommandArgs("messages.sendText", { chat: GUID, text: "x", effect }), effect).not.toThrow(); + } + expect(() => validateCommandArgs("messages.sendText", { chat: GUID, text: "x", effect: "confetti-explosion" })) + .toThrow(); + }); + + it("validates all eight text effects inside formatting ranges", () => { + for (const effect of ["big", "small", "shake", "nod", "explode", "ripple", "bloom", "jitter"]) { + const args = { chat: GUID, text: "hello", formatting: [{ type: "effect", start: 0, length: 5, effect }] }; + expect(() => validateCommandArgs("messages.sendText", args), effect).not.toThrow(); + } + expect(() => validateCommandArgs("messages.sendText", { + chat: GUID, text: "hello", formatting: [{ type: "effect", start: 0, length: 5, effect: "firework" }], + })).toThrow(); + }); + + it("enforces UTF-16 offset bounds and positive range lengths", () => { + const formatting = [{ type: "bold", start: -1, length: 5 }]; + expect(() => validateCommandArgs("messages.sendText", { chat: GUID, text: "x", formatting })).toThrow(); + expect(() => validateCommandArgs("messages.sendText", { + chat: GUID, text: "x", formatting: [{ type: "bold", start: 0, length: 0 }], + })).toThrow(); + }); + + it("accepts reply targets as guid or {guid,partIndex} with partIndex bounds", () => { + for (const replyTo of [MSG, { guid: MSG, partIndex: 0 }, { guid: MSG, partIndex: 2 }]) { + expect(() => validateCommandArgs("messages.sendText", { chat: GUID, text: "x", replyTo })).not.toThrow(); + } + // Object form requires partIndex — a bare {guid} object is not a valid reply target. + expect(() => validateCommandArgs("messages.sendText", { chat: GUID, text: "x", replyTo: { guid: MSG } })).toThrow(); + expect(() => validateCommandArgs("messages.sendText", { chat: GUID, text: "x", replyTo: { guid: MSG, partIndex: -1 } })).toThrow(); + expect(() => validateCommandArgs("messages.sendText", { chat: GUID, text: "x", replyTo: { guid: MSG, partIndex: 256 } })).toThrow(); + }); + + it("bounds edit/unsend/reaction/sticker partIndex to a byte", () => { + for (const op of ["messages.edit", "messages.unsend", "messages.setReaction", "messages.placeSticker"] as const) { + const base = { chat: GUID, messageGuid: MSG, partIndex: 300 }; + expect(() => validateCommandArgs(op, { ...base, ...(op === "messages.edit" ? { newText: "t" } : {}), + ...(op === "messages.setReaction" ? { reaction: "like", isSet: true } : {}), + ...(op === "messages.placeSticker" ? { stickerAttachmentGuid: ATT, placement: { x: 0.1, y: 0.1 } } : {}), + } as never), op).toThrow(); + } + expect(validateCommandArgs("messages.edit", { chat: GUID, messageGuid: MSG, newText: "t", partIndex: 0 }).partIndex).toBe(0); + }); + + it("limits reactions to six tapbacks or a custom emoji", () => { + for (const reaction of ["love", "like", "dislike", "laugh", "emphasize", "question"]) { + expect(() => validateCommandArgs("messages.setReaction", { chat: GUID, messageGuid: MSG, reaction, isSet: false }), String(reaction)).not.toThrow(); + } + expect(() => validateCommandArgs("messages.setReaction", { chat: GUID, messageGuid: MSG, reaction: { customEmoji: "🎃" }, isSet: true })).not.toThrow(); + expect(() => validateCommandArgs("messages.setReaction", { chat: GUID, messageGuid: MSG, reaction: "thumbsup", isSet: true })).toThrow(); + expect(() => validateCommandArgs("messages.setReaction", { chat: GUID, messageGuid: MSG, reaction: { customEmoji: "" }, isSet: true })).toThrow(); + }); + + it("requires normalized sticker placement coordinates", () => { + const base = { chat: GUID, messageGuid: MSG, stickerAttachmentGuid: ATT }; + expect(() => validateCommandArgs("messages.placeSticker", { ...base, placement: { x: -0.1, y: 0.5 } })).toThrow(); + expect(() => validateCommandArgs("messages.placeSticker", { ...base, placement: { x: 0.5, y: 1.1 } })).toThrow(); + expect(() => validateCommandArgs("messages.placeSticker", { ...base, placement: { x: 0.5, y: 0.5, scale: 1.5, rotation: 0.4, width: 120 } })).not.toThrow(); + }); + + it("requires nonempty multipart parts without collapsing ordering", () => { + expect(() => validateCommandArgs("messages.sendMultipart", { chat: GUID, parts: [] })).toThrow(); + expect(() => validateCommandArgs("messages.sendMultipart", { chat: GUID, parts: new Array(65).fill({ text: "x" }) })).toThrow(); + expect(validateCommandArgs("messages.sendMultipart", { + chat: GUID, parts: [{ text: "a", bubbleIndex: 0 }, { text: "@dana", mentionedAddress: ADDR }, { attachmentGuid: ATT }], + }).parts).toHaveLength(3); + }); + + it("scopes history reads to the documented filter surface", () => { + expect(validateCommandArgs("messages.listInChat", { chat: GUID, filter: { pageSize: 100, isFromMe: false } }).filter!.pageSize).toBe(100); + expect(() => validateCommandArgs("messages.listInChat", { chat: GUID, filter: { pageSize: 0 } })).toThrow(); + expect(() => validateCommandArgs("messages.listInChat", { chat: GUID, filter: { pageSize: 101 } })).toThrow(); + // listRecent has no chatGuid — the SDK type forbids it; strict schema rejects it too. + expect(() => validateCommandArgs("messages.listRecent", { filter: { chatGuid: GUID } })).toThrow(); + }); +}); + +describe("mini-app card validation", () => { + it("enforces the 10-character uppercase team id and colon-free bundle id", () => { + for (const teamId of ["abcde12345", "ABCDE1234", "ABCDE123456", "ABCDE1234-", "ABCDE 1234"]) { + expect(() => validateCommandArgs("messages.sendCustomizedMiniApp", { chat: GUID, message: { ...MINI_APP, teamId } }), teamId).toThrow(); + } + expect(() => validateCommandArgs("messages.sendCustomizedMiniApp", { + chat: GUID, message: { ...MINI_APP, extensionBundleId: "com.bell:bad" }, + })).toThrow(); + }); + + it("requires an absolute http(s) card url", () => { + for (const url of ["not-a-url", "ftp://x", "javascript:alert(1)", "bell.example/cards"]) { + expect(() => validateCommandArgs("messages.sendCustomizedMiniApp", { chat: GUID, message: { ...MINI_APP, url } }), url).toThrow(); + } + expect(validateCommandArgs("messages.sendCustomizedMiniApp", { chat: GUID, message: { ...MINI_APP, url: "http://127.0.0.1:4320/c" } }).message.url) + .toBe("http://127.0.0.1:4320/c"); + }); + + it("accepts appStoreId only as a positive integer and layout as structured slots", () => { + expect(() => validateCommandArgs("messages.sendCustomizedMiniApp", { chat: GUID, message: { ...MINI_APP, appStoreId: -1 } })).toThrow(); + expect(() => validateCommandArgs("messages.sendCustomizedMiniApp", { chat: GUID, message: { ...MINI_APP, appStoreId: 1.5 } })).toThrow(); + expect(() => validateCommandArgs("messages.sendCustomizedMiniApp", { chat: GUID, message: { ...MINI_APP, appStoreId: 6448314669 } })).not.toThrow(); + expect(() => validateCommandArgs("messages.sendCustomizedMiniApp", { chat: GUID, message: { ...MINI_APP, replyTo: MSG } })).toThrow(); + }); + + it("rejects an incomplete card session for updates", () => { + for (const session of [{}, { chatGuid: GUID }, { ...MINI_APP_SESSION, sessionId: "" }]) { + expect(() => validateCommandArgs("messages.updateCustomizedMiniApp", { session, message: MINI_APP })).toThrow(); + } + }); +}); + +describe("attachment, chat, group validation", () => { + it("requires artifact references instead of raw bytes or paths", () => { + expect(() => validateCommandArgs("attachments.upload", { fileName: "x.png", data: "AAAA" })).toThrow(); + expect(() => validateCommandArgs("attachments.upload", { fileName: "", artifactId: ART })).toThrow(); + expect(() => validateCommandArgs("attachments.upload", { fileName: "../etc/passwd" })).toThrow(); + expect(validateCommandArgs("attachments.upload", { fileName: "p.png", artifactId: ART, companionArtifactId: `${ART}-c` }).companionArtifactId) + .toBe("art:abc123-c"); + }); + + it("requires exactly one of preset or artifactId for setBackground", () => { + expect(() => validateCommandArgs("chats.setBackground", { chat: GUID })).toThrow(/exactly one/); + expect(() => validateCommandArgs("chats.setBackground", { chat: GUID, preset: "skyDusk", artifactId: ART })).toThrow(/exactly one/); + for (const preset of ["skyDusk", "skyHaze", "skySunset", "skyClear", "skySunrise", "skyDawn", + "waterLight", "waterDark", "auroraGreen", "auroraPurple", "auroraPink", + "colorMango", "colorIce", "colorPlum", "colorDeepSea", "colorGreenApple", "colorCherry", + "colorBubblegum", "colorTangerine", "colorMagenta", "colorLime", "colorSilver", "colorCarbon", "colorStone"]) { + expect(() => validateCommandArgs("chats.setBackground", { chat: GUID, preset }), preset).not.toThrow(); + } + expect(() => validateCommandArgs("chats.setBackground", { chat: GUID, preset: "rainbow" })).toThrow(); + }); + + it("bounds chat create addresses and rejects empty name/address lists", () => { + expect(() => validateCommandArgs("chats.create", { addresses: [] })).toThrow(); + expect(() => validateCommandArgs("chats.create", { addresses: new Array(101).fill(ADDR) })).toThrow(); + expect(() => validateCommandArgs("chats.create", { addresses: ["ab"] })).toThrow(); + expect(() => validateCommandArgs("groups.setDisplayName", { chat: GUID, name: "" })).toThrow(); + expect(() => validateCommandArgs("groups.addParticipants", { chat: GUID, addresses: [] })).toThrow(); + expect(() => validateCommandArgs("groups.setIcon", { chat: GUID })).toThrow(); + }); +}); + +describe("poll, address, and location validation", () => { + it("requires a nonempty title and two to one hundred nonempty choices", () => { + expect(() => validateCommandArgs("polls.create", { chat: GUID, title: "", choices: ["a", "b"] })).toThrow(); + expect(() => validateCommandArgs("polls.create", { chat: GUID, title: "t", choices: ["only"] })).toThrow(); + expect(() => validateCommandArgs("polls.create", { chat: GUID, title: "t", choices: ["a", ""] })).toThrow(); + expect(() => validateCommandArgs("polls.create", { chat: GUID, title: "t", choices: new Array(101).fill("x") })).toThrow(); + expect(validateCommandArgs("polls.create", { chat: GUID, title: "t", choices: new Array(100).fill("x") }).choices).toHaveLength(100); + }); + + it("votes by optionIdentifier — never by label or position", () => { + expect(() => validateCommandArgs("polls.vote", { pollMessageGuid: POLL, optionIdentifier: "" })).toThrow(); + expect(() => validateCommandArgs("polls.vote", { pollMessageGuid: POLL, option: 0 })).toThrow(); + expect(() => validateCommandArgs("polls.vote", { pollMessageGuid: POLL, optionIdentifier: "Sushi 🍣" })).not.toThrow(); + expect(() => validateCommandArgs("polls.addOption", { pollMessageGuid: POLL, text: "" })).toThrow(); + }); + + it("applies E.164-ish address bounds across address and location ops", () => { + for (const op of ["addresses.get", "addresses.isIMessageAvailable", "addresses.isFocusSilenced", "locations.get"] as const) { + expect(() => validateCommandArgs(op, { address: "x" }), op).toThrow(); + expect(() => validateCommandArgs(op, { address: `+${"1".repeat(400)}` }), op).toThrow(); + } + expect(() => validateCommandArgs("locations.request", { chat: GUID, address: "x" })).toThrow(); + }); + + it("keeps locations.watch a scoped start/stop control, not a raw stream", () => { + expect(validateCommandArgs("locations.watch", { action: "start" })).toEqual({ action: "start" }); + expect(validateCommandArgs("locations.watch", { action: "stop", address: ADDR }).action).toBe("stop"); + expect(() => validateCommandArgs("locations.watch", { action: "pause" })).toThrow(); + expect(() => validateCommandArgs("locations.watch", {})).toThrow(); + }); +}); diff --git a/tests/photon/contracts.test.ts b/tests/photon/contracts.test.ts new file mode 100644 index 0000000..fd344d3 --- /dev/null +++ b/tests/photon/contracts.test.ts @@ -0,0 +1,294 @@ +/** + * Contract tests for the frozen Photon foundation: canonical identity, + * handle normalization, deterministic keys, and strict environment parsing. + * These exercise the real functions — no mocks — because every other + * workstream builds on these exact shapes. + */ +import { describe, expect, it } from "vitest"; + +import { + E164_PATTERN, + IMESSAGE_TOKEN_URL, + PhotonConfigError, + loadPhotonConfig, + photonEnabled, + photonTokenUrl, +} from "../../server/photon/config"; +import { + IDENTITY_VERSION, + PHOTON_AUTHENTICATOR, + PHOTON_ISSUER, + bindingIdFor, + conversationKey, + dispatchKeyFor, + eveAddressFor, + handleKey, + isPhotonAuth, + normalizeHandle, + photonPrincipalId, + photonSessionAuth, + providerKeyFor, + semanticKeyFor, + sha256Hex, + sourceIdFor, + type CanonicalHandle, +} from "../../server/photon/identity"; + +const phone = (value = "+12025550123"): CanonicalHandle => ({ kind: "phone", value }); +const email = (value = "operator@example.com"): CanonicalHandle => ({ kind: "email", value }); + +describe("normalizeHandle", () => { + it("accepts only strict E.164 phone handles", () => { + expect(normalizeHandle("+12025550123")).toEqual(phone()); + expect(normalizeHandle(" +442071234567 ")).toEqual(phone("+442071234567")); + for (const junk of [ + "2025550123", "0012025550123", "+1 202 555 0123", "+1202555012x", + "+0123", "+", "tel:+12025550123", "+1-202-555-0123", + ]) { + expect(normalizeHandle(junk), junk).toBeNull(); + } + // + followed by up to 15 digits, never starting with 0. + expect(normalizeHandle(`+1${"2".repeat(14)}`)).not.toBeNull(); + expect(normalizeHandle(`+1${"2".repeat(15)}`)).toBeNull(); + }); + + it("canonicalizes email handles to lowercase", () => { + expect(normalizeHandle("Operator@Example.COM")).toEqual(email()); + expect(normalizeHandle(" user+tag@sub.domain.example ")).toEqual(email("user+tag@sub.domain.example")); + }); + + it("returns null for junk, empty, and absent handles", () => { + for (const junk of [null, undefined, "", " ", "not-a-handle", "@", "a@", "a@b", "a@@b.com"]) { + expect(normalizeHandle(junk), String(junk)).toBeNull(); + } + }); + + it("never merges phone and email handle kinds", () => { + const sameDigits = "+12025550123"; + const asEmail = "12025550123@example.com"; + const p = normalizeHandle(sameDigits)!; + const e = normalizeHandle(asEmail)!; + expect(handleKey(p)).not.toBe(handleKey(e)); + expect(photonPrincipalId("proj", p)).not.toBe(photonPrincipalId("proj", e)); + }); +}); + +describe("photonPrincipalId", () => { + it("is deterministic for the same versioned tuple", () => { + const id = photonPrincipalId("proj-1", phone()); + expect(id).toBe(photonPrincipalId("proj-1", phone())); + expect(id).toMatch(/^photon:[0-9a-f]{64}$/); + expect(id).toBe(`photon:${sha256Hex(JSON.stringify([IDENTITY_VERSION, "proj-1", "phone", "+12025550123"]))}`); + }); + + it("differs across project, handle kind, and handle value", () => { + const base = photonPrincipalId("proj-1", phone()); + expect(photonPrincipalId("proj-2", phone())).not.toBe(base); + expect(photonPrincipalId("proj-1", phone("+12025550199"))).not.toBe(base); + // A phone number and an email containing the same digits are distinct owners. + expect(photonPrincipalId("proj-1", email())).not.toBe(base); + }); + + it("excludes the receiving line so one verified handle keeps one wallet owner", () => { + // The principal tuple has no line component by construction; two source + // bindings for different lines in one project hash to the same principal. + const id = photonPrincipalId("proj-1", phone()); + expect(id).toBe(`photon:${sha256Hex(JSON.stringify(["v1", "proj-1", "phone", "+12025550123"]))}`); + }); +}); + +describe("photonSessionAuth / isPhotonAuth", () => { + it("stamps the photon authenticator, issuer, subject and principal", () => { + const auth = photonSessionAuth("proj-1", phone()); + expect(auth.authenticator).toBe(PHOTON_AUTHENTICATOR); + expect(auth.issuer).toBe(PHOTON_ISSUER); + expect(auth.principalType).toBe("user"); + expect(auth.principalId).toBe(photonPrincipalId("proj-1", phone())); + expect(auth.subject).toBe("phone:+12025550123"); + expect(isPhotonAuth(auth)).toBe(true); + expect(isPhotonAuth({ authenticator: "legacy-message", issuer: "legacy" })).toBe(false); + expect(isPhotonAuth({ authenticator: PHOTON_AUTHENTICATOR, issuer: "other" })).toBe(false); + expect(isPhotonAuth(null)).toBe(false); + }); +}); + +describe("source / conversation / dispatch identity", () => { + it("produces stable source and binding identifiers", () => { + const sourceId = sourceIdFor("proj-1", "inst-1", "+12025550100"); + expect(sourceId).toBe(sourceIdFor("proj-1", "inst-1", "+12025550100")); + expect(sourceId).toMatch(/^photon-src:[0-9a-f]{64}$/); + expect(sourceIdFor("proj-1", "inst-2", "+12025550100")).not.toBe(sourceId); + + const bindingId = bindingIdFor(sourceId, "chat-guid-1", 1); + expect(bindingId).toBe(bindingIdFor(sourceId, "chat-guid-1", 1)); + expect(bindingIdFor(sourceId, "chat-guid-1", 2)).not.toBe(bindingId); + expect(eveAddressFor(bindingId)).toBe(`photon:${bindingId}`); + expect(conversationKey(sourceId, "chat-guid-1", 1)) + .toBe(JSON.stringify(["v1", sourceId, "chat-guid-1", 1])); + }); + + it("keeps dispatch keys deterministic and within Eve's 256-character limit", () => { + const sourceId = sourceIdFor("proj-1", "inst-1", "+12025550100"); + const key = dispatchKeyFor(sourceId, "epoch-1", "42", "send"); + expect(key).toBe(dispatchKeyFor(sourceId, "epoch-1", "42", "send")); + expect(key.length).toBeLessThanOrEqual(256); + // A poll vote and its poll produce different keys for the same sequence. + expect(dispatchKeyFor(sourceId, "epoch-1", "42", "send")).not.toBe(dispatchKeyFor(sourceId, "epoch-1", "42", "respond")); + // Absurdly long inputs hash down instead of exceeding the limit. + const longKey = dispatchKeyFor("s".repeat(400), "e".repeat(200), "9".repeat(200), "send"); + expect(longKey.length).toBeLessThanOrEqual(256); + expect(longKey).toBe(dispatchKeyFor("s".repeat(400), "e".repeat(200), "9".repeat(200), "send")); + }); + + it("produces distinct semantic output keys that never collapse identical text", () => { + const base = { sessionId: "s1", turnId: "t1", outputKind: "assistant_text" as const }; + const first = semanticKeyFor(base); + expect(first).toBe(semanticKeyFor(base)); + // Two intentional identical replies in different tasks remain distinct. + expect(semanticKeyFor({ ...base, turnId: "t2" })).not.toBe(first); + expect(semanticKeyFor({ ...base, partId: "p1" })).not.toBe(first); + expect(semanticKeyFor({ ...base, toolCallId: "tc-1" })).not.toBe(first); + const long = semanticKeyFor({ ...base, commandId: "c".repeat(600) }); + expect(long.length).toBeLessThanOrEqual(512); + expect(long).toBe(semanticKeyFor({ ...base, commandId: "c".repeat(600) })); + }); + + it("derives provider retry keys per command attempt", () => { + expect(providerKeyFor("cmd-1", 0)).toBe("bell:cmd-1:0"); + expect(providerKeyFor("cmd-1", 1)).toBe("bell:cmd-1:1"); + expect(providerKeyFor("cmd-2", 0)).not.toBe(providerKeyFor("cmd-1", 0)); + }); +}); + +describe("loadPhotonConfig", () => { + const enabled: NodeJS.ProcessEnv = { NODE_ENV: "test", BELL_PHOTON_ENABLED: "1" }; + const full: NodeJS.ProcessEnv = { + ...enabled, + IMESSAGE_PROJECT_ID: "proj-1", + IMESSAGE_PROJECT_SECRET: "secret", + BELL_PHOTON_PHONE_NUMBER: "+12025550100", + BELL_PHOTON_ALLOWED_SENDERS: "+12025550123", + BELL_PHOTON_ALLOWED_EMAIL_SENDERS: "operator@example.com", + BELL_PHOTON_ALLOWED_GROUPS: "iMessage;+;chat001", + BELL_PHOTON_INTERNAL_SECRET: "a".repeat(32), + BELL_PHOTON_INTERNAL_ORIGIN: "http://127.0.0.1:4274", + DATABASE_URL: "postgres://db.invalid/bell", + }; + + it("returns null whenever the channel is disabled", () => { + for (const value of [undefined, "", "0", "false", "no", "TRUE-but-not"]) { + expect(photonEnabled({ ...enabled, BELL_PHOTON_ENABLED: value }), String(value)).toBe(false); + expect(loadPhotonConfig({ ...enabled, BELL_PHOTON_ENABLED: value })).toBeNull(); + } + for (const value of ["1", "true", "yes"]) { + expect(photonEnabled({ ...enabled, BELL_PHOTON_ENABLED: value }), value).toBe(true); + } + }); + + it("parses a complete environment into a typed config", () => { + const config = loadPhotonConfig(full); + expect(config).not.toBeNull(); + expect(config!.projectId).toBe("proj-1"); + expect(config!.phoneNumber).toBe("+12025550100"); + expect(config!.allowedSenders).toEqual(["+12025550123"]); + expect(config!.allowedEmailSenders).toEqual(["operator@example.com"]); + expect(config!.allowedGroups).toEqual(["iMessage;+;chat001"]); + expect(config!.internalOrigin).toBe("http://127.0.0.1:4274"); + expect(config!.worker.maxArtifactBytes).toBe(24 * 1024 * 1024); + }); + + it.each([ + "IMESSAGE_PROJECT_ID", + "IMESSAGE_PROJECT_SECRET", + "BELL_PHOTON_PHONE_NUMBER", + "BELL_PHOTON_ALLOWED_SENDERS", + "BELL_PHOTON_INTERNAL_SECRET", + "BELL_PHOTON_INTERNAL_ORIGIN", + "DATABASE_URL", + ])("fails closed naming the missing variable %s", (name) => { + const env = { ...full, [name]: undefined }; + try { + loadPhotonConfig(env); + expect.unreachable(`loadPhotonConfig accepted missing ${name}`); + } catch (error) { + expect(error).toBeInstanceOf(PhotonConfigError); + expect((error as PhotonConfigError).setting).toBe(name); + expect((error as Error).message).toContain(name); + } + }); + + it("requires a nonempty E.164 sender allowlist and dedicated line", () => { + for (const [name, value] of [ + ["BELL_PHOTON_PHONE_NUMBER", "2025550100"], + ["BELL_PHOTON_PHONE_NUMBER", "tel:+12025550100"], + ["BELL_PHOTON_ALLOWED_SENDERS", ""], + ["BELL_PHOTON_ALLOWED_SENDERS", "+12025550123,bad-number"], + ["BELL_PHOTON_ALLOWED_EMAIL_SENDERS", "not-an-email"], + ] as const) { + expect(() => loadPhotonConfig({ ...full, [name]: value }), `${name}=${value}`) + .toThrowError(PhotonConfigError); + } + // Email senders remain optional; an empty value denies email-origin input. + expect(loadPhotonConfig({ ...full, BELL_PHOTON_ALLOWED_EMAIL_SENDERS: "" })!.allowedEmailSenders).toEqual([]); + }); + + it("deduplicates allowlists and trims entries", () => { + const config = loadPhotonConfig({ + ...full, + BELL_PHOTON_ALLOWED_SENDERS: " +12025550123 , +12025550123 ,+12025550124 ", + BELL_PHOTON_ALLOWED_GROUPS: " g1 , g1, g2 ", + }); + expect(config!.allowedSenders).toEqual(["+12025550123", "+12025550124"]); + expect(config!.allowedGroups).toEqual(["g1", "g2"]); + }); + + it("requires an internal secret of at least 32 bytes, never a provider token reuse", () => { + expect(() => loadPhotonConfig({ ...full, BELL_PHOTON_INTERNAL_SECRET: "short" })) + .toThrowError(/BELL_PHOTON_INTERNAL_SECRET/); + expect(loadPhotonConfig({ ...full, BELL_PHOTON_INTERNAL_SECRET: "x".repeat(32) })!.internalSecret).toBe("x".repeat(32)); + }); + + it("accepts only bare http(s) origins for the private dispatch origin", () => { + for (const origin of [ + "not-a-url", + "ftp://127.0.0.1", + "http://127.0.0.1:4274/path", + "http://127.0.0.1:4274/?q=1", + "http://user:pass@127.0.0.1:4274", + "http://127.0.0.1:4274#frag", + ]) { + expect(() => loadPhotonConfig({ ...full, BELL_PHOTON_INTERNAL_ORIGIN: origin }), origin) + .toThrowError(PhotonConfigError); + } + expect(loadPhotonConfig({ ...full, BELL_PHOTON_INTERNAL_ORIGIN: "http://localhost:4274/" })!.internalOrigin) + .toBe("http://localhost:4274"); + }); + + it("bounds worker tuning knobs", () => { + expect(() => loadPhotonConfig({ ...full, BELL_PHOTON_LEASE_MS: "100" })).toThrowError(/BELL_PHOTON_LEASE_MS/); + expect(() => loadPhotonConfig({ ...full, BELL_PHOTON_LEASE_MS: "999999" })).toThrowError(/BELL_PHOTON_LEASE_MS/); + expect(() => loadPhotonConfig({ ...full, BELL_PHOTON_MAX_ARTIFACT_BYTES: "10" })).toThrowError(/BELL_PHOTON_MAX_ARTIFACT_BYTES/); + expect(() => loadPhotonConfig({ ...full, BELL_PHOTON_COMMAND_BATCH: "500" })).toThrowError(/BELL_PHOTON_COMMAND_BATCH/); + const tuned = loadPhotonConfig({ ...full, BELL_PHOTON_LEASE_MS: "60000", BELL_PHOTON_COMMAND_BATCH: "40" }); + expect(tuned!.worker.leaseMs).toBe(60_000); + expect(tuned!.worker.commandBatchSize).toBe(40); + }); +}); + +describe("photonTokenUrl", () => { + it("interpolates only a well-formed project id", () => { + expect(photonTokenUrl("proj-1_ABC")).toBe(IMESSAGE_TOKEN_URL.replace("{projectId}", "proj-1_ABC")); + for (const bad of ["", "a/b", "../x", "a?b", "p".repeat(129), "space id"]) { + expect(() => photonTokenUrl(bad), bad).toThrowError(PhotonConfigError); + } + }); +}); + +describe("E164_PATTERN", () => { + it("matches the documented strict pattern", () => { + expect(E164_PATTERN.test("+12025550123")).toBe(true); + expect(E164_PATTERN.test("+1202555012")).toBe(true); + expect(E164_PATTERN.test("+02025550123")).toBe(false); + expect(E164_PATTERN.test("+1234567890123456")).toBe(false); + }); +}); diff --git a/tests/photon/dispatch.test.ts b/tests/photon/dispatch.test.ts new file mode 100644 index 0000000..3eb17e6 --- /dev/null +++ b/tests/photon/dispatch.test.ts @@ -0,0 +1,243 @@ +/** + * Dispatch pipeline: durable intents, the internal route contract, and the + * fixed-session binding. Store-level behaviors and the worker dispatcher + * both run against the in-memory PhotonDb seam. + */ +import { describe, expect, it, vi } from "vitest"; + +import { + PHOTON_DISPATCH_PATH, + type PhotonConfig, +} from "../../server/photon/config"; +import type { PhotonEventEnvelope, PhotonDispatchResult } from "../../server/photon/contracts"; +import { createDispatcher } from "../../server/photon/dispatch"; +import { + bindingIdFor, + dispatchKeyFor, + eveAddressFor, + sourceIdFor, +} from "../../server/photon/identity"; +import type { PhotonDb } from "../../server/photon/store"; + +import { envelope, inMemoryDb, payloadHashFor, testConfig } from "./fixtures/harness"; +import * as fx from "./fixtures/sdk-events"; + +const SOURCE_ID = sourceIdFor("test-project-0001", "inst-aaaa-bbbb", fx.RECEIVING_LINE); +const EPOCH = "epoch-1"; + +async function seededDb() { + const db = inMemoryDb(); + await db.enrollSource({ + sourceId: SOURCE_ID, projectId: "test-project-0001", instanceId: "inst-aaaa-bbbb", + accountScope: "acct", phoneNumber: fx.RECEIVING_LINE, endpoint: "x:443", logEpoch: EPOCH, + }); + return db; +} + +async function capture(db: PhotonDb, seq: number, env: PhotonEventEnvelope, disposition: "admitted" | "state_only" | "denied" = "admitted") { + return db.captureEvent({ + sourceId: SOURCE_ID, logEpoch: EPOCH, sequence: String(seq), sequenceNum: String(seq), + payloadHash: payloadHashFor(env), envelope: env, + eventFamily: env.event.family, eventType: env.event.type, + chatGuid: env.chatGuid, actor: env.actor, isFromMe: env.isFromMe, + occurredAt: env.occurredAt, sourceKind: env.sourceKind, disposition, + }); +} + +function admittedEnvelope(seq: number, chatGuid = fx.DIRECT_CHAT): PhotonEventEnvelope { + return envelope( + { family: "message", type: "received", message: fx.message({ guid: `m-${seq}`, chatGuids: [chatGuid] }) }, + { sequence: String(seq), chatGuid, actor: `phone:${fx.OPERATOR_PHONE}` }, + ); +} + +/* ------------------------------------------------------------------ */ +/* Intent ledger — frozen store contract */ +/* ------------------------------------------------------------------ */ + +describe("dispatch intent ledger", () => { + it("deduplicates on the deterministic dispatch key, not the intent id", async () => { + const db = await seededDb(); + await capture(db, 1, admittedEnvelope(1)); + const key = dispatchKeyFor(SOURCE_ID, EPOCH, "1", "send"); + const first = await db.upsertIntent({ + intentId: "i-1", sourceId: SOURCE_ID, logEpoch: EPOCH, sequence: "1", + action: "send", dispatchKey: key, inputDigest: "d1", + }); + const retry = await db.upsertIntent({ + intentId: "i-2", sourceId: SOURCE_ID, logEpoch: EPOCH, sequence: "1", + action: "send", dispatchKey: key, inputDigest: "d1", + }); + expect(retry.intentId).toBe(first.intentId); + // Distinct actions on the same inbox row remain distinct intents. + const respond = await db.upsertIntent({ + intentId: "i-3", sourceId: SOURCE_ID, logEpoch: EPOCH, sequence: "1", + action: "respond", dispatchKey: dispatchKeyFor(SOURCE_ID, EPOCH, "1", "respond"), inputDigest: "d1", + }); + expect(respond.intentId).not.toBe(first.intentId); + }); + + it("orders pending intents by provider sequence per conversation", async () => { + const db = await seededDb(); + for (const seq of [30, 10, 20]) await capture(db, seq, admittedEnvelope(seq)); + for (const seq of [30, 10, 20]) { + await db.upsertIntent({ + intentId: `i-${seq}`, sourceId: SOURCE_ID, logEpoch: EPOCH, sequence: String(seq), + action: "send", dispatchKey: dispatchKeyFor(SOURCE_ID, EPOCH, String(seq), "send"), inputDigest: "d", + }); + } + const pending = await db.pendingIntents(SOURCE_ID, 10); + expect(pending.map((row) => row.sequence)).toEqual(["10", "20", "30"]); + await db.updateIntent(pending[0].intentId, { status: "accepted", deliveryId: "eve-delivery-1" }); + expect((await db.getIntentByKey(dispatchKeyFor(SOURCE_ID, EPOCH, "10", "send")))?.status).toBe("accepted"); + expect((await db.pendingIntents(SOURCE_ID, 10)).map((row) => row.sequence)).toEqual(["20", "30"]); + }); + + it("binds one Eve session to a conversation and never rebinds implicitly", async () => { + const db = await seededDb(); + const bindingId = bindingIdFor(SOURCE_ID, fx.DIRECT_CHAT, 1); + await db.upsertConversation({ + bindingId, sourceId: SOURCE_ID, chatGuid: fx.DIRECT_CHAT, generation: 1, + scope: "direct", eveAddress: eveAddressFor(bindingId), + }); + await db.bindEveSession(bindingId, "eve-session-1"); + expect((await db.getConversationById(bindingId))?.eveSessionId).toBe("eve-session-1"); + expect((await db.findConversationBySession("eve-session-1"))?.bindingId).toBe(bindingId); + // Retire marks the generation closed; late events cannot reopen it. + await db.retireConversation(bindingId); + expect((await db.getConversationById(bindingId))?.retiredAt).toBeTruthy(); + expect(await db.activeConversations(SOURCE_ID)).toHaveLength(0); + }); +}); + +/* ------------------------------------------------------------------ */ +/* createDispatcher — transport workstream module */ +/* ------------------------------------------------------------------ */ + +describe("dispatch pipeline (server/photon/dispatch.ts)", () => { + const config = testConfig(); + + function fakeDispatch(result: PhotonDispatchResult) { + return vi.fn(async () => Response.json(result)); + } + + /** One dispatcher tick over the seam; returns the settled-row count. */ + function runOnce(db: PhotonDb, cfg: PhotonConfig, fetcher: typeof fetch) { + return createDispatcher({ + store: db, config: cfg, sourceId: SOURCE_ID, logEpoch: EPOCH, + fetcher, batchSize: 10, + }).runOnce(); + } + + it("POSTs an admitted row to the private route and records the durable result", async () => { + const db = await seededDb(); + const env = admittedEnvelope(1); + await capture(db, 1, env); + const fetcher = fakeDispatch({ status: "dispatched", sessionId: "eve-session-1", deliveryId: "del-1" }); + await runOnce(db, config, fetcher); + + expect(fetcher).toHaveBeenCalledOnce(); + const [input, init] = fetcher.mock.calls[0]; + const url = input instanceof Request ? input.url : String(input); + expect(url).toBe(`${config.internalOrigin}${PHOTON_DISPATCH_PATH}`); + const headers = new Headers(init?.headers); + expect(headers.get("authorization")).toBe(`Bearer ${config.internalSecret}`); + const body = JSON.parse(String(init?.body)); + expect(body.inboxId).toEqual({ sourceId: SOURCE_ID, logEpoch: EPOCH, sequence: "1" }); + expect(body.correlationId).toBeTruthy(); + // The route must not receive trusted sender text or session ids. + expect(body.message).toBeUndefined(); + expect(body.sessionId).toBeUndefined(); + expect(body.actor).toBeUndefined(); + + const row = await db.getInboxRow({ sourceId: SOURCE_ID, logEpoch: EPOCH, sequence: "1" }); + expect(row?.processingState).toBe("dispatched"); + }); + + it("keeps a retryable dispatch pending with attempts incremented", async () => { + const db = await seededDb(); + await capture(db, 2, admittedEnvelope(2)); + const fetcher = fakeDispatch({ status: "retryable", reason: "eve busy" }); + await runOnce(db, config, fetcher); + const row = await db.getInboxRow({ sourceId: SOURCE_ID, logEpoch: EPOCH, sequence: "2" }); + expect(row?.processingState).toBe("captured"); + expect(row?.attempts ?? 0).toBeGreaterThanOrEqual(1); + }); + + it("quarantines a rejected dispatch with the reason recorded", async () => { + const db = await seededDb(); + await capture(db, 3, admittedEnvelope(3)); + const fetcher = fakeDispatch({ status: "rejected", reason: "retired session" }); + await runOnce(db, config, fetcher); + const row = await db.getInboxRow({ sourceId: SOURCE_ID, logEpoch: EPOCH, sequence: "3" }); + expect(["quarantined", "failed"]).toContain(row?.processingState); + }); + + it("delivers state_only events as context, never as a model turn", async () => { + const db = await seededDb(); + const stateEvent = envelope( + { family: "chat", type: "markedRead" }, + { sequence: "4", chatGuid: fx.DIRECT_CHAT }, + ); + await capture(db, 4, stateEvent, "state_only"); + const fetcher = fakeDispatch({ status: "state_applied" }); + await runOnce(db, config, fetcher); + // The state event still reaches the bound session as context through the + // internal route — the route applies state and never opens a turn. + expect(fetcher).toHaveBeenCalledOnce(); + const row = await db.getInboxRow({ sourceId: SOURCE_ID, logEpoch: EPOCH, sequence: "4" }); + expect(["settled", "dispatched"]).toContain(row?.processingState); + const intent = await db.getIntentByKey(dispatchKeyFor(SOURCE_ID, EPOCH, "4", "state_apply")); + expect(intent?.action).toBe("state_apply"); + expect(intent?.status).toBe("accepted"); + }); + + it("refreshes membership before dispatch for a participant join", async () => { + const db = await seededDb(); + const bindingId = bindingIdFor(SOURCE_ID, fx.GROUP_CHAT, 1); + await db.upsertConversation({ + bindingId, sourceId: SOURCE_ID, chatGuid: fx.GROUP_CHAT, generation: 1, + scope: "group", eveAddress: eveAddressFor(bindingId), membership: [`phone:${fx.OPERATOR_PHONE}`], + }); + const joined = envelope( + { family: "group", type: "participantAdded", participant: fx.phone(fx.STRANGER_PHONE) }, + { sequence: "5", chatGuid: fx.GROUP_CHAT }, + ); + await capture(db, 5, joined, "state_only"); + const fetcher = fakeDispatch({ status: "state_applied" }); + await runOnce(db, groupConfigSeam(), fetcher); + const conversation = await db.getConversationById(bindingId); + expect(conversation?.membership).toContain(`phone:${fx.STRANGER_PHONE}`); + + function groupConfigSeam() { + return testConfig({ allowedGroups: [fx.GROUP_CHAT] }); + } + }); + + it("a late event for a retired conversation opens a fresh generation — never reopens the retired one", async () => { + const db = await seededDb(); + const bindingId = bindingIdFor(SOURCE_ID, fx.DIRECT_CHAT, 1); + await db.upsertConversation({ + bindingId, sourceId: SOURCE_ID, chatGuid: fx.DIRECT_CHAT, generation: 1, + scope: "direct", eveAddress: eveAddressFor(bindingId), eveSessionId: "eve-old", + }); + await db.retireConversation(bindingId); + await capture(db, 6, admittedEnvelope(6)); + const fetcher = fakeDispatch({ status: "dispatched", sessionId: "eve-new" }); + await runOnce(db, config, fetcher); + // The retired generation stays closed: its session binding and retiredAt + // are untouched. The inbound starts generation 2 instead. + const retired = await db.getConversationById(bindingId); + expect(retired?.eveSessionId).toBe("eve-old"); + expect(retired?.retiredAt).toBeTruthy(); + const generation2 = await db.getConversationById(bindingIdFor(SOURCE_ID, fx.DIRECT_CHAT, 2)); + if (fetcher.mock.calls.length > 0) { + // A fresh session bound to generation 2 — the retired generation is + // never rebound. + expect(generation2?.eveSessionId).toBe("eve-new"); + expect(generation2?.retiredAt).toBeUndefined(); + const row = await db.getInboxRow({ sourceId: SOURCE_ID, logEpoch: EPOCH, sequence: "6" }); + expect(row?.processingState).toBe("dispatched"); + } + }); +}); diff --git a/tests/photon/events.test.ts b/tests/photon/events.test.ts new file mode 100644 index 0000000..fce7911 --- /dev/null +++ b/tests/photon/events.test.ts @@ -0,0 +1,374 @@ +/** + * Inbound event pipeline: every documented SDK variant normalizes into the + * versioned internal envelope, and each envelope family reaches its correct + * admission disposition. Normalization lives in the parallel transport + * workstream — those describes skip while `server/photon/normalize.ts` is + * absent. The admission-policy integration tests run now against the frozen + * `policy.ts`. + */ +import { describe, expect, it } from "vitest"; + +import type { CatchUpEvent, LiveEvent, SharedFriendLocationUpdated } from "@photon-ai/advanced-imessage/grpc"; + +import type { PhotonEventEnvelope } from "../../server/photon/contracts"; +import { normalizeLiveEvent } from "../../server/photon/normalize"; +import { admitEvent } from "../../server/photon/policy"; + +import { + envelope, + groupConfig, + payloadHashFor, + testConfig, +} from "./fixtures/harness"; +import * as fx from "./fixtures/sdk-events"; +import { handleKey } from "../../server/photon/identity"; + +/* ------------------------------------------------------------------ */ +/* normalizeLiveEvent — transport workstream module */ +/* ------------------------------------------------------------------ */ + +/** Test-facing adapter: live-source normalization with a default receivedAt. */ +const n = { + normalizeLiveEvent: ( + event: LiveEvent | CatchUpEvent | SharedFriendLocationUpdated, + receivedAt = "2026-10-27T12:00:01.000Z", + ): PhotonEventEnvelope | null => normalizeLiveEvent(event, "live", receivedAt), +}; + +describe("normalizeLiveEvent (server/photon/normalize.ts)", () => { + + it("maps common envelope context: sequence string, chatGuid, isFromMe, occurredAt", () => { + const raw = fx.messageReceived({ chatGuid: fx.DIRECT_CHAT }); + const out = n.normalizeLiveEvent(raw, "2026-10-27T12:00:01.000Z"); + expect(out).not.toBeNull(); + expect(out!.sequence).toBe(String(raw.sequence)); + expect(out!.chatGuid).toBe(fx.DIRECT_CHAT); + expect(out!.isFromMe).toBe(false); + expect(out!.occurredAt).toBe(raw.occurredAt.toISOString()); + expect(out!.sourceKind).toBe("live"); + expect(out!.version).toBe(1); + }); + + it("normalizes a phone actor to phone:+E164 and an email actor to email:lowercase", () => { + const withPhone = n.normalizeLiveEvent(fx.messageReceived({ actor: fx.phone() })); + expect(withPhone!.actor).toBe(`phone:${fx.OPERATOR_PHONE}`); + const withEmail = n.normalizeLiveEvent(fx.messageReceived({ actor: fx.email("Member@Example.COM") })); + expect(withEmail!.actor).toBe("email:member@example.com"); + // An actor that cannot canonicalize must not fabricate an identity. + const garbage = fx.messageReceived({ actor: { address: "not a handle", service: "iMessage" } }); + const out = n.normalizeLiveEvent(garbage); + expect(out === null || out.actor === undefined).toBe(true); + }); + + it("rejects unsafe integers rather than silently truncating the sequence", () => { + const unsafe = fx.messageReceived(); + const forged = { ...unsafe, sequence: Number.MAX_SAFE_INTEGER + 1 }; + let result: PhotonEventEnvelope | null | "threw"; + try { + result = n.normalizeLiveEvent(forged) ?? null; + } catch { + result = "threw"; + } + // Either an explicit failure or a rejection — never a truncated sequence. + if (result === "threw") { + expect(result).toBe("threw"); + } else { + expect(result).toBeNull(); + } + const maxSafe = { ...unsafe, sequence: Number.MAX_SAFE_INTEGER }; + const ok = n.normalizeLiveEvent(maxSafe); + if (ok !== null) expect(ok.sequence).toBe(String(Number.MAX_SAFE_INTEGER)); + }); + + it("maps every message variant to family 'message' with its own type", () => { + const cases: [LiveEvent, string][] = [ + [fx.messageReceived(), "received"], + [fx.messageEdited(), "edited"], + [fx.messageRead(), "read"], + [fx.messageUnsent(), "unsent"], + [fx.reactionAdded(), "reactionAdded"], + [fx.reactionRemoved(), "reactionRemoved"], + [fx.stickerPlaced(), "stickerPlaced"], + ]; + for (const [event, type] of cases) { + const out = n.normalizeLiveEvent(event); + expect(out?.event.family, event.type).toBe("message"); + expect(out?.event.type, event.type).toBe(type); + } + }); + + it("preserves message content structure instead of flattening to text", () => { + const raw = fx.messageReceived({ message: fx.unicodeMessage() }); + const out = n.normalizeLiveEvent(raw); + const event = out!.event; + expect(event.family).toBe("message"); + if (event.family === "message" && event.type === "received") { + expect(event.message.content.mentions).toHaveLength(1); + expect(event.message.content.text).toContain("🥖"); + } else { + expect.unreachable("received message did not stay a received message"); + } + }); + + it("maps every chat variant to family 'chat'", () => { + for (const event of [fx.backgroundChanged(), fx.backgroundRemoved(), fx.chatMarkedRead(), fx.chatArchived(), fx.chatUnarchived()]) { + const out = n.normalizeLiveEvent(event); + expect(out?.event.family, event.type).toBe("chat"); + expect(out?.event.type, event.type).toBe(event.type.slice("chat.".length)); + } + }); + + it("maps all six group.changed deltas to distinct group types", () => { + const events: [ReturnType, string][] = [ + [fx.groupDisplayNameChanged(), "displayNameChanged"], + [fx.groupParticipantAdded(), "participantAdded"], + [fx.groupParticipantRemoved(), "participantRemoved"], + [fx.groupParticipantLeft(), "participantLeft"], + [fx.groupIconChanged(), "iconChanged"], + [fx.groupIconRemoved(), "iconRemoved"], + ]; + for (const [event, type] of events) { + const out = n.normalizeLiveEvent(event); + expect(out?.event.family, event.change.type).toBe("group"); + expect(out?.event.type, event.change.type).toBe(type); + expect(out?.chatGuid).toBe(fx.GROUP_CHAT); + } + }); + + it("separates the group change initiator from the changed participant", () => { + const out = n.normalizeLiveEvent( + fx.groupParticipantRemoved(fx.phone(fx.STRANGER_PHONE), { actor: fx.phone(fx.OPERATOR_PHONE) }), + ); + expect(out?.actor).toBe(`phone:${fx.OPERATOR_PHONE}`); + const event = out?.event; + if (event?.family === "group" && event.type === "participantRemoved") { + expect(event.participant.address).toBe(fx.STRANGER_PHONE); + } else { + expect.unreachable("participantRemoved did not keep its participant"); + } + }); + + it("maps all four poll deltas including titleless creation", () => { + for (const [event, type] of [ + [fx.pollCreated(), "created"], + [fx.pollCreatedTitleless(), "created"], + [fx.pollOptionAdded(), "optionAdded"], + [fx.pollVoted(), "voted"], + [fx.pollUnvoted(), "unvoted"], + ] as const) { + const out = n.normalizeLiveEvent(event); + expect(out?.event.family, type).toBe("poll"); + expect(out?.event.type, type).toBe(type); + } + const created = n.normalizeLiveEvent(fx.pollVoted("opt-aaaa")); + if (created?.event.family === "poll" && created.event.type === "voted") { + expect(created.event.delta.optionIdentifier).toBe("opt-aaaa"); + } + }); + + it("maps location updates and the catchup.complete marker", () => { + const out = n.normalizeLiveEvent(fx.locationUpdated()); + expect(out?.event.family).toBe("location"); + if (out?.event.family === "location") expect(out.event.type).toBe("updated"); + // Location watch sequences live in their own namespace — they can never + // collide with durable-log inbox sequences. + expect(out?.sequence).toBe(`loc:${fx.locationUpdated().sourceSequence}`); + const done = n.normalizeLiveEvent(fx.catchupComplete(42) as unknown as LiveEvent); + if (done !== null) { + expect(done.event.family).toBe("catchup"); + expect(done.event.type).toBe("complete"); + if (done.event.family === "catchup") expect(done.event.headSequence).toBe("42"); + } + }); + + it("returns null for unknown variants instead of throwing", () => { + const futuristic = { type: "message.unknownFuture", sequence: 9, chatGuid: "c", isFromMe: false, occurredAt: new Date() } as unknown as LiveEvent; + expect(() => n.normalizeLiveEvent(futuristic)).not.toThrow(); + expect(n.normalizeLiveEvent(futuristic)).toBeNull(); + }); + + it("produces envelopes whose canonical payload hash ignores transport metadata", () => { + const raw = fx.messageReceived(); + const live = n.normalizeLiveEvent({ ...raw }, "2026-10-27T12:00:01.000Z"); + const replayed = n.normalizeLiveEvent({ ...raw }, "2026-10-27T12:00:09.000Z"); + expect(live).not.toBeNull(); + expect(replayed).not.toBeNull(); + // The hash covers provider identity only: same event from live and + // catch-up collides into one inbox record, not two. + const canonical = (env: PhotonEventEnvelope) => + payloadHashFor({ ...env, sourceKind: "live" }); + expect(canonical({ ...live!, sourceKind: "catchup" })).toBe(canonical(live!)); + }); +}); + +/* ------------------------------------------------------------------ */ +/* admitEvent — frozen policy, runs now */ +/* ------------------------------------------------------------------ */ + +const config = testConfig(); +const gconfig = groupConfig(); + +function admitDirect(event: Parameters[0], input: Parameters[1] = {}) { + return admitEvent(config, envelope(event, { chatGuid: fx.DIRECT_CHAT, ...input })); +} + +describe("admitEvent per envelope kind", () => { + it("admits a direct message from an allowlisted sender for an agent turn", () => { + const decision = admitDirect({ family: "message", type: "received", message: fx.message() }); + expect(decision).toMatchObject({ + disposition: "admitted", reason: "direct_message", + dispatchAction: "send", scope: "direct", + actor: { kind: "phone", value: fx.OPERATOR_PHONE }, + }); + }); + + it("stores a self-origin echo as state_only — never a new turn", () => { + for (const env of [ + envelope({ family: "message", type: "received", message: fx.message({ isFromMe: true }) }, { chatGuid: fx.DIRECT_CHAT }), + envelope({ family: "message", type: "received", message: fx.message() }, { chatGuid: fx.DIRECT_CHAT, isFromMe: true }), + envelope({ family: "message", type: "received", message: fx.message({ isAutoReply: true }) }, { chatGuid: fx.DIRECT_CHAT }), + ]) { + const decision = admitEvent(config, env); + expect(decision.disposition).toBe("state_only"); + expect(decision.reason).toBe("self_origin_echo"); + expect(decision.dispatchAction).toBe("state_apply"); + } + }); + + it("denies system, service and non-normal item types without dispatch", () => { + for (const message of [ + fx.message({ isSystemMessage: true }), + fx.message({ isServiceMessage: true }), + fx.message({ itemType: "participantChange" }), + fx.message({ itemType: "groupNameChange" }), + ]) { + const decision = admitDirect({ family: "message", type: "received", message }); + expect(decision).toMatchObject({ disposition: "denied", reason: "system_message", dispatchAction: "none" }); + } + }); + + it("rejects a conflicting destinationCallerId but accepts the verified line or absence", () => { + const foreign = admitDirect({ + family: "message", type: "received", + message: fx.message({ destinationCallerId: "+12025550177" }), + }); + expect(foreign).toMatchObject({ disposition: "denied", reason: "destination_caller_mismatch" }); + + const ownLine = admitDirect({ + family: "message", type: "received", + message: fx.message({ destinationCallerId: fx.RECEIVING_LINE }), + }); + expect(ownLine.disposition).toBe("admitted"); + + const absent = admitDirect({ + family: "message", type: "received", + message: fx.message({ destinationCallerId: undefined }), + }); + expect(absent.disposition).toBe("admitted"); + }); + + it("denies senders outside every allowlist and unverifiable senders", () => { + const stranger = admitDirect({ + family: "message", type: "received", + message: fx.message({ sender: fx.phone(fx.STRANGER_PHONE) }), + }); + expect(stranger).toMatchObject({ disposition: "denied", reason: "sender_not_allowed" }); + + // A sender the provider reports but Bell cannot verify as a handle never + // falls through to admission, even when no actor attribute exists. + const anonymous = admitDirect({ + family: "message", type: "received", + message: fx.message({ sender: fx.phone("not-a-handle") }), + }); + expect(anonymous).toMatchObject({ disposition: "denied", reason: "sender_unverified" }); + }); + + it("falls back to the event actor when the message sender is absent", () => { + const decision = admitEvent( + config, + envelope( + { family: "message", type: "received", message: fx.message({ sender: undefined }) }, + { chatGuid: fx.DIRECT_CHAT, actor: `phone:${fx.OPERATOR_PHONE}` }, + ), + ); + expect(decision).toMatchObject({ disposition: "admitted", actor: { kind: "phone", value: fx.OPERATOR_PHONE } }); + }); + + it("treats non-received message variants as state-only, even when self-origin", () => { + const variants = [ + { family: "message", type: "edited", messageGuid: "m", content: fx.message().content, editedAt: "t" }, + { family: "message", type: "read", messageGuid: "m", readAt: "t" }, + { family: "message", type: "unsent", messageGuid: "m", retractedAt: "t" }, + { family: "message", type: "reactionAdded", messageGuid: "m", reaction: { kind: "love" } }, + { family: "message", type: "reactionRemoved", messageGuid: "m", reaction: { kind: "love" } }, + { family: "message", type: "stickerPlaced", messageGuid: "m" }, + ] as const; + for (const event of variants) { + const decision = admitDirect(event as never, { actor: `phone:${fx.OPERATOR_PHONE}` }); + expect(decision.disposition, event.type).toBe("state_only"); + expect(decision.dispatchAction, event.type).toBe("state_apply"); + } + const selfEcho = admitDirect(variants[0] as never, { isFromMe: true }); + expect(selfEcho.reason).toBe("self_origin_mutation"); + }); + + it("keeps disallowed actors' state mutations from authorizing anything", () => { + const decision = admitDirect( + { family: "message", type: "reactionAdded", messageGuid: "m", reaction: { kind: "like" } }, + { actor: `phone:${fx.STRANGER_PHONE}` }, + ); + expect(decision).toMatchObject({ disposition: "state_only", reason: "actor_not_allowed", dispatchAction: "state_apply" }); + }); + + it("marks chat, poll, location and catchup families as state_apply", () => { + const cases: [Parameters[0], string][] = [ + [{ family: "chat", type: "markedRead" }, "chat_markedRead"], + [{ family: "chat", type: "archived" }, "chat_archived"], + [{ family: "poll", type: "created", pollMessageGuid: "p", delta: { type: "created", title: "t", options: [] } }, "poll_created"], + [{ family: "poll", type: "voted", pollMessageGuid: "p", delta: { type: "voted", optionIdentifier: "o" } }, "poll_voted"], + [{ family: "location", type: "updated", location: fx.sharedLocation(), sourceSequence: "7" }, "location_update"], + [{ family: "catchup", type: "complete", headSequence: "99" }, "catchup_complete_marker"], + ]; + for (const [event, reason] of cases) { + const decision = admitDirect(event); + expect(decision.disposition, reason).toBe("state_only"); + expect(decision.dispatchAction, reason).toBe("state_apply"); + expect(decision.reason, reason).toBe(reason); + } + }); + + it("denies a group-scoped envelope when group ingress is closed", () => { + const decision = admitEvent( + config, + envelope({ family: "message", type: "received", message: fx.message({ chatGuids: [fx.GROUP_CHAT] }) }, { chatGuid: fx.GROUP_CHAT }), + { scope: "group", generation: 1, membership: [], membershipVersion: 0 }, + ); + expect(decision).toMatchObject({ disposition: "denied", reason: "group_not_admitted" }); + }); + + it("admits an allowed group's member message and flags membership events", () => { + const conversation = { + scope: "group" as const, generation: 1, + membership: [handleKey({ kind: "phone" as const, value: fx.OPERATOR_PHONE })], + membershipVersion: 2, + }; + const decision = admitEvent( + gconfig, + envelope( + { family: "message", type: "received", message: fx.message({ chatGuids: [fx.GROUP_CHAT] }) }, + { chatGuid: fx.GROUP_CHAT }, + ), + conversation, + ); + expect(decision).toMatchObject({ disposition: "admitted", scope: "group", dispatchAction: "send" }); + const memberEvent = admitEvent( + gconfig, + envelope( + { family: "group", type: "participantLeft", participant: { address: fx.STRANGER_PHONE, service: "iMessage" } }, + { chatGuid: fx.GROUP_CHAT }, + ), + conversation, + ); + expect(memberEvent).toMatchObject({ disposition: "state_only", updatesMembership: true }); + }); +}); diff --git a/tests/photon/fixtures/harness.ts b/tests/photon/fixtures/harness.ts new file mode 100644 index 0000000..9dade2f --- /dev/null +++ b/tests/photon/fixtures/harness.ts @@ -0,0 +1,697 @@ +/** + * Shared Photon test harness: a valid config, envelope builders, a fake + * TypedEventStream, an in-memory PhotonDb seam, and the optional-module + * loader used to test pipeline modules that parallel workstreams are still + * implementing (their describes skip when the module is absent). + */ +import { createHash } from "node:crypto"; + +import type { PhotonConfig } from "../../../server/photon/config"; +import { + ENVELOPE_VERSION, + type PhotonEventEnvelope, + type PhotonNormalizedEvent, +} from "../../../server/photon/contracts"; +import { sha256Hex } from "../../../server/photon/identity"; +import type { + ArtifactInsert, + ArtifactRow, + CommandPatch, + CommandRow, + ConversationRow, + ConversationUpsert, + DeadlineRow, + InboxCapture, + InboxInsert, + InboxRef, + InboxRow, + IntentInsert, + IntentPatch, + IntentRow, + InteractionInsert, + InteractionRow, + PhotonDb, + ProjectorRow, + ResourceKind, + ResourceRow, + SourceEnrollment, + SourceRow, + SourceStatus, +} from "../../../server/photon/store"; + +import { GROUP_CHAT, OPERATOR_PHONE, RECEIVING_LINE } from "./sdk-events"; + +/* ------------------------------------------------------------------ */ +/* Configuration */ +/* ------------------------------------------------------------------ */ + +export const TEST_PROJECT_ID = "test-project-0001"; +export const TEST_INSTANCE_ID = "inst-aaaa-bbbb"; + +/** A fully valid enabled channel config; override fields per test. */ +export function testConfig(overrides: Partial = {}): PhotonConfig { + return { + projectId: TEST_PROJECT_ID, + projectSecret: "test-project-secret", + phoneNumber: RECEIVING_LINE, + allowedSenders: [OPERATOR_PHONE], + allowedEmailSenders: [], + allowedGroups: [], + internalSecret: "test-internal-secret-0123456789abcdef", + internalOrigin: "http://127.0.0.1:4274", + databaseUrl: "postgres://unused.invalid/bell", + publicUrl: "https://bell.example", + worker: { + leaseMs: 30_000, + heartbeatStaleMs: 90_000, + commandBatchSize: 8, + dispatchBatchSize: 8, + commandPollMs: 500, + dispatchPollMs: 500, + projectionPollMs: 1_000, + reconciliationMs: 300_000, + shutdownMs: 15_000, + maxArtifactBytes: 24 * 1024 * 1024, + }, + ...overrides, + }; +} + +export function groupConfig(overrides: Partial = {}): PhotonConfig { + return testConfig({ allowedGroups: [GROUP_CHAT], ...overrides }); +} + +/* ------------------------------------------------------------------ */ +/* Envelopes */ +/* ------------------------------------------------------------------ */ + +export interface EnvelopeInput { + readonly sequence?: string; + readonly chatGuid?: string; + readonly isFromMe?: boolean; + readonly occurredAt?: string; + readonly receivedAt?: string; + readonly sourceKind?: "live" | "catchup"; + readonly actor?: string; +} + +export function envelope(event: PhotonNormalizedEvent, input: EnvelopeInput = {}): PhotonEventEnvelope { + return { + version: ENVELOPE_VERSION, + sequence: input.sequence ?? "1", + chatGuid: input.chatGuid, + isFromMe: input.isFromMe, + occurredAt: input.occurredAt ?? "2026-10-27T12:00:00.000Z", + receivedAt: input.receivedAt ?? "2026-10-27T12:00:01.000Z", + sourceKind: input.sourceKind ?? "live", + actor: input.actor, + event, + }; +} + +/** Canonical payload hash over the durable envelope minus transport metadata. */ +export function payloadHashFor(env: PhotonEventEnvelope): string { + // Transport-only fields are dropped by JSON.stringify so replays and + // redeliveries hash identically. + return sha256Hex(JSON.stringify({ ...env, receivedAt: undefined, sourceKind: undefined })); +} + +/* ------------------------------------------------------------------ */ +/* Fake TypedEventStream */ +/* ------------------------------------------------------------------ */ + +export interface FakeStream extends AsyncIterable { + on(callback: (event: T) => void | Promise, onError?: (error: unknown) => void): () => void; + filter(predicate: (event: T) => boolean): FakeStream; + map(transform: (event: T) => U): FakeStream; + take(count: number): FakeStream; + close(): Promise; +} + +/** + * An async-iterable event stream mirroring the SDK's TypedEventStream surface + * (`for await`, `.on()`, `.filter()`, `.map()`, `.take()`, `.close()`). + * `opts.error` is thrown after every queued event was delivered, simulating a + * mid-stream disconnect. + */ +export function fakeStream(events: readonly T[], opts: { error?: unknown } = {}): FakeStream { + const queue = [...events]; + let closed = false; + let consuming = false; + let onCancel: (() => void) | undefined; + + async function* iterate(source: readonly T[]): AsyncGenerator { + for (const event of source) { + if (closed) return; + yield event; + await new Promise((resolve) => { onCancel = resolve; setTimeout(resolve, 0); }); + } + if (opts.error !== undefined && !closed) throw opts.error; + } + + const stream: FakeStream = { + async *[Symbol.asyncIterator]() { + if (consuming) throw new Error("stream already consumed"); + consuming = true; + yield* iterate(queue); + }, + on(callback, onError) { + if (consuming) throw new Error("stream already consumed"); + consuming = true; + let running = true; + void (async () => { + try { + for await (const event of iterate(queue)) { + if (!running) break; + await callback(event); + } + } catch (error) { + if (running) onError?.(error); + } + })(); + return () => { running = false; }; + }, + filter(predicate) { + return fakeStream(queue.filter(predicate), opts); + }, + map(transform) { + return fakeStream(queue.map(transform), opts); + }, + take(count) { + return fakeStream(queue.slice(0, Math.max(0, count)), opts); + }, + async close() { + closed = true; + onCancel?.(); + }, + }; + return stream; +} + +/* ------------------------------------------------------------------ */ +/* In-memory PhotonDb seam */ +/* ------------------------------------------------------------------ */ + +function refKey(ref: InboxRef): string { + return `${ref.sourceId}${ref.logEpoch}${ref.sequence}`; +} + +function num(value: string): bigint { + return BigInt(value); +} + +/** + * An in-memory implementation of the frozen `PhotonDb` contract. It mirrors + * the SQL semantics in `server/photon/store.ts`: PK dedup, hash-checked + * integrity conflicts, per-chat ordering, SKIP-LOCKED-style command claims, + * COALESCE patches, and one-time interaction resolution. Tests use it as the + * seam between pure pipeline logic and PostgreSQL. + */ +export function inMemoryDb(): PhotonDb & { + readonly inbox: Map; + readonly intents: Map; + readonly commands: Map; + readonly conversations: Map; + readonly sources: Map; + readonly interactions: Map; + readonly deadlines: Map; +} { + const sources = new Map(); + const inbox = new Map(); + const conversations = new Map(); + const intents = new Map(); + const commands = new Map(); + const commandsBySemanticKey = new Map(); + const resources = new Map(); + const artifacts = new Map(); + const interactions = new Map(); + const projectors = new Map(); + const deadlines = new Map(); + + function mapSourceRow(row: SourceRow): SourceRow { + return { ...row }; + } + + function requireSource(sourceId: string): SourceRow { + const source = sources.get(sourceId); + if (!source) throw new Error(`in-memory source ${sourceId} is not enrolled`); + return source; + } + + const db: PhotonDb = { + /* ------------------------------ sources ------------------------------ */ + async enrollSource(input: SourceEnrollment) { + const existing = sources.get(input.sourceId); + if (existing) { + existing.phoneNumber = input.phoneNumber; + existing.endpoint = input.endpoint; + return mapSourceRow(existing); + } + const row: SourceRow = { + ...input, + coverage: {}, + health: {}, + leaseEpoch: 0, + status: "pending", + }; + sources.set(input.sourceId, row); + return mapSourceRow(row); + }, + async getSource(sourceId) { + const row = sources.get(sourceId); + return row ? mapSourceRow(row) : undefined; + }, + async getLatestSource(projectId) { + const row = [...sources.values()].filter((s) => s.projectId === projectId).at(-1); + return row ? mapSourceRow(row) : undefined; + }, + async setSourceStatus(sourceId, status: SourceStatus) { + requireSource(sourceId).status = status; + }, + async updateCaptureCursor(sourceId, cursor, coverage) { + const source = requireSource(sourceId); + source.captureCursor = cursor; + if (coverage) source.coverage = { ...coverage }; + }, + async acquireLease(sourceId, ownerToken, leaseMs) { + const source = requireSource(sourceId); + const expired = !source.leaseExpiresAt || source.leaseExpiresAt.getTime() <= Date.now(); + if (source.ownerToken && source.ownerToken !== ownerToken && !expired) + return { acquired: false, owner: source.ownerToken } as const; + source.ownerToken = ownerToken; + source.leaseEpoch += 1; + source.leaseExpiresAt = new Date(Date.now() + leaseMs); + return { acquired: true, epoch: source.leaseEpoch } as const; + }, + async renewLease(sourceId, ownerToken, epoch, leaseMs) { + const source = requireSource(sourceId); + if (source.ownerToken !== ownerToken || source.leaseEpoch !== epoch) return false; + source.leaseExpiresAt = new Date(Date.now() + leaseMs); + return true; + }, + async releaseLease(sourceId, ownerToken, epoch) { + const source = requireSource(sourceId); + if (source.ownerToken === ownerToken && source.leaseEpoch === epoch) { + source.ownerToken = undefined; + source.leaseExpiresAt = undefined; + } + }, + async updateSourceHealth(sourceId, health) { + requireSource(sourceId).health = { ...health }; + }, + async updateCredentialExpiry(sourceId, expiresAt) { + requireSource(sourceId).credentialExpiresAt = expiresAt; + }, + + /* ------------------------------- inbox ------------------------------- */ + async captureEvent(input: InboxInsert): Promise { + const ref = { sourceId: input.sourceId, logEpoch: input.logEpoch, sequence: input.sequence }; + const key = refKey(ref); + const existing = inbox.get(key); + if (!existing) { + inbox.set(key, { + ...ref, + sequenceNum: input.sequenceNum, + payloadHash: input.payloadHash, + envelope: input.envelope, + tombstone: input.tombstone ?? false, + eventFamily: input.eventFamily, + eventType: input.eventType, + chatGuid: input.chatGuid, + actor: input.actor, + targetGuid: input.targetGuid, + isFromMe: input.isFromMe, + occurredAt: input.occurredAt, + receivedAt: new Date().toISOString(), + sourceKind: input.sourceKind, + disposition: input.disposition ?? "pending", + processingState: "captured", + attempts: 0, + }); + return { kind: "inserted", ref }; + } + if (existing.payloadHash !== input.payloadHash) + return { kind: "integrity_conflict", ref, existingHash: existing.payloadHash }; + return { kind: "duplicate", ref }; + }, + async getInboxRow(ref) { + return inbox.get(refKey(ref)); + }, + async updateDisposition(ref, disposition, lastError) { + const row = inbox.get(refKey(ref)); + if (!row) return; + row.disposition = disposition; + if (lastError !== undefined) row.lastError = lastError; + }, + async markInboxProcessing(ref, state, lastError) { + const row = inbox.get(refKey(ref)); + if (!row) return; + row.processingState = state; + row.attempts += 1; + if (lastError !== undefined) row.lastError = lastError; + }, + async dispatchableChatScopes(sourceId, limit) { + const firstSeqByScope = new Map(); + for (const row of inbox.values()) { + if (row.sourceId !== sourceId || row.processingState !== "captured") continue; + if (row.disposition !== "admitted" && row.disposition !== "state_only") continue; + const scope = row.chatGuid ?? null; + const seq = num(row.sequenceNum); + const existing = firstSeqByScope.get(scope); + if (existing === undefined || seq < existing) firstSeqByScope.set(scope, seq); + } + return [...firstSeqByScope.entries()] + .sort((a, b) => (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0)) + .slice(0, limit) + .map(([scope]) => scope); + }, + async dispatchableForChat(sourceId, chatGuid, limit) { + return [...inbox.values()] + .filter((row) => { + if (row.sourceId !== sourceId || row.processingState !== "captured") return false; + if (chatGuid === null) + return row.chatGuid === undefined && row.disposition === "state_only"; + return row.chatGuid === chatGuid + && (row.disposition === "admitted" || row.disposition === "state_only"); + }) + .sort((a, b) => (num(a.sequenceNum) < num(b.sequenceNum) ? -1 : 1)) + .slice(0, limit); + }, + async maxSequenceNum(sourceId, logEpoch) { + let max: bigint | undefined; + for (const row of inbox.values()) { + if (row.sourceId !== sourceId || row.logEpoch !== logEpoch) continue; + const value = num(row.sequenceNum); + if (max === undefined || value > max) max = value; + } + return max?.toString(); + }, + async activationBoundary(sourceId) { + return requireSource(sourceId).activationBoundary; + }, + async setActivationBoundary(sourceId, boundary) { + const source = requireSource(sourceId); + if (source.activationBoundary === undefined) source.activationBoundary = boundary; + }, + async inboxStats(sourceId) { + const pending = [...inbox.values()].filter( + (row) => row.sourceId === sourceId && row.disposition === "admitted" && row.processingState === "captured", + ); + return { + pending: pending.length, + quarantined: [...inbox.values()].filter((row) => row.sourceId === sourceId && row.processingState === "quarantined").length, + oldestPendingAgeSec: null, + }; + }, + + /* --------------------------- conversations --------------------------- */ + async getConversation(sourceId, chatGuid) { + const matches = [...conversations.values()] + .filter((row) => row.sourceId === sourceId && row.chatGuid === chatGuid) + .sort((a, b) => b.generation - a.generation); + return matches[0]; + }, + async getConversationById(bindingId) { + return conversations.get(bindingId); + }, + async upsertConversation(input: ConversationUpsert) { + const existing = conversations.get(input.bindingId); + if (existing) { + if (input.membership !== undefined) existing.membership = [...input.membership]; + return existing; + } + const row: ConversationRow = { + bindingId: input.bindingId, + sourceId: input.sourceId, + chatGuid: input.chatGuid, + generation: input.generation, + scope: input.scope, + eveAddress: input.eveAddress, + eveSessionId: input.eveSessionId, + initiatingPrincipal: input.initiatingPrincipal, + membershipVersion: 0, + membership: [...(input.membership ?? [])], + createdAt: new Date().toISOString(), + }; + conversations.set(input.bindingId, row); + return row; + }, + async bindEveSession(bindingId, eveSessionId) { + const row = conversations.get(bindingId); + if (row) row.eveSessionId = eveSessionId; + }, + async updateMembership(bindingId, membership) { + const row = conversations.get(bindingId); + if (!row) return; + row.membership = [...membership]; + row.membershipVersion += 1; + }, + async touchConversation(bindingId) { + const row = conversations.get(bindingId); + if (row) row.lastTurnAt = new Date().toISOString(); + }, + async retireConversation(bindingId) { + const row = conversations.get(bindingId); + if (row && !row.retiredAt) row.retiredAt = new Date().toISOString(); + }, + async activeConversations(sourceId) { + return [...conversations.values()].filter((row) => row.sourceId === sourceId && !row.retiredAt); + }, + async findConversationBySession(eveSessionId) { + return [...conversations.values()].find((row) => row.eveSessionId === eveSessionId); + }, + + /* --------------------------- dispatch intents ------------------------ */ + async upsertIntent(input: IntentInsert) { + const existing = [...intents.values()].find((row) => row.dispatchKey === input.dispatchKey); + if (existing) return existing; + const row: IntentRow = { ...input, status: "pending", attempts: 0, createdAt: new Date().toISOString() }; + intents.set(input.intentId, row); + return row; + }, + async getIntentByKey(dispatchKey) { + return [...intents.values()].find((row) => row.dispatchKey === dispatchKey); + }, + async updateIntent(intentId, patch: IntentPatch) { + const row = intents.get(intentId); + if (!row) return; + if (patch.status !== undefined) row.status = patch.status; + if (patch.eveSessionId !== undefined) row.eveSessionId = patch.eveSessionId; + if (patch.deliveryId !== undefined) row.deliveryId = patch.deliveryId; + if (patch.lastError !== undefined) row.lastError = patch.lastError; + row.attempts += 1; + }, + async pendingIntents(sourceId, limit) { + return [...intents.values()] + .filter((row) => row.sourceId === sourceId && row.status === "pending") + .sort((a, b) => { + const seqA = num(inbox.get(refKey(a))?.sequenceNum ?? "0"); + const seqB = num(inbox.get(refKey(b))?.sequenceNum ?? "0"); + return seqA < seqB ? -1 : 1; + }) + .slice(0, limit); + }, + + /* ------------------------------ commands ----------------------------- */ + async enqueueCommand(command) { + const byKey = commandsBySemanticKey.get(command.semanticKey); + if (byKey) { + if (byKey.argsDigest !== command.argsDigest) + throw new Error(`semantic_key ${command.semanticKey} already exists with different arguments.`); + return { commandId: byKey.commandId, deduplicated: true }; + } + if (commands.has(command.commandId)) + throw new Error(`duplicate command_id ${command.commandId}`); + const row: CommandRow = { + commandId: command.commandId, + operation: command.operation, + args: { ...command.args }, + argsDigest: command.argsDigest, + initiatorPrincipal: command.initiatorPrincipal, + audience: command.audience, + sourceId: command.sourceId, + chatGuid: command.chatGuid, + target: command.target ? { ...command.target } : undefined, + providerKey: command.providerKey, + semanticKey: command.semanticKey, + dependsOn: [...command.dependsOn], + status: "queued", + attemptCount: 0, + nextAttemptAt: new Date(), + expiresAt: command.expiresAt ? new Date(command.expiresAt) : undefined, + createdAt: new Date(), + }; + commands.set(row.commandId, row); + commandsBySemanticKey.set(row.semanticKey, row); + return { commandId: row.commandId, deduplicated: false }; + }, + async getCommand(commandId) { + return commands.get(commandId); + }, + async getCommandBySemanticKey(semanticKey) { + return commandsBySemanticKey.get(semanticKey); + }, + async claimCommands(workerId, limit) { + const now = Date.now(); + const ready = [...commands.values()] + .filter((row) => row.status === "queued" && row.nextAttemptAt.getTime() <= now) + .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) + .slice(0, limit); + for (const row of ready) { + row.status = "in_flight"; + row.claimedBy = workerId; + row.attemptCount += 1; + } + return ready; + }, + async completeCommand(commandId, status, patch: CommandPatch) { + const row = commands.get(commandId); + if (!row) return; + row.status = status; + if (patch.result !== undefined) row.result = patch.result; + if (patch.providerReceipt !== undefined) row.providerReceipt = patch.providerReceipt; + if (patch.errorCode !== undefined) row.errorCode = patch.errorCode; + if (patch.errorDetail !== undefined) row.errorDetail = patch.errorDetail; + if (patch.nextAttemptAt !== undefined) row.nextAttemptAt = patch.nextAttemptAt; + if (patch.providerKey !== undefined) row.providerKey = patch.providerKey; + // Mirrors SQL: claimed_by is always overwritten, never COALESCE'd. + row.claimedBy = patch.claimedBy; + }, + async commandStats(sourceId) { + const rows = [...commands.values()].filter((row) => row.sourceId === sourceId); + return { + queued: rows.filter((row) => row.status === "queued").length, + inFlight: rows.filter((row) => row.status === "in_flight").length, + ambiguous: rows.filter((row) => row.status === "ambiguous").length, + }; + }, + + /* ------------------------------ resources ---------------------------- */ + async upsertResource(sourceId, kind: ResourceKind, resourceId, data, opts) { + const key = `${sourceId}${kind}${resourceId}`; + const existing = resources.get(key); + if (existing && opts?.expectedRevision !== undefined && existing.revision !== opts.expectedRevision) + return; + resources.set(key, { + sourceId, + kind, + resourceId, + data: { ...data }, + revision: (existing?.revision ?? 0) + 1, + tombstone: opts?.tombstone ?? false, + updatedAt: new Date(), + }); + }, + async getResource(sourceId, kind, resourceId) { + return resources.get(`${sourceId}${kind}${resourceId}`); + }, + + /* ------------------------------ artifacts ---------------------------- */ + async putArtifact(input: ArtifactInsert) { + const artifactId = `art:${createHash("sha256").update(JSON.stringify([input.sourceId, input.chatGuid, artifacts.size])).digest("hex")}`; + artifacts.set(artifactId, { + ...input, + artifactId, + byteSize: input.data.byteLength, + createdAt: new Date(), + }); + return artifactId; + }, + async getArtifact(artifactId) { + return artifacts.get(artifactId); + }, + async findArtifactByAttachment(sourceId, chatGuid, attachmentGuid) { + const staged = [...artifacts.values()] + .filter((row) => row.sourceId === sourceId && row.chatGuid === chatGuid + && row.attachmentGuid === attachmentGuid) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + const row = staged[0]; + if (!row) return undefined; + return { + artifactId: row.artifactId, + sourceId: row.sourceId, + chatGuid: row.chatGuid, + attachmentGuid: row.attachmentGuid, + fileName: row.fileName, + mediaType: row.mediaType, + byteSize: row.byteSize, + }; + }, + + /* -------------------------- pending interactions --------------------- */ + async createInteraction(input: InteractionInsert) { + const interactionId = `int:${sha256Hex(JSON.stringify([input.sourceId, input.chatGuid, input.requestRef, input.kind]))}`; + if (interactions.has(interactionId)) return interactionId; + interactions.set(interactionId, { + ...input, + interactionId, + status: "pending", + createdAt: new Date(), + }); + return interactionId; + }, + async openInteractions(sourceId, chatGuid) { + return [...interactions.values()] + .filter((row) => row.sourceId === sourceId && row.chatGuid === chatGuid && row.status === "pending") + .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); + }, + async resolveInteraction(interactionId, actor, resolution) { + const row = interactions.get(interactionId); + if (!row || row.status !== "pending") return false; + row.status = "resolved"; + row.resolvedActor = actor; + row.resolvedAt = new Date(); + row.resolution = { ...resolution }; + return true; + }, + async expireInteractions(now) { + let count = 0; + for (const row of interactions.values()) { + if (row.status === "pending" && row.expiresAt && row.expiresAt.getTime() <= now.getTime()) { + row.status = "expired"; + count += 1; + } + } + return count; + }, + + /* --------------------------- projector offsets ------------------------ */ + async getProjectorOffset(sourceId, eveSessionId) { + return projectors.get(`${sourceId}${eveSessionId}`); + }, + async advanceProjector(input) { + const key = `${input.sourceId}${input.eveSessionId}`; + const existing = projectors.get(key); + projectors.set(key, { + ...input, + streamPosition: existing ? Math.max(existing.streamPosition, input.streamPosition) : input.streamPosition, + materializedKeys: [...input.materializedKeys], + updatedAt: new Date(), + }); + }, + + /* --------------------------- session deadlines ------------------------ */ + async registerDeadline(eveSessionId, bindingId, deadlineAt) { + if (deadlines.has(eveSessionId)) return; + deadlines.set(eveSessionId, { eveSessionId, bindingId, deadlineAt }); + }, + async dueDeadlines(now, limit) { + return [...deadlines.values()] + .filter((row) => !row.retiredAt && row.deadlineAt.getTime() <= now.getTime()) + .sort((a, b) => a.deadlineAt.getTime() - b.deadlineAt.getTime()) + .slice(0, limit); + }, + async markDeadlineRetired(eveSessionId, reason) { + const row = deadlines.get(eveSessionId); + if (row && !row.retiredAt) { + row.retiredAt = new Date(); + row.retirementReason = reason; + } + }, + async getDeadline(eveSessionId) { + return deadlines.get(eveSessionId); + }, + }; + + return Object.assign(db, { inbox, intents, commands, conversations, sources, interactions, deadlines }); +} diff --git a/tests/photon/fixtures/sdk-events.ts b/tests/photon/fixtures/sdk-events.ts new file mode 100644 index 0000000..d377cd7 --- /dev/null +++ b/tests/photon/fixtures/sdk-events.ts @@ -0,0 +1,438 @@ +/** + * Representative `@photon-ai/advanced-imessage` 2.4.0 SDK event fixtures. + * + * Every builder returns an object shaped exactly like the pinned SDK's public + * event types (see dist/groups-PW-Snfsc.d.ts). The normalization and admission + * pipeline under test must map all 24 documented inbound variants; unknown or + * malformed variants must never throw their way into the agent. + */ +import type { + AttachmentInfo, + CatchUpEvent, + ChatEvent, + GroupChange, + GroupEvent, + LiveEvent, + Message, + MessageEvent, + MessageReaction, + PollChangeDelta, + PollEvent, + SharedFriendLocation, + SharedFriendLocationUpdated, + SingleServiceAddressInfo, + StickerPlacement, +} from "@photon-ai/advanced-imessage/grpc"; + +export const RECEIVING_LINE = "+12025550100"; +export const OPERATOR_PHONE = "+12025550123"; +export const OPERATOR_EMAIL = "operator@example.com"; +export const STRANGER_PHONE = "+12025550199"; +export const DIRECT_CHAT = "iMessage;-;+12025550123"; +export const GROUP_CHAT = "iMessage;+;chat000000000000000001"; + +export function phone(address = OPERATOR_PHONE, service: SingleServiceAddressInfo["service"] = "iMessage"): SingleServiceAddressInfo { + return { address, country: "US", service }; +} + +export function email(address = OPERATOR_EMAIL): SingleServiceAddressInfo { + return { address, service: "iMessage" }; +} + +interface EventContextInput { + readonly actor?: SingleServiceAddressInfo; + readonly chatGuid?: string; + readonly isFromMe?: boolean; + readonly occurredAt?: Date; +} + +function context(input: EventContextInput = {}) { + return { + actor: input.actor, + chatGuid: input.chatGuid ?? DIRECT_CHAT, + isFromMe: input.isFromMe ?? false, + occurredAt: input.occurredAt ?? new Date("2026-10-27T12:00:00.000Z"), + }; +} + +/* ------------------------------------------------------------------ */ +/* Message fixtures */ +/* ------------------------------------------------------------------ */ + +export interface MessageInput { + readonly guid?: string; + readonly chatGuids?: readonly string[]; + readonly text?: string; + readonly sender?: SingleServiceAddressInfo; + readonly isFromMe?: boolean; + readonly isAutoReply?: boolean; + readonly isSystemMessage?: boolean; + readonly isServiceMessage?: boolean; + readonly isAudioMessage?: boolean; + readonly itemType?: Message["itemType"]; + readonly destinationCallerId?: string; + readonly subject?: string; + readonly replyTargetGuid?: string; + readonly attachments?: readonly AttachmentInfo[]; + readonly mentions?: Message["content"]["mentions"]; + readonly formatting?: Message["content"]["formatting"]; + readonly content?: Partial; +} + +export function attachment(overrides: Partial = {}): AttachmentInfo { + return { + fileName: "photo.heic", + guid: "att-guid-0001", + isHidden: false, + isOutgoing: false, + isSticker: false, + mimeType: "image/heic", + totalBytes: 65_536, + transferState: "finished", + uti: "public.heic", + ...overrides, + }; +} + +export function message(input: MessageInput = {}): Message { + const content: Message["content"] = { + attachments: input.attachments ?? [], + formatting: input.formatting ?? [], + mentions: input.mentions ?? [], + text: input.text ?? "Open Best Buy and tell me the homepage title.", + ...input.content, + }; + return { + appliedReactions: [], + chatGuids: input.chatGuids ?? [DIRECT_CHAT], + content, + dataDetectorResultsPresent: false, + dateCreated: new Date("2026-10-27T12:00:00.000Z"), + didNotifyRecipient: false, + guid: input.guid ?? "msg-guid-0001", + isArchived: false, + isAudioMessage: input.isAudioMessage ?? false, + isAutoReply: input.isAutoReply ?? false, + isCorrupt: false, + isDelayed: false, + isDelivered: false, + isDeliveredQuietly: false, + isExpirable: false, + isForward: false, + isFromMe: input.isFromMe ?? false, + isSent: true, + isServiceMessage: input.isServiceMessage ?? false, + isSpam: false, + isSystemMessage: input.isSystemMessage ?? false, + itemType: input.itemType ?? "normal", + placedStickers: [], + sendErrorCode: 0, + sender: input.sender ?? phone(), + destinationCallerId: input.destinationCallerId, + subject: input.subject, + replyTargetGuid: input.replyTargetGuid, + }; +} + +/** Non-ASCII + emoji multipart message exercising UTF-16 range handling. */ +export function unicodeMessage(): Message { + return message({ + guid: "msg-guid-unicode", + text: "Réponds en français 🥖 — テスト — مرحبا", + content: { + attachments: [], + mentions: [{ address: OPERATOR_PHONE, start: 0, length: 7 }], + formatting: [{ type: "bold", start: 20, length: 6 }], + text: "Réponds en français 🥖 — テスト — مرحبا", + }, + }); +} + +export function attachmentOnlyMessage(): Message { + return message({ + guid: "msg-guid-attach", + attachments: [attachment()], + content: { attachments: [attachment()], formatting: [], mentions: [] }, + }); +} + +export function audioNoteMessage(): Message { + return message({ + guid: "msg-guid-voice", + isAudioMessage: true, + attachments: [attachment({ fileName: "voice.m4a", mimeType: "audio/mp4", uti: "com.apple.m4a-audio" })], + content: { + attachments: [attachment({ fileName: "voice.m4a", mimeType: "audio/mp4", uti: "com.apple.m4a-audio" })], + formatting: [], + mentions: [], + }, + }); +} + +/* ------------------------------------------------------------------ */ +/* MessageEvent variants (all seven) */ +/* ------------------------------------------------------------------ */ + +let sequence = 1; +export function nextSequence(): number { + return sequence++; +} + +export function messageReceived(input: { message?: Message } & EventContextInput = {}): Extract { + return { + ...context(input), + type: "message.received", + sequence: nextSequence(), + message: input.message ?? message(), + }; +} + +export function messageEdited(input: { messageGuid?: string; content?: Message["content"]; editedAt?: Date } & EventContextInput = {}): Extract { + return { + ...context(input), + type: "message.edited", + sequence: nextSequence(), + messageGuid: input.messageGuid ?? "msg-guid-0001", + content: input.content ?? { attachments: [], formatting: [], mentions: [], text: "edited: actually close the tab" }, + editedAt: input.editedAt ?? new Date("2026-10-27T12:05:00.000Z"), + }; +} + +export function messageRead(input: { messageGuid?: string; readAt?: Date } & EventContextInput = {}): Extract { + return { + ...context(input), + type: "message.read", + sequence: nextSequence(), + messageGuid: input.messageGuid ?? "msg-guid-0001", + readAt: input.readAt ?? new Date("2026-10-27T12:06:00.000Z"), + }; +} + +export function messageUnsent(input: { messageGuid?: string; retractedAt?: Date } & EventContextInput = {}): Extract { + return { + ...context(input), + type: "message.unsent", + sequence: nextSequence(), + messageGuid: input.messageGuid ?? "msg-guid-0001", + retractedAt: input.retractedAt ?? new Date("2026-10-27T12:07:00.000Z"), + }; +} + +export function reaction(input: Partial = {}): MessageReaction { + return { kind: "love", ...input }; +} + +export function reactionAdded(input: { messageGuid?: string; reaction?: MessageReaction; targetPartIndex?: number } & EventContextInput = {}): Extract { + return { + ...context(input), + type: "message.reactionAdded", + sequence: nextSequence(), + messageGuid: input.messageGuid ?? "msg-guid-0001", + reaction: input.reaction ?? reaction(), + targetPartIndex: input.targetPartIndex, + }; +} + +export function reactionRemoved(input: { messageGuid?: string; reaction?: MessageReaction; targetPartIndex?: number } & EventContextInput = {}): Extract { + return { + ...context(input), + type: "message.reactionRemoved", + sequence: nextSequence(), + messageGuid: input.messageGuid ?? "msg-guid-0001", + reaction: input.reaction ?? reaction({ kind: "like" }), + targetPartIndex: input.targetPartIndex, + }; +} + +export function stickerPlaced(input: { messageGuid?: string; sticker?: AttachmentInfo; placement?: StickerPlacement; targetPartIndex?: number } & EventContextInput = {}): Extract { + return { + ...context(input), + type: "message.stickerPlaced", + sequence: nextSequence(), + messageGuid: input.messageGuid ?? "msg-guid-0001", + sticker: input.sticker ?? attachment({ isSticker: true, mimeType: "image/png" }), + placement: input.placement ?? { x: 0.5, y: 0.5, scale: 1.2, rotation: 0.25 }, + targetPartIndex: input.targetPartIndex, + }; +} + +/* ------------------------------------------------------------------ */ +/* ChatEvent variants (all five) */ +/* ------------------------------------------------------------------ */ + +export function chatEvent(type: ChatEvent["type"], input: EventContextInput = {}): ChatEvent { + return { ...context(input), type, sequence: nextSequence() } as ChatEvent; +} + +export function backgroundChanged(input: EventContextInput = {}): ChatEvent { + return chatEvent("chat.backgroundChanged", input); +} + +export function backgroundRemoved(input: EventContextInput = {}): ChatEvent { + return chatEvent("chat.backgroundRemoved", input); +} + +export function chatMarkedRead(input: EventContextInput = {}): ChatEvent { + return chatEvent("chat.markedRead", input); +} + +export function chatArchived(input: EventContextInput = {}): ChatEvent { + return chatEvent("chat.archived", input); +} + +export function chatUnarchived(input: EventContextInput = {}): ChatEvent { + return chatEvent("chat.unarchived", input); +} + +/* ------------------------------------------------------------------ */ +/* GroupEvent variants (all six GroupChange types) */ +/* ------------------------------------------------------------------ */ + +export function groupEvent(change: GroupChange, input: EventContextInput = {}): GroupEvent { + return { + ...context({ chatGuid: GROUP_CHAT, ...input }), + type: "group.changed", + sequence: nextSequence(), + change, + }; +} + +export function groupDisplayNameChanged(displayName = "Weekend crew", input: EventContextInput = {}): GroupEvent { + return groupEvent({ type: "displayNameChanged", displayName }, input); +} + +export function groupParticipantAdded(participant = phone(STRANGER_PHONE), input: EventContextInput = {}): GroupEvent { + return groupEvent({ type: "participantAdded", participant }, input); +} + +export function groupParticipantRemoved(participant = phone(STRANGER_PHONE), input: EventContextInput = {}): GroupEvent { + return groupEvent({ type: "participantRemoved", participant }, input); +} + +export function groupParticipantLeft(participant = phone(STRANGER_PHONE), input: EventContextInput = {}): GroupEvent { + return groupEvent({ type: "participantLeft", participant }, input); +} + +export function groupIconChanged(input: EventContextInput = {}): GroupEvent { + return groupEvent({ type: "iconChanged" }, input); +} + +export function groupIconRemoved(input: EventContextInput = {}): GroupEvent { + return groupEvent({ type: "iconRemoved" }, input); +} + +/* ------------------------------------------------------------------ */ +/* PollEvent variants (all four PollChangeDelta types) */ +/* ------------------------------------------------------------------ */ + +export const POLL_GUID = "poll-msg-guid-0001"; + +export function pollOptions() { + return [ + { optionIdentifier: "opt-aaaa", text: "Sushi 🍣", creatorHandle: OPERATOR_PHONE }, + { optionIdentifier: "opt-bbbb", text: "Pizza", creatorHandle: OPERATOR_PHONE }, + // Duplicate labels are legal: identity is the optionIdentifier. + { optionIdentifier: "opt-cccc", text: "Pizza", creatorHandle: STRANGER_PHONE }, + ]; +} + +export function pollEvent(delta: PollChangeDelta, input: { pollMessageGuid?: string } & EventContextInput = {}): PollEvent { + return { + ...context({ chatGuid: GROUP_CHAT, ...input }), + type: "poll.changed", + sequence: nextSequence(), + pollMessageGuid: input.pollMessageGuid ?? POLL_GUID, + delta, + }; +} + +export function pollCreated(input: { title?: string; pollMessageGuid?: string } & EventContextInput = {}): PollEvent { + return pollEvent({ type: "created", title: input.title ?? "Dinner tonight?", options: pollOptions() }, input); +} + +/** Native polls can arrive titleless — never fabricate a question. */ +export function pollCreatedTitleless(input: EventContextInput = {}): PollEvent { + return pollEvent({ type: "created", title: "", options: pollOptions() }, input); +} + +export function pollOptionAdded(input: EventContextInput = {}): PollEvent { + return pollEvent({ + type: "optionAdded", + title: "Dinner tonight?", + options: [...pollOptions(), { optionIdentifier: "opt-dddd", text: "Tacos", creatorHandle: STRANGER_PHONE }], + }, input); +} + +export function pollVoted(optionIdentifier = "opt-aaaa", input: EventContextInput = {}): PollEvent { + return pollEvent({ type: "voted", optionIdentifier }, input); +} + +export function pollUnvoted(optionIdentifier = "opt-aaaa", input: EventContextInput = {}): PollEvent { + return pollEvent({ type: "unvoted", optionIdentifier }, input); +} + +/* ------------------------------------------------------------------ */ +/* Catch-up + location events */ +/* ------------------------------------------------------------------ */ + +export function catchupComplete(headSequence = nextSequence() - 1): Extract { + return { type: "catchup.complete", headSequence }; +} + +export function sharedLocation(overrides: Partial = {}): SharedFriendLocation { + return { + address: OPERATOR_PHONE, + accuracy: 14.2, + expiresAt: new Date("2026-10-27T13:00:00.000Z"), + isLocatingInProgress: false, + latitude: 37.7749, + locationTimestamp: new Date("2026-10-27T11:59:00.000Z"), + locationType: "live", + longitude: -122.4194, + name: "Operator", + ...overrides, + }; +} + +export function locationUpdated(input: { location?: SharedFriendLocation; sourceSequence?: number } = {}): SharedFriendLocationUpdated { + return { + location: input.location ?? sharedLocation(), + sourceSequence: input.sourceSequence ?? 7, + }; +} + +/* ------------------------------------------------------------------ */ +/* One fixture for every documented live variant, keyed by family. */ +/* ------------------------------------------------------------------ */ + +/** All 22 live/catch-up event variants from PRD §12 (EV01–EV22 + EV24). */ +export function allLiveVariants(): readonly LiveEvent[] { + return [ + messageReceived(), + messageEdited(), + messageRead(), + messageUnsent(), + reactionAdded(), + reactionRemoved(), + stickerPlaced(), + backgroundChanged(), + backgroundRemoved(), + chatMarkedRead(), + chatArchived(), + chatUnarchived(), + groupDisplayNameChanged(), + groupParticipantAdded(), + groupParticipantRemoved(), + groupParticipantLeft(), + groupIconChanged(), + groupIconRemoved(), + pollCreated(), + pollOptionAdded(), + pollVoted(), + pollUnvoted(), + ]; +} + +export function allCatchUpVariants(): readonly CatchUpEvent[] { + return [...allLiveVariants(), catchupComplete()]; +} diff --git a/tests/photon/groups.test.ts b/tests/photon/groups.test.ts new file mode 100644 index 0000000..c04fd03 --- /dev/null +++ b/tests/photon/groups.test.ts @@ -0,0 +1,202 @@ +/** + * Group admission and actor authorization policy. Groups need an explicitly + * admitted chat GUID plus a verified actor inside the current membership; + * sensitive continuations never leave the private direct conversation. + */ +import { describe, expect, it } from "vitest"; + +import type { CanonicalHandle } from "../../server/photon/identity"; +import { handleKey, normalizeHandle, photonPrincipalId } from "../../server/photon/identity"; +import { + admitEvent, + admitGroup, + admitSender, + groupActorAuthorized, + requiresPrivateContinuation, + type ConversationState, +} from "../../server/photon/policy"; +import type { PhotonNormalizedEvent } from "../../server/photon/contracts"; + +import { envelope, groupConfig, testConfig } from "./fixtures/harness"; +import { + GROUP_CHAT, + OPERATOR_PHONE, + STRANGER_PHONE, + message as messageFixture, +} from "./fixtures/sdk-events"; + +const MEMBER = { kind: "phone", value: OPERATOR_PHONE } as const satisfies CanonicalHandle; +const STRANGER = { kind: "phone", value: STRANGER_PHONE } as const satisfies CanonicalHandle; +const EMAIL_MEMBER = { kind: "email", value: "member@example.com" } as const satisfies CanonicalHandle; + +function groupConversation(overrides: Partial = {}): ConversationState { + return { + scope: "group", + generation: 1, + membership: [handleKey(MEMBER)], + membershipVersion: 1, + initiatingPrincipal: photonPrincipalId("test-project-0001", MEMBER), + ...overrides, + }; +} + +function directConversation(overrides: Partial = {}): ConversationState { + return { + scope: "direct", + generation: 1, + membership: [], + membershipVersion: 0, + ...overrides, + }; +} + +function groupMessageEvent(sender: string, overrides: Parameters[0] = {}): PhotonNormalizedEvent { + return { + family: "message", + type: "received", + message: messageFixture({ + sender: { address: sender, service: "iMessage", country: "US" }, + chatGuids: [GROUP_CHAT], + ...overrides, + }), + }; +} + +describe("admitSender / admitGroup", () => { + const config = testConfig({ allowedSenders: [OPERATOR_PHONE], allowedEmailSenders: ["member@example.com"], allowedGroups: [GROUP_CHAT] }); + + it("admits only explicitly configured phone and email handles", () => { + expect(admitSender(config, MEMBER)).toBe(true); + expect(admitSender(config, EMAIL_MEMBER)).toBe(true); + expect(admitSender(config, STRANGER)).toBe(false); + // An email sender is not smuggled in through the phone allowlist. + expect(admitSender(config, { kind: "email", value: OPERATOR_PHONE })).toBe(false); + expect(admitSender(config, null)).toBe(false); + }); + + it("admits only explicitly configured group chats", () => { + expect(admitGroup(config, GROUP_CHAT)).toBe(true); + expect(admitGroup(config, "iMessage;+;other-group")).toBe(false); + expect(admitGroup(config, undefined)).toBe(false); + expect(admitGroup(testConfig(), GROUP_CHAT)).toBe(false); + }); +}); + +describe("groupActorAuthorized", () => { + it("authorizes a current member and rejects strangers, actors, and null", () => { + const conversation = groupConversation(); + expect(groupActorAuthorized(conversation, MEMBER)).toBe(true); + expect(groupActorAuthorized(conversation, STRANGER)).toBe(false); + expect(groupActorAuthorized(conversation, null)).toBe(false); + // An actor whose handle kind differs never matches a phone member entry. + expect(groupActorAuthorized(conversation, { kind: "email", value: OPERATOR_PHONE })).toBe(false); + }); + + it("keeps an empty-membership conversation open but never authorizes a missing actor", () => { + const conversation = groupConversation({ membership: [] }); + expect(groupActorAuthorized(conversation, STRANGER)).toBe(true); + expect(groupActorAuthorized(conversation, null)).toBe(false); + }); +}); + +describe("requiresPrivateContinuation", () => { + it("forces wallets, vaults and handoffs back to the private DM", () => { + expect(requiresPrivateContinuation("group")).toBe(true); + expect(requiresPrivateContinuation(undefined)).toBe(true); + expect(requiresPrivateContinuation("direct")).toBe(false); + }); +}); + +describe("admitEvent in an admitted group", () => { + const config = groupConfig(); + + it("admits a group message from a current member with group scope", () => { + const event = groupMessageEvent(OPERATOR_PHONE); + const decision = admitEvent(config, envelope(event, { chatGuid: GROUP_CHAT }), groupConversation()); + expect(decision).toMatchObject({ + disposition: "admitted", dispatchAction: "send", scope: "group", + actor: MEMBER, + }); + }); + + it("denies a group message from a sender outside the membership", () => { + const event = groupMessageEvent(STRANGER_PHONE); + const decision = admitEvent(config, envelope(event, { chatGuid: GROUP_CHAT }), groupConversation()); + expect(decision).toMatchObject({ disposition: "denied", reason: "actor_not_in_membership" }); + }); + + it("denies group ingress entirely when the chat is not in the allowlist", () => { + const closed = testConfig(); + const event = groupMessageEvent(OPERATOR_PHONE); + const decision = admitEvent(closed, envelope(event, { chatGuid: GROUP_CHAT }), groupConversation()); + expect(decision).toMatchObject({ disposition: "denied", reason: "group_not_admitted" }); + expect(decision.dispatchAction).toBe("none"); + }); + + it("marks membership-affecting events as state updates, never agent turns", () => { + const added: PhotonNormalizedEvent = { family: "group", type: "participantAdded", participant: { address: STRANGER_PHONE, service: "iMessage" } }; + const removed: PhotonNormalizedEvent = { family: "group", type: "participantRemoved", participant: { address: STRANGER_PHONE, service: "iMessage" } }; + for (const event of [added, removed]) { + const decision = admitEvent(config, envelope(event, { chatGuid: GROUP_CHAT }), groupConversation()); + expect(decision).toMatchObject({ disposition: "state_only", dispatchAction: "state_apply", updatesMembership: true }); + } + }); + + it("keeps a non-member actor's reaction/vote as state_only with membership update", () => { + const reaction: PhotonNormalizedEvent = { + family: "message", type: "reactionAdded", + messageGuid: "m1", reaction: { kind: "love" }, + }; + const decision = admitEvent( + config, + envelope(reaction, { chatGuid: GROUP_CHAT, actor: `phone:${STRANGER_PHONE}` }), + groupConversation(), + ); + expect(decision).toMatchObject({ disposition: "state_only", reason: "non_member_actor_state_only", updatesMembership: true }); + }); + + it("keeps a member's reaction/vote state_only without membership churn", () => { + const reaction: PhotonNormalizedEvent = { + family: "message", type: "reactionAdded", + messageGuid: "m1", reaction: { kind: "like" }, + }; + const decision = admitEvent( + config, + envelope(reaction, { chatGuid: GROUP_CHAT, actor: `phone:${OPERATOR_PHONE}` }), + groupConversation(), + ); + expect(decision.disposition).toBe("state_only"); + expect(decision.updatesMembership).not.toBe(true); + }); +}); + +describe("initiating principal isolation", () => { + it("a different member's message never claims the initiating principal", () => { + const owner = photonPrincipalId("test-project-0001", MEMBER); + const other = photonPrincipalId("test-project-0001", EMAIL_MEMBER); + const conversation = groupConversation({ initiatingPrincipal: owner, membership: [handleKey(MEMBER), handleKey(EMAIL_MEMBER)] }); + expect(conversation.initiatingPrincipal).toBe(owner); + expect(conversation.initiatingPrincipal).not.toBe(other); + // groupActorAuthorized governs tool access, not ownership transfer. + expect(groupActorAuthorized(conversation, EMAIL_MEMBER)).toBe(true); + }); + + it("group and direct scopes cannot satisfy each other's checks", () => { + const config = groupConfig(); + const direct = directConversation(); + expect(() => { + const decision = admitEvent(config, envelope(groupMessageEvent(OPERATOR_PHONE), { chatGuid: GROUP_CHAT }), direct); + // A direct-scoped binding row must never authorize group dispatch; policy + // resolves the group scope from the admitted chat GUID regardless. + expect(decision.scope).not.toBe("direct"); + }).not.toThrow(); + }); +}); + +describe("normalized handle keys", () => { + it("produces the canonical membership entries", () => { + expect(handleKey(MEMBER)).toBe(`phone:${OPERATOR_PHONE}`); + expect(handleKey(EMAIL_MEMBER)).toBe("email:member@example.com"); + expect(handleKey(normalizeHandle("Member@Example.com")!)).toBe("email:member@example.com"); + }); +}); diff --git a/tests/photon/interactions.test.ts b/tests/photon/interactions.test.ts new file mode 100644 index 0000000..2a85691 --- /dev/null +++ b/tests/photon/interactions.test.ts @@ -0,0 +1,180 @@ +/** + * Durable interaction contracts: a poll vote, reaction, prompt or card event + * may resolve exactly one pending request, for the exact permitted actor and + * target, before its expiry. Store semantics run now; the matching policy is + * a parallel workstream module (`server/photon/interactions.ts`) that skips + * while absent. + */ +import { describe, expect, it } from "vitest"; + +import type { PhotonEventEnvelope } from "../../server/photon/contracts"; +import { sourceIdFor } from "../../server/photon/identity"; +import { matchInboundToInteractions } from "../../server/photon/interactions"; + +import { envelope, inMemoryDb } from "./fixtures/harness"; +import * as fx from "./fixtures/sdk-events"; + +const SOURCE_ID = sourceIdFor("test-project-0001", "inst-aaaa-bbbb", fx.RECEIVING_LINE); +const ACTOR = `phone:${fx.OPERATOR_PHONE}`; +const STRANGER = `phone:${fx.STRANGER_PHONE}`; + +async function seededDb() { + const db = inMemoryDb(); + await db.enrollSource({ + sourceId: SOURCE_ID, projectId: "test-project-0001", instanceId: "inst-aaaa-bbbb", + accountScope: "acct", phoneNumber: fx.RECEIVING_LINE, endpoint: "x:443", logEpoch: "epoch-1", + }); + return db; +} + +function reactionEnvelope(actor: string | undefined, messageGuid = "msg-1"): PhotonEventEnvelope { + return envelope( + { family: "message", type: "reactionAdded", messageGuid, reaction: { kind: "love" } }, + { sequence: "1", chatGuid: fx.DIRECT_CHAT, actor }, + ); +} + +/* ------------------------------------------------------------------ */ +/* Interaction ledger — frozen store contract */ +/* ------------------------------------------------------------------ */ + +describe("pending interaction ledger", () => { + it("creates a deterministic interaction id and ignores duplicate creation", async () => { + const db = await seededDb(); + const input = { + sourceId: SOURCE_ID, chatGuid: fx.DIRECT_CHAT, kind: "prompt" as const, + requestRef: "req-1", permittedActor: ACTOR, + target: { messageGuid: "msg-1" }, expiresAt: new Date("2026-10-28T00:00:00Z"), + }; + const first = await db.createInteraction(input); + const again = await db.createInteraction(input); + expect(again).toBe(first); + const open = await db.openInteractions(SOURCE_ID, fx.DIRECT_CHAT); + expect(open).toHaveLength(1); + expect(open[0].permittedActor).toBe(ACTOR); + }); + + it("lists only pending interactions for the exact chat", async () => { + const db = await seededDb(); + await db.createInteraction({ + sourceId: SOURCE_ID, chatGuid: fx.DIRECT_CHAT, kind: "poll", + requestRef: "req-a", target: { pollMessageGuid: fx.POLL_GUID }, + }); + await db.createInteraction({ + sourceId: SOURCE_ID, chatGuid: "iMessage;-;+12025550177", kind: "poll", + requestRef: "req-b", target: { pollMessageGuid: fx.POLL_GUID }, + }); + expect(await db.openInteractions(SOURCE_ID, fx.DIRECT_CHAT)).toHaveLength(1); + expect(await db.openInteractions(SOURCE_ID, "iMessage;-;+12025550177")).toHaveLength(1); + }); + + it("resolves once and records the resolving actor and payload", async () => { + const db = await seededDb(); + const id = await db.createInteraction({ + sourceId: SOURCE_ID, chatGuid: fx.DIRECT_CHAT, kind: "authorization", + requestRef: "req-1", permittedActor: ACTOR, + }); + expect(await db.resolveInteraction(id, ACTOR, { approved: true })).toBe(true); + // Second resolution is refused — one-time resolution is durable. + expect(await db.resolveInteraction(id, STRANGER, { approved: false })).toBe(false); + const open = await db.openInteractions(SOURCE_ID, fx.DIRECT_CHAT); + expect(open).toHaveLength(0); + }); + + it("expires stale interactions in a bounded sweep", async () => { + const db = await seededDb(); + const now = new Date("2026-10-27T12:00:00Z"); + await db.createInteraction({ + sourceId: SOURCE_ID, chatGuid: fx.DIRECT_CHAT, kind: "prompt", + requestRef: "old", expiresAt: new Date(now.getTime() - 1_000), + }); + await db.createInteraction({ + sourceId: SOURCE_ID, chatGuid: fx.DIRECT_CHAT, kind: "prompt", + requestRef: "fresh", expiresAt: new Date(now.getTime() + 60_000), + }); + await db.createInteraction({ + sourceId: SOURCE_ID, chatGuid: fx.DIRECT_CHAT, kind: "prompt", + requestRef: "unbounded", + }); + expect(await db.expireInteractions(now)).toBe(1); + const open = await db.openInteractions(SOURCE_ID, fx.DIRECT_CHAT); + expect(open.map((row) => row.requestRef).sort()).toEqual(["fresh", "unbounded"]); + }); +}); + +/* ------------------------------------------------------------------ */ +/* Event-to-interaction matching — transport workstream module */ +/* ------------------------------------------------------------------ */ + +describe("interaction matching (server/photon/interactions.ts)", () => { + async function seedReactionContract(overrides: Record = {}) { + const db = await seededDb(); + const interactionId = await db.createInteraction({ + sourceId: SOURCE_ID, chatGuid: fx.DIRECT_CHAT, kind: "reaction_contract", + requestRef: "req-1", target: { messageGuid: "msg-1" }, + responseMapping: { reactions: ["love"] }, + ...overrides, + }); + return { db, interactionId }; + } + + it("matches by kind, target and permitted actor", async () => { + const { db, interactionId } = await seedReactionContract({ permittedActor: ACTOR }); + const match = await matchInboundToInteractions(db, SOURCE_ID, reactionEnvelope(ACTOR)); + expect(match.resolved.map((row) => row.interactionId)).toEqual([interactionId]); + expect(match.contractResolved).toBe(true); + }); + + it("refuses a different actor — arbitrary emoji never authorizes an action", async () => { + const { db } = await seedReactionContract({ permittedActor: ACTOR }); + const match = await matchInboundToInteractions(db, SOURCE_ID, reactionEnvelope(STRANGER)); + expect(match.resolved).toHaveLength(0); + expect(await db.openInteractions(SOURCE_ID, fx.DIRECT_CHAT)).toHaveLength(1); + // An unrestricted interaction may still resolve for any verified actor. + const open = await seededDb(); + await open.createInteraction({ + sourceId: SOURCE_ID, chatGuid: fx.DIRECT_CHAT, kind: "reaction_contract", + requestRef: "req-open", target: { messageGuid: "msg-1" }, + }); + const unrestricted = await matchInboundToInteractions(open, SOURCE_ID, reactionEnvelope(STRANGER)); + expect(unrestricted.resolved).toHaveLength(1); + }); + + it("never resolves on the wrong target or kind", async () => { + const { db } = await seedReactionContract({ permittedActor: ACTOR }); + // Wrong target message. + expect((await matchInboundToInteractions(db, SOURCE_ID, reactionEnvelope(ACTOR, "msg-2"))).resolved).toHaveLength(0); + // Wrong event kind: a poll contract never resolves on a reaction. + const polls = await seededDb(); + await polls.createInteraction({ + sourceId: SOURCE_ID, chatGuid: fx.DIRECT_CHAT, kind: "poll", + requestRef: "req-poll", permittedActor: ACTOR, target: { pollMessageGuid: fx.POLL_GUID }, + }); + expect((await matchInboundToInteractions(polls, SOURCE_ID, reactionEnvelope(ACTOR))).resolved).toHaveLength(0); + expect(await db.openInteractions(SOURCE_ID, fx.DIRECT_CHAT)).toHaveLength(1); + }); + + it("missing actor means state-only: a replayed or unattributed vote cannot resolve", async () => { + const { db } = await seedReactionContract({ permittedActor: ACTOR }); + const match = await matchInboundToInteractions(db, SOURCE_ID, reactionEnvelope(undefined)); + expect(match.contractResolved).toBe(false); + expect(await db.openInteractions(SOURCE_ID, fx.DIRECT_CHAT)).toHaveLength(1); + }); + + it("an expired contract cannot resolve even for the right actor", async () => { + const { db } = await seedReactionContract({ + permittedActor: ACTOR, expiresAt: new Date(Date.now() - 1_000), + }); + const match = await matchInboundToInteractions(db, SOURCE_ID, reactionEnvelope(ACTOR)); + expect(match.resolved).toHaveLength(0); + }); + + it("a replayed vote resolves at most once", async () => { + const { db } = await seedReactionContract({ permittedActor: ACTOR }); + const first = await matchInboundToInteractions(db, SOURCE_ID, reactionEnvelope(ACTOR)); + const second = await matchInboundToInteractions(db, SOURCE_ID, reactionEnvelope(ACTOR)); + expect(first.contractResolved).toBe(true); + expect(second.contractResolved).toBe(false); + expect(await db.openInteractions(SOURCE_ID, fx.DIRECT_CHAT)).toHaveLength(0); + }); +}); diff --git a/tests/photon/lifecycle.test.ts b/tests/photon/lifecycle.test.ts new file mode 100644 index 0000000..a4eb3b8 --- /dev/null +++ b/tests/photon/lifecycle.test.ts @@ -0,0 +1,149 @@ +/** + * Absolute conversation lifetime: Bell's pre-upgrade 24-hour maximum is + * preserved by a persisted deadline record and a bounded retirement sweep — + * Eve 0.82's idle timeout and `ctx.cancel()` alone are not retirement. + * Deadline store semantics and the worker's `retireDueSessions` sweep both + * run against the in-memory PhotonDb seam. + */ +import { describe, expect, it, vi } from "vitest"; + +import { PHOTON_RETIRE_PATH } from "../../server/photon/config"; +import { bindingIdFor, eveAddressFor, sourceIdFor } from "../../server/photon/identity"; +import { retireDueSessions } from "../../server/photon/worker"; + +import { inMemoryDb, testConfig } from "./fixtures/harness"; +import * as fx from "./fixtures/sdk-events"; + +const SOURCE_ID = sourceIdFor("test-project-0001", "inst-aaaa-bbbb", fx.RECEIVING_LINE); +const DAY_MS = 24 * 60 * 60 * 1000; + +async function seededDb() { + const db = inMemoryDb(); + await db.enrollSource({ + sourceId: SOURCE_ID, projectId: "test-project-0001", instanceId: "inst-aaaa-bbbb", + accountScope: "acct", phoneNumber: fx.RECEIVING_LINE, endpoint: "x:443", logEpoch: "epoch-1", + }); + return db; +} + +/* ------------------------------------------------------------------ */ +/* Deadline ledger — frozen store contract */ +/* ------------------------------------------------------------------ */ + +describe("session deadline ledger", () => { + it("registers an absolute deadline and returns it when due", async () => { + const db = await seededDb(); + const deadline = new Date(Date.now() + DAY_MS); + await db.registerDeadline("eve-1", "binding-1", deadline); + const row = await db.getDeadline("eve-1"); + expect(row?.deadlineAt.getTime()).toBe(deadline.getTime()); + expect(row?.bindingId).toBe("binding-1"); + // Not yet due. + expect(await db.dueDeadlines(new Date(), 10)).toHaveLength(0); + // Due at the deadline instant. + const due = await db.dueDeadlines(new Date(deadline.getTime() + 1), 10); + expect(due.map((row) => row.eveSessionId)).toEqual(["eve-1"]); + }); + + it("keeps the first deadline — re-registration never extends the clock", async () => { + const db = await seededDb(); + const first = new Date(Date.now() + DAY_MS); + await db.registerDeadline("eve-2", "binding-2", first); + await db.registerDeadline("eve-2", "binding-2", new Date(Date.now() + 48 * 60 * 60 * 1000)); + expect((await db.getDeadline("eve-2"))?.deadlineAt.getTime()).toBe(first.getTime()); + }); + + it("orders due deadlines oldest first and excludes retired sessions", async () => { + const db = await seededDb(); + const now = Date.now(); + await db.registerDeadline("eve-b", "b", new Date(now - 5_000)); + await db.registerDeadline("eve-a", "a", new Date(now - 10_000)); + await db.registerDeadline("eve-future", "f", new Date(now + DAY_MS)); + await db.markDeadlineRetired("eve-b", "test"); + const due = await db.dueDeadlines(new Date(now), 10); + expect(due.map((row) => row.eveSessionId)).toEqual(["eve-a"]); + expect((await db.getDeadline("eve-b"))?.retiredAt).toBeTruthy(); + expect((await db.getDeadline("eve-b"))?.retirementReason).toBe("test"); + }); + + it("survives a store restart — the deadline is re-read, not re-remembered", async () => { + const db = await seededDb(); + const deadline = new Date(Date.now() + DAY_MS); + await db.registerDeadline("eve-persist", "binding-p", deadline); + // A second read path stands in for a process restart against durable rows. + const reread = await db.getDeadline("eve-persist"); + expect(reread?.deadlineAt.getTime()).toBe(deadline.getTime()); + expect(reread?.retiredAt).toBeUndefined(); + }); + + it("a retired conversation rejects further work and drops from the active set", async () => { + const db = await seededDb(); + const bindingId = bindingIdFor(SOURCE_ID, fx.DIRECT_CHAT, 1); + await db.upsertConversation({ + bindingId, sourceId: SOURCE_ID, chatGuid: fx.DIRECT_CHAT, generation: 1, + scope: "direct", eveAddress: eveAddressFor(bindingId), eveSessionId: "eve-1", + }); + expect(await db.activeConversations(SOURCE_ID)).toHaveLength(1); + await db.retireConversation(bindingId); + expect(await db.activeConversations(SOURCE_ID)).toHaveLength(0); + expect((await db.getConversationById(bindingId))?.retiredAt).toBeTruthy(); + // Idempotent: retiring twice does not resurrect or move the timestamp. + const retiredAt = (await db.getConversationById(bindingId))?.retiredAt; + await db.retireConversation(bindingId); + expect((await db.getConversationById(bindingId))?.retiredAt).toBe(retiredAt); + }); +}); + +/* ------------------------------------------------------------------ */ +/* Retirement sweep — transport workstream module */ +/* ------------------------------------------------------------------ */ + +const lifecycle = { retireDueSessions }; + +describe("retirement sweep", () => { + const config = testConfig(); + + it("calls the private retire route, then marks the deadline retired", async () => { + const db = await seededDb(); + await db.registerDeadline("eve-old", "binding-old", new Date(Date.now() - 1_000)); + const fetcher = vi.fn(async () => Response.json({ status: "retired" })); + await lifecycle!.retireDueSessions(db, config, { fetch: fetcher }); + expect(fetcher).toHaveBeenCalledOnce(); + const [input] = fetcher.mock.calls[0]; + const url = input instanceof Request ? input.url : String(input); + expect(url).toBe(`${config.internalOrigin}${PHOTON_RETIRE_PATH}`); + expect((await db.getDeadline("eve-old"))?.retiredAt).toBeTruthy(); + }); + + it("leaves the deadline open when the route reports a retryable failure", async () => { + const db = await seededDb(); + await db.registerDeadline("eve-busy", "b", new Date(Date.now() - 1_000)); + const fetcher = vi.fn(async () => Response.json({ status: "retryable", reason: "eve restarting" })); + await lifecycle!.retireDueSessions(db, config, { fetch: fetcher }); + expect((await db.getDeadline("eve-busy"))?.retiredAt).toBeUndefined(); + expect(await db.dueDeadlines(new Date(Date.now() + 60_000), 10)).toHaveLength(1); + }); + + it("treats an already_retired response as settled work", async () => { + const db = await seededDb(); + await db.registerDeadline("eve-done", "b", new Date(Date.now() - 1_000)); + const fetcher = vi.fn(async () => Response.json({ status: "already_retired" })); + const result = await lifecycle!.retireDueSessions(db, config, { fetch: fetcher }); + expect(result.retired).toBeGreaterThanOrEqual(1); + expect((await db.getDeadline("eve-done"))?.retiredAt).toBeTruthy(); + }); + + it("uses the retire/reset path — mere cancellation is not retirement", async () => { + // PRD §16.2: `ctx.cancel()` alone must never satisfy the absolute deadline; + // the supported fixed-session retire route is the evidence. + const db = await seededDb(); + await db.registerDeadline("eve-cancel", "b", new Date(Date.now() - 1_000)); + const fetcher = vi.fn(async (input) => { + const url = input instanceof Request ? input.url : String(input); + expect(url).toContain(PHOTON_RETIRE_PATH); + return Response.json({ status: "retired" }); + }); + await lifecycle!.retireDueSessions(db, config, { fetch: fetcher }); + expect(fetcher).toHaveBeenCalled(); + }); +}); diff --git a/tests/photon/media.test.ts b/tests/photon/media.test.ts new file mode 100644 index 0000000..58b5563 --- /dev/null +++ b/tests/photon/media.test.ts @@ -0,0 +1,119 @@ +/** + * Bounded media handling: download streams are collected into separate + * bounded primary/companion outputs, oversized or corrupted payloads fail + * closed (never a partial artifact), and Live Photo pairs stay matched. + */ +import { createHash } from "node:crypto"; +import { describe, expect, it } from "vitest"; + +import type { AttachmentInfo, DownloadAttachmentChunk } from "@photon-ai/advanced-imessage/grpc"; + +import { buildUploadInput, collectDownload } from "../../server/photon/media"; +import { sanitizeFileName } from "../../server/photon/filename"; +import { fakeStream } from "./fixtures/harness"; +import { attachment } from "./fixtures/sdk-events"; + +function chunks(info: AttachmentInfo, primary: readonly Uint8Array[], companion: readonly Uint8Array[] = [], companionInfo?: AttachmentInfo extends never ? never : { fileName: string; kind: "live-photo-video"; mimeType: string; totalBytes: number }): DownloadAttachmentChunk[] { + const out: DownloadAttachmentChunk[] = [{ type: "header", info, ...(companionInfo ? { companionInfo } : {}) }]; + for (const data of primary) out.push({ type: "primaryChunk", data }); + for (const data of companion) out.push({ type: "companionChunk", data }); + return out; +} + +function bytes(length: number, fill = 0x61): Uint8Array { + const data = new Uint8Array(length); + data.fill(fill); + return data; +} + +function sha256(data: Uint8Array): string { + return createHash("sha256").update(data).digest("hex"); +} + +describe("bounded media pipeline (server/photon/media.ts)", () => { + const m = { collectDownload, buildUploadInput, sanitizeFileName }; + + it("collects a well-formed download into bounded primary bytes", async () => { + const primary = bytes(1024); + const info = attachment({ totalBytes: 1024, mimeType: "image/png", fileName: "a.png" }); + const result = await m.collectDownload(fakeStream(chunks(info, [primary.slice(0, 512), primary.slice(512)])), { maxBytes: 4096 }); + expect(result.primary).toEqual(primary); + expect(result.info.guid).toBe(info.guid); + expect(result.companion).toBeUndefined(); + }); + + it("fails closed when the payload exceeds the byte budget — no partial artifact", async () => { + const oversized = bytes(8 * 1024); + const info = attachment({ totalBytes: oversized.byteLength }); + const stream = fakeStream(chunks(info, [oversized.slice(0, 4096), oversized.slice(4096)])); + await expect(m.collectDownload(stream, { maxBytes: 1024 })).rejects.toThrow(); + }); + + it("fails closed when a header's declared size already exceeds the budget", async () => { + const info = attachment({ totalBytes: 100 * 1024 * 1024 }); + const stream = fakeStream(chunks(info, [])); + await expect(m.collectDownload(stream, { maxBytes: 1024 })).rejects.toThrow(); + }); + + it("verifies a sha256 checksum when one is supplied", async () => { + const primary = bytes(2048); + const info = attachment({ totalBytes: primary.byteLength }); + const good = await m.collectDownload( + fakeStream(chunks(info, [primary])), + { maxBytes: 4096, expectedSha256: sha256(primary) }, + ); + expect(good.primary).toEqual(primary); + await expect(m.collectDownload( + fakeStream(chunks(info, [primary])), + { maxBytes: 4096, expectedSha256: "f".repeat(64) }, + )).rejects.toThrow(); + }); + + it("keeps a Live Photo primary and companion paired in separate outputs", async () => { + const primary = bytes(1500, 0x62); + const companion = bytes(900, 0x63); + const info = attachment({ fileName: "live.heic", mimeType: "image/heic", totalBytes: primary.byteLength }); + const companionInfo = { fileName: "live.mov", kind: "live-photo-video" as const, mimeType: "video/quicktime", totalBytes: companion.byteLength }; + const result = await m.collectDownload( + fakeStream(chunks(info, [primary], [companion.slice(0, 400), companion.slice(400)], companionInfo)), + { maxBytes: 4096, maxCompanionBytes: 4096 }, + ); + expect(result.primary).toEqual(primary); + expect(result.companion).toEqual(companion); + }); + + it("abandons an interrupted stream rather than returning truncated bytes", async () => { + const primary = bytes(2048); + const info = attachment({ totalBytes: primary.byteLength }); + const stream = fakeStream(chunks(info, [primary.slice(0, 100)]), { error: new Error("grpc stream reset") }); + await expect(m.collectDownload(stream, { maxBytes: 4096 })).rejects.toThrow(); + }); + + it("maps artifact bytes to the provider upload shape with companion pairing", () => { + const primary = bytes(100); + const companion = bytes(50); + const input = m.buildUploadInput({ fileName: "photo.png", data: primary, companion }); + expect(input.fileName).toBe("photo.png"); + expect(input.data).toBe(primary); + expect(input.companion?.data).toBe(companion); + const plain = m.buildUploadInput({ fileName: "doc.pdf", data: primary }); + expect(plain.companion).toBeUndefined(); + }); + + it("preserves MIME type and filename through the attachment metadata", async () => { + const info = attachment({ fileName: "résumé 2026.pdf", mimeType: "application/pdf", uti: "com.adobe.pdf", totalBytes: 10 }); + const result = await m.collectDownload(fakeStream(chunks(info, [bytes(10)])), { maxBytes: 1024 }); + expect(result.info.mimeType).toBe("application/pdf"); + expect(result.info.fileName).toBe("résumé 2026.pdf"); + }); + + it("sanitizes hostile filenames when the helper exists", async () => { + if (!m.sanitizeFileName) return; + for (const hostile of ["../../etc/passwd", "a\\b\\c.png", "\0.png", "x".repeat(600) + ".png"]) { + const clean = m.sanitizeFileName(hostile); + expect(clean).toBeTruthy(); + expect(clean).not.toMatch(/[/\\\0]/); + expect(clean.length).toBeLessThanOrEqual(1024); + } + }); +}); diff --git a/tests/photon/outbox.test.ts b/tests/photon/outbox.test.ts new file mode 100644 index 0000000..45deb81 --- /dev/null +++ b/tests/photon/outbox.test.ts @@ -0,0 +1,268 @@ +/** + * Durable outbound commands: claim → execute → settle. The command ledger + * (enqueue dedup, claim fencing, completion states) and the outbox state + * machine both run against the in-memory PhotonDb seam. + */ +import { describe, expect, it } from "vitest"; + +import { + KEYED_OPERATIONS, + READ_OPERATIONS, + type PhotonCommand, + type PhotonCommandDeps, + type PhotonCommandExecutor, + type PhotonCommandOp, +} from "../../server/photon/contracts"; +import { providerKeyFor, semanticKeyFor, sourceIdFor } from "../../server/photon/identity"; +import { createOutbox } from "../../server/photon/outbox"; +import type { PhotonDb } from "../../server/photon/store"; + +import { inMemoryDb } from "./fixtures/harness"; +import * as fx from "./fixtures/sdk-events"; + +const SOURCE_ID = sourceIdFor("test-project-0001", "inst-aaaa-bbbb", fx.RECEIVING_LINE); + +async function seededDb() { + const db = inMemoryDb(); + await db.enrollSource({ + sourceId: SOURCE_ID, projectId: "test-project-0001", instanceId: "inst-aaaa-bbbb", + accountScope: "acct", phoneNumber: fx.RECEIVING_LINE, endpoint: "x:443", logEpoch: "epoch-1", + }); + return db; +} + +function command(overrides: Partial & { operation?: PhotonCommandOp }): PhotonCommand { + // The semantic key identifies the logical output, not this attempt — two + // commands sharing it (and the args digest) are the same work. + const semanticKey = overrides.semanticKey + ?? semanticKeyFor({ outputKind: "tool", toolCallId: "call-1" }); + return { + commandId: "cmd-1", + operation: "messages.sendText", + args: { chat: fx.DIRECT_CHAT, text: "hello" }, + argsDigest: "digest-1", + initiatorPrincipal: "photon:" + "a".repeat(64), + audience: "direct", + sourceId: SOURCE_ID, + chatGuid: fx.DIRECT_CHAT, + providerKey: providerKeyFor(overrides.commandId ?? "cmd-1", 0), + semanticKey, + dependsOn: [], + ...overrides, + } as PhotonCommand; +} + +/* ------------------------------------------------------------------ */ +/* Command ledger — frozen store contract */ +/* ------------------------------------------------------------------ */ + +describe("command ledger", () => { + it("deduplicates by semantic key only when the arguments digest matches", async () => { + const db = await seededDb(); + const first = await db.enqueueCommand(command({ commandId: "c-1" })); + expect(first).toEqual({ commandId: "c-1", deduplicated: false }); + const retry = await db.enqueueCommand(command({ commandId: "c-2" })); + expect(retry).toEqual({ commandId: "c-1", deduplicated: true }); + // Same key + changed arguments is a conflict, never a silent overwrite. + await expect(db.enqueueCommand(command({ commandId: "c-3", argsDigest: "different" }))) + .rejects.toThrow(/semantic_key/); + }); + + it("claims queued commands once and tracks the claim owner", async () => { + const db = await seededDb(); + await db.enqueueCommand(command({ commandId: "c-1" })); + await db.enqueueCommand(command({ commandId: "c-2", semanticKey: semanticKeyFor({ outputKind: "tool", toolCallId: "c-2" }) })); + const claimed = await db.claimCommands("worker-1", 10, 60_000); + expect(claimed.map((row) => row.commandId).sort()).toEqual(["c-1", "c-2"]); + expect(claimed.every((row) => row.status === "in_flight" && row.claimedBy === "worker-1" && row.attemptCount === 1)).toBe(true); + // A second claim finds nothing — the work is fenced to the first worker. + expect(await db.claimCommands("worker-2", 10, 60_000)).toHaveLength(0); + }); + + it("settles to acknowledged, ambiguous, failed or expired without losing receipts", async () => { + const db = await seededDb(); + await db.enqueueCommand(command({ commandId: "ack" })); + await db.completeCommand("ack", "acknowledged", { result: { guid: "msg-1" }, providerReceipt: { id: "rcpt" } }); + const acked = await db.getCommand("ack"); + expect(acked?.status).toBe("acknowledged"); + expect(acked?.result).toEqual({ guid: "msg-1" }); + expect(acked?.providerReceipt).toEqual({ id: "rcpt" }); + + await db.enqueueCommand(command({ commandId: "amb", semanticKey: semanticKeyFor({ outputKind: "tool", toolCallId: "amb" }) })); + await db.completeCommand("amb", "ambiguous", { errorCode: "transport_lost", providerKey: providerKeyFor("amb", 1) }); + expect((await db.getCommand("amb"))?.status).toBe("ambiguous"); + expect((await db.getCommand("amb"))?.providerKey).toBe("bell:amb:1"); + + await db.enqueueCommand(command({ commandId: "fail", semanticKey: semanticKeyFor({ outputKind: "tool", toolCallId: "fail" }) })); + await db.completeCommand("fail", "failed", { errorCode: "chatNotFound" }); + expect((await db.getCommand("fail"))?.status).toBe("failed"); + + await db.enqueueCommand(command({ commandId: "exp", semanticKey: semanticKeyFor({ outputKind: "tool", toolCallId: "exp" }) })); + await db.completeCommand("exp", "expired", { errorDetail: "typing indicator outlived its lease" }); + expect((await db.getCommand("exp"))?.status).toBe("expired"); + }); + + it("reports queue statistics per source", async () => { + const db = await seededDb(); + await db.enqueueCommand(command({ commandId: "q1" })); + await db.enqueueCommand(command({ commandId: "q2", semanticKey: semanticKeyFor({ outputKind: "tool", toolCallId: "q2" }) })); + await db.claimCommands("w", 1, 60_000); + await db.completeCommand("q2", "ambiguous", {}); + const stats = await db.commandStats(SOURCE_ID); + expect(stats.inFlight).toBe(1); + expect(stats.ambiguous).toBe(1); + expect(stats.queued).toBe(0); + }); +}); + +/* ------------------------------------------------------------------ */ +/* Outbox executor — transport workstream module */ +/* ------------------------------------------------------------------ */ + +const deps: PhotonCommandDeps = { + client: { sendText: async () => ({ guid: "m" }), raw: {} }, + artifacts: { + get: async () => undefined, + put: async () => "art:x", + }, + config: { projectId: "test-project-0001" }, + now: () => new Date("2026-10-27T12:00:00Z"), +}; + +const NOOP_WATCHES = { setLocationWatch: async () => true }; + +describe("command execution state machine (server/photon/outbox.ts)", () => { + async function enqueue(db: PhotonDb, overrides: Partial & { operation: PhotonCommandOp }) { + const cmd = command(overrides); + await db.enqueueCommand(cmd); + return cmd; + } + + /** One outbox tick with the probe executor injected for the given ops. */ + async function runOnce( + db: PhotonDb, + executor: PhotonCommandExecutor, + ops: readonly PhotonCommandOp[], + ): Promise { + const executors: Partial> = {}; + for (const op of ops) executors[op] = executor; + return createOutbox({ + store: db, workerId: "w-1", sourceId: SOURCE_ID, + commandDeps: deps, watchControl: NOOP_WATCHES, + claimMs: 60_000, batchSize: 10, executors, + }).runOnce(); + } + + it("acknowledges a successful keyed send with the provider receipt", async () => { + const db = await seededDb(); + const cmd = await enqueue(db, { commandId: "send-1", operation: "messages.sendText" }); + const executor: PhotonCommandExecutor = async (claimed) => { + expect(claimed.providerKey).toBe(providerKeyFor(cmd.commandId, 0)); + return { kind: "acknowledged", result: fx.message({ guid: "msg-9" }), providerReceipt: { ok: true } }; + }; + await runOnce(db, executor, ["messages.sendText"]); + const row = await db.getCommand("send-1"); + expect(row?.status).toBe("acknowledged"); + expect(row?.providerReceipt).toEqual({ ok: true }); + }); + + it("backs off a retryable failure without losing the queued row", async () => { + const db = await seededDb(); + await enqueue(db, { commandId: "r-1", operation: "messages.sendText" }); + const executor: PhotonCommandExecutor = async () => ({ kind: "retryable", errorCode: "serviceUnavailable", retryAfterMs: 5_000 }); + await runOnce(db, executor, ["messages.sendText"]); + const row = await db.getCommand("r-1"); + expect(row?.status).toBe("queued"); + expect(row?.nextAttemptAt.getTime()).toBeGreaterThan(Date.now()); + expect(row?.attemptCount).toBe(1); + }); + + it("marks a non-idempotent mutation ambiguous after a lost transport — no blind retry", async () => { + const db = await seededDb(); + // chats.markRead has no clientMessageId; the response was lost after the + // provider may have applied it. + await enqueue(db, { commandId: "mr-1", operation: "chats.markRead" }); + const executor: PhotonCommandExecutor = async () => ({ kind: "ambiguous", reason: "response lost after acceptance" }); + await runOnce(db, executor, ["chats.markRead"]); + const row = await db.getCommand("mr-1"); + expect(row?.status).toBe("ambiguous"); + // A later tick must NOT re-execute an ambiguous command blindly. + let calls = 0; + const second: PhotonCommandExecutor = async () => { calls++; return { kind: "acknowledged" }; }; + await runOnce(db, second, ["chats.markRead"]); + expect(calls).toBe(0); + expect((await db.getCommand("mr-1"))?.status).toBe("ambiguous"); + }); + + it("lets read operations retry freely since they carry no side effects", async () => { + const db = await seededDb(); + await enqueue(db, { commandId: "read-1", operation: "messages.get" }); + expect(READ_OPERATIONS.has("messages.get")).toBe(true); + const executor: PhotonCommandExecutor = async () => ({ kind: "retryable", errorCode: "timeout" }); + await runOnce(db, executor, ["messages.get"]); + expect((await db.getCommand("read-1"))?.status).toBe("queued"); + }); + + it("fails validation-rejected commands without retrying", async () => { + const db = await seededDb(); + await enqueue(db, { commandId: "f-1", operation: "groups.leave" }); + const executor: PhotonCommandExecutor = async () => ({ kind: "failed", errorCode: "preconditionFailed" }); + await runOnce(db, executor, ["groups.leave"]); + expect((await db.getCommand("f-1"))?.status).toBe("failed"); + }); + + it("expires stale commands instead of executing them", async () => { + const db = await seededDb(); + // Typing intents have a short lease; replaying a stale one is wrong. + await enqueue(db, { + commandId: "type-1", operation: "chats.setTyping", + args: { chat: fx.DIRECT_CHAT, isTyping: true }, + expiresAt: "2020-01-01T00:00:00.000Z", + }); + const executor: PhotonCommandExecutor = async () => { throw new Error("must not execute"); }; + await runOnce(db, executor, ["chats.setTyping"]); + const row = await db.getCommand("type-1"); + expect(["expired", "cancelled", "failed"]).toContain(row?.status); + }); + + it("gates a dependent send behind its upload completing first", async () => { + const db = await seededDb(); + await enqueue(db, { commandId: "upload-1", operation: "attachments.upload", args: { fileName: "a.png", artifactId: "art:1" } }); + await enqueue(db, { + commandId: "send-2", operation: "messages.sendAttachment", + args: { chat: fx.DIRECT_CHAT, attachmentGuid: "att-pending" }, + semanticKey: semanticKeyFor({ outputKind: "tool", toolCallId: "send-2" }), + dependsOn: ["upload-1"], + }); + const seen: string[] = []; + const executor: PhotonCommandExecutor = async (claimed) => { + seen.push(claimed.commandId); + return { + kind: "acknowledged", + result: claimed.operation === "attachments.upload" + ? { attachmentGuid: "att-1", transferState: "finished", totalBytes: 1 } + : fx.message({ guid: claimed.commandId }), + }; + }; + await runOnce(db, executor, ["attachments.upload", "messages.sendAttachment"]); + // The upload runs first (or alone); the dependent send may run only after. + expect(seen.indexOf("upload-1")).not.toBe(-1); + if (seen.includes("send-2")) { + expect(seen.indexOf("upload-1")).toBeLessThan(seen.indexOf("send-2")); + } else { + expect((await db.getCommand("send-2"))?.status).toBe("queued"); + } + }); + + it("passes the persisted providerKey as the clientMessageId for keyed ops", async () => { + const db = await seededDb(); + await enqueue(db, { commandId: "k-1", operation: "polls.vote" }); + expect(KEYED_OPERATIONS.has("polls.vote")).toBe(true); + const executor: PhotonCommandExecutor = async (claimed) => { + expect(claimed.providerKey).toBe(providerKeyFor("k-1", 0)); + return { kind: "acknowledged" }; + }; + await runOnce(db, executor, ["polls.vote"]); + expect((await db.getCommand("k-1"))?.status).toBe("acknowledged"); + }); +}); diff --git a/tests/photon/recovery.test.ts b/tests/photon/recovery.test.ts new file mode 100644 index 0000000..1905560 --- /dev/null +++ b/tests/photon/recovery.test.ts @@ -0,0 +1,212 @@ +/** + * Durable ingestion and recovery: the inbox dedup key, payload-hash + * integrity, per-chat sequence ordering, activation boundary, and the + * cold-restart watermark. Runs twice — against the in-memory PhotonDb seam + * (always) and against the real PostgreSQL store + migration 004 when + * TEST_PHOTON_DATABASE_URL (or DATABASE_URL) is set. + */ +import { randomUUID } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { Pool } from "pg"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import type { PhotonEventEnvelope } from "../../server/photon/contracts"; +import { dispatchKeyFor, sourceIdFor } from "../../server/photon/identity"; +import { + createPhotonStore, + type InboxInsert, + type PhotonDb, +} from "../../server/photon/store"; + +import { envelope, inMemoryDb, payloadHashFor } from "./fixtures/harness"; +import * as fx from "./fixtures/sdk-events"; + +const SOURCE_ID = sourceIdFor("test-project-0001", "inst-aaaa-bbbb", fx.RECEIVING_LINE); +const EPOCH = "epoch-1"; + +function insert(seq: number, env: PhotonEventEnvelope, overrides: Partial = {}): InboxInsert { + return { + sourceId: SOURCE_ID, + logEpoch: EPOCH, + sequence: String(seq), + sequenceNum: String(seq), + payloadHash: payloadHashFor(env), + envelope: env, + eventFamily: env.event.family, + eventType: env.event.type, + chatGuid: env.chatGuid, + actor: env.actor, + isFromMe: env.isFromMe, + occurredAt: env.occurredAt, + sourceKind: env.sourceKind, + disposition: "admitted", + ...overrides, + }; +} + +function receivedEnvelope(seq: number, chatGuid = fx.DIRECT_CHAT): PhotonEventEnvelope { + return envelope( + { family: "message", type: "received", message: fx.message({ guid: `m-${seq}`, chatGuids: [chatGuid] }) }, + { sequence: String(seq), chatGuid }, + ); +} + +/* Run the same capture-recovery assertions against any PhotonDb. */ +function captureSuite(getDb: () => PhotonDb) { + it("detects an integrity conflict when one key carries a different payload hash", async () => { + const db = getDb(); + const first = insert(7, receivedEnvelope(7)); + expect(await db.captureEvent(first)).toMatchObject({ kind: "inserted" }); + const tampered = insert(7, receivedEnvelope(7), { payloadHash: "different-hash" }); + const conflict = await db.captureEvent(tampered); + expect(conflict).toMatchObject({ kind: "integrity_conflict", existingHash: first.payloadHash }); + const row = await db.getInboxRow(first); + expect(row?.payloadHash).toBe(first.payloadHash); + }); + + it("deduplicates the same event captured through live and catch-up paths", async () => { + const db = getDb(); + const live = insert(11, receivedEnvelope(11), { sourceKind: "live" }); + const replayed = insert(11, receivedEnvelope(11), { sourceKind: "catchup" }); + expect(await db.captureEvent(live)).toMatchObject({ kind: "inserted" }); + expect(await db.captureEvent(replayed)).toMatchObject({ kind: "duplicate" }); + // The canonical hash excludes sourceKind — live/catch-up identity matches. + expect((await db.getInboxRow(live))?.sourceKind).toBe("live"); + }); + + it("stores denied and non-triggering events as minimal tombstones that fill sequence gaps", async () => { + const db = getDb(); + const denied = insert(5, receivedEnvelope(5), { + envelope: undefined, tombstone: true, disposition: "denied", + actor: undefined, chatGuid: undefined, + }); + expect(await db.captureEvent(denied)).toMatchObject({ kind: "inserted" }); + expect((await db.getInboxRow(denied))?.tombstone).toBe(true); + expect((await db.getInboxRow(denied))?.envelope).toBeUndefined(); + }); + + it("preserves per-chat order while other chats proceed independently", async () => { + const db = getDb(); + // Distinct chats keep this test independent of other suite captures. + const chatA = "iMessage;-;+12025550166"; + const chatB = "iMessage;-;+12025550177"; + // Out-of-order arrival in one chat must still dispatch in provider order. + for (const row of [insert(3, receivedEnvelope(3, chatA)), insert(1, receivedEnvelope(1, chatA)), insert(2, receivedEnvelope(2, chatA))]) { + await db.captureEvent(row); + } + await db.captureEvent(insert(9, receivedEnvelope(9, chatB))); + const chatARows = await db.dispatchableForChat(SOURCE_ID, chatA, 10); + expect(chatARows.map((row) => row.sequence)).toEqual(["1", "2", "3"]); + // dispatchableChatScopes orders scopes by each chat's oldest captured + // row; other chats may hold pending rows from earlier suite captures. + const scopes = await db.dispatchableChatScopes(SOURCE_ID, 10); + expect(scopes).toContain(chatA); + expect(scopes).toContain(chatB); + expect(scopes.indexOf(chatA)).toBeLessThan(scopes.indexOf(chatB)); + }); + + it("resumes the provider watermark from the persisted max sequence", async () => { + const db = getDb(); + for (const seq of [40, 41, 42]) await db.captureEvent(insert(seq, receivedEnvelope(seq))); + expect(await db.maxSequenceNum(SOURCE_ID, EPOCH)).toBe("42"); + // A different log epoch has its own sequence domain. + await db.captureEvent(insert(5, receivedEnvelope(5), { logEpoch: "epoch-2" })); + expect(await db.maxSequenceNum(SOURCE_ID, "epoch-2")).toBe("5"); + expect(await db.maxSequenceNum(SOURCE_ID, "epoch-3")).toBeUndefined(); + }); + + it("records the activation boundary once and never moves it backwards", async () => { + const db = getDb(); + await db.setActivationBoundary(SOURCE_ID, "100"); + expect(await db.activationBoundary(SOURCE_ID)).toBe("100"); + await db.setActivationBoundary(SOURCE_ID, "50"); + expect(await db.activationBoundary(SOURCE_ID)).toBe("100"); + }); + + it("supports lossless sequences beyond JavaScript's safe integer range", async () => { + const db = getDb(); + const big = "9007199254740993123"; // > Number.MAX_SAFE_INTEGER + const env = receivedEnvelope(1); + const row = insert(1, env, { sequence: big, sequenceNum: big }); + expect(await db.captureEvent(row)).toMatchObject({ kind: "inserted" }); + const stored = await db.getInboxRow(row); + expect(stored?.sequence).toBe(big); + expect(await db.maxSequenceNum(SOURCE_ID, EPOCH)).toBe(big); + }); +} + +describe("inbox dedup and ordering (in-memory PhotonDb seam)", () => { + const db = inMemoryDb(); + beforeAll(async () => { + await db.enrollSource({ + sourceId: SOURCE_ID, projectId: "test-project-0001", instanceId: "inst-aaaa-bbbb", + accountScope: "acct", phoneNumber: fx.RECEIVING_LINE, endpoint: "x:443", logEpoch: EPOCH, + }); + }); + captureSuite(() => db); + + it("holds a later live message until earlier replayed work is captured", async () => { + const gap = inMemoryDb(); + await gap.enrollSource({ + sourceId: SOURCE_ID, projectId: "p", instanceId: "i", accountScope: "a", + phoneNumber: fx.RECEIVING_LINE, endpoint: "e", logEpoch: EPOCH, + }); + // Sequence 103 arrives live before replay delivers 101/102. + await gap.captureEvent(insert(103, receivedEnvelope(103))); + const heads = await gap.dispatchableForChat(SOURCE_ID, fx.DIRECT_CHAT, 10); + expect(heads.map((row) => row.sequence)).toEqual(["103"]); + await gap.captureEvent(insert(101, receivedEnvelope(101), { sourceKind: "catchup" })); + await gap.captureEvent(insert(102, receivedEnvelope(102), { sourceKind: "catchup" })); + const ordered = await gap.dispatchableForChat(SOURCE_ID, fx.DIRECT_CHAT, 10); + expect(ordered.map((row) => row.sequence)).toEqual(["101", "102", "103"]); + // The watermark is the max persisted sequence, not arrival order. + expect(await gap.maxSequenceNum(SOURCE_ID, EPOCH)).toBe("103"); + }); +}); + +const databaseUrl = process.env.TEST_PHOTON_DATABASE_URL ?? process.env.DATABASE_URL; + +describe.skipIf(!databaseUrl)("inbox dedup and ordering (PostgreSQL + migration 004)", () => { + const schema = `photon_test_${randomUUID().replaceAll("-", "")}`; + let admin: Pool; + let db: PhotonDb & { close(): Promise }; + + beforeAll(async () => { + admin = new Pool({ connectionString: databaseUrl }); + await admin.query(`CREATE SCHEMA ${schema}`); + const store = createPhotonStore(`${databaseUrl}?options=-c%20search_path%3D${schema}`); + const migration = await readFile(new URL("../../migrations/004_photon.sql", import.meta.url), "utf8"); + await store.pool.query(migration); + db = store; + await db.enrollSource({ + sourceId: SOURCE_ID, projectId: "test-project-0001", instanceId: "inst-aaaa-bbbb", + accountScope: "acct", phoneNumber: fx.RECEIVING_LINE, endpoint: "x:443", logEpoch: EPOCH, + }); + }); + + afterAll(async () => { + await db?.close(); + if (admin) { + await admin.query(`DROP SCHEMA ${schema} CASCADE`); + await admin.end(); + } + }); + + captureSuite(() => db); + + it("keeps the dispatch intent ledger keyed once per inbox action", async () => { + const env = receivedEnvelope(77); + await db.captureEvent(insert(77, env)); + const key = dispatchKeyFor(SOURCE_ID, EPOCH, "77", "send"); + const intent = await db.upsertIntent({ + intentId: "intent-77", sourceId: SOURCE_ID, logEpoch: EPOCH, sequence: "77", + action: "send", dispatchKey: key, inputDigest: "digest-77", + }); + const again = await db.upsertIntent({ + intentId: "intent-77-b", sourceId: SOURCE_ID, logEpoch: EPOCH, sequence: "77", + action: "send", dispatchKey: key, inputDigest: "digest-77", + }); + expect(again.intentId).toBe(intent.intentId); + expect((await db.getIntentByKey(key))?.intentId).toBe(intent.intentId); + }); +}); diff --git a/tsconfig.photon-worker.json b/tsconfig.photon-worker.json new file mode 100644 index 0000000..a78260f --- /dev/null +++ b/tsconfig.photon-worker.json @@ -0,0 +1,20 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "nodenext", + "moduleResolution": "nodenext", + "outDir": ".photon-worker", + "noEmit": false, + "declaration": false, + "sourceMap": true, + "types": ["node"], + "incremental": false + }, + "include": [ + "server/photon/**/*.ts", + "scripts/photon-worker.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/vitest.config.ts b/vitest.config.ts index 58204a7..42264c8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,4 +1,4 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ - test: { include: ["tests/**/*.test.ts"], restoreMocks: true }, + test: { include: ["tests/**/*.test.ts"], restoreMocks: true, testTimeout: 20_000 }, });