@happyvertical/smrt-webhooks 0.0.0-stage → 0.55.10
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/AGENTS.md +23 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +7 -0
- package/README.md +141 -2
- package/dist/__smrt-register__.d.ts +2 -0
- package/dist/__smrt-register__.d.ts.map +1 -0
- package/dist/admin.d.ts +25 -0
- package/dist/admin.d.ts.map +1 -0
- package/dist/admin.js +2 -0
- package/dist/change-feed.d.ts +25 -0
- package/dist/change-feed.d.ts.map +1 -0
- package/dist/chunks/admin-413jve1I.js +318 -0
- package/dist/chunks/admin-413jve1I.js.map +1 -0
- package/dist/chunks/dispatcher-C5AqtgtK.js +248 -0
- package/dist/chunks/dispatcher-C5AqtgtK.js.map +1 -0
- package/dist/dispatcher.d.ts +23 -0
- package/dist/dispatcher.d.ts.map +1 -0
- package/dist/events.d.ts +6 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +148 -0
- package/dist/index.js.map +1 -0
- package/dist/manifest.json +1032 -0
- package/dist/models.d.ts +43 -0
- package/dist/models.d.ts.map +1 -0
- package/dist/recipes.d.ts +23 -0
- package/dist/recipes.d.ts.map +1 -0
- package/dist/security.d.ts +15 -0
- package/dist/security.d.ts.map +1 -0
- package/dist/smrt-knowledge.json +418 -0
- package/dist/store.d.ts +26 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/svelte/WebhookAdmin.svelte +113 -0
- package/dist/svelte/WebhookAdmin.svelte.d.ts +22 -0
- package/dist/svelte/WebhookAdmin.svelte.d.ts.map +1 -0
- package/dist/svelte/index.d.ts +2 -0
- package/dist/svelte/index.d.ts.map +1 -0
- package/dist/svelte/index.js +1 -0
- package/dist/types.d.ts +27 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +0 -0
- package/package.json +75 -3
package/AGENTS.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# @happyvertical/smrt-webhooks
|
|
2
|
+
|
|
3
|
+
Server-only outbound webhooks. `change-feed.ts` owns durable model publication
|
|
4
|
+
with tenant/runtime cursors and retained-history recovery; `events.ts` is the
|
|
5
|
+
optional non-atomic hook path. Never enable both for the same models. `store.ts` owns tenant-bound SQL and atomic
|
|
6
|
+
outbox/job enqueue; `dispatcher.ts` owns signing and job execution. Always use
|
|
7
|
+
`requireTenant()` plus explicit tenant predicates, including raw CAS writes.
|
|
8
|
+
|
|
9
|
+
`security.ts` validates every DNS result, pins HTTPS lookup, disables pooling,
|
|
10
|
+
and bounds DNS/request time. Never substitute a preflight DNS check followed by
|
|
11
|
+
fetch, follow redirects, or persist raw transport errors. Preserve exact stored
|
|
12
|
+
payload bytes for signing. HTTP is at-least-once; receivers deduplicate the
|
|
13
|
+
stable delivery ID. Do not promise exactly-once network effects.
|
|
14
|
+
|
|
15
|
+
Admin projections are allowlists. Secrets and payloads are sensitive fields;
|
|
16
|
+
generated API/CLI/MCP access is disabled. The host authorizes administrators via
|
|
17
|
+
`WebhookAdminService`; Svelte callbacks must use authenticated, CSRF-protected
|
|
18
|
+
host routes. No credentials in the browser read model.
|
|
19
|
+
|
|
20
|
+
Validate: `pnpm test`, `pnpm typecheck`, `pnpm check`, `pnpm test:e2e` within this
|
|
21
|
+
package after building its dependencies. E2E uses the maintained Playwright
|
|
22
|
+
harness under `e2e/`; no external webhook server or network credential is needed.
|
|
23
|
+
See README.md for migration, lifecycle-hook boundaries and worker setup.
|
package/CLAUDE.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@AGENTS.md
|
package/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright <2025> <Happy Vertical Corporation>
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,142 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Outbound webhooks
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`@happyvertical/smrt-webhooks` delivers tenant-scoped create, update, delete and
|
|
4
|
+
custom events to public HTTPS endpoints, with an HMAC signature, a durable jobs
|
|
5
|
+
queue, retry/backoff, and a safe delivery log.
|
|
6
|
+
|
|
7
|
+
## Server setup
|
|
8
|
+
|
|
9
|
+
Migrate the package models and jobs system tables with the normal application
|
|
10
|
+
migration flow before starting workers. Runtime code never creates application
|
|
11
|
+
schema. Run this only in a Node server; import the UI from `/svelte` separately.
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import {
|
|
15
|
+
WebhookDispatcher, WebhookAdminService,
|
|
16
|
+
registerWebhookRuntime, WebhookChangeFeed,
|
|
17
|
+
} from '@happyvertical/smrt-webhooks';
|
|
18
|
+
import { TaskRunner } from '@happyvertical/smrt-jobs';
|
|
19
|
+
|
|
20
|
+
const webhooks = new WebhookDispatcher({ db, runtime: 'application-webhooks' });
|
|
21
|
+
registerWebhookRuntime('application-webhooks', webhooks);
|
|
22
|
+
const events = new WebhookChangeFeed(webhooks, { orders: '@example/app:Order' });
|
|
23
|
+
// Poll from a host scheduler inside each authenticated tenant context.
|
|
24
|
+
await events.poll();
|
|
25
|
+
const admin = new WebhookAdminService(webhooks.store, async () =>
|
|
26
|
+
currentUserHasPermission('webhooks:admin'));
|
|
27
|
+
const runner = new TaskRunner({ queues: ['webhooks'] });
|
|
28
|
+
await runner.initialize(db);
|
|
29
|
+
await runner.start();
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Register the same runtime name in every worker process before starting jobs.
|
|
33
|
+
The runner restores captured tenant context; all service calls require the
|
|
34
|
+
host's authenticated `withTenant` context. Do not derive that context from a
|
|
35
|
+
client-supplied tenant ID. Unregister optional hooks and call `unregisterWebhookRuntime()` on shutdown. The server entry is intentionally not browser-safe.
|
|
36
|
+
|
|
37
|
+
Lifecycle subscriptions use qualified model names. The recommended durable path is
|
|
38
|
+
`WebhookChangeFeed`: map physical application tables to model names and poll in
|
|
39
|
+
each tenant context. It reads s-m-r-t's committed change feed (including delete
|
|
40
|
+
tombstones), publishes identity-only `{ id }` payloads, and advances a persisted
|
|
41
|
+
tenant/runtime cursor only after outbox/jobs are durable. Partial failure or
|
|
42
|
+
process death before cursor advancement replays stable change-sequence IDs,
|
|
43
|
+
which deduplicate against already enqueued deliveries. Global records and other
|
|
44
|
+
tenants are excluded. Shared STI tables must map to their base model: the feed
|
|
45
|
+
does not encode a child discriminator. Custom SQL that bypasses s-m-r-t persistence
|
|
46
|
+
must explicitly append its domain event or core change-feed entry.
|
|
47
|
+
|
|
48
|
+
Poll more frequently than core change-feed retention. A pruned cursor raises
|
|
49
|
+
`WebhookHistoryExpiredError` and stops rather than silently dropping history.
|
|
50
|
+
After an operator reconciles source state and publishes any recoverable missing
|
|
51
|
+
events, call `resumeAfterResync(error.resumeCursor)`. Lost historical transitions
|
|
52
|
+
cannot be reconstructed from a current-state snapshot alone; retain domain event
|
|
53
|
+
history if that guarantee is required. Keep one stable model mapping per runtime;
|
|
54
|
+
adding tables after its cursor has advanced does not backfill their older events.
|
|
55
|
+
|
|
56
|
+
`registerWebhookModelEvents` is an alternative low-latency hook binding with an
|
|
57
|
+
explicit model allowlist. It also publishes only identities, but source writes
|
|
58
|
+
and hooks are not atomic: a crash between source commit and hook enqueue can
|
|
59
|
+
miss an event. Use the durable change-feed path when that gap is unacceptable.
|
|
60
|
+
Do not enable both paths for the same models: their event IDs represent different
|
|
61
|
+
publication streams. Neither path promises atomic network effects with source
|
|
62
|
+
writes. Hooks propagate enqueue failures; re-saving a source object does not
|
|
63
|
+
reconstruct a missing historical create/update event.
|
|
64
|
+
|
|
65
|
+
For custom events, authorize the data projection on the server:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
await webhooks.dispatch({
|
|
69
|
+
id: durableDomainEventId, event: 'custom.paid', model: '@example/app:Invoice',
|
|
70
|
+
occurredAt: new Date().toISOString(), data: { id: invoiceId },
|
|
71
|
+
});
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Empty event/model filters mean all events/models. Unknown nonempty custom names
|
|
75
|
+
are supported; malformed envelopes and bodies over 256 KiB are rejected.
|
|
76
|
+
|
|
77
|
+
## Delivery and recovery
|
|
78
|
+
|
|
79
|
+
A unique `(tenant, subscription, event ID)` outbox row and its jobs enqueue commit
|
|
80
|
+
in one database transaction. Repeated dispatch is a no-op for that identity.
|
|
81
|
+
Jobs contain only runtime, opaque tenant/delivery IDs, and a replay generation. Five attempts use jobs'
|
|
82
|
+
exponential retry (1 second initial delay, multiplier 2, 5 minute cap). A delivery
|
|
83
|
+
lease prevents overlapping HTTP attempts; an expired lease permits recovery
|
|
84
|
+
after worker death. Terminal writes require the current lease token. Disabled
|
|
85
|
+
subscriptions cancel unsent deliveries. Secrets are read at delivery time, so
|
|
86
|
+
rotation applies to subsequent attempts.
|
|
87
|
+
|
|
88
|
+
Call `store.reconcile()` from a tenant-scoped operator sweep to turn exhausted
|
|
89
|
+
or cancelled jobs into replayable failed deliveries. Admin refresh also runs
|
|
90
|
+
reconciliation. `admin.replay(id)` atomically creates a new job only for a failed
|
|
91
|
+
delivery; repeated replay is a no-op. Delivered events are never replayed by this
|
|
92
|
+
API. Receivers **must deduplicate `x-smrt-delivery`**: if HTTP succeeds and the
|
|
93
|
+
process dies before the outcome commits, recovery can send the same request
|
|
94
|
+
again. The package does not claim exactly-once HTTP effects.
|
|
95
|
+
|
|
96
|
+
## Security contract
|
|
97
|
+
|
|
98
|
+
Only HTTPS port 443 is allowed, without credentials or fragments. Every resolved
|
|
99
|
+
DNS address must be public unicast. The socket lookup is pinned to a validated
|
|
100
|
+
answer while TLS still verifies the original hostname. Redirects and connection
|
|
101
|
+
pooling are disabled. DNS and request deadlines are 5 and 10 seconds. Special,
|
|
102
|
+
private, mapped, transition and documentation IP ranges are conservatively
|
|
103
|
+
rejected. Response bodies are never buffered or logged. A transport override is
|
|
104
|
+
a **trusted server-only integration seam** and must preserve those requirements.
|
|
105
|
+
|
|
106
|
+
`x-smrt-signature` is `sha256=<hex HMAC-SHA256(secret, timestamp + '.' + body)>`,
|
|
107
|
+
where timestamp is the exact `x-smrt-timestamp` header and body is the exact
|
|
108
|
+
persisted UTF-8 request body. Verify in constant time, bound timestamp age, and
|
|
109
|
+
deduplicate the delivery ID. Keys require at least 32 bytes of entropy-bearing
|
|
110
|
+
secret material (generate them cryptographically; length alone is not entropy).
|
|
111
|
+
|
|
112
|
+
Secrets and payloads are durable sensitive data: use protected database access,
|
|
113
|
+
backups and encryption at rest. Never serialize model instances with `toJSON`
|
|
114
|
+
for clients; use the admin service or `toPublicJSON`. The admin view omits secrets,
|
|
115
|
+
payloads and lease tokens, and transport errors are fixed safe strings.
|
|
116
|
+
|
|
117
|
+
## Admin UI and recipe
|
|
118
|
+
|
|
119
|
+
`WebhookAdmin` from `/svelte` uses smrt-ui Button, Card, Input and Alert primitives.
|
|
120
|
+
Supply `subscriptions`, `deliveries`, and async `onCreate`, `onToggle`, `onReplay`
|
|
121
|
+
callbacks backed by `WebhookAdminService`. Refresh the view after mutations.
|
|
122
|
+
The host owns login, administrator permissions, and CSRF protection. The component
|
|
123
|
+
has busy/error states and never receives persisted signing secrets.
|
|
124
|
+
|
|
125
|
+
The `integrations.webhooks` recipe declares `runtime: 'server'` and the
|
|
126
|
+
`@happyvertical/smrt-webhooks/svelte#WebhookAdmin` settings panel in the
|
|
127
|
+
Integrations group. The host resolves that export and supplies the authenticated
|
|
128
|
+
callbacks and safe read models described above. It intentionally declares no
|
|
129
|
+
model navigation: generated CRUD is disabled for these sensitive internal
|
|
130
|
+
models. It declares no provider or demo seed because transport configuration and
|
|
131
|
+
signing secrets belong to the server; selecting the recipe does not configure a
|
|
132
|
+
worker or authorize a browser to dispatch webhooks. The browser capability gate
|
|
133
|
+
records this intentional server-only root against #3717; the separate `/svelte`
|
|
134
|
+
export contains the administration component, not the delivery transport.
|
|
135
|
+
|
|
136
|
+
## Validation
|
|
137
|
+
|
|
138
|
+
`pnpm test` exercises persistence, rollback, tenancy, retries, lease fencing,
|
|
139
|
+
lifecycle events, redaction, HMAC and DNS pinning. `pnpm check` and
|
|
140
|
+
`pnpm typecheck` check public TypeScript/Svelte contracts. `pnpm test:e2e` runs the
|
|
141
|
+
maintained Chromium admin interaction harness. Build with
|
|
142
|
+
`pnpm exec turbo build --filter=@happyvertical/smrt-webhooks...` from the root.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"__smrt-register__.d.ts","sourceRoot":"","sources":["../src/__smrt-register__.ts"],"names":[],"mappings":""}
|
package/dist/admin.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { WebhookStore } from './store.js';
|
|
2
|
+
import type { WebhookDeliveryRecord, WebhookSubscriptionRecord } from './types.js';
|
|
3
|
+
export interface WebhookAdminView {
|
|
4
|
+
subscriptions: WebhookSubscriptionRecord[];
|
|
5
|
+
deliveries: WebhookDeliveryRecord[];
|
|
6
|
+
}
|
|
7
|
+
/** Allowlisted projection; never spread persistence objects into a browser response. */
|
|
8
|
+
export declare function webhookAdminView(subscriptions: WebhookSubscriptionRecord[], deliveries: WebhookDeliveryRecord[]): WebhookAdminView;
|
|
9
|
+
/** Host supplies its administrator permission check and authenticated tenant context. */
|
|
10
|
+
export declare class WebhookAdminService {
|
|
11
|
+
private readonly store;
|
|
12
|
+
private readonly authorize;
|
|
13
|
+
constructor(store: WebhookStore, authorize: () => Promise<boolean>);
|
|
14
|
+
private guard;
|
|
15
|
+
view(): Promise<WebhookAdminView>;
|
|
16
|
+
create(input: {
|
|
17
|
+
url: string;
|
|
18
|
+
events: string[];
|
|
19
|
+
models: string[];
|
|
20
|
+
secret: string;
|
|
21
|
+
}): Promise<void>;
|
|
22
|
+
toggle(id: string, enabled: boolean): Promise<void>;
|
|
23
|
+
replay(id: string): Promise<boolean>;
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=admin.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"admin.d.ts","sourceRoot":"","sources":["../src/admin.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC/C,OAAO,KAAK,EACV,qBAAqB,EACrB,yBAAyB,EAC1B,MAAM,YAAY,CAAC;AACpB,MAAM,WAAW,gBAAgB;IAC/B,aAAa,EAAE,yBAAyB,EAAE,CAAC;IAC3C,UAAU,EAAE,qBAAqB,EAAE,CAAC;CACrC;AACD,wFAAwF;AACxF,wBAAgB,gBAAgB,CAC9B,aAAa,EAAE,yBAAyB,EAAE,EAC1C,UAAU,EAAE,qBAAqB,EAAE,GAClC,gBAAgB,CAqBlB;AACD,yFAAyF;AACzF,qBAAa,mBAAmB;IAE5B,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,SAAS;gBADT,KAAK,EAAE,YAAY,EACnB,SAAS,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC;YAEtC,KAAK;IAKb,IAAI,IAAI,OAAO,CAAC,gBAAgB,CAAC;IAUjC,MAAM,CAAC,KAAK,EAAE;QAClB,GAAG,EAAE,MAAM,CAAC;QACZ,MAAM,EAAE,MAAM,EAAE,CAAC;QACjB,MAAM,EAAE,MAAM,EAAE,CAAC;QACjB,MAAM,EAAE,MAAM,CAAC;KAChB,GAAG,OAAO,CAAC,IAAI,CAAC;IAeX,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IAQnD,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;CAI3C"}
|
package/dist/admin.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { WebhookDispatcher } from './dispatcher.js';
|
|
2
|
+
export declare class WebhookHistoryExpiredError extends Error {
|
|
3
|
+
readonly resumeCursor: number;
|
|
4
|
+
constructor(resumeCursor: number);
|
|
5
|
+
}
|
|
6
|
+
/** Durable, replayable lifecycle publication from SMRT's committed change feed.
|
|
7
|
+
* Host scheduling calls poll() in each authenticated tenant context. The mapping
|
|
8
|
+
* is an explicit physical-table allowlist; shared STI tables represent the base model.
|
|
9
|
+
*/
|
|
10
|
+
export declare class WebhookChangeFeed {
|
|
11
|
+
private readonly dispatcher;
|
|
12
|
+
private readonly models;
|
|
13
|
+
constructor(dispatcher: WebhookDispatcher, models: Readonly<Record<string, string>>);
|
|
14
|
+
/** Cursor only advances after all matched changes have durable outbox/jobs.
|
|
15
|
+
* A crash or partial failure replays the same stable identities on the next poll.
|
|
16
|
+
* History pruning fails loudly: the host must reconcile source state before reset.
|
|
17
|
+
*/
|
|
18
|
+
poll(): Promise<{
|
|
19
|
+
cursor: number;
|
|
20
|
+
published: number;
|
|
21
|
+
}>;
|
|
22
|
+
/** Operator-only recovery: call after reconciling retained source state externally. */
|
|
23
|
+
resumeAfterResync(cursor: number): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=change-feed.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"change-feed.d.ts","sourceRoot":"","sources":["../src/change-feed.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEzD,qBAAa,0BAA2B,SAAQ,KAAK;IACvC,QAAQ,CAAC,YAAY,EAAE,MAAM;gBAApB,YAAY,EAAE,MAAM;CAK1C;AAED;;;GAGG;AACH,qBAAa,iBAAiB;IAE1B,OAAO,CAAC,QAAQ,CAAC,UAAU;IAC3B,OAAO,CAAC,QAAQ,CAAC,MAAM;gBADN,UAAU,EAAE,iBAAiB,EAC7B,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAS3D;;;OAGG;IACG,IAAI,IAAI,OAAO,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC;IAyD5D,uFAAuF;IACjF,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAYvD"}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { SmrtCollection, SmrtObject, field, foreignKey, smrt } from "@happyvertical/smrt-core";
|
|
2
|
+
import { TenantScoped, requireTenant, tenantId } from "@happyvertical/smrt-tenancy";
|
|
3
|
+
import { backgroundEligible } from "@happyvertical/smrt-jobs";
|
|
4
|
+
import { lookup } from "node:dns/promises";
|
|
5
|
+
import { request } from "node:https";
|
|
6
|
+
import { isIP } from "node:net";
|
|
7
|
+
//#region src/security.ts
|
|
8
|
+
function isPublicAddress(address) {
|
|
9
|
+
if (isIP(address) === 4) {
|
|
10
|
+
const [a, b, c] = address.split(".").map(Number);
|
|
11
|
+
return !(a === 0 || a === 10 || a === 127 || a >= 224 || a === 100 && b >= 64 && b <= 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && (b === 0 || b === 168 || b === 88 && c === 99) || a === 198 && (b === 18 || b === 19 || b === 51 && c === 100) || a === 203 && b === 0 && c === 113);
|
|
12
|
+
}
|
|
13
|
+
if (isIP(address) !== 6) return false;
|
|
14
|
+
const normalized = new URL(`https://[${address}]/`).hostname.slice(1, -1);
|
|
15
|
+
const first = Number.parseInt(normalized.split(":")[0], 16);
|
|
16
|
+
return first >= 8192 && first < 16384 && first !== 8194 && !(first === 8193 && Number.parseInt(normalized.split(":")[1] || "0", 16) < 512) && !normalized.startsWith("2001:db8:") && first !== 16383;
|
|
17
|
+
}
|
|
18
|
+
function assertWebhookUrl(value) {
|
|
19
|
+
let url;
|
|
20
|
+
try {
|
|
21
|
+
url = new URL(value);
|
|
22
|
+
} catch {
|
|
23
|
+
throw new Error("Webhook URL must be an absolute HTTPS URL.");
|
|
24
|
+
}
|
|
25
|
+
const host = url.hostname.replace(/^\[|\]$/g, "");
|
|
26
|
+
if (url.protocol !== "https:" || url.username || url.password || url.hash || url.port && url.port !== "443" || host === "localhost" || host.endsWith(".localhost") || isIP(host) && !isPublicAddress(host)) throw new Error("Webhook URL must use public HTTPS on port 443 without credentials or a fragment.");
|
|
27
|
+
return url;
|
|
28
|
+
}
|
|
29
|
+
function assertHmacSecret(secret) {
|
|
30
|
+
if (typeof secret !== "string" || Buffer.byteLength(secret) < 32 || Buffer.byteLength(secret) > 4096) throw new Error("Webhook signing secret must contain 32 to 4096 bytes.");
|
|
31
|
+
}
|
|
32
|
+
var sendPublicWebhook = async ({ url, body, headers }) => {
|
|
33
|
+
const target = assertWebhookUrl(url);
|
|
34
|
+
const host = target.hostname.replace(/^\[|\]$/g, "");
|
|
35
|
+
let dnsTimer;
|
|
36
|
+
const answers = isIP(host) ? [{
|
|
37
|
+
address: host,
|
|
38
|
+
family: isIP(host)
|
|
39
|
+
}] : await Promise.race([lookup(host, {
|
|
40
|
+
all: true,
|
|
41
|
+
verbatim: true
|
|
42
|
+
}), new Promise((_, reject) => {
|
|
43
|
+
dnsTimer = setTimeout(() => reject(/* @__PURE__ */ new Error("Webhook DNS timeout.")), 5e3);
|
|
44
|
+
dnsTimer.unref();
|
|
45
|
+
})]).finally(() => clearTimeout(dnsTimer));
|
|
46
|
+
if (!answers.length || answers.some(({ address }) => !isPublicAddress(address))) throw new Error("Webhook destination is not public.");
|
|
47
|
+
const pinned = answers[0];
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
const req = request(target, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
agent: false,
|
|
52
|
+
headers: {
|
|
53
|
+
...headers,
|
|
54
|
+
"content-length": Buffer.byteLength(body).toString()
|
|
55
|
+
},
|
|
56
|
+
lookup: (_hostname, options, callback) => {
|
|
57
|
+
if (options.all) callback(null, [pinned]);
|
|
58
|
+
else callback(null, pinned.address, pinned.family);
|
|
59
|
+
}
|
|
60
|
+
}, (response) => {
|
|
61
|
+
const status = response.statusCode ?? 0;
|
|
62
|
+
response.destroy();
|
|
63
|
+
resolve(status);
|
|
64
|
+
});
|
|
65
|
+
const timer = setTimeout(() => req.destroy(/* @__PURE__ */ new Error("Webhook request timeout.")), 1e4);
|
|
66
|
+
req.on("close", () => clearTimeout(timer));
|
|
67
|
+
req.on("error", () => reject(/* @__PURE__ */ new Error("Webhook transport failed.")));
|
|
68
|
+
req.end(body);
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
//#endregion
|
|
72
|
+
//#region src/models.ts
|
|
73
|
+
var __defProp = Object.defineProperty;
|
|
74
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
75
|
+
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, {
|
|
76
|
+
enumerable: true,
|
|
77
|
+
configurable: true,
|
|
78
|
+
writable: true,
|
|
79
|
+
value
|
|
80
|
+
}) : obj[key] = value;
|
|
81
|
+
var __decorateClass = (decorators, target, key, kind) => {
|
|
82
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
|
|
83
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
84
|
+
if (kind && result) __defProp(target, key, result);
|
|
85
|
+
return result;
|
|
86
|
+
};
|
|
87
|
+
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
88
|
+
var WebhookSubscription = class extends SmrtObject {
|
|
89
|
+
tenantId = "";
|
|
90
|
+
url = "";
|
|
91
|
+
events = [];
|
|
92
|
+
models = [];
|
|
93
|
+
secret = "";
|
|
94
|
+
enabled = true;
|
|
95
|
+
async save() {
|
|
96
|
+
assertWebhookUrl(this.url);
|
|
97
|
+
assertHmacSecret(this.secret);
|
|
98
|
+
for (const values of [this.events, this.models]) if (!Array.isArray(values) || values.length > 100 || values.some((v) => typeof v !== "string" || !v || v.length > 200)) throw new Error("Webhook filters must be lists of nonempty names.");
|
|
99
|
+
return super.save();
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
__publicField(WebhookSubscription, "__smrtPackage__", "@happyvertical/smrt-webhooks");
|
|
103
|
+
__decorateClass([tenantId()], WebhookSubscription.prototype, "tenantId", 2);
|
|
104
|
+
__decorateClass([field({
|
|
105
|
+
type: "text",
|
|
106
|
+
required: true
|
|
107
|
+
})], WebhookSubscription.prototype, "url", 2);
|
|
108
|
+
__decorateClass([field({ type: "json" })], WebhookSubscription.prototype, "events", 2);
|
|
109
|
+
__decorateClass([field({ type: "json" })], WebhookSubscription.prototype, "models", 2);
|
|
110
|
+
__decorateClass([field({
|
|
111
|
+
type: "text",
|
|
112
|
+
sensitive: true,
|
|
113
|
+
required: true
|
|
114
|
+
})], WebhookSubscription.prototype, "secret", 2);
|
|
115
|
+
__decorateClass([field({
|
|
116
|
+
type: "boolean",
|
|
117
|
+
default: true
|
|
118
|
+
})], WebhookSubscription.prototype, "enabled", 2);
|
|
119
|
+
WebhookSubscription = __decorateClass([smrt({
|
|
120
|
+
tableName: "_smrt_webhook_subscriptions",
|
|
121
|
+
api: false,
|
|
122
|
+
cli: false,
|
|
123
|
+
mcp: false
|
|
124
|
+
}), TenantScoped({ mode: "required" })], WebhookSubscription);
|
|
125
|
+
var WebhookSubscriptionCollection = class extends SmrtCollection {
|
|
126
|
+
static _itemClass = WebhookSubscription;
|
|
127
|
+
};
|
|
128
|
+
var WebhookDelivery = class extends SmrtObject {
|
|
129
|
+
tenantId = "";
|
|
130
|
+
subscriptionId = "";
|
|
131
|
+
eventId = "";
|
|
132
|
+
payload = "";
|
|
133
|
+
status = "pending";
|
|
134
|
+
generation = 0;
|
|
135
|
+
attempts = 0;
|
|
136
|
+
responseStatus = null;
|
|
137
|
+
error = null;
|
|
138
|
+
leaseToken = null;
|
|
139
|
+
leaseUntil = null;
|
|
140
|
+
};
|
|
141
|
+
__publicField(WebhookDelivery, "__smrtPackage__", "@happyvertical/smrt-webhooks");
|
|
142
|
+
__decorateClass([tenantId()], WebhookDelivery.prototype, "tenantId", 2);
|
|
143
|
+
__decorateClass([foreignKey(WebhookSubscription, {
|
|
144
|
+
onUpdate: "RESTRICT",
|
|
145
|
+
onDelete: "RESTRICT"
|
|
146
|
+
})], WebhookDelivery.prototype, "subscriptionId", 2);
|
|
147
|
+
__decorateClass([field({
|
|
148
|
+
type: "text",
|
|
149
|
+
required: true
|
|
150
|
+
})], WebhookDelivery.prototype, "eventId", 2);
|
|
151
|
+
__decorateClass([field({
|
|
152
|
+
type: "text",
|
|
153
|
+
required: true,
|
|
154
|
+
sensitive: true
|
|
155
|
+
})], WebhookDelivery.prototype, "payload", 2);
|
|
156
|
+
__decorateClass([field({
|
|
157
|
+
type: "text",
|
|
158
|
+
default: "pending"
|
|
159
|
+
})], WebhookDelivery.prototype, "status", 2);
|
|
160
|
+
__decorateClass([field({
|
|
161
|
+
type: "integer",
|
|
162
|
+
default: 0
|
|
163
|
+
})], WebhookDelivery.prototype, "generation", 2);
|
|
164
|
+
__decorateClass([field({
|
|
165
|
+
type: "integer",
|
|
166
|
+
default: 0
|
|
167
|
+
})], WebhookDelivery.prototype, "attempts", 2);
|
|
168
|
+
__decorateClass([field({
|
|
169
|
+
type: "integer",
|
|
170
|
+
nullable: true
|
|
171
|
+
})], WebhookDelivery.prototype, "responseStatus", 2);
|
|
172
|
+
__decorateClass([field({
|
|
173
|
+
type: "text",
|
|
174
|
+
nullable: true
|
|
175
|
+
})], WebhookDelivery.prototype, "error", 2);
|
|
176
|
+
__decorateClass([field({
|
|
177
|
+
type: "text",
|
|
178
|
+
nullable: true,
|
|
179
|
+
sensitive: true
|
|
180
|
+
})], WebhookDelivery.prototype, "leaseToken", 2);
|
|
181
|
+
__decorateClass([field({
|
|
182
|
+
type: "datetime",
|
|
183
|
+
nullable: true
|
|
184
|
+
})], WebhookDelivery.prototype, "leaseUntil", 2);
|
|
185
|
+
WebhookDelivery = __decorateClass([smrt({
|
|
186
|
+
tableName: "_smrt_webhook_deliveries",
|
|
187
|
+
api: false,
|
|
188
|
+
cli: false,
|
|
189
|
+
mcp: false,
|
|
190
|
+
indexes: [{
|
|
191
|
+
name: "_smrt_webhook_delivery_identity",
|
|
192
|
+
columns: [
|
|
193
|
+
"tenantId",
|
|
194
|
+
"subscriptionId",
|
|
195
|
+
"eventId"
|
|
196
|
+
],
|
|
197
|
+
unique: true
|
|
198
|
+
}]
|
|
199
|
+
}), TenantScoped({ mode: "required" })], WebhookDelivery);
|
|
200
|
+
var WebhookDeliveryCollection = class extends SmrtCollection {
|
|
201
|
+
static _itemClass = WebhookDelivery;
|
|
202
|
+
};
|
|
203
|
+
var WebhookDeliveryTask = class extends SmrtObject {
|
|
204
|
+
async deliver(args = {}, _context) {
|
|
205
|
+
const { runWebhookDeliveryJob } = await import("./dispatcher-C5AqtgtK.js").then((n) => n.r);
|
|
206
|
+
await runWebhookDeliveryJob(args);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
__publicField(WebhookDeliveryTask, "__smrtPackage__", "@happyvertical/smrt-webhooks");
|
|
210
|
+
__decorateClass([backgroundEligible()], WebhookDeliveryTask.prototype, "deliver", 1);
|
|
211
|
+
WebhookDeliveryTask = __decorateClass([smrt({
|
|
212
|
+
tableName: "_smrt_webhook_delivery_tasks",
|
|
213
|
+
api: false,
|
|
214
|
+
cli: false,
|
|
215
|
+
mcp: false
|
|
216
|
+
})], WebhookDeliveryTask);
|
|
217
|
+
var WebhookCursor = class extends SmrtObject {
|
|
218
|
+
tenantId = "";
|
|
219
|
+
runtime = "";
|
|
220
|
+
position = 0;
|
|
221
|
+
};
|
|
222
|
+
__publicField(WebhookCursor, "__smrtPackage__", "@happyvertical/smrt-webhooks");
|
|
223
|
+
__decorateClass([tenantId()], WebhookCursor.prototype, "tenantId", 2);
|
|
224
|
+
__decorateClass([field({
|
|
225
|
+
type: "text",
|
|
226
|
+
required: true
|
|
227
|
+
})], WebhookCursor.prototype, "runtime", 2);
|
|
228
|
+
__decorateClass([field({
|
|
229
|
+
type: "integer",
|
|
230
|
+
default: 0
|
|
231
|
+
})], WebhookCursor.prototype, "position", 2);
|
|
232
|
+
WebhookCursor = __decorateClass([smrt({
|
|
233
|
+
tableName: "_smrt_webhook_cursors",
|
|
234
|
+
api: false,
|
|
235
|
+
cli: false,
|
|
236
|
+
mcp: false,
|
|
237
|
+
indexes: [{
|
|
238
|
+
name: "_smrt_webhook_cursor_identity",
|
|
239
|
+
columns: ["tenantId", "runtime"],
|
|
240
|
+
unique: true
|
|
241
|
+
}]
|
|
242
|
+
}), TenantScoped({ mode: "required" })], WebhookCursor);
|
|
243
|
+
//#endregion
|
|
244
|
+
//#region src/admin.ts
|
|
245
|
+
function webhookAdminView(subscriptions, deliveries) {
|
|
246
|
+
return {
|
|
247
|
+
subscriptions: subscriptions.map((s) => ({
|
|
248
|
+
id: s.id,
|
|
249
|
+
tenantId: s.tenantId,
|
|
250
|
+
url: s.url,
|
|
251
|
+
models: s.models,
|
|
252
|
+
events: s.events,
|
|
253
|
+
enabled: s.enabled
|
|
254
|
+
})),
|
|
255
|
+
deliveries: deliveries.map((d) => ({
|
|
256
|
+
id: d.id,
|
|
257
|
+
tenantId: d.tenantId,
|
|
258
|
+
subscriptionId: d.subscriptionId,
|
|
259
|
+
eventId: d.eventId,
|
|
260
|
+
status: d.status,
|
|
261
|
+
attempts: d.attempts,
|
|
262
|
+
responseStatus: d.responseStatus,
|
|
263
|
+
error: d.error
|
|
264
|
+
}))
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
var WebhookAdminService = class {
|
|
268
|
+
constructor(store, authorize) {
|
|
269
|
+
this.store = store;
|
|
270
|
+
this.authorize = authorize;
|
|
271
|
+
}
|
|
272
|
+
store;
|
|
273
|
+
authorize;
|
|
274
|
+
async guard() {
|
|
275
|
+
requireTenant();
|
|
276
|
+
if (!await this.authorize()) throw new Error("Webhook administration forbidden.");
|
|
277
|
+
}
|
|
278
|
+
async view() {
|
|
279
|
+
await this.guard();
|
|
280
|
+
await this.store.reconcile();
|
|
281
|
+
const subscriptions = await this.store.subscriptions();
|
|
282
|
+
const deliveries = await this.store.deliveries();
|
|
283
|
+
return webhookAdminView(subscriptions.map((s) => ({
|
|
284
|
+
...s,
|
|
285
|
+
id: s.id ?? ""
|
|
286
|
+
})), deliveries.map((d) => ({
|
|
287
|
+
...d,
|
|
288
|
+
id: d.id ?? ""
|
|
289
|
+
})));
|
|
290
|
+
}
|
|
291
|
+
async create(input) {
|
|
292
|
+
await this.guard();
|
|
293
|
+
await (await (await WebhookSubscriptionCollection.create({ db: this.store.db })).create({
|
|
294
|
+
tenantId: requireTenant().tenantId,
|
|
295
|
+
url: input.url,
|
|
296
|
+
events: input.events,
|
|
297
|
+
models: input.models,
|
|
298
|
+
secret: input.secret,
|
|
299
|
+
enabled: true
|
|
300
|
+
})).save();
|
|
301
|
+
}
|
|
302
|
+
async toggle(id, enabled) {
|
|
303
|
+
await this.guard();
|
|
304
|
+
if (typeof enabled !== "boolean") throw new Error("Invalid enabled value.");
|
|
305
|
+
const sub = await this.store.subscription(id);
|
|
306
|
+
if (!sub) throw new Error("Webhook subscription not found.");
|
|
307
|
+
sub.enabled = enabled;
|
|
308
|
+
await sub.save();
|
|
309
|
+
}
|
|
310
|
+
async replay(id) {
|
|
311
|
+
await this.guard();
|
|
312
|
+
return this.store.replay(id);
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
//#endregion
|
|
316
|
+
export { WebhookDeliveryCollection as a, WebhookSubscriptionCollection as c, isPublicAddress as d, sendPublicWebhook as f, WebhookDelivery as i, assertHmacSecret as l, webhookAdminView as n, WebhookDeliveryTask as o, WebhookCursor as r, WebhookSubscription as s, WebhookAdminService as t, assertWebhookUrl as u };
|
|
317
|
+
|
|
318
|
+
//# sourceMappingURL=admin-413jve1I.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"admin-413jve1I.js","names":[],"sources":["../../src/security.ts","../../src/models.ts","../../src/admin.ts"],"sourcesContent":["import { lookup } from 'node:dns/promises';\nimport { request } from 'node:https';\nimport { isIP } from 'node:net';\n\n/** Conservative public-unicast allowlist. Transition/mapped IPv6 is excluded. */\nexport function isPublicAddress(address: string): boolean {\n if (isIP(address) === 4) {\n const [a, b, c] = address.split('.').map(Number);\n return !(\n a === 0 ||\n a === 10 ||\n a === 127 ||\n a >= 224 ||\n (a === 100 && b >= 64 && b <= 127) ||\n (a === 169 && b === 254) ||\n (a === 172 && b >= 16 && b <= 31) ||\n (a === 192 && (b === 0 || b === 168 || (b === 88 && c === 99))) ||\n (a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) ||\n (a === 203 && b === 0 && c === 113)\n );\n }\n if (isIP(address) !== 6) return false;\n const normalized = new URL(`https://[${address}]/`).hostname.slice(1, -1);\n // Only global unicast 2000::/3; exclude IETF special-purpose, documentation,\n // 6to4, and the newer documentation block (3fff::/20).\n const first = Number.parseInt(normalized.split(':')[0], 16);\n return (\n first >= 0x2000 &&\n first < 0x4000 &&\n first !== 0x2002 &&\n !(\n first === 0x2001 &&\n Number.parseInt(normalized.split(':')[1] || '0', 16) < 0x200\n ) &&\n !normalized.startsWith('2001:db8:') &&\n first !== 0x3fff\n );\n}\n\nexport function assertWebhookUrl(value: string): URL {\n let url: URL;\n try {\n url = new URL(value);\n } catch {\n throw new Error('Webhook URL must be an absolute HTTPS URL.');\n }\n const host = url.hostname.replace(/^\\[|\\]$/g, '');\n if (\n url.protocol !== 'https:' ||\n url.username ||\n url.password ||\n url.hash ||\n (url.port && url.port !== '443') ||\n host === 'localhost' ||\n host.endsWith('.localhost') ||\n (isIP(host) && !isPublicAddress(host))\n ) {\n throw new Error(\n 'Webhook URL must use public HTTPS on port 443 without credentials or a fragment.',\n );\n }\n return url;\n}\n\nexport function assertHmacSecret(secret: string): void {\n if (\n typeof secret !== 'string' ||\n Buffer.byteLength(secret) < 32 ||\n Buffer.byteLength(secret) > 4096\n ) {\n throw new Error('Webhook signing secret must contain 32 to 4096 bytes.');\n }\n}\n\nexport interface WebhookRequest {\n url: string;\n body: string;\n headers: Record<string, string>;\n}\nexport type WebhookTransport = (request: WebhookRequest) => Promise<number>;\n\n/** Validate every DNS answer, then pin the socket lookup; TLS still verifies the hostname.\n * No proxy, redirects, pooled sockets, response body buffering, or second DNS lookup.\n */\nexport const sendPublicWebhook: WebhookTransport = async ({\n url,\n body,\n headers,\n}) => {\n const target = assertWebhookUrl(url);\n const host = target.hostname.replace(/^\\[|\\]$/g, '');\n let dnsTimer: ReturnType<typeof setTimeout> | undefined;\n const answers = isIP(host)\n ? [{ address: host, family: isIP(host) }]\n : await Promise.race([\n lookup(host, { all: true, verbatim: true }),\n new Promise<never>((_, reject) => {\n dnsTimer = setTimeout(\n () => reject(new Error('Webhook DNS timeout.')),\n 5000,\n );\n dnsTimer.unref();\n }),\n ]).finally(() => clearTimeout(dnsTimer));\n if (\n !answers.length ||\n answers.some(({ address }) => !isPublicAddress(address))\n ) {\n throw new Error('Webhook destination is not public.');\n }\n const pinned = answers[0];\n return new Promise<number>((resolve, reject) => {\n const req = request(\n target,\n {\n method: 'POST',\n agent: false,\n headers: {\n ...headers,\n 'content-length': Buffer.byteLength(body).toString(),\n },\n lookup: (_hostname, options, callback) => {\n if (options.all) callback(null, [pinned]);\n else callback(null, pinned.address, pinned.family);\n },\n },\n (response) => {\n const status = response.statusCode ?? 0;\n response.destroy();\n resolve(status);\n },\n );\n const timer = setTimeout(\n () => req.destroy(new Error('Webhook request timeout.')),\n 10000,\n );\n req.on('close', () => clearTimeout(timer));\n req.on('error', () => reject(new Error('Webhook transport failed.')));\n req.end(body);\n });\n};\n","import {\n field,\n foreignKey,\n SmrtCollection,\n SmrtObject,\n smrt,\n} from '@happyvertical/smrt-core';\nimport {\n backgroundEligible,\n type JobExecutionContext,\n} from '@happyvertical/smrt-jobs';\nimport { TenantScoped, tenantId } from '@happyvertical/smrt-tenancy';\nimport { assertHmacSecret, assertWebhookUrl } from './security.js';\n\n/** Server-only subscription. Hosts authorize administration before accessing it. */\n@smrt({\n tableName: '_smrt_webhook_subscriptions',\n api: false,\n cli: false,\n mcp: false,\n})\n@TenantScoped({ mode: 'required' })\nexport class WebhookSubscription extends SmrtObject { static __smrtPackage__ = \"@happyvertical/smrt-webhooks\";\n @tenantId() tenantId: string = '';\n @field({ type: 'text', required: true }) url: string = '';\n @field({ type: 'json' }) events: string[] = [];\n @field({ type: 'json' }) models: string[] = [];\n @field({ type: 'text', sensitive: true, required: true }) secret: string = '';\n @field({ type: 'boolean', default: true }) enabled: boolean = true;\n\n override async save(): Promise<this> {\n assertWebhookUrl(this.url);\n assertHmacSecret(this.secret);\n for (const values of [this.events, this.models]) {\n if (\n !Array.isArray(values) ||\n values.length > 100 ||\n values.some((v) => typeof v !== 'string' || !v || v.length > 200)\n ) {\n throw new Error('Webhook filters must be lists of nonempty names.');\n }\n }\n return super.save();\n }\n}\nexport class WebhookSubscriptionCollection extends SmrtCollection<WebhookSubscription> {\n static readonly _itemClass = WebhookSubscription;\n}\n\n/** Durable outbox and safe outcome metadata; payload never appears in public projections. */\n@smrt({\n tableName: '_smrt_webhook_deliveries',\n api: false,\n cli: false,\n mcp: false,\n indexes: [\n {\n name: '_smrt_webhook_delivery_identity',\n columns: ['tenantId', 'subscriptionId', 'eventId'],\n unique: true,\n },\n ],\n})\n@TenantScoped({ mode: 'required' })\nexport class WebhookDelivery extends SmrtObject { static __smrtPackage__ = \"@happyvertical/smrt-webhooks\";\n @tenantId() tenantId: string = '';\n @foreignKey(WebhookSubscription, {\n onUpdate: 'RESTRICT',\n onDelete: 'RESTRICT',\n })\n subscriptionId: string = '';\n @field({ type: 'text', required: true }) eventId: string = '';\n @field({ type: 'text', required: true, sensitive: true }) payload: string =\n '';\n @field({ type: 'text', default: 'pending' }) status: string = 'pending';\n @field({ type: 'integer', default: 0 }) generation: number = 0;\n @field({ type: 'integer', default: 0 }) attempts: number = 0;\n @field({ type: 'integer', nullable: true }) responseStatus: number | null =\n null;\n @field({ type: 'text', nullable: true }) error: string | null = null;\n @field({ type: 'text', nullable: true, sensitive: true }) leaseToken:\n | string\n | null = null;\n @field({ type: 'datetime', nullable: true }) leaseUntil: Date | null = null;\n}\nexport class WebhookDeliveryCollection extends SmrtCollection<WebhookDelivery> {\n static readonly _itemClass = WebhookDelivery;\n}\n\n/** Jobs queue entry point; persisted arguments contain IDs only. */\n@smrt({\n tableName: '_smrt_webhook_delivery_tasks',\n api: false,\n cli: false,\n mcp: false,\n})\nexport class WebhookDeliveryTask extends SmrtObject { static __smrtPackage__ = \"@happyvertical/smrt-webhooks\";\n @backgroundEligible()\n async deliver(\n args: Record<string, unknown> = {},\n _context?: JobExecutionContext,\n ): Promise<void> {\n const { runWebhookDeliveryJob } = await import('./dispatcher.js');\n await runWebhookDeliveryJob(args);\n }\n}\n\n/** Tenant-bound durable cursor for replayable model change-feed publication. */\n@smrt({\n tableName: '_smrt_webhook_cursors',\n api: false,\n cli: false,\n mcp: false,\n indexes: [\n {\n name: '_smrt_webhook_cursor_identity',\n columns: ['tenantId', 'runtime'],\n unique: true,\n },\n ],\n})\n@TenantScoped({ mode: 'required' })\nexport class WebhookCursor extends SmrtObject { static __smrtPackage__ = \"@happyvertical/smrt-webhooks\";\n @tenantId() tenantId: string = '';\n @field({ type: 'text', required: true }) runtime: string = '';\n @field({ type: 'integer', default: 0 }) position: number = 0;\n}\n","import { requireTenant } from '@happyvertical/smrt-tenancy';\nimport { WebhookSubscriptionCollection } from './models.js';\nimport type { WebhookStore } from './store.js';\nimport type {\n WebhookDeliveryRecord,\n WebhookSubscriptionRecord,\n} from './types.js';\nexport interface WebhookAdminView {\n subscriptions: WebhookSubscriptionRecord[];\n deliveries: WebhookDeliveryRecord[];\n}\n/** Allowlisted projection; never spread persistence objects into a browser response. */\nexport function webhookAdminView(\n subscriptions: WebhookSubscriptionRecord[],\n deliveries: WebhookDeliveryRecord[],\n): WebhookAdminView {\n return {\n subscriptions: subscriptions.map((s) => ({\n id: s.id,\n tenantId: s.tenantId,\n url: s.url,\n models: s.models,\n events: s.events,\n enabled: s.enabled,\n })),\n deliveries: deliveries.map((d) => ({\n id: d.id,\n tenantId: d.tenantId,\n subscriptionId: d.subscriptionId,\n eventId: d.eventId,\n status: d.status,\n attempts: d.attempts,\n responseStatus: d.responseStatus,\n error: d.error,\n })),\n };\n}\n/** Host supplies its administrator permission check and authenticated tenant context. */\nexport class WebhookAdminService {\n constructor(\n private readonly store: WebhookStore,\n private readonly authorize: () => Promise<boolean>,\n ) {}\n private async guard() {\n requireTenant();\n if (!(await this.authorize()))\n throw new Error('Webhook administration forbidden.');\n }\n async view(): Promise<WebhookAdminView> {\n await this.guard();\n await this.store.reconcile();\n const subscriptions = await this.store.subscriptions();\n const deliveries = await this.store.deliveries();\n return webhookAdminView(\n subscriptions.map((s) => ({ ...s, id: s.id ?? '' })),\n deliveries.map((d) => ({ ...d, id: d.id ?? '' })),\n );\n }\n async create(input: {\n url: string;\n events: string[];\n models: string[];\n secret: string;\n }): Promise<void> {\n await this.guard();\n const collection = await WebhookSubscriptionCollection.create({\n db: this.store.db,\n });\n const sub = await collection.create({\n tenantId: requireTenant().tenantId,\n url: input.url,\n events: input.events,\n models: input.models,\n secret: input.secret,\n enabled: true,\n });\n await sub.save();\n }\n async toggle(id: string, enabled: boolean): Promise<void> {\n await this.guard();\n if (typeof enabled !== 'boolean') throw new Error('Invalid enabled value.');\n const sub = await this.store.subscription(id);\n if (!sub) throw new Error('Webhook subscription not found.');\n sub.enabled = enabled;\n await sub.save();\n }\n async replay(id: string): Promise<boolean> {\n await this.guard();\n return this.store.replay(id);\n }\n}\n"],"mappings":"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"}
|