From 2ffc58e52e6b9e6612554bc140b5ec6218a7ce15 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 01:17:13 +0000 Subject: [PATCH 1/3] fix(service-messaging): stamp organization_id on sys_http_delivery rows (#13546) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #13546 — the enqueue door never wrote the tenant column, so every row landed in the SQL driver's (organization_id IS NULL) global-row arm and the cross-organization wall on redeliver() (#10740) excluded nothing. Mirrors the notification outbox's existing repair (EnqueueDeliveryInput): - EnqueueHttpInput gains an optional organizationId (inherited by UndeliverableHttpInput, so parked rows are stamped too); HttpDelivery surfaces it on read-back. - SqlHttpOutbox.insert writes organization_id: input.organizationId ?? null, the same line SqlOutbox.enqueue writes. - MemoryHttpOutbox stores the field and, now that its rows carry a tenant, applies RedeliverOptions.tenantId in redeliver() with the driver's exact semantics (other org invisible; org-less row global; tenant-less caller unscoped). - The flow http node (durable mode) threads AutomationContext.tenantId — the notify node's #11303 source — and warns loudly when a run has none. - The webhook auto-enqueuer stamps each delivery with its subscription's own organization (sys_webhook.organization_id). Forward-stamping only; existing NULL rows are untouched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs --- .changeset/http-outbox-organization-stamp.md | 39 ++++ .../plugin-webhooks/src/auto-enqueuer.test.ts | 61 ++++++ .../plugin-webhooks/src/auto-enqueuer.ts | 28 +++ .../src/builtin/http-nodes.test.ts | 72 +++++++ .../src/builtin/http-nodes.ts | 38 ++++ .../src/http-outbox-organization.test.ts | 188 ++++++++++++++++++ .../service-messaging/src/http-outbox.ts | 38 ++++ .../src/memory-http-outbox.ts | 36 +++- .../service-messaging/src/sql-http-outbox.ts | 16 ++ 9 files changed, 506 insertions(+), 10 deletions(-) create mode 100644 .changeset/http-outbox-organization-stamp.md create mode 100644 packages/services/service-messaging/src/http-outbox-organization.test.ts diff --git a/.changeset/http-outbox-organization-stamp.md b/.changeset/http-outbox-organization-stamp.md new file mode 100644 index 0000000000..6d3d0e68de --- /dev/null +++ b/.changeset/http-outbox-organization-stamp.md @@ -0,0 +1,39 @@ +--- +"@objectstack/service-messaging": minor +"@objectstack/service-automation": patch +"@objectstack/plugin-webhooks": patch +--- + +fix(service-messaging,service-automation,plugin-webhooks): stamp `organization_id` on `sys_http_delivery` rows so the cross-organization wall on `redeliver()` actually excludes other tenants' rows (#13546) + +`sys_http_delivery` is tenant-scoped and `redeliver()` — the one +request-reachable door on it — deliberately scopes by the caller's +organization (#10740). But the enqueue door never stamped the +`organization_id` column, and the SQL driver's tenant term is +`(organization_id = :tenantId OR organization_id IS NULL)` — a deliberate +global-row fail-open — so 100% of delivery rows landed in the NULL arm: +visible to, and replayable by, every organization on a walled deployment. + +The repair mirrors the notification outbox's existing convention +(`EnqueueDeliveryInput.organizationId`), end to end: + +- `EnqueueHttpInput` gains an **optional** `organizationId` member (inherited + by `UndeliverableHttpInput`, so parked rows are tenant-stamped too), and + `HttpDelivery` surfaces it on read-back. `SqlHttpOutbox.insert` writes + `organization_id: input.organizationId ?? null` exactly like + `SqlOutbox.enqueue`; `MemoryHttpOutbox` stores the same field and — now + that its rows carry a tenant — applies `RedeliverOptions.tenantId` in + `redeliver()` with the driver's exact semantics (another organization's row + is invisible/`RESOURCE_NOT_FOUND`; an org-less row stays a global row; a + tenant-less caller stays unscoped). +- The flow `http` node (durable mode) threads its run's acting organization + (`AutomationContext.tenantId` — the same source as the `notify` node's + #11303 repair) and warns loudly when a multi-org run has none to thread. +- The webhook auto-enqueuer stamps each delivery with its subscription's own + organization (`sys_webhook.organization_id`); org-less subscriptions + enqueue org-less, unchanged. + +Forward-stamping only: existing NULL rows are untouched (their disposition is +a separate decision). Producers with genuinely no organization — a +`single`-posture deployment, a stack before its first organization — keep +working unchanged; their rows land NULL, which is the honest global-row shape. diff --git a/packages/plugins/plugin-webhooks/src/auto-enqueuer.test.ts b/packages/plugins/plugin-webhooks/src/auto-enqueuer.test.ts index af3361bb5b..ac327ddd6d 100644 --- a/packages/plugins/plugin-webhooks/src/auto-enqueuer.test.ts +++ b/packages/plugins/plugin-webhooks/src/auto-enqueuer.test.ts @@ -222,6 +222,48 @@ describe('AutoEnqueuer', () => { await ae.stop(); }); + // [#13546] The delivery row belongs to the SUBSCRIPTION's organization — + // `sys_webhook` is organization-scoped (#8554), the enqueuer runs + // fire-and-forget off the write path with no request context, so the + // subscription row is the one honest tenant source. Without the stamp the + // row lands `organization_id = NULL` — the driver's global-row arm — and + // the redeliver() cross-organization wall (#10740) excludes nothing. + it("stamps the subscription's organization onto the enqueue input (#13546)", async () => { + const engine = new FakeEngine({ + sys_webhook: [webhook({ organization_id: 'org_pin_alpha' })], + }); + const realtime = new FakeRealtime(); + const { enqueue, calls } = makeRecorder(); + const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 }); + await ae.start(); + + await realtime.publish(event('created', 'contact', { id: 'c-1' })); + await flush(); + + expect(calls).toHaveLength(1); + // Verbatim from the sys_webhook row — threaded, never fabricated. + expect(calls[0].organizationId).toBe('org_pin_alpha'); + await ae.stop(); + }); + + it('an org-less subscription enqueues with NO organization (the honest global-row shape, #13546)', async () => { + // The over-denial control: a `single`-posture install has org-less + // sys_webhook rows, and their events must still deliver — org-less, + // never refused, never stamped with a guess. + const engine = new FakeEngine({ sys_webhook: [webhook()] }); + const realtime = new FakeRealtime(); + const { enqueue, calls } = makeRecorder(); + const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 }); + await ae.start(); + + await realtime.publish(event('created', 'contact', { id: 'c-1' })); + await flush(); + + expect(calls).toHaveLength(1); + expect(calls[0].organizationId).toBeUndefined(); + await ae.stop(); + }); + it('[#4626] drops an off-contract data event instead of enqueuing it as "unknown"', async () => { // Pre-#4626 the enqueuer read `recordId ?? id ?? after?.id ?? 'unknown'`, // so a payload that named no record still produced a delivery whose @@ -583,6 +625,25 @@ describe('AutoEnqueuer — bulk data events (#4639)', () => { await ae.stop(); }); + it("the bulk path stamps the subscription's organization too (#13546)", async () => { + // Same tenant seam as the per-record path — a bulk delivery for an + // organization-owned subscription must not land as a global row either. + const engine = new FakeEngine({ + sys_webhook: [webhook({ triggers: 'bulk_update', organization_id: 'org_pin_alpha' })], + }); + const realtime = new FakeRealtime(); + const { enqueue, calls } = makeRecorder(); + const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 }); + await ae.start(); + + await realtime.publish(bulkEvent('updated', 'contact', 3)); + await flush(); + + expect(calls).toHaveLength(1); + expect(calls[0].organizationId).toBe('org_pin_alpha'); + await ae.stop(); + }); + it('does NOT deliver a bulk event to a per-record update subscriber', async () => { // The opt-in half of the decision: an existing `update` webhook keeps // receiving only bodies shaped the way it already reads them. diff --git a/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts b/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts index 9a909cf7dd..7f5269275a 100644 --- a/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts +++ b/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts @@ -115,6 +115,21 @@ interface CachedSubscription { headers?: Record; secret?: string; timeoutMs?: number; + /** + * [#13546] The subscription's own organization — `sys_webhook` is + * organization-scoped (#8554), so each row carries the tenant that authored + * it. Stamped onto every delivery row this subscription produces + * (`EnqueueHttpInput.organizationId`), which is what makes the + * cross-organization wall on `redeliver()` (#10740) actually exclude other + * tenants' rows: a row enqueued without it lands `organization_id = NULL`, + * the driver's global-row arm, visible to every organization. There is no + * request context to read here — the enqueuer runs fire-and-forget off the + * write path — so the subscription row is the one honest source. Absent + * when the row itself carries no organization (a `single`-posture install): + * the delivery then lands NULL, which is honest for a subscription that + * belongs to no organization. Threaded, never fabricated (#11303's rule). + */ + organizationId?: string; /** * [#8069] Set when a credential this subscription needs could not be * recovered. The subscription stays CACHED — that is the change — but every @@ -783,6 +798,10 @@ export class AutoEnqueuer { // from their encrypted columns, NOT read off the row — see #7799 // (secret) and #7986 (headers). timeoutMs: defn.timeoutMs, + // [#13546] The tenant column the kernel provisions on sys_webhook. + // This cache read is a dispatcher-side unscoped find, so the column + // comes back for every organization's rows. + organizationId: row.organization_id ? String(row.organization_id) : undefined, }; } @@ -867,6 +886,12 @@ export class AutoEnqueuer { // subscription, so the delivery path is byte-identical to before. undeliverableReason: sub.parkedReason, timeoutMs: sub.timeoutMs, + // [#13546] The delivery row belongs to the SUBSCRIPTION's + // organization — the one honest tenant in scope on this + // fire-and-forget path (no request context exists here). + // Absent for an org-less subscription; the row then lands + // NULL, the global-row shape. + organizationId: sub.organizationId, // [#3946] Envelope keys are written LAST so the event payload // cannot rewrite them. Behaviour-neutral for the engine's own // publishers — since #4626 a `data.record.*` payload is a @@ -960,6 +985,9 @@ export class AutoEnqueuer { // an undeliverable row instead of enqueuing a delivery. undeliverableReason: sub.parkedReason, timeoutMs: sub.timeoutMs, + // [#13546] See the per-record path — the subscription's own + // organization, absent for an org-less subscription. + organizationId: sub.organizationId, // [#3946] Envelope keys last so the payload cannot rewrite them. payload: { ...payload, diff --git a/packages/services/service-automation/src/builtin/http-nodes.test.ts b/packages/services/service-automation/src/builtin/http-nodes.test.ts index 7c4bb2be68..547d3d4f0e 100644 --- a/packages/services/service-automation/src/builtin/http-nodes.test.ts +++ b/packages/services/service-automation/src/builtin/http-nodes.test.ts @@ -110,6 +110,78 @@ describe('http (canonical node)', () => { expect(result.success).toBe(true); expect(fetchMock).toHaveBeenCalledOnce(); }); + + // [#13546] The producer pin: `sys_http_delivery` rows must carry the + // organization of the run that caused them, or they land in the + // driver's `organization_id IS NULL` global-row arm — visible to and + // replayable by every organization through redeliver() (#10740). The + // organization is THREADED from the run's own acting context + // (`AutomationContext.tenantId`) — the same source, and the same + // no-fallback rule, as the notify node's #11303 repair. + it("threads the run's acting organization onto the enqueue input (#13546)", async () => { + const enqueued: any[] = []; + const messaging: HttpSurface = { + isHttpDeliveryReady: () => true, + async enqueueHttp(input) { + enqueued.push(input); + return 'dlv_1'; + }, + }; + const engine = new AutomationEngine(createTestLogger()); + registerHttpNodes(engine, createCtx(messaging)); + engine.registerFlow( + 'http_flow', + httpFlow('http', { url: 'https://example.test/hook', durable: true }), + ); + + const result = await engine.execute('http_flow', { tenantId: 'org_pin_alpha' } as any); + + expect(result.success).toBe(true); + expect(enqueued).toHaveLength(1); + // Verbatim — the acting tenant, not a derived or defaulted value. + expect(enqueued[0].organizationId).toBe('org_pin_alpha'); + }); + + it('with NO organization in scope: still enqueues, passes NO organizationId key, and says so out loud (#13546)', async () => { + // The over-denial control (the notify suite's PIN C shape): a + // `single`-posture install and a stack before its first + // organization legitimately have no tenant to thread, and a + // durable callout there must still enqueue — org-less, loudly, + // never refused and never guessed. + const warnings: string[] = []; + const logger: any = { + info: () => {}, error: () => {}, debug: () => {}, + warn: (...args: unknown[]) => { warnings.push(args.map(String).join(' ')); }, + }; + logger.child = () => logger; + const enqueued: any[] = []; + const messaging: HttpSurface = { + isHttpDeliveryReady: () => true, + async enqueueHttp(input) { + enqueued.push(input); + return 'dlv_1'; + }, + }; + const engine = new AutomationEngine(logger); + registerHttpNodes(engine, { + logger, + getService: (name: string) => (name === 'messaging' ? messaging : undefined), + } as any); + engine.registerFlow( + 'http_flow', + httpFlow('http', { url: 'https://example.test/hook', durable: true }), + ); + + const result = await engine.execute('http_flow'); + + expect(result.success).toBe(true); + expect(enqueued).toHaveLength(1); + // Absent, not null and not '' — the outbox normalizes a missing + // value to NULL exactly once, at its insert. + expect('organizationId' in enqueued[0]).toBe(false); + // Fail-LOUD: the org-less durable callout is a visible event. + expect(warnings.some((w) => w.includes('organization_id = NULL'))).toBe(true); + }); }); describe('request/response mode (default)', () => { diff --git a/packages/services/service-automation/src/builtin/http-nodes.ts b/packages/services/service-automation/src/builtin/http-nodes.ts index a37d7dbc9f..469fdc0e13 100644 --- a/packages/services/service-automation/src/builtin/http-nodes.ts +++ b/packages/services/service-automation/src/builtin/http-nodes.ts @@ -48,6 +48,11 @@ interface MessagingHttpSurface { signingSecret?: string; timeoutMs?: number; payload: unknown; + /** + * [#13546] Organization the delivery row belongs to — the tenant + * column the `redeliver()` cross-organization wall scopes by (#10740). + */ + organizationId?: string; }): Promise; } @@ -121,6 +126,35 @@ export function registerHttpNodes(engine: AutomationEngine, ctx: PluginContext): if (durable) { const messaging = getMessaging(); if (messaging?.isHttpDeliveryReady?.() && messaging.enqueueHttp) { + // [#13546] The organization this delivery belongs to, + // THREADED from the run's own acting context — never + // fabricated. Same source and same no-fallback rule as the + // `notify` node's #11303 repair one file over: + // `AutomationContext.tenantId` is the acting run's + // organization, and a wrong value is worse than a null (a + // null is visibly missing; a wrong one is silently + // authoritative). Without it the sys_http_delivery row + // lands `organization_id = NULL` — the driver's global-row + // arm — visible to and replayable by EVERY organization + // through the redeliver() door (#10740). + const organizationId = + typeof context.tenantId === 'string' && context.tenantId !== '' + ? context.tenantId + : undefined; + if (!organizationId) { + // Fail-LOUD, not fail-guess, not fail-closed (#11303's + // triage): a `single`-posture install and a stack before + // its first organization legitimately have none, and a + // durable callout there must still enqueue. + ctx.logger.warn( + `[http] node '${node.id}': no organization in scope for this durable callout — its ` + + `sys_http_delivery row will carry organization_id = NULL, which is a global row ` + + `every organization's redeliver door can reach on a walled deployment (#13546). ` + + `On a multi-organization install the triggering context lost its tenant: give the ` + + `flow's trigger an acting organization (AutomationContext.tenantId). On a ` + + `single-organization install this is expected and can be ignored.`, + ); + } try { const deliveryId = await messaging.enqueueHttp({ source: 'flow', @@ -133,6 +167,10 @@ export function registerHttpNodes(engine: AutomationEngine, ctx: PluginContext): signingSecret, timeoutMs, payload: body ?? {}, + // [#13546] Absent (not null) when the run has no + // organization; the outbox normalizes a missing + // value to NULL exactly once, at the insert. + ...(organizationId ? { organizationId } : {}), }); // #4354 — the outbox row IS a durable effect this run // caused, but it is NOT a countable one (#7882). What diff --git a/packages/services/service-messaging/src/http-outbox-organization.test.ts b/packages/services/service-messaging/src/http-outbox-organization.test.ts new file mode 100644 index 0000000000..9bdfddc3ae --- /dev/null +++ b/packages/services/service-messaging/src/http-outbox-organization.test.ts @@ -0,0 +1,188 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#13546] `sys_http_delivery` rows carry the producer's organization. + * + * The consequence being pinned: the cross-organization wall on `redeliver()` + * (#10740) scopes by the row's `organization_id`, and the driver's tenant term + * is `(organization_id = :tenantId OR organization_id IS NULL)` — a deliberate + * global-row fail-open. A row enqueued without an organization therefore + * belongs to NO organization and is visible to EVERY one; before this repair + * the enqueue door never stamped the column, so 100% of rows were in that arm + * and the wall excluded nothing. + * + * ⭐ The control for this suite is the sibling notification outbox + * (`SqlOutbox.enqueue` writes `organization_id: input.organizationId ?? null`) + * — the repair the HTTP outbox is here made to mirror. One package, one + * convention: the member is optional on the input, the write normalizes a + * missing value to NULL exactly once, and the read-back maps NULL to absent. + * + * The memory double is pinned alongside on purpose: it now STORES the tenant, + * so per its own #10740 note ("a future memory implementation that DOES store + * a tenant owes the predicate here") its `redeliver()` owes the same + * invisible-not-forbidden scoping the SQL store applies — otherwise a suite + * running against the double passes cross-organization replays production + * refuses. + */ + +import { describe, it, expect } from 'vitest'; +import type { IDataEngine } from '@objectstack/spec/contracts'; +import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { SqlHttpOutbox } from './sql-http-outbox.js'; +import { MemoryHttpOutbox } from './memory-http-outbox.js'; +import { HttpRedeliverError } from './http-outbox.js'; + +/** + * A capturing data engine for the INSERT path: dedup probes miss (so the + * insert runs), writes are recorded verbatim, and `find` replays what was + * inserted so the `list()` read-back mapping can be asserted. + */ +function capturingEngine() { + const inserts: Array<{ object: string; row: Record }> = []; + const engine = { + async insert(object: string, row: Record) { + inserts.push({ object, row: { ...row } }); + return { ...row }; + }, + async findOne(object: string, query?: { fields?: string[] }) { + assertEngineFindOnePredicate(object, query); + return null; // no dedup winner — the insert path runs + }, + async find() { + return inserts.map((i) => ({ ...i.row })); + }, + } as unknown as IDataEngine; + return { engine, inserts }; +} + +const enqueueInput = { + source: 'flow', + refId: 'node_1', + dedupKey: 'dk_1', + url: 'https://example.test/hook', + payload: { hello: 'world' }, +}; + +describe('#13546 — SqlHttpOutbox stamps organization_id on the delivery row', () => { + it('enqueue() writes the producer organization onto the row', async () => { + const { engine, inserts } = capturingEngine(); + const outbox = new SqlHttpOutbox(engine, { partitionCount: 8 }); + + await outbox.enqueue({ ...enqueueInput, organizationId: 'org_pin_alpha' }); + + expect(inserts).toHaveLength(1); + // Verbatim — threaded, never derived or defaulted. + expect(inserts[0].row.organization_id).toBe('org_pin_alpha'); + }); + + it('enqueue() without an organization writes an EXPLICIT null (normalized once, at the write)', async () => { + const { engine, inserts } = capturingEngine(); + const outbox = new SqlHttpOutbox(engine, { partitionCount: 8 }); + + await outbox.enqueue(enqueueInput); + + expect(inserts).toHaveLength(1); + // Present-and-null, mirroring `SqlOutbox.enqueue` — the key exists so + // the normalization site is this insert, not scattered consumers. + expect(Object.prototype.hasOwnProperty.call(inserts[0].row, 'organization_id')).toBe(true); + expect(inserts[0].row.organization_id).toBeNull(); + }); + + it('recordUndeliverable() stamps the parked row the same way', async () => { + const { engine, inserts } = capturingEngine(); + const outbox = new SqlHttpOutbox(engine, { partitionCount: 8 }); + + await outbox.recordUndeliverable({ + ...enqueueInput, + organizationId: 'org_pin_alpha', + reason: 'signing secret unresolvable', + }); + + expect(inserts).toHaveLength(1); + expect(inserts[0].row.status).toBe('dead'); + // A parked row is a tenant-scoped row too — it sits in the Failures + // view for the full retention window and must not be a global row. + expect(inserts[0].row.organization_id).toBe('org_pin_alpha'); + }); + + it('list() maps organization_id back to organizationId (NULL → absent)', async () => { + const { engine } = capturingEngine(); + const outbox = new SqlHttpOutbox(engine, { partitionCount: 8 }); + + await outbox.enqueue({ ...enqueueInput, organizationId: 'org_pin_alpha' }); + await outbox.enqueue({ ...enqueueInput, dedupKey: 'dk_2' }); + + const rows = await outbox.list(); + expect(rows.map((r) => r.organizationId)).toEqual(['org_pin_alpha', undefined]); + }); +}); + +describe('#13546 — MemoryHttpOutbox parity', () => { + it('enqueue() stamps organizationId on the stored row', async () => { + const outbox = new MemoryHttpOutbox(); + const id = await outbox.enqueue({ ...enqueueInput, organizationId: 'org_pin_alpha' }); + + const rows = await outbox.list(); + expect(rows).toHaveLength(1); + expect(rows[0].id).toBe(id); + expect(rows[0].organizationId).toBe('org_pin_alpha'); + }); + + /** Enqueue + dead-ack: the shortest path to a redeliverable terminal row. */ + async function deadRow(outbox: MemoryHttpOutbox, organizationId?: string): Promise { + const id = await outbox.enqueue({ + ...enqueueInput, + dedupKey: `dk_${organizationId ?? 'none'}`, + ...(organizationId ? { organizationId } : {}), + }); + await outbox.ack(id, { success: false, dead: true, error: 'boom', durationMs: 1 }); + return id; + } + + it("redeliver() from ANOTHER organization is INVISIBLE — RESOURCE_NOT_FOUND, row untouched", async () => { + const outbox = new MemoryHttpOutbox(); + const id = await deadRow(outbox, 'org_pin_alpha'); + + // [ADR-0112] The envelope, not just "it threw": the refusal must be + // the not-found the contract rules (never an existence oracle), on the + // error class callers match on. + const attempt = outbox.redeliver(id, { tenantId: 'org_pin_beta' }); + await expect(attempt).rejects.toBeInstanceOf(HttpRedeliverError); + await expect(attempt).rejects.toMatchObject({ code: 'RESOURCE_NOT_FOUND' }); + + // Invisible means NOTHING was written — the row still reads dead. + const [row] = await outbox.list({ status: 'dead' }); + expect(row.id).toBe(id); + expect(row.attempts).toBe(1); + }); + + it('redeliver() inside the owning organization succeeds', async () => { + const outbox = new MemoryHttpOutbox(); + const id = await deadRow(outbox, 'org_pin_alpha'); + + const row = await outbox.redeliver(id, { tenantId: 'org_pin_alpha' }); + expect(row.status).toBe('pending'); + // The reset must not strip the tenant — a replay stays scoped. + expect(row.organizationId).toBe('org_pin_alpha'); + }); + + it('an org-less row stays a GLOBAL row any tenant may replay (the driver fail-open arm, mirrored)', async () => { + // The over-denial control: hiding NULL rows from every tenant would be + // a different defect (platform rows invisible to everyone). The memory + // predicate must mirror `(organization_id = :tenantId OR organization_id + // IS NULL)`, not tighten it. + const outbox = new MemoryHttpOutbox(); + const id = await deadRow(outbox); + + const row = await outbox.redeliver(id, { tenantId: 'org_pin_beta' }); + expect(row.status).toBe('pending'); + }); + + it('a tenant-less caller stays unscoped (the honest degraded shape RedeliverOptions rules)', async () => { + const outbox = new MemoryHttpOutbox(); + const id = await deadRow(outbox, 'org_pin_alpha'); + + const row = await outbox.redeliver(id, { tenantId: undefined }); + expect(row.status).toBe('pending'); + }); +}); diff --git a/packages/services/service-messaging/src/http-outbox.ts b/packages/services/service-messaging/src/http-outbox.ts index da55ea0add..23ae79fde7 100644 --- a/packages/services/service-messaging/src/http-outbox.ts +++ b/packages/services/service-messaging/src/http-outbox.ts @@ -104,6 +104,15 @@ export interface HttpDelivery { timeoutMs?: number; /** JSON-serialisable body. */ payload: unknown; + /** + * [#13546] Organization this delivery row belongs to — the read-back of + * the row's kernel-provisioned `organization_id` tenant column, which is + * the column the cross-organization wall on {@link IHttpOutbox.redeliver} + * scopes by (#10740). Absent for a global row (`organization_id = NULL`). + * Mirrors `NotificationDeliveryRecord.organizationId` on the sibling + * notification outbox. + */ + organizationId?: string; /** Lifecycle state. */ status: HttpDeliveryStatus; @@ -158,6 +167,35 @@ export interface EnqueueHttpInput { signingSecret?: string; timeoutMs?: number; payload: unknown; + /** + * [#13546] Organization this delivery belongs to. Lands on the row's + * kernel-provisioned `organization_id` tenant column — the column the + * cross-organization wall on {@link IHttpOutbox.redeliver} scopes by + * (#10740). The driver's tenant term is + * `(organization_id = :tenantId OR organization_id IS NULL)`, so a row + * enqueued WITHOUT one lands in the deliberate global-row arm: visible + * to — and replayable by — every organization on a walled deployment. + * Producers MUST thread the organization they are acting for whenever + * they have one: the flow `http` node passes its run's acting tenant + * (`AutomationContext.tenantId`, the same source as the `notify` node's + * #11303 repair), the webhook auto-enqueuer its subscription's own + * organization (`sys_webhook.organization_id`). Threaded, never + * fabricated: a producer with genuinely no organization — a + * `single`-posture deployment, a stack before its first organization — + * leaves it absent, and the row lands NULL, the honest global-row shape. + * + * Deliberately OPTIONAL, mirroring `EnqueueDeliveryInput.organizationId` + * — the sibling notification outbox's identical repair — so the two + * outboxes in this package keep one convention. The + * required-but-`undefined`-able shape {@link RedeliverOptions.tenantId} + * uses was weighed and not chosen: that shape guards a REQUEST-reachable + * door any forgetful route can call, while this seam's producers are + * enumerated (the two above), each repaired in the same change to thread + * the value, and org-less enqueues remain legitimate for org-less + * deployments. Inherited by {@link UndeliverableHttpInput}, so parked + * rows are tenant-stamped the same way. + */ + organizationId?: string; /** * [#8069] Transport-only discriminator for the ONE seam that carries both * kinds of write — `MessagingService.enqueueHttp()`, the single function a diff --git a/packages/services/service-messaging/src/memory-http-outbox.ts b/packages/services/service-messaging/src/memory-http-outbox.ts index 6a8c9737bc..dabf78a5c0 100644 --- a/packages/services/service-messaging/src/memory-http-outbox.ts +++ b/packages/services/service-messaging/src/memory-http-outbox.ts @@ -75,6 +75,10 @@ export class MemoryHttpOutbox implements IHttpOutbox { signature: terminal.signature, timeoutMs: input.timeoutMs, payload: input.payload, + // [#13546] Same stamp as `SqlHttpOutbox.insert`, so a test that + // inspects a row here sees what production persists — and so + // `redeliver()` below has a tenant to scope by. + organizationId: input.organizationId, status: terminal.status, attempts: 0, error: terminal.error, @@ -157,20 +161,32 @@ export class MemoryHttpOutbox implements IHttpOutbox { } /** - * [#10740] `options.tenantId` is accepted and deliberately not applied: - * this outbox is a `Map` of in-process rows with no tenant column and no - * driver behind it, so there is nothing to scope and nothing that could - * emit a tenant-audit finding. It is a test/dev double, never the - * request-reachable production store — `SqlHttpOutbox.redeliver` is the - * site that owes the isolation, and it applies the field. + * [#10740] `options.tenantId` IS applied here since #13546. The [#10740] + * text this replaces disclaimed the predicate because the rows carried no + * tenant at all ("a future memory implementation that DOES store a tenant + * owes the predicate here") — and `insert()` now stamps + * {@link HttpDelivery.organizationId}, so the debt is due. * - * ⚠️ Stated rather than left implicit, because "the parameter is ignored" - * and "the isolation is missing" look identical from a call site. A future - * memory implementation that DOES store a tenant owes the predicate here. + * The predicate mirrors the SQL driver's tenant term + * `(organization_id = :tenantId OR organization_id IS NULL)`, all three + * arms deliberately: + * - a row in ANOTHER organization is INVISIBLE (`RESOURCE_NOT_FOUND`), + * never forbidden — the same non-oracle refusal the contract rules; + * - a row with NO organization is a global row every tenant may reach + * (the driver's deliberate fail-open arm — hiding platform rows from + * every tenant is a different defect); + * - a caller with NO tenant (`tenantId: undefined`) stays unscoped, + * which is the honest degraded shape {@link RedeliverOptions} rules + * for genuinely tenant-less deployments. */ async redeliver(id: string, options: RedeliverOptions): Promise { const row = this.rows.get(id); - if (!row) { + if ( + !row || + (options.tenantId !== undefined && + row.organizationId !== undefined && + row.organizationId !== options.tenantId) + ) { throw new HttpRedeliverError(`Delivery row '${id}' not found`, 'RESOURCE_NOT_FOUND'); } // [#8069] Refuse BEFORE any mutation — a refused redelivery must leave diff --git a/packages/services/service-messaging/src/sql-http-outbox.ts b/packages/services/service-messaging/src/sql-http-outbox.ts index 93676e22aa..d1d60d27f0 100644 --- a/packages/services/service-messaging/src/sql-http-outbox.ts +++ b/packages/services/service-messaging/src/sql-http-outbox.ts @@ -59,6 +59,12 @@ interface DeliveryRow { signature?: string | null; timeout_ms?: number | null; payload_json: string; + /** + * [#13546] Kernel-provisioned tenant column — the predicate of the + * cross-organization wall on `redeliver()` (#10740). NULL = global row, + * visible to every organization (the driver's deliberate fail-open arm). + */ + organization_id?: string | null; partition_key: number; status: HttpDeliveryStatus; attempts: number; @@ -167,6 +173,15 @@ export class SqlHttpOutbox implements IHttpOutbox { signature: terminal.signature, timeout_ms: input.timeoutMs, payload_json: JSON.stringify(input.payload ?? null), + // [#13546] Stamp the producer's organization on the row — the same + // line `SqlOutbox.enqueue` writes. There is no execution context on + // this path (both producers run off the write path), so the row + // value is the ONE seam that can scope this row; without it every + // row lands in the driver's `organization_id IS NULL` global-row + // arm and the redeliver() wall (#10740) excludes nothing. The + // explicit `?? null` normalizes "producer has no organization" + // to NULL exactly once, here. + organization_id: input.organizationId ?? null, partition_key: hashPartition(input.refId, this.partitionCount), status: terminal.status, attempts: 0, @@ -439,6 +454,7 @@ export class SqlHttpOutbox implements IHttpOutbox { signature: r.signature ?? undefined, timeoutMs: r.timeout_ms ?? undefined, payload: JSON.parse(r.payload_json), + organizationId: r.organization_id ?? undefined, status: r.status, attempts: r.attempts, claimedBy: r.claimed_by ?? undefined, From e2ecb71c051192e46d35edc09cf2e0088770ba29 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 01:28:16 +0000 Subject: [PATCH 2/3] fix(service-automation): keep the tracker id out of the runtime warn string check:doc-authoring red: operators cannot resolve #NNNN. The anchor stays in the adjacent code comment. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs --- .../services/service-automation/src/builtin/http-nodes.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/services/service-automation/src/builtin/http-nodes.ts b/packages/services/service-automation/src/builtin/http-nodes.ts index 469fdc0e13..01f7801bce 100644 --- a/packages/services/service-automation/src/builtin/http-nodes.ts +++ b/packages/services/service-automation/src/builtin/http-nodes.ts @@ -146,10 +146,13 @@ export function registerHttpNodes(engine: AutomationEngine, ctx: PluginContext): // triage): a `single`-posture install and a stack before // its first organization legitimately have none, and a // durable callout there must still enqueue. + // (Issue anchor lives in these comments, not in the + // runtime string — operators cannot resolve a tracker + // id; see check:doc-authoring.) ctx.logger.warn( `[http] node '${node.id}': no organization in scope for this durable callout — its ` + `sys_http_delivery row will carry organization_id = NULL, which is a global row ` + - `every organization's redeliver door can reach on a walled deployment (#13546). ` + + `every organization's redeliver door can reach on a walled deployment. ` + `On a multi-organization install the triggering context lost its tenant: give the ` + `flow's trigger an acting organization (AutomationContext.tenantId). On a ` + `single-organization install this is expected and can be ignored.`, From 479e6315071c3b8cef710d46da7e64b4d3a160c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 04:19:05 +0000 Subject: [PATCH 3/3] chore: register http-outbox-organization.test.ts in the engine-double ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check-engine-double-contract --write: 1 row added (findOne, pinned: 1), 0 lost — additive ratchet only, per the gate's own instruction. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs --- scripts/engine-double-contract.pinned.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 2ec9d616db..d299f748e4 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3111,6 +3111,11 @@ "verb": "findOne", "pinned": 1 }, + { + "file": "packages/services/service-messaging/src/http-outbox-organization.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/services/service-messaging/src/inbox-channel.test.ts", "verb": "findOne",