@things-factory/webhook 10.1.4

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.
Files changed (39) hide show
  1. package/dist-client/bootstrap.d.ts +1 -0
  2. package/dist-client/bootstrap.js +2 -0
  3. package/dist-client/bootstrap.js.map +1 -0
  4. package/dist-client/index.d.ts +1 -0
  5. package/dist-client/index.js +2 -0
  6. package/dist-client/index.js.map +1 -0
  7. package/dist-client/pages/webhook-destinations-page.d.ts +20 -0
  8. package/dist-client/pages/webhook-destinations-page.js +228 -0
  9. package/dist-client/pages/webhook-destinations-page.js.map +1 -0
  10. package/dist-client/route.d.ts +1 -0
  11. package/dist-client/route.js +8 -0
  12. package/dist-client/route.js.map +1 -0
  13. package/dist-client/tsconfig.tsbuildinfo +1 -0
  14. package/dist-server/index.d.ts +1 -0
  15. package/dist-server/index.js +5 -0
  16. package/dist-server/index.js.map +1 -0
  17. package/dist-server/service/index.d.ts +4 -0
  18. package/dist-server/service/index.js +17 -0
  19. package/dist-server/service/index.js.map +1 -0
  20. package/dist-server/service/webhook-destination/webhook-destination-resolver.d.ts +22 -0
  21. package/dist-server/service/webhook-destination/webhook-destination-resolver.js +130 -0
  22. package/dist-server/service/webhook-destination/webhook-destination-resolver.js.map +1 -0
  23. package/dist-server/service/webhook-destination/webhook-destination.d.ts +57 -0
  24. package/dist-server/service/webhook-destination/webhook-destination.js +118 -0
  25. package/dist-server/service/webhook-destination/webhook-destination.js.map +1 -0
  26. package/dist-server/service/webhook-outbox-event/webhook-outbox-event.d.ts +57 -0
  27. package/dist-server/service/webhook-outbox-event/webhook-outbox-event.js +166 -0
  28. package/dist-server/service/webhook-outbox-event/webhook-outbox-event.js.map +1 -0
  29. package/dist-server/service/webhook-sequence/webhook-sequence.d.ts +31 -0
  30. package/dist-server/service/webhook-sequence/webhook-sequence.js +82 -0
  31. package/dist-server/service/webhook-sequence/webhook-sequence.js.map +1 -0
  32. package/dist-server/tsconfig.tsbuildinfo +1 -0
  33. package/package.json +39 -0
  34. package/things-factory.config.js +13 -0
  35. package/translations/en.json +11 -0
  36. package/translations/ja.json +11 -0
  37. package/translations/ko.json +11 -0
  38. package/translations/ms.json +11 -0
  39. package/translations/zh.json +11 -0
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const tslib_1 = require("tslib");
4
+ /*
5
+ * Each source file is named rather than re-exporting folder barrels.
6
+ *
7
+ * A name added to a folder barrel but not listed here stops at the package boundary, and the
8
+ * consumer's error is `TS2305: has no exported member` — which reads like the name does not
9
+ * exist rather than like it is unreachable. That happened on 2026-09-07 with
10
+ * `work-shift/days-of-week`: the parser was in the folder barrel, not here, and the consuming
11
+ * lane wrote its own until the miss was found.
12
+ */
13
+ tslib_1.__exportStar(require("./webhook-destination/webhook-destination"), exports);
14
+ tslib_1.__exportStar(require("./webhook-destination/webhook-destination-resolver"), exports);
15
+ tslib_1.__exportStar(require("./webhook-outbox-event/webhook-outbox-event"), exports);
16
+ tslib_1.__exportStar(require("./webhook-sequence/webhook-sequence"), exports);
17
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../server/service/index.ts"],"names":[],"mappings":";;;AAAA;;;;;;;;GAQG;AACH,oFAAyD;AACzD,6FAAkE;AAClE,sFAA2D;AAC3D,8EAAmD","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 */\nexport * from './webhook-destination/webhook-destination'\nexport * from './webhook-destination/webhook-destination-resolver'\nexport * from './webhook-outbox-event/webhook-outbox-event'\nexport * from './webhook-sequence/webhook-sequence'\n"]}
@@ -0,0 +1,22 @@
1
+ import { WebhookDestination } from './webhook-destination';
2
+ export declare class WebhookDestinationResolver {
3
+ webhookDestinations(context: ResolverContext): Promise<WebhookDestination[]>;
4
+ webhookDestination(peer: string, context: ResolverContext): Promise<WebhookDestination | null>;
5
+ /**
6
+ * Create or update one peer's destination.
7
+ *
8
+ * `patch` is merged field by field so a caller may send only what changed. A screen that
9
+ * has to send every field in order to change one either reads the row first — and writes
10
+ * back a value that moved in between — or blanks the fields it did not ask about.
11
+ */
12
+ saveWebhookDestination(peer: string, patch: any, context: ResolverContext): Promise<WebhookDestination>;
13
+ /**
14
+ * Stop pushing to a peer without forgetting it.
15
+ *
16
+ * Deliberately not a delete: the numbering and the queue are keyed by peer, and removing
17
+ * the row would leave both orphaned. A destination that is turned off resumes where it
18
+ * left off; one that is deleted starts over, and the peer reads that as a stream of
19
+ * numbers it has already seen.
20
+ */
21
+ setWebhookDestinationActive(peer: string, active: boolean, context: ResolverContext): Promise<boolean>;
22
+ }
@@ -0,0 +1,130 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WebhookDestinationResolver = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const type_graphql_1 = require("type-graphql");
6
+ const shell_1 = require("@things-factory/shell");
7
+ const webhook_destination_1 = require("./webhook-destination");
8
+ /*
9
+ * ═══════════════════════════════════════════════════════════════════════════
10
+ * Reading and writing the destinations of one tenant.
11
+ *
12
+ * ── A list, not "the" destination ──────────────────────────────────────────
13
+ * The screen this replaces asked for one row (`UNIQUE(domain_id)`), so its query took no
14
+ * arguments and its editor had no way to say which peer it was editing. That holds exactly
15
+ * until a tenant mirrors into two twins, or pushes to a twin and an ERP — and then the second
16
+ * peer cannot be entered at all (ADR-0041).
17
+ *
18
+ * ── Secrets are not read or written here ───────────────────────────────────
19
+ * Signing and verifying keys come from the environment, per peer. A mutation that accepted
20
+ * them would put them in a request body, a resolver log and an audit row; a query that
21
+ * returned them would hand them to anyone who can open the settings screen.
22
+ *
23
+ * What the screen may say about a key is whether one is *present* — that is a fact about
24
+ * configuration, not the value.
25
+ * ═══════════════════════════════════════════════════════════════════════════
26
+ */
27
+ let WebhookDestinationResolver = class WebhookDestinationResolver {
28
+ async webhookDestinations(context) {
29
+ return await (0, shell_1.getRepository)(webhook_destination_1.WebhookDestination).find({
30
+ where: { domain: { id: context.state.domain.id } },
31
+ order: { peer: 'ASC' }
32
+ });
33
+ }
34
+ async webhookDestination(peer, context) {
35
+ return await (0, shell_1.getRepository)(webhook_destination_1.WebhookDestination).findOne({
36
+ where: { domain: { id: context.state.domain.id }, peer }
37
+ });
38
+ }
39
+ /**
40
+ * Create or update one peer's destination.
41
+ *
42
+ * `patch` is merged field by field so a caller may send only what changed. A screen that
43
+ * has to send every field in order to change one either reads the row first — and writes
44
+ * back a value that moved in between — or blanks the fields it did not ask about.
45
+ */
46
+ async saveWebhookDestination(peer, patch, context) {
47
+ const domainId = context.state.domain.id;
48
+ const repo = (0, shell_1.getRepository)(webhook_destination_1.WebhookDestination);
49
+ const existing = await repo.findOne({ where: { domain: { id: domainId }, peer } });
50
+ /*
51
+ * `params` merges by key for the same reason the patch does — a peer's options are added
52
+ * to over time, and replacing the whole object drops the ones this screen version does
53
+ * not know about.
54
+ */
55
+ const params = patch.params
56
+ ? { ...(existing?.params ?? {}), ...patch.params }
57
+ : existing?.params;
58
+ return await repo.save(repo.create({
59
+ ...(existing ?? {}),
60
+ domain: { id: domainId },
61
+ peer,
62
+ endpoint: patch.endpoint ?? existing?.endpoint,
63
+ description: patch.description ?? existing?.description,
64
+ params,
65
+ /*
66
+ * `active` is a decision, so `false` has to be writable — `??` rather than `||`,
67
+ * or turning a peer off would silently do nothing.
68
+ */
69
+ active: patch.active ?? existing?.active ?? true,
70
+ creator: existing?.creator ?? context.state.user,
71
+ updater: context.state.user
72
+ }));
73
+ }
74
+ /**
75
+ * Stop pushing to a peer without forgetting it.
76
+ *
77
+ * Deliberately not a delete: the numbering and the queue are keyed by peer, and removing
78
+ * the row would leave both orphaned. A destination that is turned off resumes where it
79
+ * left off; one that is deleted starts over, and the peer reads that as a stream of
80
+ * numbers it has already seen.
81
+ */
82
+ async setWebhookDestinationActive(peer, active, context) {
83
+ const result = await (0, shell_1.getRepository)(webhook_destination_1.WebhookDestination).update({ domain: { id: context.state.domain.id }, peer }, { active, updater: context.state.user });
84
+ return !!result.affected;
85
+ }
86
+ };
87
+ exports.WebhookDestinationResolver = WebhookDestinationResolver;
88
+ tslib_1.__decorate([
89
+ (0, type_graphql_1.Directive)('@privilege(category: "webhook", privilege: "query", domainOwnerGranted: true, superUserGranted: true)'),
90
+ (0, type_graphql_1.Query)(returns => [webhook_destination_1.WebhookDestination], {
91
+ description: 'Every peer this tenant pushes facts to. Secrets are never included — only whether a key is present.'
92
+ }),
93
+ tslib_1.__param(0, (0, type_graphql_1.Ctx)()),
94
+ tslib_1.__metadata("design:type", Function),
95
+ tslib_1.__metadata("design:paramtypes", [Object]),
96
+ tslib_1.__metadata("design:returntype", Promise)
97
+ ], WebhookDestinationResolver.prototype, "webhookDestinations", null);
98
+ tslib_1.__decorate([
99
+ (0, type_graphql_1.Directive)('@privilege(category: "webhook", privilege: "query", domainOwnerGranted: true, superUserGranted: true)'),
100
+ (0, type_graphql_1.Query)(returns => webhook_destination_1.WebhookDestination, { nullable: true, description: 'One peer\'s destination, or null if this tenant has none by that name.' }),
101
+ tslib_1.__param(0, (0, type_graphql_1.Arg)('peer')),
102
+ tslib_1.__param(1, (0, type_graphql_1.Ctx)()),
103
+ tslib_1.__metadata("design:type", Function),
104
+ tslib_1.__metadata("design:paramtypes", [String, Object]),
105
+ tslib_1.__metadata("design:returntype", Promise)
106
+ ], WebhookDestinationResolver.prototype, "webhookDestination", null);
107
+ tslib_1.__decorate([
108
+ (0, type_graphql_1.Directive)('@privilege(category: "webhook", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)'),
109
+ (0, type_graphql_1.Mutation)(returns => webhook_destination_1.WebhookDestination, { description: 'Create or update the destination for one peer. Fields absent from the patch keep their stored value.' }),
110
+ tslib_1.__param(0, (0, type_graphql_1.Arg)('peer')),
111
+ tslib_1.__param(1, (0, type_graphql_1.Arg)('patch', type => shell_1.ScalarObject)),
112
+ tslib_1.__param(2, (0, type_graphql_1.Ctx)()),
113
+ tslib_1.__metadata("design:type", Function),
114
+ tslib_1.__metadata("design:paramtypes", [String, Object, Object]),
115
+ tslib_1.__metadata("design:returntype", Promise)
116
+ ], WebhookDestinationResolver.prototype, "saveWebhookDestination", null);
117
+ tslib_1.__decorate([
118
+ (0, type_graphql_1.Directive)('@privilege(category: "webhook", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)'),
119
+ (0, type_graphql_1.Mutation)(returns => Boolean, { description: 'Turn pushing to one peer off or on. The queue and the numbering are kept either way.' }),
120
+ tslib_1.__param(0, (0, type_graphql_1.Arg)('peer')),
121
+ tslib_1.__param(1, (0, type_graphql_1.Arg)('active')),
122
+ tslib_1.__param(2, (0, type_graphql_1.Ctx)()),
123
+ tslib_1.__metadata("design:type", Function),
124
+ tslib_1.__metadata("design:paramtypes", [String, Boolean, Object]),
125
+ tslib_1.__metadata("design:returntype", Promise)
126
+ ], WebhookDestinationResolver.prototype, "setWebhookDestinationActive", null);
127
+ exports.WebhookDestinationResolver = WebhookDestinationResolver = tslib_1.__decorate([
128
+ (0, type_graphql_1.Resolver)()
129
+ ], WebhookDestinationResolver);
130
+ //# sourceMappingURL=webhook-destination-resolver.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webhook-destination-resolver.js","sourceRoot":"","sources":["../../../server/service/webhook-destination/webhook-destination-resolver.ts"],"names":[],"mappings":"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","sourcesContent":["import { Arg, Ctx, Directive, Mutation, Query, Resolver } from 'type-graphql'\n\nimport { getRepository, ScalarObject } from '@things-factory/shell'\n\nimport { WebhookDestination } from './webhook-destination'\n\n/*\n * ═══════════════════════════════════════════════════════════════════════════\n * Reading and writing the destinations of one tenant.\n *\n * ── A list, not \"the\" destination ──────────────────────────────────────────\n * The screen this replaces asked for one row (`UNIQUE(domain_id)`), so its query took no\n * arguments and its editor had no way to say which peer it was editing. That holds exactly\n * until a tenant mirrors into two twins, or pushes to a twin and an ERP — and then the second\n * peer cannot be entered at all (ADR-0041).\n *\n * ── Secrets are not read or written here ───────────────────────────────────\n * Signing and verifying keys come from the environment, per peer. A mutation that accepted\n * them would put them in a request body, a resolver log and an audit row; a query that\n * returned them would hand them to anyone who can open the settings screen.\n *\n * What the screen may say about a key is whether one is *present* — that is a fact about\n * configuration, not the value.\n * ═══════════════════════════════════════════════════════════════════════════\n */\n@Resolver()\nexport class WebhookDestinationResolver {\n @Directive('@privilege(category: \"webhook\", privilege: \"query\", domainOwnerGranted: true, superUserGranted: true)')\n @Query(returns => [WebhookDestination], {\n description: 'Every peer this tenant pushes facts to. Secrets are never included — only whether a key is present.'\n })\n async webhookDestinations(@Ctx() context: ResolverContext): Promise<WebhookDestination[]> {\n return await getRepository(WebhookDestination).find({\n where: { domain: { id: context.state.domain.id } },\n order: { peer: 'ASC' }\n })\n }\n\n @Directive('@privilege(category: \"webhook\", privilege: \"query\", domainOwnerGranted: true, superUserGranted: true)')\n @Query(returns => WebhookDestination, { nullable: true, description: 'One peer\\'s destination, or null if this tenant has none by that name.' })\n async webhookDestination(\n @Arg('peer') peer: string,\n @Ctx() context: ResolverContext\n ): Promise<WebhookDestination | null> {\n return await getRepository(WebhookDestination).findOne({\n where: { domain: { id: context.state.domain.id }, peer }\n })\n }\n\n /**\n * Create or update one peer's destination.\n *\n * `patch` is merged field by field so a caller may send only what changed. A screen that\n * has to send every field in order to change one either reads the row first — and writes\n * back a value that moved in between — or blanks the fields it did not ask about.\n */\n @Directive('@privilege(category: \"webhook\", privilege: \"mutation\", domainOwnerGranted: true, superUserGranted: true)')\n @Mutation(returns => WebhookDestination, { description: 'Create or update the destination for one peer. Fields absent from the patch keep their stored value.' })\n async saveWebhookDestination(\n @Arg('peer') peer: string,\n @Arg('patch', type => ScalarObject) patch: any,\n @Ctx() context: ResolverContext\n ): Promise<WebhookDestination> {\n const domainId = context.state.domain.id\n const repo = getRepository(WebhookDestination)\n const existing = await repo.findOne({ where: { domain: { id: domainId }, peer } })\n\n /*\n * `params` merges by key for the same reason the patch does — a peer's options are added\n * to over time, and replacing the whole object drops the ones this screen version does\n * not know about.\n */\n const params = patch.params\n ? { ...((existing?.params as any) ?? {}), ...patch.params }\n : existing?.params\n\n return await repo.save(\n repo.create({\n ...(existing ?? {}),\n domain: { id: domainId } as any,\n peer,\n endpoint: patch.endpoint ?? existing?.endpoint,\n description: patch.description ?? existing?.description,\n params,\n /*\n * `active` is a decision, so `false` has to be writable — `??` rather than `||`,\n * or turning a peer off would silently do nothing.\n */\n active: patch.active ?? existing?.active ?? true,\n creator: existing?.creator ?? (context.state.user as any),\n updater: context.state.user as any\n })\n )\n }\n\n /**\n * Stop pushing to a peer without forgetting it.\n *\n * Deliberately not a delete: the numbering and the queue are keyed by peer, and removing\n * the row would leave both orphaned. A destination that is turned off resumes where it\n * left off; one that is deleted starts over, and the peer reads that as a stream of\n * numbers it has already seen.\n */\n @Directive('@privilege(category: \"webhook\", privilege: \"mutation\", domainOwnerGranted: true, superUserGranted: true)')\n @Mutation(returns => Boolean, { description: 'Turn pushing to one peer off or on. The queue and the numbering are kept either way.' })\n async setWebhookDestinationActive(\n @Arg('peer') peer: string,\n @Arg('active') active: boolean,\n @Ctx() context: ResolverContext\n ): Promise<boolean> {\n const result = await getRepository(WebhookDestination).update(\n { domain: { id: context.state.domain.id } as any, peer },\n { active, updater: context.state.user as any }\n )\n return !!result.affected\n }\n}\n"]}
@@ -0,0 +1,57 @@
1
+ import { Domain } from '@things-factory/shell';
2
+ import { User } from '@things-factory/auth-base';
3
+ export declare class WebhookDestination {
4
+ readonly id: string;
5
+ domain: Domain;
6
+ domainId: string;
7
+ /**
8
+ * Who we are talking to. **The unit of everything on this seam** — keys, numbering,
9
+ * delivery state and accounting are all per peer.
10
+ *
11
+ * A name, not a kind: `mes-miratek`, `erp-sap`, `twin-b`. Two peers of the same kind are
12
+ * two rows, and that is the point.
13
+ */
14
+ peer: string;
15
+ description: string;
16
+ /** Where the POST goes. */
17
+ endpoint: string;
18
+ /**
19
+ * What the receiving side needs in order to place the envelopes — its own tenant, the
20
+ * instance they belong to, and anything else that peer asks for.
21
+ *
22
+ * Deliberately open: the protocol is fixed by the contract, but what a given peer wants in
23
+ * the path or the headers is that peer's business. `receivingDomain` and `instanceId` live
24
+ * here rather than as columns, because the next peer may want neither.
25
+ *
26
+ * **No secrets here.** Signing and verifying keys come from the environment, per peer
27
+ * (`webhookSecretCandidates`), because that kind of key is one per installation and can be
28
+ * provisioned at deploy time.
29
+ *
30
+ * The column is nonetheless encrypted at rest. The rule above is a declaration, and a
31
+ * declaration does not stop a value arriving — on 2026-09-09 a connection config in the twin
32
+ * was found holding a signing secret and an access token in the clear, in a column whose own
33
+ * comment said credentials belong there. Encrypting this one costs a line and means a mistake
34
+ * is not readable from a database file or a backup. It does not make this the place for keys.
35
+ */
36
+ params: any;
37
+ /**
38
+ * Whether to push at all. **Off is not the same as removed** — a destination that is
39
+ * turned off keeps its numbering and its queue, so turning it back on resumes rather than
40
+ * starts over.
41
+ */
42
+ active: boolean;
43
+ /**
44
+ * What the last attempt learned — reachable or not.
45
+ *
46
+ * Separate from `active` because they answer different questions: `active` is what a person
47
+ * decided, this is what the network said. Folding them together loses the difference
48
+ * between "we stopped pushing" and "we cannot get through".
49
+ */
50
+ state: string;
51
+ createdAt: Date;
52
+ updatedAt: Date;
53
+ creator: User;
54
+ creatorId: string;
55
+ updater: User;
56
+ updaterId: string;
57
+ }
@@ -0,0 +1,118 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WebhookDestination = 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
+ const auth_base_1 = require("@things-factory/auth-base");
9
+ /*
10
+ * ═══════════════════════════════════════════════════════════════════════════
11
+ * Where this tenant pushes facts, and who it is pushing them to.
12
+ *
13
+ * ── The unit is (domain, peer) — three places used three units ─────────────
14
+ * This concept already existed twice in the platform, keyed three different ways
15
+ * (ADR-0041, measured 2026-09-08):
16
+ *
17
+ * operato-plant the destination table UNIQUE(domain_id) per tenant
18
+ * operato-plant the inbound signing key keyed by siteId per site
19
+ * operato-twin the same pair of keys twin_references row per connection
20
+ *
21
+ * None of those survives one tenant talking to two peers. A tenant that mirrors into two
22
+ * twins, or pushes to a twin *and* an ERP, needs a row for each — and each of those rows
23
+ * needs its own keys, its own numbering and its own delivery state.
24
+ *
25
+ * So `peer` is the unit: `mes-miratek`, `erp-sap`, a second twin. It names **who we are
26
+ * talking to**, not what kind of system we are. That distinction is why `erp_postings` and
27
+ * `mes_outbox_events` were two machines in one package — they were two peers, and nothing
28
+ * in the model said so.
29
+ *
30
+ * ── The address is not here ────────────────────────────────────────────────
31
+ * `endpoint` is where the POST goes. The *route* on the receiving side is owned by whichever
32
+ * module opens that door, and it uses the contract's system vocabulary
33
+ * (`DomainSystem = 'wms' | 'yms' | 'mes' | 'ems'`), not a product name — `/webhook/mes/…`
34
+ * stays as it is. A sender writes an address into its own config, so the word in it has to
35
+ * outlive product renames.
36
+ * ═══════════════════════════════════════════════════════════════════════════
37
+ */
38
+ let WebhookDestination = class WebhookDestination {
39
+ };
40
+ exports.WebhookDestination = WebhookDestination;
41
+ tslib_1.__decorate([
42
+ (0, typeorm_1.PrimaryGeneratedColumn)('uuid'),
43
+ (0, type_graphql_1.Field)(type => type_graphql_1.ID, { description: 'Unique identifier for this destination.' }),
44
+ tslib_1.__metadata("design:type", String)
45
+ ], WebhookDestination.prototype, "id", void 0);
46
+ tslib_1.__decorate([
47
+ (0, typeorm_1.ManyToOne)(type => shell_1.Domain),
48
+ (0, type_graphql_1.Field)(type => shell_1.Domain, { description: 'The tenant whose facts go to this peer.' }),
49
+ tslib_1.__metadata("design:type", shell_1.Domain)
50
+ ], WebhookDestination.prototype, "domain", void 0);
51
+ tslib_1.__decorate([
52
+ (0, typeorm_1.RelationId)((dest) => dest.domain),
53
+ tslib_1.__metadata("design:type", String)
54
+ ], WebhookDestination.prototype, "domainId", void 0);
55
+ tslib_1.__decorate([
56
+ (0, typeorm_1.Column)(),
57
+ (0, type_graphql_1.Field)({ description: 'Name of the peer being pushed to (e.g. mes-miratek, erp-sap). The unit of keys, numbering and delivery state.' }),
58
+ tslib_1.__metadata("design:type", String)
59
+ ], WebhookDestination.prototype, "peer", void 0);
60
+ tslib_1.__decorate([
61
+ (0, typeorm_1.Column)({ nullable: true }),
62
+ (0, type_graphql_1.Field)({ nullable: true, description: 'What this destination is for, in a person\'s words.' }),
63
+ tslib_1.__metadata("design:type", String)
64
+ ], WebhookDestination.prototype, "description", void 0);
65
+ tslib_1.__decorate([
66
+ (0, typeorm_1.Column)(),
67
+ (0, type_graphql_1.Field)({ description: 'The URL facts are posted to.' }),
68
+ tslib_1.__metadata("design:type", String)
69
+ ], WebhookDestination.prototype, "endpoint", void 0);
70
+ tslib_1.__decorate([
71
+ (0, typeorm_1.Column)({ nullable: true, ...(0, shell_1.encryptedJsonColumn)() }),
72
+ (0, type_graphql_1.Field)(type => shell_1.ScalarObject, { nullable: true, description: 'Options this peer needs (receiving domain, instance, path parts). Not the place for secrets; encrypted at rest so a mistake is not readable.' }),
73
+ tslib_1.__metadata("design:type", Object)
74
+ ], WebhookDestination.prototype, "params", void 0);
75
+ tslib_1.__decorate([
76
+ (0, typeorm_1.Column)({ default: true }),
77
+ (0, type_graphql_1.Field)({ description: 'Whether facts are being pushed to this peer right now. Turning it off keeps the queue and the numbering.' }),
78
+ tslib_1.__metadata("design:type", Boolean)
79
+ ], WebhookDestination.prototype, "active", void 0);
80
+ tslib_1.__decorate([
81
+ (0, typeorm_1.Column)({ nullable: true }),
82
+ (0, type_graphql_1.Field)({ nullable: true, description: 'What the last delivery attempt learned about reachability. Not the same as `active`, which is a decision.' }),
83
+ tslib_1.__metadata("design:type", String)
84
+ ], WebhookDestination.prototype, "state", void 0);
85
+ tslib_1.__decorate([
86
+ (0, typeorm_1.CreateDateColumn)(),
87
+ (0, type_graphql_1.Field)({ nullable: true }),
88
+ tslib_1.__metadata("design:type", Date)
89
+ ], WebhookDestination.prototype, "createdAt", void 0);
90
+ tslib_1.__decorate([
91
+ (0, typeorm_1.UpdateDateColumn)(),
92
+ (0, type_graphql_1.Field)({ nullable: true }),
93
+ tslib_1.__metadata("design:type", Date)
94
+ ], WebhookDestination.prototype, "updatedAt", void 0);
95
+ tslib_1.__decorate([
96
+ (0, typeorm_1.ManyToOne)(type => auth_base_1.User, { nullable: true }),
97
+ (0, type_graphql_1.Field)(type => auth_base_1.User, { nullable: true }),
98
+ tslib_1.__metadata("design:type", auth_base_1.User)
99
+ ], WebhookDestination.prototype, "creator", void 0);
100
+ tslib_1.__decorate([
101
+ (0, typeorm_1.RelationId)((dest) => dest.creator),
102
+ tslib_1.__metadata("design:type", String)
103
+ ], WebhookDestination.prototype, "creatorId", void 0);
104
+ tslib_1.__decorate([
105
+ (0, typeorm_1.ManyToOne)(type => auth_base_1.User, { nullable: true }),
106
+ (0, type_graphql_1.Field)(type => auth_base_1.User, { nullable: true }),
107
+ tslib_1.__metadata("design:type", auth_base_1.User)
108
+ ], WebhookDestination.prototype, "updater", void 0);
109
+ tslib_1.__decorate([
110
+ (0, typeorm_1.RelationId)((dest) => dest.updater),
111
+ tslib_1.__metadata("design:type", String)
112
+ ], WebhookDestination.prototype, "updaterId", void 0);
113
+ exports.WebhookDestination = WebhookDestination = tslib_1.__decorate([
114
+ (0, type_graphql_1.ObjectType)({ description: 'A peer this tenant pushes facts to, and the address and options to reach it.' }),
115
+ (0, typeorm_1.Entity)(),
116
+ (0, typeorm_1.Index)('ix_webhook_destination_0', (dest) => [dest.domain, dest.peer], { unique: true })
117
+ ], WebhookDestination);
118
+ //# sourceMappingURL=webhook-destination.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webhook-destination.js","sourceRoot":"","sources":["../../../server/service/webhook-destination/webhook-destination.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n Column,\n CreateDateColumn,\n Entity,\n Index,\n ManyToOne,\n PrimaryGeneratedColumn,\n RelationId,\n UpdateDateColumn\n} from 'typeorm'\nimport { Field, ID, ObjectType } from 'type-graphql'\n\nimport { Domain, ScalarObject, encryptedJsonColumn } from '@things-factory/shell'\nimport { User } from '@things-factory/auth-base'\n\n/*\n * ═══════════════════════════════════════════════════════════════════════════\n * Where this tenant pushes facts, and who it is pushing them to.\n *\n * ── The unit is (domain, peer) — three places used three units ─────────────\n * This concept already existed twice in the platform, keyed three different ways\n * (ADR-0041, measured 2026-09-08):\n *\n * operato-plant the destination table UNIQUE(domain_id) per tenant\n * operato-plant the inbound signing key keyed by siteId per site\n * operato-twin the same pair of keys twin_references row per connection\n *\n * None of those survives one tenant talking to two peers. A tenant that mirrors into two\n * twins, or pushes to a twin *and* an ERP, needs a row for each — and each of those rows\n * needs its own keys, its own numbering and its own delivery state.\n *\n * So `peer` is the unit: `mes-miratek`, `erp-sap`, a second twin. It names **who we are\n * talking to**, not what kind of system we are. That distinction is why `erp_postings` and\n * `mes_outbox_events` were two machines in one package — they were two peers, and nothing\n * in the model said so.\n *\n * ── The address is not here ────────────────────────────────────────────────\n * `endpoint` is where the POST goes. The *route* on the receiving side is owned by whichever\n * module opens that door, and it uses the contract's system vocabulary\n * (`DomainSystem = 'wms' | 'yms' | 'mes' | 'ems'`), not a product name — `/webhook/mes/…`\n * stays as it is. A sender writes an address into its own config, so the word in it has to\n * outlive product renames.\n * ═══════════════════════════════════════════════════════════════════════════\n */\n@ObjectType({ description: 'A peer this tenant pushes facts to, and the address and options to reach it.' })\n@Entity()\n@Index('ix_webhook_destination_0', (dest: WebhookDestination) => [dest.domain, dest.peer], { unique: true })\nexport class WebhookDestination {\n @PrimaryGeneratedColumn('uuid')\n @Field(type => ID, { description: 'Unique identifier for this destination.' })\n readonly id: string\n\n @ManyToOne(type => Domain)\n @Field(type => Domain, { description: 'The tenant whose facts go to this peer.' })\n domain: Domain\n\n @RelationId((dest: WebhookDestination) => dest.domain)\n domainId: string\n\n /**\n * Who we are talking to. **The unit of everything on this seam** — keys, numbering,\n * delivery state and accounting are all per peer.\n *\n * A name, not a kind: `mes-miratek`, `erp-sap`, `twin-b`. Two peers of the same kind are\n * two rows, and that is the point.\n */\n @Column()\n @Field({ description: 'Name of the peer being pushed to (e.g. mes-miratek, erp-sap). The unit of keys, numbering and delivery state.' })\n peer: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'What this destination is for, in a person\\'s words.' })\n description: string\n\n /** Where the POST goes. */\n @Column()\n @Field({ description: 'The URL facts are posted to.' })\n endpoint: string\n\n /**\n * What the receiving side needs in order to place the envelopes — its own tenant, the\n * instance they belong to, and anything else that peer asks for.\n *\n * Deliberately open: the protocol is fixed by the contract, but what a given peer wants in\n * the path or the headers is that peer's business. `receivingDomain` and `instanceId` live\n * here rather than as columns, because the next peer may want neither.\n *\n * **No secrets here.** Signing and verifying keys come from the environment, per peer\n * (`webhookSecretCandidates`), because that kind of key is one per installation and can be\n * provisioned at deploy time.\n *\n * The column is nonetheless encrypted at rest. The rule above is a declaration, and a\n * declaration does not stop a value arriving — on 2026-09-09 a connection config in the twin\n * was found holding a signing secret and an access token in the clear, in a column whose own\n * comment said credentials belong there. Encrypting this one costs a line and means a mistake\n * is not readable from a database file or a backup. It does not make this the place for keys.\n */\n @Column({ nullable: true, ...encryptedJsonColumn() })\n @Field(type => ScalarObject, { nullable: true, description: 'Options this peer needs (receiving domain, instance, path parts). Not the place for secrets; encrypted at rest so a mistake is not readable.' })\n params: any\n\n /**\n * Whether to push at all. **Off is not the same as removed** — a destination that is\n * turned off keeps its numbering and its queue, so turning it back on resumes rather than\n * starts over.\n */\n @Column({ default: true })\n @Field({ description: 'Whether facts are being pushed to this peer right now. Turning it off keeps the queue and the numbering.' })\n active: boolean\n\n /**\n * What the last attempt learned — reachable or not.\n *\n * Separate from `active` because they answer different questions: `active` is what a person\n * decided, this is what the network said. Folding them together loses the difference\n * between \"we stopped pushing\" and \"we cannot get through\".\n */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'What the last delivery attempt learned about reachability. Not the same as `active`, which is a decision.' })\n state: string\n\n @CreateDateColumn()\n @Field({ nullable: true })\n createdAt: Date\n\n @UpdateDateColumn()\n @Field({ nullable: true })\n updatedAt: Date\n\n @ManyToOne(type => User, { nullable: true })\n @Field(type => User, { nullable: true })\n creator: User\n\n @RelationId((dest: WebhookDestination) => dest.creator)\n creatorId: string\n\n @ManyToOne(type => User, { nullable: true })\n @Field(type => User, { nullable: true })\n updater: User\n\n @RelationId((dest: WebhookDestination) => dest.updater)\n updaterId: string\n}\n"]}
@@ -0,0 +1,57 @@
1
+ import { Domain } from '@things-factory/shell';
2
+ export declare enum WebhookOutboxStatus {
3
+ /** Waiting to go. The pump picks these up in `seq` order. */
4
+ Pending = "PENDING",
5
+ /** The peer took it. */
6
+ Sent = "SENT",
7
+ /** The peer refused it and resending will not change that — a person has to look. */
8
+ Failed = "FAILED"
9
+ }
10
+ export declare class WebhookOutboxEvent {
11
+ readonly id: string;
12
+ domain: Domain;
13
+ domainId: string;
14
+ /** Who this is going to. Same unit as the destination row. */
15
+ peer: string;
16
+ /**
17
+ * The unit the numbers are counted in — the contract's `WebhookEnvelope.scope`.
18
+ *
19
+ * Without it the receiving side cannot tell which run of numbers a given envelope belongs
20
+ * to, and two streams sharing one cursor answer "a number is missing" forever.
21
+ */
22
+ scope: string;
23
+ /** Increases by one within (domain, peer, scope). A hole here is a lost fact. */
24
+ seq: number;
25
+ /**
26
+ * Idempotency key. The receiving side uses it to recognise a fact it has already taken, so
27
+ * a resend after a lost response does not seat the same fact twice.
28
+ *
29
+ * It also lets a refusal name **which** envelope was refused: the receiver echoes this back
30
+ * in `WebhookResponse.rejected`, and without it the sender can only be told "one of these
31
+ * was refused" (measured 2026-09-07 — a whole batch was marked delivered because the
32
+ * refusal could not be matched to a row).
33
+ */
34
+ eventId: string;
35
+ /** When the fact happened. */
36
+ eventTime: Date;
37
+ /**
38
+ * When we wrote it down. **Kept apart from `eventTime`** — a fact that arrives late is not
39
+ * a fact that happened late, and folding them together makes a backfill look like a
40
+ * present-tense event.
41
+ */
42
+ recordTime: Date;
43
+ /** The fact itself, in whatever shape this product speaks. The only product-specific column. */
44
+ payload: any;
45
+ status: WebhookOutboxStatus;
46
+ sentAt: Date;
47
+ /**
48
+ * How many times a delivery was **really refused** — not how many times we tried.
49
+ *
50
+ * A peer answering 429 or 503 is asking us to wait; that does not count here, or a
51
+ * receiver under load becomes indistinguishable from a receiver that rejects us.
52
+ */
53
+ attempts: number;
54
+ lastError: string;
55
+ createdAt: Date;
56
+ updatedAt: Date;
57
+ }
@@ -0,0 +1,166 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WebhookOutboxEvent = exports.WebhookOutboxStatus = 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
+ * One fact waiting to reach a peer.
11
+ *
12
+ * ── This row is the contract's envelope plus delivery state ────────────────
13
+ * The shape is not invented here. `@operato/ops-contract` already declares what crosses
14
+ * this seam, and the row is that declaration with the delivery columns added:
15
+ *
16
+ * WebhookEnvelope seq · scope · eventId · eventTime · recordTime · record
17
+ * this row seq · scope · eventId · eventTime · recordTime · payload
18
+ * + status · attempts · lastError · sentAt
19
+ *
20
+ * `payload` is the only product-specific column — a production report, a putaway task, a
21
+ * setpoint. Everything else is the same whether the sender is an MES, a WMS, a YMS or an EMS,
22
+ * which is why this table does not carry a product's name (ADR-0041).
23
+ *
24
+ * ── Why `scope` and not `channel` ─────────────────────────────────────────
25
+ * operato-plant called this `channel` and the contract calls it `scope`. Same idea: the unit
26
+ * the numbers are counted in. The contract's word wins, because the receiving side reads
27
+ * `scope` off the envelope to decide which cursor to compare against — a sender using a
28
+ * different word has to translate, and a translation is a place to be wrong.
29
+ *
30
+ * ── Failure that keeps the fact, and failure that gives up ─────────────────
31
+ * A 429 or a 503 is not a failed delivery — it is "not now". Those must not consume the
32
+ * retry budget, and they must not be recorded as attempts, or a receiver asking us to wait
33
+ * looks identical to a receiver refusing us. Measured on 2026-09-08: the sender's
34
+ * backpressure path touched neither `attempts` nor `lastError`, so a loop that re-offered the
35
+ * same batch every five seconds left no trace on either side. The row cannot fix that alone —
36
+ * the pump has to say something out loud — but it is why `attempts` means "tries that were
37
+ * really refused".
38
+ * ═══════════════════════════════════════════════════════════════════════════
39
+ */
40
+ var WebhookOutboxStatus;
41
+ (function (WebhookOutboxStatus) {
42
+ /** Waiting to go. The pump picks these up in `seq` order. */
43
+ WebhookOutboxStatus["Pending"] = "PENDING";
44
+ /** The peer took it. */
45
+ WebhookOutboxStatus["Sent"] = "SENT";
46
+ /** The peer refused it and resending will not change that — a person has to look. */
47
+ WebhookOutboxStatus["Failed"] = "FAILED";
48
+ })(WebhookOutboxStatus || (exports.WebhookOutboxStatus = WebhookOutboxStatus = {}));
49
+ (0, type_graphql_1.registerEnumType)(WebhookOutboxStatus, {
50
+ name: 'WebhookOutboxStatus',
51
+ description: 'Whether a queued fact is waiting, delivered, or refused for good.'
52
+ });
53
+ let WebhookOutboxEvent = class WebhookOutboxEvent {
54
+ };
55
+ exports.WebhookOutboxEvent = WebhookOutboxEvent;
56
+ tslib_1.__decorate([
57
+ (0, typeorm_1.PrimaryGeneratedColumn)('uuid'),
58
+ (0, type_graphql_1.Field)(type => type_graphql_1.ID, { description: 'Unique identifier for this queued fact.' }),
59
+ tslib_1.__metadata("design:type", String)
60
+ ], WebhookOutboxEvent.prototype, "id", void 0);
61
+ tslib_1.__decorate([
62
+ (0, typeorm_1.ManyToOne)(type => shell_1.Domain),
63
+ (0, type_graphql_1.Field)(type => shell_1.Domain, { description: 'The tenant this fact belongs to.' }),
64
+ tslib_1.__metadata("design:type", shell_1.Domain)
65
+ ], WebhookOutboxEvent.prototype, "domain", void 0);
66
+ tslib_1.__decorate([
67
+ (0, typeorm_1.RelationId)((event) => event.domain),
68
+ tslib_1.__metadata("design:type", String)
69
+ ], WebhookOutboxEvent.prototype, "domainId", void 0);
70
+ tslib_1.__decorate([
71
+ (0, typeorm_1.Column)(),
72
+ (0, type_graphql_1.Field)({ description: 'The peer this fact is queued for.' }),
73
+ tslib_1.__metadata("design:type", String)
74
+ ], WebhookOutboxEvent.prototype, "peer", void 0);
75
+ tslib_1.__decorate([
76
+ (0, typeorm_1.Column)(),
77
+ (0, type_graphql_1.Field)({ description: 'The unit the sequence is counted in (the contract\'s scope). Two streams must not share one cursor.' }),
78
+ tslib_1.__metadata("design:type", String)
79
+ ], WebhookOutboxEvent.prototype, "scope", void 0);
80
+ tslib_1.__decorate([
81
+ (0, typeorm_1.Column)({ type: 'int' }),
82
+ (0, type_graphql_1.Field)(type => type_graphql_1.Int, { description: 'Position in this scope. Increases by one; a hole means a fact went missing.' }),
83
+ tslib_1.__metadata("design:type", Number)
84
+ ], WebhookOutboxEvent.prototype, "seq", void 0);
85
+ tslib_1.__decorate([
86
+ (0, typeorm_1.Column)(),
87
+ (0, type_graphql_1.Field)({ description: 'Idempotency key. Echoed back in a refusal so the sender knows which row was refused.' }),
88
+ tslib_1.__metadata("design:type", String)
89
+ ], WebhookOutboxEvent.prototype, "eventId", void 0);
90
+ tslib_1.__decorate([
91
+ (0, typeorm_1.Column)({ nullable: true }),
92
+ (0, type_graphql_1.Field)({ nullable: true, description: 'When the fact happened, as the source knows it.' }),
93
+ tslib_1.__metadata("design:type", Date
94
+ /**
95
+ * When we wrote it down. **Kept apart from `eventTime`** — a fact that arrives late is not
96
+ * a fact that happened late, and folding them together makes a backfill look like a
97
+ * present-tense event.
98
+ */
99
+ )
100
+ ], WebhookOutboxEvent.prototype, "eventTime", void 0);
101
+ tslib_1.__decorate([
102
+ (0, typeorm_1.Column)({ nullable: true }),
103
+ (0, type_graphql_1.Field)({ nullable: true, description: 'When this side recorded the fact. Not the same as when it happened.' }),
104
+ tslib_1.__metadata("design:type", Date
105
+ /** The fact itself, in whatever shape this product speaks. The only product-specific column. */
106
+ )
107
+ ], WebhookOutboxEvent.prototype, "recordTime", void 0);
108
+ tslib_1.__decorate([
109
+ (0, typeorm_1.Column)({ type: 'simple-json' }),
110
+ (0, type_graphql_1.Field)(type => shell_1.ScalarObject, { description: 'The fact being pushed. The only column whose shape belongs to the product.' }),
111
+ tslib_1.__metadata("design:type", Object)
112
+ ], WebhookOutboxEvent.prototype, "payload", void 0);
113
+ tslib_1.__decorate([
114
+ (0, typeorm_1.Column)({ type: 'varchar', default: WebhookOutboxStatus.Pending }),
115
+ (0, type_graphql_1.Field)(type => WebhookOutboxStatus, { description: 'Waiting, delivered, or refused for good.' }),
116
+ tslib_1.__metadata("design:type", String)
117
+ ], WebhookOutboxEvent.prototype, "status", void 0);
118
+ tslib_1.__decorate([
119
+ (0, typeorm_1.Column)({ nullable: true }),
120
+ (0, type_graphql_1.Field)({ nullable: true, description: 'When the peer took it.' }),
121
+ tslib_1.__metadata("design:type", Date
122
+ /**
123
+ * How many times a delivery was **really refused** — not how many times we tried.
124
+ *
125
+ * A peer answering 429 or 503 is asking us to wait; that does not count here, or a
126
+ * receiver under load becomes indistinguishable from a receiver that rejects us.
127
+ */
128
+ )
129
+ ], WebhookOutboxEvent.prototype, "sentAt", void 0);
130
+ tslib_1.__decorate([
131
+ (0, typeorm_1.Column)({ type: 'int', default: 0 }),
132
+ (0, type_graphql_1.Field)(type => type_graphql_1.Int, { description: 'Deliveries that were refused. Being asked to wait (429/503) does not count.' }),
133
+ tslib_1.__metadata("design:type", Number)
134
+ ], WebhookOutboxEvent.prototype, "attempts", void 0);
135
+ tslib_1.__decorate([
136
+ (0, typeorm_1.Column)({ nullable: true }),
137
+ (0, type_graphql_1.Field)({ nullable: true, description: 'What the peer said the last time it refused.' }),
138
+ tslib_1.__metadata("design:type", String)
139
+ ], WebhookOutboxEvent.prototype, "lastError", void 0);
140
+ tslib_1.__decorate([
141
+ (0, typeorm_1.CreateDateColumn)(),
142
+ (0, type_graphql_1.Field)({ nullable: true }),
143
+ tslib_1.__metadata("design:type", Date)
144
+ ], WebhookOutboxEvent.prototype, "createdAt", void 0);
145
+ tslib_1.__decorate([
146
+ (0, typeorm_1.UpdateDateColumn)(),
147
+ (0, type_graphql_1.Field)({ nullable: true }),
148
+ tslib_1.__metadata("design:type", Date)
149
+ ], WebhookOutboxEvent.prototype, "updatedAt", void 0);
150
+ exports.WebhookOutboxEvent = WebhookOutboxEvent = tslib_1.__decorate([
151
+ (0, type_graphql_1.ObjectType)({ description: 'A fact queued for one peer, with its number and what happened when we tried to deliver it.' }),
152
+ (0, typeorm_1.Entity)()
153
+ /*
154
+ * Numbering is per (domain, peer, scope) and must be unique there — the receiving side
155
+ * detects gaps by comparing consecutive numbers, so two rows sharing a number would make a
156
+ * gap invisible.
157
+ */
158
+ ,
159
+ (0, typeorm_1.Index)('ix_webhook_outbox_event_0', (event) => [event.domain, event.peer, event.scope, event.seq], {
160
+ unique: true
161
+ })
162
+ /* The pump's own query: what is still waiting for this peer, oldest first. */
163
+ ,
164
+ (0, typeorm_1.Index)('ix_webhook_outbox_event_1', (event) => [event.domain, event.peer, event.status, event.seq])
165
+ ], WebhookOutboxEvent);
166
+ //# sourceMappingURL=webhook-outbox-event.js.map