nv-endpoint-routed-tool-provider
GitHub构建或重构工具渠道提供商,实现基于端点的路由模式。支持按订阅者加密密钥、无状态发送及跳过无效步骤。需先进行文档发现与架构验证,适用于PagerDuty/Opsgenie等告警工具的标准化改造。
Trigger Scenarios
Install
npx skills add novuhq/novu --skill nv-endpoint-routed-tool-provider -g -y
SKILL.md
Frontmatter
{
"name": "nv-endpoint-routed-tool-provider",
"description": "Build or refactor a Tool-channel provider (PagerDuty, Opsgenie, future incident\/alerting tools) to be endpoint-routed: per-subscriber secrets encrypted on the channel endpoint resource, a stateless provider that resolves routing from channelData at send time, SKIPPED steps when no endpoint exists, and the full API\/worker\/dashboard\/docs\/playground surface. Starts with a mandatory provider-docs discovery gate. Use when refactoring Opsgenie to the PagerDuty model, adding a new tool provider, or touching pagerduty_service channel endpoints."
}
Endpoint-Routed Tool Provider (the PagerDuty model)
PagerDuty is the reference implementation. "Endpoint-routed" means the provider
has no environment-level credentials: every subscriber registers their own
secret (e.g. a PagerDuty Events API v2 routing key) as a channel endpoint, and
one trigger fans out to a different external service per subscriber. Grep for
pagerduty_service / ENDPOINT_ROUTED_TOOL_PROVIDERS to find every touch
point of the reference implementation.
Step 0: Provider discovery gate (mandatory, before any code)
Every invariant and checklist item below encodes a fact from PagerDuty's
Events API v2 docs (32-char key regex, us/eu endpoints, dedup_key, 1024-char
summary limit). Do not transplant those facts onto another provider. First,
read the target provider's official API docs and pin down, with citations:
- Per-recipient secret: name, format (drives the validator regex and DTO
@Matches), and scope. It must be issuable per recipient/service. - Regional endpoints: the
regionequivalent, or none. - Idempotency: the
dedup_keyequivalent and its retry semantics. - Payload contract: required fields, size limits, allowed actions and
severities (drives defaults,
RESERVED_OVERRIDE_KEYS, and truncation). - Auth transport: secret in body vs header.
Then apply the gate:
- Open question that changes the design (secret scope, missing idempotency, OAuth-only auth, ...): ask the user with concrete options. Do not assume.
- An architecture invariant cannot be satisfied (e.g. no per-recipient secret exists): halt. Explain which invariant fails and name the alternatives (credential-routed tool provider, OAuth connection model) instead of forcing this pattern.
- All questions answered: record the findings (they become the validator, provider defaults, and docs content) and proceed to the checklist.
Architecture invariants (do not renegotiate these)
- Sensitive routing fields encrypt on
ChannelEndpoint.endpoint; connections are OAuth-only. The wire shape (e.g.{ routingKey, region }) is accepted and returned by the API. Sensitive fields (routing key, API key, webhook URL and header values) are persisted encrypted on the endpoint document viaencryptChannelEndpoint. Non-sensitive companions (region,method) stay plaintext. No syntheticChannelConnectionis created for these types. - Exactly one endpoint per (environment, subscriber, integration),
enforced by a Mongo partial unique index on the endpoint
type. DuplicatePOST→409 Conflict; rotation is aPATCHon the existing endpoint. (tool_webhookis the exception: many endpoints per subscriber are allowed.) - The provider is stateless. Constructor takes no credentials. Routing is
resolved inside
sendMessagefromoptions.channelData.endpoint, guarded byisChannelDataOfType(channelData, ENDPOINT_TYPES.X). Missing/wrong channelData throws. - No endpoint → step is SKIPPED, never errored. The worker send loop
checks
ENDPOINT_ROUTED_TOOL_PROVIDERS(aSetinsend-message-tool.usecase.ts); credential-routed tool providers still fall back tointegration.credentialswhen that pattern applies. - Deterministic
dedup_keyfromtransactionId + subscriberId + stepIdso worker retries update the same incident instead of duplicating it. An explicit override wins. - Secrets never leak: not in logs, execution details, or message
documents. Sensitive endpoint fields are encrypted at rest by
encryptChannelEndpoint. createSubscriberIfMissing(optional boolean on the create-endpoint body) JIT-creates the subscriber viaCreateOrUpdateSubscriberUseCasewithallowUpdate: false(create-only, never mutates). Without it, unknown subscriberId → 404 whose message names the flag. Connect surfaces are often the user's first Novu touchpoint, so client guides should passtrue.
Implementation checklist (slice order matters)
Only start after the discovery gate has passed. Work bottom-up; each slice must build before the next (see Build order below).
A. Foundations (packages/shared, packages/stateless, libs/dal, libs/application-generic)
- Add
X_TYPEtoENDPOINT_TYPES+ wire shape inChannelEndpointByType—packages/shared/src/types/channel-endpoint.ts - Mirror both in
packages/stateless/src/lib/provider/channel-data.type.ts; add theXDatatype toChannelDataand toENDPOINT_TYPES_REQUIRING_TOKEN - Partial unique index in
libs/dal/src/repositories/channel-endpoint/channel-endpoint.schema.ts({ _environmentId, subscriberId, integrationIdentifier, type },partialFilterExpression: { type }) when cardinality is 1:1 - Extend
encryptChannelEndpoint/ decrypt helpers inlibs/application-generic/src/encryption/so sensitive endpoint fields encrypt at rest (+ spec) - Validator entry in
libs/application-generic/src/schemas/channel-endpoint/channel-endpoint.schema.ts(format-validate the secret, e.g./^[a-zA-Z0-9]{32}$/, and reject extra keys)
B. Provider (packages/providers, libs/application-generic)
- Stateless provider in
packages/providers/src/lib/tool/<provider>/— resolve routing fromchannelData, deterministic dedup, reserved-override handling (+ spec) - Handler in
libs/application-generic/src/factories/tool/handlers/—buildProviderignoresICredentials - Empty credentials config in
packages/shared/src/consts/providers/credentials/provider-credentials.ts(export const xConfig: IConfigCredential[] = []) with a comment pointing at the channel-endpoints API
C. API (apps/api/src/app/channel-endpoints)
- Endpoint DTO in
dtos/endpoint-types.dto.ts(regex-validate the secret) +Create<X>EndpointDtoindtos/create-channel-endpoint-variants.dto.ts - Controller:
@ApiExtraModels+oneOf+ discriminator mapping for the new DTO - Create usecase: persist the endpoint directly with
encryptChannelEndpoint(no connection create); duplicate-key → 409; update usecase re-encrypts rotated secrets on the endpoint; delete removes the endpoint document; get/list decrypt sensitive fields for the wire response - e2e in
e2e/create-channel-endpoint.e2e.ts: happy path, 409, rotation,createSubscriberIfMissing(404 hint / JIT create / no-mutation)
D. Worker (apps/worker/src/app/workflow/usecases/send-message)
-
resolve-channel-endpoints.usecase.ts: decrypt the endpoint document intochannelData.endpoint -
send-message-tool.usecase.ts: add the provider id toENDPOINT_ROUTED_TOOL_PROVIDERS; verify SKIPPED + execution detail on missing endpoint (+ spec for the predicate)
E. Surface (dashboard, docs, playground)
- Provider const in
packages/shared/src/consts/providers/channels/tool.tswithdocReference→https://docs.novu.co/platform/integrations/tool/<provider>(rebuild@novu/sharedafter) - Docs guide
docs/platform/integrations/tool/<provider>.mdx+ register in theToolgroup ofdocs/docs.json— see reference.md for the required page structure - Playground demo under
playground/nextjsmirroring the PagerDuty trio:src/lib/<provider>-endpoint-connect.ts,src/pages/api/<provider>-endpoint.ts,src/components/<provider>-end-user-connect.tsx+ Clerk-gated page + SideNav entry
For per-file code patterns (index definition, provider skeleton, usecase transaction shape, DTO union, worker extraction, playground helper contract), see reference.md.
Opsgenie refactor note
Opsgenie is credential-routed today (env-level apiKey). Refactoring it to
this model = running the checklist with opsgenie_* as the endpoint type and
removing it from the credential-routed fallback: empty opsgenieConfig,
drop the API-key read in opsgenie.handler.ts, add it to
ENDPOINT_ROUTED_TOOL_PROVIDERS. PagerDuty was never released so it had no
migration; Opsgenie may need one — check for existing integrations with
credentials before deleting the old path. The discovery gate still applies:
confirm Opsgenie's Alert API supports per-recipient API keys and idempotent
alert deduplication before assuming parity with PagerDuty.
Build order & verification
# types flow stateless → providers → application-generic; skipping a step
# yields phantom TS errors (missing channelData on IToolOptions, etc.)
pnpm --filter @novu/shared build
pnpm --filter @novu/stateless build
pnpm --filter @novu/providers build
pnpm --filter @novu/application-generic build
# provider unit tests
CI=true pnpm --filter @novu/providers exec vitest run src/lib/tool/<provider>
# channel-endpoints e2e (from apps/api)
pnpm exec cross-env NODE_ENV=test CI_EE_TEST=true CLERK_ENABLED=true \
NODE_OPTIONS=--max_old_space_size=8192 mocha --timeout 30000 --retries 3 \
--grep '#novu-v2' --require ./swc-register.js --exit --file e2e/setup.ts \
'src/**/create-channel-endpoint.e2e{,-ee}.ts'
Gotchas learned the hard way
- Module DI: JIT subscriber creation needs
SharedModule+CreateOrUpdateSubscriberUseCase+UpdateSubscriber+UpdateSubscriberChannelas providers inchannel-endpoints.module.ts(mirrorchannel-connections.module.tsfor the DI pattern only; these tool endpoints do not create connections). - Worker/app-generic specs need
STORE_ENCRYPTION_KEY(32 chars) in the env orencryptChannelEndpointthrows a Buffer TypeError. @novu/apiSDK lags: until the OpenAPI regen runs, the internal SDK's create-endpoint union won't include the new DTO — playground/demo code calls the raw REST endpoint (novuFetchpattern) and swaps to the SDK later. Never editlibs/internal-sdkby hand.- Dashboard View Guide CTA: zero-credential providers skip the credentials
accordion; the docs CTA in
integration-settings.tsxmust live outside theproviderCredentials.length > 0block or it never renders. - Dashboard gating: the Tool channel is behind the
IS_TOOL_CHANNEL_ENABLEDLaunchDarkly flag — check it before debugging a "missing provider" in the integration store. - Secrets in reads: Novu returns secrets on read and masks client-side
(last 4 chars,
••••XXXX) — follow that convention, don't invent server-side masking. - Writing style: no em dashes (
—) in this feature's docs/UI copy; use periods, colons, or parentheses.
Version History
- 56a8a16 Current 2026-08-29 05:10


