@calimero-network/mero-js 29.15.0 → 29.16.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/README.md +35 -0
- package/dist/cloud-v2/client.d.ts +20 -129
- package/dist/cloud-v2/client.js +43 -452
- package/dist/index.browser.mjs +5 -5
- package/dist/index.browser.mjs.map +4 -4
- package/dist/index.cjs +503 -383
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/index.mjs +503 -383
- package/dist/index.mjs.map +4 -4
- package/dist/runtime/index.d.ts +10 -0
- package/dist/runtime/index.js +21 -0
- package/dist/runtime/transport.d.ts +311 -0
- package/dist/runtime/transport.js +576 -0
- package/dist/runtime.browser.mjs +67 -0
- package/dist/runtime.cjs +67 -0
- package/dist/runtime.mjs +67 -0
- package/package.json +9 -2
package/dist/index.mjs
CHANGED
|
@@ -1669,6 +1669,440 @@ function browserPasskey() {
|
|
|
1669
1669
|
};
|
|
1670
1670
|
}
|
|
1671
1671
|
|
|
1672
|
+
// src/runtime/transport.ts
|
|
1673
|
+
var DEFAULT_RETRY = { retries: 6, baseDelayMs: 200, maxDelayMs: 2e3 };
|
|
1674
|
+
function retryDelayMs(retry, options) {
|
|
1675
|
+
return Math.min(options.baseDelayMs * 2 ** retry, options.maxDelayMs);
|
|
1676
|
+
}
|
|
1677
|
+
function userVerificationOf(named, fallback) {
|
|
1678
|
+
return named === "required" || named === "preferred" || named === "discouraged" ? named : fallback;
|
|
1679
|
+
}
|
|
1680
|
+
function promptFailure(error) {
|
|
1681
|
+
const e = error;
|
|
1682
|
+
const name = e?.name;
|
|
1683
|
+
const message = typeof e?.message === "string" ? e.message : "";
|
|
1684
|
+
if (name === "InvalidStateError" || /not focused|already pending/i.test(message)) {
|
|
1685
|
+
return new ConfirmPromptBlocked(error);
|
|
1686
|
+
}
|
|
1687
|
+
if (name === "NotAllowedError" || name === "AbortError") return new ConfirmCancelled(error);
|
|
1688
|
+
return error;
|
|
1689
|
+
}
|
|
1690
|
+
function sealedError(error) {
|
|
1691
|
+
if (error instanceof SealedTransportError && error.status === 413) {
|
|
1692
|
+
return errorFrom(413, {
|
|
1693
|
+
error: "TOO_LARGE",
|
|
1694
|
+
message: error.message.replace(/^Sealed request refused: /, "")
|
|
1695
|
+
});
|
|
1696
|
+
}
|
|
1697
|
+
return error;
|
|
1698
|
+
}
|
|
1699
|
+
async function refusal(response) {
|
|
1700
|
+
const text = await response.text().catch(() => "");
|
|
1701
|
+
let body = {};
|
|
1702
|
+
try {
|
|
1703
|
+
body = text ? JSON.parse(text) : {};
|
|
1704
|
+
} catch {
|
|
1705
|
+
body = { message: text };
|
|
1706
|
+
}
|
|
1707
|
+
return errorFrom(response.status, body, response.headers.get("retry-after"));
|
|
1708
|
+
}
|
|
1709
|
+
async function* sseFrames(body) {
|
|
1710
|
+
const reader = body.getReader();
|
|
1711
|
+
const decoder = new TextDecoder();
|
|
1712
|
+
let buffer = "";
|
|
1713
|
+
for (; ; ) {
|
|
1714
|
+
const { value, done } = await reader.read();
|
|
1715
|
+
if (done) return;
|
|
1716
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1717
|
+
let cut;
|
|
1718
|
+
while ((cut = buffer.indexOf("\n\n")) >= 0) {
|
|
1719
|
+
const frame = buffer.slice(0, cut);
|
|
1720
|
+
buffer = buffer.slice(cut + 2);
|
|
1721
|
+
let id = "";
|
|
1722
|
+
let kind = "";
|
|
1723
|
+
let data = "";
|
|
1724
|
+
for (const line of frame.split("\n")) {
|
|
1725
|
+
if (line.startsWith("id:")) id = line.slice(3).trim();
|
|
1726
|
+
else if (line.startsWith("event:")) kind = line.slice(6).trim();
|
|
1727
|
+
else if (line.startsWith("data:")) data += line.slice(5).trim();
|
|
1728
|
+
}
|
|
1729
|
+
if (!kind && !data) continue;
|
|
1730
|
+
let parsed = data;
|
|
1731
|
+
try {
|
|
1732
|
+
parsed = JSON.parse(data);
|
|
1733
|
+
} catch {
|
|
1734
|
+
}
|
|
1735
|
+
yield { id, kind, data: parsed };
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
function newIdempotencyKey() {
|
|
1740
|
+
return `node:c-${b64url(crypto.getRandomValues(new Uint8Array(16)))}`;
|
|
1741
|
+
}
|
|
1742
|
+
function routePath(template, params = {}) {
|
|
1743
|
+
return template.replace(/\{([^}]+)\}/g, (_, name) => {
|
|
1744
|
+
const value = params[name];
|
|
1745
|
+
if (value === void 0) throw new Error(`no value for path parameter ${name} of ${template}`);
|
|
1746
|
+
return encodeURIComponent(String(value));
|
|
1747
|
+
});
|
|
1748
|
+
}
|
|
1749
|
+
function queryString(query) {
|
|
1750
|
+
if (!query) return "";
|
|
1751
|
+
const search = new URLSearchParams();
|
|
1752
|
+
for (const [name, value] of Object.entries(query)) {
|
|
1753
|
+
for (const one of Array.isArray(value) ? value : [value]) {
|
|
1754
|
+
if (one !== void 0 && one !== null) search.append(name, String(one));
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
const text = search.toString();
|
|
1758
|
+
return text ? `?${text}` : "";
|
|
1759
|
+
}
|
|
1760
|
+
function ceremony(path) {
|
|
1761
|
+
return path === "auth/refresh" || path.startsWith("auth/passkey/") || RECOVERY_CEREMONIES.has(path);
|
|
1762
|
+
}
|
|
1763
|
+
var RECOVERY_CEREMONIES = /* @__PURE__ */ new Set(["auth/recovery/begin", "auth/recovery/start", "auth/recovery/finish"]);
|
|
1764
|
+
function relative(path) {
|
|
1765
|
+
return path.replace(/^\/+/, "");
|
|
1766
|
+
}
|
|
1767
|
+
var NodeTransport = class {
|
|
1768
|
+
constructor(options) {
|
|
1769
|
+
/** The one in use: an index into {@link nodeUrls}. */
|
|
1770
|
+
this.at = 0;
|
|
1771
|
+
/** A sealed fetch per node, each attesting its own node on first use. */
|
|
1772
|
+
this.channels = /* @__PURE__ */ new Map();
|
|
1773
|
+
/** Rejections of the underlying `fetch` itself: the node could not be reached or dropped the connection. */
|
|
1774
|
+
this.networkFailures = /* @__PURE__ */ new WeakSet();
|
|
1775
|
+
const trim = (url2) => url2.replace(/\/+$/, "");
|
|
1776
|
+
this.nodeUrls = [...new Set([options.baseUrl, ...options.nodes ?? []].map(trim))];
|
|
1777
|
+
this.policy = options.policy;
|
|
1778
|
+
this.retry = { ...DEFAULT_RETRY, ...options.retry };
|
|
1779
|
+
const given = options.fetch ?? ((input, init) => fetch(input, init));
|
|
1780
|
+
const raw = async (input, init) => {
|
|
1781
|
+
try {
|
|
1782
|
+
return await given(input, init);
|
|
1783
|
+
} catch (error) {
|
|
1784
|
+
if (error instanceof TypeError) this.networkFailures.add(error);
|
|
1785
|
+
throw error;
|
|
1786
|
+
}
|
|
1787
|
+
};
|
|
1788
|
+
this.raw = raw;
|
|
1789
|
+
}
|
|
1790
|
+
/** Re-read the node list from `source` when it is due, and after a failover. */
|
|
1791
|
+
useDirectory(source) {
|
|
1792
|
+
this.source = source;
|
|
1793
|
+
}
|
|
1794
|
+
/** The node requests go to now: `baseUrl`, until a `421 WRONG_NODE` or a failover moved on. */
|
|
1795
|
+
get nodeUrl() {
|
|
1796
|
+
return this.nodeUrls[this.at];
|
|
1797
|
+
}
|
|
1798
|
+
/** Every node this transport may use, in the order it moves through them. */
|
|
1799
|
+
get nodes() {
|
|
1800
|
+
return [...this.nodeUrls];
|
|
1801
|
+
}
|
|
1802
|
+
/** Authenticate every request with an API key. */
|
|
1803
|
+
useApiKey(key) {
|
|
1804
|
+
this.auth = key;
|
|
1805
|
+
this.current = void 0;
|
|
1806
|
+
}
|
|
1807
|
+
/** Authenticate every request with `session`, refreshed when a node answers `401` to it. */
|
|
1808
|
+
useSession(session) {
|
|
1809
|
+
this.auth = session.sessionToken;
|
|
1810
|
+
this.current = session;
|
|
1811
|
+
}
|
|
1812
|
+
/** Drop the credential: no request after this carries it. */
|
|
1813
|
+
signOut() {
|
|
1814
|
+
this.auth = void 0;
|
|
1815
|
+
this.current = void 0;
|
|
1816
|
+
}
|
|
1817
|
+
get session() {
|
|
1818
|
+
return this.current;
|
|
1819
|
+
}
|
|
1820
|
+
/** The sealed channel to `url`, attested under the policy before its first request. */
|
|
1821
|
+
channel(url2) {
|
|
1822
|
+
let sealed = this.channels.get(url2);
|
|
1823
|
+
if (!sealed) {
|
|
1824
|
+
sealed = createSealedFetch({
|
|
1825
|
+
baseUrl: url2,
|
|
1826
|
+
transportPublicKey: () => fetchAttestedKey(url2, this.policy, this.raw),
|
|
1827
|
+
fetch: this.raw
|
|
1828
|
+
});
|
|
1829
|
+
this.channels.set(url2, sealed);
|
|
1830
|
+
}
|
|
1831
|
+
return sealed;
|
|
1832
|
+
}
|
|
1833
|
+
/**
|
|
1834
|
+
* Move on from node `from` (the one that answered `421 WRONG_NODE`) to the
|
|
1835
|
+
* next one. False when there is no other. A request that saw the same node
|
|
1836
|
+
* refuse after another request moved on already does not move again.
|
|
1837
|
+
*/
|
|
1838
|
+
moveOn(from) {
|
|
1839
|
+
if (this.nodeUrls.length < 2) return false;
|
|
1840
|
+
if (this.nodeUrl === from) this.at = (this.at + 1) % this.nodeUrls.length;
|
|
1841
|
+
return true;
|
|
1842
|
+
}
|
|
1843
|
+
/**
|
|
1844
|
+
* Fail over from node `from`, where a request's retries ran out, to the next
|
|
1845
|
+
* node it has not `tried`. False when it has tried them all. A request whose
|
|
1846
|
+
* node another request moved away from already goes to that node, if untried.
|
|
1847
|
+
*/
|
|
1848
|
+
failOver(from, tried) {
|
|
1849
|
+
if (this.nodeUrl !== from && !tried.has(this.nodeUrl)) return true;
|
|
1850
|
+
const count = this.nodeUrls.length;
|
|
1851
|
+
for (let step = 1; step < count; step++) {
|
|
1852
|
+
const next = (this.at + step) % count;
|
|
1853
|
+
if (!tried.has(this.nodeUrls[next])) {
|
|
1854
|
+
this.at = next;
|
|
1855
|
+
this.reread();
|
|
1856
|
+
return true;
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
return false;
|
|
1860
|
+
}
|
|
1861
|
+
/**
|
|
1862
|
+
* Read the node list again from the directory, in the background: the node
|
|
1863
|
+
* in use stays first, then the directory's order; a node no longer listed is
|
|
1864
|
+
* dropped. A failed read is ignored. One read at a time.
|
|
1865
|
+
*/
|
|
1866
|
+
reread() {
|
|
1867
|
+
const source = this.source;
|
|
1868
|
+
if (!source || source.reading) return;
|
|
1869
|
+
source.reading = readDirectory(source.directory, source.cluster, this.raw).then((listing) => {
|
|
1870
|
+
const current = this.nodeUrl;
|
|
1871
|
+
const listed = orderNodes(listing.nodes, source.zone, source.random);
|
|
1872
|
+
this.nodeUrls = [current, ...listed.filter((url2) => url2 !== current)];
|
|
1873
|
+
this.at = 0;
|
|
1874
|
+
source.due = Date.now() + listing.refreshAfterSeconds * 1e3;
|
|
1875
|
+
}).catch(() => void 0).finally(() => {
|
|
1876
|
+
source.reading = void 0;
|
|
1877
|
+
});
|
|
1878
|
+
}
|
|
1879
|
+
/**
|
|
1880
|
+
* Send one request to the node in use, with the credential held now, and
|
|
1881
|
+
* answer the response, whatever its status. {@link send} judges it.
|
|
1882
|
+
*/
|
|
1883
|
+
async once(method, path, options) {
|
|
1884
|
+
const headers = {};
|
|
1885
|
+
if (this.auth) headers.authorization = `Bearer ${this.auth}`;
|
|
1886
|
+
let payload;
|
|
1887
|
+
if (options.bytes) {
|
|
1888
|
+
headers["content-type"] = options.contentType ?? "application/wasm";
|
|
1889
|
+
payload = options.bytes;
|
|
1890
|
+
} else if (options.body !== void 0) {
|
|
1891
|
+
headers["content-type"] = "application/json";
|
|
1892
|
+
payload = JSON.stringify(options.body);
|
|
1893
|
+
}
|
|
1894
|
+
if (this.source && Date.now() >= this.source.due) this.reread();
|
|
1895
|
+
const url2 = this.nodeUrl;
|
|
1896
|
+
try {
|
|
1897
|
+
return await this.channel(url2)(`${url2}/${path}${queryString(options.query)}`, {
|
|
1898
|
+
method,
|
|
1899
|
+
headers,
|
|
1900
|
+
body: payload,
|
|
1901
|
+
signal: options.signal
|
|
1902
|
+
});
|
|
1903
|
+
} catch (error) {
|
|
1904
|
+
throw sealedError(error);
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
/**
|
|
1908
|
+
* {@link once}, answering what a lost issuing node or a node on the wrong
|
|
1909
|
+
* release does to a session: a successful response, or the refusal as a
|
|
1910
|
+
* typed error ({@link errorFrom}).
|
|
1911
|
+
*
|
|
1912
|
+
* - `401 UNAUTHENTICATED` to a request that carried a session: the node that
|
|
1913
|
+
* signed the session may be gone, replaced, or out of reach of the node
|
|
1914
|
+
* asked, which any node of the cluster answers with a refresh. The
|
|
1915
|
+
* transport refreshes once, shared by every request that needs it at the
|
|
1916
|
+
* same time ({@link refresh}), and repeats the request once. A refresh that
|
|
1917
|
+
* is refused surfaces as {@link LoginRequired}.
|
|
1918
|
+
* - `421 WRONG_NODE`: this node's release may not serve the account. The
|
|
1919
|
+
* transport moves to the next node it knows and repeats the request once;
|
|
1920
|
+
* with none, the {@link WrongNode} reaches the caller.
|
|
1921
|
+
*
|
|
1922
|
+
* Repeating is safe even for a request that is not idempotent: the node
|
|
1923
|
+
* answers `401` and `421` when it judges the credential, before it reads what
|
|
1924
|
+
* the request asks, so the first attempt ran nothing. The exceptions are the
|
|
1925
|
+
* ceremonies that mint a session ({@link ceremony}), never repeated here.
|
|
1926
|
+
*/
|
|
1927
|
+
async send(method, path, options) {
|
|
1928
|
+
let refreshed = false;
|
|
1929
|
+
let moved = false;
|
|
1930
|
+
for (; ; ) {
|
|
1931
|
+
const [token, at] = [this.auth, this.nodeUrl];
|
|
1932
|
+
const response = await this.once(method, path, options);
|
|
1933
|
+
if (response.ok) return response;
|
|
1934
|
+
const error = await refusal(response);
|
|
1935
|
+
if (error instanceof WrongNode && !moved) {
|
|
1936
|
+
moved = true;
|
|
1937
|
+
if (this.moveOn(at) && !ceremony(path)) continue;
|
|
1938
|
+
} else if (error instanceof LoginRequired && !refreshed && this.mayRefresh(token, path)) {
|
|
1939
|
+
refreshed = true;
|
|
1940
|
+
await this.renew(token);
|
|
1941
|
+
continue;
|
|
1942
|
+
}
|
|
1943
|
+
throw error;
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
/** Whether a `401` to a request sent with `token` is one a refresh may answer. */
|
|
1947
|
+
mayRefresh(token, path) {
|
|
1948
|
+
return token !== void 0 && this.current !== void 0 && !ceremony(path);
|
|
1949
|
+
}
|
|
1950
|
+
/**
|
|
1951
|
+
* A fresh session in place of the one `token` belonged to: by the refresh
|
|
1952
|
+
* another request already made, or by one this request starts.
|
|
1953
|
+
*/
|
|
1954
|
+
async renew(token) {
|
|
1955
|
+
if (this.auth !== token) return;
|
|
1956
|
+
await this.refresh();
|
|
1957
|
+
}
|
|
1958
|
+
async read(method, path, options) {
|
|
1959
|
+
const response = await this.send(method, path, options);
|
|
1960
|
+
const text = await response.text();
|
|
1961
|
+
return text ? JSON.parse(text) : {};
|
|
1962
|
+
}
|
|
1963
|
+
/**
|
|
1964
|
+
* {@link read} for a call or a view (D70): while the node answers 503 or
|
|
1965
|
+
* cannot be reached, the same request, with the same idempotency key, is sent
|
|
1966
|
+
* again after {@link retryDelayMs} (or the pause the node asked for, if
|
|
1967
|
+
* longer), at most `retries` times. Every other answer is final.
|
|
1968
|
+
*
|
|
1969
|
+
* When a node's retries run out, the request fails over to the next node the
|
|
1970
|
+
* transport knows and its retries start again there, with the same key: the
|
|
1971
|
+
* key is kept in the app's replicated log, so the cluster answers it once
|
|
1972
|
+
* whichever node it reaches. Each node is tried once per request. The last
|
|
1973
|
+
* failure surfaces as {@link UnavailableError}: the node's 503, or reason
|
|
1974
|
+
* `network` for a connection that failed, whose `committed` is `'unknown'`.
|
|
1975
|
+
*
|
|
1976
|
+
* `once` sends the request exactly once, to the node in use (a confirmed
|
|
1977
|
+
* attempt, whose challenge is single-use).
|
|
1978
|
+
*/
|
|
1979
|
+
async retried(method, path, options, once) {
|
|
1980
|
+
const retries = once ? 0 : this.retry.retries;
|
|
1981
|
+
const tried = /* @__PURE__ */ new Set();
|
|
1982
|
+
for (; ; ) {
|
|
1983
|
+
let unavailable;
|
|
1984
|
+
let node = this.nodeUrl;
|
|
1985
|
+
for (let attempt = 0; ; attempt++) {
|
|
1986
|
+
node = this.nodeUrl;
|
|
1987
|
+
tried.add(node);
|
|
1988
|
+
try {
|
|
1989
|
+
return await this.read(method, path, options);
|
|
1990
|
+
} catch (error) {
|
|
1991
|
+
if (error instanceof UnavailableError) unavailable = error;
|
|
1992
|
+
else if (error instanceof TypeError && this.networkFailures.has(error)) {
|
|
1993
|
+
unavailable = new UnavailableError("NETWORK", `the node could not be reached: ${error.message}`, {}, void 0, error);
|
|
1994
|
+
} else throw error;
|
|
1995
|
+
}
|
|
1996
|
+
if (attempt >= retries) break;
|
|
1997
|
+
const backoff = retryDelayMs(attempt, this.retry);
|
|
1998
|
+
await new Promise((resolve) => setTimeout(resolve, Math.max(backoff, (unavailable.retryAfter ?? 0) * 1e3)));
|
|
1999
|
+
}
|
|
2000
|
+
if (once || !this.failOver(node, tried)) throw unavailable;
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
/**
|
|
2004
|
+
* One JSON request, retried as `options.retry` says. The confirmed attempt
|
|
2005
|
+
* of a step-up (`body.confirm` set) goes out exactly once.
|
|
2006
|
+
*/
|
|
2007
|
+
attempt(method, path, options) {
|
|
2008
|
+
if (!options.retry) return this.read(method, path, options);
|
|
2009
|
+
const confirming = options.body?.confirm !== void 0;
|
|
2010
|
+
return this.retried(method, path, options, options.retry === "once" || confirming);
|
|
2011
|
+
}
|
|
2012
|
+
/**
|
|
2013
|
+
* {@link attempt}, answering a `412 CONFIRM_REQUIRED` with `passkey`'s
|
|
2014
|
+
* assertion over the node's challenge: the same body again, with `confirm`.
|
|
2015
|
+
* Without a passkey the `412` reaches the caller as {@link ConfirmRequired}.
|
|
2016
|
+
*/
|
|
2017
|
+
async confirmed(method, path, options) {
|
|
2018
|
+
const { passkey } = options;
|
|
2019
|
+
const signal = options.promptSignal ?? options.signal;
|
|
2020
|
+
try {
|
|
2021
|
+
return await this.attempt(method, path, options);
|
|
2022
|
+
} catch (error) {
|
|
2023
|
+
if (!(error instanceof ConfirmRequired) || !passkey) throw error;
|
|
2024
|
+
const d = error.details;
|
|
2025
|
+
let assertion;
|
|
2026
|
+
try {
|
|
2027
|
+
assertion = await passkey.get(fromB64url(d.challenge), d.rpId, d.allowCredentials, {
|
|
2028
|
+
userVerification: userVerificationOf(d.userVerification, options.userVerification ?? "required"),
|
|
2029
|
+
...signal ? { signal } : {}
|
|
2030
|
+
});
|
|
2031
|
+
} catch (declined) {
|
|
2032
|
+
throw promptFailure(declined);
|
|
2033
|
+
}
|
|
2034
|
+
const body = { ...options.body, confirm: { challengeId: d.challengeId, assertion } };
|
|
2035
|
+
return this.attempt(method, path, { ...options, body });
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
request(method, path, options = {}) {
|
|
2039
|
+
return this.send(method, relative(path), options);
|
|
2040
|
+
}
|
|
2041
|
+
json(method, path, options = {}) {
|
|
2042
|
+
return this.confirmed(method, relative(path), options);
|
|
2043
|
+
}
|
|
2044
|
+
async *events(path, options = {}) {
|
|
2045
|
+
const response = await this.send("GET", relative(path), options);
|
|
2046
|
+
if (!response.body) throw new CalimeroError(response.status, "EVENTS", "the event stream has no body");
|
|
2047
|
+
yield* sseFrames(response.body);
|
|
2048
|
+
}
|
|
2049
|
+
/**
|
|
2050
|
+
* `GET path` in the clear, from the node in use: only for the route a node
|
|
2051
|
+
* serves unsealed (`attestation`), and never with the credential.
|
|
2052
|
+
*/
|
|
2053
|
+
async unsealed(path, options = {}) {
|
|
2054
|
+
const response = await this.raw(`${this.nodeUrl}/${relative(path)}${queryString(options.query)}`);
|
|
2055
|
+
if (!response.ok) throw await refusal(response);
|
|
2056
|
+
return response;
|
|
2057
|
+
}
|
|
2058
|
+
/**
|
|
2059
|
+
* Swap the refresh token for a new session, on whichever node the transport
|
|
2060
|
+
* uses: any node of the cluster takes it. A refresh token is used once, so
|
|
2061
|
+
* requests made while one refresh is under way share it rather than sending
|
|
2062
|
+
* the same token again (which would revoke the session).
|
|
2063
|
+
*/
|
|
2064
|
+
refresh() {
|
|
2065
|
+
if (!this.refreshing) {
|
|
2066
|
+
const pending = this.exchangeRefresh();
|
|
2067
|
+
this.refreshing = pending;
|
|
2068
|
+
const clear = () => {
|
|
2069
|
+
if (this.refreshing === pending) this.refreshing = void 0;
|
|
2070
|
+
};
|
|
2071
|
+
pending.then(clear, clear);
|
|
2072
|
+
}
|
|
2073
|
+
return this.refreshing;
|
|
2074
|
+
}
|
|
2075
|
+
async exchangeRefresh() {
|
|
2076
|
+
const current = this.current;
|
|
2077
|
+
if (!current) throw new Error("no session to refresh");
|
|
2078
|
+
let session;
|
|
2079
|
+
for (let tried = 1; session === void 0; tried++) {
|
|
2080
|
+
try {
|
|
2081
|
+
session = await this.json("POST", "auth/refresh", { body: { refreshToken: current.refreshToken } });
|
|
2082
|
+
} catch (error) {
|
|
2083
|
+
if (!(error instanceof WrongNode) || tried >= this.nodeUrls.length) throw error;
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
if (this.current === current) this.useSession(session);
|
|
2087
|
+
return session;
|
|
2088
|
+
}
|
|
2089
|
+
};
|
|
2090
|
+
async function directoryTransport(options) {
|
|
2091
|
+
const random = options.random ?? Math.random;
|
|
2092
|
+
const fetcher = options.fetch ?? ((input, init) => fetch(input, init));
|
|
2093
|
+
const listing = await readDirectory(options.directory, options.cluster, fetcher);
|
|
2094
|
+
const [baseUrl, ...nodes] = orderNodes(listing.nodes, options.zone, random);
|
|
2095
|
+
const transport = new NodeTransport({ baseUrl, nodes, policy: options.policy, fetch: options.fetch, retry: options.retry });
|
|
2096
|
+
transport.useDirectory({
|
|
2097
|
+
directory: options.directory,
|
|
2098
|
+
cluster: options.cluster,
|
|
2099
|
+
zone: options.zone,
|
|
2100
|
+
random,
|
|
2101
|
+
due: Date.now() + listing.refreshAfterSeconds * 1e3
|
|
2102
|
+
});
|
|
2103
|
+
return transport;
|
|
2104
|
+
}
|
|
2105
|
+
|
|
1672
2106
|
// src/cloud-v2/recovery.ts
|
|
1673
2107
|
var RECOVERY_DOMAIN = "calimero.org-recovery.v1\0";
|
|
1674
2108
|
var MAX_RECOVERY_NONCE_BYTES = 64;
|
|
@@ -1857,114 +2291,36 @@ function upgradeConsent(app, toVersion, returnTo) {
|
|
|
1857
2291
|
return { kind: "upgrade", subject: { app, toVersion }, returnTo };
|
|
1858
2292
|
}
|
|
1859
2293
|
|
|
1860
|
-
// src/cloud-v2/client.ts
|
|
1861
|
-
function deployerKey(deployer) {
|
|
1862
|
-
return "account" in deployer ? `account:${deployer.account}` : `org:${deployer.org}`;
|
|
1863
|
-
}
|
|
1864
|
-
async function frontendManifest(origin, fetcher = fetch) {
|
|
1865
|
-
const base = origin ?? globalThis.location?.origin;
|
|
1866
|
-
if (!base) throw new Error("no page origin to read the frontend manifest from; pass `origin`");
|
|
1867
|
-
const response = await fetcher(new URL("/.well-known/calimero.json", base).toString());
|
|
1868
|
-
if (!response.ok) throw await refusal(response);
|
|
1869
|
-
const manifest = await response.json();
|
|
1870
|
-
return { ...manifest, deployments: manifest.deployments ?? [] };
|
|
1871
|
-
}
|
|
1872
|
-
function pageDeployment(href) {
|
|
1873
|
-
const at = href ?? globalThis.location?.href;
|
|
1874
|
-
if (!at) return null;
|
|
1875
|
-
try {
|
|
1876
|
-
return new URL(at).searchParams.get("deployment") || null;
|
|
1877
|
-
} catch {
|
|
1878
|
-
return null;
|
|
1879
|
-
}
|
|
1880
|
-
}
|
|
1881
|
-
function userVerificationOf(named, fallback) {
|
|
1882
|
-
return named === "required" || named === "preferred" || named === "discouraged" ? named : fallback;
|
|
1883
|
-
}
|
|
1884
|
-
function promptFailure(error) {
|
|
1885
|
-
const e = error;
|
|
1886
|
-
const name = e?.name;
|
|
1887
|
-
const message = typeof e?.message === "string" ? e.message : "";
|
|
1888
|
-
if (name === "InvalidStateError" || /not focused|already pending/i.test(message)) {
|
|
1889
|
-
return new ConfirmPromptBlocked(error);
|
|
1890
|
-
}
|
|
1891
|
-
if (name === "NotAllowedError" || name === "AbortError") return new ConfirmCancelled(error);
|
|
1892
|
-
return error;
|
|
1893
|
-
}
|
|
1894
|
-
function seg(value) {
|
|
1895
|
-
return encodeURIComponent(value);
|
|
1896
|
-
}
|
|
1897
|
-
function sealedError(error) {
|
|
1898
|
-
if (error instanceof SealedTransportError && error.status === 413) {
|
|
1899
|
-
return errorFrom(413, {
|
|
1900
|
-
error: "TOO_LARGE",
|
|
1901
|
-
message: error.message.replace(/^Sealed request refused: /, "")
|
|
1902
|
-
});
|
|
1903
|
-
}
|
|
1904
|
-
return error;
|
|
1905
|
-
}
|
|
1906
|
-
async function refusal(response) {
|
|
1907
|
-
const text = await response.text().catch(() => "");
|
|
1908
|
-
let body = {};
|
|
1909
|
-
try {
|
|
1910
|
-
body = text ? JSON.parse(text) : {};
|
|
1911
|
-
} catch {
|
|
1912
|
-
body = { message: text };
|
|
1913
|
-
}
|
|
1914
|
-
return errorFrom(response.status, body, response.headers.get("retry-after"));
|
|
1915
|
-
}
|
|
1916
|
-
async function* sseFrames(body) {
|
|
1917
|
-
const reader = body.getReader();
|
|
1918
|
-
const decoder = new TextDecoder();
|
|
1919
|
-
let buffer = "";
|
|
1920
|
-
for (; ; ) {
|
|
1921
|
-
const { value, done } = await reader.read();
|
|
1922
|
-
if (done) return;
|
|
1923
|
-
buffer += decoder.decode(value, { stream: true });
|
|
1924
|
-
let cut;
|
|
1925
|
-
while ((cut = buffer.indexOf("\n\n")) >= 0) {
|
|
1926
|
-
const frame = buffer.slice(0, cut);
|
|
1927
|
-
buffer = buffer.slice(cut + 2);
|
|
1928
|
-
let id = "";
|
|
1929
|
-
let kind = "";
|
|
1930
|
-
let data = "";
|
|
1931
|
-
for (const line of frame.split("\n")) {
|
|
1932
|
-
if (line.startsWith("id:")) id = line.slice(3).trim();
|
|
1933
|
-
else if (line.startsWith("event:")) kind = line.slice(6).trim();
|
|
1934
|
-
else if (line.startsWith("data:")) data += line.slice(5).trim();
|
|
1935
|
-
}
|
|
1936
|
-
if (!kind && !data) continue;
|
|
1937
|
-
let parsed = data;
|
|
1938
|
-
try {
|
|
1939
|
-
parsed = JSON.parse(data);
|
|
1940
|
-
} catch {
|
|
1941
|
-
}
|
|
1942
|
-
yield { id, kind, data: parsed };
|
|
1943
|
-
}
|
|
1944
|
-
}
|
|
2294
|
+
// src/cloud-v2/client.ts
|
|
2295
|
+
function deployerKey(deployer) {
|
|
2296
|
+
return "account" in deployer ? `account:${deployer.account}` : `org:${deployer.org}`;
|
|
1945
2297
|
}
|
|
1946
|
-
function
|
|
1947
|
-
|
|
2298
|
+
async function frontendManifest(origin, fetcher = fetch) {
|
|
2299
|
+
const base = origin ?? globalThis.location?.origin;
|
|
2300
|
+
if (!base) throw new Error("no page origin to read the frontend manifest from; pass `origin`");
|
|
2301
|
+
const response = await fetcher(new URL("/.well-known/calimero.json", base).toString());
|
|
2302
|
+
if (!response.ok) throw await refusal(response);
|
|
2303
|
+
const manifest = await response.json();
|
|
2304
|
+
return { ...manifest, deployments: manifest.deployments ?? [] };
|
|
1948
2305
|
}
|
|
1949
|
-
function
|
|
1950
|
-
|
|
2306
|
+
function pageDeployment(href) {
|
|
2307
|
+
const at = href ?? globalThis.location?.href;
|
|
2308
|
+
if (!at) return null;
|
|
2309
|
+
try {
|
|
2310
|
+
return new URL(at).searchParams.get("deployment") || null;
|
|
2311
|
+
} catch {
|
|
2312
|
+
return null;
|
|
2313
|
+
}
|
|
1951
2314
|
}
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
return Math.min(options.baseDelayMs * 2 ** retry, options.maxDelayMs);
|
|
2315
|
+
function seg(value) {
|
|
2316
|
+
return encodeURIComponent(value);
|
|
1955
2317
|
}
|
|
1956
|
-
function
|
|
1957
|
-
return
|
|
2318
|
+
function toHex(bytes) {
|
|
2319
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
1958
2320
|
}
|
|
1959
|
-
var RECOVERY_CEREMONIES = /* @__PURE__ */ new Set(["auth/recovery/begin", "auth/recovery/start", "auth/recovery/finish"]);
|
|
1960
2321
|
var CalimeroClient = class _CalimeroClient {
|
|
2322
|
+
/** A client of the nodes `options` names, or one sending through an existing `transport`. */
|
|
1961
2323
|
constructor(options) {
|
|
1962
|
-
/** The one in use: an index into {@link nodeUrls}. */
|
|
1963
|
-
this.at = 0;
|
|
1964
|
-
/** A sealed fetch per node, each attesting its own node on first use. */
|
|
1965
|
-
this.channels = /* @__PURE__ */ new Map();
|
|
1966
|
-
/** Rejections of the underlying `fetch` itself: the node could not be reached or dropped the connection. */
|
|
1967
|
-
this.networkFailures = /* @__PURE__ */ new WeakSet();
|
|
1968
2324
|
// ---------------------------------------------------------------- package origins
|
|
1969
2325
|
/**
|
|
1970
2326
|
* The web origins `{package}`'s managers registered so a frontend they host
|
|
@@ -1990,20 +2346,7 @@ var CalimeroClient = class _CalimeroClient {
|
|
|
1990
2346
|
),
|
|
1991
2347
|
remove: (pkg, origin, passkey) => this.confirmed("DELETE", `package-origins/${seg(pkg)}`, { origin }, passkey)
|
|
1992
2348
|
};
|
|
1993
|
-
|
|
1994
|
-
this.nodeUrls = [...new Set([options.baseUrl, ...options.nodes ?? []].map(trim))];
|
|
1995
|
-
this.policy = options.policy;
|
|
1996
|
-
this.retry = { ...DEFAULT_RETRY, ...options.retry };
|
|
1997
|
-
const given = options.fetch ?? ((input, init) => fetch(input, init));
|
|
1998
|
-
const raw = async (input, init) => {
|
|
1999
|
-
try {
|
|
2000
|
-
return await given(input, init);
|
|
2001
|
-
} catch (error) {
|
|
2002
|
-
if (error instanceof TypeError) this.networkFailures.add(error);
|
|
2003
|
-
throw error;
|
|
2004
|
-
}
|
|
2005
|
-
};
|
|
2006
|
-
this.raw = raw;
|
|
2349
|
+
this.transport = options instanceof NodeTransport ? options : new NodeTransport(options);
|
|
2007
2350
|
}
|
|
2008
2351
|
/**
|
|
2009
2352
|
* A client for the Calimero Cloud cluster named `cluster`: its node list read
|
|
@@ -2016,219 +2359,31 @@ var CalimeroClient = class _CalimeroClient {
|
|
|
2016
2359
|
* first request once the directory's `refreshAfterSeconds` has passed.
|
|
2017
2360
|
*/
|
|
2018
2361
|
static async connect(options) {
|
|
2019
|
-
const
|
|
2020
|
-
|
|
2021
|
-
const fetcher = options.fetch ?? ((input, init) => fetch(input, init));
|
|
2022
|
-
const listing = await readDirectory(directory, options.cluster, fetcher);
|
|
2023
|
-
const [baseUrl, ...nodes] = orderNodes(listing.nodes, options.zone, random);
|
|
2024
|
-
const client = new _CalimeroClient({ baseUrl, nodes, policy: options.policy, fetch: options.fetch, retry: options.retry });
|
|
2025
|
-
client.source = {
|
|
2026
|
-
directory,
|
|
2027
|
-
cluster: options.cluster,
|
|
2028
|
-
zone: options.zone,
|
|
2029
|
-
random,
|
|
2030
|
-
due: Date.now() + listing.refreshAfterSeconds * 1e3
|
|
2031
|
-
};
|
|
2032
|
-
return client;
|
|
2362
|
+
const transport = await directoryTransport({ ...options, directory: options.directory ?? DEFAULT_DIRECTORY });
|
|
2363
|
+
return new _CalimeroClient(transport);
|
|
2033
2364
|
}
|
|
2034
2365
|
/** The node requests go to now: `baseUrl`, until a `421 WRONG_NODE` or a failover moved the client on. */
|
|
2035
2366
|
get nodeUrl() {
|
|
2036
|
-
return this.
|
|
2367
|
+
return this.transport.nodeUrl;
|
|
2037
2368
|
}
|
|
2038
2369
|
/** Every node this client may use, in the order it moves through them. */
|
|
2039
2370
|
get nodes() {
|
|
2040
|
-
return
|
|
2041
|
-
}
|
|
2042
|
-
/** The sealed channel to `url`, attested under the client's policy before its first request. */
|
|
2043
|
-
channel(url2) {
|
|
2044
|
-
let sealed = this.channels.get(url2);
|
|
2045
|
-
if (!sealed) {
|
|
2046
|
-
sealed = createSealedFetch({
|
|
2047
|
-
baseUrl: url2,
|
|
2048
|
-
transportPublicKey: () => fetchAttestedKey(url2, this.policy, this.raw),
|
|
2049
|
-
fetch: this.raw
|
|
2050
|
-
});
|
|
2051
|
-
this.channels.set(url2, sealed);
|
|
2052
|
-
}
|
|
2053
|
-
return sealed;
|
|
2054
|
-
}
|
|
2055
|
-
/**
|
|
2056
|
-
* Move on from node `from` (the one that answered `421 WRONG_NODE`) to the
|
|
2057
|
-
* next one. False when there is no other. A request that saw the same node
|
|
2058
|
-
* refuse after another request moved on already does not move again.
|
|
2059
|
-
*/
|
|
2060
|
-
moveOn(from) {
|
|
2061
|
-
if (this.nodeUrls.length < 2) return false;
|
|
2062
|
-
if (this.nodeUrl === from) this.at = (this.at + 1) % this.nodeUrls.length;
|
|
2063
|
-
return true;
|
|
2064
|
-
}
|
|
2065
|
-
/**
|
|
2066
|
-
* Fail over from node `from`, where a request's retries ran out, to the next
|
|
2067
|
-
* node it has not `tried`. False when it has tried them all. A request whose
|
|
2068
|
-
* node another request moved away from already goes to that node, if untried.
|
|
2069
|
-
*/
|
|
2070
|
-
failOver(from, tried) {
|
|
2071
|
-
if (this.nodeUrl !== from && !tried.has(this.nodeUrl)) return true;
|
|
2072
|
-
const count = this.nodeUrls.length;
|
|
2073
|
-
for (let step = 1; step < count; step++) {
|
|
2074
|
-
const next = (this.at + step) % count;
|
|
2075
|
-
if (!tried.has(this.nodeUrls[next])) {
|
|
2076
|
-
this.at = next;
|
|
2077
|
-
this.reread();
|
|
2078
|
-
return true;
|
|
2079
|
-
}
|
|
2080
|
-
}
|
|
2081
|
-
return false;
|
|
2082
|
-
}
|
|
2083
|
-
/**
|
|
2084
|
-
* Read the node list again from the directory, in the background, for a
|
|
2085
|
-
* client made by {@link connect}: the node in use stays first, then the
|
|
2086
|
-
* directory's order; a node no longer listed is dropped. A failed read is
|
|
2087
|
-
* ignored. One read at a time.
|
|
2088
|
-
*/
|
|
2089
|
-
reread() {
|
|
2090
|
-
const source = this.source;
|
|
2091
|
-
if (!source || source.reading) return;
|
|
2092
|
-
source.reading = readDirectory(source.directory, source.cluster, this.raw).then((listing) => {
|
|
2093
|
-
const current = this.nodeUrl;
|
|
2094
|
-
const listed = orderNodes(listing.nodes, source.zone, source.random);
|
|
2095
|
-
this.nodeUrls = [current, ...listed.filter((url2) => url2 !== current)];
|
|
2096
|
-
this.at = 0;
|
|
2097
|
-
source.due = Date.now() + listing.refreshAfterSeconds * 1e3;
|
|
2098
|
-
}).catch(() => void 0).finally(() => {
|
|
2099
|
-
source.reading = void 0;
|
|
2100
|
-
});
|
|
2371
|
+
return this.transport.nodes;
|
|
2101
2372
|
}
|
|
2102
2373
|
/** Authenticate every request with an API key. */
|
|
2103
2374
|
useApiKey(key) {
|
|
2104
|
-
this.
|
|
2105
|
-
this.current = void 0;
|
|
2375
|
+
this.transport.useApiKey(key);
|
|
2106
2376
|
}
|
|
2107
2377
|
useSession(session) {
|
|
2108
|
-
this.
|
|
2109
|
-
this.current = session;
|
|
2378
|
+
this.transport.useSession(session);
|
|
2110
2379
|
}
|
|
2111
2380
|
get session() {
|
|
2112
|
-
return this.
|
|
2113
|
-
}
|
|
2114
|
-
/**
|
|
2115
|
-
* Send one request to the node in use, with the credential held now, and
|
|
2116
|
-
* answer the response, whatever its status. {@link send} judges it.
|
|
2117
|
-
*/
|
|
2118
|
-
async request(method, path, body, raw, contentType = "application/wasm", signal) {
|
|
2119
|
-
const headers = {};
|
|
2120
|
-
if (this.auth) headers.authorization = `Bearer ${this.auth}`;
|
|
2121
|
-
let payload;
|
|
2122
|
-
if (raw) {
|
|
2123
|
-
headers["content-type"] = contentType;
|
|
2124
|
-
payload = raw;
|
|
2125
|
-
} else if (body !== void 0) {
|
|
2126
|
-
headers["content-type"] = "application/json";
|
|
2127
|
-
payload = JSON.stringify(body);
|
|
2128
|
-
}
|
|
2129
|
-
if (this.source && Date.now() >= this.source.due) this.reread();
|
|
2130
|
-
const url2 = this.nodeUrl;
|
|
2131
|
-
try {
|
|
2132
|
-
return await this.channel(url2)(`${url2}/${path}`, { method, headers, body: payload, signal });
|
|
2133
|
-
} catch (error) {
|
|
2134
|
-
throw sealedError(error);
|
|
2135
|
-
}
|
|
2136
|
-
}
|
|
2137
|
-
/**
|
|
2138
|
-
* {@link request}, answering what a lost issuing node or a node on the
|
|
2139
|
-
* wrong release does to a session: a successful response, or the refusal as a
|
|
2140
|
-
* typed error ({@link errorFrom}).
|
|
2141
|
-
*
|
|
2142
|
-
* - `401 UNAUTHENTICATED` to a request that carried a session: the node that
|
|
2143
|
-
* signed the session may be gone, replaced, or out of reach of the node
|
|
2144
|
-
* asked, which any node of the cluster answers with a refresh. The client
|
|
2145
|
-
* refreshes once, shared by every request that needs it at the same time
|
|
2146
|
-
* ({@link refresh}), and repeats the request once. A refresh that is
|
|
2147
|
-
* refused surfaces as {@link LoginRequired}.
|
|
2148
|
-
* - `421 WRONG_NODE`: this node's release may not serve the account. The
|
|
2149
|
-
* client moves to the next node it knows and repeats the request once; with
|
|
2150
|
-
* none, the {@link WrongNode} reaches the caller.
|
|
2151
|
-
*
|
|
2152
|
-
* Repeating is safe even for a request that is not idempotent: the node
|
|
2153
|
-
* answers `401` and `421` when it judges the credential, before it reads what
|
|
2154
|
-
* the request asks, so the first attempt ran nothing. The exceptions are the
|
|
2155
|
-
* ceremonies that mint a session ({@link ceremony}), never repeated here.
|
|
2156
|
-
*/
|
|
2157
|
-
async send(method, path, body, raw, contentType, signal) {
|
|
2158
|
-
let refreshed = false;
|
|
2159
|
-
let moved = false;
|
|
2160
|
-
for (; ; ) {
|
|
2161
|
-
const [token, at] = [this.auth, this.nodeUrl];
|
|
2162
|
-
const response = await this.request(method, path, body, raw, contentType, signal);
|
|
2163
|
-
if (response.ok) return response;
|
|
2164
|
-
const error = await refusal(response);
|
|
2165
|
-
if (error instanceof WrongNode && !moved) {
|
|
2166
|
-
moved = true;
|
|
2167
|
-
if (this.moveOn(at) && !ceremony(path)) continue;
|
|
2168
|
-
} else if (error instanceof LoginRequired && !refreshed && this.mayRefresh(token, path)) {
|
|
2169
|
-
refreshed = true;
|
|
2170
|
-
await this.renew(token);
|
|
2171
|
-
continue;
|
|
2172
|
-
}
|
|
2173
|
-
throw error;
|
|
2174
|
-
}
|
|
2381
|
+
return this.transport.session;
|
|
2175
2382
|
}
|
|
2176
|
-
/**
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
/**
|
|
2181
|
-
* A fresh session in place of the one `token` belonged to: by the refresh
|
|
2182
|
-
* another request already made, or by one this request starts.
|
|
2183
|
-
*/
|
|
2184
|
-
async renew(token) {
|
|
2185
|
-
if (this.auth !== token) return;
|
|
2186
|
-
await this.refresh();
|
|
2187
|
-
}
|
|
2188
|
-
async json(method, path, body, raw, contentType) {
|
|
2189
|
-
const response = await this.send(method, path, body, raw, contentType);
|
|
2190
|
-
const text = await response.text();
|
|
2191
|
-
return text ? JSON.parse(text) : {};
|
|
2192
|
-
}
|
|
2193
|
-
/**
|
|
2194
|
-
* {@link json} for a call or a view (D70): while the node answers 503 or
|
|
2195
|
-
* cannot be reached, the same request, with the same idempotency key, is sent
|
|
2196
|
-
* again after {@link retryDelayMs} (or the pause the node asked for, if
|
|
2197
|
-
* longer), at most `retries` times. Every other answer is final.
|
|
2198
|
-
*
|
|
2199
|
-
* When a node's retries run out, the request fails over to the next node the
|
|
2200
|
-
* client knows and its retries start again there, with the same key: the
|
|
2201
|
-
* key is kept in the app's replicated log, so the cluster answers it once
|
|
2202
|
-
* whichever node it reaches. Each node is tried once per request. The last
|
|
2203
|
-
* failure surfaces as {@link UnavailableError}: the node's 503, or reason
|
|
2204
|
-
* `network` for a connection that failed, whose `committed` is `'unknown'`.
|
|
2205
|
-
*
|
|
2206
|
-
* `once` sends the request exactly once, to the node in use (the confirmed
|
|
2207
|
-
* attempt of {@link callConfirmed}, whose challenge is single-use).
|
|
2208
|
-
*/
|
|
2209
|
-
async retried(method, path, body, once = false) {
|
|
2210
|
-
const retries = once ? 0 : this.retry.retries;
|
|
2211
|
-
const tried = /* @__PURE__ */ new Set();
|
|
2212
|
-
for (; ; ) {
|
|
2213
|
-
let unavailable;
|
|
2214
|
-
let node = this.nodeUrl;
|
|
2215
|
-
for (let attempt = 0; ; attempt++) {
|
|
2216
|
-
node = this.nodeUrl;
|
|
2217
|
-
tried.add(node);
|
|
2218
|
-
try {
|
|
2219
|
-
return await this.json(method, path, body);
|
|
2220
|
-
} catch (error) {
|
|
2221
|
-
if (error instanceof UnavailableError) unavailable = error;
|
|
2222
|
-
else if (error instanceof TypeError && this.networkFailures.has(error)) {
|
|
2223
|
-
unavailable = new UnavailableError("NETWORK", `the node could not be reached: ${error.message}`, {}, void 0, error);
|
|
2224
|
-
} else throw error;
|
|
2225
|
-
}
|
|
2226
|
-
if (attempt >= retries) break;
|
|
2227
|
-
const backoff = retryDelayMs(attempt, this.retry);
|
|
2228
|
-
await new Promise((resolve) => setTimeout(resolve, Math.max(backoff, (unavailable.retryAfter ?? 0) * 1e3)));
|
|
2229
|
-
}
|
|
2230
|
-
if (once || !this.failOver(node, tried)) throw unavailable;
|
|
2231
|
-
}
|
|
2383
|
+
/** A JSON request, or a raw one when `raw` is given (as `contentType`, the transport's default when absent). */
|
|
2384
|
+
json(method, path, body, raw, contentType) {
|
|
2385
|
+
const options = raw ? { bytes: raw, ...contentType === void 0 ? {} : { contentType } } : { body };
|
|
2386
|
+
return this.transport.json(method, path, options);
|
|
2232
2387
|
}
|
|
2233
2388
|
post(path, body = {}) {
|
|
2234
2389
|
return this.json("POST", path, body);
|
|
@@ -2272,7 +2427,6 @@ var CalimeroClient = class _CalimeroClient {
|
|
|
2272
2427
|
"auth/passkey/register/finish",
|
|
2273
2428
|
{ challengeId: begin.challengeId, registration, ...invite },
|
|
2274
2429
|
passkey,
|
|
2275
|
-
void 0,
|
|
2276
2430
|
"preferred",
|
|
2277
2431
|
signal
|
|
2278
2432
|
);
|
|
@@ -2313,7 +2467,7 @@ var CalimeroClient = class _CalimeroClient {
|
|
|
2313
2467
|
const page = globalThis.location?.origin;
|
|
2314
2468
|
if (!page || page === "null") return void 0;
|
|
2315
2469
|
const nodes = new Set(
|
|
2316
|
-
this.
|
|
2470
|
+
this.transport.nodes.map((url2) => {
|
|
2317
2471
|
try {
|
|
2318
2472
|
return new URL(url2).origin;
|
|
2319
2473
|
} catch {
|
|
@@ -2341,29 +2495,7 @@ var CalimeroClient = class _CalimeroClient {
|
|
|
2341
2495
|
* same token again (which would revoke the session).
|
|
2342
2496
|
*/
|
|
2343
2497
|
refresh() {
|
|
2344
|
-
|
|
2345
|
-
const pending = this.exchangeRefresh();
|
|
2346
|
-
this.refreshing = pending;
|
|
2347
|
-
const clear = () => {
|
|
2348
|
-
if (this.refreshing === pending) this.refreshing = void 0;
|
|
2349
|
-
};
|
|
2350
|
-
pending.then(clear, clear);
|
|
2351
|
-
}
|
|
2352
|
-
return this.refreshing;
|
|
2353
|
-
}
|
|
2354
|
-
async exchangeRefresh() {
|
|
2355
|
-
const current = this.current;
|
|
2356
|
-
if (!current) throw new Error("no session to refresh");
|
|
2357
|
-
let session;
|
|
2358
|
-
for (let tried = 1; session === void 0; tried++) {
|
|
2359
|
-
try {
|
|
2360
|
-
session = await this.post("auth/refresh", { refreshToken: current.refreshToken });
|
|
2361
|
-
} catch (error) {
|
|
2362
|
-
if (!(error instanceof WrongNode) || tried >= this.nodeUrls.length) throw error;
|
|
2363
|
-
}
|
|
2364
|
-
}
|
|
2365
|
-
if (this.current === current) this.useSession(session);
|
|
2366
|
-
return session;
|
|
2498
|
+
return this.transport.refresh();
|
|
2367
2499
|
}
|
|
2368
2500
|
/**
|
|
2369
2501
|
* End this session. The client is signed out whether or not the node
|
|
@@ -2423,8 +2555,7 @@ var CalimeroClient = class _CalimeroClient {
|
|
|
2423
2555
|
try {
|
|
2424
2556
|
await this.post(path);
|
|
2425
2557
|
} finally {
|
|
2426
|
-
this.
|
|
2427
|
-
this.current = void 0;
|
|
2558
|
+
this.transport.signOut();
|
|
2428
2559
|
}
|
|
2429
2560
|
}
|
|
2430
2561
|
/** Who the credential is: the account, how it signed in, and the package (`app`) and `origin` a session is bound to. */
|
|
@@ -2530,8 +2661,7 @@ var CalimeroClient = class _CalimeroClient {
|
|
|
2530
2661
|
*/
|
|
2531
2662
|
async attestation() {
|
|
2532
2663
|
const nonce2 = toHex(crypto.getRandomValues(new Uint8Array(32)));
|
|
2533
|
-
const response = await this.
|
|
2534
|
-
if (!response.ok) throw await refusal(response);
|
|
2664
|
+
const response = await this.transport.unsealed("attestation", { query: { nonce: nonce2 } });
|
|
2535
2665
|
return await response.json();
|
|
2536
2666
|
}
|
|
2537
2667
|
/** The account's API keys (never the keys themselves). */
|
|
@@ -2592,8 +2722,9 @@ var CalimeroClient = class _CalimeroClient {
|
|
|
2592
2722
|
* app; `application/octet-stream` otherwise.
|
|
2593
2723
|
*/
|
|
2594
2724
|
async blobContent(app, id, options = {}) {
|
|
2595
|
-
const
|
|
2596
|
-
|
|
2725
|
+
const response = await this.transport.request("GET", `apps/${seg(app)}/blobs/${seg(id)}`, {
|
|
2726
|
+
query: { grant: options.grant }
|
|
2727
|
+
});
|
|
2597
2728
|
return {
|
|
2598
2729
|
data: new Uint8Array(await response.arrayBuffer()),
|
|
2599
2730
|
contentType: response.headers.get("content-type") ?? "application/octet-stream"
|
|
@@ -2738,23 +2869,12 @@ var CalimeroClient = class _CalimeroClient {
|
|
|
2738
2869
|
deleteConfirmed(path, passkey, body = {}) {
|
|
2739
2870
|
return this.confirmed("DELETE", path, body, passkey);
|
|
2740
2871
|
}
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
let assertion;
|
|
2748
|
-
try {
|
|
2749
|
-
assertion = await passkey.get(fromB64url(d.challenge), d.rpId, d.allowCredentials, {
|
|
2750
|
-
userVerification: userVerificationOf(d.userVerification, userVerification),
|
|
2751
|
-
...signal ? { signal } : {}
|
|
2752
|
-
});
|
|
2753
|
-
} catch (declined) {
|
|
2754
|
-
throw promptFailure(declined);
|
|
2755
|
-
}
|
|
2756
|
-
return send({ ...body, confirm: { challengeId: d.challengeId, assertion } });
|
|
2757
|
-
}
|
|
2872
|
+
/**
|
|
2873
|
+
* `body` to `path`, answering a `412 CONFIRM_REQUIRED` with `passkey` when one
|
|
2874
|
+
* is given; `signal` aborts only the passkey prompt.
|
|
2875
|
+
*/
|
|
2876
|
+
confirmed(method, path, body, passkey, userVerification, signal) {
|
|
2877
|
+
return this.transport.json(method, path, { body, passkey, userVerification, promptSignal: signal });
|
|
2758
2878
|
}
|
|
2759
2879
|
/**
|
|
2760
2880
|
* Create an app instance from a bundle hash, or from a package release
|
|
@@ -2778,7 +2898,7 @@ var CalimeroClient = class _CalimeroClient {
|
|
|
2778
2898
|
*/
|
|
2779
2899
|
call(app, method, args = {}, idempotencyKey) {
|
|
2780
2900
|
const key = idempotencyKey ?? newIdempotencyKey();
|
|
2781
|
-
return this.
|
|
2901
|
+
return this.transport.json("POST", `apps/${seg(app)}/intents`, { body: { method, args, idempotencyKey: key }, retry: true });
|
|
2782
2902
|
}
|
|
2783
2903
|
/**
|
|
2784
2904
|
* Call a method that needs the account holder's approval: the node returns a
|
|
@@ -2788,32 +2908,23 @@ var CalimeroClient = class _CalimeroClient {
|
|
|
2788
2908
|
async callConfirmed(app, method, args, passkey, idempotencyKey) {
|
|
2789
2909
|
const path = `apps/${seg(app)}/intents`;
|
|
2790
2910
|
const key = idempotencyKey ?? newIdempotencyKey();
|
|
2791
|
-
return this.
|
|
2792
|
-
"POST",
|
|
2793
|
-
path,
|
|
2794
|
-
{ method, args, idempotencyKey: key },
|
|
2795
|
-
passkey,
|
|
2796
|
-
(body) => this.retried("POST", path, body, body.confirm !== void 0)
|
|
2797
|
-
);
|
|
2911
|
+
return this.transport.json("POST", path, { body: { method, args, idempotencyKey: key }, passkey, retry: true });
|
|
2798
2912
|
}
|
|
2799
2913
|
/** Run a read-only method: strong by default, or stale from any replica. */
|
|
2800
2914
|
view(app, method, args = {}, options = {}) {
|
|
2801
|
-
return this.
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2915
|
+
return this.transport.json("POST", `apps/${seg(app)}/views`, {
|
|
2916
|
+
body: {
|
|
2917
|
+
method,
|
|
2918
|
+
args,
|
|
2919
|
+
consistency: options.stale ? "stale" : "strong",
|
|
2920
|
+
minIndex: options.minIndex ?? 0
|
|
2921
|
+
},
|
|
2922
|
+
retry: true
|
|
2806
2923
|
});
|
|
2807
2924
|
}
|
|
2808
2925
|
/** Committed events from index `from`, then live, until `signal` aborts. */
|
|
2809
2926
|
events(app, from = 0, signal) {
|
|
2810
|
-
return this.
|
|
2811
|
-
}
|
|
2812
|
-
/** A server-sent event stream on `path`, sealed like every other answer. */
|
|
2813
|
-
async *stream(path, signal) {
|
|
2814
|
-
const response = await this.send("GET", path, void 0, void 0, void 0, signal);
|
|
2815
|
-
if (!response.body) throw new CalimeroError(response.status, "EVENTS", "the event stream has no body");
|
|
2816
|
-
yield* sseFrames(response.body);
|
|
2927
|
+
return this.transport.events(`apps/${seg(app)}/events`, { query: { from }, signal });
|
|
2817
2928
|
}
|
|
2818
2929
|
// ---------------------------------------------------------------- presence
|
|
2819
2930
|
/**
|
|
@@ -2834,7 +2945,7 @@ var CalimeroClient = class _CalimeroClient {
|
|
|
2834
2945
|
* entries present), then a `presence` event (`data` one entry) per change.
|
|
2835
2946
|
*/
|
|
2836
2947
|
presenceStream(app, signal) {
|
|
2837
|
-
return this.
|
|
2948
|
+
return this.transport.events(`apps/${seg(app)}/presence/stream`, { signal });
|
|
2838
2949
|
}
|
|
2839
2950
|
// ---------------------------------------------------------------- aliases
|
|
2840
2951
|
/** Name an app (its id or context id) for this account. */
|
|
@@ -3849,6 +3960,7 @@ export {
|
|
|
3849
3960
|
DEFAULT_DIRECTORY,
|
|
3850
3961
|
DEFAULT_ORG_CAPABILITIES,
|
|
3851
3962
|
DEFAULT_RELEASE_MIRROR,
|
|
3963
|
+
DEFAULT_RETRY,
|
|
3852
3964
|
DirectoryError,
|
|
3853
3965
|
GITHUB_RELEASES,
|
|
3854
3966
|
HANDSHAKE_PATH,
|
|
@@ -3859,6 +3971,7 @@ export {
|
|
|
3859
3971
|
MAX_REPORT_PAGE,
|
|
3860
3972
|
MOCK_QUOTE_HEADER,
|
|
3861
3973
|
NODE_RELEASE_SIGNER,
|
|
3974
|
+
NodeTransport,
|
|
3862
3975
|
NotFound,
|
|
3863
3976
|
NotServing,
|
|
3864
3977
|
ORG_CAPABILITIES,
|
|
@@ -3896,6 +4009,7 @@ export {
|
|
|
3896
4009
|
createSealedFetch,
|
|
3897
4010
|
createSignedReleaseVerifier,
|
|
3898
4011
|
deployerKey,
|
|
4012
|
+
directoryTransport,
|
|
3899
4013
|
errorFrom,
|
|
3900
4014
|
fetchAttestedKey,
|
|
3901
4015
|
fetchGithubNodeRelease,
|
|
@@ -3904,6 +4018,7 @@ export {
|
|
|
3904
4018
|
fromB64url,
|
|
3905
4019
|
frontendManifest,
|
|
3906
4020
|
generateOwnerKey,
|
|
4021
|
+
newIdempotencyKey,
|
|
3907
4022
|
newRecoverySeed,
|
|
3908
4023
|
orgCapabilities,
|
|
3909
4024
|
ownerPublicKey,
|
|
@@ -3915,13 +4030,18 @@ export {
|
|
|
3915
4030
|
provisionInfo,
|
|
3916
4031
|
publisherBindingBytes,
|
|
3917
4032
|
publisherBindingMessage,
|
|
4033
|
+
queryString,
|
|
3918
4034
|
recoveryMessage,
|
|
3919
4035
|
recoveryPublicKey,
|
|
3920
4036
|
recoverySeedText,
|
|
4037
|
+
refusal,
|
|
3921
4038
|
releaseAgent,
|
|
3922
4039
|
releaseConsent,
|
|
3923
4040
|
releaseNotesUrl,
|
|
4041
|
+
retryDelayMs,
|
|
4042
|
+
routePath,
|
|
3924
4043
|
signRecovery,
|
|
4044
|
+
sseFrames,
|
|
3925
4045
|
stalledBy,
|
|
3926
4046
|
transportKeyBinding,
|
|
3927
4047
|
trustSignedRelease,
|