@particle-academy/fancy-connector-core 0.1.0 → 0.3.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 CHANGED
@@ -6,8 +6,120 @@ All notable changes to `fancy-connector-core` are documented here, in
6
6
  **This package is pre-1.0, so breaking changes land in MINOR releases.** The
7
7
  version number is not a promise it can keep yet; the entries below are.
8
8
 
9
+ ## [0.3.0] - 2026-08-19
10
+
11
+ ### Fixed
12
+
13
+ - **`ECONNRESET` and `EPIPE` are `ambiguous`, not `unreachable`. This was a live
14
+ duplicate-send hazard.** Both were listed as codes proving the request never
15
+ left. They do not. A peer sends RST whenever it tears the connection down,
16
+ *including after it has received and acted on the request but before the
17
+ response comes back*; `EPIPE` is the same story from our side. Node surfaces
18
+ the safe case and the unsafe case as the same code, so the code cannot carry
19
+ the claim.
20
+
21
+ Because `shouldRetry` returns true for `unreachable` **without consulting
22
+ `idempotent`**, a reset produced up to three sends of a connector that had
23
+ explicitly declared `idempotent: false`. For a consumer publishing to social
24
+ networks that is a second public post that cannot be withdrawn.
25
+
26
+ This is the same defect as the 5xx / thrown-transport split fixed earlier, and
27
+ it contradicted this module's own stated rule -- *"an unknown failure treated
28
+ as unreachable would be retried, and the one thing worse than a lost send is
29
+ two sends nobody approved."* The two codes were standing exceptions to a rule
30
+ that should have covered them.
31
+
32
+ **What a consumer must DO:** nothing, if your connectors declare `idempotent`
33
+ honestly -- the fix only removes retries that were never safe. If you have a
34
+ connector marked `idempotent: true` that is not actually safe to repeat, this
35
+ release does not save you; that field is still load-bearing. If you were
36
+ relying on resets being retried for a genuinely idempotent connector, they
37
+ still are: `ambiguous` retries when `idempotent: true`.
38
+
39
+ `ECONNREFUSED`, `ENOTFOUND`, `EAI_AGAIN`, `EHOSTUNREACH` and `ENETUNREACH`
40
+ stay `unreachable` -- they do prove nothing was delivered -- and a test now
41
+ pins them there so the fix cannot overcorrect.
42
+
43
+ Found by a consumer's conformance fixture disagreeing with the core on its
44
+ first run, with a reproduction.
45
+
9
46
  ## [Unreleased]
10
47
 
48
+ ## [0.2.0] — unreleased
49
+
50
+ The reference consumer migrated five adapters onto `0.1.0`, ran its acceptance
51
+ suite, and filed four findings. All four were cases where the type or the
52
+ classifier was *safe* and still *wrong* — the hardest kind to notice from inside,
53
+ because nothing fails.
54
+
55
+ ### Fixed
56
+
57
+ - **`classifyError` ignored the classification `httpFailure` attached.** It
58
+ re-derived from scratch, found no error code, and answered `ambiguous`
59
+ ("nobody can tell") for a `400` the provider had been explicit about.
60
+ **This was never a retry bug** — `deliver()` reads `error.classified` first and
61
+ only falls back — but a host that calls the exported classifier directly, which
62
+ is a reasonable thing to do, was told nobody could tell. The **PHP twin already
63
+ did this**, so the two runtimes disagreed. **What a consumer must do: nothing,
64
+ unless you asserted the old answer.** If you pinned `classifyError` on an
65
+ `httpFailure` to `ambiguous`, that assertion now reads `rejected` or
66
+ `refused-explicitly` — which is the answer you wanted.
67
+
68
+ ### Changed
69
+
70
+ - **BREAKING (types only): `ChainRef` is `object`, was
71
+ `Record<string, string | number>`.** An ordinary interface does not satisfy an
72
+ index signature unless it declares one, so `interface PostRef { uri; cid }` did
73
+ **not** satisfy the old constraint — and the only other fix was to add an index
74
+ signature to a type used across a codebase in order to please a generic used in
75
+ one place. The reference consumer worked around it with `postChain<T & ChainRef>`
76
+ and two `as unknown as` casts, and could not use `ChainOutcome<T>` in its own
77
+ signatures at all. **What a consumer must do: delete the casts.** A constraint
78
+ everyone casts past is not enforcing anything.
79
+
80
+ ### Added
81
+
82
+ - **`SandboxKind` gained `restricted-reach`** — the fifth shape, and the
83
+ dangerous one. A Meta app in Development Mode, an unaudited TikTok app that can
84
+ only post `SELF_ONLY`: same credentials, same endpoints, same estate, and only
85
+ the **audience** restricted. That is none of the previous four, and `none` —
86
+ which is what it was being forced into — is actively wrong. It is the shape
87
+ most likely to be mistaken for a sandbox, because it looks exactly like a
88
+ successful post that nobody can see: `ok: true`, a real id, no audience.
89
+ - **`SandboxKind` gained `unverified`** — *nobody has checked yet*. A real state,
90
+ and the type has to carry it: this is the field where being wrong sends someone
91
+ to a live estate believing it is a test one, so "researched but not verified"
92
+ must not be forced to pick one of the other five. A comment saying so is not a
93
+ type.
94
+ - **`sandboxIsSelectable()` / `sandboxRefusal()`** (`SandboxKind::isSelectable()`
95
+ / `->refusal()` in PHP). Auto-resolution no longer picks "sandbox" for anything
96
+ that cannot be selected, and each refusal explains **its own** reason —
97
+ collapsing the three into "no sandbox available" would hide the one that
98
+ matters.
99
+ - **`providerProblems()`** — the sibling of `capabilityProblems()`, one level up.
100
+ Reports `implemented: true` with `sandbox: "unverified"`, a `verify` that does
101
+ not say what it **proves**, a `restricted-reach` provider whose summary never
102
+ mentions reach, and empty fields or setup on something claimed to be
103
+ implemented.
104
+ - **`ProviderAdapter.proves`** — what a verify does NOT cover, declared on the
105
+ adapter as well as on each result, so a surface can say it *before* anyone runs
106
+ the check.
107
+ - **`withResolvedLimit(rules, limit)`** — the supported way to resolve a
108
+ per-connection limit. A Mastodon instance publishes its own `max_toot_chars`;
109
+ baking a limit into a connector renders to 500 on an instance allowing 5000, or
110
+ to 5000 on one allowing 500, and both fail silently. `undefined` keeps the
111
+ declared limit, because *"I did not look"* is not *"there is no limit"* — and
112
+ `null` is a real answer meaning **uncounted**.
113
+ - **`CREDENTIAL_SCOPES`, `SANDBOX_KINDS`, `PROBLEM_SEVERITIES`,
114
+ `CANONICAL_METRICS`** — every string union that crosses a JSON boundary now
115
+ ships as **data** as well as a type. The compiler cannot follow a type across
116
+ JSON: when `scope` was renamed from `app`/`brand`, a consumer re-declaring the
117
+ field shape on its client kept compiling and its `scope === "brand"` silently
118
+ became never-true, so every credential field would have rendered as shared.
119
+ (PHP had this for free — an enum is runtime data, and `tryFrom` fails loudly on
120
+ a value nobody serves any more.)
121
+
122
+
11
123
  ## [0.1.0] — unreleased
12
124
 
13
125
  First cut. Extracted and generalised from the `_connector` shared runtime in the
@@ -99,5 +211,6 @@ clock; this package is the runtime it is written on.
99
211
  satisfies it with no dependency in either direction and a host that has never
100
212
  heard of a workflow engine can implement it.
101
213
 
102
- [Unreleased]: https://github.com/Particle-Academy/fancy-connector-core/compare/v0.1.0...HEAD
214
+ [Unreleased]: https://github.com/Particle-Academy/fancy-connector-core/compare/v0.2.0...HEAD
215
+ [0.2.0]: https://github.com/Particle-Academy/fancy-connector-core/compare/v0.1.0...v0.2.0
103
216
  [0.1.0]: https://github.com/Particle-Academy/fancy-connector-core/releases/tag/v0.1.0
package/README.md CHANGED
@@ -165,6 +165,31 @@ wrong.
165
165
 
166
166
  ---
167
167
 
168
+ ## Depend on this with an open range, not a caret
169
+
170
+ ```jsonc
171
+ // package.json
172
+ "@particle-academy/fancy-connector-core": ">=0.2.0 <2.0.0"
173
+ ```
174
+ ```jsonc
175
+ // composer.json
176
+ "particle-academy/fancy-connector-core": ">=0.2.0 <2.0.0"
177
+ ```
178
+
179
+ **Not `^0.2.0`.** A caret on a `0.x` locks the MINOR — npm and Composer both read
180
+ `^0.2.0` as `>=0.2.0 <0.3.0` — so the next release is out of range and
181
+ **`npm update` reports success having installed nothing.** A resolver quietly
182
+ keeping the old version is indistinguishable from a resolver that had nothing to
183
+ do, which is why this is worth stating rather than leaving to convention.
184
+
185
+ This package intends the open range: **pre-1.0 minors may change what a connector
186
+ sees, and `CONNECTOR_API_VERSION` is what tells you when** — loudly, at
187
+ registration, naming which side is behind. That is a stronger guarantee than a
188
+ caret gives you, and it is the reason the caret is not needed here.
189
+
190
+ The first consumer of this package pinned `^0.1.0` and would have missed 0.2.0
191
+ entirely. That is the whole argument.
192
+
168
193
  ## Documentation
169
194
 
170
195
  - [`AGENTS.md`](./AGENTS.md) — the invariants, the traps, and what a change here
package/dist/index.cjs CHANGED
@@ -21,8 +21,10 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var src_exports = {};
22
22
  __export(src_exports, {
23
23
  AMBIGUOUS_REFUSAL: () => AMBIGUOUS_REFUSAL,
24
+ CANONICAL_METRICS: () => CANONICAL_METRICS,
24
25
  CONNECTOR_API_VERSION: () => CONNECTOR_API_VERSION,
25
26
  CONNECTOR_MODES: () => CONNECTOR_MODES,
27
+ CREDENTIAL_SCOPES: () => CREDENTIAL_SCOPES,
26
28
  ConnectorAmbiguous: () => ConnectorAmbiguous,
27
29
  ConnectorApiMismatch: () => ConnectorApiMismatch,
28
30
  ConnectorAuthError: () => ConnectorAuthError,
@@ -39,8 +41,10 @@ __export(src_exports, {
39
41
  FAKE_EPOCH: () => FAKE_EPOCH,
40
42
  MAX_IDEMPOTENCY_KEY_LENGTH: () => MAX_IDEMPOTENCY_KEY_LENGTH,
41
43
  NO_IDEMPOTENCY_KEY_WARNING: () => NO_IDEMPOTENCY_KEY_WARNING,
44
+ PROBLEM_SEVERITIES: () => PROBLEM_SEVERITIES,
42
45
  RENDERER_VERSION: () => RENDERER_VERSION,
43
46
  REVIEW_STALE_DAYS: () => REVIEW_STALE_DAYS,
47
+ SANDBOX_KINDS: () => SANDBOX_KINDS,
44
48
  SUPPORTED_CONNECTOR_API: () => SUPPORTED_CONNECTOR_API,
45
49
  assertConnectorApi: () => assertConnectorApi,
46
50
  byteRangeOf: () => byteRangeOf,
@@ -72,6 +76,8 @@ __export(src_exports, {
72
76
  payloadHash: () => payloadHash,
73
77
  postChain: () => postChain,
74
78
  probe: () => probe,
79
+ providerProblems: () => providerProblems,
80
+ providerSandboxIsSelectable: () => providerSandboxIsSelectable,
75
81
  registerConnectionHost: () => registerConnectionHost,
76
82
  registerTransport: () => registerTransport,
77
83
  render: () => render,
@@ -83,6 +89,8 @@ __export(src_exports, {
83
89
  runIdentityFrom: () => runIdentityFrom,
84
90
  runKeyFrom: () => runKeyFrom,
85
91
  runProbes: () => runProbes,
92
+ sandboxIsSelectable: () => sandboxIsSelectable,
93
+ sandboxRefusal: () => sandboxRefusal,
86
94
  satisfiesMinimum: () => satisfiesMinimum,
87
95
  seedFrom: () => seedFrom,
88
96
  shapeOf: () => shapeOf,
@@ -93,7 +101,8 @@ __export(src_exports, {
93
101
  unchecked: () => unchecked,
94
102
  utf8Length: () => utf8Length,
95
103
  verifyDelivery: () => verifyDelivery,
96
- verifyHmac: () => verifyHmac
104
+ verifyHmac: () => verifyHmac,
105
+ withResolvedLimit: () => withResolvedLimit
97
106
  });
98
107
  module.exports = __toCommonJS(src_exports);
99
108
 
@@ -149,16 +158,33 @@ function satisfiesMinimum(coreVersion, minimum) {
149
158
 
150
159
  // src/delivery.ts
151
160
  var UNREACHABLE_CODES = /* @__PURE__ */ new Set([
161
+ // Every code here must PROVE the request never reached an application:
162
+ // nothing listening, no route, or a name that never resolved. If a code can
163
+ // also occur after the peer received and acted on the request, it belongs in
164
+ // AMBIGUOUS_CODES -- `shouldRetry` retries `unreachable` without consulting
165
+ // `idempotent`, so a wrong entry here is a duplicate send, not a slow one.
152
166
  "ECONNREFUSED",
153
167
  "ENOTFOUND",
154
168
  "EAI_AGAIN",
155
169
  "EHOSTUNREACH",
156
170
  "ENETUNREACH",
157
- "ECONNRESET",
158
- "EPIPE",
159
171
  "ERR_SOCKET_CONNECTION_TIMEOUT"
160
172
  ]);
161
173
  var AMBIGUOUS_CODES = /* @__PURE__ */ new Set([
174
+ // A peer sends RST whenever it tears the connection down -- INCLUDING after
175
+ // it has received and acted on the request but before the response comes
176
+ // back. EPIPE is the same story from our side: we wrote to a socket the peer
177
+ // had already closed, and whether an earlier complete request was processed
178
+ // first is not knowable from here. Node surfaces the safe case and the unsafe
179
+ // case as the same code, so the code cannot carry the claim.
180
+ //
181
+ // These two sat in UNREACHABLE_CODES until 0.3.0, which made them
182
+ // unconditionally retryable and produced three sends of connectors that had
183
+ // declared `idempotent: false`. Same defect as the 5xx / thrown-transport
184
+ // split this module already fixed once, and a stated exception to a rule that
185
+ // should have covered them.
186
+ "ECONNRESET",
187
+ "EPIPE",
162
188
  "ETIMEDOUT",
163
189
  "UND_ERR_HEADERS_TIMEOUT",
164
190
  "UND_ERR_BODY_TIMEOUT",
@@ -166,6 +192,8 @@ var AMBIGUOUS_CODES = /* @__PURE__ */ new Set([
166
192
  ]);
167
193
  var AMBIGUOUS_NAMES = /* @__PURE__ */ new Set(["AbortError", "TimeoutError"]);
168
194
  function classifyError(error) {
195
+ const attached = error?.classified;
196
+ if (attached && typeof attached === "object" && typeof attached.kind === "string") return attached;
169
197
  const detail = error instanceof Error ? error.message : String(error);
170
198
  const code = error?.code ?? error?.cause?.code ?? "";
171
199
  const name = error instanceof Error ? error.name : "";
@@ -403,19 +431,42 @@ function fakeRequest(service, operation, config, input) {
403
431
 
404
432
  // src/mode.ts
405
433
  var CONNECTOR_MODES = ["fake", "sandbox", "live"];
434
+ var SANDBOX_KINDS = [
435
+ "credential",
436
+ "base-url",
437
+ "separate-account",
438
+ "restricted-reach",
439
+ "none",
440
+ "unverified"
441
+ ];
442
+ var SELECTABLE_SANDBOX = /* @__PURE__ */ new Set([
443
+ "credential",
444
+ "base-url",
445
+ "separate-account"
446
+ ]);
447
+ function sandboxIsSelectable(kind) {
448
+ return SELECTABLE_SANDBOX.has(kind);
449
+ }
450
+ function sandboxRefusal(service, kind) {
451
+ if (kind === "restricted-reach") {
452
+ return service + ` has no separate test estate. What it has instead is an ACCOUNT-LEVEL reach restriction - an unreviewed app that posts only to its own developers, or only privately. Same credentials, same endpoints, same estate: only the audience changes, and nothing in a request selects it. That makes it the sandbox shape most likely to be mistaken for a real one, because a restricted call looks exactly like a successful one - ok, a real id, and no audience. Use "fake" to develop without touching the provider, or "live" and confirm reach on the provider's own surface, which is the only place it is visible.`;
453
+ }
454
+ if (kind === "unverified") {
455
+ return "Nobody has verified what test estate " + service + ' offers, so "sandbox" cannot be honoured - the resolver would be guessing, and the cost of guessing wrong here is a workflow pointed at a live estate while somebody believes it is a test one. Find out and declare it, or use "fake".';
456
+ }
457
+ return service + ' has no sandbox estate, so "sandbox" cannot be honoured. Use "fake" to develop without credentials, or "live" to talk to the provider.';
458
+ }
406
459
  function resolveConnectorMode(inputs) {
407
460
  const { requested, connectionMode, sandbox, hasSandboxCredentials, environment } = inputs;
408
461
  if (requested && requested !== "auto") {
409
- if (requested === "sandbox" && sandbox === "none") {
410
- throw new ConnectorModeError(
411
- `This connector's provider has no sandbox estate, so "sandbox" cannot be honoured. Use "fake" to develop without credentials, or "live" to talk to the provider.`
412
- );
462
+ if (requested === "sandbox" && !sandboxIsSelectable(sandbox)) {
463
+ throw new ConnectorModeError(sandboxRefusal("This connector's provider", sandbox));
413
464
  }
414
465
  return requested;
415
466
  }
416
467
  if (connectionMode) return connectionMode;
417
468
  if (environment.production) return "live";
418
- return sandbox !== "none" && hasSandboxCredentials ? "sandbox" : "fake";
469
+ return sandboxIsSelectable(sandbox) && hasSandboxCredentials ? "sandbox" : "fake";
419
470
  }
420
471
  var ConnectorModeError = class extends Error {
421
472
  constructor(message) {
@@ -446,10 +497,8 @@ function connectionsFor(service) {
446
497
  function resolveConnection(options) {
447
498
  const { service, operation, sandbox } = options;
448
499
  const requestedMode = options.requested ?? "auto";
449
- if (requestedMode === "sandbox" && sandbox === "none") {
450
- throw new ConnectorModeError(
451
- `${service} has no sandbox estate, so "sandbox" cannot be honoured. Use "fake" to develop without credentials, or "live" to talk to the provider.`
452
- );
500
+ if (requestedMode === "sandbox" && !sandboxIsSelectable(sandbox)) {
501
+ throw new ConnectorModeError(sandboxRefusal(service, sandbox));
453
502
  }
454
503
  const id = options.connectionId?.trim() || service;
455
504
  if (options.credentials) {
@@ -976,6 +1025,41 @@ function reported(values) {
976
1025
  }
977
1026
  return out;
978
1027
  }
1028
+ function providerProblems(provider) {
1029
+ const problems = [];
1030
+ if (provider.implemented && provider.sandbox === "unverified") {
1031
+ problems.push(
1032
+ `${provider.id} says it is implemented while its sandbox shape is still "unverified". Something a person can run today must not be ambiguous about which estate it reaches \u2014 verify it, or set implemented: false.`
1033
+ );
1034
+ }
1035
+ if (provider.implemented && provider.fields.length === 0) {
1036
+ problems.push(`${provider.id} says it is implemented but declares no credential fields.`);
1037
+ }
1038
+ if (provider.implemented && provider.setup.length === 0) {
1039
+ problems.push(
1040
+ `${provider.id} says it is implemented and names no setup steps. The code is never the expensive part; the setup is, and a provider whose setup is undocumented is one nobody can stand up.`
1041
+ );
1042
+ }
1043
+ for (const field of provider.fields) {
1044
+ if (field.help.trim().length < 10) {
1045
+ problems.push(`${provider.id}.${field.key} has no help text worth reading.`);
1046
+ }
1047
+ }
1048
+ if (provider.verify && !provider.proves) {
1049
+ problems.push(
1050
+ `${provider.id} ships a verify but does not say what it PROVES. A green tick that means more than it should is worse than no tick \u2014 name the step it does not cover.`
1051
+ );
1052
+ }
1053
+ if (provider.sandbox === "restricted-reach" && !provider.summary.toLowerCase().includes("reach")) {
1054
+ problems.push(
1055
+ `${provider.id} is declared restricted-reach and its summary never mentions reach. That shape looks exactly like a successful post nobody can see, so the surface has to say so before anyone runs it.`
1056
+ );
1057
+ }
1058
+ return problems;
1059
+ }
1060
+ function providerSandboxIsSelectable(provider) {
1061
+ return sandboxIsSelectable(provider.sandbox);
1062
+ }
979
1063
 
980
1064
  // src/probe.ts
981
1065
  async function probe(spec) {
@@ -1177,6 +1261,14 @@ async function payloadHash(payload) {
1177
1261
  function isEmptyPayload(payload) {
1178
1262
  return payload.segments.every((segment) => segment.text.trim() === "");
1179
1263
  }
1264
+ function withResolvedLimit(rules, limit) {
1265
+ return limit === void 0 ? rules : { ...rules, limit };
1266
+ }
1267
+
1268
+ // src/seam.ts
1269
+ var CREDENTIAL_SCOPES = ["provider", "account"];
1270
+ var PROBLEM_SEVERITIES = ["block", "warn"];
1271
+ var CANONICAL_METRICS = ["like", "share", "reply", "quote", "view"];
1180
1272
 
1181
1273
  // src/webhook.ts
1182
1274
  async function verifyHmac(options) {
@@ -1279,8 +1371,10 @@ function hasDelivery(value) {
1279
1371
  // Annotate the CommonJS export names for ESM import in node:
1280
1372
  0 && (module.exports = {
1281
1373
  AMBIGUOUS_REFUSAL,
1374
+ CANONICAL_METRICS,
1282
1375
  CONNECTOR_API_VERSION,
1283
1376
  CONNECTOR_MODES,
1377
+ CREDENTIAL_SCOPES,
1284
1378
  ConnectorAmbiguous,
1285
1379
  ConnectorApiMismatch,
1286
1380
  ConnectorAuthError,
@@ -1297,8 +1391,10 @@ function hasDelivery(value) {
1297
1391
  FAKE_EPOCH,
1298
1392
  MAX_IDEMPOTENCY_KEY_LENGTH,
1299
1393
  NO_IDEMPOTENCY_KEY_WARNING,
1394
+ PROBLEM_SEVERITIES,
1300
1395
  RENDERER_VERSION,
1301
1396
  REVIEW_STALE_DAYS,
1397
+ SANDBOX_KINDS,
1302
1398
  SUPPORTED_CONNECTOR_API,
1303
1399
  assertConnectorApi,
1304
1400
  byteRangeOf,
@@ -1330,6 +1426,8 @@ function hasDelivery(value) {
1330
1426
  payloadHash,
1331
1427
  postChain,
1332
1428
  probe,
1429
+ providerProblems,
1430
+ providerSandboxIsSelectable,
1333
1431
  registerConnectionHost,
1334
1432
  registerTransport,
1335
1433
  render,
@@ -1341,6 +1439,8 @@ function hasDelivery(value) {
1341
1439
  runIdentityFrom,
1342
1440
  runKeyFrom,
1343
1441
  runProbes,
1442
+ sandboxIsSelectable,
1443
+ sandboxRefusal,
1344
1444
  satisfiesMinimum,
1345
1445
  seedFrom,
1346
1446
  shapeOf,
@@ -1351,6 +1451,7 @@ function hasDelivery(value) {
1351
1451
  unchecked,
1352
1452
  utf8Length,
1353
1453
  verifyDelivery,
1354
- verifyHmac
1454
+ verifyHmac,
1455
+ withResolvedLimit
1355
1456
  });
1356
1457
  //# sourceMappingURL=index.cjs.map