@particle-academy/fancy-connector-core 0.13.0 → 0.15.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,6 +6,69 @@ 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.15.0] - 2026-11-08
10
+
11
+ ### Added
12
+
13
+ - **`SetupStep` says who does each step and what it yields.** New optional
14
+ fields, in both runtimes:
15
+ - `actor`: `person` (the default when absent) or `agent`; `SETUP_ACTORS` /
16
+ `Setup::ACTORS` ship it as data.
17
+ - `prompt`: what an agent is asked to do. Required for an agent step.
18
+ - `copy`: `[{label, value}]` values the person pastes somewhere, such as a
19
+ callback URL.
20
+ - `fields`: the credential keys the step yields.
21
+ - `done`: how the person knows the step is done.
22
+
23
+ PHP's `SetupStep` gains `fromArray()` / `toArray()`. Absent stays absent:
24
+ a field nobody wrote is omitted, never null.
25
+ - **`renderSetup` / `Setup::render`: a connector's steps rendered for one
26
+ app.** `{{appUrl}}`, `{{appName}}` and `{{connector}}` are filled in every
27
+ text a person or an agent reads.
28
+ - Any other `{{...}}` is refused as `unknown-token`, so a person never
29
+ pastes a literal brace into a provider console.
30
+ - A token with no value, or an empty one, is refused as `missing-context`,
31
+ so a callback never becomes `/hooks/twilio`.
32
+ - `appUrl` loses one trailing slash first, and a value is never expanded
33
+ again.
34
+ - Held to `fixtures/setup-render/cases.json` in both runtimes.
35
+
36
+ ## [0.14.0] - 2026-11-07
37
+
38
+ ### Added
39
+
40
+ - **`readWithCursor` / `CursorRead::read`: a read that resumes from a
41
+ HOST-HELD cursor, and pages.** It's for Gmail's `history.list` after a push,
42
+ and Drive's `changes.list` after a channel notification, where the delivery
43
+ says only that something moved. The package holds no state: the host stores
44
+ the cursor, passes it in, and persists `cursor.next` when the run succeeds.
45
+ - `next` is read ONLY from the LAST page: Gmail's `historyId` is the
46
+ mailbox's current one on every page.
47
+ - More than `page.maxPages` is refused as `cursor-overflow`, never a partial
48
+ read.
49
+ - The provider's `expiredStatus` (Gmail's 404) is refused as
50
+ `cursor-expired`. Every other failure is rethrown unchanged.
51
+ - No stored cursor is refused as `cursor-missing`, and the provider is not
52
+ called.
53
+ - A collected list that's absent is `[]`; one that isn't a list is
54
+ `bad-page`.
55
+ - Cursors are strings.
56
+ - A refusal carries the caller's `resyncQuery`.
57
+
58
+ The reasons ship as data (`CURSOR_READ_REASONS`, `CursorRead::REASONS`).
59
+ Both runtimes read `fixtures/cursor-read/cases.json`: 14 authored cases
60
+ against a scripted provider, every call compared.
61
+
62
+ ### Changed
63
+
64
+ - The README and the npm description now say plainly that PHP apps install
65
+ the package with Composer.
66
+ - `fixtures/jwt` gains rows 0015 to 0024: signature length, values at and
67
+ above the modulus, and valid RSA signatures over malformed PKCS#1 v1.5
68
+ blocks.
69
+
70
+ `CONNECTOR_API_VERSION` is unchanged: everything here is optional.
71
+
9
72
  ## [0.13.0] - 2026-11-01
10
73
 
11
74
  ### Added
package/README.md CHANGED
@@ -9,10 +9,15 @@ versioned rendering, chain posting, declared metric shapes, credential-free
9
9
  probes, and API-drift reporting.
10
10
 
11
11
  Matched **TypeScript + PHP**, one repository, **zero runtime dependencies in
12
- either ecosystem**.
12
+ either ecosystem**. TypeScript and Node apps install it from npm; **PHP apps
13
+ (Laravel included) install it with Composer**. Both packages are released
14
+ together at the same version.
13
15
 
14
16
  ```bash
17
+ # TypeScript / Node
15
18
  npm install @particle-academy/fancy-connector-core
19
+
20
+ # PHP (Composer)
16
21
  composer require particle-academy/fancy-connector-core
17
22
  ```
18
23
 
package/dist/index.cjs CHANGED
@@ -25,6 +25,7 @@ __export(src_exports, {
25
25
  CONNECTOR_API_VERSION: () => CONNECTOR_API_VERSION,
26
26
  CONNECTOR_MODES: () => CONNECTOR_MODES,
27
27
  CREDENTIAL_SCOPES: () => CREDENTIAL_SCOPES,
28
+ CURSOR_READ_REASONS: () => CURSOR_READ_REASONS,
28
29
  ConnectorAmbiguous: () => ConnectorAmbiguous,
29
30
  ConnectorApiMismatch: () => ConnectorApiMismatch,
30
31
  ConnectorAuthError: () => ConnectorAuthError,
@@ -36,6 +37,7 @@ __export(src_exports, {
36
37
  ConnectorRequestError: () => ConnectorRequestError,
37
38
  ConnectorTransient: () => ConnectorTransient,
38
39
  ConnectorUnreachable: () => ConnectorUnreachable,
40
+ CursorReadError: () => CursorReadError,
39
41
  DEFAULT_IDEMPOTENCY_WINDOW_SECONDS: () => DEFAULT_IDEMPOTENCY_WINDOW_SECONDS,
40
42
  DEFAULT_RETRY: () => DEFAULT_RETRY,
41
43
  DELIVERY_DECODE_KINDS: () => DELIVERY_DECODE_KINDS,
@@ -53,8 +55,12 @@ __export(src_exports, {
53
55
  RENDERER_VERSION: () => RENDERER_VERSION,
54
56
  REVIEW_STALE_DAYS: () => REVIEW_STALE_DAYS,
55
57
  SANDBOX_KINDS: () => SANDBOX_KINDS,
58
+ SETUP_ACTORS: () => SETUP_ACTORS,
59
+ SETUP_RENDER_REASONS: () => SETUP_RENDER_REASONS,
60
+ SETUP_TOKENS: () => SETUP_TOKENS,
56
61
  SIGNED_URI_RULES: () => SIGNED_URI_RULES,
57
62
  SUPPORTED_CONNECTOR_API: () => SUPPORTED_CONNECTOR_API,
63
+ SetupRenderError: () => SetupRenderError,
58
64
  assertConnectorApi: () => assertConnectorApi,
59
65
  byteRangeOf: () => byteRangeOf,
60
66
  callConnector: () => callConnector,
@@ -104,9 +110,11 @@ __export(src_exports, {
104
110
  probe: () => probe,
105
111
  providerProblems: () => providerProblems,
106
112
  providerSandboxIsSelectable: () => providerSandboxIsSelectable,
113
+ readWithCursor: () => readWithCursor,
107
114
  registerConnectionHost: () => registerConnectionHost,
108
115
  registerTransport: () => registerTransport,
109
116
  render: () => render,
117
+ renderSetup: () => renderSetup,
110
118
  reported: () => reported,
111
119
  resetRateState: () => resetRateState,
112
120
  resolveConnection: () => resolveConnection,
@@ -302,10 +310,10 @@ var lastCallAt = /* @__PURE__ */ new Map();
302
310
  async function respectRate(channel, minIntervalMs, now = () => Date.now(), sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
303
311
  if (minIntervalMs <= 0) return { waitedMs: 0 };
304
312
  const previous = lastCallAt.get(channel);
305
- const at = now();
306
- const waitedMs = previous === void 0 ? 0 : Math.max(0, previous + minIntervalMs - at);
313
+ const at2 = now();
314
+ const waitedMs = previous === void 0 ? 0 : Math.max(0, previous + minIntervalMs - at2);
307
315
  if (waitedMs > 0) await sleep(waitedMs);
308
- lastCallAt.set(channel, at + waitedMs);
316
+ lastCallAt.set(channel, at2 + waitedMs);
309
317
  return { waitedMs };
310
318
  }
311
319
  function resetRateState() {
@@ -803,6 +811,71 @@ function readString(config, key) {
803
811
  return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
804
812
  }
805
813
 
814
+ // src/cursor-read.ts
815
+ var CURSOR_READ_REASONS = ["cursor-missing", "cursor-expired", "cursor-overflow", "bad-page"];
816
+ var CursorReadError = class extends Error {
817
+ constructor(reason, message, resyncQuery) {
818
+ super(message);
819
+ this.reason = reason;
820
+ this.resyncQuery = resyncQuery;
821
+ this.name = "CursorReadError";
822
+ }
823
+ reason;
824
+ resyncQuery;
825
+ };
826
+ async function readWithCursor(spec, cursor, call, options = {}) {
827
+ const refuse = (reason, message) => new CursorReadError(reason, message, options.resyncQuery);
828
+ const used = asCursor(cursor);
829
+ if (used === null) throw refuse("cursor-missing", `no stored ${spec.param}: the host seeds one before the first read`);
830
+ const collected = [];
831
+ let last;
832
+ let token = null;
833
+ let pages = 0;
834
+ for (; ; ) {
835
+ if (spec.page && pages === spec.page.maxPages) {
836
+ throw refuse("cursor-overflow", `more than ${spec.page.maxPages} pages since ${spec.param} ${used}`);
837
+ }
838
+ const params = { [spec.param]: used };
839
+ if (spec.page && token !== null) params[spec.page.param] = token;
840
+ try {
841
+ last = await call(params);
842
+ } catch (error) {
843
+ const status = error?.status;
844
+ if (spec.expiredStatus !== void 0 && status === spec.expiredStatus) {
845
+ throw refuse("cursor-expired", `${spec.param} ${used} is out of the provider's range (${status})`);
846
+ }
847
+ throw error;
848
+ }
849
+ pages++;
850
+ if (!spec.page) break;
851
+ const items = at(last, spec.page.collect);
852
+ if (items !== void 0 && !Array.isArray(items)) {
853
+ throw refuse("bad-page", `${spec.page.collect} is not a list`);
854
+ }
855
+ if (items) collected.push(...items);
856
+ token = asCursor(at(last, spec.page.from));
857
+ if (token === null) break;
858
+ }
859
+ const data = spec.page ? withList(last, spec.page.collect, collected) : last;
860
+ return { data, cursor: { used, next: asCursor(at(last, spec.advanceFrom)) } };
861
+ }
862
+ function asCursor(value) {
863
+ if (typeof value === "string") return value === "" ? null : value;
864
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
865
+ return null;
866
+ }
867
+ function at(value, path) {
868
+ return path.split(".").reduce(
869
+ (v, key) => v !== null && typeof v === "object" && !Array.isArray(v) ? v[key] : void 0,
870
+ value
871
+ );
872
+ }
873
+ function withList(data, path, list) {
874
+ const [head, ...rest] = path.split(".");
875
+ const base = data !== null && typeof data === "object" && !Array.isArray(data) ? data : {};
876
+ return { ...base, [head]: rest.length ? withList(base[head], rest.join("."), list) : list };
877
+ }
878
+
806
879
  // src/drift.ts
807
880
  function resolveRef(document, node, depth = 0) {
808
881
  if (depth > 8 || node === null || typeof node !== "object") return node;
@@ -1457,32 +1530,32 @@ function base64urlBytes(segment) {
1457
1530
  var EXPIRES_AT_UNITS = ["rfc3339", "epoch-ms"];
1458
1531
  var INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/;
1459
1532
  var DEFAULT_CONTEXT = { service: "", operation: "subscription" };
1460
- function instantMs(value, field, at) {
1533
+ function instantMs(value, field, at2) {
1461
1534
  if (typeof value !== "string" || !INSTANT.test(value)) {
1462
1535
  throw new ConnectorConfigError(
1463
1536
  `${field} must be an RFC 3339 instant such as "2026-09-22T00:00:00Z", got ${JSON.stringify(value)}. A provider's own shape \u2014 Google's epoch milliseconds, say \u2014 is converted by the connector, never guessed at here.`,
1464
- at
1537
+ at2
1465
1538
  );
1466
1539
  }
1467
1540
  const ms = Date.parse(value.replace(/(\.\d{3})\d+/, "$1"));
1468
1541
  if (Number.isNaN(ms)) {
1469
- throw new ConnectorConfigError(`${field} is not a real instant: ${JSON.stringify(value)}`, at);
1542
+ throw new ConnectorConfigError(`${field} is not a real instant: ${JSON.stringify(value)}`, at2);
1470
1543
  }
1471
1544
  return ms;
1472
1545
  }
1473
- function leaseFromResponse(declaration, response, at = DEFAULT_CONTEXT) {
1546
+ function leaseFromResponse(declaration, response, at2 = DEFAULT_CONTEXT) {
1474
1547
  const { expiresAtFrom: path, expiresAtUnit: unit } = declaration;
1475
1548
  if (!EXPIRES_AT_UNITS.includes(unit)) {
1476
1549
  throw new ConnectorConfigError(
1477
1550
  `expiresAtUnit must be one of ${EXPIRES_AT_UNITS.join(" | ")}, got ${JSON.stringify(unit)}.`,
1478
- at
1551
+ at2
1479
1552
  );
1480
1553
  }
1481
1554
  const value = readPath(response, path);
1482
1555
  if (value === void 0 || value === null) {
1483
1556
  throw new ConnectorConfigError(
1484
1557
  `${path} is not present in the response, so no lease can be built \u2014 a subscription with no known expiry is one nobody renews.`,
1485
- at
1558
+ at2
1486
1559
  );
1487
1560
  }
1488
1561
  let expiresAt;
@@ -1491,7 +1564,7 @@ function leaseFromResponse(declaration, response, at = DEFAULT_CONTEXT) {
1491
1564
  if (!Number.isSafeInteger(ms)) {
1492
1565
  throw new ConnectorConfigError(
1493
1566
  `${path} must be epoch milliseconds as digits under "epoch-ms", got ${JSON.stringify(value)}.`,
1494
- at
1567
+ at2
1495
1568
  );
1496
1569
  }
1497
1570
  expiresAt = new Date(ms).toISOString();
@@ -1499,14 +1572,14 @@ function leaseFromResponse(declaration, response, at = DEFAULT_CONTEXT) {
1499
1572
  if (typeof value !== "string" || !INSTANT.test(value)) {
1500
1573
  throw new ConnectorConfigError(
1501
1574
  `${path} must be an RFC 3339 instant under "rfc3339", got ${JSON.stringify(value)}.`,
1502
- at
1575
+ at2
1503
1576
  );
1504
1577
  }
1505
- expiresAt = new Date(instantMs(value, path, at)).toISOString();
1578
+ expiresAt = new Date(instantMs(value, path, at2)).toISOString();
1506
1579
  }
1507
1580
  return subscriptionLease(
1508
1581
  { expiresAt, renewBeforeSeconds: declaration.renewBeforeSeconds, renewOperation: declaration.renewOperation },
1509
- at
1582
+ at2
1510
1583
  );
1511
1584
  }
1512
1585
  function readPath(value, path) {
@@ -1517,18 +1590,18 @@ function readPath(value, path) {
1517
1590
  }
1518
1591
  return current;
1519
1592
  }
1520
- function subscriptionLease(input, at = DEFAULT_CONTEXT) {
1521
- instantMs(input.expiresAt, "expiresAt", at);
1593
+ function subscriptionLease(input, at2 = DEFAULT_CONTEXT) {
1594
+ instantMs(input.expiresAt, "expiresAt", at2);
1522
1595
  if (!Number.isInteger(input.renewBeforeSeconds) || input.renewBeforeSeconds <= 0) {
1523
1596
  throw new ConnectorConfigError(
1524
1597
  `renewBeforeSeconds must be a positive whole number of seconds, got ${JSON.stringify(input.renewBeforeSeconds)}. Zero would make the lease due only at the instant it expires, which is a lease nobody renews.`,
1525
- at
1598
+ at2
1526
1599
  );
1527
1600
  }
1528
1601
  if (typeof input.renewOperation !== "string" || input.renewOperation.trim() === "") {
1529
1602
  throw new ConnectorConfigError(
1530
1603
  "renewOperation must name the operation the host calls when the lease is due; a due lease with nothing to call is one nobody renews.",
1531
- at
1604
+ at2
1532
1605
  );
1533
1606
  }
1534
1607
  return { expiresAt: input.expiresAt, renewBeforeSeconds: input.renewBeforeSeconds, renewOperation: input.renewOperation };
@@ -1537,10 +1610,10 @@ function leaseRenewAt(lease) {
1537
1610
  return new Date(instantMs(lease.expiresAt, "expiresAt", DEFAULT_CONTEXT) - lease.renewBeforeSeconds * 1e3).toISOString();
1538
1611
  }
1539
1612
  function leaseState(lease, now) {
1540
- const at = now instanceof Date ? now.getTime() : instantMs(now, "now", DEFAULT_CONTEXT);
1613
+ const at2 = now instanceof Date ? now.getTime() : instantMs(now, "now", DEFAULT_CONTEXT);
1541
1614
  const expires = instantMs(lease.expiresAt, "expiresAt", DEFAULT_CONTEXT);
1542
- if (at >= expires) return "expired";
1543
- if (at >= expires - lease.renewBeforeSeconds * 1e3) return "due";
1615
+ if (at2 >= expires) return "expired";
1616
+ if (at2 >= expires - lease.renewBeforeSeconds * 1e3) return "due";
1544
1617
  return "active";
1545
1618
  }
1546
1619
  function leaseAction(lease, now) {
@@ -1860,8 +1933,8 @@ function splitMultipart(body, boundary) {
1860
1933
  if (partStart >= 0 && partStart <= body.length) children.push(body.subarray(partStart));
1861
1934
  return children;
1862
1935
  }
1863
- function startsWith(haystack, at, needle) {
1864
- for (let k = 0; k < needle.length; k++) if (haystack[at + k] !== needle[k]) return false;
1936
+ function startsWith(haystack, at2, needle) {
1937
+ for (let k = 0; k < needle.length; k++) if (haystack[at2 + k] !== needle[k]) return false;
1865
1938
  return true;
1866
1939
  }
1867
1940
  function parseMime(raw) {
@@ -2428,6 +2501,53 @@ function withResolvedLimit(rules, limit) {
2428
2501
  var CREDENTIAL_SCOPES = ["provider", "account"];
2429
2502
  var PROBLEM_SEVERITIES = ["block", "warn"];
2430
2503
  var CANONICAL_METRICS = ["like", "share", "reply", "quote", "view"];
2504
+ var SETUP_ACTORS = ["person", "agent"];
2505
+
2506
+ // src/setup.ts
2507
+ var SETUP_TOKENS = ["appUrl", "appName", "connector"];
2508
+ var SETUP_RENDER_REASONS = ["unknown-token", "missing-context"];
2509
+ var SetupRenderError = class extends Error {
2510
+ constructor(reason, message) {
2511
+ super(message);
2512
+ this.reason = reason;
2513
+ this.name = "SetupRenderError";
2514
+ }
2515
+ reason;
2516
+ };
2517
+ var TOKEN = /\{\{([^{}]*)\}\}/g;
2518
+ function fill(text, context) {
2519
+ return text.replace(TOKEN, (whole, name) => {
2520
+ if (!SETUP_TOKENS.includes(name)) {
2521
+ throw new SetupRenderError(
2522
+ "unknown-token",
2523
+ `setup step text uses ${whole}, which is not one of ${SETUP_TOKENS.map((t) => `{{${t}}}`).join(", ")}`
2524
+ );
2525
+ }
2526
+ let value = context[name];
2527
+ if (value === void 0 || value === null || value === "") {
2528
+ throw new SetupRenderError("missing-context", `setup step text uses ${whole} and the host gave no value for ${name}`);
2529
+ }
2530
+ if (name === "appUrl" && value.endsWith("/")) value = value.slice(0, -1);
2531
+ return value;
2532
+ });
2533
+ }
2534
+ function renderSetup(steps, context) {
2535
+ return steps.map((step) => {
2536
+ const out = {
2537
+ title: fill(step.title, context),
2538
+ detail: fill(step.detail, context),
2539
+ actor: step.actor ?? "person",
2540
+ fields: [...step.fields ?? []]
2541
+ };
2542
+ if (step.url !== void 0) out.url = fill(step.url, context);
2543
+ if (step.prompt !== void 0) out.prompt = fill(step.prompt, context);
2544
+ if (step.done !== void 0) out.done = fill(step.done, context);
2545
+ if (step.copy !== void 0) {
2546
+ out.copy = step.copy.map((c) => ({ label: fill(c.label, context), value: fill(c.value, context) }));
2547
+ }
2548
+ return out;
2549
+ });
2550
+ }
2431
2551
 
2432
2552
  // src/trigger.ts
2433
2553
  function isOidcJwtSpec(spec) {
@@ -2503,6 +2623,7 @@ function hasDelivery(value) {
2503
2623
  CONNECTOR_API_VERSION,
2504
2624
  CONNECTOR_MODES,
2505
2625
  CREDENTIAL_SCOPES,
2626
+ CURSOR_READ_REASONS,
2506
2627
  ConnectorAmbiguous,
2507
2628
  ConnectorApiMismatch,
2508
2629
  ConnectorAuthError,
@@ -2514,6 +2635,7 @@ function hasDelivery(value) {
2514
2635
  ConnectorRequestError,
2515
2636
  ConnectorTransient,
2516
2637
  ConnectorUnreachable,
2638
+ CursorReadError,
2517
2639
  DEFAULT_IDEMPOTENCY_WINDOW_SECONDS,
2518
2640
  DEFAULT_RETRY,
2519
2641
  DELIVERY_DECODE_KINDS,
@@ -2531,8 +2653,12 @@ function hasDelivery(value) {
2531
2653
  RENDERER_VERSION,
2532
2654
  REVIEW_STALE_DAYS,
2533
2655
  SANDBOX_KINDS,
2656
+ SETUP_ACTORS,
2657
+ SETUP_RENDER_REASONS,
2658
+ SETUP_TOKENS,
2534
2659
  SIGNED_URI_RULES,
2535
2660
  SUPPORTED_CONNECTOR_API,
2661
+ SetupRenderError,
2536
2662
  assertConnectorApi,
2537
2663
  byteRangeOf,
2538
2664
  callConnector,
@@ -2582,9 +2708,11 @@ function hasDelivery(value) {
2582
2708
  probe,
2583
2709
  providerProblems,
2584
2710
  providerSandboxIsSelectable,
2711
+ readWithCursor,
2585
2712
  registerConnectionHost,
2586
2713
  registerTransport,
2587
2714
  render,
2715
+ renderSetup,
2588
2716
  reported,
2589
2717
  resetRateState,
2590
2718
  resolveConnection,