@things-factory/webhook 10.1.203 → 10.1.206
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/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/service/inbound/batch-judgement.d.ts +69 -0
- package/dist-server/service/inbound/batch-judgement.js +77 -0
- package/dist-server/service/inbound/batch-judgement.js.map +1 -0
- package/dist-server/service/inbound/receive-signed-batch.d.ts +25 -0
- package/dist-server/service/inbound/receive-signed-batch.js +80 -0
- package/dist-server/service/inbound/receive-signed-batch.js.map +1 -0
- package/dist-server/service/inbound/signed-request.d.ts +59 -0
- package/dist-server/service/inbound/signed-request.js +48 -0
- package/dist-server/service/inbound/signed-request.js.map +1 -0
- package/dist-server/service/index.d.ts +6 -1
- package/dist-server/service/index.js +10 -1
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/webhook-receive-cursor/webhook-receive-cursor.d.ts +16 -0
- package/dist-server/service/webhook-receive-cursor/webhook-receive-cursor.js +68 -0
- package/dist-server/service/webhook-receive-cursor/webhook-receive-cursor.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { WebhookIntakeRefusal, WebhookRejection, WebhookResponse } from '@operato/ops-contract';
|
|
2
|
+
/**
|
|
3
|
+
* **What a numbered batch lets the receiver take** - and what it answers, in the contract's words.
|
|
4
|
+
*
|
|
5
|
+
* ═════════════════════════════════════════════════════════════════════════
|
|
6
|
+
* The sender (`delivery.ts` `buildBatch`) numbers each envelope on its stream and the receiver keeps one
|
|
7
|
+
* cursor per stream. The rules are the contract's and are not restated here: `checkSequenceRun` takes the
|
|
8
|
+
* envelopes up to the first hole, a number already seen is a resend (`behind`), and a hole is answered with
|
|
9
|
+
* 409 and the number to resend from (ADR-0041, ADR-0078 decision 4).
|
|
10
|
+
*
|
|
11
|
+
* So the order of facts on one stream is the sender's order, and the receiver never sorts or compares
|
|
12
|
+
* times to decide which fact is newer: a later number is a later fact. A resend changes nothing, a gap
|
|
13
|
+
* holds everything behind it until the hole is filled, and a lost reply is a resend.
|
|
14
|
+
*
|
|
15
|
+
* Pure. The store and the door are in `receive-signed-batch.ts`.
|
|
16
|
+
* ═════════════════════════════════════════════════════════════════════════
|
|
17
|
+
*/
|
|
18
|
+
export interface ReceivedEnvelope {
|
|
19
|
+
seq: number;
|
|
20
|
+
scope: string;
|
|
21
|
+
eventId: string;
|
|
22
|
+
eventTime?: string;
|
|
23
|
+
recordTime?: string;
|
|
24
|
+
record: unknown;
|
|
25
|
+
}
|
|
26
|
+
export type BatchReading = {
|
|
27
|
+
kind: 'invalid';
|
|
28
|
+
reason: string;
|
|
29
|
+
} | {
|
|
30
|
+
kind: 'run';
|
|
31
|
+
scope: string;
|
|
32
|
+
/** Envelopes with numbers above the cursor and before any hole - the ones to apply, in order. */
|
|
33
|
+
take: ReceivedEnvelope[];
|
|
34
|
+
/** Envelopes at or below the cursor - already applied once; they change nothing. */
|
|
35
|
+
resent: number;
|
|
36
|
+
/** Where the cursor stands once `take` is applied. */
|
|
37
|
+
lastSeq: number;
|
|
38
|
+
/** A hole: everything from here was not taken. */
|
|
39
|
+
expectedSeq?: number;
|
|
40
|
+
/** First envelope of a stream this receiver had never seen: how many numbers below it never arrived. */
|
|
41
|
+
firstUnseenBefore?: number;
|
|
42
|
+
};
|
|
43
|
+
/** Reads the body a sender posts (`{ batch: [...] }`). A body that is not that shape is refused whole. */
|
|
44
|
+
export declare function readBatch(body: unknown, cursor: number | undefined): BatchReading;
|
|
45
|
+
/** What applying the taken envelopes did - the receiving application says. */
|
|
46
|
+
export interface Applied {
|
|
47
|
+
applied: number;
|
|
48
|
+
duplicates: number;
|
|
49
|
+
rejected: WebhookRejection[];
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The reply, in the contract's shape (`WebhookResponse`): 200 when everything taken went in, 422 when some
|
|
53
|
+
* were refused for their shape, 409 when a hole stopped the batch - with `lastSeq` up to what did go in, so
|
|
54
|
+
* the sender marks those and resends from `expectedSeq`.
|
|
55
|
+
*
|
|
56
|
+
* `offered = applied + duplicates + rejected` closes on the 200 and the 422; resent envelopes count as
|
|
57
|
+
* duplicates, since they were applied the first time.
|
|
58
|
+
*/
|
|
59
|
+
export declare function batchReply(reading: Extract<BatchReading, {
|
|
60
|
+
kind: 'run';
|
|
61
|
+
}>, applied: Applied): {
|
|
62
|
+
status: number;
|
|
63
|
+
body: WebhookResponse;
|
|
64
|
+
};
|
|
65
|
+
/** A body that is not a batch: 400 - resending the same bytes will be refused again. */
|
|
66
|
+
export declare function invalidBatchReply(reason: string): {
|
|
67
|
+
status: number;
|
|
68
|
+
body: WebhookIntakeRefusal;
|
|
69
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.readBatch = readBatch;
|
|
4
|
+
exports.batchReply = batchReply;
|
|
5
|
+
exports.invalidBatchReply = invalidBatchReply;
|
|
6
|
+
const ops_contract_1 = require("@operato/ops-contract");
|
|
7
|
+
/** Reads the body a sender posts (`{ batch: [...] }`). A body that is not that shape is refused whole. */
|
|
8
|
+
function readBatch(body, cursor) {
|
|
9
|
+
const batch = body?.batch;
|
|
10
|
+
if (!Array.isArray(batch) || !batch.length)
|
|
11
|
+
return { kind: 'invalid', reason: 'the body carries no batch of envelopes' };
|
|
12
|
+
const envelopes = [];
|
|
13
|
+
for (const one of batch) {
|
|
14
|
+
const e = one;
|
|
15
|
+
if (!Number.isInteger(e?.seq) || e.seq < 1)
|
|
16
|
+
return { kind: 'invalid', reason: `an envelope carries no usable number: ${JSON.stringify(e?.seq)}` };
|
|
17
|
+
if (typeof e.scope !== 'string' || !e.scope)
|
|
18
|
+
return { kind: 'invalid', reason: 'an envelope does not name its numbering unit (scope)' };
|
|
19
|
+
if (typeof e.eventId !== 'string' || !e.eventId)
|
|
20
|
+
return { kind: 'invalid', reason: `envelope ${e.seq} carries no event id` };
|
|
21
|
+
envelopes.push(e);
|
|
22
|
+
}
|
|
23
|
+
/* One cursor per stream: a batch mixing streams would advance one cursor with another's numbers. */
|
|
24
|
+
const scopes = new Set(envelopes.map(e => e.scope));
|
|
25
|
+
if (scopes.size > 1)
|
|
26
|
+
return { kind: 'invalid', reason: `one batch names ${scopes.size} numbering units: ${[...scopes].join(', ')}` };
|
|
27
|
+
const scope = envelopes[0].scope;
|
|
28
|
+
const fresh = envelopes.filter(e => cursor === undefined || e.seq > cursor);
|
|
29
|
+
const resent = envelopes.length - fresh.length;
|
|
30
|
+
if (!fresh.length)
|
|
31
|
+
return { kind: 'run', scope, take: [], resent, lastSeq: cursor };
|
|
32
|
+
const first = (0, ops_contract_1.checkSequence)(cursor, fresh[0].seq);
|
|
33
|
+
const run = (0, ops_contract_1.checkSequenceRun)(cursor, fresh.map(e => e.seq));
|
|
34
|
+
const take = fresh.slice(0, run.accepted);
|
|
35
|
+
return {
|
|
36
|
+
kind: 'run',
|
|
37
|
+
scope,
|
|
38
|
+
take,
|
|
39
|
+
resent,
|
|
40
|
+
lastSeq: run.lastSeq,
|
|
41
|
+
...(run.gap ? { expectedSeq: run.gap.expectedSeq } : {}),
|
|
42
|
+
...(first.kind === 'first' && first.unseenBefore > 0 ? { firstUnseenBefore: first.unseenBefore } : {})
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The reply, in the contract's shape (`WebhookResponse`): 200 when everything taken went in, 422 when some
|
|
47
|
+
* were refused for their shape, 409 when a hole stopped the batch - with `lastSeq` up to what did go in, so
|
|
48
|
+
* the sender marks those and resends from `expectedSeq`.
|
|
49
|
+
*
|
|
50
|
+
* `offered = applied + duplicates + rejected` closes on the 200 and the 422; resent envelopes count as
|
|
51
|
+
* duplicates, since they were applied the first time.
|
|
52
|
+
*/
|
|
53
|
+
function batchReply(reading, applied) {
|
|
54
|
+
const common = {
|
|
55
|
+
scope: reading.scope,
|
|
56
|
+
sequenceChecked: true,
|
|
57
|
+
lastSeq: reading.lastSeq,
|
|
58
|
+
...(reading.firstUnseenBefore !== undefined ? { firstUnseenBefore: reading.firstUnseenBefore } : {})
|
|
59
|
+
};
|
|
60
|
+
if (reading.expectedSeq !== undefined) {
|
|
61
|
+
return { status: ops_contract_1.WEBHOOK_STATUS.sequenceGap, body: { ok: false, error: `numbers are missing: resend from ${reading.expectedSeq}`, ...common, expectedSeq: reading.expectedSeq } };
|
|
62
|
+
}
|
|
63
|
+
const counted = {
|
|
64
|
+
offered: reading.take.length + reading.resent,
|
|
65
|
+
applied: applied.applied,
|
|
66
|
+
duplicates: applied.duplicates + reading.resent
|
|
67
|
+
};
|
|
68
|
+
if (applied.rejected.length) {
|
|
69
|
+
return { status: ops_contract_1.WEBHOOK_STATUS.partial, body: { ok: false, error: `${applied.rejected.length} record(s) were refused`, ...common, ...counted, rejected: applied.rejected } };
|
|
70
|
+
}
|
|
71
|
+
return { status: ops_contract_1.WEBHOOK_STATUS.ok, body: { ok: true, ...common, ...counted } };
|
|
72
|
+
}
|
|
73
|
+
/** A body that is not a batch: 400 - resending the same bytes will be refused again. */
|
|
74
|
+
function invalidBatchReply(reason) {
|
|
75
|
+
return { status: ops_contract_1.WEBHOOK_STATUS.badPayload, body: { code: ops_contract_1.WEBHOOK_DELIVERY_CODES.envelopeInvalid, error: reason } };
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=batch-judgement.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"batch-judgement.js","sourceRoot":"","sources":["../../../server/service/inbound/batch-judgement.ts"],"names":[],"mappings":"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","sourcesContent":["import { WEBHOOK_DELIVERY_CODES, WEBHOOK_STATUS, checkSequence, checkSequenceRun } from '@operato/ops-contract'\nimport type { WebhookIntakeRefusal, WebhookRejection, WebhookResponse } from '@operato/ops-contract'\n\n/**\n * **What a numbered batch lets the receiver take** - and what it answers, in the contract's words.\n *\n * ═════════════════════════════════════════════════════════════════════════\n * The sender (`delivery.ts` `buildBatch`) numbers each envelope on its stream and the receiver keeps one\n * cursor per stream. The rules are the contract's and are not restated here: `checkSequenceRun` takes the\n * envelopes up to the first hole, a number already seen is a resend (`behind`), and a hole is answered with\n * 409 and the number to resend from (ADR-0041, ADR-0078 decision 4).\n *\n * So the order of facts on one stream is the sender's order, and the receiver never sorts or compares\n * times to decide which fact is newer: a later number is a later fact. A resend changes nothing, a gap\n * holds everything behind it until the hole is filled, and a lost reply is a resend.\n *\n * Pure. The store and the door are in `receive-signed-batch.ts`.\n * ═════════════════════════════════════════════════════════════════════════\n */\nexport interface ReceivedEnvelope {\n seq: number\n scope: string\n eventId: string\n eventTime?: string\n recordTime?: string\n record: unknown\n}\n\nexport type BatchReading =\n | { kind: 'invalid'; reason: string }\n | {\n kind: 'run'\n scope: string\n /** Envelopes with numbers above the cursor and before any hole - the ones to apply, in order. */\n take: ReceivedEnvelope[]\n /** Envelopes at or below the cursor - already applied once; they change nothing. */\n resent: number\n /** Where the cursor stands once `take` is applied. */\n lastSeq: number\n /** A hole: everything from here was not taken. */\n expectedSeq?: number\n /** First envelope of a stream this receiver had never seen: how many numbers below it never arrived. */\n firstUnseenBefore?: number\n }\n\n/** Reads the body a sender posts (`{ batch: [...] }`). A body that is not that shape is refused whole. */\nexport function readBatch(body: unknown, cursor: number | undefined): BatchReading {\n const batch = (body as { batch?: unknown })?.batch\n if (!Array.isArray(batch) || !batch.length) return { kind: 'invalid', reason: 'the body carries no batch of envelopes' }\n\n const envelopes: ReceivedEnvelope[] = []\n for (const one of batch) {\n const e = one as Partial<ReceivedEnvelope>\n if (!Number.isInteger(e?.seq) || (e.seq as number) < 1) return { kind: 'invalid', reason: `an envelope carries no usable number: ${JSON.stringify(e?.seq)}` }\n if (typeof e.scope !== 'string' || !e.scope) return { kind: 'invalid', reason: 'an envelope does not name its numbering unit (scope)' }\n if (typeof e.eventId !== 'string' || !e.eventId) return { kind: 'invalid', reason: `envelope ${e.seq} carries no event id` }\n envelopes.push(e as ReceivedEnvelope)\n }\n\n /* One cursor per stream: a batch mixing streams would advance one cursor with another's numbers. */\n const scopes = new Set(envelopes.map(e => e.scope))\n if (scopes.size > 1) return { kind: 'invalid', reason: `one batch names ${scopes.size} numbering units: ${[...scopes].join(', ')}` }\n const scope = envelopes[0].scope\n\n const fresh = envelopes.filter(e => cursor === undefined || e.seq > cursor)\n const resent = envelopes.length - fresh.length\n if (!fresh.length) return { kind: 'run', scope, take: [], resent, lastSeq: cursor as number }\n\n const first = checkSequence(cursor, fresh[0].seq)\n const run = checkSequenceRun(cursor, fresh.map(e => e.seq))\n const take = fresh.slice(0, run.accepted)\n\n return {\n kind: 'run',\n scope,\n take,\n resent,\n lastSeq: run.lastSeq,\n ...(run.gap ? { expectedSeq: run.gap.expectedSeq } : {}),\n ...(first.kind === 'first' && first.unseenBefore > 0 ? { firstUnseenBefore: first.unseenBefore } : {})\n }\n}\n\n/** What applying the taken envelopes did - the receiving application says. */\nexport interface Applied {\n applied: number\n duplicates: number\n rejected: WebhookRejection[]\n}\n\n/**\n * The reply, in the contract's shape (`WebhookResponse`): 200 when everything taken went in, 422 when some\n * were refused for their shape, 409 when a hole stopped the batch - with `lastSeq` up to what did go in, so\n * the sender marks those and resends from `expectedSeq`.\n *\n * `offered = applied + duplicates + rejected` closes on the 200 and the 422; resent envelopes count as\n * duplicates, since they were applied the first time.\n */\nexport function batchReply(reading: Extract<BatchReading, { kind: 'run' }>, applied: Applied): { status: number; body: WebhookResponse } {\n const common = {\n scope: reading.scope,\n sequenceChecked: true,\n lastSeq: reading.lastSeq,\n ...(reading.firstUnseenBefore !== undefined ? { firstUnseenBefore: reading.firstUnseenBefore } : {})\n }\n\n if (reading.expectedSeq !== undefined) {\n return { status: WEBHOOK_STATUS.sequenceGap, body: { ok: false, error: `numbers are missing: resend from ${reading.expectedSeq}`, ...common, expectedSeq: reading.expectedSeq } }\n }\n\n const counted = {\n offered: reading.take.length + reading.resent,\n applied: applied.applied,\n duplicates: applied.duplicates + reading.resent\n }\n\n if (applied.rejected.length) {\n return { status: WEBHOOK_STATUS.partial, body: { ok: false, error: `${applied.rejected.length} record(s) were refused`, ...common, ...counted, rejected: applied.rejected } }\n }\n\n return { status: WEBHOOK_STATUS.ok, body: { ok: true, ...common, ...counted } }\n}\n\n/** A body that is not a batch: 400 - resending the same bytes will be refused again. */\nexport function invalidBatchReply(reason: string): { status: number; body: WebhookIntakeRefusal } {\n return { status: WEBHOOK_STATUS.badPayload, body: { code: WEBHOOK_DELIVERY_CODES.envelopeInvalid, error: reason } }\n}\n"]}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { EntityManager } from 'typeorm';
|
|
2
|
+
import { type Applied, type ReceivedEnvelope } from './batch-judgement';
|
|
3
|
+
/**
|
|
4
|
+
* **A door that takes a numbered batch a peer pushes** - signature, site, cursor, apply, reply, in one
|
|
5
|
+
* transaction per batch.
|
|
6
|
+
*
|
|
7
|
+
* The application gives what only it knows: how to find its site, and what one envelope means
|
|
8
|
+
* (`apply`). The module does the rest the same way for every receiving door (ADR-0078 decision 1). The
|
|
9
|
+
* cursor moves in the same transaction as what `apply` writes, so a crash leaves both or neither - the
|
|
10
|
+
* sender then resends and the cursor says which part is new.
|
|
11
|
+
*/
|
|
12
|
+
export interface ReceiveDoors {
|
|
13
|
+
findSite(siteId: string): Promise<{
|
|
14
|
+
id: string;
|
|
15
|
+
} | null | undefined>;
|
|
16
|
+
/** Run `work` in one transaction. */
|
|
17
|
+
transaction<T>(work: (tx: EntityManager) => Promise<T>): Promise<T>;
|
|
18
|
+
/** Applies the taken envelopes, in number order, inside the batch's transaction. */
|
|
19
|
+
apply(tx: EntityManager, site: {
|
|
20
|
+
id: string;
|
|
21
|
+
}, envelopes: ReceivedEnvelope[], peer: string): Promise<Applied>;
|
|
22
|
+
/** For the refusal sentence: what this door takes. */
|
|
23
|
+
takes: string;
|
|
24
|
+
}
|
|
25
|
+
export declare function receiveSignedBatch(context: any, peer: string, doors: ReceiveDoors, env?: Record<string, string | undefined>): Promise<void>;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.receiveSignedBatch = receiveSignedBatch;
|
|
4
|
+
const ops_contract_1 = require("@operato/ops-contract");
|
|
5
|
+
const webhook_1 = require("@operato/ops-contract/webhook");
|
|
6
|
+
const env_1 = require("@things-factory/env");
|
|
7
|
+
const shell_1 = require("@things-factory/shell");
|
|
8
|
+
const clock_offset_1 = require("../observability/clock-offset");
|
|
9
|
+
const trace_context_1 = require("../observability/trace-context");
|
|
10
|
+
const webhook_receive_cursor_1 = require("../webhook-receive-cursor/webhook-receive-cursor");
|
|
11
|
+
const batch_judgement_1 = require("./batch-judgement");
|
|
12
|
+
const signed_request_1 = require("./signed-request");
|
|
13
|
+
async function receiveSignedBatch(context, peer, doors, env = process.env) {
|
|
14
|
+
const siteId = String(context.params?.siteId ?? '');
|
|
15
|
+
const timestamp = context.get(webhook_1.WEBHOOK_HEADER.timestamp);
|
|
16
|
+
const nowMs = Date.now();
|
|
17
|
+
/* The two clocks' gap is worth knowing most when the signature fails for time (`clock-offset.ts`). */
|
|
18
|
+
(0, clock_offset_1.noteClockReading)(siteId, (0, clock_offset_1.offsetFrom)(timestamp, nowMs), nowMs);
|
|
19
|
+
const verdict = (0, signed_request_1.verifySignedRequest)({
|
|
20
|
+
env,
|
|
21
|
+
siteId,
|
|
22
|
+
peer,
|
|
23
|
+
timestamp,
|
|
24
|
+
signature: context.get(webhook_1.WEBHOOK_HEADER.signature),
|
|
25
|
+
rawBody: context.request.rawBody ?? JSON.stringify(context.request.body ?? {}),
|
|
26
|
+
nowMs,
|
|
27
|
+
takes: doors.takes
|
|
28
|
+
});
|
|
29
|
+
if (verdict.ok === false) {
|
|
30
|
+
if (verdict.logNote)
|
|
31
|
+
env_1.logger.warn(`[webhook-receive] ${siteId}/${peer} ${doors.takes}: ${verdict.logNote}`);
|
|
32
|
+
context.status = verdict.status;
|
|
33
|
+
context.body = verdict.body;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const trace = (0, trace_context_1.traceFor)(context.get(trace_context_1.TRACEPARENT));
|
|
37
|
+
context.set(trace_context_1.TRACEPARENT, (0, trace_context_1.toTraceparent)(trace));
|
|
38
|
+
const site = await doors.findSite(siteId);
|
|
39
|
+
if (!site) {
|
|
40
|
+
context.status = 404;
|
|
41
|
+
context.body = {
|
|
42
|
+
code: ops_contract_1.WEBHOOK_DELIVERY_CODES.siteUnknown,
|
|
43
|
+
error: `no site named ${siteId} - the path segment is this app's domain subdomain, not the sender's site name`
|
|
44
|
+
};
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const reply = await doors.transaction(async (tx) => {
|
|
49
|
+
const scope = context.request.body?.batch?.[0]?.scope;
|
|
50
|
+
const cursor = typeof scope === 'string' && scope ? await lockedCursor(tx, site.id, peer, scope) : null;
|
|
51
|
+
const reading = (0, batch_judgement_1.readBatch)(context.request.body, cursor ? Number(cursor.lastSeq) : undefined);
|
|
52
|
+
if (reading.kind === 'invalid')
|
|
53
|
+
return (0, batch_judgement_1.invalidBatchReply)(reading.reason);
|
|
54
|
+
const applied = reading.take.length ? await doors.apply(tx, site, reading.take, peer) : { applied: 0, duplicates: 0, rejected: [] };
|
|
55
|
+
if (reading.take.length) {
|
|
56
|
+
const repository = tx.getRepository(webhook_receive_cursor_1.WebhookReceiveCursor);
|
|
57
|
+
await repository.save({
|
|
58
|
+
...(cursor ?? { domain: { id: site.id }, peer, scope: reading.scope }),
|
|
59
|
+
lastSeq: reading.lastSeq,
|
|
60
|
+
...(!cursor && reading.firstUnseenBefore !== undefined ? { firstUnseenBefore: reading.firstUnseenBefore } : {})
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return (0, batch_judgement_1.batchReply)(reading, applied);
|
|
64
|
+
});
|
|
65
|
+
context.status = reply.status;
|
|
66
|
+
context.body = reply.body;
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
env_1.logger.warn(`[webhook-receive] ${siteId}/${peer} ${doors.takes} ${(0, trace_context_1.traceTag)(trace)} failed - ${String(error).slice(0, 200)}`);
|
|
70
|
+
context.status = 500;
|
|
71
|
+
context.body = { code: ops_contract_1.WEBHOOK_DELIVERY_CODES.peerInternalFailure, error: `could not store this batch`, detail: String(error).slice(0, 200) };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async function lockedCursor(tx, domainId, peer, scope) {
|
|
75
|
+
return await tx.getRepository(webhook_receive_cursor_1.WebhookReceiveCursor).findOne({
|
|
76
|
+
where: { domain: { id: domainId }, peer, scope },
|
|
77
|
+
...((0, shell_1.writeLockSupported)(tx) ? { lock: { mode: 'pessimistic_write' } } : {})
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
//# sourceMappingURL=receive-signed-batch.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"receive-signed-batch.js","sourceRoot":"","sources":["../../../server/service/inbound/receive-signed-batch.ts"],"names":[],"mappings":"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","sourcesContent":["import type { EntityManager } from 'typeorm'\n\nimport { WEBHOOK_DELIVERY_CODES } from '@operato/ops-contract'\nimport { WEBHOOK_HEADER } from '@operato/ops-contract/webhook'\nimport { logger } from '@things-factory/env'\nimport { writeLockSupported } from '@things-factory/shell'\n\nimport { noteClockReading, offsetFrom } from '../observability/clock-offset'\nimport { TRACEPARENT, toTraceparent, traceFor, traceTag } from '../observability/trace-context'\nimport { WebhookReceiveCursor } from '../webhook-receive-cursor/webhook-receive-cursor'\nimport { type Applied, type ReceivedEnvelope, batchReply, invalidBatchReply, readBatch } from './batch-judgement'\nimport { verifySignedRequest } from './signed-request'\n\n/**\n * **A door that takes a numbered batch a peer pushes** - signature, site, cursor, apply, reply, in one\n * transaction per batch.\n *\n * The application gives what only it knows: how to find its site, and what one envelope means\n * (`apply`). The module does the rest the same way for every receiving door (ADR-0078 decision 1). The\n * cursor moves in the same transaction as what `apply` writes, so a crash leaves both or neither - the\n * sender then resends and the cursor says which part is new.\n */\nexport interface ReceiveDoors {\n findSite(siteId: string): Promise<{ id: string } | null | undefined>\n /** Run `work` in one transaction. */\n transaction<T>(work: (tx: EntityManager) => Promise<T>): Promise<T>\n /** Applies the taken envelopes, in number order, inside the batch's transaction. */\n apply(tx: EntityManager, site: { id: string }, envelopes: ReceivedEnvelope[], peer: string): Promise<Applied>\n /** For the refusal sentence: what this door takes. */\n takes: string\n}\n\nexport async function receiveSignedBatch(context: any, peer: string, doors: ReceiveDoors, env: Record<string, string | undefined> = process.env): Promise<void> {\n const siteId = String(context.params?.siteId ?? '')\n const timestamp = context.get(WEBHOOK_HEADER.timestamp)\n const nowMs = Date.now()\n\n /* The two clocks' gap is worth knowing most when the signature fails for time (`clock-offset.ts`). */\n noteClockReading(siteId, offsetFrom(timestamp, nowMs), nowMs)\n\n const verdict = verifySignedRequest({\n env,\n siteId,\n peer,\n timestamp,\n signature: context.get(WEBHOOK_HEADER.signature),\n rawBody: context.request.rawBody ?? JSON.stringify(context.request.body ?? {}),\n nowMs,\n takes: doors.takes\n })\n if (verdict.ok === false) {\n if (verdict.logNote) logger.warn(`[webhook-receive] ${siteId}/${peer} ${doors.takes}: ${verdict.logNote}`)\n context.status = verdict.status\n context.body = verdict.body\n return\n }\n\n const trace = traceFor(context.get(TRACEPARENT))\n context.set(TRACEPARENT, toTraceparent(trace))\n\n const site = await doors.findSite(siteId)\n if (!site) {\n context.status = 404\n context.body = {\n code: WEBHOOK_DELIVERY_CODES.siteUnknown,\n error: `no site named ${siteId} - the path segment is this app's domain subdomain, not the sender's site name`\n }\n return\n }\n\n try {\n const reply = await doors.transaction(async tx => {\n const scope = (context.request.body as { batch?: { scope?: unknown }[] })?.batch?.[0]?.scope\n const cursor = typeof scope === 'string' && scope ? await lockedCursor(tx, site.id, peer, scope) : null\n const reading = readBatch(context.request.body, cursor ? Number(cursor.lastSeq) : undefined)\n if (reading.kind === 'invalid') return invalidBatchReply(reading.reason)\n\n const applied = reading.take.length ? await doors.apply(tx, site, reading.take, peer) : { applied: 0, duplicates: 0, rejected: [] }\n\n if (reading.take.length) {\n const repository = tx.getRepository(WebhookReceiveCursor)\n await repository.save({\n ...(cursor ?? { domain: { id: site.id } as any, peer, scope: reading.scope }),\n lastSeq: reading.lastSeq,\n ...(!cursor && reading.firstUnseenBefore !== undefined ? { firstUnseenBefore: reading.firstUnseenBefore } : {})\n })\n }\n\n return batchReply(reading, applied)\n })\n\n context.status = reply.status\n context.body = reply.body\n } catch (error) {\n logger.warn(`[webhook-receive] ${siteId}/${peer} ${doors.takes} ${traceTag(trace)} failed - ${String(error).slice(0, 200)}`)\n context.status = 500\n context.body = { code: WEBHOOK_DELIVERY_CODES.peerInternalFailure, error: `could not store this batch`, detail: String(error).slice(0, 200) }\n }\n}\n\nasync function lockedCursor(tx: EntityManager, domainId: string, peer: string, scope: string): Promise<WebhookReceiveCursor | null> {\n return await tx.getRepository(WebhookReceiveCursor).findOne({\n where: { domain: { id: domainId }, peer, scope },\n ...(writeLockSupported(tx) ? { lock: { mode: 'pessimistic_write' as const } } : {})\n })\n}\n"]}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { WebhookIntakeRefusal } from '@operato/ops-contract';
|
|
2
|
+
/**
|
|
3
|
+
* **Whether a pushed request may be read at all** - the key, the headers and the signature, judged before
|
|
4
|
+
* anything is looked up or stored.
|
|
5
|
+
*
|
|
6
|
+
* ═════════════════════════════════════════════════════════════════════════
|
|
7
|
+
* ## One judgement for every door that receives
|
|
8
|
+
*
|
|
9
|
+
* operato-plant's job-order door was the only receiving door and held this judgement in its own files
|
|
10
|
+
* (`peer-secret.ts`, `intake-receive.ts`). A second door - the warehouse taking lot status from the plant
|
|
11
|
+
* (ADR-0080 decision 3) - needs exactly the same answer, so the judgement moves here and both read it
|
|
12
|
+
* (ADR-0078 decision 1: the machine is one, the doors are the applications').
|
|
13
|
+
*
|
|
14
|
+
* ## 503, 401 and 404 are three different people's problems
|
|
15
|
+
*
|
|
16
|
+
* ```
|
|
17
|
+
* 503 we hold no key for that peer we fix it
|
|
18
|
+
* 401 the signature or the time is wrong the sender fixes it
|
|
19
|
+
* 401 the signing headers are missing the sender's code fixes it (envelope-invalid, not signature-rejected)
|
|
20
|
+
* ```
|
|
21
|
+
* The site lookup (404) comes after this, because it touches the store.
|
|
22
|
+
*
|
|
23
|
+
* ## The key is chosen by the name **we** call the peer
|
|
24
|
+
*
|
|
25
|
+
* `WEBHOOK_VERIFY_SECRET__<our site>__<peer>` and nothing wider: a wider key would let a peer that signs
|
|
26
|
+
* correctly land its envelope on another peer's address, and the address is what says whose it is. The
|
|
27
|
+
* contract no longer offers a wider rung (ADR-0046 1). A name that the derivation cannot form is "no key",
|
|
28
|
+
* not an exception - a name's shape is never a 500.
|
|
29
|
+
*
|
|
30
|
+
* ## The variable's name goes to the log, never to the reply
|
|
31
|
+
*
|
|
32
|
+
* A reply naming the variable tells a request that proved nothing how keys are chosen and which sites
|
|
33
|
+
* exist. `logNote` carries it for the operator; the reply carries only the code and the peer.
|
|
34
|
+
* ═════════════════════════════════════════════════════════════════════════
|
|
35
|
+
*/
|
|
36
|
+
export interface SignedRequest {
|
|
37
|
+
env: Record<string, string | undefined>;
|
|
38
|
+
/** Our site, as the address names it (the domain's subdomain). */
|
|
39
|
+
siteId: string;
|
|
40
|
+
/** What we call the sender - the address segment, chosen by us. */
|
|
41
|
+
peer: string;
|
|
42
|
+
timestamp?: string;
|
|
43
|
+
signature?: string;
|
|
44
|
+
/** The exact bytes that were signed. */
|
|
45
|
+
rawBody: string;
|
|
46
|
+
nowMs: number;
|
|
47
|
+
/** What the refusal says the door does not take yet, for a person reading it. */
|
|
48
|
+
takes?: string;
|
|
49
|
+
}
|
|
50
|
+
export type SignedRequestVerdict = {
|
|
51
|
+
ok: true;
|
|
52
|
+
keyName: string;
|
|
53
|
+
} | {
|
|
54
|
+
ok: false;
|
|
55
|
+
status: 401 | 503;
|
|
56
|
+
body: WebhookIntakeRefusal;
|
|
57
|
+
logNote?: string;
|
|
58
|
+
};
|
|
59
|
+
export declare function verifySignedRequest(request: SignedRequest): SignedRequestVerdict;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.verifySignedRequest = verifySignedRequest;
|
|
4
|
+
const ops_contract_1 = require("@operato/ops-contract");
|
|
5
|
+
const webhook_1 = require("@operato/ops-contract/webhook");
|
|
6
|
+
function verifySignedRequest(request) {
|
|
7
|
+
const { siteId, peer } = request;
|
|
8
|
+
if (!(0, ops_contract_1.isWebhookSecretSegment)(siteId) || !(0, ops_contract_1.isWebhookSecretSegment)(peer)) {
|
|
9
|
+
return refusedForKey(peer, request.takes, `the name is not of the derivable shape - lower-case letters, digits and single inner hyphens (site=${siteId} peer=${peer})`);
|
|
10
|
+
}
|
|
11
|
+
const lookup = { purpose: 'verify', domain: siteId, peer };
|
|
12
|
+
const found = (0, ops_contract_1.webhookSecretFrom)(request.env, lookup);
|
|
13
|
+
if (!found.secret)
|
|
14
|
+
return refusedForKey(peer, request.takes, (0, ops_contract_1.webhookSecretMissingNote)((0, ops_contract_1.webhookSecretCandidates)(lookup)));
|
|
15
|
+
if (!request.timestamp || !request.signature) {
|
|
16
|
+
return {
|
|
17
|
+
ok: false,
|
|
18
|
+
status: 401,
|
|
19
|
+
body: { code: ops_contract_1.WEBHOOK_DELIVERY_CODES.envelopeInvalid, error: `${webhook_1.WEBHOOK_HEADER.timestamp} and ${webhook_1.WEBHOOK_HEADER.signature} are required` }
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
const verdict = (0, webhook_1.verifyWebhookSignature)({
|
|
23
|
+
secret: found.secret,
|
|
24
|
+
timestamp: request.timestamp,
|
|
25
|
+
signature: request.signature,
|
|
26
|
+
body: request.rawBody,
|
|
27
|
+
nowMs: request.nowMs
|
|
28
|
+
});
|
|
29
|
+
if (!verdict.ok) {
|
|
30
|
+
const reason = verdict.reason;
|
|
31
|
+
return {
|
|
32
|
+
ok: false,
|
|
33
|
+
status: 401,
|
|
34
|
+
body: { code: ops_contract_1.WEBHOOK_DELIVERY_CODES.signatureRejected, params: { reason }, error: `webhook rejected: ${reason}` }
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
return { ok: true, keyName: found.opened?.name ?? '' };
|
|
38
|
+
}
|
|
39
|
+
function refusedForKey(peer, takes, logNote) {
|
|
40
|
+
return {
|
|
41
|
+
ok: false,
|
|
42
|
+
status: 503,
|
|
43
|
+
/* The peer is our name for the sender. The variable name is never here - it goes to the log. */
|
|
44
|
+
body: { code: ops_contract_1.WEBHOOK_DELIVERY_CODES.verifySecretMissing, params: { peer }, error: `this site does not accept ${takes ?? 'pushes'} yet` },
|
|
45
|
+
logNote
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=signed-request.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"signed-request.js","sourceRoot":"","sources":["../../../server/service/inbound/signed-request.ts"],"names":[],"mappings":";;AAyDA,kDAwCC;AAjGD,wDAA4J;AAE5J,2DAAsF;AAuDtF,SAAgB,mBAAmB,CAAC,OAAsB;IACxD,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,OAAO,CAAA;IAEhC,IAAI,CAAC,IAAA,qCAAsB,EAAC,MAAM,CAAC,IAAI,CAAC,IAAA,qCAAsB,EAAC,IAAI,CAAC,EAAE,CAAC;QACrE,OAAO,aAAa,CAClB,IAAI,EACJ,OAAO,CAAC,KAAK,EACb,sGAAsG,MAAM,SAAS,IAAI,GAAG,CAC7H,CAAA;IACH,CAAC;IAED,MAAM,MAAM,GAAG,EAAE,OAAO,EAAE,QAAiB,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,CAAA;IACnE,MAAM,KAAK,GAAG,IAAA,gCAAiB,EAAC,OAAO,CAAC,GAAwB,EAAE,MAAM,CAAC,CAAA;IACzE,IAAI,CAAC,KAAK,CAAC,MAAM;QAAE,OAAO,aAAa,CAAC,IAAI,EAAE,OAAO,CAAC,KAAK,EAAE,IAAA,uCAAwB,EAAC,IAAA,sCAAuB,EAAC,MAAM,CAAC,CAAC,CAAC,CAAA;IAEvH,IAAI,CAAC,OAAO,CAAC,SAAS,IAAI,CAAC,OAAO,CAAC,SAAS,EAAE,CAAC;QAC7C,OAAO;YACL,EAAE,EAAE,KAAK;YACT,MAAM,EAAE,GAAG;YACX,IAAI,EAAE,EAAE,IAAI,EAAE,qCAAsB,CAAC,eAAe,EAAE,KAAK,EAAE,GAAG,wBAAc,CAAC,SAAS,QAAQ,wBAAc,CAAC,SAAS,eAAe,EAAE;SAC1I,CAAA;IACH,CAAC;IAED,MAAM,OAAO,GAAG,IAAA,gCAAsB,EAAC;QACrC,MAAM,EAAE,KAAK,CAAC,MAAM;QACpB,SAAS,EAAE,OAAO,CAAC,SAAS;QAC5B,SAAS,EAAE,OAAO,CAAC,SAAS;QAC5B,IAAI,EAAE,OAAO,CAAC,OAAO;QACrB,KAAK,EAAE,OAAO,CAAC,KAAK;KACrB,CAAC,CAAA;IACF,IAAI,CAAC,OAAO,CAAC,EAAE,EAAE,CAAC;QAChB,MAAM,MAAM,GAAI,OAA8B,CAAC,MAAM,CAAA;QACrD,OAAO;YACL,EAAE,EAAE,KAAK;YACT,MAAM,EAAE,GAAG;YACX,IAAI,EAAE,EAAE,IAAI,EAAE,qCAAsB,CAAC,iBAAiB,EAAE,MAAM,EAAE,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,qBAAqB,MAAM,EAAE,EAAE;SACnH,CAAA;IACH,CAAC;IAED,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,OAAO,EAAG,KAAuC,CAAC,MAAM,EAAE,IAAI,IAAI,EAAE,EAAE,CAAA;AAC3F,CAAC;AAED,SAAS,aAAa,CAAC,IAAY,EAAE,KAAyB,EAAE,OAAe;IAC7E,OAAO;QACL,EAAE,EAAE,KAAK;QACT,MAAM,EAAE,GAAG;QACX,gGAAgG;QAChG,IAAI,EAAE,EAAE,IAAI,EAAE,qCAAsB,CAAC,mBAAmB,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,6BAA6B,KAAK,IAAI,QAAQ,MAAM,EAAE;QACzI,OAAO;KACR,CAAA;AACH,CAAC","sourcesContent":["import { WEBHOOK_DELIVERY_CODES, isWebhookSecretSegment, webhookSecretCandidates, webhookSecretFrom, webhookSecretMissingNote } from '@operato/ops-contract'\nimport type { WebhookIntakeRefusal } from '@operato/ops-contract'\nimport { WEBHOOK_HEADER, verifyWebhookSignature } from '@operato/ops-contract/webhook'\n\n/**\n * **Whether a pushed request may be read at all** - the key, the headers and the signature, judged before\n * anything is looked up or stored.\n *\n * ═════════════════════════════════════════════════════════════════════════\n * ## One judgement for every door that receives\n *\n * operato-plant's job-order door was the only receiving door and held this judgement in its own files\n * (`peer-secret.ts`, `intake-receive.ts`). A second door - the warehouse taking lot status from the plant\n * (ADR-0080 decision 3) - needs exactly the same answer, so the judgement moves here and both read it\n * (ADR-0078 decision 1: the machine is one, the doors are the applications').\n *\n * ## 503, 401 and 404 are three different people's problems\n *\n * ```\n * 503 we hold no key for that peer we fix it\n * 401 the signature or the time is wrong the sender fixes it\n * 401 the signing headers are missing the sender's code fixes it (envelope-invalid, not signature-rejected)\n * ```\n * The site lookup (404) comes after this, because it touches the store.\n *\n * ## The key is chosen by the name **we** call the peer\n *\n * `WEBHOOK_VERIFY_SECRET__<our site>__<peer>` and nothing wider: a wider key would let a peer that signs\n * correctly land its envelope on another peer's address, and the address is what says whose it is. The\n * contract no longer offers a wider rung (ADR-0046 1). A name that the derivation cannot form is \"no key\",\n * not an exception - a name's shape is never a 500.\n *\n * ## The variable's name goes to the log, never to the reply\n *\n * A reply naming the variable tells a request that proved nothing how keys are chosen and which sites\n * exist. `logNote` carries it for the operator; the reply carries only the code and the peer.\n * ═════════════════════════════════════════════════════════════════════════\n */\nexport interface SignedRequest {\n env: Record<string, string | undefined>\n /** Our site, as the address names it (the domain's subdomain). */\n siteId: string\n /** What we call the sender - the address segment, chosen by us. */\n peer: string\n timestamp?: string\n signature?: string\n /** The exact bytes that were signed. */\n rawBody: string\n nowMs: number\n /** What the refusal says the door does not take yet, for a person reading it. */\n takes?: string\n}\n\nexport type SignedRequestVerdict =\n | { ok: true; keyName: string }\n | { ok: false; status: 401 | 503; body: WebhookIntakeRefusal; logNote?: string }\n\nexport function verifySignedRequest(request: SignedRequest): SignedRequestVerdict {\n const { siteId, peer } = request\n\n if (!isWebhookSecretSegment(siteId) || !isWebhookSecretSegment(peer)) {\n return refusedForKey(\n peer,\n request.takes,\n `the name is not of the derivable shape - lower-case letters, digits and single inner hyphens (site=${siteId} peer=${peer})`\n )\n }\n\n const lookup = { purpose: 'verify' as const, domain: siteId, peer }\n const found = webhookSecretFrom(request.env as NodeJS.ProcessEnv, lookup)\n if (!found.secret) return refusedForKey(peer, request.takes, webhookSecretMissingNote(webhookSecretCandidates(lookup)))\n\n if (!request.timestamp || !request.signature) {\n return {\n ok: false,\n status: 401,\n body: { code: WEBHOOK_DELIVERY_CODES.envelopeInvalid, error: `${WEBHOOK_HEADER.timestamp} and ${WEBHOOK_HEADER.signature} are required` }\n }\n }\n\n const verdict = verifyWebhookSignature({\n secret: found.secret,\n timestamp: request.timestamp,\n signature: request.signature,\n body: request.rawBody,\n nowMs: request.nowMs\n })\n if (!verdict.ok) {\n const reason = (verdict as { reason: string }).reason\n return {\n ok: false,\n status: 401,\n body: { code: WEBHOOK_DELIVERY_CODES.signatureRejected, params: { reason }, error: `webhook rejected: ${reason}` }\n }\n }\n\n return { ok: true, keyName: (found as { opened?: { name: string } }).opened?.name ?? '' }\n}\n\nfunction refusedForKey(peer: string, takes: string | undefined, logNote: string): SignedRequestVerdict {\n return {\n ok: false,\n status: 503,\n /* The peer is our name for the sender. The variable name is never here - it goes to the log. */\n body: { code: WEBHOOK_DELIVERY_CODES.verifySecretMissing, params: { peer }, error: `this site does not accept ${takes ?? 'pushes'} yet` },\n logNote\n }\n}\n"]}
|
|
@@ -3,12 +3,14 @@ import { WebhookDestinationResolver } from './webhook-destination/webhook-destin
|
|
|
3
3
|
import { WebhookOutboxEvent } from './webhook-outbox-event/webhook-outbox-event';
|
|
4
4
|
import { WebhookOutboxResolver } from './webhook-outbox-event/webhook-outbox-resolver';
|
|
5
5
|
import { WebhookSequence } from './webhook-sequence/webhook-sequence';
|
|
6
|
+
import { WebhookReceiveCursor } from './webhook-receive-cursor/webhook-receive-cursor';
|
|
6
7
|
export * from './webhook-destination/webhook-destination';
|
|
7
8
|
export * from './webhook-destination/webhook-destination-resolver';
|
|
8
9
|
export * from './webhook-outbox-event/webhook-outbox-event';
|
|
9
10
|
export * from './webhook-outbox-event/webhook-outbox-resolver';
|
|
10
11
|
export * from './webhook-sequence/webhook-sequence';
|
|
11
|
-
export
|
|
12
|
+
export * from './webhook-receive-cursor/webhook-receive-cursor';
|
|
13
|
+
export declare const entities: (typeof WebhookDestination | typeof WebhookOutboxEvent | typeof WebhookSequence | typeof WebhookReceiveCursor)[];
|
|
12
14
|
export declare const schema: {
|
|
13
15
|
resolverClasses: (typeof WebhookDestinationResolver | typeof WebhookOutboxResolver)[];
|
|
14
16
|
};
|
|
@@ -32,3 +34,6 @@ export * from './outbox/webhook-append-store';
|
|
|
32
34
|
export * from './outbox/pump-round';
|
|
33
35
|
export * from './outbox/pump';
|
|
34
36
|
export * from './outbox/pump-loop';
|
|
37
|
+
export * from './inbound/signed-request';
|
|
38
|
+
export * from './inbound/batch-judgement';
|
|
39
|
+
export * from './inbound/receive-signed-batch';
|
|
@@ -16,18 +16,20 @@ const webhook_destination_resolver_1 = require("./webhook-destination/webhook-de
|
|
|
16
16
|
const webhook_outbox_event_1 = require("./webhook-outbox-event/webhook-outbox-event");
|
|
17
17
|
const webhook_outbox_resolver_1 = require("./webhook-outbox-event/webhook-outbox-resolver");
|
|
18
18
|
const webhook_sequence_1 = require("./webhook-sequence/webhook-sequence");
|
|
19
|
+
const webhook_receive_cursor_1 = require("./webhook-receive-cursor/webhook-receive-cursor");
|
|
19
20
|
tslib_1.__exportStar(require("./webhook-destination/webhook-destination"), exports);
|
|
20
21
|
tslib_1.__exportStar(require("./webhook-destination/webhook-destination-resolver"), exports);
|
|
21
22
|
tslib_1.__exportStar(require("./webhook-outbox-event/webhook-outbox-event"), exports);
|
|
22
23
|
tslib_1.__exportStar(require("./webhook-outbox-event/webhook-outbox-resolver"), exports);
|
|
23
24
|
tslib_1.__exportStar(require("./webhook-sequence/webhook-sequence"), exports);
|
|
25
|
+
tslib_1.__exportStar(require("./webhook-receive-cursor/webhook-receive-cursor"), exports);
|
|
24
26
|
/*
|
|
25
27
|
* ⚠ **The shell builds the database and the schema from these two names.** Without them the module's
|
|
26
28
|
* tables are never created in any application and the destination door is in no schema - the files stand
|
|
27
29
|
* and nothing reaches them. That was the state through 10.1.37 (found 2026-09-24 by the first application to
|
|
28
30
|
* append: its database had no webhook table at all). `test/append-store-db.test.ts` holds this line.
|
|
29
31
|
*/
|
|
30
|
-
exports.entities = [webhook_destination_1.WebhookDestination, webhook_outbox_event_1.WebhookOutboxEvent, webhook_sequence_1.WebhookSequence];
|
|
32
|
+
exports.entities = [webhook_destination_1.WebhookDestination, webhook_outbox_event_1.WebhookOutboxEvent, webhook_sequence_1.WebhookSequence, webhook_receive_cursor_1.WebhookReceiveCursor];
|
|
31
33
|
exports.schema = { resolverClasses: [webhook_destination_resolver_1.WebhookDestinationResolver, webhook_outbox_resolver_1.WebhookOutboxResolver] };
|
|
32
34
|
/*
|
|
33
35
|
* The outbox machine, moved from operato-plant (ADR-0078 decision 2). Step ①: the pure judgement —
|
|
@@ -66,4 +68,11 @@ tslib_1.__exportStar(require("./outbox/webhook-append-store"), exports);
|
|
|
66
68
|
tslib_1.__exportStar(require("./outbox/pump-round"), exports);
|
|
67
69
|
tslib_1.__exportStar(require("./outbox/pump"), exports);
|
|
68
70
|
tslib_1.__exportStar(require("./outbox/pump-loop"), exports);
|
|
71
|
+
/*
|
|
72
|
+
* The receiving side (ADR-0080 decision 3 is its first door): whether a pushed request may be read, what a
|
|
73
|
+
* numbered batch lets the receiver take, and the door that does both and keeps the cursor.
|
|
74
|
+
*/
|
|
75
|
+
tslib_1.__exportStar(require("./inbound/signed-request"), exports);
|
|
76
|
+
tslib_1.__exportStar(require("./inbound/batch-judgement"), exports);
|
|
77
|
+
tslib_1.__exportStar(require("./inbound/receive-signed-batch"), exports);
|
|
69
78
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../server/service/index.ts"],"names":[],"mappings":";;;;AAAA;;;;;;;;GAQG;AACH,mFAA8E;AAC9E,qGAA+F;AAC/F,sFAAgF;AAChF,4FAAsF;AACtF,0EAAqE;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../server/service/index.ts"],"names":[],"mappings":";;;;AAAA;;;;;;;;GAQG;AACH,mFAA8E;AAC9E,qGAA+F;AAC/F,sFAAgF;AAChF,4FAAsF;AACtF,0EAAqE;AACrE,4FAAsF;AAEtF,oFAAyD;AACzD,6FAAkE;AAClE,sFAA2D;AAC3D,yFAA8D;AAC9D,8EAAmD;AACnD,0FAA+D;AAE/D;;;;;GAKG;AACU,QAAA,QAAQ,GAAG,CAAC,wCAAkB,EAAE,yCAAkB,EAAE,kCAAe,EAAE,6CAAoB,CAAC,CAAA;AAE1F,QAAA,MAAM,GAAG,EAAE,eAAe,EAAE,CAAC,yDAA0B,EAAE,+CAAqB,CAAC,EAAE,CAAA;AAE9F;;;;GAIG;AACH,4DAAiC;AACjC,8DAAmC;AACnC,8DAAmC;AACnC,8DAAmC;AACnC,kEAAuC;AACvC,gEAAqC;AACrC,4DAAiC;AACjC,gEAAqC;AACrC,wDAA6B;AAC7B,uEAA4C;AAE5C;;;GAGG;AACH,0DAA+B;AAE/B;+EAC+E;AAC/E,uEAA4C;AAC5C,wEAA6C;AAE7C,6GAA6G;AAC7G,yDAA8B;AAC9B,8DAAmC;AACnC,iEAAsC;AACtC,wEAA6C;AAE7C;;;;GAIG;AACH,8DAAmC;AACnC,wDAA6B;AAC7B,6DAAkC;AAGlC;;;GAGG;AACH,mEAAwC;AACxC,oEAAyC;AACzC,yEAA8C","sourcesContent":["/*\n * Each source file is named rather than re-exporting folder barrels.\n *\n * A name added to a folder barrel but not listed here stops at the package boundary, and the\n * consumer's error is `TS2305: has no exported member` — which reads like the name does not\n * exist rather than like it is unreachable. That happened on 2026-09-07 with\n * `work-shift/days-of-week`: the parser was in the folder barrel, not here, and the consuming\n * lane wrote its own until the miss was found.\n */\nimport { WebhookDestination } from './webhook-destination/webhook-destination'\nimport { WebhookDestinationResolver } from './webhook-destination/webhook-destination-resolver'\nimport { WebhookOutboxEvent } from './webhook-outbox-event/webhook-outbox-event'\nimport { WebhookOutboxResolver } from './webhook-outbox-event/webhook-outbox-resolver'\nimport { WebhookSequence } from './webhook-sequence/webhook-sequence'\nimport { WebhookReceiveCursor } from './webhook-receive-cursor/webhook-receive-cursor'\n\nexport * from './webhook-destination/webhook-destination'\nexport * from './webhook-destination/webhook-destination-resolver'\nexport * from './webhook-outbox-event/webhook-outbox-event'\nexport * from './webhook-outbox-event/webhook-outbox-resolver'\nexport * from './webhook-sequence/webhook-sequence'\nexport * from './webhook-receive-cursor/webhook-receive-cursor'\n\n/*\n * ⚠ **The shell builds the database and the schema from these two names.** Without them the module's\n * tables are never created in any application and the destination door is in no schema - the files stand\n * and nothing reaches them. That was the state through 10.1.37 (found 2026-09-24 by the first application to\n * append: its database had no webhook table at all). `test/append-store-db.test.ts` holds this line.\n */\nexport const entities = [WebhookDestination, WebhookOutboxEvent, WebhookSequence, WebhookReceiveCursor]\n\nexport const schema = { resolverClasses: [WebhookDestinationResolver, WebhookOutboxResolver] }\n\n/*\n * The outbox machine, moved from operato-plant (ADR-0078 decision 2). Step ①: the pure judgement —\n * envelope, signature, reading the receiver's answer, settlement, what an answer does to our rows,\n * the sequence report, reconciliation, standing. None of these touch a store.\n */\nexport * from './outbox/delivery'\nexport * from './outbox/settlement'\nexport * from './outbox/row-effect'\nexport * from './outbox/seq-report'\nexport * from './outbox/reconciliation'\nexport * from './outbox/landed-probe'\nexport * from './outbox/standing'\nexport * from './outbox/requeue-note'\nexport * from './outbox/page'\nexport * from './outbox/writing-transaction'\n\n/*\n * Step ②: the back row. Writing a fact down takes the caller's open transaction - in the type, not only\n * in a check - because the number and the row have to commit together or the number burns.\n */\nexport * from './outbox/append'\n\n/* What a delivery needs to say \"when\" and \"which request\" — moved from operato-plant with the dispatcher's\n * needs in mind (ADR-0078 decision 1). The inbound side reads the same two. */\nexport * from './observability/clock-offset'\nexport * from './observability/trace-context'\n\n/* Step ②: the back row. One dispatcher over a store interface; `webhookStore` is this module's own store. */\nexport * from './outbox/store'\nexport * from './outbox/dispatcher'\nexport * from './outbox/webhook-store'\nexport * from './outbox/webhook-append-store'\n\n/*\n * The pump: one round's judgement (`pump-round`), one round (`pump`), and the loop an application starts from its\n * boot hook (`pump-loop`). Keys come from the contract's ladder - the product-named `pump-gate` is gone\n * (ADR-0078 decision 5, re-ruled 2026-09-24).\n */\nexport * from './outbox/pump-round'\nexport * from './outbox/pump'\nexport * from './outbox/pump-loop'\n\n\n/*\n * The receiving side (ADR-0080 decision 3 is its first door): whether a pushed request may be read, what a\n * numbered batch lets the receiver take, and the door that does both and keeps the cursor.\n */\nexport * from './inbound/signed-request'\nexport * from './inbound/batch-judgement'\nexport * from './inbound/receive-signed-batch'\n"]}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Domain } from '@things-factory/shell';
|
|
2
|
+
export declare class WebhookReceiveCursor {
|
|
3
|
+
readonly id: string;
|
|
4
|
+
domain: Domain;
|
|
5
|
+
domainId: string;
|
|
6
|
+
peer: string;
|
|
7
|
+
scope: string;
|
|
8
|
+
lastSeq: number;
|
|
9
|
+
/**
|
|
10
|
+
* How many numbers below the first envelope this site ever took were never received - taken once, when
|
|
11
|
+
* the stream began here. Not a loss count: a receiver attached after the sender started legitimately
|
|
12
|
+
* begins late, and only the sender knows which case it is.
|
|
13
|
+
*/
|
|
14
|
+
firstUnseenBefore?: number;
|
|
15
|
+
updatedAt: Date;
|
|
16
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.WebhookReceiveCursor = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const typeorm_1 = require("typeorm");
|
|
6
|
+
const type_graphql_1 = require("type-graphql");
|
|
7
|
+
const shell_1 = require("@things-factory/shell");
|
|
8
|
+
/*
|
|
9
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
10
|
+
* The last number this site took on one stream a peer pushes to it - the receiving twin of
|
|
11
|
+
* `WebhookSequence`.
|
|
12
|
+
*
|
|
13
|
+
* One row per (domain, peer, scope): the peer is what **we** call the sender (the address segment), the
|
|
14
|
+
* scope is the sender's numbering unit as the envelope names it. Moving forward only - a resend is below it
|
|
15
|
+
* and changes nothing, a hole above it holds the batch until the sender fills it (ADR-0041).
|
|
16
|
+
*
|
|
17
|
+
* This row is the receiver's own fact. The sender keeps the peer's reply in `WebhookSequence.peerLastSeq`;
|
|
18
|
+
* the two are the two ends of one stream.
|
|
19
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
20
|
+
*/
|
|
21
|
+
let WebhookReceiveCursor = class WebhookReceiveCursor {
|
|
22
|
+
};
|
|
23
|
+
exports.WebhookReceiveCursor = WebhookReceiveCursor;
|
|
24
|
+
tslib_1.__decorate([
|
|
25
|
+
(0, typeorm_1.PrimaryGeneratedColumn)('uuid'),
|
|
26
|
+
(0, type_graphql_1.Field)(type => type_graphql_1.ID),
|
|
27
|
+
tslib_1.__metadata("design:type", String)
|
|
28
|
+
], WebhookReceiveCursor.prototype, "id", void 0);
|
|
29
|
+
tslib_1.__decorate([
|
|
30
|
+
(0, typeorm_1.ManyToOne)(type => shell_1.Domain),
|
|
31
|
+
(0, type_graphql_1.Field)(type => shell_1.Domain),
|
|
32
|
+
tslib_1.__metadata("design:type", shell_1.Domain)
|
|
33
|
+
], WebhookReceiveCursor.prototype, "domain", void 0);
|
|
34
|
+
tslib_1.__decorate([
|
|
35
|
+
(0, typeorm_1.RelationId)((cursor) => cursor.domain),
|
|
36
|
+
tslib_1.__metadata("design:type", String)
|
|
37
|
+
], WebhookReceiveCursor.prototype, "domainId", void 0);
|
|
38
|
+
tslib_1.__decorate([
|
|
39
|
+
(0, typeorm_1.Column)(),
|
|
40
|
+
(0, type_graphql_1.Field)({ description: 'What this site calls the sender.' }),
|
|
41
|
+
tslib_1.__metadata("design:type", String)
|
|
42
|
+
], WebhookReceiveCursor.prototype, "peer", void 0);
|
|
43
|
+
tslib_1.__decorate([
|
|
44
|
+
(0, typeorm_1.Column)(),
|
|
45
|
+
(0, type_graphql_1.Field)({ description: 'The sender\'s numbering unit.' }),
|
|
46
|
+
tslib_1.__metadata("design:type", String)
|
|
47
|
+
], WebhookReceiveCursor.prototype, "scope", void 0);
|
|
48
|
+
tslib_1.__decorate([
|
|
49
|
+
(0, typeorm_1.Column)({ type: 'int' }),
|
|
50
|
+
(0, type_graphql_1.Field)(type => type_graphql_1.Int, { description: 'Highest number taken on this stream. Never goes backwards.' }),
|
|
51
|
+
tslib_1.__metadata("design:type", Number)
|
|
52
|
+
], WebhookReceiveCursor.prototype, "lastSeq", void 0);
|
|
53
|
+
tslib_1.__decorate([
|
|
54
|
+
(0, typeorm_1.Column)({ type: 'int', nullable: true }),
|
|
55
|
+
(0, type_graphql_1.Field)(type => type_graphql_1.Int, { nullable: true, description: 'Numbers below the first envelope taken that never arrived, counted once at the start.' }),
|
|
56
|
+
tslib_1.__metadata("design:type", Number)
|
|
57
|
+
], WebhookReceiveCursor.prototype, "firstUnseenBefore", void 0);
|
|
58
|
+
tslib_1.__decorate([
|
|
59
|
+
(0, typeorm_1.UpdateDateColumn)(),
|
|
60
|
+
(0, type_graphql_1.Field)({ nullable: true }),
|
|
61
|
+
tslib_1.__metadata("design:type", Date)
|
|
62
|
+
], WebhookReceiveCursor.prototype, "updatedAt", void 0);
|
|
63
|
+
exports.WebhookReceiveCursor = WebhookReceiveCursor = tslib_1.__decorate([
|
|
64
|
+
(0, type_graphql_1.ObjectType)({ description: 'The last sequence number this site took on one stream a peer pushes to it.' }),
|
|
65
|
+
(0, typeorm_1.Entity)(),
|
|
66
|
+
(0, typeorm_1.Index)('ix_webhook_receive_cursor_0', (cursor) => [cursor.domain, cursor.peer, cursor.scope], { unique: true })
|
|
67
|
+
], WebhookReceiveCursor);
|
|
68
|
+
//# sourceMappingURL=webhook-receive-cursor.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhook-receive-cursor.js","sourceRoot":"","sources":["../../../server/service/webhook-receive-cursor/webhook-receive-cursor.ts"],"names":[],"mappings":";;;;AAAA,qCAAgH;AAChH,+CAAyD;AAEzD,iDAA8C;AAE9C;;;;;;;;;;;;GAYG;AAII,IAAM,oBAAoB,GAA1B,MAAM,oBAAoB;CAoChC,CAAA;AApCY,oDAAoB;AAGtB;IAFR,IAAA,gCAAsB,EAAC,MAAM,CAAC;IAC9B,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,iBAAE,CAAC;;gDACC;AAInB;IAFC,IAAA,mBAAS,EAAC,IAAI,CAAC,EAAE,CAAC,cAAM,CAAC;IACzB,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,cAAM,CAAC;sCACd,cAAM;oDAAA;AAGd;IADC,IAAA,oBAAU,EAAC,CAAC,MAA4B,EAAE,EAAE,CAAC,MAAM,CAAC,MAAM,CAAC;;sDAC5C;AAIhB;IAFC,IAAA,gBAAM,GAAE;IACR,IAAA,oBAAK,EAAC,EAAE,WAAW,EAAE,kCAAkC,EAAE,CAAC;;kDAC/C;AAIZ;IAFC,IAAA,gBAAM,GAAE;IACR,IAAA,oBAAK,EAAC,EAAE,WAAW,EAAE,+BAA+B,EAAE,CAAC;;mDAC3C;AAIb;IAFC,IAAA,gBAAM,EAAC,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC;IACvB,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,kBAAG,EAAE,EAAE,WAAW,EAAE,4DAA4D,EAAE,CAAC;;qDACnF;AASf;IAFC,IAAA,gBAAM,EAAC,EAAE,IAAI,EAAE,KAAK,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IACvC,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,kBAAG,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,uFAAuF,EAAE,CAAC;;+DACnH;AAI1B;IAFC,IAAA,0BAAgB,GAAE;IAClB,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;sCACf,IAAI;uDAAA;+BAnCJ,oBAAoB;IAHhC,IAAA,yBAAU,EAAC,EAAE,WAAW,EAAE,4EAA4E,EAAE,CAAC;IACzG,IAAA,gBAAM,GAAE;IACR,IAAA,eAAK,EAAC,6BAA6B,EAAE,CAAC,MAA4B,EAAE,EAAE,CAAC,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,KAAK,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC;GACxH,oBAAoB,CAoChC","sourcesContent":["import { Column, Entity, Index, ManyToOne, PrimaryGeneratedColumn, RelationId, UpdateDateColumn } from 'typeorm'\nimport { Field, ID, Int, ObjectType } from 'type-graphql'\n\nimport { Domain } from '@things-factory/shell'\n\n/*\n * ═══════════════════════════════════════════════════════════════════════════\n * The last number this site took on one stream a peer pushes to it - the receiving twin of\n * `WebhookSequence`.\n *\n * One row per (domain, peer, scope): the peer is what **we** call the sender (the address segment), the\n * scope is the sender's numbering unit as the envelope names it. Moving forward only - a resend is below it\n * and changes nothing, a hole above it holds the batch until the sender fills it (ADR-0041).\n *\n * This row is the receiver's own fact. The sender keeps the peer's reply in `WebhookSequence.peerLastSeq`;\n * the two are the two ends of one stream.\n * ═══════════════════════════════════════════════════════════════════════════\n */\n@ObjectType({ description: 'The last sequence number this site took on one stream a peer pushes to it.' })\n@Entity()\n@Index('ix_webhook_receive_cursor_0', (cursor: WebhookReceiveCursor) => [cursor.domain, cursor.peer, cursor.scope], { unique: true })\nexport class WebhookReceiveCursor {\n @PrimaryGeneratedColumn('uuid')\n @Field(type => ID)\n readonly id: string\n\n @ManyToOne(type => Domain)\n @Field(type => Domain)\n domain: Domain\n\n @RelationId((cursor: WebhookReceiveCursor) => cursor.domain)\n domainId: string\n\n @Column()\n @Field({ description: 'What this site calls the sender.' })\n peer: string\n\n @Column()\n @Field({ description: 'The sender\\'s numbering unit.' })\n scope: string\n\n @Column({ type: 'int' })\n @Field(type => Int, { description: 'Highest number taken on this stream. Never goes backwards.' })\n lastSeq: number\n\n /**\n * How many numbers below the first envelope this site ever took were never received - taken once, when\n * the stream began here. Not a loss count: a receiver attached after the sender started legitimately\n * begins late, and only the sender knows which case it is.\n */\n @Column({ type: 'int', nullable: true })\n @Field(type => Int, { nullable: true, description: 'Numbers below the first envelope taken that never arrived, counted once at the start.' })\n firstUnseenBefore?: number\n\n @UpdateDateColumn()\n @Field({ nullable: true })\n updatedAt: Date\n}\n"]}
|