From 915ce6673d27507b1b5cc14dc774f6df93f33e6b Mon Sep 17 00:00:00 2001 From: alex-w-99 Date: Fri, 7 Aug 2026 23:37:39 +0000 Subject: [PATCH] Add skipWebhookReconcile for pre-provisioned subscriptions At boot the gateway points the identity's mailbox, phone number, iMessage, and A2A events at whatever URL it just came up on. That is the right default when the gateway owns its ingress, but not when subscriptions are provisioned ahead of time: there the destination is already fixed, and the API key may not be permitted to change it, so the write is redundant at best and fatal to boot at worst. Default false, so nothing changes unless a deployment opts in. Settable as gateway.skipWebhookReconcile or INKBOX_SKIP_WEBHOOK_RECONCILE. Co-Authored-By: Claude Opus 5 (1M context) --- package.json | 2 +- src/config.ts | 5 ++++ src/gateway/subscriptions.ts | 8 ++++++ tests/gateway/subscriptions.test.ts | 44 +++++++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 9924919..d2fadac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@inkbox/opencode-plugin", - "version": "0.2.9", + "version": "0.2.10", "private": true, "description": "Inkbox for opencode \u2014 give your agent an email address, a phone number (SMS/MMS + voice), iMessage, contacts, notes, and an encrypted credential vault.", "license": "MIT", diff --git a/src/config.ts b/src/config.ts index 14781ec..c6668db 100644 --- a/src/config.ts +++ b/src/config.ts @@ -64,6 +64,8 @@ export interface GatewayOptions { allowedInboundContactIds?: string[]; // Verify webhook signatures (default true). Disable only for local dev. requireSignature?: boolean; + /** Leave webhook subscriptions untouched at boot; they must already point here. */ + skipWebhookReconcile?: boolean; // Deliver verified non-Inkbox webhooks (and unverified ones) to the agent. externalEvents?: boolean; // Outbound sends from gateway sessions never prompt interactively: @@ -121,6 +123,7 @@ export interface ResolvedGatewayConfig { allowAllUsers: boolean; allowedInboundContactIds: string[]; requireSignature: boolean; + skipWebhookReconcile: boolean; externalEvents: boolean; outboundApproval: "allowlist" | "auto"; permissionTimeoutS: number; @@ -400,6 +403,8 @@ function resolveGatewayConfig( allowAllUsers: opts.allowAllUsers ?? boolEnv(env.INKBOX_ALLOW_ALL_USERS) ?? false, allowedInboundContactIds: stringArray(opts.allowedInboundContactIds), requireSignature: opts.requireSignature ?? boolEnv(env.INKBOX_REQUIRE_SIGNATURE) ?? true, + skipWebhookReconcile: + opts.skipWebhookReconcile ?? boolEnv(env.INKBOX_SKIP_WEBHOOK_RECONCILE) ?? false, externalEvents: opts.externalEvents ?? boolEnv(env.INKBOX_EXTERNAL_EVENTS_ENABLED) ?? false, outboundApproval: opts.outboundApproval === "auto" ? "auto" : "allowlist", permissionTimeoutS: diff --git a/src/gateway/subscriptions.ts b/src/gateway/subscriptions.ts index 735c105..49940aa 100644 --- a/src/gateway/subscriptions.ts +++ b/src/gateway/subscriptions.ts @@ -108,6 +108,14 @@ export async function reconcileSubscriptions( ): Promise { const base = normalizePublicUrl(publicUrl); const webhookUrl = `${base}${WEBHOOK_PATH}`; + + // Deployments that provision subscriptions ahead of time have a fixed + // destination, and an API key that may not be allowed to change it. + if (deps.config.gateway.skipWebhookReconcile) { + deps.logger.info("subscriptions.skipped", { expectedUrl: webhookUrl }); + return { created: 0, updated: 0, unchanged: 0 }; + } + const identity = await deps.inkbox.getIdentity(); const client = await deps.inkbox.getClient(); diff --git a/tests/gateway/subscriptions.test.ts b/tests/gateway/subscriptions.test.ts index afd03fc..48bfab8 100644 --- a/tests/gateway/subscriptions.test.ts +++ b/tests/gateway/subscriptions.test.ts @@ -77,6 +77,7 @@ function makeDeps( options: { voiceEnabled?: boolean; phoneVoiceStack?: "inkbox_voice_ai" | "openai_realtime" | "inkbox_tts_stt"; + skipWebhookReconcile?: boolean; } = {}, ): GatewayDeps & { logger: { [K in keyof GatewayLogger]: ReturnType } } { const client = { webhooks: { subscriptions } }; @@ -94,6 +95,7 @@ function makeDeps( allowAllUsers: false, allowedInboundContactIds: [], requireSignature: true, + skipWebhookReconcile: options.skipWebhookReconcile ?? false, externalEvents: false, outboundApproval: "allowlist", permissionTimeoutS: 600, @@ -448,3 +450,45 @@ describe("reconcileSubscriptions", () => { ).rejects.not.toThrow(secret); }); }); + +describe("skipWebhookReconcile", () => { + // Deployments that provision subscriptions ahead of time have a fixed + // destination and a key that may not be allowed to change it, so writing on + // every boot is redundant at best and fatal to startup at worst. + const identity = { + id: "identity-1", + mailbox: { id: "mailbox-1" }, + phoneNumber: { id: "phone-1" }, + imessageEnabled: true, + }; + + it("touches no subscriptions when enabled", async () => { + const subscriptions = makeSubscriptions(); + const deps = makeDeps(identity, subscriptions, { skipWebhookReconcile: true }); + + const result = await reconcileSubscriptions(deps, PUBLIC_URL); + + expect(result).toEqual({ created: 0, updated: 0, unchanged: 0 }); + expect(subscriptions.list).not.toHaveBeenCalled(); + expect(subscriptions.create).not.toHaveBeenCalled(); + }); + + it("names the URL it expects deliveries to reach", async () => { + const deps = makeDeps(identity, makeSubscriptions(), { skipWebhookReconcile: true }); + + await reconcileSubscriptions(deps, PUBLIC_URL); + + expect(deps.logger.info).toHaveBeenCalledWith("subscriptions.skipped", { + expectedUrl: WEBHOOK_URL, + }); + }); + + it("still reconciles when left at the default", async () => { + const subscriptions = makeSubscriptions(); + const deps = makeDeps(identity, subscriptions); + + await reconcileSubscriptions(deps, PUBLIC_URL); + + expect(subscriptions.create).toHaveBeenCalled(); + }); +});