@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.
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
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:
|
|
1900
|
+
fetchMs: input.fetchMs ?? 1e4,
|
|
1705
1901
|
now,
|
|
1706
|
-
fetchImpl
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
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
|
|
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
|
-
|
|
1717
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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>;
|