Skip to content

Commit 641c8c9

Browse files
alex-w-99claude
andauthored
Add skipWebhookReconcile for pre-provisioned subscriptions (#25)
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) <noreply@anthropic.com>
1 parent 8c71b31 commit 641c8c9

4 files changed

Lines changed: 58 additions & 1 deletion

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@inkbox/opencode-plugin",
3-
"version": "0.2.9",
3+
"version": "0.2.10",
44
"private": true,
55
"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.",
66
"license": "MIT",

src/config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ export interface GatewayOptions {
6464
allowedInboundContactIds?: string[];
6565
// Verify webhook signatures (default true). Disable only for local dev.
6666
requireSignature?: boolean;
67+
/** Leave webhook subscriptions untouched at boot; they must already point here. */
68+
skipWebhookReconcile?: boolean;
6769
// Deliver verified non-Inkbox webhooks (and unverified ones) to the agent.
6870
externalEvents?: boolean;
6971
// Outbound sends from gateway sessions never prompt interactively:
@@ -121,6 +123,7 @@ export interface ResolvedGatewayConfig {
121123
allowAllUsers: boolean;
122124
allowedInboundContactIds: string[];
123125
requireSignature: boolean;
126+
skipWebhookReconcile: boolean;
124127
externalEvents: boolean;
125128
outboundApproval: "allowlist" | "auto";
126129
permissionTimeoutS: number;
@@ -400,6 +403,8 @@ function resolveGatewayConfig(
400403
allowAllUsers: opts.allowAllUsers ?? boolEnv(env.INKBOX_ALLOW_ALL_USERS) ?? false,
401404
allowedInboundContactIds: stringArray(opts.allowedInboundContactIds),
402405
requireSignature: opts.requireSignature ?? boolEnv(env.INKBOX_REQUIRE_SIGNATURE) ?? true,
406+
skipWebhookReconcile:
407+
opts.skipWebhookReconcile ?? boolEnv(env.INKBOX_SKIP_WEBHOOK_RECONCILE) ?? false,
403408
externalEvents: opts.externalEvents ?? boolEnv(env.INKBOX_EXTERNAL_EVENTS_ENABLED) ?? false,
404409
outboundApproval: opts.outboundApproval === "auto" ? "auto" : "allowlist",
405410
permissionTimeoutS:

src/gateway/subscriptions.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,14 @@ export async function reconcileSubscriptions(
108108
): Promise<ReconcileResult> {
109109
const base = normalizePublicUrl(publicUrl);
110110
const webhookUrl = `${base}${WEBHOOK_PATH}`;
111+
112+
// Deployments that provision subscriptions ahead of time have a fixed
113+
// destination, and an API key that may not be allowed to change it.
114+
if (deps.config.gateway.skipWebhookReconcile) {
115+
deps.logger.info("subscriptions.skipped", { expectedUrl: webhookUrl });
116+
return { created: 0, updated: 0, unchanged: 0 };
117+
}
118+
111119
const identity = await deps.inkbox.getIdentity();
112120
const client = await deps.inkbox.getClient();
113121

tests/gateway/subscriptions.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ function makeDeps(
7777
options: {
7878
voiceEnabled?: boolean;
7979
phoneVoiceStack?: "inkbox_voice_ai" | "openai_realtime" | "inkbox_tts_stt";
80+
skipWebhookReconcile?: boolean;
8081
} = {},
8182
): GatewayDeps & { logger: { [K in keyof GatewayLogger]: ReturnType<typeof vi.fn> } } {
8283
const client = { webhooks: { subscriptions } };
@@ -94,6 +95,7 @@ function makeDeps(
9495
allowAllUsers: false,
9596
allowedInboundContactIds: [],
9697
requireSignature: true,
98+
skipWebhookReconcile: options.skipWebhookReconcile ?? false,
9799
externalEvents: false,
98100
outboundApproval: "allowlist",
99101
permissionTimeoutS: 600,
@@ -448,3 +450,45 @@ describe("reconcileSubscriptions", () => {
448450
).rejects.not.toThrow(secret);
449451
});
450452
});
453+
454+
describe("skipWebhookReconcile", () => {
455+
// Deployments that provision subscriptions ahead of time have a fixed
456+
// destination and a key that may not be allowed to change it, so writing on
457+
// every boot is redundant at best and fatal to startup at worst.
458+
const identity = {
459+
id: "identity-1",
460+
mailbox: { id: "mailbox-1" },
461+
phoneNumber: { id: "phone-1" },
462+
imessageEnabled: true,
463+
};
464+
465+
it("touches no subscriptions when enabled", async () => {
466+
const subscriptions = makeSubscriptions();
467+
const deps = makeDeps(identity, subscriptions, { skipWebhookReconcile: true });
468+
469+
const result = await reconcileSubscriptions(deps, PUBLIC_URL);
470+
471+
expect(result).toEqual({ created: 0, updated: 0, unchanged: 0 });
472+
expect(subscriptions.list).not.toHaveBeenCalled();
473+
expect(subscriptions.create).not.toHaveBeenCalled();
474+
});
475+
476+
it("names the URL it expects deliveries to reach", async () => {
477+
const deps = makeDeps(identity, makeSubscriptions(), { skipWebhookReconcile: true });
478+
479+
await reconcileSubscriptions(deps, PUBLIC_URL);
480+
481+
expect(deps.logger.info).toHaveBeenCalledWith("subscriptions.skipped", {
482+
expectedUrl: WEBHOOK_URL,
483+
});
484+
});
485+
486+
it("still reconciles when left at the default", async () => {
487+
const subscriptions = makeSubscriptions();
488+
const deps = makeDeps(identity, subscriptions);
489+
490+
await reconcileSubscriptions(deps, PUBLIC_URL);
491+
492+
expect(subscriptions.create).toHaveBeenCalled();
493+
});
494+
});

0 commit comments

Comments
 (0)