@a2a-exposed/control-core 0.8.5 → 0.8.6

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.
@@ -0,0 +1,4 @@
1
+ -- Sealed access token for a removal step that is waiting on a Cloudflare permission.
2
+ -- The column is cleared when that step passes or fails.
3
+ -- Append-only: do not edit this file.
4
+ ALTER TABLE cloudflare_grant ADD COLUMN removal_access TEXT;
package/dist/index.js CHANGED
@@ -826,6 +826,10 @@ async function openCloudflareRefresh(secret, userId, sealed) {
826
826
  return null;
827
827
  }
828
828
  }
829
+ function refreshTokenAfterResponse(responseToken, previous) {
830
+ if (typeof responseToken === "string" && responseToken.length > 0) return responseToken;
831
+ return previous && previous.length > 0 ? previous : null;
832
+ }
829
833
  function expiresAt(expiresIn, now) {
830
834
  const seconds = typeof expiresIn === "number" ? expiresIn : typeof expiresIn === "string" ? Number(expiresIn) : NaN;
831
835
  if (!Number.isFinite(seconds) || seconds <= 0 || seconds > 365 * 24 * 60 * 60) return null;
@@ -966,18 +970,19 @@ async function refreshCloudflareGrant(input) {
966
970
  const body = read.body && typeof read.body === "object" ? read.body : null;
967
971
  const accessToken = typeof body?.access_token === "string" ? body.access_token : "";
968
972
  if (!accessToken) return { ok: false, error: "failed" };
969
- const next = typeof body?.refresh_token === "string" ? body.refresh_token : "";
970
- const sealed = next ? await sealCloudflareRefresh(input.secret, input.userId, next) : null;
973
+ const next = refreshTokenAfterResponse(body?.refresh_token, null);
974
+ const sealed = next ? await sealCloudflareRefresh(input.secret, input.userId, next) : current?.refresh_enc ?? null;
971
975
  if (next && !sealed) {
972
976
  await clearRefresh(input.database, input.userId, now);
973
977
  return { ok: false, error: "unreadable" };
974
978
  }
975
- const scope = typeof body?.scope === "string" ? body.scope : current?.requested_scope ?? "";
979
+ const reportedScope = typeof body?.scope === "string" ? body.scope : null;
980
+ const scope = reportedScope ?? current?.granted_scope ?? current?.requested_scope ?? "";
976
981
  await input.database.prepare(
977
982
  `UPDATE cloudflare_grant SET refresh_enc = ?, granted_scope = COALESCE(?, granted_scope), access_expires_at = ?, updated_at = ? WHERE "userId" = ?`
978
983
  ).bind(
979
984
  sealed,
980
- typeof body?.scope === "string" ? body.scope : null,
985
+ reportedScope,
981
986
  expiresAt(body?.expires_in, now),
982
987
  new Date(now).toISOString(),
983
988
  input.userId
@@ -1597,6 +1602,8 @@ function cloudflareHostname(row) {
1597
1602
  }
1598
1603
 
1599
1604
  // src/provision/remove.ts
1605
+ var REMOVAL_PERMISSION_DELAYS_MS = [2e3, 4e3, 8e3, 16e3, 3e4, 3e4];
1606
+ var REMOVAL_PERMISSION_WINDOW_MS = 9e4;
1600
1607
  function uploaded(row) {
1601
1608
  return row.status === "ready" || row.wizard_step === "upload" || row.wizard_step === "verify" || Boolean(row.public_url);
1602
1609
  }
@@ -1613,12 +1620,29 @@ function removalItems(row) {
1613
1620
  }
1614
1621
  return items;
1615
1622
  }
1623
+ function finite(value) {
1624
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
1625
+ }
1616
1626
  function readNote(raw) {
1617
1627
  if (!raw) return null;
1618
1628
  try {
1619
1629
  const parsed = JSON.parse(raw);
1620
1630
  if (!parsed || parsed.removing !== true || !Array.isArray(parsed.passed)) return null;
1621
- return parsed;
1631
+ const state = parsed.state === "wait" || parsed.state === "fail" ? parsed.state : void 0;
1632
+ const permissionTries = Number.isInteger(parsed.permissionTries) && (parsed.permissionTries ?? 0) >= 1 ? parsed.permissionTries : void 0;
1633
+ const retryAt = finite(parsed.retryAt);
1634
+ const consentAt = finite(parsed.consentAt);
1635
+ const waitStartedAt = finite(parsed.waitStartedAt);
1636
+ const rotations = Number.isInteger(parsed.rotations) && (parsed.rotations ?? 0) >= 0 ? parsed.rotations : void 0;
1637
+ return {
1638
+ ...parsed,
1639
+ ...state ? { state } : {},
1640
+ ...permissionTries ? { permissionTries } : {},
1641
+ ...retryAt != null ? { retryAt } : {},
1642
+ ...consentAt != null ? { consentAt } : {},
1643
+ ...waitStartedAt != null ? { waitStartedAt } : {},
1644
+ ...rotations != null ? { rotations } : {}
1645
+ };
1622
1646
  } catch {
1623
1647
  return null;
1624
1648
  }
@@ -1629,6 +1653,9 @@ function removalSteps(row) {
1629
1653
  const items = removalItems(row);
1630
1654
  const steps = items.map((item) => {
1631
1655
  if (passed.has(item.id)) return { id: item.id, title: item.title, state: "pass", text: note?.texts?.[item.id] || `${item.detail} is removed.`, cloudflare: "" };
1656
+ if (note?.failed === item.id && note.state === "wait") {
1657
+ return { id: item.id, title: item.title, state: "wait", text: note.text || PERMISSION_WAIT, cloudflare: "" };
1658
+ }
1632
1659
  if (note?.failed === item.id) {
1633
1660
  return { id: item.id, title: item.title, state: "fail", text: note.text || "Cloudflare did not remove this.", cloudflare: note.cloudflare || "" };
1634
1661
  }
@@ -1644,87 +1671,344 @@ function removalSteps(row) {
1644
1671
  });
1645
1672
  return steps;
1646
1673
  }
1674
+ function removalRetryDue(row, now = Date.now()) {
1675
+ if (!row || row.status === "ready") return false;
1676
+ const note = readNote(row.step_error);
1677
+ if (!note || note.state !== "wait") return false;
1678
+ if (typeof note.retryAt !== "number") return true;
1679
+ return now >= note.retryAt;
1680
+ }
1647
1681
  async function save(db, row, note, now) {
1648
1682
  await db.prepare(
1649
1683
  `UPDATE cloudflare_inbox SET status = 'removing', step_error = ?, updated_at = ? WHERE id = ? AND "userId" = ?`
1650
1684
  ).bind(JSON.stringify(note), new Date(now).toISOString(), row.id, row.userId).run();
1651
1685
  }
1652
1686
  function failureText(failure) {
1653
- return { text: "Cloudflare did not remove this. Try again.", cloudflare: describeCfFailure(failure) };
1687
+ return { ok: false, text: "Cloudflare did not remove this. Try again.", cloudflare: describeCfFailure(failure), failure };
1688
+ }
1689
+ function missingColumn(error) {
1690
+ return error instanceof Error && /no such column/i.test(error.message);
1691
+ }
1692
+ async function readHeldAccess(db, userId) {
1693
+ try {
1694
+ const row = await db.prepare(
1695
+ `SELECT removal_access FROM cloudflare_grant WHERE "userId" = ?`
1696
+ ).bind(userId).first();
1697
+ if (!row?.removal_access) return null;
1698
+ const parsed = JSON.parse(row.removal_access);
1699
+ if (!parsed || typeof parsed.enc !== "string" || typeof parsed.inbox !== "string") return null;
1700
+ if (parsed.step !== "worker" && parsed.step !== "database" && parsed.step !== "address") return null;
1701
+ if (typeof parsed.until !== "number" || !Number.isFinite(parsed.until)) return null;
1702
+ return parsed;
1703
+ } catch (error) {
1704
+ if (missingColumn(error)) return null;
1705
+ if (error instanceof SyntaxError) return null;
1706
+ throw error;
1707
+ }
1708
+ }
1709
+ async function writeHeldAccess(db, userId, value) {
1710
+ try {
1711
+ await db.prepare(
1712
+ `UPDATE cloudflare_grant SET removal_access = ? WHERE "userId" = ?`
1713
+ ).bind(value, userId).run();
1714
+ } catch (error) {
1715
+ if (missingColumn(error)) return;
1716
+ throw error;
1717
+ }
1654
1718
  }
1655
1719
  async function removeAddress(input, row) {
1656
1720
  const domains = await listWorkersDomains(input);
1657
- if (!Array.isArray(domains)) return { ok: false, ...failureText(domains) };
1721
+ if (!Array.isArray(domains)) return failureText(domains);
1658
1722
  const hostname = (row.hostname ?? "").toLowerCase();
1659
1723
  const found = domains.find((domain) => domain.hostname === hostname);
1660
1724
  if (!found) return { ok: true, text: "Already gone." };
1661
1725
  if (found.service !== row.worker_name) return { ok: true, text: "This address is not on the Worker this setup created, so it stays." };
1662
1726
  const removed = await deleteWorkersDomain({ ...input, domainId: found.id });
1663
- if (removed !== true) return { ok: false, ...failureText(removed) };
1727
+ if (removed !== true) return failureText(removed);
1664
1728
  return { ok: true, text: `${hostname} is removed.` };
1665
1729
  }
1666
1730
  async function removeWorker(input, row) {
1667
1731
  const tag = await workerProvisionTag({ ...input, name: row.worker_name });
1668
- if (tag && typeof tag === "object") return { ok: false, ...failureText(tag) };
1732
+ if (tag && typeof tag === "object") return failureText(tag);
1669
1733
  if (tag === null) return { ok: true, text: "Already gone." };
1670
1734
  if (!row.script_tag || tag !== row.script_tag) return { ok: true, text: "This Worker was not created by this setup, so it stays." };
1671
1735
  const removed = await deleteWorker({ ...input, name: row.worker_name });
1672
- if (removed !== true) return { ok: false, ...failureText(removed) };
1736
+ if (removed !== true) return failureText(removed);
1673
1737
  return { ok: true, text: `${row.worker_name} is removed.` };
1674
1738
  }
1675
1739
  async function removeDatabase(input, row) {
1676
1740
  const databaseId = row.d1_id ?? "";
1677
1741
  const found = await getD1({ ...input, databaseId });
1678
- if (found && "ok" in found) return { ok: false, ...failureText(found) };
1742
+ if (found && "ok" in found) return failureText(found);
1679
1743
  if (!found) return { ok: true, text: "Already gone." };
1680
1744
  const expected = row.d1_name || row.worker_name;
1681
1745
  if (found.name !== expected) return { ok: true, text: "This database was not created by this setup, so it stays." };
1682
1746
  const removed = await deleteD1({ ...input, databaseId });
1683
- if (removed !== true) return { ok: false, ...failureText(removed) };
1747
+ if (removed !== true) return failureText(removed);
1684
1748
  return { ok: true, text: `${expected} is removed.` };
1685
1749
  }
1750
+ function delaysOf(value) {
1751
+ if (!value || value.length === 0) return REMOVAL_PERMISSION_DELAYS_MS;
1752
+ const clean = value.filter((item) => typeof item === "number" && Number.isFinite(item) && item >= 0 && item <= 12e4);
1753
+ return clean.length ? clean : REMOVAL_PERMISSION_DELAYS_MS;
1754
+ }
1755
+ function windowOf(value) {
1756
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 10 * 6e4) return value;
1757
+ return REMOVAL_PERMISSION_WINDOW_MS;
1758
+ }
1759
+ function consentAnchor(input, note, now) {
1760
+ if (typeof input.secondsSinceConsent === "number" && Number.isFinite(input.secondsSinceConsent)) {
1761
+ return now - Math.max(0, input.secondsSinceConsent) * 1e3;
1762
+ }
1763
+ if (typeof input.consentedAt === "number" && Number.isFinite(input.consentedAt)) return input.consentedAt;
1764
+ if (typeof note?.consentAt === "number") return note.consentAt;
1765
+ return null;
1766
+ }
1767
+ function secondsSince(anchor, now) {
1768
+ if (anchor == null) return null;
1769
+ return Math.max(0, Math.floor((now - anchor) / 1e3));
1770
+ }
1771
+ function emit(onEvent, event) {
1772
+ if (!onEvent) return;
1773
+ const line = {
1774
+ event: event.event,
1775
+ step: event.step,
1776
+ status: event.status,
1777
+ code: event.code,
1778
+ secondsSinceConsent: event.secondsSinceConsent,
1779
+ rotations: event.rotations,
1780
+ lock: event.lock
1781
+ };
1782
+ try {
1783
+ onEvent(line);
1784
+ } catch {
1785
+ }
1786
+ }
1787
+ function lockForRefreshError(error) {
1788
+ if (error === "busy") return "busy";
1789
+ if (error === "no_grant" || error === "no_refresh") return "skipped";
1790
+ return "claimed";
1791
+ }
1792
+ function waitsForPermission(step, failure, now, deadline, tries, delays) {
1793
+ if (step !== "worker" && step !== "database") return null;
1794
+ if (!permissionNotReady(failure)) return null;
1795
+ if (!(now < deadline)) return null;
1796
+ if (tries > delays.length) return null;
1797
+ const delay = delays[tries - 1] ?? 0;
1798
+ const retryAt = now + delay;
1799
+ if (delay > 0 && retryAt > deadline) return null;
1800
+ return retryAt;
1801
+ }
1802
+ async function obtainAccess(input, row, step, rotationsBefore, continuing, windowMs) {
1803
+ if (input.refreshAccess) {
1804
+ await writeHeldAccess(input.database, input.userId, null);
1805
+ try {
1806
+ const refreshed2 = await input.refreshAccess({
1807
+ database: input.database,
1808
+ secret: input.secret,
1809
+ clientId: input.clientId,
1810
+ clientSecret: input.clientSecret,
1811
+ userId: input.userId,
1812
+ fetchMs: input.fetchMs,
1813
+ now: input.now,
1814
+ fetchImpl: input.fetchImpl
1815
+ });
1816
+ if (!refreshed2.ok) {
1817
+ return { ok: false, error: refreshed2.error, rotations: rotationsBefore, lock: refreshed2.lock ?? (refreshed2.error === "busy" ? "busy" : "hook") };
1818
+ }
1819
+ const rotated = refreshed2.rotated !== false;
1820
+ return {
1821
+ ok: true,
1822
+ accessToken: refreshed2.accessToken,
1823
+ rotations: rotationsBefore + (rotated ? 1 : 0),
1824
+ lock: refreshed2.lock ?? "hook"
1825
+ };
1826
+ } catch {
1827
+ return { ok: false, error: "failed", rotations: rotationsBefore, lock: "hook" };
1828
+ }
1829
+ }
1830
+ if (typeof input.accessToken === "string" && input.accessToken) {
1831
+ await writeHeldAccess(input.database, input.userId, null);
1832
+ return { ok: true, accessToken: input.accessToken, rotations: rotationsBefore, lock: "skipped" };
1833
+ }
1834
+ if (continuing) {
1835
+ const held = await readHeldAccess(input.database, input.userId);
1836
+ if (held && held.inbox === row.id && held.step === step && held.until >= input.now) {
1837
+ const token = await openCloudflareRefresh(input.secret, input.userId, held.enc);
1838
+ if (token) return { ok: true, accessToken: token, rotations: rotationsBefore, lock: "skipped" };
1839
+ }
1840
+ }
1841
+ const refreshed = await refreshCloudflareGrant({
1842
+ database: input.database,
1843
+ secret: input.secret,
1844
+ clientId: input.clientId,
1845
+ clientSecret: input.clientSecret,
1846
+ userId: input.userId,
1847
+ fetchMs: Math.min(input.fetchMs, 5e3),
1848
+ now: input.now,
1849
+ fetchImpl: input.fetchImpl
1850
+ });
1851
+ if (!refreshed.ok) {
1852
+ return { ok: false, error: refreshed.error, rotations: rotationsBefore, lock: lockForRefreshError(refreshed.error) };
1853
+ }
1854
+ const sealed = await sealCloudflareRefresh(input.secret, input.userId, refreshed.accessToken);
1855
+ if (sealed) {
1856
+ const held = { inbox: row.id, step, until: input.now + windowMs, enc: sealed };
1857
+ await writeHeldAccess(input.database, input.userId, JSON.stringify(held));
1858
+ }
1859
+ return { ok: true, accessToken: refreshed.accessToken, rotations: rotationsBefore + 1, lock: "claimed" };
1860
+ }
1686
1861
  async function runRemovalStep(input) {
1687
1862
  const now = input.now ?? Date.now();
1688
1863
  const row = await readCloudflareInboxById(input.database, input.userId, input.inboxId);
1689
- if (!row) return { finished: true, row: null };
1864
+ if (!row) return { finished: true, row: null, status: "finished" };
1690
1865
  const note = readNote(row.step_error) ?? { removing: true, passed: [] };
1691
1866
  const passed = new Set(note.passed);
1692
1867
  const order = [...removalItems(row).map((item) => item.id), "dashboard"];
1693
1868
  const step = order.find((id) => !passed.has(id));
1694
1869
  if (!step || step === "dashboard") {
1870
+ await writeHeldAccess(input.database, input.userId, null);
1695
1871
  await deleteCloudflareInbox(input.database, input.userId, row.id);
1696
- return { finished: true, row: null };
1697
- }
1698
- const refreshed = await refreshCloudflareGrant({
1872
+ emit(input.onEvent, {
1873
+ event: "removal_step",
1874
+ step: "dashboard",
1875
+ status: 200,
1876
+ code: null,
1877
+ secondsSinceConsent: secondsSince(consentAnchor(input, note, now), now),
1878
+ rotations: 0,
1879
+ lock: "skipped"
1880
+ });
1881
+ return { finished: true, row: null, status: "finished" };
1882
+ }
1883
+ const continuing = note.state === "wait" && note.failed === step;
1884
+ if (continuing && typeof note.retryAt === "number" && now < note.retryAt) {
1885
+ return { finished: false, row, status: "waiting", retryAt: note.retryAt };
1886
+ }
1887
+ const windowMs = windowOf(input.permissionWindowMs);
1888
+ const delays = delaysOf(input.permissionDelaysMs);
1889
+ const anchor = consentAnchor(input, note, now);
1890
+ const started = continuing && typeof note.waitStartedAt === "number" ? note.waitStartedAt : now;
1891
+ const deadline = (anchor ?? started) + windowMs;
1892
+ const rotationsBefore = continuing ? note.rotations ?? 0 : 0;
1893
+ const fetchImpl = input.fetchImpl ?? fetch;
1894
+ const obtained = await obtainAccess({
1699
1895
  database: input.database,
1700
1896
  secret: input.secret,
1701
1897
  clientId: input.clientId,
1702
1898
  clientSecret: input.clientSecret,
1703
1899
  userId: input.userId,
1704
- fetchMs: Math.min(input.fetchMs ?? 1e4, 5e3),
1900
+ fetchMs: input.fetchMs ?? 1e4,
1705
1901
  now,
1706
- fetchImpl: input.fetchImpl
1707
- });
1708
- if (!refreshed.ok) {
1709
- await save(input.database, row, { removing: true, passed: [...passed], failed: step, text: "The saved Cloudflare permission expired. Connect Cloudflare again, then try again.", cloudflare: "" }, now);
1902
+ fetchImpl,
1903
+ refreshAccess: input.refreshAccess,
1904
+ accessToken: input.accessToken
1905
+ }, row, step, rotationsBefore, continuing, windowMs);
1906
+ const since = secondsSince(anchor, now);
1907
+ if (!obtained.ok) {
1908
+ emit(input.onEvent, {
1909
+ event: "removal_step_failed",
1910
+ step,
1911
+ status: 0,
1912
+ code: null,
1913
+ secondsSinceConsent: since,
1914
+ rotations: obtained.rotations,
1915
+ lock: obtained.lock
1916
+ });
1917
+ await writeHeldAccess(input.database, input.userId, null);
1918
+ await save(input.database, row, {
1919
+ removing: true,
1920
+ passed: [...passed],
1921
+ ...note.texts ? { texts: note.texts } : {},
1922
+ failed: step,
1923
+ state: "fail",
1924
+ text: "The saved Cloudflare permission expired. Connect Cloudflare again, then try again.",
1925
+ cloudflare: "",
1926
+ ...anchor != null ? { consentAt: anchor } : {}
1927
+ }, now);
1710
1928
  const updated = await readCloudflareInboxById(input.database, input.userId, row.id);
1711
- return { finished: false, row: updated };
1929
+ return { finished: false, row: updated, status: "fail" };
1712
1930
  }
1713
- const api = { fetchImpl: input.fetchImpl ?? fetch, token: refreshed.accessToken, fetchMs: input.fetchMs ?? 1e4, accountId: row.cf_account_id };
1931
+ const api = { fetchImpl, token: obtained.accessToken, fetchMs: input.fetchMs ?? 1e4, accountId: row.cf_account_id };
1714
1932
  const result = step === "address" ? await removeAddress(api, row) : step === "worker" ? await removeWorker(api, row) : await removeDatabase(api, row);
1715
1933
  if (!result.ok) {
1716
- await save(input.database, row, { removing: true, passed: [...passed], failed: step, text: result.text, cloudflare: result.cloudflare }, now);
1717
- return { finished: false, row: await readCloudflareInboxById(input.database, input.userId, row.id) };
1718
- }
1934
+ const tries = (continuing ? note.permissionTries ?? 0 : 0) + 1;
1935
+ const retryAt = waitsForPermission(step, result.failure, now, deadline, tries, delays);
1936
+ if (retryAt != null) {
1937
+ emit(input.onEvent, {
1938
+ event: "removal_step",
1939
+ step,
1940
+ status: result.failure.status,
1941
+ code: result.failure.code,
1942
+ secondsSinceConsent: since,
1943
+ rotations: obtained.rotations,
1944
+ lock: obtained.lock
1945
+ });
1946
+ await save(input.database, row, {
1947
+ removing: true,
1948
+ passed: [...passed],
1949
+ ...note.texts ? { texts: note.texts } : {},
1950
+ failed: step,
1951
+ state: "wait",
1952
+ text: PERMISSION_WAIT,
1953
+ cloudflare: "",
1954
+ permissionTries: tries,
1955
+ retryAt,
1956
+ waitStartedAt: continuing && typeof note.waitStartedAt === "number" ? note.waitStartedAt : now,
1957
+ rotations: obtained.rotations,
1958
+ ...anchor != null ? { consentAt: anchor } : {}
1959
+ }, now);
1960
+ return { finished: false, row: await readCloudflareInboxById(input.database, input.userId, row.id), status: "waiting", retryAt };
1961
+ }
1962
+ emit(input.onEvent, {
1963
+ event: "removal_step_failed",
1964
+ step,
1965
+ status: result.failure.status,
1966
+ code: result.failure.code,
1967
+ secondsSinceConsent: since,
1968
+ rotations: obtained.rotations,
1969
+ lock: obtained.lock
1970
+ });
1971
+ await writeHeldAccess(input.database, input.userId, null);
1972
+ await save(input.database, row, {
1973
+ removing: true,
1974
+ passed: [...passed],
1975
+ ...note.texts ? { texts: note.texts } : {},
1976
+ failed: step,
1977
+ state: "fail",
1978
+ text: result.text,
1979
+ cloudflare: result.cloudflare,
1980
+ ...anchor != null ? { consentAt: anchor } : {}
1981
+ }, now);
1982
+ return { finished: false, row: await readCloudflareInboxById(input.database, input.userId, row.id), status: "fail" };
1983
+ }
1984
+ emit(input.onEvent, {
1985
+ event: "removal_step",
1986
+ step,
1987
+ status: 200,
1988
+ code: null,
1989
+ secondsSinceConsent: since,
1990
+ rotations: obtained.rotations,
1991
+ lock: obtained.lock
1992
+ });
1993
+ await writeHeldAccess(input.database, input.userId, null);
1719
1994
  passed.add(step);
1720
1995
  const texts = { ...note.texts ?? {}, [step]: result.text };
1721
1996
  const next = order.find((id) => !passed.has(id));
1722
1997
  if (!next || next === "dashboard") {
1723
1998
  await deleteCloudflareInbox(input.database, input.userId, row.id);
1724
- return { finished: true, row: null };
1999
+ emit(input.onEvent, {
2000
+ event: "removal_step",
2001
+ step: "dashboard",
2002
+ status: 200,
2003
+ code: null,
2004
+ secondsSinceConsent: since,
2005
+ rotations: 0,
2006
+ lock: "skipped"
2007
+ });
2008
+ return { finished: true, row: null, status: "finished" };
1725
2009
  }
1726
2010
  await save(input.database, row, { removing: true, passed: [...passed], texts, text: result.text, cloudflare: "" }, now);
1727
- return { finished: false, row: await readCloudflareInboxById(input.database, input.userId, row.id) };
2011
+ return { finished: false, row: await readCloudflareInboxById(input.database, input.userId, row.id), status: "pass" };
1728
2012
  }
1729
2013
 
1730
2014
  // src/provision/inbox.ts
@@ -2704,6 +2988,9 @@ var cloudflare_inbox_wizard_default = "-- Wizard progress for an inbox this page
2704
2988
  // sql-raw:/home/runner/work/a2a-exposed/a2a-exposed/control/migrations/0014_cloudflare_inbox_agent.sql
2705
2989
  var cloudflare_inbox_agent_default = '-- An account can have more than one Cloudflare agent. The old primary key was userId.\n-- Those rows keep that value as id. zone_id is the domain this setup attached, when it did.\n-- Append-only: do not edit this file.\nCREATE TABLE cloudflare_inbox_agent (\n id TEXT NOT NULL PRIMARY KEY,\n "userId" TEXT NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE,\n cf_account_id TEXT NOT NULL,\n worker_name TEXT NOT NULL,\n d1_name TEXT NOT NULL,\n d1_id TEXT,\n public_url TEXT,\n owner_token_hash TEXT,\n host TEXT NOT NULL,\n hostname TEXT,\n status TEXT NOT NULL,\n script_tag TEXT,\n wizard_step TEXT,\n workers_subdomain TEXT,\n step_error TEXT,\n zone_id TEXT,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\nINSERT INTO cloudflare_inbox_agent (\n id, "userId", cf_account_id, worker_name, d1_name, d1_id, public_url, owner_token_hash,\n host, hostname, status, script_tag, wizard_step, workers_subdomain, step_error, zone_id,\n created_at, updated_at\n)\nSELECT\n "userId", "userId", cf_account_id, worker_name, d1_name, d1_id, public_url, owner_token_hash,\n host, hostname, status, script_tag, wizard_step, workers_subdomain, step_error, NULL,\n created_at, updated_at\nFROM cloudflare_inbox;\n\nDROP TABLE cloudflare_inbox;\nALTER TABLE cloudflare_inbox_agent RENAME TO cloudflare_inbox;\nCREATE INDEX IF NOT EXISTS cloudflare_inbox_user_idx ON cloudflare_inbox ("userId", created_at);\n';
2706
2990
 
2991
+ // sql-raw:/home/runner/work/a2a-exposed/a2a-exposed/control/migrations/0015_cloudflare_grant_access.sql
2992
+ var cloudflare_grant_access_default = "-- Sealed access token for a removal step that is waiting on a Cloudflare permission.\n-- The column is cleared when that step passes or fails.\n-- Append-only: do not edit this file.\nALTER TABLE cloudflare_grant ADD COLUMN removal_access TEXT;\n";
2993
+
2707
2994
  // src/core/migrations.ts
2708
2995
  var CONTROL_MIGRATIONS_DIR = "control/migrations";
2709
2996
  var CONTROL_MIGRATIONS = [
@@ -2720,7 +3007,8 @@ var CONTROL_MIGRATIONS = [
2720
3007
  { name: "0011_cloudflare_inbox.sql", sql: cloudflare_inbox_default },
2721
3008
  { name: "0012_cloudflare_inbox_tag.sql", sql: cloudflare_inbox_tag_default },
2722
3009
  { name: "0013_cloudflare_inbox_wizard.sql", sql: cloudflare_inbox_wizard_default },
2723
- { name: "0014_cloudflare_inbox_agent.sql", sql: cloudflare_inbox_agent_default }
3010
+ { name: "0014_cloudflare_inbox_agent.sql", sql: cloudflare_inbox_agent_default },
3011
+ { name: "0015_cloudflare_grant_access.sql", sql: cloudflare_grant_access_default }
2724
3012
  ];
2725
3013
  export {
2726
3014
  ACCOUNT_SETTINGS_READ,
@@ -2741,6 +3029,8 @@ export {
2741
3029
  PERMISSION_TRIES,
2742
3030
  PERMISSION_WAIT,
2743
3031
  PROVISION_TAG_NAME,
3032
+ REMOVAL_PERMISSION_DELAYS_MS,
3033
+ REMOVAL_PERMISSION_WINDOW_MS,
2744
3034
  RESERVED_NAMES,
2745
3035
  RUN_WORKER_FIRST,
2746
3036
  SETUP_NOT_PROVISIONED,
@@ -2811,8 +3101,10 @@ export {
2811
3101
  readCloudflareInboxById,
2812
3102
  readInboxById,
2813
3103
  refreshCloudflareGrant,
3104
+ refreshTokenAfterResponse,
2814
3105
  registerWorkersDevSubdomain,
2815
3106
  removalItems,
3107
+ removalRetryDue,
2816
3108
  removalSteps,
2817
3109
  resultList,
2818
3110
  resultObject,
@@ -21,6 +21,11 @@ export type RefreshResult = {
21
21
  /** `v1.<iv>.<ciphertext>`, or null when the secret or the token cannot be sealed. */
22
22
  export declare function sealCloudflareRefresh(secret: string, userId: string, token: string): Promise<string | null>;
23
23
  export declare function openCloudflareRefresh(secret: string, userId: string, sealed: string | null | undefined): Promise<string | null>;
24
+ /**
25
+ * Refresh token to keep after a token response.
26
+ * A non-empty `refresh_token` replaces `previous`. An empty one leaves `previous` in place.
27
+ */
28
+ export declare function refreshTokenAfterResponse(responseToken: unknown, previous: string | null): string | null;
24
29
  export declare function saveCloudflareGrant(db: Sql, secret: string, input: {
25
30
  userId: string;
26
31
  accountId: string;
@@ -1,6 +1,21 @@
1
1
  import type { Sql } from "../core/sql.js";
2
+ import { type RefreshResult } from "./grant.js";
2
3
  import { type InboxRow } from "./inbox-row.js";
3
4
  export type RemovalId = "address" | "worker" | "database" | "dashboard";
5
+ /** Pauses between Worker and database removal tries. The pauses add up to 90 seconds. */
6
+ export declare const REMOVAL_PERMISSION_DELAYS_MS: number[];
7
+ /** How long after consent a 10000 or 7403 on removal stays a wait. */
8
+ export declare const REMOVAL_PERMISSION_WINDOW_MS = 90000;
9
+ export type RemovalLock = "claimed" | "busy" | "skipped" | "hook";
10
+ export interface RemovalEvent {
11
+ event: "removal_step" | "removal_step_failed";
12
+ step: RemovalId;
13
+ status: number;
14
+ code: number | null;
15
+ secondsSinceConsent: number | null;
16
+ rotations: number;
17
+ lock: RemovalLock;
18
+ }
4
19
  export interface RemovalItem {
5
20
  id: Exclude<RemovalId, "dashboard">;
6
21
  title: string;
@@ -9,16 +24,42 @@ export interface RemovalItem {
9
24
  export interface RemovalStep {
10
25
  id: RemovalId;
11
26
  title: string;
12
- state: "pass" | "fail" | "pending";
27
+ state: "pass" | "fail" | "pending" | "wait";
13
28
  text: string;
14
29
  cloudflare: string;
15
30
  }
31
+ export interface RemovalStepResult {
32
+ finished: boolean;
33
+ row: InboxRow | null;
34
+ status: "finished" | "pass" | "waiting" | "fail";
35
+ retryAt?: number;
36
+ }
37
+ /** Same inputs as `refreshCloudflareGrant`, so a caller can refresh on a cache miss. */
38
+ export interface RefreshAccessInput {
39
+ database: Sql;
40
+ secret: string;
41
+ clientId: string;
42
+ clientSecret: string;
43
+ userId: string;
44
+ fetchMs: number;
45
+ now: number;
46
+ fetchImpl: typeof fetch;
47
+ }
48
+ export type RefreshAccessResult = RefreshResult & {
49
+ /** True when this call rotated the refresh token. A cache hit sets false. */
50
+ rotated?: boolean;
51
+ lock?: RemovalLock;
52
+ };
16
53
  /** What this setup created, in the order removal runs. */
17
54
  export declare function removalItems(row: InboxRow): RemovalItem[];
18
55
  export declare function removalSteps(row: InboxRow): RemovalStep[];
56
+ /** True when a waiting removal step is ready to try again. */
57
+ export declare function removalRetryDue(row: InboxRow | null, now?: number): boolean;
19
58
  /**
20
59
  * Runs the next removal step. A pass is stored. A failure stays on that step for Try again.
21
60
  * The dashboard row is removed only after the Cloudflare steps have passed.
61
+ * Worker and database calls that hit Cloudflare code 10000 or 7403 return `waiting`
62
+ * until 90 seconds after consent. Retries of that step reuse the access token.
22
63
  */
23
64
  export declare function runRemovalStep(input: {
24
65
  database: Sql;
@@ -30,7 +71,20 @@ export declare function runRemovalStep(input: {
30
71
  fetchMs?: number;
31
72
  now?: number;
32
73
  fetchImpl?: typeof fetch;
33
- }): Promise<{
34
- finished: boolean;
35
- row: InboxRow | null;
36
- }>;
74
+ /** Milliseconds when the Cloudflare consent completed. */
75
+ consentedAt?: number;
76
+ /** Seconds from consent to this call. Used when `consentedAt` is omitted. */
77
+ secondsSinceConsent?: number;
78
+ /** Pauses after a permission error. The default pauses add up to 90 seconds. */
79
+ permissionDelaysMs?: number[];
80
+ /** Milliseconds after consent that a permission error stays a wait. Default 90 seconds. */
81
+ permissionWindowMs?: number;
82
+ /**
83
+ * Supplies the access token. When set, this step skips `refreshCloudflareGrant`.
84
+ * A cache hit reports `rotated: false`.
85
+ */
86
+ refreshAccess?: (input: RefreshAccessInput) => Promise<RefreshAccessResult>;
87
+ /** Access token the caller already holds. When set, this step skips the refresh. */
88
+ accessToken?: string;
89
+ onEvent?: (event: RemovalEvent) => void;
90
+ }): Promise<RemovalStepResult>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a2a-exposed/control-core",
3
- "version": "0.8.5",
3
+ "version": "0.8.6",
4
4
  "description": "Inbox provisioning, routing, and D1 migrations for a2a-exposed.",
5
5
  "type": "module",
6
6
  "scripts": {