@ultimat3/jobs 25.0.0 → 25.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +3 -1
- package/README.md +23 -11
- package/package.json +6 -6
- package/src/errors-tenant.ts +22 -0
- package/src/errors.ts +5 -0
- package/src/index.ts +2 -0
- package/src/webhook.ts +77 -9
package/CLAUDE.md
CHANGED
|
@@ -227,7 +227,8 @@ Long form: [`docs/history/jobs.md`](../../docs/history/jobs.md), "Moved 2026-10-
|
|
|
227
227
|
reading per pass, duplicate names refused.
|
|
228
228
|
- **`exportRows()`**: one object per PAGE, named by page index; NO cross-tenant escape.
|
|
229
229
|
- **`webhook()`**: ONE event to ONE endpoint, no steps; each attempt but a CANCELLED one recorded
|
|
230
|
-
before the throw; `ledger.isDisabled` gates the socket. **The wire format is core's.**
|
|
230
|
+
before the throw; `ledger.isDisabled` gates the socket. **The wire format is core's.** An org
|
|
231
|
+
tenant reads `orgId` OFF THE INPUT; none, or not the run's org: TERMINAL `X_JOB_TENANT_MISMATCH`.
|
|
231
232
|
- A `-fixture.ts` file does not ship; `backfill-pass-fixture.ts` raises a plain `Error` subclass on
|
|
232
233
|
purpose (it stands in for app code).
|
|
233
234
|
|
|
@@ -301,6 +302,7 @@ Long form: [`docs/history/jobs.md`](../../docs/history/jobs.md), "Moved 2026-10-
|
|
|
301
302
|
| `worker-loop.ts` | the claim loop's timer: wake, `again`, the kicks |
|
|
302
303
|
| `driver-pg-outbox-sql.ts` | the `x_outbox` statements |
|
|
303
304
|
| `errors-operator.ts` / `errors-requeue.ts` | the operator verbs' refusals; `requeue`'s |
|
|
305
|
+
| `errors-tenant.ts` | `JobTenantMismatchError` |
|
|
304
306
|
|
|
305
307
|
Which `-fixture.ts` harness each suite shares, and what each `.job.` suite proves:
|
|
306
308
|
`docs/history/jobs.md`, "Moved 2026-10-01 — test harnesses".
|
package/README.md
CHANGED
|
@@ -502,13 +502,17 @@ subscription table, one `enqueue` per endpoint.
|
|
|
502
502
|
import { memoryWebhookLedger, webhook } from '@ultimat3/jobs';
|
|
503
503
|
|
|
504
504
|
declare const db: {
|
|
505
|
-
endpoints: {
|
|
505
|
+
endpoints: {
|
|
506
|
+
byId(id: string): Promise<{ id: string; orgId: string; url: string; secret: string } | null>;
|
|
507
|
+
};
|
|
506
508
|
events: { byId(id: string): Promise<{ topic: string; body: string } | null> };
|
|
507
509
|
};
|
|
508
510
|
|
|
509
511
|
export const deliver = webhook({
|
|
510
512
|
name: 'partner.webhooks',
|
|
511
|
-
tenant: 'none'
|
|
513
|
+
// The org that owns the endpoint, carried on every enqueue — or `tenant: 'none'` for endpoints
|
|
514
|
+
// no tenant owns. `tenant` is synchronous over the input, so the org rides beside the two ids.
|
|
515
|
+
tenant: ({ orgId }) => orgId,
|
|
512
516
|
// Read once per ATTEMPT and never checkpointed: the endpoint carries a secret, and a step's
|
|
513
517
|
// output is written to `x_job_steps`.
|
|
514
518
|
endpoint: ({ endpointId }) => db.endpoints.byId(endpointId),
|
|
@@ -518,11 +522,11 @@ export const deliver = webhook({
|
|
|
518
522
|
});
|
|
519
523
|
|
|
520
524
|
// The fan-out is yours, because the subscription table is yours.
|
|
521
|
-
declare function subscribersOf(topic: string): Promise<readonly { id: string }[]>;
|
|
525
|
+
declare function subscribersOf(topic: string): Promise<readonly { id: string; orgId: string }[]>;
|
|
522
526
|
declare const event: { readonly id: string };
|
|
523
527
|
|
|
524
528
|
for (const endpoint of await subscribersOf('orders.paid')) {
|
|
525
|
-
await deliver.enqueue({ endpointId: endpoint.id, eventId: event.id });
|
|
529
|
+
await deliver.enqueue({ endpointId: endpoint.id, eventId: event.id, orgId: endpoint.orgId });
|
|
526
530
|
}
|
|
527
531
|
```
|
|
528
532
|
|
|
@@ -558,6 +562,13 @@ re-exports (`X_HELPER_COPY`). Same argument `timing-safe-equal.ts` makes for its
|
|
|
558
562
|
| the DRAIN never aborts a POST already on the wire; it stops one not yet sent | torn down, the job goes to the next pod, which re-POSTs the same event on every deploy. Finished, it is recorded like any other outcome — a real failure during the drain included |
|
|
559
563
|
| re-enabling is always yours | an endpoint the framework un-disabled on its own is a retry loop with no end |
|
|
560
564
|
| `WebhookLedger` is a seam, not a table | retention is seven years for one business and thirty days for the next, so shipping a schema would ship one of those answers |
|
|
565
|
+
| an org-owned delivery carries `orgId` on its input | `tenant` is a synchronous function of the input, so an org looked up from the endpoint id would be a read before the run has an org to read under. `tenant: ({ orgId }) => orgId` needs it on every enqueue, and both seams are handed it. A tenant that reads no `orgId` (`'none'`, or 25.0.0's `({ endpointId }) => …`) enqueues without one, as before |
|
|
566
|
+
| the payload's org is the run's org, or the delivery dies | a tenant that names no org for the payload, or an `orgId` that is not the org the run is under (with `tenant: 'none'`, any `orgId`), is `X_JOB_TENANT_MISMATCH` before either seam reads — **terminal**, dead-lettered on attempt 1, because the same row is refused on every attempt and by `x jobs retry` |
|
|
567
|
+
|
|
568
|
+
**Upgrading a webhook to `tenant: ({ orgId }) => orgId`.** Deliveries already queued without an
|
|
569
|
+
`orgId` dead-letter on their next attempt with `X_JOB_TENANT_MISMATCH`, and a retry cannot add the
|
|
570
|
+
field. Drain the queue before deploying the new declaration, or re-enqueue what dead-letters with its
|
|
571
|
+
org: `x jobs ls --state dead --name partner.webhooks --json`, then `deliver.enqueue({ endpointId, eventId, orgId })` for each.
|
|
561
572
|
|
|
562
573
|
**Set `timeout` below the drain budget.** A webhook has no timeout unless you declare one, and a
|
|
563
574
|
POST on the wire when SIGTERM lands runs until the receiver answers. With a `timeout`, a drained
|
|
@@ -867,18 +878,18 @@ hourly task fire 24 catch-ups a second apart.
|
|
|
867
878
|
|
|
868
879
|
### A `PgExecutor`
|
|
869
880
|
|
|
870
|
-
`PgExecutor` is one method — `query(text, values)` — and
|
|
871
|
-
`
|
|
872
|
-
client
|
|
881
|
+
`PgExecutor` is one method — `query(text, values)` — and there is ONE builder for it:
|
|
882
|
+
`@ultimat3/db`'s `dbExecutor()`, which the boot uses too (`dbExecutor(() => client)`). It resolves
|
|
883
|
+
its client per statement — by default the installed one, which inside `withTransaction` is the
|
|
884
|
+
transaction's own connection — and that client already speaks `(text, values)` and decodes `jsonb`.
|
|
885
|
+
Outside the boot — a test, a script — hand it the client to stay on:
|
|
873
886
|
|
|
874
887
|
```ts
|
|
875
|
-
import { postgresClient } from '@ultimat3/db';
|
|
888
|
+
import { dbExecutor, postgresClient } from '@ultimat3/db';
|
|
876
889
|
import type { PgExecutor } from '@ultimat3/jobs';
|
|
877
890
|
|
|
878
891
|
const client = postgresClient({ url: 'postgres://localhost:5432/app_test' });
|
|
879
|
-
export const executor: PgExecutor =
|
|
880
|
-
query: <R>(text: string, values: readonly unknown[]) => client.query<R>({ text, values }),
|
|
881
|
-
};
|
|
892
|
+
export const executor: PgExecutor = dbExecutor(() => client);
|
|
882
893
|
```
|
|
883
894
|
|
|
884
895
|
| Not this | Because |
|
|
@@ -1294,6 +1305,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
1294
1305
|
| `JobRowStatusUnknownError` | `X_JOB_ROW_STATUS_UNKNOWN` | `src/errors.ts` |
|
|
1295
1306
|
| `JobSlotLostError` | `X_JOB_SLOT_LOST` | `src/errors.ts` |
|
|
1296
1307
|
| `JobTenantRequiredError` | `X_JOB_TENANT_REQUIRED` | `src/errors.ts` |
|
|
1308
|
+
| `JobTenantMismatchError` | `X_JOB_TENANT_MISMATCH` | `src/errors-tenant.ts` |
|
|
1297
1309
|
| `JobTimeoutError` | `X_JOB_TIMEOUT` | `src/errors.ts` |
|
|
1298
1310
|
| `LeaseLostError` | `X_JOB_LEASE_LOST` | `src/errors.ts` |
|
|
1299
1311
|
| `OutboxNoTxError` | `X_OUTBOX_NO_TX` | `src/errors.ts` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/jobs",
|
|
3
|
-
"version": "25.
|
|
3
|
+
"version": "25.2.0",
|
|
4
4
|
"description": "Durable background work: steps, transactional outbox, cron tasks, one driver interface",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -32,10 +32,10 @@
|
|
|
32
32
|
"test": "bun test"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@ultimat3/core": "25.
|
|
36
|
-
"@ultimat3/db": "25.
|
|
37
|
-
"@ultimat3/entity": "25.
|
|
38
|
-
"@ultimat3/schema": "25.
|
|
39
|
-
"@ultimat3/time": "25.
|
|
35
|
+
"@ultimat3/core": "25.2.0",
|
|
36
|
+
"@ultimat3/db": "25.2.0",
|
|
37
|
+
"@ultimat3/entity": "25.2.0",
|
|
38
|
+
"@ultimat3/schema": "25.2.0",
|
|
39
|
+
"@ultimat3/time": "25.2.0"
|
|
40
40
|
}
|
|
41
41
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// The one refusal for a job whose PAYLOAD and declared tenant disagree — a webhook delivery's
|
|
2
|
+
// `orgId` that is not the org the run is under, a tenant that derives no org from the payload, an
|
|
3
|
+
// agent actor resolved in another org. Its code stays declared in `errors.ts`, classified terminal.
|
|
4
|
+
|
|
5
|
+
import { UltimateError } from '@ultimat3/core';
|
|
6
|
+
|
|
7
|
+
export class JobTenantMismatchError extends UltimateError {
|
|
8
|
+
constructor(input: {
|
|
9
|
+
readonly job: string;
|
|
10
|
+
/** What disagrees, in words — org ids only; never a secret, never a row. */
|
|
11
|
+
readonly reason: string;
|
|
12
|
+
/** The enqueue, or the declaration, that repairs it — specific to the caller. */
|
|
13
|
+
readonly fix: string;
|
|
14
|
+
}) {
|
|
15
|
+
super({
|
|
16
|
+
code: 'X_JOB_TENANT_MISMATCH',
|
|
17
|
+
cause: `job "${input.job}" ${input.reason} — the same payload is refused on every attempt, so it is dead-lettered on this one`,
|
|
18
|
+
fix: input.fix,
|
|
19
|
+
meta: { job: input.job },
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -44,6 +44,7 @@ export const JOB_OWNED_ERROR_CODES = [
|
|
|
44
44
|
'X_JOB_PAGE_INVALID',
|
|
45
45
|
'X_JOB_ON_SETTLED_FAILED',
|
|
46
46
|
'X_JOB_NOT_FOUND',
|
|
47
|
+
'X_JOB_TENANT_MISMATCH',
|
|
47
48
|
] as const;
|
|
48
49
|
|
|
49
50
|
/**
|
|
@@ -102,6 +103,7 @@ export const JOB_ERROR_TITLES: Readonly<Record<JobOwnedErrorCode, string>> = {
|
|
|
102
103
|
X_JOB_PAGE_INVALID: 'a job list page was asked for outside its bounds',
|
|
103
104
|
X_JOB_ON_SETTLED_FAILED: 'the onSettled hook failed after the run was settled',
|
|
104
105
|
X_JOB_NOT_FOUND: 'the queue holds no job with this id',
|
|
106
|
+
X_JOB_TENANT_MISMATCH: "a job's payload names an org other than the tenant it runs under",
|
|
105
107
|
};
|
|
106
108
|
|
|
107
109
|
// One unconditional call, so a second package claiming one of jobs' codes throws
|
|
@@ -155,6 +157,9 @@ registerErrorRetry({
|
|
|
155
157
|
X_EXPORT_PART_TOO_LARGE: 'terminal',
|
|
156
158
|
// The worker settles a busy key itself; listed so a body that RETHROWS one it was handed stops.
|
|
157
159
|
X_JOB_KEY_BUSY: 'terminal',
|
|
160
|
+
// The payload names the wrong org, or none its tenant can run under — a fact about the ROW, so
|
|
161
|
+
// every attempt and every `x jobs retry` is refused identically: the repair is a new enqueue.
|
|
162
|
+
X_JOB_TENANT_MISMATCH: 'terminal',
|
|
158
163
|
});
|
|
159
164
|
|
|
160
165
|
// No `docs:` on any class below, here or in `backfill-errors.ts`. `UltimateError` fills it from
|
package/src/index.ts
CHANGED
|
@@ -153,6 +153,7 @@ export {
|
|
|
153
153
|
JobPageInvalidError,
|
|
154
154
|
} from './errors-operator';
|
|
155
155
|
export { JobNotFoundError, JobNotRequeueableError } from './errors-requeue';
|
|
156
|
+
export { JobTenantMismatchError } from './errors-tenant';
|
|
156
157
|
export type { EventBus, JobEvent, MemoryEventBusOptions, PublishOptions } from './events';
|
|
157
158
|
export {
|
|
158
159
|
EVENTS_PURGE_TARGET,
|
|
@@ -361,6 +362,7 @@ export type {
|
|
|
361
362
|
WebhookEndpoint,
|
|
362
363
|
WebhookEvent,
|
|
363
364
|
WebhookReport,
|
|
365
|
+
WebhookTenant,
|
|
364
366
|
} from './webhook';
|
|
365
367
|
export { DEFAULT_WEBHOOK_DISABLE_AFTER, webhook } from './webhook';
|
|
366
368
|
export type { WebhookFetch } from './webhook-attempt';
|
package/src/webhook.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
import { t } from '@ultimat3/schema';
|
|
28
28
|
import type { DurationInput } from './clock';
|
|
29
29
|
import { nowMs } from './clock';
|
|
30
|
+
import { JobTenantMismatchError } from './errors-tenant';
|
|
30
31
|
import type { JobHandle } from './job';
|
|
31
32
|
import { job } from './job';
|
|
32
33
|
import type { RetryPolicy } from './retry';
|
|
@@ -94,8 +95,23 @@ export interface WebhookDeliveryInput {
|
|
|
94
95
|
readonly endpointId: string;
|
|
95
96
|
/** Also the id the receiver dedupes on — it is signed, so it cannot be moved in transit. */
|
|
96
97
|
readonly eventId: string;
|
|
98
|
+
/**
|
|
99
|
+
* The org that owns the endpoint, for a delivery declared `tenant: ({ orgId }) => orgId`. On the
|
|
100
|
+
* input because `tenant` is a synchronous function of the input alone — derived from the
|
|
101
|
+
* endpoint id it would be a read before the run has an org to read under. Neither signed nor
|
|
102
|
+
* sent: the receiver learns nothing it did not already know.
|
|
103
|
+
*/
|
|
104
|
+
readonly orgId?: string | undefined;
|
|
97
105
|
}
|
|
98
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Who a delivery runs as: `'none'`, or the org derived from its payload. May answer `undefined` —
|
|
109
|
+
* `({ orgId }) => orgId` over a row that carries none — and that answer is refused, TERMINALLY,
|
|
110
|
+
* before either seam reads (`X_JOB_TENANT_MISMATCH`). One signature for both shapes on purpose: a
|
|
111
|
+
* 25.0.0 `({ endpointId }) => …` that never reads `orgId` keeps enqueuing without one.
|
|
112
|
+
*/
|
|
113
|
+
export type WebhookTenant = 'none' | ((input: WebhookDeliveryInput) => string | undefined);
|
|
114
|
+
|
|
99
115
|
/** What one landed delivery reports. Bounded, so `x jobs show` can print it. */
|
|
100
116
|
export interface WebhookReport {
|
|
101
117
|
readonly endpointId: string;
|
|
@@ -111,12 +127,13 @@ export interface WebhookDefinition {
|
|
|
111
127
|
*/
|
|
112
128
|
readonly name: string;
|
|
113
129
|
/**
|
|
114
|
-
* REQUIRED, exactly as on `job()`: `tenant: ({
|
|
115
|
-
* org that owns the endpoint
|
|
116
|
-
*
|
|
117
|
-
*
|
|
130
|
+
* REQUIRED, exactly as on `job()`: `tenant: ({ orgId }) => orgId` for a delivery scoped to the
|
|
131
|
+
* org that owns the endpoint — every enqueue then carries `orgId` — or the explicit
|
|
132
|
+
* `tenant: 'none'`. A delivery reads the app's own endpoint and event rows
|
|
133
|
+
* through the seams below, so the org those reads run under is a fact about the WORK and is
|
|
134
|
+
* declared here rather than inherited from whichever worker claimed it.
|
|
118
135
|
*/
|
|
119
|
-
readonly tenant:
|
|
136
|
+
readonly tenant: WebhookTenant;
|
|
120
137
|
/**
|
|
121
138
|
* The endpoint this delivery is for. Read once PER ATTEMPT and never checkpointed: it carries a
|
|
122
139
|
* secret, and a `step.run` output is written to `x_job_steps` — a credential in a durable table
|
|
@@ -124,11 +141,18 @@ export interface WebhookDefinition {
|
|
|
124
141
|
*/
|
|
125
142
|
endpoint(args: {
|
|
126
143
|
readonly endpointId: string;
|
|
144
|
+
/**
|
|
145
|
+
* The delivery's own `orgId`, off the input — by the time a seam runs, the org the run is
|
|
146
|
+
* under, or absent. Handed over so a seam names its tenant from the work rather than reading it
|
|
147
|
+
* back off `ctx.actor`.
|
|
148
|
+
*/
|
|
149
|
+
readonly orgId: string | undefined;
|
|
127
150
|
readonly ctx: Ctx;
|
|
128
151
|
}): Promise<WebhookEndpoint | null> | WebhookEndpoint | null;
|
|
129
152
|
/** The event's bytes. Read per attempt as well, out of the app's own table. */
|
|
130
153
|
event(args: {
|
|
131
154
|
readonly eventId: string;
|
|
155
|
+
readonly orgId: string | undefined;
|
|
132
156
|
readonly ctx: Ctx;
|
|
133
157
|
}): Promise<WebhookEvent | null> | WebhookEvent | null;
|
|
134
158
|
/** Where every attempt is recorded, and where the consecutive-failure count comes from. */
|
|
@@ -175,11 +199,11 @@ export function webhook(definition: WebhookDefinition): JobHandle<WebhookDeliver
|
|
|
175
199
|
// Named so `run` can read the deadline `job()` resolved — one parse of `timeout`, never two.
|
|
176
200
|
const handle: JobHandle<WebhookDeliveryInput> = job<WebhookDeliveryInput>({
|
|
177
201
|
name: definition.name,
|
|
178
|
-
input: t.object({ endpointId: t.string, eventId: t.string }),
|
|
202
|
+
input: t.object({ endpointId: t.string, eventId: t.string, orgId: t.optional(t.string) }),
|
|
179
203
|
// Endpoint AND event: the same event fans out to every subscribed endpoint, so a key on the
|
|
180
204
|
// event alone would dedupe every one of those deliveries into the first endpoint's row.
|
|
181
205
|
idempotencyKey: ({ endpointId, eventId }) => `${definition.name}:${endpointId}:${eventId}`,
|
|
182
|
-
tenant: definition
|
|
206
|
+
tenant: tenantOf(definition),
|
|
183
207
|
retry: definition.retry ?? DEFAULT_RETRY,
|
|
184
208
|
...(definition.queue === undefined ? {} : { queue: definition.queue }),
|
|
185
209
|
...(definition.timeout === undefined ? {} : { timeout: definition.timeout }),
|
|
@@ -194,7 +218,13 @@ export function webhook(definition: WebhookDefinition): JobHandle<WebhookDeliver
|
|
|
194
218
|
job: definition.name,
|
|
195
219
|
timeoutMs: handle.timeoutMs,
|
|
196
220
|
};
|
|
197
|
-
|
|
221
|
+
// Before either seam: the org they are handed must be the org every read runs under.
|
|
222
|
+
assertInputOrg(definition.name, input.orgId, ctx);
|
|
223
|
+
const endpoint = await definition.endpoint({
|
|
224
|
+
endpointId: input.endpointId,
|
|
225
|
+
orgId: input.orgId,
|
|
226
|
+
ctx,
|
|
227
|
+
});
|
|
198
228
|
if (endpoint === null) {
|
|
199
229
|
throw new WebhookEndpointUnknownError({
|
|
200
230
|
webhook: definition.name,
|
|
@@ -222,7 +252,7 @@ export function webhook(definition: WebhookDefinition): JobHandle<WebhookDeliver
|
|
|
222
252
|
resolve: definition.resolve ?? resolveWebhookHost,
|
|
223
253
|
});
|
|
224
254
|
|
|
225
|
-
const event = await definition.event({ eventId: input.eventId, ctx });
|
|
255
|
+
const event = await definition.event({ eventId: input.eventId, orgId: input.orgId, ctx });
|
|
226
256
|
if (event === null) {
|
|
227
257
|
throw new WebhookEventUnknownError({ webhook: definition.name, eventId: input.eventId });
|
|
228
258
|
}
|
|
@@ -323,6 +353,44 @@ export function webhook(definition: WebhookDefinition): JobHandle<WebhookDeliver
|
|
|
323
353
|
return handle;
|
|
324
354
|
}
|
|
325
355
|
|
|
356
|
+
/**
|
|
357
|
+
* The declared tenant, asked as declared, with one refusal added: an answer that names NO org. Only
|
|
358
|
+
* then — a 25.0.0 `tenant: ({ endpointId }) => …` never reads `orgId` and needs none, so requiring
|
|
359
|
+
* one would refuse every delivery it queued. The usual cause is a row queued before `orgId` existed
|
|
360
|
+
* under a `({ orgId }) => orgId` declaration. Coded and TERMINAL rather than `jobTenantFor`'s
|
|
361
|
+
* `assert`: an unclassified refusal spent the whole retry policy, and `x jobs retry`, re-proving it.
|
|
362
|
+
*/
|
|
363
|
+
function tenantOf(definition: WebhookDefinition): JobTenant<WebhookDeliveryInput> {
|
|
364
|
+
const declared = definition.tenant;
|
|
365
|
+
if (typeof declared !== 'function') return declared;
|
|
366
|
+
return (input) => {
|
|
367
|
+
const orgId = declared(input);
|
|
368
|
+
if (typeof orgId === 'string' && orgId.length > 0) return orgId;
|
|
369
|
+
throw new JobTenantMismatchError({
|
|
370
|
+
job: definition.name,
|
|
371
|
+
reason: `declares an org tenant, and this delivery's payload names no org for it${input.orgId === undefined ? ' (it carries no orgId)' : ''}`,
|
|
372
|
+
fix: `${definition.name}.enqueue({ endpointId, eventId, orgId }) — re-enqueue it with the org that owns the endpoint; a queued row cannot be retried into having one`,
|
|
373
|
+
});
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* The org a seam is HANDED is the org the run is UNDER, or the delivery is refused before either
|
|
379
|
+
* seam reads: a lookup filtered by `orgId` would otherwise read another org's endpoint, secret and
|
|
380
|
+
* all, inside this org's run. With `tenant: 'none'` nothing would check it at all, so any `orgId`
|
|
381
|
+
* is refused there. Terminal for `tenantOf`'s reason — the payload is the same on every attempt.
|
|
382
|
+
*/
|
|
383
|
+
function assertInputOrg(name: string, orgId: string | undefined, ctx: Ctx): void {
|
|
384
|
+
if (orgId === undefined) return;
|
|
385
|
+
const runOrg = ctx.actor.orgId ?? undefined;
|
|
386
|
+
if (orgId === runOrg) return;
|
|
387
|
+
throw new JobTenantMismatchError({
|
|
388
|
+
job: name,
|
|
389
|
+
reason: `was handed orgId ${orgId}, but the run is under ${runOrg === undefined ? "tenant 'none'" : `org ${runOrg}`}`,
|
|
390
|
+
fix: `webhook({ name: '${name}', tenant: ({ orgId }) => orgId, … }) — derive the tenant from the payload's own org, or enqueue ${name} with the orgId its tenant resolves to`,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
326
394
|
/** A URL no delivery may open, or a secret that would make the POST unsigned. The parsed url. */
|
|
327
395
|
function assertDeliverable(name: string, endpoint: WebhookEndpoint): URL {
|
|
328
396
|
if (endpoint.secret.length === 0) {
|