@pattern-stack/codegen 0.4.0 → 0.4.2
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/CHANGELOG.md +14 -0
- package/dist/src/cli/index.js +1616 -1070
- package/dist/src/cli/index.js.map +1 -1
- package/package.json +3 -1
- package/runtime/analytics/index.ts +31 -0
- package/runtime/analytics/metrics.ts +85 -0
- package/runtime/analytics/packs/crm-entity-measures.ts +20 -0
- package/runtime/analytics/packs/index.ts +5 -0
- package/runtime/analytics/packs/monetary-measures.ts +20 -0
- package/runtime/analytics/specs.ts +54 -0
- package/runtime/analytics/types.ts +105 -0
- package/runtime/base-classes/activity-entity-repository.ts +50 -0
- package/runtime/base-classes/activity-entity-service.ts +48 -0
- package/runtime/base-classes/base-read-use-cases.ts +88 -0
- package/runtime/base-classes/base-repository.ts +289 -0
- package/runtime/base-classes/base-service.ts +183 -0
- package/runtime/base-classes/index.ts +38 -0
- package/runtime/base-classes/knowledge-entity-repository.ts +12 -0
- package/runtime/base-classes/knowledge-entity-service.ts +14 -0
- package/runtime/base-classes/lifecycle-events.ts +152 -0
- package/runtime/base-classes/metadata-entity-repository.ts +80 -0
- package/runtime/base-classes/metadata-entity-service.ts +48 -0
- package/runtime/base-classes/synced-entity-repository.ts +57 -0
- package/runtime/base-classes/synced-entity-service.ts +50 -0
- package/runtime/base-classes/with-analytics.ts +22 -0
- package/runtime/constants/tokens.ts +29 -0
- package/runtime/eav-helpers.ts +74 -0
- package/runtime/pipes/zod-validation.pipe.ts +64 -0
- package/runtime/shared/openapi/error-response.dto.ts +24 -0
- package/runtime/shared/openapi/errors.ts +39 -0
- package/runtime/shared/openapi/index.ts +20 -0
- package/runtime/shared/openapi/registry.tokens.ts +13 -0
- package/runtime/shared/openapi/registry.ts +151 -0
- package/runtime/subsystems/analytics/analytics-query.protocol.ts +37 -0
- package/runtime/subsystems/analytics/analytics.module.ts +64 -0
- package/runtime/subsystems/analytics/analytics.tokens.ts +24 -0
- package/runtime/subsystems/analytics/cube-backend.ts +75 -0
- package/runtime/subsystems/analytics/index.ts +15 -0
- package/runtime/subsystems/analytics/noop-backend.ts +27 -0
- package/runtime/subsystems/auth/auth.module.ts +91 -0
- package/runtime/subsystems/auth/auth.tokens.ts +27 -0
- package/runtime/subsystems/auth/backends/encryption-key/env.ts +76 -0
- package/runtime/subsystems/auth/backends/oauth-state-store/in-memory.ts +42 -0
- package/runtime/subsystems/auth/index.ts +77 -0
- package/runtime/subsystems/auth/protocols/auth-strategy.ts +46 -0
- package/runtime/subsystems/auth/protocols/encryption-key.ts +21 -0
- package/runtime/subsystems/auth/protocols/integration-store.ts +66 -0
- package/runtime/subsystems/auth/protocols/oauth-state-store.ts +16 -0
- package/runtime/subsystems/auth/runtime/integration-broken.error.ts +21 -0
- package/runtime/subsystems/auth/runtime/oauth2-refresh.strategy.ts +189 -0
- package/runtime/subsystems/auth/runtime/session-expired.error.ts +39 -0
- package/runtime/subsystems/auth/runtime/with-auth-retry.ts +50 -0
- package/runtime/subsystems/bridge/assert-tenant-id.ts +57 -0
- package/runtime/subsystems/bridge/bridge-delivery-handler.ts +220 -0
- package/runtime/subsystems/bridge/bridge-delivery.drizzle-backend.ts +149 -0
- package/runtime/subsystems/bridge/bridge-delivery.memory-backend.ts +140 -0
- package/runtime/subsystems/bridge/bridge-delivery.schema.ts +142 -0
- package/runtime/subsystems/bridge/bridge-errors.ts +112 -0
- package/runtime/subsystems/bridge/bridge-outbox-drain-hook.ts +175 -0
- package/runtime/subsystems/bridge/bridge.module.ts +160 -0
- package/runtime/subsystems/bridge/bridge.protocol.ts +351 -0
- package/runtime/subsystems/bridge/bridge.tokens.ts +68 -0
- package/runtime/subsystems/bridge/event-flow.service.ts +175 -0
- package/runtime/subsystems/bridge/generated/.gitkeep +0 -0
- package/runtime/subsystems/bridge/generated/registry.ts +6 -0
- package/runtime/subsystems/bridge/index.ts +84 -0
- package/runtime/subsystems/bridge/reserved-pools.ts +36 -0
- package/runtime/subsystems/cache/cache.drizzle-backend.ts +150 -0
- package/runtime/subsystems/cache/cache.memory-backend.ts +116 -0
- package/runtime/subsystems/cache/cache.module.ts +115 -0
- package/runtime/subsystems/cache/cache.protocol.ts +45 -0
- package/runtime/subsystems/cache/cache.schema.ts +27 -0
- package/runtime/subsystems/cache/cache.tokens.ts +17 -0
- package/runtime/subsystems/cache/index.ts +22 -0
- package/runtime/subsystems/events/domain-events.schema.ts +77 -0
- package/runtime/subsystems/events/event-bus.drizzle-backend.ts +327 -0
- package/runtime/subsystems/events/event-bus.memory-backend.ts +142 -0
- package/runtime/subsystems/events/event-bus.protocol.ts +86 -0
- package/runtime/subsystems/events/event-bus.redis-backend.ts +304 -0
- package/runtime/subsystems/events/events-errors.ts +30 -0
- package/runtime/subsystems/events/events.module.ts +230 -0
- package/runtime/subsystems/events/events.tokens.ts +62 -0
- package/runtime/subsystems/events/generated/bus.ts +103 -0
- package/runtime/subsystems/events/generated/index.ts +7 -0
- package/runtime/subsystems/events/generated/registry.ts +84 -0
- package/runtime/subsystems/events/generated/schemas.ts +59 -0
- package/runtime/subsystems/events/generated/types.ts +94 -0
- package/runtime/subsystems/events/index.ts +21 -0
- package/runtime/subsystems/index.ts +63 -0
- package/runtime/subsystems/jobs/generated/job-orchestration.schema.multi-tenant.ts +217 -0
- package/runtime/subsystems/jobs/generated/job-orchestration.schema.single-tenant.ts +217 -0
- package/runtime/subsystems/jobs/generated/scope-entity-type.ts +10 -0
- package/runtime/subsystems/jobs/index.ts +120 -0
- package/runtime/subsystems/jobs/job-handler.base.ts +206 -0
- package/runtime/subsystems/jobs/job-orchestration.schema.ts +217 -0
- package/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.ts +536 -0
- package/runtime/subsystems/jobs/job-orchestrator.memory-backend.ts +850 -0
- package/runtime/subsystems/jobs/job-orchestrator.protocol.ts +179 -0
- package/runtime/subsystems/jobs/job-run-service.drizzle-backend.ts +171 -0
- package/runtime/subsystems/jobs/job-run-service.memory-backend.ts +165 -0
- package/runtime/subsystems/jobs/job-run-service.protocol.ts +79 -0
- package/runtime/subsystems/jobs/job-step-service.drizzle-backend.ts +66 -0
- package/runtime/subsystems/jobs/job-step-service.memory-backend.ts +119 -0
- package/runtime/subsystems/jobs/job-step-service.protocol.ts +53 -0
- package/runtime/subsystems/jobs/job-worker.module.ts +302 -0
- package/runtime/subsystems/jobs/job-worker.ts +615 -0
- package/runtime/subsystems/jobs/jobs-domain.module.ts +119 -0
- package/runtime/subsystems/jobs/jobs-domain.tokens.ts +30 -0
- package/runtime/subsystems/jobs/jobs-errors.ts +150 -0
- package/runtime/subsystems/jobs/memory-job-store.ts +35 -0
- package/runtime/subsystems/jobs/pool-config.loader.ts +218 -0
- package/runtime/subsystems/storage/index.ts +18 -0
- package/runtime/subsystems/storage/storage.local-backend.ts +113 -0
- package/runtime/subsystems/storage/storage.memory-backend.ts +78 -0
- package/runtime/subsystems/storage/storage.module.ts +60 -0
- package/runtime/subsystems/storage/storage.protocol.ts +78 -0
- package/runtime/subsystems/storage/storage.tokens.ts +9 -0
- package/runtime/subsystems/storage/storage.utils.ts +20 -0
- package/runtime/subsystems/sync/deep-equal.differ.ts +198 -0
- package/runtime/subsystems/sync/execute-sync.use-case.ts +334 -0
- package/runtime/subsystems/sync/index.ts +98 -0
- package/runtime/subsystems/sync/sync-audit.schema.ts +300 -0
- package/runtime/subsystems/sync/sync-change-source.protocol.ts +99 -0
- package/runtime/subsystems/sync/sync-cursor-store.drizzle-backend.ts +104 -0
- package/runtime/subsystems/sync/sync-cursor-store.memory-backend.ts +64 -0
- package/runtime/subsystems/sync/sync-cursor-store.protocol.ts +53 -0
- package/runtime/subsystems/sync/sync-errors.ts +54 -0
- package/runtime/subsystems/sync/sync-field-diff.protocol.ts +61 -0
- package/runtime/subsystems/sync/sync-loopback.protocol.ts +33 -0
- package/runtime/subsystems/sync/sync-run-recorder.drizzle-backend.ts +123 -0
- package/runtime/subsystems/sync/sync-run-recorder.memory-backend.ts +143 -0
- package/runtime/subsystems/sync/sync-run-recorder.protocol.ts +86 -0
- package/runtime/subsystems/sync/sync-sink.protocol.ts +55 -0
- package/runtime/subsystems/sync/sync.module.ts +156 -0
- package/runtime/subsystems/sync/sync.tokens.ts +57 -0
- package/runtime/types/drizzle.ts +23 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth subsystem — integration storage ports.
|
|
3
|
+
*
|
|
4
|
+
* `OAuth2RefreshStrategy` reads decrypted integration rows and persists
|
|
5
|
+
* refreshed tokens. The subsystem doesn't care what entity framework stores
|
|
6
|
+
* those rows — consumers implement these narrow ports against whatever
|
|
7
|
+
* `integrations` table their app uses.
|
|
8
|
+
*
|
|
9
|
+
* In dealbrain-v2 (the extraction source) both ports are satisfied by a
|
|
10
|
+
* pair of thin adapters over `IntegrationService` + `RefreshIntegrationUseCase`.
|
|
11
|
+
* The codegen-patterns `examples/auth-integrations/` starter (separate PR)
|
|
12
|
+
* ships a canonical `integration.yaml` whose generated service + use case
|
|
13
|
+
* satisfy the shape out of the box.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* An integration row with its secrets decrypted and ready to use.
|
|
18
|
+
*
|
|
19
|
+
* Consumers produce this shape from their own storage by passing stored
|
|
20
|
+
* ciphertexts through `IEncryptionKey.decrypt`. The subsystem never sees
|
|
21
|
+
* the ciphertext form.
|
|
22
|
+
*/
|
|
23
|
+
export interface DecryptedIntegration {
|
|
24
|
+
id: string;
|
|
25
|
+
/** Provider slug — must match the strategy's `provider`. */
|
|
26
|
+
provider: string;
|
|
27
|
+
/** Plaintext access token, or empty string if never granted. */
|
|
28
|
+
accessToken: string;
|
|
29
|
+
/** Plaintext refresh token, or null if not yet granted / revoked. */
|
|
30
|
+
refreshToken: string | null;
|
|
31
|
+
/** Access-token expiry wall time, or null if unknown. */
|
|
32
|
+
expiresAt: Date | null;
|
|
33
|
+
/** Opaque provider-specific metadata bag (instance URL, scopes, …). */
|
|
34
|
+
providerMetadata?: Record<string, unknown> | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Read port — fetches a decrypted integration by id.
|
|
39
|
+
*
|
|
40
|
+
* Adapters typically wrap a service/repo call that does the decryption
|
|
41
|
+
* internally. `OAuth2RefreshStrategy.resolve()` calls this on every invocation.
|
|
42
|
+
*/
|
|
43
|
+
export interface IIntegrationReader {
|
|
44
|
+
findByIdDecrypted(integrationId: string): Promise<DecryptedIntegration | null>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Write port — persists a refreshed access token (and optionally rotated
|
|
49
|
+
* refresh token) with the new expiry.
|
|
50
|
+
*
|
|
51
|
+
* The subsystem calls this after a successful refresh. Implementations are
|
|
52
|
+
* responsible for re-encrypting the tokens before they hit storage.
|
|
53
|
+
*
|
|
54
|
+
* `refreshToken` semantics: `undefined` means "provider did not rotate; keep
|
|
55
|
+
* existing ciphertext". A rotated token comes through as a string.
|
|
56
|
+
*/
|
|
57
|
+
export interface IntegrationTokenUpdate {
|
|
58
|
+
integrationId: string;
|
|
59
|
+
accessToken: string;
|
|
60
|
+
refreshToken?: string;
|
|
61
|
+
expiresAt: Date;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface IIntegrationTokenWriter {
|
|
65
|
+
persistRefresh(update: IntegrationTokenUpdate): Promise<void>;
|
|
66
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth subsystem — `IOAuthStateStore` port.
|
|
3
|
+
*
|
|
4
|
+
* CSRF protection for the OAuth2 authorize-code callback. Generic across
|
|
5
|
+
* providers. Concrete backends live under `../backends/oauth-state-store/`.
|
|
6
|
+
*/
|
|
7
|
+
export interface OAuthStateEntry {
|
|
8
|
+
userId: string;
|
|
9
|
+
createdAt: Date;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface IOAuthStateStore {
|
|
13
|
+
put(state: string, entry: OAuthStateEntry): Promise<void>;
|
|
14
|
+
/** Single-use consume: returns entry if present + valid, deletes it. */
|
|
15
|
+
consume(state: string): Promise<OAuthStateEntry | null>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thrown when an OAuth2 provider returns `400 invalid_grant`/`invalid_token`
|
|
3
|
+
* on refresh — the refresh token itself is dead (user revoked, org
|
|
4
|
+
* deactivated, token expired beyond the provider's rotation window). The
|
|
5
|
+
* integration should be marked broken so background sync stops picking it
|
|
6
|
+
* up; the user re-initiates OAuth.
|
|
7
|
+
*
|
|
8
|
+
* Shared across every OAuth2 strategy.
|
|
9
|
+
*/
|
|
10
|
+
export class IntegrationBrokenError extends Error {
|
|
11
|
+
constructor(
|
|
12
|
+
readonly integrationId: string,
|
|
13
|
+
readonly errorCode: string,
|
|
14
|
+
readonly errorDescription: string,
|
|
15
|
+
) {
|
|
16
|
+
super(
|
|
17
|
+
`Integration ${integrationId} broken: ${errorCode} - ${errorDescription}`,
|
|
18
|
+
);
|
|
19
|
+
this.name = 'IntegrationBrokenError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Abstract base class for OAuth2 refresh-token strategies.
|
|
3
|
+
*
|
|
4
|
+
* Template-method pattern: `resolve()` is concrete; four small hooks inject
|
|
5
|
+
* provider specifics. Validated across two providers in dealbrain-v2
|
|
6
|
+
* (SalesforceAuthStrategy, HubSpotAuthStrategy) before extraction here — see
|
|
7
|
+
* `docs/gate-1-auth-extraction-findings.md` for the "build first, extract
|
|
8
|
+
* later" evidence.
|
|
9
|
+
*
|
|
10
|
+
* Subclass contract:
|
|
11
|
+
* - `provider` — slug matched against `integrations.provider`
|
|
12
|
+
* - `defaultExpiresInSec` — fallback when refresh response omits `expires_in`
|
|
13
|
+
* - `tokenEndpoint()` — URL to POST the refresh grant
|
|
14
|
+
* - `refreshBodyExtras()` — provider-specific body params
|
|
15
|
+
* - `parseRefreshResponse()` — raw JSON → ParsedRefreshResponse
|
|
16
|
+
* - `buildCredentials()` — stored or freshly-refreshed access token +
|
|
17
|
+
* integration + optional raw refresh response
|
|
18
|
+
* → provider credentials
|
|
19
|
+
*
|
|
20
|
+
* Base handles: expiry check w/ 5-min safety window, `forceRefresh` escape
|
|
21
|
+
* hatch, POST form-urlencoded body, OAuth2 error mapping to
|
|
22
|
+
* `IntegrationBrokenError`, refresh-token rotation persistence, fetch +
|
|
23
|
+
* clock injection for tests.
|
|
24
|
+
*/
|
|
25
|
+
import type {
|
|
26
|
+
AuthCredentials,
|
|
27
|
+
AuthResolveOptions,
|
|
28
|
+
IAuthStrategy,
|
|
29
|
+
} from '../protocols/auth-strategy';
|
|
30
|
+
import type {
|
|
31
|
+
DecryptedIntegration,
|
|
32
|
+
IIntegrationReader,
|
|
33
|
+
IIntegrationTokenWriter,
|
|
34
|
+
} from '../protocols/integration-store';
|
|
35
|
+
import { IntegrationBrokenError } from './integration-broken.error';
|
|
36
|
+
|
|
37
|
+
export type FetchLike = (
|
|
38
|
+
input: string | URL | Request,
|
|
39
|
+
init?: RequestInit,
|
|
40
|
+
) => Promise<Response>;
|
|
41
|
+
|
|
42
|
+
/** Safety window before expiry that triggers a refresh. */
|
|
43
|
+
const REFRESH_SAFETY_MS = 5 * 60 * 1000;
|
|
44
|
+
|
|
45
|
+
export interface OAuth2RefreshStrategyOptions {
|
|
46
|
+
integrationReader: IIntegrationReader;
|
|
47
|
+
tokenWriter: IIntegrationTokenWriter;
|
|
48
|
+
/** Injectable fetch for tests. Defaults to the global `fetch`. */
|
|
49
|
+
fetch?: FetchLike;
|
|
50
|
+
/** Injectable clock for tests. Defaults to `Date.now`. */
|
|
51
|
+
now?: () => number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ParsedRefreshResponse {
|
|
55
|
+
accessToken: string;
|
|
56
|
+
/**
|
|
57
|
+
* New refresh token if the provider rotated it (HubSpot: always, Salesforce:
|
|
58
|
+
* sometimes). Omit when the provider reused the old refresh token.
|
|
59
|
+
*/
|
|
60
|
+
refreshToken?: string;
|
|
61
|
+
/** Seconds from now. If omitted, subclass `defaultExpiresInSec` applies. */
|
|
62
|
+
expiresInSec?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export abstract class OAuth2RefreshStrategy implements IAuthStrategy {
|
|
66
|
+
protected abstract readonly provider: string;
|
|
67
|
+
protected abstract readonly defaultExpiresInSec: number;
|
|
68
|
+
|
|
69
|
+
protected readonly integrationReader: IIntegrationReader;
|
|
70
|
+
protected readonly tokenWriter: IIntegrationTokenWriter;
|
|
71
|
+
protected readonly fetchImpl: FetchLike;
|
|
72
|
+
protected readonly now: () => number;
|
|
73
|
+
|
|
74
|
+
constructor(opts: OAuth2RefreshStrategyOptions) {
|
|
75
|
+
this.integrationReader = opts.integrationReader;
|
|
76
|
+
this.tokenWriter = opts.tokenWriter;
|
|
77
|
+
this.fetchImpl = opts.fetch ?? fetch;
|
|
78
|
+
this.now = opts.now ?? Date.now;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async resolve(
|
|
82
|
+
integrationId: string,
|
|
83
|
+
opts: AuthResolveOptions = {},
|
|
84
|
+
): Promise<AuthCredentials> {
|
|
85
|
+
const integration =
|
|
86
|
+
await this.integrationReader.findByIdDecrypted(integrationId);
|
|
87
|
+
if (!integration) {
|
|
88
|
+
throw new Error(`Integration ${integrationId} not found`);
|
|
89
|
+
}
|
|
90
|
+
if (integration.provider !== this.provider) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`${this.constructor.name} called for non-${this.provider} integration ${integrationId} (provider=${integration.provider})`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const needsRefresh =
|
|
97
|
+
opts.forceRefresh ||
|
|
98
|
+
this.isExpiring(integration.expiresAt) ||
|
|
99
|
+
!integration.accessToken;
|
|
100
|
+
|
|
101
|
+
if (!needsRefresh) {
|
|
102
|
+
return this.buildCredentials(integration.accessToken, integration);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!integration.refreshToken) {
|
|
106
|
+
throw new IntegrationBrokenError(
|
|
107
|
+
integrationId,
|
|
108
|
+
'no_refresh_token',
|
|
109
|
+
'Integration has no refresh token; user must reconnect',
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const { parsed, raw } = await this.executeRefresh(
|
|
114
|
+
integrationId,
|
|
115
|
+
integration.refreshToken,
|
|
116
|
+
);
|
|
117
|
+
const newExpiresAt = new Date(
|
|
118
|
+
this.now() + (parsed.expiresInSec ?? this.defaultExpiresInSec) * 1000,
|
|
119
|
+
);
|
|
120
|
+
await this.tokenWriter.persistRefresh({
|
|
121
|
+
integrationId,
|
|
122
|
+
accessToken: parsed.accessToken,
|
|
123
|
+
refreshToken: parsed.refreshToken ?? undefined,
|
|
124
|
+
expiresAt: newExpiresAt,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
return this.buildCredentials(parsed.accessToken, integration, raw);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
protected abstract tokenEndpoint(): string;
|
|
131
|
+
protected abstract refreshBodyExtras(): Record<string, string>;
|
|
132
|
+
protected abstract parseRefreshResponse(raw: unknown): ParsedRefreshResponse;
|
|
133
|
+
protected abstract buildCredentials(
|
|
134
|
+
accessToken: string,
|
|
135
|
+
integration: DecryptedIntegration,
|
|
136
|
+
refreshRaw?: unknown,
|
|
137
|
+
): AuthCredentials;
|
|
138
|
+
|
|
139
|
+
private async executeRefresh(
|
|
140
|
+
integrationId: string,
|
|
141
|
+
refreshToken: string,
|
|
142
|
+
): Promise<{ parsed: ParsedRefreshResponse; raw: unknown }> {
|
|
143
|
+
const body = new URLSearchParams({
|
|
144
|
+
grant_type: 'refresh_token',
|
|
145
|
+
refresh_token: refreshToken,
|
|
146
|
+
...this.refreshBodyExtras(),
|
|
147
|
+
});
|
|
148
|
+
const response = await this.fetchImpl(this.tokenEndpoint(), {
|
|
149
|
+
method: 'POST',
|
|
150
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
151
|
+
body: body.toString(),
|
|
152
|
+
});
|
|
153
|
+
if (!response.ok) {
|
|
154
|
+
const err = (await safeJson(response)) as Partial<{
|
|
155
|
+
error: string;
|
|
156
|
+
error_description: string;
|
|
157
|
+
message: string;
|
|
158
|
+
}>;
|
|
159
|
+
if (
|
|
160
|
+
response.status === 400 &&
|
|
161
|
+
(err.error === 'invalid_grant' || err.error === 'invalid_token')
|
|
162
|
+
) {
|
|
163
|
+
throw new IntegrationBrokenError(
|
|
164
|
+
integrationId,
|
|
165
|
+
err.error ?? 'invalid_grant',
|
|
166
|
+
err.error_description ?? err.message ?? 'refresh token rejected',
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
throw new Error(
|
|
170
|
+
`${this.provider} token refresh failed: ${response.status} ${err.error ?? ''} ${err.error_description ?? err.message ?? ''}`.trim(),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
const raw = await response.json();
|
|
174
|
+
return { parsed: this.parseRefreshResponse(raw), raw };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private isExpiring(expiresAt: Date | null): boolean {
|
|
178
|
+
if (!expiresAt) return true;
|
|
179
|
+
return expiresAt.getTime() - this.now() < REFRESH_SAFETY_MS;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function safeJson(response: Response): Promise<unknown> {
|
|
184
|
+
try {
|
|
185
|
+
return await response.clone().json();
|
|
186
|
+
} catch {
|
|
187
|
+
return {};
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider-agnostic marker for "the access token was rejected; a forced
|
|
3
|
+
* refresh may recover."
|
|
4
|
+
*
|
|
5
|
+
* Concrete provider error classes (e.g. SalesforceSessionExpiredError,
|
|
6
|
+
* HubSpotUnauthorizedError) either extend `SessionExpiredError` directly or
|
|
7
|
+
* set `isSessionExpired === true` on their instances. `withAuthRetry` uses
|
|
8
|
+
* the `isSessionExpiredError` predicate to decide whether to force-refresh
|
|
9
|
+
* and retry once.
|
|
10
|
+
*
|
|
11
|
+
* This discriminator replaces the SFDC-only `instanceof` check from
|
|
12
|
+
* dealbrain-v2's original `withAuthRetry`. See
|
|
13
|
+
* `docs/gate-1-auth-extraction-findings.md` (recommendation 4).
|
|
14
|
+
*/
|
|
15
|
+
export class SessionExpiredError extends Error {
|
|
16
|
+
/** Duck-type marker — works across package boundaries where `instanceof` fails. */
|
|
17
|
+
readonly isSessionExpired = true as const;
|
|
18
|
+
|
|
19
|
+
constructor(message = 'Access token rejected by provider') {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = 'SessionExpiredError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Predicate used by `withAuthRetry` by default.
|
|
27
|
+
*
|
|
28
|
+
* Matches any error that either `instanceof SessionExpiredError` or carries
|
|
29
|
+
* the `isSessionExpired === true` marker property. Provider adapters that
|
|
30
|
+
* want their existing error classes to participate can simply add the
|
|
31
|
+
* marker property without touching the class hierarchy.
|
|
32
|
+
*/
|
|
33
|
+
export function isSessionExpiredError(err: unknown): boolean {
|
|
34
|
+
if (err instanceof SessionExpiredError) return true;
|
|
35
|
+
if (err !== null && typeof err === 'object' && 'isSessionExpired' in err) {
|
|
36
|
+
return (err as { isSessionExpired?: unknown }).isSessionExpired === true;
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run `op` with auth-aware retry-once on session-expired errors.
|
|
3
|
+
*
|
|
4
|
+
* Pattern: resolve creds → run op → if `isSessionExpired(e)` → resolve with
|
|
5
|
+
* `forceRefresh: true` → retry → propagate. A second session-expired error
|
|
6
|
+
* on the refreshed token propagates rather than looping, so transient
|
|
7
|
+
* adapter bugs can't hang the caller.
|
|
8
|
+
*
|
|
9
|
+
* Generalisation over dealbrain's original SFDC-specific version: the
|
|
10
|
+
* session-expired classifier is injected. Providers mark their session-
|
|
11
|
+
* expired errors (via `instanceof` of a marker class, or by setting a known
|
|
12
|
+
* property) and pass a classifier matching that shape.
|
|
13
|
+
*
|
|
14
|
+
* Default classifier recognises the marker interface `SessionExpiredError`
|
|
15
|
+
* shipped in `session-expired.error.ts` — concrete provider errors that
|
|
16
|
+
* extend it (or set `isSessionExpired === true`) get retried without any
|
|
17
|
+
* further wiring.
|
|
18
|
+
*/
|
|
19
|
+
import type {
|
|
20
|
+
AuthCredentials,
|
|
21
|
+
IAuthStrategy,
|
|
22
|
+
} from '../protocols/auth-strategy';
|
|
23
|
+
import { isSessionExpiredError } from './session-expired.error';
|
|
24
|
+
|
|
25
|
+
export interface WithAuthRetryOptions {
|
|
26
|
+
/**
|
|
27
|
+
* Classifier that decides whether a thrown error is a session-expired
|
|
28
|
+
* signal worth retrying once with a fresh token. Defaults to the marker-
|
|
29
|
+
* interface check in `session-expired.error.ts`.
|
|
30
|
+
*/
|
|
31
|
+
isSessionExpired?: (err: unknown) => boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function withAuthRetry<T>(
|
|
35
|
+
authStrategy: IAuthStrategy,
|
|
36
|
+
integrationId: string,
|
|
37
|
+
op: (credentials: AuthCredentials) => Promise<T>,
|
|
38
|
+
options: WithAuthRetryOptions = {},
|
|
39
|
+
): Promise<T> {
|
|
40
|
+
const classify = options.isSessionExpired ?? isSessionExpiredError;
|
|
41
|
+
|
|
42
|
+
let creds = await authStrategy.resolve(integrationId);
|
|
43
|
+
try {
|
|
44
|
+
return await op(creds);
|
|
45
|
+
} catch (e) {
|
|
46
|
+
if (!classify(e)) throw e;
|
|
47
|
+
creds = await authStrategy.resolve(integrationId, { forceRefresh: true });
|
|
48
|
+
return op(creds);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `assertTenantId` — shared multi-tenancy enforcement helper for the
|
|
3
|
+
* bridge subsystem (BRIDGE-8, ADR-023 §Multi-tenancy null-tenantId).
|
|
4
|
+
*
|
|
5
|
+
* Single source of truth for the three enforcement sites named in
|
|
6
|
+
* ADR-023 §Multi-tenancy and the BRIDGE-2 spec:
|
|
7
|
+
*
|
|
8
|
+
* (a) `EventFlowService.publishAndStart` — request-path entry
|
|
9
|
+
* (b) `BridgeDeliveryHandler.run` — wrapper handler entry
|
|
10
|
+
* (c) `DrizzleBridgeDeliveryRepo.insertDelivery` — last-line repo defense
|
|
11
|
+
*
|
|
12
|
+
* Contract (mirrors JOB-8 / SYNC-6 — locked 2026-04-18 for jobs and
|
|
13
|
+
* carried into the bridge here):
|
|
14
|
+
*
|
|
15
|
+
* - `multiTenant === false` → no-op (always passes).
|
|
16
|
+
* - `multiTenant === true`,
|
|
17
|
+
* `tenantId === undefined` → throw `MissingTenantIdError(site)`.
|
|
18
|
+
* - `multiTenant === true`,
|
|
19
|
+
* `tenantId === null` → passes; opts the call into
|
|
20
|
+
* cross-tenant work (system
|
|
21
|
+
* housekeeping, framework events
|
|
22
|
+
* with no owning tenant). Persists
|
|
23
|
+
* to the DB as `tenant_id = NULL`.
|
|
24
|
+
* - `multiTenant === true`,
|
|
25
|
+
* `tenantId` is a string → passes.
|
|
26
|
+
*
|
|
27
|
+
* The strict `undefined`-vs-`null` discrimination is the entire point —
|
|
28
|
+
* silent defaulting is exactly the failure mode that lets cross-tenant
|
|
29
|
+
* leaks ship.
|
|
30
|
+
*/
|
|
31
|
+
import { MissingTenantIdError } from './bridge-errors';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Throws `MissingTenantIdError(site)` if `multiTenant === true` and
|
|
35
|
+
* `tenantId === undefined`. Explicit `null` always passes.
|
|
36
|
+
*
|
|
37
|
+
* @param site Canonical site name — one of:
|
|
38
|
+
* `'EventFlowService.publishAndStart'`,
|
|
39
|
+
* `'BridgeDeliveryHandler.run'`,
|
|
40
|
+
* `'DrizzleBridgeDeliveryRepo.insertDelivery'`.
|
|
41
|
+
* Stable strings; ops dashboards / review reports key
|
|
42
|
+
* on these. Use the same string the existing tests
|
|
43
|
+
* and `MissingTenantIdError` JSDoc enumerate.
|
|
44
|
+
* @param multiTenant Resolved `BRIDGE_MULTI_TENANT` flag (from
|
|
45
|
+
* `BridgeModule.forRoot({ multiTenant })`).
|
|
46
|
+
* @param tenantId The tenantId the caller supplied (or didn't).
|
|
47
|
+
*/
|
|
48
|
+
export function assertTenantId(
|
|
49
|
+
site: string,
|
|
50
|
+
multiTenant: boolean,
|
|
51
|
+
tenantId: string | null | undefined,
|
|
52
|
+
): void {
|
|
53
|
+
if (multiTenant && tenantId === undefined) {
|
|
54
|
+
throw new MissingTenantIdError(site);
|
|
55
|
+
}
|
|
56
|
+
// explicit null passes — opts into cross-tenant work
|
|
57
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BridgeDeliveryHandler — the framework `@JobHandler` that runs every
|
|
3
|
+
* bridge-fanout wrapper on the reserved `events_*` pools (BRIDGE-5,
|
|
4
|
+
* ADR-023 §Decision 2 flow diagram).
|
|
5
|
+
*
|
|
6
|
+
* Role: when the outbox drain (BRIDGE-4) inserts a `bridge_delivery + wrapper
|
|
7
|
+
* job_run` pair, the worker that polls the wrapper's pool claims that
|
|
8
|
+
* wrapper and dispatches it to this handler. The handler:
|
|
9
|
+
*
|
|
10
|
+
* 1. Loads the `bridge_delivery` row by `ctx.input.deliveryId`.
|
|
11
|
+
* 2. Looks up the trigger entry in the codegen-emitted `bridgeRegistry`
|
|
12
|
+
* (`runtime/subsystems/bridge/generated/registry.ts`, BRIDGE-6).
|
|
13
|
+
* A missing entry means the trigger was renamed or removed since the
|
|
14
|
+
* delivery row was written; mark `skipped` with
|
|
15
|
+
* `skip_reason='trigger_unregistered'` per ADR-023 §Trigger rename
|
|
16
|
+
* or removal.
|
|
17
|
+
* 3. Re-fetches the authoritative `domain_events` row (`IEventBus.findById`)
|
|
18
|
+
* so `when:` / `map:` callbacks see the committed payload — never a
|
|
19
|
+
* copy that drifted between drain and claim time.
|
|
20
|
+
* 4. Evaluates `entry.when?.(event)`. False ⇒ mark `skipped` with
|
|
21
|
+
* `skip_reason='predicate_false'`.
|
|
22
|
+
* 5. Calls `IJobOrchestrator.start(entry.jobType, entry.map(event), …)`
|
|
23
|
+
* INSIDE `ctx.step('spawn_user_run', …)`. The step memoization is
|
|
24
|
+
* what makes wrapper retries (BRIDGE-1 ledger says no auto-retry past
|
|
25
|
+
* the wrapper's own retry policy, but Phase 1 wrappers DO retry per
|
|
26
|
+
* JOB-3) idempotent — a successful spawn followed by a transient
|
|
27
|
+
* ledger-update failure would otherwise re-spawn on the next attempt.
|
|
28
|
+
* 6. Marks `delivered` with the spawned `runId`.
|
|
29
|
+
*
|
|
30
|
+
* Pool registration: BRIDGE-5 ships ONE `@JobHandler` registration with
|
|
31
|
+
* `pool: 'events_change'` (the default). The wrapper rows the drain
|
|
32
|
+
* inserts carry `pool: events_<direction>` per row, so workers polling
|
|
33
|
+
* `events_inbound` / `events_outbound` claim and dispatch to this same
|
|
34
|
+
* handler class regardless of the metadata pool — the worker filter is on
|
|
35
|
+
* `job_run.pool`, not on `@JobHandler.meta.pool`. BRIDGE-8 confirms the
|
|
36
|
+
* three pools are active and registered at module init. The
|
|
37
|
+
* `@framework/*` job-type prefix exempts this registration from the
|
|
38
|
+
* reserved-pool validator (BRIDGE-5 added that exemption to
|
|
39
|
+
* `job-worker.module.ts`).
|
|
40
|
+
*
|
|
41
|
+
* Tenant threading: when `BRIDGE_MULTI_TENANT=true`, the handler asserts
|
|
42
|
+
* `delivery.tenantId !== undefined` before the spawn (the column is
|
|
43
|
+
* nullable, so explicit `null` is allowed for cross-tenant work — same
|
|
44
|
+
* contract as JOB-8). BRIDGE-8 wires the assertion via the
|
|
45
|
+
* `BRIDGE_MULTI_TENANT` token.
|
|
46
|
+
*
|
|
47
|
+
* Failure path: any throw inside the handler propagates up; the worker's
|
|
48
|
+
* normal retry policy (declared on the `@JobHandler` here as `attempts:
|
|
49
|
+
* 3, backoff: exponential, baseMs: 250`) absorbs transient infra blips.
|
|
50
|
+
* After exhaustion, the wrapper transitions to `failed`; the outer error
|
|
51
|
+
* handler catches and calls `repo.markFailed(...)` so the delivery row
|
|
52
|
+
* reflects the final state. Operators see `bridge_delivery.status='failed'`
|
|
53
|
+
* surface via the `idx_bridge_delivery_status` partial index (BRIDGE-1).
|
|
54
|
+
*/
|
|
55
|
+
import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
|
|
56
|
+
|
|
57
|
+
import { JOB_ORCHESTRATOR } from '../jobs/jobs-domain.tokens';
|
|
58
|
+
import type { IJobOrchestrator } from '../jobs/job-orchestrator.protocol';
|
|
59
|
+
import {
|
|
60
|
+
JobHandler,
|
|
61
|
+
JobHandlerBase,
|
|
62
|
+
type JobContext,
|
|
63
|
+
} from '../jobs/job-handler.base';
|
|
64
|
+
|
|
65
|
+
import { EVENT_BUS } from '../events/events.tokens';
|
|
66
|
+
import type { IEventBus, DomainEvent } from '../events/event-bus.protocol';
|
|
67
|
+
import type { EventTypeName } from '../events/generated/types';
|
|
68
|
+
|
|
69
|
+
import {
|
|
70
|
+
BRIDGE_DELIVERY_REPO,
|
|
71
|
+
BRIDGE_MULTI_TENANT,
|
|
72
|
+
BRIDGE_REGISTRY,
|
|
73
|
+
} from './bridge.tokens';
|
|
74
|
+
import type {
|
|
75
|
+
BridgeRegistry,
|
|
76
|
+
BridgeTriggerEntry,
|
|
77
|
+
IJobBridge,
|
|
78
|
+
} from './bridge.protocol';
|
|
79
|
+
import { assertTenantId } from './assert-tenant-id';
|
|
80
|
+
|
|
81
|
+
/** Stable canonical job type — referenced by BRIDGE-4 wrapper inserts. */
|
|
82
|
+
export const BRIDGE_DELIVERY_JOB_TYPE = '@framework/bridge_delivery' as const;
|
|
83
|
+
|
|
84
|
+
/** Stable canonical step id — referenced for memoization across attempts. */
|
|
85
|
+
const SPAWN_USER_RUN_STEP = 'spawn_user_run' as const;
|
|
86
|
+
|
|
87
|
+
export interface BridgeDeliveryInput {
|
|
88
|
+
/** PK of the `bridge_delivery` row this wrapper services. */
|
|
89
|
+
deliveryId: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@Injectable()
|
|
93
|
+
@JobHandler<BridgeDeliveryInput>(BRIDGE_DELIVERY_JOB_TYPE, {
|
|
94
|
+
pool: 'events_change',
|
|
95
|
+
retry: { attempts: 3, backoff: 'exponential', baseMs: 250 },
|
|
96
|
+
replayFrom: 'last_step',
|
|
97
|
+
})
|
|
98
|
+
export class BridgeDeliveryHandler extends JobHandlerBase<
|
|
99
|
+
BridgeDeliveryInput,
|
|
100
|
+
{ runId: string } | { skipped: true; reason: string }
|
|
101
|
+
> {
|
|
102
|
+
private readonly classLogger = new Logger(BridgeDeliveryHandler.name);
|
|
103
|
+
|
|
104
|
+
constructor(
|
|
105
|
+
@Inject(BRIDGE_DELIVERY_REPO) private readonly repo: IJobBridge,
|
|
106
|
+
@Inject(JOB_ORCHESTRATOR) private readonly orchestrator: IJobOrchestrator,
|
|
107
|
+
@Inject(EVENT_BUS) private readonly events: IEventBus,
|
|
108
|
+
@Inject(BRIDGE_REGISTRY) private readonly registry: BridgeRegistry,
|
|
109
|
+
@Optional()
|
|
110
|
+
@Inject(BRIDGE_MULTI_TENANT)
|
|
111
|
+
private readonly multiTenant: boolean = false,
|
|
112
|
+
) {
|
|
113
|
+
super();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async run(
|
|
117
|
+
ctx: JobContext<BridgeDeliveryInput>,
|
|
118
|
+
): Promise<{ runId: string } | { skipped: true; reason: string }> {
|
|
119
|
+
const { deliveryId } = ctx.input;
|
|
120
|
+
|
|
121
|
+
// Step 1 — locate the delivery row by primary key.
|
|
122
|
+
const delivery = await this.repo.findDeliveryById(deliveryId);
|
|
123
|
+
if (!delivery) {
|
|
124
|
+
// The drain wrote a wrapper job_run but the delivery row is gone
|
|
125
|
+
// (manual ops cleanup, or delete-cascade from the parent event).
|
|
126
|
+
// No row → no work; return without throwing so the wrapper marks
|
|
127
|
+
// completed cleanly.
|
|
128
|
+
this.classLogger.warn(
|
|
129
|
+
`bridge_delivery row '${deliveryId}' not found; wrapper completes ` +
|
|
130
|
+
`without spawning a user job.`,
|
|
131
|
+
);
|
|
132
|
+
return { skipped: true, reason: 'delivery_row_missing' };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Step 2 — multi-tenancy gate. Site (b) of the three ADR-023
|
|
136
|
+
// §Multi-tenancy enforcement sites; shared helper from BRIDGE-8.
|
|
137
|
+
// The DB always returns string|null, never undefined; this branch
|
|
138
|
+
// exists for the in-memory backend's older test fixtures and to
|
|
139
|
+
// pin the contract in shape-typed tests.
|
|
140
|
+
assertTenantId(
|
|
141
|
+
'BridgeDeliveryHandler.run',
|
|
142
|
+
this.multiTenant,
|
|
143
|
+
delivery.tenantId,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// Step 3 — load the typed event row.
|
|
147
|
+
const event = await this.events.findById(delivery.eventId);
|
|
148
|
+
if (!event) {
|
|
149
|
+
// FK from bridge_delivery.event_id → domain_events.id should make
|
|
150
|
+
// this impossible at the DB layer, but defensive: if the row is
|
|
151
|
+
// missing we mark skipped, not failed (no work the bridge can do).
|
|
152
|
+
this.classLogger.warn(
|
|
153
|
+
`domain_events row '${delivery.eventId}' missing for delivery ` +
|
|
154
|
+
`'${deliveryId}'; marking skipped.`,
|
|
155
|
+
);
|
|
156
|
+
await this.repo.markSkipped(delivery.id, 'event_row_missing');
|
|
157
|
+
return { skipped: true, reason: 'event_row_missing' };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Step 4 — registry lookup. Handles trigger rename/removal cleanly.
|
|
161
|
+
const entry = this.findRegistryEntry(event.type, delivery.triggerId);
|
|
162
|
+
if (!entry) {
|
|
163
|
+
await this.repo.markSkipped(delivery.id, 'trigger_unregistered');
|
|
164
|
+
return { skipped: true, reason: 'trigger_unregistered' };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Step 5 — `when:` predicate.
|
|
168
|
+
if (entry.when && !entry.when(event as never)) {
|
|
169
|
+
await this.repo.markSkipped(delivery.id, 'predicate_false');
|
|
170
|
+
return { skipped: true, reason: 'predicate_false' };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Step 6 — memoized spawn. `ctx.step` records the result in
|
|
174
|
+
// `job_step` and on retry returns the cached `{ runId }` so a
|
|
175
|
+
// transient failure between `orchestrator.start` and `markDelivered`
|
|
176
|
+
// doesn't double-spawn the user job.
|
|
177
|
+
const input = entry.map(event as never);
|
|
178
|
+
const { runId } = await ctx.step<{ runId: string }>(
|
|
179
|
+
SPAWN_USER_RUN_STEP,
|
|
180
|
+
async () => {
|
|
181
|
+
const run = await this.orchestrator.start(entry.jobType, input, {
|
|
182
|
+
parentRunId: ctx.run.id,
|
|
183
|
+
triggerSource: 'event',
|
|
184
|
+
triggerRef: delivery.eventId,
|
|
185
|
+
tenantId: delivery.tenantId,
|
|
186
|
+
});
|
|
187
|
+
return { runId: run.id };
|
|
188
|
+
},
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
// Step 7 — ledger transition.
|
|
192
|
+
await this.repo.markDelivered(delivery.id, runId);
|
|
193
|
+
return { runId };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Locate the registry entry for `(eventType, triggerId)`. Linear scan
|
|
198
|
+
* over the per-event-type array — N is the number of triggers declared
|
|
199
|
+
* for one event, typically 1–5; the table is not big enough to warrant
|
|
200
|
+
* a secondary index.
|
|
201
|
+
*/
|
|
202
|
+
private findRegistryEntry(
|
|
203
|
+
eventType: string,
|
|
204
|
+
triggerId: string,
|
|
205
|
+
): BridgeTriggerEntry | undefined {
|
|
206
|
+
const candidates =
|
|
207
|
+
this.registry[eventType as EventTypeName] ?? undefined;
|
|
208
|
+
if (!candidates) return undefined;
|
|
209
|
+
return candidates.find((c) => c.triggerId === triggerId) as
|
|
210
|
+
| BridgeTriggerEntry
|
|
211
|
+
| undefined;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Re-export for BRIDGE-7 facade Case B and BRIDGE-4 wrapper insert.
|
|
217
|
+
* Single source of truth for the canonical type string keeps refactors
|
|
218
|
+
* in one place.
|
|
219
|
+
*/
|
|
220
|
+
export { BRIDGE_DELIVERY_JOB_TYPE as BridgeDeliveryJobType };
|