@particle-academy/fancy-connector-core 0.1.0 → 0.2.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
@@ -8,6 +8,81 @@ version number is not a promise it can keep yet; the entries below are.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.2.0] — unreleased
12
+
13
+ The reference consumer migrated five adapters onto `0.1.0`, ran its acceptance
14
+ suite, and filed four findings. All four were cases where the type or the
15
+ classifier was *safe* and still *wrong* — the hardest kind to notice from inside,
16
+ because nothing fails.
17
+
18
+ ### Fixed
19
+
20
+ - **`classifyError` ignored the classification `httpFailure` attached.** It
21
+ re-derived from scratch, found no error code, and answered `ambiguous`
22
+ ("nobody can tell") for a `400` the provider had been explicit about.
23
+ **This was never a retry bug** — `deliver()` reads `error.classified` first and
24
+ only falls back — but a host that calls the exported classifier directly, which
25
+ is a reasonable thing to do, was told nobody could tell. The **PHP twin already
26
+ did this**, so the two runtimes disagreed. **What a consumer must do: nothing,
27
+ unless you asserted the old answer.** If you pinned `classifyError` on an
28
+ `httpFailure` to `ambiguous`, that assertion now reads `rejected` or
29
+ `refused-explicitly` — which is the answer you wanted.
30
+
31
+ ### Changed
32
+
33
+ - **BREAKING (types only): `ChainRef` is `object`, was
34
+ `Record<string, string | number>`.** An ordinary interface does not satisfy an
35
+ index signature unless it declares one, so `interface PostRef { uri; cid }` did
36
+ **not** satisfy the old constraint — and the only other fix was to add an index
37
+ signature to a type used across a codebase in order to please a generic used in
38
+ one place. The reference consumer worked around it with `postChain<T & ChainRef>`
39
+ and two `as unknown as` casts, and could not use `ChainOutcome<T>` in its own
40
+ signatures at all. **What a consumer must do: delete the casts.** A constraint
41
+ everyone casts past is not enforcing anything.
42
+
43
+ ### Added
44
+
45
+ - **`SandboxKind` gained `restricted-reach`** — the fifth shape, and the
46
+ dangerous one. A Meta app in Development Mode, an unaudited TikTok app that can
47
+ only post `SELF_ONLY`: same credentials, same endpoints, same estate, and only
48
+ the **audience** restricted. That is none of the previous four, and `none` —
49
+ which is what it was being forced into — is actively wrong. It is the shape
50
+ most likely to be mistaken for a sandbox, because it looks exactly like a
51
+ successful post that nobody can see: `ok: true`, a real id, no audience.
52
+ - **`SandboxKind` gained `unverified`** — *nobody has checked yet*. A real state,
53
+ and the type has to carry it: this is the field where being wrong sends someone
54
+ to a live estate believing it is a test one, so "researched but not verified"
55
+ must not be forced to pick one of the other five. A comment saying so is not a
56
+ type.
57
+ - **`sandboxIsSelectable()` / `sandboxRefusal()`** (`SandboxKind::isSelectable()`
58
+ / `->refusal()` in PHP). Auto-resolution no longer picks "sandbox" for anything
59
+ that cannot be selected, and each refusal explains **its own** reason —
60
+ collapsing the three into "no sandbox available" would hide the one that
61
+ matters.
62
+ - **`providerProblems()`** — the sibling of `capabilityProblems()`, one level up.
63
+ Reports `implemented: true` with `sandbox: "unverified"`, a `verify` that does
64
+ not say what it **proves**, a `restricted-reach` provider whose summary never
65
+ mentions reach, and empty fields or setup on something claimed to be
66
+ implemented.
67
+ - **`ProviderAdapter.proves`** — what a verify does NOT cover, declared on the
68
+ adapter as well as on each result, so a surface can say it *before* anyone runs
69
+ the check.
70
+ - **`withResolvedLimit(rules, limit)`** — the supported way to resolve a
71
+ per-connection limit. A Mastodon instance publishes its own `max_toot_chars`;
72
+ baking a limit into a connector renders to 500 on an instance allowing 5000, or
73
+ to 5000 on one allowing 500, and both fail silently. `undefined` keeps the
74
+ declared limit, because *"I did not look"* is not *"there is no limit"* — and
75
+ `null` is a real answer meaning **uncounted**.
76
+ - **`CREDENTIAL_SCOPES`, `SANDBOX_KINDS`, `PROBLEM_SEVERITIES`,
77
+ `CANONICAL_METRICS`** — every string union that crosses a JSON boundary now
78
+ ships as **data** as well as a type. The compiler cannot follow a type across
79
+ JSON: when `scope` was renamed from `app`/`brand`, a consumer re-declaring the
80
+ field shape on its client kept compiling and its `scope === "brand"` silently
81
+ became never-true, so every credential field would have rendered as shared.
82
+ (PHP had this for free — an enum is runtime data, and `tryFrom` fails loudly on
83
+ a value nobody serves any more.)
84
+
85
+
11
86
  ## [0.1.0] — unreleased
12
87
 
13
88
  First cut. Extracted and generalised from the `_connector` shared runtime in the
@@ -99,5 +174,6 @@ clock; this package is the runtime it is written on.
99
174
  satisfies it with no dependency in either direction and a host that has never
100
175
  heard of a workflow engine can implement it.
101
176
 
102
- [Unreleased]: https://github.com/Particle-Academy/fancy-connector-core/compare/v0.1.0...HEAD
177
+ [Unreleased]: https://github.com/Particle-Academy/fancy-connector-core/compare/v0.2.0...HEAD
178
+ [0.2.0]: https://github.com/Particle-Academy/fancy-connector-core/compare/v0.1.0...v0.2.0
103
179
  [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
 
@@ -166,6 +175,8 @@ var AMBIGUOUS_CODES = /* @__PURE__ */ new Set([
166
175
  ]);
167
176
  var AMBIGUOUS_NAMES = /* @__PURE__ */ new Set(["AbortError", "TimeoutError"]);
168
177
  function classifyError(error) {
178
+ const attached = error?.classified;
179
+ if (attached && typeof attached === "object" && typeof attached.kind === "string") return attached;
169
180
  const detail = error instanceof Error ? error.message : String(error);
170
181
  const code = error?.code ?? error?.cause?.code ?? "";
171
182
  const name = error instanceof Error ? error.name : "";
@@ -403,19 +414,42 @@ function fakeRequest(service, operation, config, input) {
403
414
 
404
415
  // src/mode.ts
405
416
  var CONNECTOR_MODES = ["fake", "sandbox", "live"];
417
+ var SANDBOX_KINDS = [
418
+ "credential",
419
+ "base-url",
420
+ "separate-account",
421
+ "restricted-reach",
422
+ "none",
423
+ "unverified"
424
+ ];
425
+ var SELECTABLE_SANDBOX = /* @__PURE__ */ new Set([
426
+ "credential",
427
+ "base-url",
428
+ "separate-account"
429
+ ]);
430
+ function sandboxIsSelectable(kind) {
431
+ return SELECTABLE_SANDBOX.has(kind);
432
+ }
433
+ function sandboxRefusal(service, kind) {
434
+ if (kind === "restricted-reach") {
435
+ 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.`;
436
+ }
437
+ if (kind === "unverified") {
438
+ 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".';
439
+ }
440
+ return service + ' has no sandbox estate, so "sandbox" cannot be honoured. Use "fake" to develop without credentials, or "live" to talk to the provider.';
441
+ }
406
442
  function resolveConnectorMode(inputs) {
407
443
  const { requested, connectionMode, sandbox, hasSandboxCredentials, environment } = inputs;
408
444
  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
- );
445
+ if (requested === "sandbox" && !sandboxIsSelectable(sandbox)) {
446
+ throw new ConnectorModeError(sandboxRefusal("This connector's provider", sandbox));
413
447
  }
414
448
  return requested;
415
449
  }
416
450
  if (connectionMode) return connectionMode;
417
451
  if (environment.production) return "live";
418
- return sandbox !== "none" && hasSandboxCredentials ? "sandbox" : "fake";
452
+ return sandboxIsSelectable(sandbox) && hasSandboxCredentials ? "sandbox" : "fake";
419
453
  }
420
454
  var ConnectorModeError = class extends Error {
421
455
  constructor(message) {
@@ -446,10 +480,8 @@ function connectionsFor(service) {
446
480
  function resolveConnection(options) {
447
481
  const { service, operation, sandbox } = options;
448
482
  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
- );
483
+ if (requestedMode === "sandbox" && !sandboxIsSelectable(sandbox)) {
484
+ throw new ConnectorModeError(sandboxRefusal(service, sandbox));
453
485
  }
454
486
  const id = options.connectionId?.trim() || service;
455
487
  if (options.credentials) {
@@ -976,6 +1008,41 @@ function reported(values) {
976
1008
  }
977
1009
  return out;
978
1010
  }
1011
+ function providerProblems(provider) {
1012
+ const problems = [];
1013
+ if (provider.implemented && provider.sandbox === "unverified") {
1014
+ problems.push(
1015
+ `${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.`
1016
+ );
1017
+ }
1018
+ if (provider.implemented && provider.fields.length === 0) {
1019
+ problems.push(`${provider.id} says it is implemented but declares no credential fields.`);
1020
+ }
1021
+ if (provider.implemented && provider.setup.length === 0) {
1022
+ problems.push(
1023
+ `${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.`
1024
+ );
1025
+ }
1026
+ for (const field of provider.fields) {
1027
+ if (field.help.trim().length < 10) {
1028
+ problems.push(`${provider.id}.${field.key} has no help text worth reading.`);
1029
+ }
1030
+ }
1031
+ if (provider.verify && !provider.proves) {
1032
+ problems.push(
1033
+ `${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.`
1034
+ );
1035
+ }
1036
+ if (provider.sandbox === "restricted-reach" && !provider.summary.toLowerCase().includes("reach")) {
1037
+ problems.push(
1038
+ `${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.`
1039
+ );
1040
+ }
1041
+ return problems;
1042
+ }
1043
+ function providerSandboxIsSelectable(provider) {
1044
+ return sandboxIsSelectable(provider.sandbox);
1045
+ }
979
1046
 
980
1047
  // src/probe.ts
981
1048
  async function probe(spec) {
@@ -1177,6 +1244,14 @@ async function payloadHash(payload) {
1177
1244
  function isEmptyPayload(payload) {
1178
1245
  return payload.segments.every((segment) => segment.text.trim() === "");
1179
1246
  }
1247
+ function withResolvedLimit(rules, limit) {
1248
+ return limit === void 0 ? rules : { ...rules, limit };
1249
+ }
1250
+
1251
+ // src/seam.ts
1252
+ var CREDENTIAL_SCOPES = ["provider", "account"];
1253
+ var PROBLEM_SEVERITIES = ["block", "warn"];
1254
+ var CANONICAL_METRICS = ["like", "share", "reply", "quote", "view"];
1180
1255
 
1181
1256
  // src/webhook.ts
1182
1257
  async function verifyHmac(options) {
@@ -1279,8 +1354,10 @@ function hasDelivery(value) {
1279
1354
  // Annotate the CommonJS export names for ESM import in node:
1280
1355
  0 && (module.exports = {
1281
1356
  AMBIGUOUS_REFUSAL,
1357
+ CANONICAL_METRICS,
1282
1358
  CONNECTOR_API_VERSION,
1283
1359
  CONNECTOR_MODES,
1360
+ CREDENTIAL_SCOPES,
1284
1361
  ConnectorAmbiguous,
1285
1362
  ConnectorApiMismatch,
1286
1363
  ConnectorAuthError,
@@ -1297,8 +1374,10 @@ function hasDelivery(value) {
1297
1374
  FAKE_EPOCH,
1298
1375
  MAX_IDEMPOTENCY_KEY_LENGTH,
1299
1376
  NO_IDEMPOTENCY_KEY_WARNING,
1377
+ PROBLEM_SEVERITIES,
1300
1378
  RENDERER_VERSION,
1301
1379
  REVIEW_STALE_DAYS,
1380
+ SANDBOX_KINDS,
1302
1381
  SUPPORTED_CONNECTOR_API,
1303
1382
  assertConnectorApi,
1304
1383
  byteRangeOf,
@@ -1330,6 +1409,8 @@ function hasDelivery(value) {
1330
1409
  payloadHash,
1331
1410
  postChain,
1332
1411
  probe,
1412
+ providerProblems,
1413
+ providerSandboxIsSelectable,
1333
1414
  registerConnectionHost,
1334
1415
  registerTransport,
1335
1416
  render,
@@ -1341,6 +1422,8 @@ function hasDelivery(value) {
1341
1422
  runIdentityFrom,
1342
1423
  runKeyFrom,
1343
1424
  runProbes,
1425
+ sandboxIsSelectable,
1426
+ sandboxRefusal,
1344
1427
  satisfiesMinimum,
1345
1428
  seedFrom,
1346
1429
  shapeOf,
@@ -1351,6 +1434,7 @@ function hasDelivery(value) {
1351
1434
  unchecked,
1352
1435
  utf8Length,
1353
1436
  verifyDelivery,
1354
- verifyHmac
1437
+ verifyHmac,
1438
+ withResolvedLimit
1355
1439
  });
1356
1440
  //# sourceMappingURL=index.cjs.map