@particle-academy/fancy-connector-core 0.0.0
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 +103 -0
- package/LICENSE +21 -0
- package/README.md +175 -0
- package/dist/index.cjs +1356 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2129 -0
- package/dist/index.d.ts +2129 -0
- package/dist/index.js +1256 -0
- package/dist/index.js.map +1 -0
- package/package.json +71 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,2129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Posting a sequence as a CHAIN — the bug that is silent in every direction.
|
|
3
|
+
*
|
|
4
|
+
* ## What went wrong in the reference implementation
|
|
5
|
+
*
|
|
6
|
+
* A renderer split long copy into `(1/3)`, `(2/3)`, `(3/3)` and the connector
|
|
7
|
+
* posted each segment **top-level**, on two providers. Numbered like a thread,
|
|
8
|
+
* connected to nothing. Nothing threw; the send reported success; the numbering
|
|
9
|
+
* made it look deliberate, so a reader assumes the thread exists and they missed
|
|
10
|
+
* it.
|
|
11
|
+
*
|
|
12
|
+
* It was unreachable by any test, because the chaining lived inside a loop that
|
|
13
|
+
* needed a live session. So the chain builder here **takes the post function as
|
|
14
|
+
* an argument** — which is the entire reason this is a module rather than four
|
|
15
|
+
* lines inside a connector.
|
|
16
|
+
*
|
|
17
|
+
* ## The rule it encodes, which is one line and easy to reverse
|
|
18
|
+
*
|
|
19
|
+
* > **`root` is fixed at the top of the chain and never moves. `parent` advances
|
|
20
|
+
* > to whatever was just posted.**
|
|
21
|
+
*
|
|
22
|
+
* Reverse them and every message attaches to the first: a fan, not a thread —
|
|
23
|
+
* and the provider's response looks identical either way, which is why this
|
|
24
|
+
* needs a test rather than care.
|
|
25
|
+
*
|
|
26
|
+
* ## What it deliberately does NOT do
|
|
27
|
+
*
|
|
28
|
+
* No retry. Retry wraps ONE request (see `delivery.ts`); wrapping the chain
|
|
29
|
+
* would re-post every earlier segment when a later one failed, turning a partial
|
|
30
|
+
* send into a duplicated one. A partial chain is reported with what it did post,
|
|
31
|
+
* so a person can see exactly where it stopped.
|
|
32
|
+
*/
|
|
33
|
+
/**
|
|
34
|
+
* A reference to something already posted, as the provider identifies it.
|
|
35
|
+
*
|
|
36
|
+
* Opaque to this module: AT Protocol needs a uri **and** a cid, Mastodon needs
|
|
37
|
+
* one status id, Telegram needs a message id and a chat. A shape naming one
|
|
38
|
+
* provider's model would be wrong for the next one, so the chain carries
|
|
39
|
+
* whatever the poster returned and never reads inside it.
|
|
40
|
+
*/
|
|
41
|
+
type ChainRef = Record<string, string | number>;
|
|
42
|
+
type ChainLinks<Ref extends ChainRef> = {
|
|
43
|
+
/** The top of the conversation. Fixed for the whole chain. */
|
|
44
|
+
root: Ref;
|
|
45
|
+
/** The message immediately above this one. Advances every step. */
|
|
46
|
+
parent: Ref;
|
|
47
|
+
};
|
|
48
|
+
type ChainOutcome<Ref extends ChainRef> = {
|
|
49
|
+
/** Everything that was posted, in order. */
|
|
50
|
+
posted: Ref[];
|
|
51
|
+
/** The failure that stopped it, if one did. */
|
|
52
|
+
failed?: {
|
|
53
|
+
index: number;
|
|
54
|
+
error: unknown;
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Post `segments` as a connected chain, optionally starting inside an existing
|
|
59
|
+
* conversation.
|
|
60
|
+
*
|
|
61
|
+
* `post` is given the text and the links for this position — `undefined` for a
|
|
62
|
+
* top-level message — and returns the provider's reference to what it created.
|
|
63
|
+
*
|
|
64
|
+
* **Stops at the first failure and reports what it posted.** Continuing would
|
|
65
|
+
* produce a thread with a hole in it, and unwinding is not available: nothing
|
|
66
|
+
* here can delete a public message, and pretending otherwise would be worse than
|
|
67
|
+
* the hole.
|
|
68
|
+
*/
|
|
69
|
+
declare function postChain<Ref extends ChainRef>(segments: string[], answering: ChainLinks<Ref> | undefined, post: (text: string, links: ChainLinks<Ref> | undefined, index: number) => Promise<Ref>): Promise<ChainOutcome<Ref>>;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The connector API version — what makes two release clocks safe.
|
|
73
|
+
*
|
|
74
|
+
* ## The problem this exists for
|
|
75
|
+
*
|
|
76
|
+
* The core and the connector catalogue are separate repositories, so they
|
|
77
|
+
* release on separate clocks. That is deliberate: a provider changing its API is
|
|
78
|
+
* a connector fix and must not wait on a core release. But it creates a hazard
|
|
79
|
+
* that is worse here than in an ordinary package pair, because **connectors ship
|
|
80
|
+
* as vendored source**:
|
|
81
|
+
*
|
|
82
|
+
* > A consumer copies a connector into their project. Six months later they
|
|
83
|
+
* > upgrade the core. Their copy of the connector was written against a core two
|
|
84
|
+
* > minors older, and pre-1.0 minors are allowed to break.
|
|
85
|
+
*
|
|
86
|
+
* Nothing in a version range catches that, because the vendored copy has no
|
|
87
|
+
* manifest of its own — it is just files in someone else's repository. And the
|
|
88
|
+
* failure is the quiet kind: the connector still compiles, still runs, and does
|
|
89
|
+
* something subtly different.
|
|
90
|
+
*
|
|
91
|
+
* ## The fix: a second number that moves far more slowly
|
|
92
|
+
*
|
|
93
|
+
* The core's package version tracks everything — fixes, additions, internal
|
|
94
|
+
* changes. `CONNECTOR_API_VERSION` tracks only one thing: **the surface a
|
|
95
|
+
* connector is written against.** Adding a module, fixing a classifier, or
|
|
96
|
+
* improving a message does not move it. Changing what a `Connector` must
|
|
97
|
+
* implement, renaming a field a connector declares, or altering what `render`
|
|
98
|
+
* returns does.
|
|
99
|
+
*
|
|
100
|
+
* So a core can go 0.3 → 0.9 without a single vendored connector caring, and the
|
|
101
|
+
* one release that does matter announces itself in a number a connector can
|
|
102
|
+
* check at import time rather than discover at run time.
|
|
103
|
+
*
|
|
104
|
+
* ## The support window, and why it is two rather than one
|
|
105
|
+
*
|
|
106
|
+
* The core accepts the current version **and the one before it**. One would mean
|
|
107
|
+
* every consumer has to re-vendor every connector on the same day the core ships
|
|
108
|
+
* — which nobody does, so in practice they would pin the core instead and stop
|
|
109
|
+
* getting fixes. Two gives a real window: the catalogue re-issues its connectors
|
|
110
|
+
* on the new version, consumers re-vendor when they next touch that connector,
|
|
111
|
+
* and the old shape keeps working until the version after next.
|
|
112
|
+
*
|
|
113
|
+
* More than two would be a promise we cannot keep, because it means maintaining
|
|
114
|
+
* a compatibility shim for a shape nobody has read in a year.
|
|
115
|
+
*/
|
|
116
|
+
/**
|
|
117
|
+
* The connector surface this core implements.
|
|
118
|
+
*
|
|
119
|
+
* **Bump this ONLY for a change a connector's own source can see**, and say in
|
|
120
|
+
* the changelog what a connector author must DO. If nothing in `connectors/`
|
|
121
|
+
* would have to change, it is not a connector API change.
|
|
122
|
+
*/
|
|
123
|
+
declare const CONNECTOR_API_VERSION = 1;
|
|
124
|
+
/**
|
|
125
|
+
* Versions this core will run.
|
|
126
|
+
*
|
|
127
|
+
* Ordered newest first, purely so the error message reads naturally.
|
|
128
|
+
*/
|
|
129
|
+
declare const SUPPORTED_CONNECTOR_API: readonly number[];
|
|
130
|
+
declare class ConnectorApiMismatch extends Error {
|
|
131
|
+
readonly connector: string;
|
|
132
|
+
readonly declared: number;
|
|
133
|
+
constructor(message: string, connector: string, declared: number);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Refuse a connector written against a surface this core does not implement.
|
|
137
|
+
*
|
|
138
|
+
* **Called at registration, not at call time.** A mismatch discovered on the
|
|
139
|
+
* first real request is a mismatch discovered in production; discovered when the
|
|
140
|
+
* catalogue is assembled, it is a build failure with a name attached.
|
|
141
|
+
*
|
|
142
|
+
* The message names the direction, because the two cases need opposite actions
|
|
143
|
+
* and are trivially confused:
|
|
144
|
+
*
|
|
145
|
+
* - **connector NEWER than the core** → upgrade the core.
|
|
146
|
+
* - **connector OLDER than the window** → re-vendor the connector.
|
|
147
|
+
*
|
|
148
|
+
* "Incompatible versions" sends someone to read source; naming which side is
|
|
149
|
+
* behind sends them to the command.
|
|
150
|
+
*/
|
|
151
|
+
declare function assertConnectorApi(connectorId: string, declared: number): void;
|
|
152
|
+
/**
|
|
153
|
+
* Whether a core version satisfies a connector's declared minimum.
|
|
154
|
+
*
|
|
155
|
+
* Exists so a registry or a CLI can answer *before* vendoring, which is the only
|
|
156
|
+
* moment the answer is cheap. Compares the numeric prefix of each dotted
|
|
157
|
+
* segment, so a prerelease suffix does not make `0.4.0-rc.1` sort below `0.3.9`
|
|
158
|
+
* in a way nobody expected.
|
|
159
|
+
*/
|
|
160
|
+
declare function satisfiesMinimum(coreVersion: string, minimum: string): boolean;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Failure classification — the one decision every retry gets wrong.
|
|
164
|
+
*
|
|
165
|
+
* ## The tension
|
|
166
|
+
*
|
|
167
|
+
* A network hiccup should not lose a call. And a retry after a request that
|
|
168
|
+
* **succeeded and whose response was lost** is a second write — the exact
|
|
169
|
+
* failure an idempotency gate exists to prevent, arriving through the door
|
|
170
|
+
* marked *reliability*.
|
|
171
|
+
*
|
|
172
|
+
* Nearly every retry helper reconciles those the same way and loses: it catches
|
|
173
|
+
* an error, waits, and tries again, because from the caller's side all failures
|
|
174
|
+
* look alike. They are not alike.
|
|
175
|
+
*
|
|
176
|
+
* | what happened | did the provider receive it? | retry? |
|
|
177
|
+
* |---|---|---|
|
|
178
|
+
* | DNS failure, connection refused, socket reset before send | **no** | always safe |
|
|
179
|
+
* | HTTP 429 / 5xx | **yes**, and it explicitly refused | safe — it says it did nothing |
|
|
180
|
+
* | timeout, aborted request, unrecognised error | **unknown** | only where the provider is idempotent |
|
|
181
|
+
* | HTTP 4xx that is not 429 | **yes**, and the answer was a real no | never |
|
|
182
|
+
*
|
|
183
|
+
* That third row is the whole design. A timeout looks exactly like a failure and
|
|
184
|
+
* may have been a success.
|
|
185
|
+
*
|
|
186
|
+
* ## Why this is a separate axis from the error CLASS
|
|
187
|
+
*
|
|
188
|
+
* The first version of this runtime carried `retryable` as a fixed boolean on
|
|
189
|
+
* each error class, and `ConnectorTransient` covered both a 5xx *and* a thrown
|
|
190
|
+
* transport. Those are opposite cases: a 5xx is the provider telling you it did
|
|
191
|
+
* nothing, and a thrown transport may be a socket that closed after the bytes
|
|
192
|
+
* went out. Marking both `retryable = true` meant an ambiguous failure on a
|
|
193
|
+
* connector with no idempotency key was retried — a silent double write, on the
|
|
194
|
+
* path whose entire job is not producing one.
|
|
195
|
+
*
|
|
196
|
+
* So the primitive is the **kind**, and retryability is a function of the kind
|
|
197
|
+
* AND what the connector declared about repeating a request. `error.retryable`
|
|
198
|
+
* survives and now answers the narrower question — *is this safe to retry
|
|
199
|
+
* whatever the connector is?* — which makes an old caller conservative rather
|
|
200
|
+
* than wrong.
|
|
201
|
+
*/
|
|
202
|
+
/** Why a call failed, in the only terms that decide whether to try again. */
|
|
203
|
+
type FailureKind =
|
|
204
|
+
/** Never reached the provider. A second attempt cannot duplicate anything. */
|
|
205
|
+
"unreachable"
|
|
206
|
+
/** The provider answered, and its answer was "not now". It did nothing. */
|
|
207
|
+
| "refused-explicitly"
|
|
208
|
+
/** It may or may not have been acted on. Nobody can tell. */
|
|
209
|
+
| "ambiguous"
|
|
210
|
+
/** The provider answered and the answer was a real, permanent no. */
|
|
211
|
+
| "rejected";
|
|
212
|
+
/**
|
|
213
|
+
* How a connector behaves when a call goes wrong, and how fast it may be used.
|
|
214
|
+
*
|
|
215
|
+
* **`idempotent` is the load-bearing field**, and it is the only thing that
|
|
216
|
+
* makes an ambiguous failure safe to retry. A connector that has not thought
|
|
217
|
+
* about it gets `false`, and an ambiguous failure is then reported for a person
|
|
218
|
+
* instead of retried into a double write.
|
|
219
|
+
*/
|
|
220
|
+
type DeliveryDeclaration = {
|
|
221
|
+
idempotent: boolean;
|
|
222
|
+
/**
|
|
223
|
+
* Why it is or is not — **cited, not asserted**.
|
|
224
|
+
*
|
|
225
|
+
* `idempotent: true` with no reason is the one claim whose failure is a public
|
|
226
|
+
* double write, so the reason names the mechanism (`Idempotency-Key`,
|
|
227
|
+
* `com.atproto.repo.createRecord` having none) rather than restating the flag.
|
|
228
|
+
*/
|
|
229
|
+
why: string;
|
|
230
|
+
/** Smallest gap between two calls on this connector. */
|
|
231
|
+
minIntervalMs: number;
|
|
232
|
+
/**
|
|
233
|
+
* Whose number `minIntervalMs` is.
|
|
234
|
+
*
|
|
235
|
+
* A confident figure nobody can cite is worse than an honest one that is too
|
|
236
|
+
* slow, because the honest one gets revised when evidence turns up and the
|
|
237
|
+
* confident one gets quoted as a platform fact.
|
|
238
|
+
*/
|
|
239
|
+
rateSource: "documented" | "self-imposed";
|
|
240
|
+
/** Where the numbers came from. See `citation.ts`. */
|
|
241
|
+
citation?: Citation;
|
|
242
|
+
};
|
|
243
|
+
/**
|
|
244
|
+
* Where a declared fact was read, and when.
|
|
245
|
+
*
|
|
246
|
+
* A citation with no date is an assertion wearing a URL. The date is what lets a
|
|
247
|
+
* drift check say *this was true eight months ago and nobody has looked since*,
|
|
248
|
+
* which is a different and more useful statement than *this is true*.
|
|
249
|
+
*/
|
|
250
|
+
type Citation = {
|
|
251
|
+
/** The provider's own documentation for this fact. */
|
|
252
|
+
url: string;
|
|
253
|
+
/** ISO date the URL was actually read by a person or a check. */
|
|
254
|
+
readOn: string;
|
|
255
|
+
/** The sentence the fact rests on, quoted. Optional but strongly wanted. */
|
|
256
|
+
quote?: string;
|
|
257
|
+
};
|
|
258
|
+
type Classified = {
|
|
259
|
+
kind: FailureKind;
|
|
260
|
+
detail: string;
|
|
261
|
+
/** Seconds the provider asked us to wait, when it said so. */
|
|
262
|
+
retryAfter?: number;
|
|
263
|
+
};
|
|
264
|
+
/**
|
|
265
|
+
* Classify a thrown transport failure.
|
|
266
|
+
*
|
|
267
|
+
* **An unrecognised error falls to `ambiguous`, never to `unreachable`.** That
|
|
268
|
+
* asymmetry is deliberate: an unknown failure treated as unreachable would be
|
|
269
|
+
* retried, and the one thing worse than a lost call is two calls nobody asked
|
|
270
|
+
* for. Guessing in the safe-looking direction is how this goes wrong.
|
|
271
|
+
*/
|
|
272
|
+
declare function classifyError(error: unknown): Classified;
|
|
273
|
+
/**
|
|
274
|
+
* Classify an HTTP status.
|
|
275
|
+
*
|
|
276
|
+
* A status means the provider answered, so it knows what it did. 429 and 5xx are
|
|
277
|
+
* "not now"; everything else in the 4xx range is a real no, and retrying a real
|
|
278
|
+
* no just spends someone's rate limit on the same rejection.
|
|
279
|
+
*
|
|
280
|
+
* 429 is decided **before** the 4xx sweep, because it is a 4xx and is the one
|
|
281
|
+
* 4xx worth retrying — the other ordering marks every throttle permanent and
|
|
282
|
+
* turns a busy minute into a failed run.
|
|
283
|
+
*/
|
|
284
|
+
declare function classifyStatus(status: number, body?: string, retryAfterHeader?: string | null): Classified;
|
|
285
|
+
type RetryPolicy = {
|
|
286
|
+
/** Total attempts, including the first. */
|
|
287
|
+
attempts: number;
|
|
288
|
+
/** First backoff in ms. Doubles each attempt. */
|
|
289
|
+
baseDelayMs: number;
|
|
290
|
+
/** Never wait longer than this between attempts. */
|
|
291
|
+
maxDelayMs: number;
|
|
292
|
+
/**
|
|
293
|
+
* Whether the provider makes a repeated request harmless.
|
|
294
|
+
*
|
|
295
|
+
* `false` is the correct default for any connector that has not proven
|
|
296
|
+
* otherwise, and "proven" means a test that publishes twice and compares the
|
|
297
|
+
* result — not a comment.
|
|
298
|
+
*/
|
|
299
|
+
idempotent: boolean;
|
|
300
|
+
};
|
|
301
|
+
declare const DEFAULT_RETRY: RetryPolicy;
|
|
302
|
+
/**
|
|
303
|
+
* May this failure be retried?
|
|
304
|
+
*
|
|
305
|
+
* The whole rule, in four lines, in one place. A connector that classified its
|
|
306
|
+
* own statuses would eventually decide some 4xx was worth retrying, and the
|
|
307
|
+
* reason retries are safe at all is that this decision is made once.
|
|
308
|
+
*/
|
|
309
|
+
declare function shouldRetry(kind: FailureKind, policy: Pick<RetryPolicy, "idempotent">): boolean;
|
|
310
|
+
/**
|
|
311
|
+
* True where a retry is safe **whatever the connector is** — i.e. without
|
|
312
|
+
* needing to know whether repeating the request is harmless.
|
|
313
|
+
*
|
|
314
|
+
* This is what `ConnectorError.retryable` answers, and it is deliberately the
|
|
315
|
+
* narrow question. A caller that has an idempotency declaration should ask
|
|
316
|
+
* `shouldRetry` instead and get the extra case.
|
|
317
|
+
*/
|
|
318
|
+
declare function isUnconditionallyRetryable(kind: FailureKind): boolean;
|
|
319
|
+
type Attempt = {
|
|
320
|
+
attempt: number;
|
|
321
|
+
kind: FailureKind;
|
|
322
|
+
detail: string;
|
|
323
|
+
/** How long we waited before the NEXT attempt. Absent on the last. */
|
|
324
|
+
waitedMs?: number;
|
|
325
|
+
};
|
|
326
|
+
type DeliveryOutcome<T> = {
|
|
327
|
+
ok: boolean;
|
|
328
|
+
value?: T;
|
|
329
|
+
/** Every attempt that failed, in order. Empty when the first one worked. */
|
|
330
|
+
attempts: Attempt[];
|
|
331
|
+
/** Why it stopped, when it failed. Written for a person who has to act. */
|
|
332
|
+
gaveUp?: string;
|
|
333
|
+
/** The classification of the last failure, so a host can route on it. */
|
|
334
|
+
kind?: FailureKind;
|
|
335
|
+
};
|
|
336
|
+
/**
|
|
337
|
+
* The message a person reads when an ambiguous failure could not be retried.
|
|
338
|
+
*
|
|
339
|
+
* Exported because a host will want to recognise it, and because the wording is
|
|
340
|
+
* load-bearing: it has to say *go and look*, not *it failed*. Those prompt
|
|
341
|
+
* different actions and only one of them is correct.
|
|
342
|
+
*/
|
|
343
|
+
declare const AMBIGUOUS_REFUSAL: string;
|
|
344
|
+
/**
|
|
345
|
+
* Run ONE request, retrying only where retrying is provably safe.
|
|
346
|
+
*
|
|
347
|
+
* ## Retry wraps ONE request, never a sequence
|
|
348
|
+
*
|
|
349
|
+
* Wrapping a multi-post publish would re-send every earlier segment of a thread
|
|
350
|
+
* when a later one failed — turning a partial send into a duplicated one. So the
|
|
351
|
+
* unit is a single request and a chain composes above it, not below.
|
|
352
|
+
*
|
|
353
|
+
* `send` must throw on failure. An error carrying a `classified` property (see
|
|
354
|
+
* `httpFailure`) is trusted; anything else is classified here. That indirection
|
|
355
|
+
* lets each connector decide what counts as a failure for it — a Telegram
|
|
356
|
+
* `200 OK` with `{"ok": false}` is a failure and only the connector knows that —
|
|
357
|
+
* while the decision about *retrying* stays in one place for all of them.
|
|
358
|
+
*
|
|
359
|
+
* `sleep` is injectable so a test proves the real backoff schedule without
|
|
360
|
+
* waiting for it. A test that actually slept would be slow enough that somebody
|
|
361
|
+
* would eventually shorten the delays to speed it up, and then the thing under
|
|
362
|
+
* test would be the shortened version.
|
|
363
|
+
*/
|
|
364
|
+
declare function deliver<T>(send: (attempt: number) => Promise<T>, policy?: RetryPolicy, sleep?: (ms: number) => Promise<void>): Promise<DeliveryOutcome<T>>;
|
|
365
|
+
/**
|
|
366
|
+
* Wait until this channel may be used again.
|
|
367
|
+
*
|
|
368
|
+
* In-process only, and that is the correct scope for a host running on one
|
|
369
|
+
* machine. A host that fans out across processes owns the coordination, because
|
|
370
|
+
* the package cannot see the other senders and pretending otherwise would be a
|
|
371
|
+
* limiter that reports success while doing nothing.
|
|
372
|
+
*/
|
|
373
|
+
declare function respectRate(channel: string, minIntervalMs: number, now?: () => number, sleep?: (ms: number) => Promise<void>): Promise<{
|
|
374
|
+
waitedMs: number;
|
|
375
|
+
}>;
|
|
376
|
+
/** Only for tests — the map is module-global and would leak between them. */
|
|
377
|
+
declare function resetRateState(): void;
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* The faker layer — every connector ships one, always.
|
|
381
|
+
*
|
|
382
|
+
* ## Why every connector, and not just the ones without a sandbox
|
|
383
|
+
*
|
|
384
|
+
* A sandbox still needs an account, a key, a network, and a provider that is
|
|
385
|
+
* up. That is four ways for someone evaluating the kit to get nothing working,
|
|
386
|
+
* before they have learned anything about the kit. A faker removes all four:
|
|
387
|
+
* vendor a node, drop it on a canvas, press run, see a shaped result.
|
|
388
|
+
*
|
|
389
|
+
* It is also what makes the golden fixtures honest. Fixtures run on both
|
|
390
|
+
* runtimes and are the publish gate; if they needed a network they would either
|
|
391
|
+
* be skipped in CI or be flaky, and a flaky gate is one people learn to
|
|
392
|
+
* re-run rather than read.
|
|
393
|
+
*
|
|
394
|
+
* ## Deterministic, not random
|
|
395
|
+
*
|
|
396
|
+
* Same inputs, same output — always. A faker that returns a fresh uuid every
|
|
397
|
+
* call cannot be asserted on, so its fixtures degrade to "it did not throw",
|
|
398
|
+
* which is the assertion that catches nothing. Values here are derived from a
|
|
399
|
+
* seed built out of the service, the operation and the request, so two runs
|
|
400
|
+
* agree and two runtimes can be compared to each other.
|
|
401
|
+
*
|
|
402
|
+
* The values are obviously fake ON PURPOSE — `fake_`-prefixed ids, `example.test`
|
|
403
|
+
* hosts, round numbers. Nobody should ever look at a faked result and wonder
|
|
404
|
+
* whether it moved real money.
|
|
405
|
+
*/
|
|
406
|
+
/** A faker: one function per service, switching on the operation. */
|
|
407
|
+
type ConnectorFaker = (operation: string, request: FakeRequest) => unknown;
|
|
408
|
+
type FakeRequest = {
|
|
409
|
+
/** The node's resolved config. */
|
|
410
|
+
config: Record<string, unknown>;
|
|
411
|
+
/** The value on the incoming wire. */
|
|
412
|
+
input?: unknown;
|
|
413
|
+
/** Deterministic helpers, seeded from service + operation + config. */
|
|
414
|
+
fake: FakeValues;
|
|
415
|
+
};
|
|
416
|
+
/**
|
|
417
|
+
* Deterministic value helpers handed to a faker.
|
|
418
|
+
*
|
|
419
|
+
* Small on purpose. A faker's job is to return the SHAPE the provider returns —
|
|
420
|
+
* the field names a downstream node will reference — not to simulate the
|
|
421
|
+
* provider's business logic.
|
|
422
|
+
*/
|
|
423
|
+
type FakeValues = {
|
|
424
|
+
/** A stable id with the provider's usual prefix: `id("ch")` → `ch_fake_1a2b3c`. */
|
|
425
|
+
id: (prefix: string) => string;
|
|
426
|
+
/** A stable integer in `[min, max]`. */
|
|
427
|
+
int: (min: number, max: number) => number;
|
|
428
|
+
/** Pick a stable element of a list. */
|
|
429
|
+
pick: <T>(options: readonly T[]) => T;
|
|
430
|
+
/** A fixed ISO-8601 instant, offset by whole seconds. Never `Date.now()`. */
|
|
431
|
+
timestamp: (offsetSeconds?: number) => string;
|
|
432
|
+
/** A stable lowercase hex string of `length` characters. */
|
|
433
|
+
hex: (length: number) => string;
|
|
434
|
+
};
|
|
435
|
+
/**
|
|
436
|
+
* The instant every faker counts from.
|
|
437
|
+
*
|
|
438
|
+
* A constant rather than the clock, because a fixture asserting on `createdAt`
|
|
439
|
+
* must not start failing tomorrow. Chosen to be recognisably synthetic.
|
|
440
|
+
*/
|
|
441
|
+
declare const FAKE_EPOCH = "2026-01-01T00:00:00.000Z";
|
|
442
|
+
/** FNV-1a. Small, dependency-free, and stable across both runtimes. */
|
|
443
|
+
declare function seedFrom(...parts: unknown[]): number;
|
|
444
|
+
/** Build the deterministic helpers for one faked call. */
|
|
445
|
+
declare function fakeValues(seed: number): FakeValues;
|
|
446
|
+
/** Build the request handed to a faker, with helpers already seeded. */
|
|
447
|
+
declare function fakeRequest(service: string, operation: string, config: Record<string, unknown>, input?: unknown): FakeRequest;
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Which copy of a provider a connector talks to — and who decides.
|
|
451
|
+
*
|
|
452
|
+
* Three modes, and the third is the one that makes this marketplace usable:
|
|
453
|
+
*
|
|
454
|
+
* | mode | talks to | needs credentials | needs network |
|
|
455
|
+
* |-----------|-----------------------------|-------------------|---------------|
|
|
456
|
+
* | `live` | the provider, for real | yes | yes |
|
|
457
|
+
* | `sandbox` | the provider's test estate | yes (test ones) | yes |
|
|
458
|
+
* | `fake` | the node's own faker | no | no |
|
|
459
|
+
*
|
|
460
|
+
* ## The environment is the DEFAULT, never the constraint
|
|
461
|
+
*
|
|
462
|
+
* A locally-hosted project defaults to sandbox. It is not *pinned* to sandbox:
|
|
463
|
+
* an author who explicitly asks for `live` on their laptop gets `live`, because
|
|
464
|
+
* the alternative — an environment that silently overrides a stated intention —
|
|
465
|
+
* produces the worst outcome available here, which is a workflow that reports
|
|
466
|
+
* success while having charged nobody. Equally, `sandbox` set explicitly stays
|
|
467
|
+
* sandbox in production, which is how you stage a connector before cutting it
|
|
468
|
+
* over.
|
|
469
|
+
*
|
|
470
|
+
* So: explicit beats the connection, the connection beats the environment.
|
|
471
|
+
*
|
|
472
|
+
* ## Why "fake" is a MODE and not a test double
|
|
473
|
+
*
|
|
474
|
+
* Every connector ships a faker (see `faker.ts`), and it is reachable the same
|
|
475
|
+
* way in a test, in a demo and on a laptop with no credentials. Making it a
|
|
476
|
+
* separate mechanism — a mock injected only under test — would mean the code
|
|
477
|
+
* path a consumer develops against is not the one that runs, and the fixtures
|
|
478
|
+
* would prove nothing about the executor.
|
|
479
|
+
*/
|
|
480
|
+
/** The three environments a connector node can run against. */
|
|
481
|
+
type ConnectorMode = "fake" | "sandbox" | "live";
|
|
482
|
+
/** What a node's `mode` config field may say. `auto` means "let the rules decide". */
|
|
483
|
+
type RequestedMode = ConnectorMode | "auto";
|
|
484
|
+
declare const CONNECTOR_MODES: ConnectorMode[];
|
|
485
|
+
/**
|
|
486
|
+
* How a provider exposes its test estate — the four shapes that actually exist.
|
|
487
|
+
*
|
|
488
|
+
* This is data, not per-node code, because getting it wrong is the difference
|
|
489
|
+
* between hitting a test ledger and hitting a real one. Verified per provider in
|
|
490
|
+
* `.ai/plans/fancy-flow-connector-nodes.md`; a node declares which of the four
|
|
491
|
+
* it is and the core does the rest.
|
|
492
|
+
*
|
|
493
|
+
* - `credential` — same base URL, a test-scoped key selects the estate
|
|
494
|
+
* (Stripe's `sk_test_…`). The trap: pointing a live key at it works, so the
|
|
495
|
+
* only guard is the key itself.
|
|
496
|
+
* - `base-url` — a different host entirely (PayPal's `api-m.sandbox.paypal.com`).
|
|
497
|
+
* - `separate-account` — a distinct tenant you must create, often with its own
|
|
498
|
+
* login host (Salesforce sandbox orgs on `test.salesforce.com`).
|
|
499
|
+
* - `none` — the provider has no test estate. `sandbox` is then not offered at
|
|
500
|
+
* all, and the node's honest choices are `fake` and `live`.
|
|
501
|
+
*/
|
|
502
|
+
type SandboxKind = "credential" | "base-url" | "separate-account" | "none";
|
|
503
|
+
/** The host's view of where it is running. Kept tiny so it is trivial to fake. */
|
|
504
|
+
type HostEnvironment = {
|
|
505
|
+
/** True only in a real production deployment. Everything else is "local". */
|
|
506
|
+
production: boolean;
|
|
507
|
+
};
|
|
508
|
+
type ModeInputs = {
|
|
509
|
+
/** What the node's config asked for. `undefined` / `"auto"` means "decide". */
|
|
510
|
+
requested?: RequestedMode | null;
|
|
511
|
+
/** A mode pinned on the CONNECTION, if the host pinned one. */
|
|
512
|
+
connectionMode?: ConnectorMode | null;
|
|
513
|
+
/** Whether the provider has a test estate at all. */
|
|
514
|
+
sandbox: SandboxKind;
|
|
515
|
+
/** Whether the connection actually carries credentials for the sandbox estate. */
|
|
516
|
+
hasSandboxCredentials: boolean;
|
|
517
|
+
environment: HostEnvironment;
|
|
518
|
+
};
|
|
519
|
+
/**
|
|
520
|
+
* Resolve the mode a connector call runs in.
|
|
521
|
+
*
|
|
522
|
+
* Deliberately a pure function of stated facts, so both runtimes can be held to
|
|
523
|
+
* the same table of cases and a host can unit-test its own wiring without a
|
|
524
|
+
* network or a provider.
|
|
525
|
+
*/
|
|
526
|
+
declare function resolveConnectorMode(inputs: ModeInputs): ConnectorMode;
|
|
527
|
+
/** Raised when a requested mode cannot be honoured. Never downgraded silently. */
|
|
528
|
+
declare class ConnectorModeError extends Error {
|
|
529
|
+
constructor(message: string);
|
|
530
|
+
}
|
|
531
|
+
/** True when the mode reaches the provider over the network. */
|
|
532
|
+
declare function isRemote(mode: ConnectorMode): boolean;
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* A CONNECTION — credentials plus an environment choice — configured once per
|
|
536
|
+
* service and referenced by every node that uses it.
|
|
537
|
+
*
|
|
538
|
+
* ## Why this is not per-node config
|
|
539
|
+
*
|
|
540
|
+
* The obvious design is to put an API key field on each node. It is also wrong,
|
|
541
|
+
* for three reasons that only show up later:
|
|
542
|
+
*
|
|
543
|
+
* 1. **Rotation.** A key on twelve nodes is twelve edits and one you will miss.
|
|
544
|
+
* 2. **The sandbox/live switch has to live somewhere singular.** If each node
|
|
545
|
+
* carries its own, a graph can be half in the test estate and half in the
|
|
546
|
+
* real one, and nothing about the canvas shows it.
|
|
547
|
+
* 3. **Credentials would be in the graph.** A `WorkflowSchema` is plain JSON
|
|
548
|
+
* that gets exported, committed, MCP'd to an agent and pasted into issues.
|
|
549
|
+
* Nothing secret may ever be in it.
|
|
550
|
+
*
|
|
551
|
+
* So a node's config stores a **connection id** — an opaque, non-secret name
|
|
552
|
+
* like `"stripe"` or `"stripe-eu"` — and the host resolves it to credentials at
|
|
553
|
+
* run time. This is the same arrangement fancy-flow already uses for LLM clients
|
|
554
|
+
* and `fancy-git` uses for providers: the node cannot reach for a credential,
|
|
555
|
+
* the host has to hand one over.
|
|
556
|
+
*
|
|
557
|
+
* ```ts
|
|
558
|
+
* registerConnectionHost({
|
|
559
|
+
* environment: { production: process.env.NODE_ENV === "production" },
|
|
560
|
+
* connections: {
|
|
561
|
+
* stripe: {
|
|
562
|
+
* service: "stripe",
|
|
563
|
+
* live: { secretKey: process.env.STRIPE_SECRET_KEY },
|
|
564
|
+
* sandbox: { secretKey: process.env.STRIPE_TEST_SECRET_KEY },
|
|
565
|
+
* },
|
|
566
|
+
* },
|
|
567
|
+
* });
|
|
568
|
+
* ```
|
|
569
|
+
*
|
|
570
|
+
* Note what is NOT here: no key, no token, no placeholder that looks like one.
|
|
571
|
+
* Credentials are configuration, and configuration comes from the consumer's
|
|
572
|
+
* environment.
|
|
573
|
+
*/
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Credentials for one estate. Values are opaque strings the node's auth
|
|
577
|
+
* strategy understands — `secretKey`, `clientId`/`clientSecret`, `token`.
|
|
578
|
+
*/
|
|
579
|
+
type ConnectionCredentials = Record<string, string | undefined>;
|
|
580
|
+
/** One configured connection to one service. */
|
|
581
|
+
type ConnectionSpec = {
|
|
582
|
+
/** The service this connection is for — must match the node's `connector.service`. */
|
|
583
|
+
service: string;
|
|
584
|
+
/** Human label, shown in the picker. Defaults to the id. */
|
|
585
|
+
label?: string;
|
|
586
|
+
/**
|
|
587
|
+
* Pin this connection to a mode. Optional, and usually absent: leaving it
|
|
588
|
+
* unset is what lets the environment supply the default.
|
|
589
|
+
*/
|
|
590
|
+
mode?: ConnectorMode;
|
|
591
|
+
live?: ConnectionCredentials;
|
|
592
|
+
sandbox?: ConnectionCredentials;
|
|
593
|
+
/**
|
|
594
|
+
* Override the base URL per mode. Only needed for a `separate-account`
|
|
595
|
+
* provider whose tenant has its own host (a Salesforce instance URL); the
|
|
596
|
+
* `base-url` providers carry theirs in the node's service descriptor.
|
|
597
|
+
*/
|
|
598
|
+
baseUrl?: Partial<Record<ConnectorMode, string>>;
|
|
599
|
+
};
|
|
600
|
+
type ConnectionHost = {
|
|
601
|
+
environment: HostEnvironment;
|
|
602
|
+
connections: Record<string, ConnectionSpec>;
|
|
603
|
+
};
|
|
604
|
+
/**
|
|
605
|
+
* Install the host's connections. Returns an unregister function.
|
|
606
|
+
*
|
|
607
|
+
* One registration for the whole app, matching how a person thinks about it:
|
|
608
|
+
* you authorize Stripe once, then every node that needs Stripe already has it.
|
|
609
|
+
*/
|
|
610
|
+
declare function registerConnectionHost(next: ConnectionHost | null): () => void;
|
|
611
|
+
declare function getConnectionHost(): ConnectionHost | null;
|
|
612
|
+
/** Connection ids the host has configured for a service — feeds the picker. */
|
|
613
|
+
declare function connectionsFor(service: string): Array<{
|
|
614
|
+
id: string;
|
|
615
|
+
label: string;
|
|
616
|
+
}>;
|
|
617
|
+
/** What a node's executor actually needs, once everything has been resolved. */
|
|
618
|
+
type ResolvedConnection = {
|
|
619
|
+
id: string;
|
|
620
|
+
service: string;
|
|
621
|
+
mode: ConnectorMode;
|
|
622
|
+
/** Empty in `fake` mode — there is nothing to authenticate against. */
|
|
623
|
+
credentials: ConnectionCredentials;
|
|
624
|
+
/** Undefined in `fake` mode. */
|
|
625
|
+
baseUrl?: string;
|
|
626
|
+
};
|
|
627
|
+
type ResolveOptions = {
|
|
628
|
+
service: string;
|
|
629
|
+
operation: string;
|
|
630
|
+
/** The connection id from host config. Defaults to the service name. */
|
|
631
|
+
connectionId?: string | null;
|
|
632
|
+
/** The mode the caller asked for. */
|
|
633
|
+
requested?: RequestedMode | null;
|
|
634
|
+
/**
|
|
635
|
+
* Credentials handed straight in, bypassing the registry entirely.
|
|
636
|
+
*
|
|
637
|
+
* The primary path for a host that already owns credential storage and has no
|
|
638
|
+
* wish to hand a package a second copy: it passes them per call, this resolver
|
|
639
|
+
* does not consult any global, and there is nothing to initialise. The
|
|
640
|
+
* registry below remains for hosts that prefer to configure once.
|
|
641
|
+
*
|
|
642
|
+
* An EMPTY object is not the same as `null`: it means "the caller supplied
|
|
643
|
+
* credentials and there are none", which fails the `requires` check loudly
|
|
644
|
+
* rather than falling through to the registry and silently using someone
|
|
645
|
+
* else's.
|
|
646
|
+
*/
|
|
647
|
+
credentials?: ConnectionCredentials | null;
|
|
648
|
+
sandbox: SandboxKind;
|
|
649
|
+
/** Base URL per mode, from the node's service descriptor. */
|
|
650
|
+
baseUrls?: Partial<Record<ConnectorMode, string>>;
|
|
651
|
+
/** Credential keys that must be present for a remote call. */
|
|
652
|
+
requires?: string[];
|
|
653
|
+
};
|
|
654
|
+
/**
|
|
655
|
+
* Resolve the connection and mode for one call.
|
|
656
|
+
*
|
|
657
|
+
* ## `fake` needs no host at all
|
|
658
|
+
*
|
|
659
|
+
* A freshly vendored connector runs before anything is configured, and that is
|
|
660
|
+
* deliberate: it is the difference between a marketplace someone can try and
|
|
661
|
+
* one they can only read about. So the no-host case resolves to `fake` rather
|
|
662
|
+
* than throwing — UNLESS the author explicitly asked for a remote mode, in
|
|
663
|
+
* which case the ask is honoured by failing loudly instead of quietly doing
|
|
664
|
+
* something else.
|
|
665
|
+
*/
|
|
666
|
+
declare function resolveConnection(options: ResolveOptions): ResolvedConnection;
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* The one call path every connector uses.
|
|
670
|
+
*
|
|
671
|
+
* A connector never chooses between "call the provider" and "call the faker" —
|
|
672
|
+
* it describes the request it wants and hands it here. That is the point: the
|
|
673
|
+
* code path a consumer develops against with no credentials is the same code
|
|
674
|
+
* path that runs in production, so fixtures prove something about the connector
|
|
675
|
+
* rather than about a mock.
|
|
676
|
+
*
|
|
677
|
+
* ```ts
|
|
678
|
+
* const charge = await callConnector(STRIPE, {
|
|
679
|
+
* operation: "charge_create",
|
|
680
|
+
* credentials,
|
|
681
|
+
* request: { method: "POST", path: "/v1/charges", form: { amount, currency } },
|
|
682
|
+
* idempotencyKey,
|
|
683
|
+
* idempotent: true,
|
|
684
|
+
* });
|
|
685
|
+
* ```
|
|
686
|
+
*
|
|
687
|
+
* ## What changed from the first version of this runtime, and why
|
|
688
|
+
*
|
|
689
|
+
* The old loop caught a thrown transport, called it transient, and retried it.
|
|
690
|
+
* A thrown transport includes a **timeout**, which may be a request the provider
|
|
691
|
+
* received and acted on — so on a connector with no idempotency key that retry
|
|
692
|
+
* is a silent double write. Retrying is now a function of the classification AND
|
|
693
|
+
* the connector's declared idempotency, and `idempotent` defaults to `false`
|
|
694
|
+
* because a connector that has not thought about it must not be assumed safe.
|
|
695
|
+
*
|
|
696
|
+
* See `delivery.ts` for the full table.
|
|
697
|
+
*/
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Everything true of a SERVICE rather than of one operation.
|
|
701
|
+
*
|
|
702
|
+
* One per provider, declared once and shared by that provider's connectors. It
|
|
703
|
+
* is data — base URLs, required credential keys, how the sandbox is selected —
|
|
704
|
+
* so that facts verified against provider documentation live in exactly one
|
|
705
|
+
* reviewable place instead of being retyped into each connector.
|
|
706
|
+
*/
|
|
707
|
+
type ServiceDescriptor = {
|
|
708
|
+
service: string;
|
|
709
|
+
title: string;
|
|
710
|
+
sandbox: SandboxKind;
|
|
711
|
+
/**
|
|
712
|
+
* Base URL per mode. A `credential`-sandbox provider gives the same URL for
|
|
713
|
+
* both, which is itself worth writing down: it is why a live key pointed at
|
|
714
|
+
* "sandbox" quietly reaches production.
|
|
715
|
+
*/
|
|
716
|
+
baseUrls: Partial<Record<ConnectorMode, string>>;
|
|
717
|
+
/** Credential keys a remote call cannot proceed without. */
|
|
718
|
+
requires: string[];
|
|
719
|
+
/**
|
|
720
|
+
* Apply the provider's auth scheme to an outgoing request.
|
|
721
|
+
*
|
|
722
|
+
* A FUNCTION rather than a declarative header name, because there is no common
|
|
723
|
+
* shape: the key can be a header (under any of a dozen names), a Basic
|
|
724
|
+
* username with a blank password, a query parameter, a body field, or a URL
|
|
725
|
+
* path segment — and several providers need more than one at once. It receives
|
|
726
|
+
* the resolved mode too, because for some providers auth and estate are the
|
|
727
|
+
* same decision expressed in the URL.
|
|
728
|
+
*/
|
|
729
|
+
authorize: (credentials: ConnectionCredentials, request: PreparedRequest, mode: ConnectorMode) => void | Promise<void>;
|
|
730
|
+
/** The service's faker. Required — see `faker.ts`. */
|
|
731
|
+
faker: ConnectorFaker;
|
|
732
|
+
/** Header the provider uses for idempotency, when it has one. */
|
|
733
|
+
idempotencyHeader?: string;
|
|
734
|
+
};
|
|
735
|
+
type ConnectorRequest = {
|
|
736
|
+
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
737
|
+
path: string;
|
|
738
|
+
query?: Record<string, string | number | boolean | undefined>;
|
|
739
|
+
headers?: Record<string, string>;
|
|
740
|
+
/** JSON body. Mutually exclusive with `form`. */
|
|
741
|
+
json?: unknown;
|
|
742
|
+
/** `application/x-www-form-urlencoded` body — what several payment APIs want. */
|
|
743
|
+
form?: Record<string, string | number | boolean | undefined>;
|
|
744
|
+
};
|
|
745
|
+
type PreparedRequest = {
|
|
746
|
+
method: string;
|
|
747
|
+
url: string;
|
|
748
|
+
headers: Record<string, string>;
|
|
749
|
+
body?: string;
|
|
750
|
+
};
|
|
751
|
+
/** The HTTP seam. A host may swap it; nothing here assumes a browser or Node. */
|
|
752
|
+
type Transport = (request: PreparedRequest) => Promise<TransportResponse>;
|
|
753
|
+
type TransportResponse = {
|
|
754
|
+
status: number;
|
|
755
|
+
headers: Record<string, string>;
|
|
756
|
+
body: string;
|
|
757
|
+
};
|
|
758
|
+
type CallOptions = {
|
|
759
|
+
operation: string;
|
|
760
|
+
/**
|
|
761
|
+
* Credentials, passed IN.
|
|
762
|
+
*
|
|
763
|
+
* **Nothing in this package reads `process.env`.** A host stores values
|
|
764
|
+
* wherever it stores values — a gitignored file behind a whitelist, a secret
|
|
765
|
+
* manager, a Laravel config — and hands them over per call. A package that
|
|
766
|
+
* reached for the environment itself would bypass that discipline entirely,
|
|
767
|
+
* and a test in this repo asserts that it does not.
|
|
768
|
+
*
|
|
769
|
+
* Omit to resolve through a registered connection host instead; see
|
|
770
|
+
* `connection.ts`.
|
|
771
|
+
*/
|
|
772
|
+
credentials?: ConnectionCredentials;
|
|
773
|
+
/** Which estate to use. Resolved from the connection host when omitted. */
|
|
774
|
+
mode?: ConnectorMode;
|
|
775
|
+
/** Connection id, for the registered-host path. */
|
|
776
|
+
connectionId?: string | null;
|
|
777
|
+
/**
|
|
778
|
+
* The caller's own config for this call.
|
|
779
|
+
*
|
|
780
|
+
* Passed to the faker so its output is deterministic, and read for
|
|
781
|
+
* `connection` and `mode` when those were not given explicitly — which is what
|
|
782
|
+
* a workflow node stores them in. Explicit arguments always win; this is a
|
|
783
|
+
* convenience for a host whose configuration IS a config object, not a second
|
|
784
|
+
* source of truth.
|
|
785
|
+
*/
|
|
786
|
+
config?: Record<string, unknown>;
|
|
787
|
+
input?: unknown;
|
|
788
|
+
request: ConnectorRequest;
|
|
789
|
+
/**
|
|
790
|
+
* Value for the provider's idempotency header. Derive it from the run and the
|
|
791
|
+
* step, never a fresh uuid: the entire point is that a RETRY carries the same
|
|
792
|
+
* key. See `idempotency.ts`.
|
|
793
|
+
*/
|
|
794
|
+
idempotencyKey?: string;
|
|
795
|
+
/**
|
|
796
|
+
* Whether repeating this exact request is harmless.
|
|
797
|
+
*
|
|
798
|
+
* **Defaults to `false`.** The only thing that makes an ambiguous failure
|
|
799
|
+
* retryable, so it is opt-in and has to be true because the provider makes it
|
|
800
|
+
* true — not because a retry would be convenient.
|
|
801
|
+
*/
|
|
802
|
+
idempotent?: boolean;
|
|
803
|
+
/** Retry budget for retryable failures. Total attempts, including the first. */
|
|
804
|
+
attempts?: number;
|
|
805
|
+
/** Override the transport for this call. Otherwise the registered one, then `fetch`. */
|
|
806
|
+
transport?: Transport;
|
|
807
|
+
/**
|
|
808
|
+
* Called for each FAILED attempt once the call has finished, so a host can
|
|
809
|
+
* journal what actually happened. A call that worked on the third try is a
|
|
810
|
+
* different operational fact from one that worked immediately.
|
|
811
|
+
*
|
|
812
|
+
* A callback rather than a field on the result, because the result is a
|
|
813
|
+
* published shape and this is a diagnostic — see `ConnectorResult`.
|
|
814
|
+
*/
|
|
815
|
+
onAttempt?: (attempt: Attempt) => void;
|
|
816
|
+
};
|
|
817
|
+
/**
|
|
818
|
+
* What a connector call returns, alongside the provider's own payload.
|
|
819
|
+
*
|
|
820
|
+
* The mode is REPORTED, never inferred by the caller. A call that emitted the
|
|
821
|
+
* provider's data alone would leave every downstream reader — a human, an agent,
|
|
822
|
+
* a log — unable to tell a faked result from a real one, which is the single
|
|
823
|
+
* most important fact about a connector run.
|
|
824
|
+
*
|
|
825
|
+
* **Retry history is deliberately NOT here.** It was, briefly, and the flow
|
|
826
|
+
* node marketplace's cross-runtime fixtures caught it immediately: this object
|
|
827
|
+
* is published on a node's output port and referenced by templates and agents,
|
|
828
|
+
* and PHP's `ConnectorResult::toArray()` does not carry it. Two runtimes
|
|
829
|
+
* publishing different shapes is the exact drift those fixtures exist to
|
|
830
|
+
* prevent, and a diagnostic field is a poor reason to break it. Pass
|
|
831
|
+
* `onAttempt` to observe retries.
|
|
832
|
+
*/
|
|
833
|
+
type ConnectorResult<T = unknown> = {
|
|
834
|
+
data: T;
|
|
835
|
+
/** Which estate this actually ran against. Always reported, never inferred. */
|
|
836
|
+
mode: ConnectorMode;
|
|
837
|
+
connection: string;
|
|
838
|
+
};
|
|
839
|
+
/** Install a transport. Only needed if the runtime has no global `fetch`. */
|
|
840
|
+
declare function registerTransport(next: Transport | null): void;
|
|
841
|
+
declare function callConnector<T = unknown>(service: ServiceDescriptor, options: CallOptions): Promise<ConnectorResult<T>>;
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* API drift — being told the provider changed, rather than discovering it.
|
|
845
|
+
*
|
|
846
|
+
* ## The failure this addresses
|
|
847
|
+
*
|
|
848
|
+
* A connector's worst failure is silent. The provider changes its API and
|
|
849
|
+
* nothing says so until something breaks in production — and by then the person
|
|
850
|
+
* who wrote the connector has moved on, the reason it worked is gone, and the
|
|
851
|
+
* first symptom is usually a 400 that reads like bad input.
|
|
852
|
+
*
|
|
853
|
+
* ## What the system DOES on drift: it REPORTS. It never adapts.
|
|
854
|
+
*
|
|
855
|
+
* This is the load-bearing decision and it is deliberate.
|
|
856
|
+
*
|
|
857
|
+
* A connector that reshapes itself around a changed API is worse than one that
|
|
858
|
+
* fails loudly, for three reasons that compound:
|
|
859
|
+
*
|
|
860
|
+
* 1. **It cannot be reviewed.** The bytes that go out stop being a function of
|
|
861
|
+
* the source someone read. A host that guarantees "what was approved is what
|
|
862
|
+
* was sent" cannot make that guarantee about code that rewrote itself.
|
|
863
|
+
* 2. **Its behaviour depends on WHEN it ran**, because it depends on what the
|
|
864
|
+
* spec said at that moment. Two runs of the same workflow on the same input
|
|
865
|
+
* can differ, and nothing in the run record explains why.
|
|
866
|
+
* 3. **Adaptation is only ever a guess at intent.** A renamed field might be a
|
|
867
|
+
* rename or might be a different field with a similar name; a removed one
|
|
868
|
+
* might be gone or moved. Getting it wrong writes wrong data confidently.
|
|
869
|
+
*
|
|
870
|
+
* ## And it never changes RUNTIME behaviour either
|
|
871
|
+
*
|
|
872
|
+
* A drift check runs out of band — on a schedule, in CI, on demand. It does not
|
|
873
|
+
* fail a call that is working. If the spec changed and the call still succeeds,
|
|
874
|
+
* failing the run would be a self-inflicted outage caused by a document. The
|
|
875
|
+
* only thing that changes runtime behaviour is the provider actually refusing,
|
|
876
|
+
* which the error taxonomy already handles.
|
|
877
|
+
*
|
|
878
|
+
* So: **drift reports; the provider decides; a person acts.**
|
|
879
|
+
*
|
|
880
|
+
* ## Two detection strategies, because most providers publish nothing
|
|
881
|
+
*
|
|
882
|
+
* 1. **Specs**, where a provider publishes a machine-readable one at a stable
|
|
883
|
+
* URL. Checked against the connector's declared slice — see `ApiContract`.
|
|
884
|
+
* 2. **Recorded shapes**, where it does not. A fixture of the response fields a
|
|
885
|
+
* connector reads, compared against a live response on a schedule. Field
|
|
886
|
+
* NAMES only; values are never recorded, because a recorded response from a
|
|
887
|
+
* real account is a data leak wearing a test fixture's clothes.
|
|
888
|
+
*
|
|
889
|
+
* The second is the fallback and is designed anyway, because it is the majority
|
|
890
|
+
* case: of the providers surveyed for this package, the ones publishing a
|
|
891
|
+
* maintained spec are a minority, and publishing one is not the same as
|
|
892
|
+
* maintaining it — `slackapi/slack-api-specs` has not been touched since 2021.
|
|
893
|
+
*/
|
|
894
|
+
/**
|
|
895
|
+
* One operation a connector calls.
|
|
896
|
+
*
|
|
897
|
+
* The `path` is the TEMPLATE, matching the provider's own documentation, so it
|
|
898
|
+
* can be looked up in a spec: `/v1/charges/{charge}`, not the interpolated URL.
|
|
899
|
+
*/
|
|
900
|
+
type ContractOperation = {
|
|
901
|
+
/** Stable name, matching the connector's own operation id. */
|
|
902
|
+
operation: string;
|
|
903
|
+
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
904
|
+
path: string;
|
|
905
|
+
/** Request fields the connector SENDS. Drift here breaks writes. */
|
|
906
|
+
sends?: string[];
|
|
907
|
+
/** Response fields the connector READS. Drift here breaks reads, silently. */
|
|
908
|
+
reads?: string[];
|
|
909
|
+
};
|
|
910
|
+
/**
|
|
911
|
+
* Where a provider's machine-readable description lives.
|
|
912
|
+
*
|
|
913
|
+
* `kind` matters because "machine-readable" is not one format. AT Protocol
|
|
914
|
+
* publishes **lexicons**, not OpenAPI, and a checker that assumed OpenAPI would
|
|
915
|
+
* report every AT Protocol connector as unspecified — which is a different and
|
|
916
|
+
* much worse answer than "specified, in another format".
|
|
917
|
+
*/
|
|
918
|
+
type SpecSource = {
|
|
919
|
+
kind: "openapi";
|
|
920
|
+
url: string;
|
|
921
|
+
note?: string;
|
|
922
|
+
} | {
|
|
923
|
+
kind: "lexicon";
|
|
924
|
+
url: string;
|
|
925
|
+
note?: string;
|
|
926
|
+
} | {
|
|
927
|
+
kind: "none";
|
|
928
|
+
/**
|
|
929
|
+
* Why there is no spec, and what the fallback is. Required — "none" with
|
|
930
|
+
* no reason is indistinguishable from "nobody looked".
|
|
931
|
+
*/
|
|
932
|
+
note: string;
|
|
933
|
+
};
|
|
934
|
+
/** Everything a connector declares about the API it was written against. */
|
|
935
|
+
type ApiContract = {
|
|
936
|
+
connector: string;
|
|
937
|
+
/** Base URL the operations hang off, for a human reading the report. */
|
|
938
|
+
baseUrl?: string;
|
|
939
|
+
spec: SpecSource;
|
|
940
|
+
operations: ContractOperation[];
|
|
941
|
+
/** When a person last read the provider's documentation for this contract. */
|
|
942
|
+
reviewedOn: string;
|
|
943
|
+
};
|
|
944
|
+
type DriftFinding = {
|
|
945
|
+
/**
|
|
946
|
+
* `missing-operation` — the path or method is gone. A write will start failing.
|
|
947
|
+
* `missing-request-field` — something we send is no longer accepted.
|
|
948
|
+
* `missing-response-field` — something we read is gone. **Silent**: the code
|
|
949
|
+
* keeps running and produces nothing, which is the dangerous one.
|
|
950
|
+
* `unreadable-spec` — the spec URL moved or stopped parsing. Not drift in the
|
|
951
|
+
* API, but drift in our ability to see it, and it must not read as "clean".
|
|
952
|
+
* `stale-review` — nobody has looked at this contract in a long time.
|
|
953
|
+
*/
|
|
954
|
+
kind: "missing-operation" | "missing-request-field" | "missing-response-field" | "unreadable-spec" | "stale-review";
|
|
955
|
+
operation?: string;
|
|
956
|
+
detail: string;
|
|
957
|
+
};
|
|
958
|
+
type DriftReport = {
|
|
959
|
+
connector: string;
|
|
960
|
+
checkedAt: string;
|
|
961
|
+
/** `unchecked` is a real outcome and never counts as clean. */
|
|
962
|
+
outcome: "clean" | "drifted" | "unchecked";
|
|
963
|
+
findings: DriftFinding[];
|
|
964
|
+
/** How the check was done, so a report can be read without the code. */
|
|
965
|
+
method: "openapi" | "lexicon" | "recorded-shape" | "none";
|
|
966
|
+
};
|
|
967
|
+
/**
|
|
968
|
+
* Check a contract against an OpenAPI document already fetched by the caller.
|
|
969
|
+
*
|
|
970
|
+
* The FETCH is the caller's, not this function's — so the check is a pure
|
|
971
|
+
* function of two documents and can be tested against a fixture with no network,
|
|
972
|
+
* and so a host chooses its own HTTP client, cache and proxy. A checker that
|
|
973
|
+
* fetched would also be a checker that phones out on a schedule, which is
|
|
974
|
+
* exactly the thing several hosts of this package refuse to have.
|
|
975
|
+
*/
|
|
976
|
+
declare function checkAgainstOpenApi(contract: ApiContract, document: unknown): DriftReport;
|
|
977
|
+
/**
|
|
978
|
+
* The field NAMES a connector saw in a real response, per operation.
|
|
979
|
+
*
|
|
980
|
+
* Names only. A recorded response body from a real account is a data leak
|
|
981
|
+
* wearing a test fixture's clothes, and the thing being checked is the shape.
|
|
982
|
+
*/
|
|
983
|
+
type RecordedShape = {
|
|
984
|
+
connector: string;
|
|
985
|
+
recordedOn: string;
|
|
986
|
+
operations: Record<string, string[]>;
|
|
987
|
+
};
|
|
988
|
+
/**
|
|
989
|
+
* The field names present in a response, flattened to dotted paths.
|
|
990
|
+
*
|
|
991
|
+
* Arrays collapse to their first element's shape: a response with three items
|
|
992
|
+
* has one shape, and recording `items.0.id`, `items.1.id`, `items.2.id` would
|
|
993
|
+
* make the fixture depend on how much data the account happened to have.
|
|
994
|
+
*/
|
|
995
|
+
declare function shapeOf(value: unknown, prefix?: string, depth?: number): string[];
|
|
996
|
+
/**
|
|
997
|
+
* Compare a live response's shape against what was recorded.
|
|
998
|
+
*
|
|
999
|
+
* **Only fields the connector READS are findings.** A provider adding fields is
|
|
1000
|
+
* normal and healthy; reporting it would bury the one line that matters under
|
|
1001
|
+
* every additive release, and a drift report nobody reads is worse than none
|
|
1002
|
+
* because it is evidence somebody is watching.
|
|
1003
|
+
*/
|
|
1004
|
+
declare function checkAgainstRecordedShape(contract: ApiContract, operation: string, liveResponse: unknown): DriftReport;
|
|
1005
|
+
/** How long a contract may go unreviewed before that is itself a finding. */
|
|
1006
|
+
declare const REVIEW_STALE_DAYS = 180;
|
|
1007
|
+
/**
|
|
1008
|
+
* A contract whose provider publishes nothing, checked as far as it can be.
|
|
1009
|
+
*
|
|
1010
|
+
* Reported as `unchecked` with the reason, never `clean`. Those are different
|
|
1011
|
+
* claims and the difference is the entire value of a report.
|
|
1012
|
+
*/
|
|
1013
|
+
declare function unchecked(contract: ApiContract): DriftReport;
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* The connector error taxonomy.
|
|
1017
|
+
*
|
|
1018
|
+
* A host that retries needs to know WHICH KIND of failure it is looking at.
|
|
1019
|
+
* "Something went wrong" is not enough information to choose between retrying,
|
|
1020
|
+
* pausing for a person, and stopping — so every failure is classified at the
|
|
1021
|
+
* point it is known, which is inside the connector, not three frames up where
|
|
1022
|
+
* the type has already been lost.
|
|
1023
|
+
*
|
|
1024
|
+
* | class | kind | retry? |
|
|
1025
|
+
* |---|---|---|
|
|
1026
|
+
* | `ConnectorConfigError` | `rejected` | never — nothing about a second attempt changes an unset key |
|
|
1027
|
+
* | `ConnectorAuthError` | `rejected` | never — the credential is wrong; hammering it locks accounts |
|
|
1028
|
+
* | `ConnectorRateLimited` | `refused-explicitly` | yes, after `retryAfter` |
|
|
1029
|
+
* | `ConnectorTransient` | `refused-explicitly` | yes — a 5xx is the provider saying it did nothing |
|
|
1030
|
+
* | `ConnectorAmbiguous` | `ambiguous` | **only** where the connector is idempotent |
|
|
1031
|
+
* | `ConnectorUnreachable` | `unreachable` | yes — it never arrived |
|
|
1032
|
+
* | `ConnectorRequestError` | `rejected` | never — a 4xx we caused fails the same way twice |
|
|
1033
|
+
*
|
|
1034
|
+
* ## What changed, and why it is a fix rather than a refactor
|
|
1035
|
+
*
|
|
1036
|
+
* `ConnectorTransient` used to mean *5xx OR a thrown transport*, with
|
|
1037
|
+
* `retryable = true`. Those are opposite cases. A 5xx is the provider reporting
|
|
1038
|
+
* that it did nothing; a thrown transport may be a socket that closed after the
|
|
1039
|
+
* bytes went out. Retrying the second on a connector with no idempotency key is
|
|
1040
|
+
* a silent double write — the failure this whole layer exists to prevent.
|
|
1041
|
+
*
|
|
1042
|
+
* So the two are separate classes now, `kind` is the primitive, and `retryable`
|
|
1043
|
+
* answers the narrow question *is this safe whatever the connector is?* An older
|
|
1044
|
+
* caller reading `.retryable` therefore becomes conservative rather than wrong.
|
|
1045
|
+
*/
|
|
1046
|
+
|
|
1047
|
+
type ConnectorErrorContext = {
|
|
1048
|
+
service: string;
|
|
1049
|
+
operation: string;
|
|
1050
|
+
/** HTTP status when there was one. */
|
|
1051
|
+
status?: number;
|
|
1052
|
+
/** The provider's own error code, when it publishes one. */
|
|
1053
|
+
providerCode?: string;
|
|
1054
|
+
};
|
|
1055
|
+
/** Base for everything below, so a host can catch the family in one clause. */
|
|
1056
|
+
declare class ConnectorError extends Error {
|
|
1057
|
+
readonly service: string;
|
|
1058
|
+
readonly operation: string;
|
|
1059
|
+
readonly status?: number;
|
|
1060
|
+
readonly providerCode?: string;
|
|
1061
|
+
/**
|
|
1062
|
+
* What kind of failure this is. The primitive every retry decision reads.
|
|
1063
|
+
*
|
|
1064
|
+
* Defaults to `ambiguous` on the base class deliberately: an unclassified
|
|
1065
|
+
* failure must not be assumed safe.
|
|
1066
|
+
*/
|
|
1067
|
+
readonly kind: FailureKind;
|
|
1068
|
+
constructor(message: string, ctx: ConnectorErrorContext);
|
|
1069
|
+
/**
|
|
1070
|
+
* Safe to retry **without knowing anything about the connector**.
|
|
1071
|
+
*
|
|
1072
|
+
* Ambiguous failures answer `false` here even though they may be retryable on
|
|
1073
|
+
* an idempotent connector — ask `shouldRetry(error.kind, { idempotent })` for
|
|
1074
|
+
* that. This getter is the conservative half on purpose.
|
|
1075
|
+
*/
|
|
1076
|
+
get retryable(): boolean;
|
|
1077
|
+
/** The classification, in the shape `deliver()` reads off a thrown error. */
|
|
1078
|
+
get classified(): Classified;
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* A required piece of configuration is missing or unusable.
|
|
1082
|
+
*
|
|
1083
|
+
* The message must name the exact key the consumer has to set. "Stripe is not
|
|
1084
|
+
* configured" sends someone reading source; "no `secretKey` on the `stripe`
|
|
1085
|
+
* connection" sends them to the line.
|
|
1086
|
+
*/
|
|
1087
|
+
declare class ConnectorConfigError extends ConnectorError {
|
|
1088
|
+
readonly kind: FailureKind;
|
|
1089
|
+
}
|
|
1090
|
+
/** The provider rejected the credential. Retrying cannot help. */
|
|
1091
|
+
declare class ConnectorAuthError extends ConnectorError {
|
|
1092
|
+
readonly kind: FailureKind;
|
|
1093
|
+
}
|
|
1094
|
+
/** The provider asked us to slow down. It did nothing, and said so. */
|
|
1095
|
+
declare class ConnectorRateLimited extends ConnectorError {
|
|
1096
|
+
readonly kind: FailureKind;
|
|
1097
|
+
/** Seconds to wait, when the provider said. */
|
|
1098
|
+
readonly retryAfter?: number;
|
|
1099
|
+
constructor(message: string, ctx: ConnectorErrorContext & {
|
|
1100
|
+
retryAfter?: number;
|
|
1101
|
+
});
|
|
1102
|
+
get classified(): Classified;
|
|
1103
|
+
}
|
|
1104
|
+
/**
|
|
1105
|
+
* A 5xx. The provider answered, and its answer was "not now".
|
|
1106
|
+
*
|
|
1107
|
+
* Named for what it is rather than for how it feels: this is an *explicit
|
|
1108
|
+
* refusal*, which is why it is always safe to repeat. A timeout feels the same
|
|
1109
|
+
* to the caller and is not the same thing — see `ConnectorAmbiguous`.
|
|
1110
|
+
*/
|
|
1111
|
+
declare class ConnectorTransient extends ConnectorError {
|
|
1112
|
+
readonly kind: FailureKind;
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* The request never reached the provider — DNS, refused connection, a socket
|
|
1116
|
+
* closed before anything went out.
|
|
1117
|
+
*
|
|
1118
|
+
* Always safe to repeat, and the only transport-level failure of which that is
|
|
1119
|
+
* true.
|
|
1120
|
+
*/
|
|
1121
|
+
declare class ConnectorUnreachable extends ConnectorError {
|
|
1122
|
+
readonly kind: FailureKind;
|
|
1123
|
+
}
|
|
1124
|
+
/**
|
|
1125
|
+
* Nobody can tell whether the provider acted on it. A timeout, an abort, an
|
|
1126
|
+
* error nothing recognises.
|
|
1127
|
+
*
|
|
1128
|
+
* **Retryable only where the connector declared that repeating a request is
|
|
1129
|
+
* harmless.** On anything else this is reported for a person, because the
|
|
1130
|
+
* alternative is a duplicate that nobody will ever trace back to a bug.
|
|
1131
|
+
*/
|
|
1132
|
+
declare class ConnectorAmbiguous extends ConnectorError {
|
|
1133
|
+
readonly kind: FailureKind;
|
|
1134
|
+
}
|
|
1135
|
+
/** A 4xx we caused. The same request will fail the same way. */
|
|
1136
|
+
declare class ConnectorRequestError extends ConnectorError {
|
|
1137
|
+
readonly kind: FailureKind;
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* Classify an HTTP response into the taxonomy.
|
|
1141
|
+
*
|
|
1142
|
+
* 429 before the 4xx sweep, deliberately — see `classifyStatus`.
|
|
1143
|
+
*/
|
|
1144
|
+
declare function classifyHttp(status: number, ctx: ConnectorErrorContext, body: string, retryAfter?: number): ConnectorError;
|
|
1145
|
+
/**
|
|
1146
|
+
* Turn a thrown transport failure into the right class.
|
|
1147
|
+
*
|
|
1148
|
+
* The default is `ConnectorAmbiguous`, not `ConnectorTransient`. That single
|
|
1149
|
+
* default is the difference between "a flaky network costs us a retry" and "a
|
|
1150
|
+
* flaky network costs someone a duplicate charge".
|
|
1151
|
+
*/
|
|
1152
|
+
declare function classifyThrown(cause: unknown, ctx: ConnectorErrorContext): ConnectorError;
|
|
1153
|
+
/**
|
|
1154
|
+
* An HTTP failure carrying its classification, for connectors that decide what
|
|
1155
|
+
* counts as a failure themselves.
|
|
1156
|
+
*
|
|
1157
|
+
* Telegram answers `200 OK` with `{"ok": false}` for a real refusal, so its
|
|
1158
|
+
* connector has to raise the failure itself — and when it does, it must be
|
|
1159
|
+
* classified the same way every other failure is. `httpFailure(400, …)` is how
|
|
1160
|
+
* it says "this is a real no" without inventing a second vocabulary.
|
|
1161
|
+
*/
|
|
1162
|
+
declare function httpFailure(status: number, body: string, retryAfter?: string | null): Error;
|
|
1163
|
+
|
|
1164
|
+
/**
|
|
1165
|
+
* A run, a position inside it, and how many times that position has been tried.
|
|
1166
|
+
*
|
|
1167
|
+
* Declared STRUCTURALLY rather than imported, and that is the whole point: this
|
|
1168
|
+
* package must be usable by a host that has never heard of a workflow engine.
|
|
1169
|
+
* `fancy-flow`'s `RunIdentity` class satisfies this shape exactly, so a flow
|
|
1170
|
+
* node passes `ctx.run` straight in with no adapter and no dependency in either
|
|
1171
|
+
* direction — and a host with its own notion of "the same logical attempt"
|
|
1172
|
+
* implements five members and gets the same guarantees.
|
|
1173
|
+
*
|
|
1174
|
+
* The identity is more than `(run, step)` for a reason: a step legitimately runs
|
|
1175
|
+
* many times in one run — once per loop iteration, once per nested invocation —
|
|
1176
|
+
* so the key carries the PATH that led to it. `attempt` is carried and
|
|
1177
|
+
* deliberately NOT part of the key, because a retry must produce the same key.
|
|
1178
|
+
*/
|
|
1179
|
+
type RunIdentity = {
|
|
1180
|
+
/** Stable for the whole run: same across retries, resumes, workers and hosts. */
|
|
1181
|
+
readonly runKey: string;
|
|
1182
|
+
/** 1-based attempt of THIS logical step. Never part of the key. */
|
|
1183
|
+
readonly attempt: number;
|
|
1184
|
+
/** ISO-8601 instant of attempt 1 of this step. */
|
|
1185
|
+
readonly firstAttemptAt: string;
|
|
1186
|
+
/** The identity of one execution of one step, stable across its retries. */
|
|
1187
|
+
stepKey(stepId: string, occurrence?: number | null): string;
|
|
1188
|
+
/** May this attempt reuse the key and still be deduplicated? */
|
|
1189
|
+
isReplaySafe(windowSeconds: number | null | undefined, now?: Date | string): boolean;
|
|
1190
|
+
};
|
|
1191
|
+
/**
|
|
1192
|
+
* The idempotency key a writing connector sends, and when it must refuse to.
|
|
1193
|
+
*
|
|
1194
|
+
* ## What the engine now provides
|
|
1195
|
+
*
|
|
1196
|
+
* `fancy-flow` 0.46.0 carries a **run identity** on the execution context, so a
|
|
1197
|
+
* node can derive a key that is the same on every retry of one logical step and
|
|
1198
|
+
* different for every other execution of the same node.
|
|
1199
|
+
* `ctx.run.stepKey(ctx.node.id)` is that key. The equivalent landed in
|
|
1200
|
+
* `fancy-flow-php` 0.19.0 and the Python runtime, all three pinned by
|
|
1201
|
+
* `shared/flow-run-identity` in `@particle-academy/fancy-conformance`.
|
|
1202
|
+
*
|
|
1203
|
+
* Before that, neither engine could produce one, and both obvious substitutes
|
|
1204
|
+
* were worse than sending nothing:
|
|
1205
|
+
*
|
|
1206
|
+
* - **the node id alone** is stable across retries and also across RUNS, so two
|
|
1207
|
+
* legitimate payments share a key and the provider silently collapses the
|
|
1208
|
+
* second into the first — a payment that never happened, reported as success;
|
|
1209
|
+
* - **a fresh random value** is unique per run and also per ATTEMPT, so a retry
|
|
1210
|
+
* creates a second charge.
|
|
1211
|
+
*
|
|
1212
|
+
* So this used to return `null` and every writing node emitted a warning and
|
|
1213
|
+
* sent no key, staying pinned to one attempt. That is no longer the case where
|
|
1214
|
+
* a host supplies an identity — which durable runs do automatically.
|
|
1215
|
+
*
|
|
1216
|
+
* ## The part that is still a judgement call: the provider's window
|
|
1217
|
+
*
|
|
1218
|
+
* Providers forget idempotency keys. Stripe's window is **24 hours**. Past it,
|
|
1219
|
+
* resending the key creates a second charge and sending a fresh one creates a
|
|
1220
|
+
* second charge — there is no safe third option — so `idempotencyKeyFor`
|
|
1221
|
+
* **throws** rather than choosing. A loud stuck run that a person reconciles
|
|
1222
|
+
* beats a silent double write nobody ever sees.
|
|
1223
|
+
*
|
|
1224
|
+
* A first attempt is never refused, however long the run was parked: nothing
|
|
1225
|
+
* was sent on an earlier attempt, so there is nothing for the provider to have
|
|
1226
|
+
* forgotten. That is what lets an approval sit for a week and then charge.
|
|
1227
|
+
*/
|
|
1228
|
+
/** Stripe's window, and the default for a provider that has not said otherwise. */
|
|
1229
|
+
declare const DEFAULT_IDEMPOTENCY_WINDOW_SECONDS: number;
|
|
1230
|
+
/**
|
|
1231
|
+
* Longest key the strictest provider in the catalogue accepts.
|
|
1232
|
+
*
|
|
1233
|
+
* Stripe caps `Idempotency-Key` at 255 characters. A deep subflow path could
|
|
1234
|
+
* exceed that, and a provider rejecting the header is a 400 that looks like a
|
|
1235
|
+
* bug in the request body.
|
|
1236
|
+
*/
|
|
1237
|
+
declare const MAX_IDEMPOTENCY_KEY_LENGTH = 255;
|
|
1238
|
+
/** Raised when a retry can no longer be made safe. Never retryable. */
|
|
1239
|
+
declare class ConnectorIdempotencyExpiredError extends ConnectorConfigError {
|
|
1240
|
+
}
|
|
1241
|
+
/** The run identity a host published, or `null`. Checked in order. */
|
|
1242
|
+
declare function runIdentityFrom(ctx: unknown): RunIdentity | null;
|
|
1243
|
+
/**
|
|
1244
|
+
* Where a host with no engine support may still publish a run key.
|
|
1245
|
+
*
|
|
1246
|
+
* Kept because a consumer on an older `fancy-flow` has no `ctx.run`, and
|
|
1247
|
+
* seeding `__runKey` into the run's initial inputs is the documented fallback.
|
|
1248
|
+
* It gives a per-run key with no attempt information, so the window check below
|
|
1249
|
+
* treats it as a first attempt — correct for a host that does not retry, and
|
|
1250
|
+
* the reason the engine-supplied identity is strictly better.
|
|
1251
|
+
*/
|
|
1252
|
+
declare function runKeyFrom(ctx: unknown): string | null;
|
|
1253
|
+
type IdempotencyOptions = {
|
|
1254
|
+
/**
|
|
1255
|
+
* How long the provider remembers a key. `null` means it never forgets; `0`
|
|
1256
|
+
* means it does not dedupe at all, so no retry may reuse one.
|
|
1257
|
+
*/
|
|
1258
|
+
windowSeconds?: number | null;
|
|
1259
|
+
/** Distinguishes repeated executions of one node at the same level. */
|
|
1260
|
+
occurrence?: number | null;
|
|
1261
|
+
/** Injectable for tests. Never a network call. */
|
|
1262
|
+
now?: Date;
|
|
1263
|
+
/** For the error message when a key cannot be made safe. */
|
|
1264
|
+
context?: Partial<ConnectorErrorContext>;
|
|
1265
|
+
};
|
|
1266
|
+
/**
|
|
1267
|
+
* The idempotency key for this execution of this node, or `null`.
|
|
1268
|
+
*
|
|
1269
|
+
* `null` means the host published no run identity at all — send no header
|
|
1270
|
+
* rather than inventing one, and keep the node `unsafe-to-replay`.
|
|
1271
|
+
*
|
|
1272
|
+
* **Throws** `ConnectorIdempotencyExpiredError` when this is a RETRY and the
|
|
1273
|
+
* provider's window has elapsed. That is not a defensive check; it is the only
|
|
1274
|
+
* correct answer, because both alternatives write twice.
|
|
1275
|
+
*/
|
|
1276
|
+
declare function idempotencyKeyFor(ctx: unknown, nodeId: string, options?: IdempotencyOptions): string | null;
|
|
1277
|
+
/**
|
|
1278
|
+
* The warning a node emits when it is about to write without a key.
|
|
1279
|
+
*
|
|
1280
|
+
* Said out loud, at the moment it matters, because the failure it precedes is
|
|
1281
|
+
* invisible: the run succeeds, the retry succeeds, and there are two charges.
|
|
1282
|
+
*/
|
|
1283
|
+
declare const NO_IDEMPOTENCY_KEY_WARNING: string;
|
|
1284
|
+
|
|
1285
|
+
/**
|
|
1286
|
+
* Measuring and slicing text the way providers actually measure and slice it.
|
|
1287
|
+
*
|
|
1288
|
+
* ## Why a package owns this at all
|
|
1289
|
+
*
|
|
1290
|
+
* Two of the nastiest bugs in a connector catalogue are one-liners, and both
|
|
1291
|
+
* come from reaching for the obvious JavaScript idiom:
|
|
1292
|
+
*
|
|
1293
|
+
* 1. **`text.length` is not a count of anything a provider limits.** It counts
|
|
1294
|
+
* UTF-16 code units. `"👍".length` is 2 and `"👨👩👧".length` is 8, so it
|
|
1295
|
+
* overcounts exactly the posts most likely to sit near a limit — and it fails
|
|
1296
|
+
* in the worse direction, because a post that passes our check and is then
|
|
1297
|
+
* refused by the server reads as the network being flaky rather than as our
|
|
1298
|
+
* bug.
|
|
1299
|
+
* 2. **`indexOf` returns a character index, and rich-text formats want BYTES.**
|
|
1300
|
+
* AT Protocol facets are UTF-8 byte ranges. An implementation using character
|
|
1301
|
+
* indices is correct for ASCII and silently corrupts every post containing an
|
|
1302
|
+
* emoji or an accent — and it corrupts the *link*, so the post looks fine and
|
|
1303
|
+
* the link goes somewhere wrong.
|
|
1304
|
+
*
|
|
1305
|
+
* Neither is discoverable by testing with ASCII, which is what everyone tests
|
|
1306
|
+
* with. So the package makes the wrong thing unreachable: a `ByteRange` is a
|
|
1307
|
+
* type, not a convention, and it can only be produced by a function that
|
|
1308
|
+
* encoded the string.
|
|
1309
|
+
*
|
|
1310
|
+
* ## The unit is declared, never assumed
|
|
1311
|
+
*
|
|
1312
|
+
* Providers count different things — Bluesky counts graphemes, Discord and
|
|
1313
|
+
* Telegram count characters, some count bytes. `TextUnit` makes the choice
|
|
1314
|
+
* explicit at the point a limit is declared, so nobody has to remember which
|
|
1315
|
+
* network is which.
|
|
1316
|
+
*/
|
|
1317
|
+
/** What a provider actually counts. Declared per connector, never guessed. */
|
|
1318
|
+
type TextUnit = "graphemes" | "characters" | "utf8-bytes";
|
|
1319
|
+
/**
|
|
1320
|
+
* A half-open range of UTF-8 BYTES.
|
|
1321
|
+
*
|
|
1322
|
+
* Named for the unit because the whole failure mode is somebody passing
|
|
1323
|
+
* character offsets into a field that wants bytes. The two are equal for ASCII,
|
|
1324
|
+
* which is why this survives review and fails in production.
|
|
1325
|
+
*/
|
|
1326
|
+
type ByteRange = {
|
|
1327
|
+
byteStart: number;
|
|
1328
|
+
byteEnd: number;
|
|
1329
|
+
};
|
|
1330
|
+
/**
|
|
1331
|
+
* Count graphemes — what a person means by "characters".
|
|
1332
|
+
*
|
|
1333
|
+
* `Intl.Segmenter` is the correct tool and has been in Node since 16. The
|
|
1334
|
+
* fallback is the spread operator, which counts code points rather than UTF-16
|
|
1335
|
+
* units and so is wrong by less. Both are better than `.length`.
|
|
1336
|
+
*/
|
|
1337
|
+
declare function graphemes(text: string): number;
|
|
1338
|
+
/** Length of `text` in UTF-8 bytes. */
|
|
1339
|
+
declare function utf8Length(text: string): number;
|
|
1340
|
+
/**
|
|
1341
|
+
* Count `text` in whichever unit a provider enforces.
|
|
1342
|
+
*
|
|
1343
|
+
* The one function every limit check goes through, so a connector never has to
|
|
1344
|
+
* decide — and so `text.length` never appears in connector source at all.
|
|
1345
|
+
*/
|
|
1346
|
+
declare function measure(text: string, unit: TextUnit): number;
|
|
1347
|
+
/**
|
|
1348
|
+
* The UTF-8 byte range of a substring that starts at a CHARACTER index.
|
|
1349
|
+
*
|
|
1350
|
+
* The bridge between the index a regex gives you and the offsets a provider
|
|
1351
|
+
* wants. Takes the character index because that is what `matchAll` produces, and
|
|
1352
|
+
* returns bytes because that is what goes on the wire — doing the conversion in
|
|
1353
|
+
* one place is the entire point.
|
|
1354
|
+
*/
|
|
1355
|
+
declare function byteRangeOf(text: string, charIndex: number, substring: string): ByteRange;
|
|
1356
|
+
/**
|
|
1357
|
+
* Extract the substring a byte range names.
|
|
1358
|
+
*
|
|
1359
|
+
* Exists so a test can prove a range points where it claims to, which is the
|
|
1360
|
+
* only way to catch an off-by-one that ASCII hides. A range that lands
|
|
1361
|
+
* mid-character yields the replacement character rather than throwing, because
|
|
1362
|
+
* the useful failure here is a visibly wrong string in an assertion, not an
|
|
1363
|
+
* exception three frames away.
|
|
1364
|
+
*/
|
|
1365
|
+
declare function sliceByteRange(text: string, range: ByteRange): string;
|
|
1366
|
+
/**
|
|
1367
|
+
* Bare URLs in `text`, with their UTF-8 byte ranges.
|
|
1368
|
+
*
|
|
1369
|
+
* Trailing punctuation is excluded: a link at the end of a sentence would
|
|
1370
|
+
* otherwise swallow the full stop and 404. Closing brackets go too, for the same
|
|
1371
|
+
* reason and because a URL in parentheses is common in prose.
|
|
1372
|
+
*
|
|
1373
|
+
* This is the generic half of a "link facet" — a connector maps the ranges into
|
|
1374
|
+
* whatever its provider's rich-text shape is. The package owns the offsets
|
|
1375
|
+
* because the offsets are what people get wrong; it does not own the provider's
|
|
1376
|
+
* schema, because that is the provider's.
|
|
1377
|
+
*/
|
|
1378
|
+
declare function linkRanges(text: string): Array<ByteRange & {
|
|
1379
|
+
url: string;
|
|
1380
|
+
}>;
|
|
1381
|
+
|
|
1382
|
+
/**
|
|
1383
|
+
* Rendering — what a provider would ACTUALLY receive.
|
|
1384
|
+
*
|
|
1385
|
+
* ## Why a connector package owns this, and why it is dangerous
|
|
1386
|
+
*
|
|
1387
|
+
* One piece of content often has to reach several providers whose limits are
|
|
1388
|
+
* incompatible. Without adaptation the same text is wasteful on one and refused
|
|
1389
|
+
* by another. With naive adaptation, the bytes that reach the public are not the
|
|
1390
|
+
* bytes anyone approved — and every guarantee a host makes about approval
|
|
1391
|
+
* becomes a lie the moment adaptation exists.
|
|
1392
|
+
*
|
|
1393
|
+
* So rendering happens **before** whatever gate the host applies: the person
|
|
1394
|
+
* approving sees exactly what each provider will receive and approves *that*.
|
|
1395
|
+
* Nothing is invented after the decision.
|
|
1396
|
+
*
|
|
1397
|
+
* ## Which forces three properties
|
|
1398
|
+
*
|
|
1399
|
+
* 1. **Pure.** Rules in, payload out. No clock, no randomness, no network. If
|
|
1400
|
+
* the preview and the send could disagree, the approval means nothing.
|
|
1401
|
+
* Anything variable — an instance's configured limit — is resolved by the
|
|
1402
|
+
* CALLER and passed in as a rule, so it becomes part of what was approved.
|
|
1403
|
+
* 2. **Versioned.** Any change to how text is split, counted or faceted bumps
|
|
1404
|
+
* `RENDERER_VERSION`, and the version is **inside** the payload hash rather
|
|
1405
|
+
* than beside it, so it cannot be checked separately and forgotten.
|
|
1406
|
+
* 3. **Honest about loss.** Anything that could not be fitted is reported as a
|
|
1407
|
+
* problem, never quietly applied. A truncated URL is worse than a refused
|
|
1408
|
+
* post, because it looks deliberate and goes somewhere wrong.
|
|
1409
|
+
*
|
|
1410
|
+
* ## And one rule about where length lives
|
|
1411
|
+
*
|
|
1412
|
+
* **No length rule outside this module.** A validator and a renderer that both
|
|
1413
|
+
* judge length will disagree, and then the validator refuses content the
|
|
1414
|
+
* renderer had already solved. `validate()` on a connector checks things the
|
|
1415
|
+
* renderer cannot — media, alt text, a required field — and leaves length here.
|
|
1416
|
+
*/
|
|
1417
|
+
|
|
1418
|
+
/**
|
|
1419
|
+
* Bump on ANY change to how text is split, counted, or faceted.
|
|
1420
|
+
*
|
|
1421
|
+
* A payload approved under `1` refuses to send once this reads `2`, because the
|
|
1422
|
+
* payload the approver saw is no longer the payload that would go out. That is
|
|
1423
|
+
* the correct outcome and the reason the field exists.
|
|
1424
|
+
*/
|
|
1425
|
+
declare const RENDERER_VERSION = "render@1";
|
|
1426
|
+
type Segment = {
|
|
1427
|
+
/** The text of this message, exactly as it would be sent. */
|
|
1428
|
+
text: string;
|
|
1429
|
+
/** How many of the provider's unit this segment uses. */
|
|
1430
|
+
count: number;
|
|
1431
|
+
/** Link ranges, in UTF-8 bytes, **relative to this segment**. */
|
|
1432
|
+
links?: Array<ByteRange & {
|
|
1433
|
+
url: string;
|
|
1434
|
+
}>;
|
|
1435
|
+
};
|
|
1436
|
+
type RenderedPayload = {
|
|
1437
|
+
/** One entry per message. More than one means a thread. */
|
|
1438
|
+
segments: Segment[];
|
|
1439
|
+
/** What the provider counts, so a preview can label the number. */
|
|
1440
|
+
unit: TextUnit;
|
|
1441
|
+
/**
|
|
1442
|
+
* What the provider allows, or **null where it imposes no limit**.
|
|
1443
|
+
*
|
|
1444
|
+
* Null rather than a very large number: `Number.MAX_SAFE_INTEGER` is
|
|
1445
|
+
* arithmetically fine and reads, on a person's screen, as
|
|
1446
|
+
* `157 / 9007199254740991` — a number pretending to be a fact. "No limit" and
|
|
1447
|
+
* "a limit so large it cannot be hit" are different statements.
|
|
1448
|
+
*/
|
|
1449
|
+
limit: number | null;
|
|
1450
|
+
/** Why this could not be rendered, when it could not. Never silently repaired. */
|
|
1451
|
+
problems: string[];
|
|
1452
|
+
rendererVersion: string;
|
|
1453
|
+
};
|
|
1454
|
+
/**
|
|
1455
|
+
* The rules a provider imposes on a message, as DATA.
|
|
1456
|
+
*
|
|
1457
|
+
* Declarative on purpose. Across the providers this was extracted from, the
|
|
1458
|
+
* differences that mattered were all values — the limit, the unit, whether a
|
|
1459
|
+
* thread mechanism exists, whether links carry byte offsets. A provider whose
|
|
1460
|
+
* rendering genuinely needs code supplies its own `render` on the connector and
|
|
1461
|
+
* ignores this; the point of the rule set is that most do not have to.
|
|
1462
|
+
*/
|
|
1463
|
+
type RenderRules = {
|
|
1464
|
+
/** The provider's limit per message, or null where it has none. */
|
|
1465
|
+
limit: number | null;
|
|
1466
|
+
unit: TextUnit;
|
|
1467
|
+
/**
|
|
1468
|
+
* Whether this provider can carry a sequence of connected messages.
|
|
1469
|
+
*
|
|
1470
|
+
* False means over-length content is REFUSED rather than split — splitting
|
|
1471
|
+
* would invent a structure the provider does not have, and a numbered sequence
|
|
1472
|
+
* of unconnected messages is worse than a refusal because it looks deliberate.
|
|
1473
|
+
*/
|
|
1474
|
+
thread: boolean;
|
|
1475
|
+
/** Include UTF-8 link ranges per segment, for providers with rich-text spans. */
|
|
1476
|
+
links?: boolean;
|
|
1477
|
+
/** Human name, used in refusal messages. */
|
|
1478
|
+
label: string;
|
|
1479
|
+
/**
|
|
1480
|
+
* Suffix added to each part of a thread, given the 1-based index and total.
|
|
1481
|
+
*
|
|
1482
|
+
* Defaults to ` (i/n)`. Its cost is measured and subtracted from the limit
|
|
1483
|
+
* BEFORE splitting, because a suffix discovered afterwards is a bug that only
|
|
1484
|
+
* appears on long threads.
|
|
1485
|
+
*/
|
|
1486
|
+
numbering?: (index: number, total: number) => string;
|
|
1487
|
+
};
|
|
1488
|
+
/**
|
|
1489
|
+
* Split text into parts that each fit the limit.
|
|
1490
|
+
*
|
|
1491
|
+
* **Sentence boundaries first, then words, and never mid-grapheme.** A thread
|
|
1492
|
+
* that splits mid-argument is worse than one that splits a little unevenly, so
|
|
1493
|
+
* this prefers a natural break even when it wastes room.
|
|
1494
|
+
*
|
|
1495
|
+
* A single token that cannot fit at all is reported as a problem rather than
|
|
1496
|
+
* hard-cut — see the module note about truncated URLs.
|
|
1497
|
+
*/
|
|
1498
|
+
declare function splitToFit(text: string, limit: number, unit: TextUnit): {
|
|
1499
|
+
parts: string[];
|
|
1500
|
+
problems: string[];
|
|
1501
|
+
};
|
|
1502
|
+
/**
|
|
1503
|
+
* Render text against a provider's rule set.
|
|
1504
|
+
*
|
|
1505
|
+
* The one entry point. A connector that needs nothing bespoke declares rules and
|
|
1506
|
+
* calls this; the shared behaviour — numbering cost, per-segment link offsets,
|
|
1507
|
+
* refusal where there is no thread mechanism — is then identical everywhere,
|
|
1508
|
+
* which is what makes a catalogue feel like one thing.
|
|
1509
|
+
*/
|
|
1510
|
+
declare function render(text: string, rules: RenderRules): RenderedPayload;
|
|
1511
|
+
/**
|
|
1512
|
+
* A stable fingerprint of what would actually be sent.
|
|
1513
|
+
*
|
|
1514
|
+
* This is what an approval covers. A host recomputes it at dispatch and refuses
|
|
1515
|
+
* on mismatch: if the rendering changed for ANY reason — edited text, a changed
|
|
1516
|
+
* limit, a new renderer version — the hashes differ, because the approver
|
|
1517
|
+
* approved a payload rather than an intention.
|
|
1518
|
+
*
|
|
1519
|
+
* Async because it uses WebCrypto, which is the only hash available on every
|
|
1520
|
+
* runtime the suite supports. A host that wants it synchronously should hold the
|
|
1521
|
+
* value rather than recompute it.
|
|
1522
|
+
*/
|
|
1523
|
+
declare function payloadHash(payload: RenderedPayload): Promise<string>;
|
|
1524
|
+
/**
|
|
1525
|
+
* Refuse empty content, centrally.
|
|
1526
|
+
*
|
|
1527
|
+
* The sha of the empty string is a valid sha, so an empty payload passes every
|
|
1528
|
+
* byte-verification a host can write. Refusing it in one place is what stops
|
|
1529
|
+
* every one of those checks from being hollow.
|
|
1530
|
+
*/
|
|
1531
|
+
declare function isEmptyPayload(payload: RenderedPayload): boolean;
|
|
1532
|
+
|
|
1533
|
+
/**
|
|
1534
|
+
* The seam — two objects, deliberately separate.
|
|
1535
|
+
*
|
|
1536
|
+
* They answer different questions and change on different clocks:
|
|
1537
|
+
*
|
|
1538
|
+
* - **`ProviderAdapter`** — *how does an operator stand this up, and does it
|
|
1539
|
+
* work?* Credential shapes, setup steps with the trap in each one named,
|
|
1540
|
+
* scopes, a read-only verify. It changes when the provider changes its
|
|
1541
|
+
* onboarding, which is rarely and disruptively.
|
|
1542
|
+
* - **`Connector`** — *what does calling this actually involve?* Capabilities,
|
|
1543
|
+
* delivery behaviour, validation, rendering, the call itself, measurement. It
|
|
1544
|
+
* changes when the API changes, which is often and quietly.
|
|
1545
|
+
*
|
|
1546
|
+
* Folding them together produces an object that has to be edited for both, and
|
|
1547
|
+
* a diff that cannot tell you which happened.
|
|
1548
|
+
*
|
|
1549
|
+
* ## What this package does NOT own, and never will
|
|
1550
|
+
*
|
|
1551
|
+
* Everything on this list belongs to the HOST, because each is enforced in one
|
|
1552
|
+
* place and every connector inherits it from the dispatch path rather than
|
|
1553
|
+
* implementing it:
|
|
1554
|
+
*
|
|
1555
|
+
* - **approval** — whether this may go out at all;
|
|
1556
|
+
* - **liveness** — `call(..., { dryRun })` takes the flag; a connector must
|
|
1557
|
+
* never resolve its own. A host may require two independent yeses, or ten;
|
|
1558
|
+
* that is its business and the package cannot help it get that wrong;
|
|
1559
|
+
* - **the approved-bytes comparison** — the host re-renders and refuses on
|
|
1560
|
+
* mismatch. The package makes that possible by keeping `render` pure and
|
|
1561
|
+
* versioned, and does not perform it;
|
|
1562
|
+
* - **consent** for anything reaching a list of people;
|
|
1563
|
+
* - **any journal** — decisions, dispatches, runs;
|
|
1564
|
+
* - **credential storage.** Credentials are ARGUMENTS. Nothing in this package
|
|
1565
|
+
* reads `process.env`, and a test asserts that it does not.
|
|
1566
|
+
*
|
|
1567
|
+
* A packager that owned any of those would be unusable by a host that takes them
|
|
1568
|
+
* seriously — which is the only kind of host worth building for.
|
|
1569
|
+
*/
|
|
1570
|
+
|
|
1571
|
+
/**
|
|
1572
|
+
* One credential a provider needs.
|
|
1573
|
+
*
|
|
1574
|
+
* **`secret` is chosen per field, never inferred from the type.** A Discord
|
|
1575
|
+
* webhook URL is a URL and is entirely a secret — it carries its token in the
|
|
1576
|
+
* path — and the reference implementation very nearly stored it as
|
|
1577
|
+
* configuration because it looked like a URL.
|
|
1578
|
+
*/
|
|
1579
|
+
type CredentialField = {
|
|
1580
|
+
/** Stable key. The host maps it to wherever it keeps values. */
|
|
1581
|
+
key: string;
|
|
1582
|
+
label: string;
|
|
1583
|
+
/** What it is, where it comes from, and what it does NOT prove. */
|
|
1584
|
+
help: string;
|
|
1585
|
+
/**
|
|
1586
|
+
* `provider` — one value for the whole installation (an OAuth app serves
|
|
1587
|
+
* every account). `account` — one value per connected account.
|
|
1588
|
+
*
|
|
1589
|
+
* The distinction exists because getting it wrong means either asking for the
|
|
1590
|
+
* same app secret five times or letting one account's token reach another's.
|
|
1591
|
+
*/
|
|
1592
|
+
scope: "provider" | "account";
|
|
1593
|
+
secret: boolean;
|
|
1594
|
+
required: boolean;
|
|
1595
|
+
/** A hint for an empty field. NEVER a real value. */
|
|
1596
|
+
placeholder?: string;
|
|
1597
|
+
};
|
|
1598
|
+
type SetupStep = {
|
|
1599
|
+
title: string;
|
|
1600
|
+
/** The step, and the trap in it. A step with no trap named is usually wrong. */
|
|
1601
|
+
detail: string;
|
|
1602
|
+
url?: string;
|
|
1603
|
+
};
|
|
1604
|
+
/**
|
|
1605
|
+
* The result of a read-only credential check.
|
|
1606
|
+
*
|
|
1607
|
+
* **`proves` is not decoration.** Telegram's `getMe` validates the token and
|
|
1608
|
+
* says nothing about whether the bot was added to the target chat — which is the
|
|
1609
|
+
* step everyone actually gets stuck on. A green tick that means more than it
|
|
1610
|
+
* should is worse than no tick, so a verify states its own scope.
|
|
1611
|
+
*/
|
|
1612
|
+
type VerifyResult = {
|
|
1613
|
+
ok: boolean;
|
|
1614
|
+
/** What was learned — ideally the account's real name, so the id can be checked. */
|
|
1615
|
+
detail: string;
|
|
1616
|
+
/** Exactly what this proves, and what it does not. */
|
|
1617
|
+
proves?: string;
|
|
1618
|
+
};
|
|
1619
|
+
/**
|
|
1620
|
+
* How an operator stands a provider up.
|
|
1621
|
+
*
|
|
1622
|
+
* Every provider worth listing is declared, **including the ones that cannot
|
|
1623
|
+
* work yet**: a provider missing from a catalogue reads as unsupported, while a
|
|
1624
|
+
* provider listed as `implemented: false` tells you exactly what it would take.
|
|
1625
|
+
*/
|
|
1626
|
+
type ProviderAdapter = {
|
|
1627
|
+
id: string;
|
|
1628
|
+
label: string;
|
|
1629
|
+
/** True when this can actually be used today. A real state, not a placeholder. */
|
|
1630
|
+
implemented: boolean;
|
|
1631
|
+
/** One line on what it is for, shown before anyone opens the setup. */
|
|
1632
|
+
summary: string;
|
|
1633
|
+
fields: CredentialField[];
|
|
1634
|
+
setup: SetupStep[];
|
|
1635
|
+
/** Exactly what to ask for, and no more. */
|
|
1636
|
+
scopes: string[];
|
|
1637
|
+
/**
|
|
1638
|
+
* How long credentials last, where the provider publishes a figure.
|
|
1639
|
+
*
|
|
1640
|
+
* Undefined means the provider states no lifetime — which a surface should
|
|
1641
|
+
* report as "no published expiry" rather than inventing one, and still note
|
|
1642
|
+
* that anything can be revoked. "No known expiry" is not "cannot stop working".
|
|
1643
|
+
*/
|
|
1644
|
+
credentialLifetimeDays?: number;
|
|
1645
|
+
/** How the provider exposes a test estate, in the four shapes that exist. */
|
|
1646
|
+
sandbox: SandboxKind;
|
|
1647
|
+
/**
|
|
1648
|
+
* A read-only call proving the credential works AND reaches the right account.
|
|
1649
|
+
*
|
|
1650
|
+
* Deliberately a read. A verify that wrote something would be a send, and
|
|
1651
|
+
* sends are gated.
|
|
1652
|
+
*/
|
|
1653
|
+
verify?(credentials: Record<string, string>): Promise<VerifyResult>;
|
|
1654
|
+
};
|
|
1655
|
+
type Problem = {
|
|
1656
|
+
severity: "block" | "warn";
|
|
1657
|
+
message: string;
|
|
1658
|
+
};
|
|
1659
|
+
/**
|
|
1660
|
+
* The result of one call.
|
|
1661
|
+
*
|
|
1662
|
+
* `ref` is how the provider identifies what was done — the handle every later
|
|
1663
|
+
* question is joined on. **A 2xx with no ref is a failure**, and the connector
|
|
1664
|
+
* says so rather than reporting a success nobody can point at.
|
|
1665
|
+
*/
|
|
1666
|
+
type CallResult = {
|
|
1667
|
+
ok: boolean;
|
|
1668
|
+
ref: string | null;
|
|
1669
|
+
/** True when nothing actually left the building. */
|
|
1670
|
+
dryRun: boolean;
|
|
1671
|
+
detail: string;
|
|
1672
|
+
/** Which estate this ran against. Always reported, never inferred. */
|
|
1673
|
+
mode?: ConnectorMode;
|
|
1674
|
+
};
|
|
1675
|
+
/**
|
|
1676
|
+
* What a connector can report, in its own words — **declared, not inferred**.
|
|
1677
|
+
*
|
|
1678
|
+
* Inferring the shape from whatever a pull last returned gives an empty shape
|
|
1679
|
+
* when nothing has been pulled, and then *"this reports nothing"* and *"nobody
|
|
1680
|
+
* has asked yet"* are the same blank, needing opposite actions.
|
|
1681
|
+
*
|
|
1682
|
+
* `canonical` is what the metric IS across providers, where an honest equivalent
|
|
1683
|
+
* exists. A like and a favourite are the same act; a repost and a boost are the
|
|
1684
|
+
* same act; a quote often has no equivalent, and `null` says so — which is more
|
|
1685
|
+
* useful than a mapping somebody invented to make a table line up.
|
|
1686
|
+
*/
|
|
1687
|
+
type MetricDescriptor = {
|
|
1688
|
+
/** The key the connector's own mapping actually returns. */
|
|
1689
|
+
key: string;
|
|
1690
|
+
/** What the provider itself calls it. */
|
|
1691
|
+
label: string;
|
|
1692
|
+
canonical: "like" | "share" | "reply" | "quote" | "view" | null;
|
|
1693
|
+
means: string;
|
|
1694
|
+
};
|
|
1695
|
+
type MetricSample = {
|
|
1696
|
+
ref: string;
|
|
1697
|
+
at: string;
|
|
1698
|
+
/** Only what the provider actually reported. **Absent stays absent.** */
|
|
1699
|
+
metrics: Record<string, number>;
|
|
1700
|
+
};
|
|
1701
|
+
/**
|
|
1702
|
+
* What a connector can do.
|
|
1703
|
+
*
|
|
1704
|
+
* **A capability flag must not outrun the code**, and this is checkable rather
|
|
1705
|
+
* than a rule people remember: `assertCapabilitiesHonest` in `metrics.ts` fails
|
|
1706
|
+
* when `metrics: true` has no declared shape. In the reference implementation
|
|
1707
|
+
* two connectors claimed metrics while returning `[]` for weeks — so a pull did
|
|
1708
|
+
* not skip them, it asked, got nothing, and reported nothing, which on a
|
|
1709
|
+
* dashboard is indistinguishable from *"we asked and nobody engaged"*.
|
|
1710
|
+
*/
|
|
1711
|
+
type Capabilities = {
|
|
1712
|
+
call: boolean;
|
|
1713
|
+
metrics: boolean;
|
|
1714
|
+
feedback: boolean;
|
|
1715
|
+
};
|
|
1716
|
+
/**
|
|
1717
|
+
* One capability of one provider.
|
|
1718
|
+
*
|
|
1719
|
+
* `Target` is the host's own content shape — the package has no opinion about
|
|
1720
|
+
* it, and a field named for one host's content model has no business in a shared
|
|
1721
|
+
* contract.
|
|
1722
|
+
*/
|
|
1723
|
+
type Connector<Target = unknown> = {
|
|
1724
|
+
id: string;
|
|
1725
|
+
label: string;
|
|
1726
|
+
/** Which `ProviderAdapter` stands this up. */
|
|
1727
|
+
provider: string;
|
|
1728
|
+
/**
|
|
1729
|
+
* The connector API version this was written against.
|
|
1730
|
+
*
|
|
1731
|
+
* Required, and checked at registration by `assertConnectorApi`. The core and
|
|
1732
|
+
* the catalogue release on separate clocks, and a connector is VENDORED — a
|
|
1733
|
+
* copy in someone else's repository, with no manifest of its own to carry a
|
|
1734
|
+
* version range. This number is the only thing that can say "this copy was
|
|
1735
|
+
* written for a core you no longer have", and it says it at build time rather
|
|
1736
|
+
* than in production. See `compat.ts`.
|
|
1737
|
+
*/
|
|
1738
|
+
connectorApi: number;
|
|
1739
|
+
capabilities: Capabilities;
|
|
1740
|
+
/**
|
|
1741
|
+
* How this behaves when a call goes wrong, and how fast it may be used.
|
|
1742
|
+
* Cited, not asserted — see `DeliveryDeclaration`.
|
|
1743
|
+
*/
|
|
1744
|
+
delivery: DeliveryDeclaration;
|
|
1745
|
+
/** Declared metric shape. **Absent, not empty**, where there are none. */
|
|
1746
|
+
metricShape?: MetricDescriptor[];
|
|
1747
|
+
/**
|
|
1748
|
+
* Rendering rules, where this connector renders text.
|
|
1749
|
+
*
|
|
1750
|
+
* Data rather than code for the common case. A provider whose rendering
|
|
1751
|
+
* genuinely needs code supplies `render` instead.
|
|
1752
|
+
*/
|
|
1753
|
+
renderRules?: RenderRules;
|
|
1754
|
+
/**
|
|
1755
|
+
* Everything the renderer cannot judge — media, alt text, a required field.
|
|
1756
|
+
* **Never length**: see the note at the top of `render.ts`.
|
|
1757
|
+
*/
|
|
1758
|
+
validate(target: Target): Problem[];
|
|
1759
|
+
/** Pure and versioned. Overrides `renderRules` when both are present. */
|
|
1760
|
+
render?(target: Target, rules?: Record<string, number>): RenderedPayload;
|
|
1761
|
+
/**
|
|
1762
|
+
* Do the thing.
|
|
1763
|
+
*
|
|
1764
|
+
* `credentials` are passed IN. `dryRun` is decided by the HOST. Neither is
|
|
1765
|
+
* negotiable, and both are asserted by tests in this package.
|
|
1766
|
+
*/
|
|
1767
|
+
call(target: Target, options: {
|
|
1768
|
+
dryRun: boolean;
|
|
1769
|
+
credentials: Record<string, string>;
|
|
1770
|
+
mode?: ConnectorMode;
|
|
1771
|
+
}): Promise<CallResult>;
|
|
1772
|
+
fetchMetrics?(refs: string[], credentials: Record<string, string>): Promise<MetricSample[]>;
|
|
1773
|
+
/**
|
|
1774
|
+
* Feedback on things we did, for refs the host already holds.
|
|
1775
|
+
*
|
|
1776
|
+
* **Optional, and absent is a real answer** rather than a gap to be filled. A
|
|
1777
|
+
* connector with no feedback mechanism must not pretend by returning an empty
|
|
1778
|
+
* array — "nobody replied" and "this cannot tell you" are different, and only
|
|
1779
|
+
* one of them is news.
|
|
1780
|
+
*/
|
|
1781
|
+
fetchFeedback?(refs: string[], credentials: Record<string, string>): Promise<Array<{
|
|
1782
|
+
id: string;
|
|
1783
|
+
inReplyTo: string;
|
|
1784
|
+
author: string;
|
|
1785
|
+
text: string;
|
|
1786
|
+
at: string;
|
|
1787
|
+
url: string | null;
|
|
1788
|
+
/** What answering this would need. Opaque to the host. */
|
|
1789
|
+
context: Record<string, string>;
|
|
1790
|
+
}>>;
|
|
1791
|
+
};
|
|
1792
|
+
/**
|
|
1793
|
+
* A catalogue of connectors and the providers that stand them up.
|
|
1794
|
+
*
|
|
1795
|
+
* A plain lookup rather than a registry with lifecycle: the host owns which
|
|
1796
|
+
* connectors it installed, and a package-level global would be one more thing
|
|
1797
|
+
* that can be half-initialised.
|
|
1798
|
+
*/
|
|
1799
|
+
type Catalogue<Target = unknown> = {
|
|
1800
|
+
providers: Record<string, ProviderAdapter>;
|
|
1801
|
+
connectors: Record<string, Connector<Target>>;
|
|
1802
|
+
};
|
|
1803
|
+
|
|
1804
|
+
/**
|
|
1805
|
+
* Making a declared metric shape checkable against the code that produces it.
|
|
1806
|
+
*
|
|
1807
|
+
* ## The bug this exists to catch, which shipped for weeks
|
|
1808
|
+
*
|
|
1809
|
+
* Two connectors in the reference implementation declared
|
|
1810
|
+
* `capabilities.metrics: true` while their metric fetch returned `[]`. A pull
|
|
1811
|
+
* therefore did not skip them — it asked, got nothing, and reported nothing,
|
|
1812
|
+
* which on a dashboard is indistinguishable from *"we asked and nobody
|
|
1813
|
+
* engaged"*. A capability flag had quietly turned an unimplemented feature into
|
|
1814
|
+
* a reported zero, on the one surface whose entire job is not making claims it
|
|
1815
|
+
* cannot stand behind.
|
|
1816
|
+
*
|
|
1817
|
+
* A declared shape and the code that produces the numbers are the classic pair
|
|
1818
|
+
* that agrees on the day it is written and drifts silently after. So the mapping
|
|
1819
|
+
* is extracted as a **pure function per connector**, and these helpers feed it a
|
|
1820
|
+
* synthetic response and compare the keys it returns against the keys declared.
|
|
1821
|
+
* **No credentials, no network.**
|
|
1822
|
+
*
|
|
1823
|
+
* ## And the rule underneath all of it
|
|
1824
|
+
*
|
|
1825
|
+
* **Absent stays absent.** A count the provider did not send is omitted, never
|
|
1826
|
+
* reported as zero. A zero says "nothing happened"; an absence says "we don't
|
|
1827
|
+
* know". A measurement surface that confuses those is worthless, and the
|
|
1828
|
+
* confusion is one `?? 0` away.
|
|
1829
|
+
*/
|
|
1830
|
+
|
|
1831
|
+
type ShapeMismatch = {
|
|
1832
|
+
connector: string;
|
|
1833
|
+
/** Keys the mapping produced that nothing declared. */
|
|
1834
|
+
undeclared: string[];
|
|
1835
|
+
/** Keys declared that the mapping never produces. */
|
|
1836
|
+
unproduced: string[];
|
|
1837
|
+
};
|
|
1838
|
+
/**
|
|
1839
|
+
* Compare a declared shape against what a mapping actually returns.
|
|
1840
|
+
*
|
|
1841
|
+
* `produced` is the output of the connector's pure response→metrics function
|
|
1842
|
+
* given a synthetic response with EVERY field populated. Anything the mapping
|
|
1843
|
+
* can emit therefore appears, and anything it cannot is caught as a declaration
|
|
1844
|
+
* nobody can honour.
|
|
1845
|
+
*/
|
|
1846
|
+
declare function compareShape(connectorId: string, declared: MetricDescriptor[] | undefined, produced: Record<string, number>): ShapeMismatch | null;
|
|
1847
|
+
/**
|
|
1848
|
+
* Every way a capability flag can outrun the code, as findings.
|
|
1849
|
+
*
|
|
1850
|
+
* Returns strings rather than throwing so a host can report all of them at once.
|
|
1851
|
+
* A check that stops at the first problem trains people to fix one thing and
|
|
1852
|
+
* re-run, which is how a list of six becomes six rounds.
|
|
1853
|
+
*/
|
|
1854
|
+
declare function capabilityProblems(connector: Connector<never>): string[];
|
|
1855
|
+
/**
|
|
1856
|
+
* Drop keys whose value the provider did not actually send.
|
|
1857
|
+
*
|
|
1858
|
+
* The one function a connector's mapping should build on, so *absent stays
|
|
1859
|
+
* absent* is a property of the code rather than a habit. `0` survives; `null`,
|
|
1860
|
+
* `undefined` and non-numbers do not.
|
|
1861
|
+
*/
|
|
1862
|
+
declare function reported(values: Record<string, unknown>): Record<string, number>;
|
|
1863
|
+
|
|
1864
|
+
/**
|
|
1865
|
+
* Dry-verify — proving the transport with a credential that cannot work.
|
|
1866
|
+
*
|
|
1867
|
+
* ## The discipline, and why it beats a fake server
|
|
1868
|
+
*
|
|
1869
|
+
* A connector written from documentation can be wrong in three ways that look
|
|
1870
|
+
* identical until a real credential arrives:
|
|
1871
|
+
*
|
|
1872
|
+
* 1. the URL is wrong,
|
|
1873
|
+
* 2. the request shape is wrong,
|
|
1874
|
+
* 3. the error handling never runs.
|
|
1875
|
+
*
|
|
1876
|
+
* **All three are invisible to a test with a fake server**, because a fake
|
|
1877
|
+
* server agrees with whatever the code does. That is the single most important
|
|
1878
|
+
* thing this module encodes: a green unit suite against a mock proves the code
|
|
1879
|
+
* is consistent with itself.
|
|
1880
|
+
*
|
|
1881
|
+
* So a probe calls the REAL API with a deliberately invalid credential and
|
|
1882
|
+
* requires an **auth-shaped refusal**. That proves the host resolved, the path
|
|
1883
|
+
* exists, the method was accepted, and a failure was recognised as a failure.
|
|
1884
|
+
*
|
|
1885
|
+
* ## What counts as a pass, precisely
|
|
1886
|
+
*
|
|
1887
|
+
* An auth-shaped refusal is `401`, `403`, or a `404` on a provider that answers
|
|
1888
|
+
* 404 for an unknown credential-in-the-path (a Discord webhook does exactly
|
|
1889
|
+
* this). The provider declares which, because guessing here would let a genuine
|
|
1890
|
+
* "this endpoint moved" 404 read as success — the one outcome a probe exists to
|
|
1891
|
+
* catch.
|
|
1892
|
+
*
|
|
1893
|
+
* ## Offline is SKIPPED, never failed
|
|
1894
|
+
*
|
|
1895
|
+
* A check that goes red on a train gets ignored, and then it is worth nothing
|
|
1896
|
+
* when it goes red for real. Unreachable is reported as skipped with the reason,
|
|
1897
|
+
* and the assertions that need no network still run.
|
|
1898
|
+
*
|
|
1899
|
+
* ## And nothing is ever sent
|
|
1900
|
+
*
|
|
1901
|
+
* Every probe is a READ. A probe that wrote something would be a send, and sends
|
|
1902
|
+
* are the host's to gate.
|
|
1903
|
+
*/
|
|
1904
|
+
|
|
1905
|
+
type ProbeOutcome = "pass" | "fail" | "skip";
|
|
1906
|
+
type ProbeResult = {
|
|
1907
|
+
connector: string;
|
|
1908
|
+
outcome: ProbeOutcome;
|
|
1909
|
+
/** What happened, in the provider's own words where possible. */
|
|
1910
|
+
detail: string;
|
|
1911
|
+
status?: number;
|
|
1912
|
+
kind?: FailureKind;
|
|
1913
|
+
};
|
|
1914
|
+
type ProbeSpec = {
|
|
1915
|
+
/** Which connector this probes. */
|
|
1916
|
+
connector: string;
|
|
1917
|
+
/**
|
|
1918
|
+
* The read-only request, fully formed, carrying a credential that cannot be
|
|
1919
|
+
* valid. Built by the connector so the probe exercises the connector's own URL
|
|
1920
|
+
* and auth placement rather than a second copy of them.
|
|
1921
|
+
*/
|
|
1922
|
+
request: () => Promise<{
|
|
1923
|
+
status: number;
|
|
1924
|
+
body: string;
|
|
1925
|
+
}>;
|
|
1926
|
+
/**
|
|
1927
|
+
* Statuses that count as an auth-shaped refusal for THIS provider.
|
|
1928
|
+
*
|
|
1929
|
+
* Declared rather than assumed. Discord answers `404` for an unknown webhook
|
|
1930
|
+
* id, and on a provider that does not, a 404 means the endpoint moved — the
|
|
1931
|
+
* exact drift a probe is for.
|
|
1932
|
+
*/
|
|
1933
|
+
authStatuses: number[];
|
|
1934
|
+
/** Why those statuses, for whoever reads a failure at 3am. */
|
|
1935
|
+
why: string;
|
|
1936
|
+
};
|
|
1937
|
+
/**
|
|
1938
|
+
* Run one probe.
|
|
1939
|
+
*
|
|
1940
|
+
* Never throws. A probe that threw would take the suite down with it, and the
|
|
1941
|
+
* suite's whole value is that it runs everywhere including on a laptop with no
|
|
1942
|
+
* network.
|
|
1943
|
+
*/
|
|
1944
|
+
declare function probe(spec: ProbeSpec): Promise<ProbeResult>;
|
|
1945
|
+
type ProbeReport = {
|
|
1946
|
+
results: ProbeResult[];
|
|
1947
|
+
passed: number;
|
|
1948
|
+
failed: number;
|
|
1949
|
+
skipped: number;
|
|
1950
|
+
/** True when nothing failed. Skips do not fail a report. */
|
|
1951
|
+
ok: boolean;
|
|
1952
|
+
};
|
|
1953
|
+
/** Run a set of probes and summarise. Sequential: probes hit real providers. */
|
|
1954
|
+
declare function runProbes(specs: ProbeSpec[]): Promise<ProbeReport>;
|
|
1955
|
+
|
|
1956
|
+
/**
|
|
1957
|
+
* Verifying an inbound provider webhook.
|
|
1958
|
+
*
|
|
1959
|
+
* An unverified webhook endpoint is a public, unauthenticated way to start a
|
|
1960
|
+
* workflow in someone's account — which is to say, a way for a stranger to make
|
|
1961
|
+
* your app refund an order or post to your Slack. So verification is part of the
|
|
1962
|
+
* connector pattern rather than an exercise for the host: a trigger node that
|
|
1963
|
+
* cannot verify its deliveries must say so, and a host must not be able to mount
|
|
1964
|
+
* one by accident.
|
|
1965
|
+
*
|
|
1966
|
+
* ## The three things every provider's scheme actually needs
|
|
1967
|
+
*
|
|
1968
|
+
* Providers differ in header names and in what exactly gets signed, and almost
|
|
1969
|
+
* nothing else:
|
|
1970
|
+
*
|
|
1971
|
+
* 1. **The RAW body.** Signatures are computed over bytes. Re-serialising parsed
|
|
1972
|
+
* JSON changes key order and whitespace and produces a signature mismatch
|
|
1973
|
+
* that looks like a wrong secret. The host must pass the body it received.
|
|
1974
|
+
* 2. **A constant-time comparison.** `===` on a signature leaks, through timing,
|
|
1975
|
+
* which prefix was right. That is a real forgery path, not a theoretical one.
|
|
1976
|
+
* 3. **A timestamp tolerance.** Without it a valid signature is valid forever,
|
|
1977
|
+
* so anyone who ever saw one delivery can replay it whenever they like.
|
|
1978
|
+
*
|
|
1979
|
+
* WebCrypto rather than `node:crypto`, so the same file runs on Node, Bun, Deno
|
|
1980
|
+
* and an edge runtime. Node 22 is the suite's floor and has it globally.
|
|
1981
|
+
*/
|
|
1982
|
+
type WebhookVerification = {
|
|
1983
|
+
ok: true;
|
|
1984
|
+
} | {
|
|
1985
|
+
ok: false;
|
|
1986
|
+
reason: string;
|
|
1987
|
+
};
|
|
1988
|
+
type HmacScheme = {
|
|
1989
|
+
/** Hash to use. */
|
|
1990
|
+
algorithm: "SHA-256" | "SHA-1" | "SHA-512";
|
|
1991
|
+
/**
|
|
1992
|
+
* Build the exact string that gets signed. Providers differ here more than
|
|
1993
|
+
* anywhere else — Stripe signs `${timestamp}.${body}`, Slack signs
|
|
1994
|
+
* `v0:${timestamp}:${body}`, GitHub signs the body alone.
|
|
1995
|
+
*/
|
|
1996
|
+
payload: (raw: string, timestamp?: string) => string;
|
|
1997
|
+
/** Seconds a delivery stays acceptable. */
|
|
1998
|
+
tolerance?: number;
|
|
1999
|
+
/** Encoding of the signature the provider sends. */
|
|
2000
|
+
encoding?: "hex" | "base64";
|
|
2001
|
+
};
|
|
2002
|
+
/**
|
|
2003
|
+
* Verify an HMAC-signed delivery.
|
|
2004
|
+
*
|
|
2005
|
+
* Returns a RESULT rather than throwing, and the failure carries a reason: a
|
|
2006
|
+
* host wants to log which check failed (stale? wrong secret? no header?) while
|
|
2007
|
+
* still answering the provider with an opaque 400.
|
|
2008
|
+
*/
|
|
2009
|
+
declare function verifyHmac(options: {
|
|
2010
|
+
raw: string;
|
|
2011
|
+
signature: string | undefined;
|
|
2012
|
+
secret: string | undefined;
|
|
2013
|
+
scheme: HmacScheme;
|
|
2014
|
+
timestamp?: string;
|
|
2015
|
+
/** Seconds since the epoch. Injected so tests are not clock-dependent. */
|
|
2016
|
+
now?: number;
|
|
2017
|
+
}): Promise<WebhookVerification>;
|
|
2018
|
+
/** HMAC of `payload` under `secret`, hex or base64 encoded. */
|
|
2019
|
+
declare function hmac(secret: string, payload: string, algorithm: HmacScheme["algorithm"], encoding?: "hex" | "base64"): Promise<string>;
|
|
2020
|
+
/**
|
|
2021
|
+
* Compare two strings without leaking where they diverge.
|
|
2022
|
+
*
|
|
2023
|
+
* The length check first is deliberate and safe: a signature's length is fixed
|
|
2024
|
+
* by its algorithm and is not a secret, so revealing a mismatch there tells an
|
|
2025
|
+
* attacker nothing they could not compute.
|
|
2026
|
+
*/
|
|
2027
|
+
declare function constantTimeEquals(a: string, b: string): boolean;
|
|
2028
|
+
/**
|
|
2029
|
+
* Pull one header case-insensitively.
|
|
2030
|
+
*
|
|
2031
|
+
* Header case is not preserved consistently across proxies, frameworks and
|
|
2032
|
+
* runtimes; a connector that read `req.headers["Stripe-Signature"]` would work
|
|
2033
|
+
* behind one server and reject every delivery behind another.
|
|
2034
|
+
*/
|
|
2035
|
+
declare function header(headers: Record<string, string | string[] | undefined>, name: string): string | undefined;
|
|
2036
|
+
|
|
2037
|
+
/**
|
|
2038
|
+
* How a connector trigger learns that something happened.
|
|
2039
|
+
*
|
|
2040
|
+
* **Not every provider pushes, and assuming webhooks is the single most common
|
|
2041
|
+
* way a connector catalogue ends up lying.** Google Drive and Microsoft Graph
|
|
2042
|
+
* want a *subscription* you must renew before it expires. Salesforce wants a
|
|
2043
|
+
* Pub/Sub or CometD stream. S3 wants an event notification routed to
|
|
2044
|
+
* infrastructure the consumer provisions. Reddit wants you to poll. Each of
|
|
2045
|
+
* those is a different obligation on the host, and a node that declared
|
|
2046
|
+
* "webhook" for all of them would install cleanly and then never fire.
|
|
2047
|
+
*
|
|
2048
|
+
* So a trigger declares its delivery mechanism, and the host can refuse to mount
|
|
2049
|
+
* one it has no machinery for — at author time, not at 3am.
|
|
2050
|
+
*/
|
|
2051
|
+
|
|
2052
|
+
/**
|
|
2053
|
+
* The delivery mechanisms that actually exist in the wild.
|
|
2054
|
+
*
|
|
2055
|
+
* - `webhook` — the provider POSTs to a URL you own. Verify the signature.
|
|
2056
|
+
* - `subscription` — a webhook that EXPIRES. Someone has to renew it, forever,
|
|
2057
|
+
* and if nobody does the workflow stops firing without an error anywhere.
|
|
2058
|
+
* Distinct from `webhook` precisely because that ongoing duty is invisible.
|
|
2059
|
+
* - `poll` — you ask, on a schedule, and track a cursor.
|
|
2060
|
+
* - `pubsub` — the provider publishes to a broker (Google Pub/Sub, Salesforce
|
|
2061
|
+
* Pub/Sub API, SNS/SQS) that the consumer must provision.
|
|
2062
|
+
* - `stream` — a long-lived connection the host maintains (websocket, gRPC,
|
|
2063
|
+
* SSE, CometD).
|
|
2064
|
+
*/
|
|
2065
|
+
type DeliveryMechanism = "webhook" | "subscription" | "poll" | "pubsub" | "stream";
|
|
2066
|
+
type TriggerDescriptor = {
|
|
2067
|
+
service: string;
|
|
2068
|
+
operation: string;
|
|
2069
|
+
delivery: DeliveryMechanism;
|
|
2070
|
+
/**
|
|
2071
|
+
* What the host has to do before this fires, in one sentence, aimed at the
|
|
2072
|
+
* person who has to do it. Surfaced by the editor and by the MCP, because a
|
|
2073
|
+
* trigger whose setup is undocumented is a trigger that silently never fires.
|
|
2074
|
+
*/
|
|
2075
|
+
setup: string;
|
|
2076
|
+
/** For `subscription`: how long the provider keeps it alive, in seconds. */
|
|
2077
|
+
subscriptionTtl?: number;
|
|
2078
|
+
/** For `webhook` / `subscription`: how a delivery is authenticated. */
|
|
2079
|
+
verification?: WebhookVerificationSpec;
|
|
2080
|
+
/** For `poll`: the provider's smallest sane interval, in seconds. */
|
|
2081
|
+
minPollSeconds?: number;
|
|
2082
|
+
/** A faked sample event, so the node is runnable before any of the above. */
|
|
2083
|
+
faker: ConnectorFaker;
|
|
2084
|
+
};
|
|
2085
|
+
type WebhookVerificationSpec = {
|
|
2086
|
+
/** Header carrying the signature. */
|
|
2087
|
+
signatureHeader: string;
|
|
2088
|
+
/** Header carrying the timestamp, when the scheme signs one. */
|
|
2089
|
+
timestampHeader?: string;
|
|
2090
|
+
scheme: HmacScheme;
|
|
2091
|
+
/**
|
|
2092
|
+
* Some schemes pack the timestamp INTO the signature header
|
|
2093
|
+
* (Stripe: `t=…,v1=…`). Given the raw header value, return the parts.
|
|
2094
|
+
*/
|
|
2095
|
+
parse?: (raw: string) => {
|
|
2096
|
+
signature?: string;
|
|
2097
|
+
timestamp?: string;
|
|
2098
|
+
};
|
|
2099
|
+
};
|
|
2100
|
+
type InboundDelivery = {
|
|
2101
|
+
/** The body EXACTLY as received. Not re-serialised. See `webhook.ts`. */
|
|
2102
|
+
raw: string;
|
|
2103
|
+
headers: Record<string, string | string[] | undefined>;
|
|
2104
|
+
};
|
|
2105
|
+
/**
|
|
2106
|
+
* Verify an inbound delivery against a trigger's declared scheme.
|
|
2107
|
+
*
|
|
2108
|
+
* A trigger with no `verification` REFUSES rather than accepts. That asymmetry
|
|
2109
|
+
* is the whole safety property: an unverifiable endpoint is a stranger's button
|
|
2110
|
+
* for starting workflows in your account, and defaulting to "allow" would make
|
|
2111
|
+
* every misconfiguration into an open door that looks shut.
|
|
2112
|
+
*/
|
|
2113
|
+
declare function verifyDelivery(trigger: TriggerDescriptor, delivery: InboundDelivery, secret: string | undefined, now?: number): Promise<WebhookVerification>;
|
|
2114
|
+
/**
|
|
2115
|
+
* The event a trigger node publishes when it runs.
|
|
2116
|
+
*
|
|
2117
|
+
* Three sources, in order, and the third is why a connector trigger is usable
|
|
2118
|
+
* on day one:
|
|
2119
|
+
*
|
|
2120
|
+
* 1. A verified delivery the host injected on the `in` port.
|
|
2121
|
+
* 2. Nothing injected, but the connection is remote — that is a host wiring
|
|
2122
|
+
* error, and it fails loudly rather than emitting a plausible blank.
|
|
2123
|
+
* 3. `fake` mode — the trigger emits its own sample event, so an author can
|
|
2124
|
+
* press Run, see the real field names, and wire the downstream nodes against
|
|
2125
|
+
* them before the provider has ever been contacted.
|
|
2126
|
+
*/
|
|
2127
|
+
declare function triggerEvent(trigger: TriggerDescriptor, connection: ResolvedConnection, injected: unknown, config: Record<string, unknown>): unknown;
|
|
2128
|
+
|
|
2129
|
+
export { AMBIGUOUS_REFUSAL, type ApiContract, type Attempt, type ByteRange, CONNECTOR_API_VERSION, CONNECTOR_MODES, type CallOptions, type CallResult, type Capabilities, type Catalogue, type ChainLinks, type ChainOutcome, type ChainRef, type Citation, type Classified, type ConnectionCredentials, type ConnectionHost, type ConnectionSpec, type Connector, ConnectorAmbiguous, ConnectorApiMismatch, ConnectorAuthError, ConnectorConfigError, ConnectorError, type ConnectorErrorContext, type ConnectorFaker, ConnectorIdempotencyExpiredError, type ConnectorMode, ConnectorModeError, ConnectorRateLimited, type ConnectorRequest, ConnectorRequestError, type ConnectorResult, ConnectorTransient, ConnectorUnreachable, type ContractOperation, type CredentialField, DEFAULT_IDEMPOTENCY_WINDOW_SECONDS, DEFAULT_RETRY, type DeliveryDeclaration, type DeliveryMechanism, type DeliveryOutcome, type DriftFinding, type DriftReport, FAKE_EPOCH, type FailureKind, type FakeRequest, type FakeValues, type HmacScheme, type HostEnvironment, type IdempotencyOptions, type InboundDelivery, MAX_IDEMPOTENCY_KEY_LENGTH, type MetricDescriptor, type MetricSample, type ModeInputs, NO_IDEMPOTENCY_KEY_WARNING, type PreparedRequest, type ProbeOutcome, type ProbeReport, type ProbeResult, type ProbeSpec, type Problem, type ProviderAdapter, RENDERER_VERSION, REVIEW_STALE_DAYS, type RecordedShape, type RenderRules, type RenderedPayload, type RequestedMode, type ResolveOptions, type ResolvedConnection, type RetryPolicy, type RunIdentity, SUPPORTED_CONNECTOR_API, type SandboxKind, type Segment, type ServiceDescriptor, type SetupStep, type ShapeMismatch, type SpecSource, type TextUnit, type Transport, type TransportResponse, type TriggerDescriptor, type VerifyResult, type WebhookVerification, type WebhookVerificationSpec, assertConnectorApi, byteRangeOf, callConnector, capabilityProblems, checkAgainstOpenApi, checkAgainstRecordedShape, classifyError, classifyHttp, classifyStatus, classifyThrown, compareShape, connectionsFor, constantTimeEquals, deliver, fakeRequest, fakeValues, getConnectionHost, graphemes, header, hmac, httpFailure, idempotencyKeyFor, isEmptyPayload, isRemote, isUnconditionallyRetryable, linkRanges, measure, payloadHash, postChain, probe, registerConnectionHost, registerTransport, render, reported, resetRateState, resolveConnection, resolveConnectorMode, respectRate, runIdentityFrom, runKeyFrom, runProbes, satisfiesMinimum, seedFrom, shapeOf, shouldRetry, sliceByteRange, splitToFit, triggerEvent, unchecked, utf8Length, verifyDelivery, verifyHmac };
|