@bounded-sh/core 0.0.41 → 0.0.44
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/client/config.d.ts +20 -1
- package/dist/client/operations.d.ts +3 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +1307 -202
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1301 -203
- package/dist/index.mjs.map +1 -1
- package/dist/types.d.ts +29 -5
- package/dist/utils/auth-api.d.ts +10 -1
- package/dist/utils/evm/evm-transaction-validation.d.ts +46 -0
- package/dist/utils/evm/evm-utils.d.ts +40 -0
- package/dist/utils/server-session-manager.d.ts +7 -0
- package/dist/utils/utils.d.ts +3 -1
- package/package.json +15 -16
package/dist/index.mjs
CHANGED
|
@@ -31,6 +31,10 @@ let clientConfig = {
|
|
|
31
31
|
isServer: false,
|
|
32
32
|
};
|
|
33
33
|
let isInitialized = false;
|
|
34
|
+
// Callers awaiting getConfig() before init() completes. Resolved synchronously
|
|
35
|
+
// by init() — the previous 50ms setInterval poll added up to 50ms of latency
|
|
36
|
+
// to the first SDK operation after init for no reason.
|
|
37
|
+
let initWaiters = [];
|
|
34
38
|
const BOUNDED_NETWORKS = {
|
|
35
39
|
'bounded-staging': {
|
|
36
40
|
wsApiUrl: 'wss://realtime-staging.bounded.sh',
|
|
@@ -99,23 +103,17 @@ function init(newConfig) {
|
|
|
99
103
|
const preset = newConfig.network ? BOUNDED_NETWORKS[newConfig.network] : {};
|
|
100
104
|
clientConfig = Object.assign(Object.assign(Object.assign({}, clientConfig), preset), newConfig);
|
|
101
105
|
isInitialized = true;
|
|
106
|
+
const waiters = initWaiters;
|
|
107
|
+
initWaiters = [];
|
|
108
|
+
for (const wake of waiters)
|
|
109
|
+
wake();
|
|
102
110
|
resolve();
|
|
103
111
|
});
|
|
104
112
|
}
|
|
105
113
|
function waitForInitialization() {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}
|
|
110
|
-
else {
|
|
111
|
-
const interval = setInterval(() => {
|
|
112
|
-
if (isInitialized) {
|
|
113
|
-
clearInterval(interval);
|
|
114
|
-
resolve();
|
|
115
|
-
}
|
|
116
|
-
}, 50);
|
|
117
|
-
}
|
|
118
|
-
});
|
|
114
|
+
if (isInitialized)
|
|
115
|
+
return Promise.resolve();
|
|
116
|
+
return new Promise((resolve) => { initWaiters.push(resolve); });
|
|
119
117
|
}
|
|
120
118
|
async function getConfig() {
|
|
121
119
|
await waitForInitialization();
|
|
@@ -2801,8 +2799,24 @@ async function genAuthNonce() {
|
|
|
2801
2799
|
// stamps an is_anonymous claim. Without this a guest would be indistinguishable
|
|
2802
2800
|
// from 'phantom' and couldn't be gated or flagged.
|
|
2803
2801
|
const SIGNATURE_AUTH_METHODS = new Set(['phantom', 'mobile-wallet-adapter', 'guest']);
|
|
2804
|
-
|
|
2805
|
-
|
|
2802
|
+
// Maps a client authMethod to the /session issuer's signature discriminator when
|
|
2803
|
+
// they differ. EVM WALLET LOGIN (config.authMethod 'evm-wallet') exchanges a SIWE
|
|
2804
|
+
// (EIP-4361 personal_sign, secp256k1) signature, which the bounded-auth issuer
|
|
2805
|
+
// routes on authMethod:"siwe" (index.ts /session → parseSiweMessage/recoverSiweSigner)
|
|
2806
|
+
// and mints under custom:evmWalletAddress. The EVM provider also passes 'siwe'
|
|
2807
|
+
// explicitly (authMethodOverride), so this map is the belt-and-suspenders path.
|
|
2808
|
+
const SESSION_METHOD_ALIASES = { 'evm-wallet': 'siwe' };
|
|
2809
|
+
/**
|
|
2810
|
+
* Exchange a wallet signature for a Bounded session at the /session issuer.
|
|
2811
|
+
*
|
|
2812
|
+
* @param authMethodOverride the exact /session discriminator to send (e.g. 'siwe'
|
|
2813
|
+
* for the SIWE/EVM path). When omitted the method is derived from config: an
|
|
2814
|
+
* aliased method (evm-wallet → siwe) maps through SESSION_METHOD_ALIASES, a
|
|
2815
|
+
* Solana wallet/guest method passes through verbatim, and anything else
|
|
2816
|
+
* defaults to the canonical 'phantom' the issuer accepts for a signature.
|
|
2817
|
+
*/
|
|
2818
|
+
async function createSessionWithSignature(address, message, signature, authMethodOverride) {
|
|
2819
|
+
var _a, _b, _c;
|
|
2806
2820
|
const client = await getAxiosAuthClient();
|
|
2807
2821
|
const config = await getConfig();
|
|
2808
2822
|
const appId = config.appId;
|
|
@@ -2811,7 +2825,7 @@ async function createSessionWithSignature(address, message, signature) {
|
|
|
2811
2825
|
// canonical Solana wallet method 'phantom' that every /session issuer accepts
|
|
2812
2826
|
// for a signature exchange. This is what makes the server SDK + guest device
|
|
2813
2827
|
// keypair work under the email-default config.
|
|
2814
|
-
const authMethod = SIGNATURE_AUTH_METHODS.has(config.authMethod) ? config.authMethod : 'phantom';
|
|
2828
|
+
const authMethod = (_a = authMethodOverride !== null && authMethodOverride !== void 0 ? authMethodOverride : SESSION_METHOD_ALIASES[config.authMethod]) !== null && _a !== void 0 ? _a : (SIGNATURE_AUTH_METHODS.has(config.authMethod) ? config.authMethod : 'phantom');
|
|
2815
2829
|
try {
|
|
2816
2830
|
const response = await client.post('/session', {
|
|
2817
2831
|
appId,
|
|
@@ -2826,11 +2840,11 @@ async function createSessionWithSignature(address, message, signature) {
|
|
|
2826
2840
|
// Surface the issuer's actionable message (e.g. "Anonymous auth is not enabled for
|
|
2827
2841
|
// this app. Add \"auth\": { \"anonymous\": true } to policy.json and redeploy.")
|
|
2828
2842
|
// instead of axios's generic "Request failed with status code 403".
|
|
2829
|
-
const d = (
|
|
2843
|
+
const d = (_b = err === null || err === void 0 ? void 0 : err.response) === null || _b === void 0 ? void 0 : _b.data;
|
|
2830
2844
|
if (d && (d.message || d.error)) {
|
|
2831
2845
|
const e = new Error(String(d.message || d.error));
|
|
2832
2846
|
e.code = d.error;
|
|
2833
|
-
e.status = (
|
|
2847
|
+
e.status = (_c = err === null || err === void 0 ? void 0 : err.response) === null || _c === void 0 ? void 0 : _c.status;
|
|
2834
2848
|
e.hint = d.hint;
|
|
2835
2849
|
throw e;
|
|
2836
2850
|
}
|
|
@@ -2909,7 +2923,14 @@ async function revokeSession(refreshToken, issuer) {
|
|
|
2909
2923
|
await axios.post(`${base}/session/revoke`, {
|
|
2910
2924
|
refreshToken,
|
|
2911
2925
|
appId: config.appId,
|
|
2912
|
-
}, {
|
|
2926
|
+
}, {
|
|
2927
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2928
|
+
timeout: 15000,
|
|
2929
|
+
// Preserve the transport error for programmatic diagnostics while
|
|
2930
|
+
// ensuring AxiosError.toJSON() cannot serialize the live token body
|
|
2931
|
+
// into logs, telemetry, UI state, or crash reports.
|
|
2932
|
+
redact: ['data'],
|
|
2933
|
+
});
|
|
2913
2934
|
}
|
|
2914
2935
|
catch (err) {
|
|
2915
2936
|
throw new SessionRevokeError(revokeFailureMessage(err), err);
|
|
@@ -3598,12 +3619,13 @@ const NO_AMBIENT_SERVER_SESSION = "Server sessions are not process-global. Use c
|
|
|
3598
3619
|
/* SESSION MANAGER */
|
|
3599
3620
|
/* ------------------------------------------------------------------ */
|
|
3600
3621
|
class ServerSessionManager {
|
|
3601
|
-
constructor(keypair) {
|
|
3622
|
+
constructor(keypair, sessionFactory) {
|
|
3602
3623
|
/* Private cache (lives for the life of the instance) */
|
|
3603
3624
|
this.session = null;
|
|
3604
3625
|
/* Coalesce concurrent getSession() calls into a single in-flight request */
|
|
3605
3626
|
this.pendingSession = null;
|
|
3606
3627
|
this.keypair = keypair !== null && keypair !== void 0 ? keypair : null;
|
|
3628
|
+
this.sessionFactory = sessionFactory !== null && sessionFactory !== void 0 ? sessionFactory : null;
|
|
3607
3629
|
}
|
|
3608
3630
|
/**
|
|
3609
3631
|
* Create a session manager for a specific keypair.
|
|
@@ -3612,10 +3634,30 @@ class ServerSessionManager {
|
|
|
3612
3634
|
static forKeypair(keypair) {
|
|
3613
3635
|
return new ServerSessionManager(keypair);
|
|
3614
3636
|
}
|
|
3637
|
+
/**
|
|
3638
|
+
* Create an isolated server session manager for a non-Solana signer (currently
|
|
3639
|
+
* EVM/SIWE). The factory must perform the signature exchange and return the
|
|
3640
|
+
* canonical signer address with its tokens; no signer state becomes global.
|
|
3641
|
+
*/
|
|
3642
|
+
static forSessionFactory(factory) {
|
|
3643
|
+
if (typeof factory !== 'function')
|
|
3644
|
+
throw new Error('A server session factory is required');
|
|
3645
|
+
return new ServerSessionManager(undefined, factory);
|
|
3646
|
+
}
|
|
3615
3647
|
/* ---------------------------------------------- *
|
|
3616
3648
|
* Session creation (instance method)
|
|
3617
3649
|
* ---------------------------------------------- */
|
|
3618
3650
|
async createSession() {
|
|
3651
|
+
if (this.sessionFactory) {
|
|
3652
|
+
const session = await this.sessionFactory();
|
|
3653
|
+
if (!session || typeof session.address !== 'string' || !session.address ||
|
|
3654
|
+
typeof session.idToken !== 'string' || !session.idToken ||
|
|
3655
|
+
typeof session.accessToken !== 'string' || !session.accessToken ||
|
|
3656
|
+
typeof session.refreshToken !== 'string' || !session.refreshToken) {
|
|
3657
|
+
throw new Error('Server signer session factory returned an invalid session');
|
|
3658
|
+
}
|
|
3659
|
+
return session;
|
|
3660
|
+
}
|
|
3619
3661
|
if (!this.keypair) {
|
|
3620
3662
|
throw new Error(NO_AMBIENT_SERVER_SESSION);
|
|
3621
3663
|
}
|
|
@@ -3719,6 +3761,133 @@ function decodeBase64Url(input) {
|
|
|
3719
3761
|
return safeAtob(b64);
|
|
3720
3762
|
}
|
|
3721
3763
|
|
|
3764
|
+
// evm-utils.ts — the EVM twin of sol/sol-utils.ts's genSolanaMessage. It builds
|
|
3765
|
+
// the canonical EIP-4361 (Sign-In with Ethereum, SIWE) prompt the bounded-auth
|
|
3766
|
+
// issuer verifies, and normalizes EVM addresses to the single canonical form the
|
|
3767
|
+
// identity layer matches on.
|
|
3768
|
+
//
|
|
3769
|
+
// The SIWE message MUST match bounded-auth's parser byte-for-byte
|
|
3770
|
+
// (packages/cdk/cloudflare/bounded-auth/src/verify-siwe.ts → parseSiweMessage):
|
|
3771
|
+
// <domain> wants you to sign in with your Ethereum account:
|
|
3772
|
+
// <address>
|
|
3773
|
+
// <blank>
|
|
3774
|
+
// <statement>
|
|
3775
|
+
// <blank>
|
|
3776
|
+
// URI: <uri>
|
|
3777
|
+
// Version: 1
|
|
3778
|
+
// Chain ID: <chainId>
|
|
3779
|
+
// Nonce: <nonce>
|
|
3780
|
+
// Issued At: <ISO8601>
|
|
3781
|
+
// Expiration Time: <ISO8601>
|
|
3782
|
+
// Resources:
|
|
3783
|
+
// - bounded:app:<appId>
|
|
3784
|
+
//
|
|
3785
|
+
// Bindings the verifier enforces (so we produce them exactly): the address is
|
|
3786
|
+
// lowercased (identity match is case-sensitive, so lowercase-at-boundary is
|
|
3787
|
+
// mandatory), the single `bounded:app:<appId>` resource binds the signature to
|
|
3788
|
+
// THIS app (a signature for one app must not mint a session for another — the
|
|
3789
|
+
// SIWE analog of genSolanaMessage's `App ID:` line), and Expiration Time is
|
|
3790
|
+
// REQUIRED and must fall within the issuer's 10-minute max validity window.
|
|
3791
|
+
/** Default statement — kept identical to genSolanaMessage's for a consistent prompt. */
|
|
3792
|
+
const DEFAULT_STATEMENT = 'Sign this message to authenticate with our application.';
|
|
3793
|
+
/** Match bounded-auth's MAX_AUTH_WINDOW_MS (10 min); we default to a 5-min window. */
|
|
3794
|
+
const DEFAULT_VALIDITY_MS = 5 * 60 * 1000;
|
|
3795
|
+
/**
|
|
3796
|
+
* Build the canonical EIP-4361 SIWE message the bounded-auth issuer verifies.
|
|
3797
|
+
*
|
|
3798
|
+
* @param address the connected EVM account (lowercased into the prompt).
|
|
3799
|
+
* @param nonce single-use nonce from genAuthNonce().
|
|
3800
|
+
* @param appId the target app id (bound as the sole `bounded:app:<appId>` resource).
|
|
3801
|
+
* @param domain RFC-3986 authority of the requesting site (e.g. window.location.host).
|
|
3802
|
+
* @param uri the requesting URI; its origin is bound to the domain and actual browser Origin.
|
|
3803
|
+
*/
|
|
3804
|
+
function genSiweMessage(address, nonce, appId, domain, uri, options = {}) {
|
|
3805
|
+
var _a, _b, _c, _d, _e;
|
|
3806
|
+
const addr = normalizeEvmAddress(address);
|
|
3807
|
+
if (!addr)
|
|
3808
|
+
throw new Error('SIWE address must be a 20-byte 0x-prefixed EVM address');
|
|
3809
|
+
const statement = (_a = options.statement) !== null && _a !== void 0 ? _a : DEFAULT_STATEMENT;
|
|
3810
|
+
const version = (_b = options.version) !== null && _b !== void 0 ? _b : '1';
|
|
3811
|
+
const chainId = (_c = options.chainId) !== null && _c !== void 0 ? _c : 1;
|
|
3812
|
+
if (statement !== DEFAULT_STATEMENT)
|
|
3813
|
+
throw new Error('SIWE statement must use the canonical Bounded authentication text');
|
|
3814
|
+
if (version !== '1')
|
|
3815
|
+
throw new Error('SIWE version must be 1');
|
|
3816
|
+
if (!Number.isSafeInteger(chainId) || chainId <= 0)
|
|
3817
|
+
throw new Error('SIWE chainId must be a positive safe integer');
|
|
3818
|
+
if (!/^[A-Za-z0-9]{8,}$/.test(nonce))
|
|
3819
|
+
throw new Error('SIWE nonce must be at least 8 alphanumeric characters');
|
|
3820
|
+
if (!appId || /[\r\n]/.test(appId))
|
|
3821
|
+
throw new Error('SIWE appId is required and must be single-line');
|
|
3822
|
+
if (!domain || domain !== domain.trim() || /[\s/@?#\\]/.test(domain))
|
|
3823
|
+
throw new Error('SIWE domain must be an RFC-3986 authority');
|
|
3824
|
+
try {
|
|
3825
|
+
const parsedAuthority = new URL(`https://${domain}`);
|
|
3826
|
+
if (parsedAuthority.username || parsedAuthority.password || parsedAuthority.pathname !== '/' ||
|
|
3827
|
+
parsedAuthority.search || parsedAuthority.hash || parsedAuthority.host.toLowerCase() !== domain) {
|
|
3828
|
+
throw new Error('non-canonical authority');
|
|
3829
|
+
}
|
|
3830
|
+
}
|
|
3831
|
+
catch (_f) {
|
|
3832
|
+
throw new Error('SIWE domain must be a canonical lowercase RFC-3986 authority');
|
|
3833
|
+
}
|
|
3834
|
+
let parsedUri;
|
|
3835
|
+
try {
|
|
3836
|
+
parsedUri = new URL(uri);
|
|
3837
|
+
}
|
|
3838
|
+
catch (_g) {
|
|
3839
|
+
throw new Error('SIWE URI must be an absolute URL');
|
|
3840
|
+
}
|
|
3841
|
+
const local = parsedUri.hostname === 'localhost' || parsedUri.hostname === '127.0.0.1' || parsedUri.hostname === '[::1]';
|
|
3842
|
+
if (parsedUri.protocol !== 'https:' && !(local && parsedUri.protocol === 'http:')) {
|
|
3843
|
+
throw new Error('SIWE URI must use HTTPS (HTTP is allowed only for localhost)');
|
|
3844
|
+
}
|
|
3845
|
+
if (parsedUri.username || parsedUri.password || parsedUri.hash || parsedUri.host.toLowerCase() !== domain.toLowerCase()) {
|
|
3846
|
+
throw new Error('SIWE URI authority must match the SIWE domain');
|
|
3847
|
+
}
|
|
3848
|
+
const issuedAtDate = (_d = options.issuedAt) !== null && _d !== void 0 ? _d : new Date();
|
|
3849
|
+
const expirationDate = (_e = options.expirationTime) !== null && _e !== void 0 ? _e : new Date(issuedAtDate.getTime() + DEFAULT_VALIDITY_MS);
|
|
3850
|
+
if (!Number.isFinite(issuedAtDate.getTime()) || !Number.isFinite(expirationDate.getTime()) || expirationDate <= issuedAtDate) {
|
|
3851
|
+
throw new Error('SIWE validity window is invalid');
|
|
3852
|
+
}
|
|
3853
|
+
if (expirationDate.getTime() - issuedAtDate.getTime() > 10 * 60 * 1000) {
|
|
3854
|
+
throw new Error('SIWE validity window exceeds 10 minutes');
|
|
3855
|
+
}
|
|
3856
|
+
const issuedAt = issuedAtDate.toISOString();
|
|
3857
|
+
const expirationTime = expirationDate.toISOString();
|
|
3858
|
+
return (`${domain} wants you to sign in with your Ethereum account:\n${addr}\n\n` +
|
|
3859
|
+
`${statement}\n\n` +
|
|
3860
|
+
`URI: ${parsedUri.toString()}\n` +
|
|
3861
|
+
`Version: ${version}\n` +
|
|
3862
|
+
`Chain ID: ${chainId}\n` +
|
|
3863
|
+
`Nonce: ${nonce}\n` +
|
|
3864
|
+
`Issued At: ${issuedAt}\n` +
|
|
3865
|
+
`Expiration Time: ${expirationTime}\n` +
|
|
3866
|
+
`Resources:\n` +
|
|
3867
|
+
`- bounded:app:${appId}`);
|
|
3868
|
+
}
|
|
3869
|
+
/**
|
|
3870
|
+
* Lowercase an EVM address to the canonical identity form. EVM addresses are
|
|
3871
|
+
* case-insensitive on-chain but the identity layer (@user.evmAddress) matches
|
|
3872
|
+
* case-sensitively, so every boundary normalizes to lowercase. Does not validate
|
|
3873
|
+
* shape — use normalizeEvmAddress() when the input is untrusted.
|
|
3874
|
+
*/
|
|
3875
|
+
function toLowerEvmAddress(address) {
|
|
3876
|
+
return address.trim().toLowerCase();
|
|
3877
|
+
}
|
|
3878
|
+
/**
|
|
3879
|
+
* Normalize an untrusted EVM address to canonical lowercase 0x-hex, or null when
|
|
3880
|
+
* it isn't a well-formed 20-byte hex address. Mirrors the realtime-worker's
|
|
3881
|
+
* normalizeEvmAddress (auth.ts) EXACTLY so a client-derived @user.evmAddress and
|
|
3882
|
+
* the server-authenticated one are byte-identical (fail-closed on malformed).
|
|
3883
|
+
*/
|
|
3884
|
+
function normalizeEvmAddress(raw) {
|
|
3885
|
+
if (typeof raw !== 'string')
|
|
3886
|
+
return null;
|
|
3887
|
+
const v = raw.trim().toLowerCase();
|
|
3888
|
+
return /^0x[0-9a-f]{40}$/.test(v) ? v : null;
|
|
3889
|
+
}
|
|
3890
|
+
|
|
3722
3891
|
async function createBearerToken(isServer) {
|
|
3723
3892
|
if (isServer) {
|
|
3724
3893
|
const sessionMgr = ServerSessionManager.instance;
|
|
@@ -3767,7 +3936,7 @@ async function getUserInfo(isServer) {
|
|
|
3767
3936
|
* Returns all-null when the token can't be decoded (caller treats as no identity).
|
|
3768
3937
|
*/
|
|
3769
3938
|
function deriveUserIdentityFromIdToken(idToken) {
|
|
3770
|
-
const empty = { id: null, address: null, email: null };
|
|
3939
|
+
const empty = { id: null, address: null, email: null, evmAddress: null };
|
|
3771
3940
|
if (!idToken)
|
|
3772
3941
|
return empty;
|
|
3773
3942
|
try {
|
|
@@ -3780,14 +3949,25 @@ function deriveUserIdentityFromIdToken(idToken) {
|
|
|
3780
3949
|
? walletClaim
|
|
3781
3950
|
: null;
|
|
3782
3951
|
const userIdClaim = payload['custom:userId'];
|
|
3783
|
-
|
|
3952
|
+
let id = (typeof userIdClaim === 'string' && userIdClaim.length > 0)
|
|
3784
3953
|
? userIdClaim
|
|
3785
3954
|
: null;
|
|
3786
3955
|
const emailClaim = payload['email'];
|
|
3787
3956
|
const email = (typeof emailClaim === 'string' && emailClaim.length > 0)
|
|
3788
3957
|
? emailClaim.toLowerCase()
|
|
3789
3958
|
: null;
|
|
3790
|
-
|
|
3959
|
+
// EVM wallet — carried on the `custom:evmWalletAddress` claim (an EVM login
|
|
3960
|
+
// OMITS custom:walletAddress). Normalized to canonical lowercase 0x-hex the
|
|
3961
|
+
// SAME way the realtime-worker does, so the client @user.evmAddress and the
|
|
3962
|
+
// server-authenticated one are byte-identical (fail-closed on malformed).
|
|
3963
|
+
const evmAddress = normalizeEvmAddress(payload['custom:evmWalletAddress']);
|
|
3964
|
+
// A SIWE identity uses its EVM address as custom:userId. Normalize legacy
|
|
3965
|
+
// mixed-case tokens at this boundary too so the principal and
|
|
3966
|
+
// @user.evmAddress cannot diverge solely by hex casing. Opaque human-login
|
|
3967
|
+
// ids remain byte-for-byte unchanged even when they have a linked wallet.
|
|
3968
|
+
if (evmAddress && id && normalizeEvmAddress(id) === evmAddress)
|
|
3969
|
+
id = evmAddress;
|
|
3970
|
+
return { id, address, email, evmAddress };
|
|
3791
3971
|
}
|
|
3792
3972
|
catch (_a) {
|
|
3793
3973
|
return empty;
|
|
@@ -4109,6 +4289,456 @@ async function makeApiRequest(method, urlPath, data, _overrides) {
|
|
|
4109
4289
|
}
|
|
4110
4290
|
}
|
|
4111
4291
|
|
|
4292
|
+
/**
|
|
4293
|
+
* Selectors derived from the canonical BoundedRuntime signatures (BoundedTypes.sol):
|
|
4294
|
+
*
|
|
4295
|
+
* cast sig 'setDocuments(string,(string[],bool,(string,uint8,uint8,bytes)[])[])'
|
|
4296
|
+
* => 0x0827b094
|
|
4297
|
+
* cast sig 'setDocumentsAttested(string,(string[],bool,(string,uint8,uint8,bytes)[])[],(bytes32,uint256,uint256,uint8,bytes32,bytes32))'
|
|
4298
|
+
* => 0x87b111c4
|
|
4299
|
+
*
|
|
4300
|
+
* Keep the signatures beside the constants: changing either Solidity tuple is an
|
|
4301
|
+
* ABI break and must deliberately update these values and the Foundry goldens.
|
|
4302
|
+
*/
|
|
4303
|
+
const BOUNDED_RUNTIME_SET_DOCUMENTS_SELECTOR = '0x0827b094';
|
|
4304
|
+
const BOUNDED_RUNTIME_SET_DOCUMENTS_ATTESTED_SELECTOR = '0x87b111c4';
|
|
4305
|
+
const SET_DOCUMENTS_SELECTOR = BOUNDED_RUNTIME_SET_DOCUMENTS_SELECTOR.slice(2);
|
|
4306
|
+
const SET_DOCUMENTS_ATTESTED_SELECTOR = BOUNDED_RUNTIME_SET_DOCUMENTS_ATTESTED_SELECTOR.slice(2);
|
|
4307
|
+
const MAX_CALLDATA_BYTES = 1024 * 1024;
|
|
4308
|
+
const MAX_ARRAY_ITEMS = 2048;
|
|
4309
|
+
const BIGINT_ZERO = BigInt(0);
|
|
4310
|
+
const BIGINT_ONE = BigInt(1);
|
|
4311
|
+
const BIGINT_EIGHT = BigInt(8);
|
|
4312
|
+
const SECP256K1_N = BigInt('0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141');
|
|
4313
|
+
const SECP256K1_HALF_N = BigInt('0x7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a0');
|
|
4314
|
+
function hexBytes(raw) {
|
|
4315
|
+
if (typeof raw !== 'string' || !/^0x[0-9a-fA-F]*$/.test(raw) || raw.length % 2 !== 0) {
|
|
4316
|
+
throw new Error('EVM transaction calldata must be even-length 0x hex');
|
|
4317
|
+
}
|
|
4318
|
+
const hex = raw.slice(2);
|
|
4319
|
+
if (hex.length / 2 > MAX_CALLDATA_BYTES)
|
|
4320
|
+
throw new Error('EVM transaction calldata exceeds the client safety limit');
|
|
4321
|
+
const out = new Uint8Array(hex.length / 2);
|
|
4322
|
+
for (let i = 0; i < out.length; i++)
|
|
4323
|
+
out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
4324
|
+
return out;
|
|
4325
|
+
}
|
|
4326
|
+
class AbiReader {
|
|
4327
|
+
constructor(bytes) {
|
|
4328
|
+
this.bytes = bytes;
|
|
4329
|
+
this.maxRead = 0;
|
|
4330
|
+
this.dynamicPayloadBytes = 0;
|
|
4331
|
+
this.utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: false });
|
|
4332
|
+
}
|
|
4333
|
+
range(offset, length, label) {
|
|
4334
|
+
if (!Number.isSafeInteger(offset) || !Number.isSafeInteger(length) || offset < 0 || length < 0 || offset + length > this.bytes.length) {
|
|
4335
|
+
throw new Error(`Malformed EVM setDocuments calldata (${label})`);
|
|
4336
|
+
}
|
|
4337
|
+
this.maxRead = Math.max(this.maxRead, offset + length);
|
|
4338
|
+
}
|
|
4339
|
+
word(offset, label) {
|
|
4340
|
+
this.range(offset, 32, label);
|
|
4341
|
+
let value = BIGINT_ZERO;
|
|
4342
|
+
for (let i = 0; i < 32; i++)
|
|
4343
|
+
value = (value << BIGINT_EIGHT) | BigInt(this.bytes[offset + i]);
|
|
4344
|
+
return value;
|
|
4345
|
+
}
|
|
4346
|
+
numberWord(offset, label, max = Number.MAX_SAFE_INTEGER) {
|
|
4347
|
+
const value = this.word(offset, label);
|
|
4348
|
+
if (value > BigInt(max))
|
|
4349
|
+
throw new Error(`Malformed EVM setDocuments calldata (${label} too large)`);
|
|
4350
|
+
return Number(value);
|
|
4351
|
+
}
|
|
4352
|
+
offset(base, wordOffset, min, label) {
|
|
4353
|
+
const relative = this.numberWord(wordOffset, `${label} offset`, this.bytes.length);
|
|
4354
|
+
if (relative < min || relative % 32 !== 0)
|
|
4355
|
+
throw new Error(`Malformed EVM setDocuments calldata (${label} offset)`);
|
|
4356
|
+
const absolute = base + relative;
|
|
4357
|
+
this.range(absolute, 0, label);
|
|
4358
|
+
return absolute;
|
|
4359
|
+
}
|
|
4360
|
+
dynamicBytes(at, label) {
|
|
4361
|
+
const length = this.numberWord(at, `${label} length`, MAX_CALLDATA_BYTES);
|
|
4362
|
+
this.dynamicPayloadBytes += length;
|
|
4363
|
+
// ABI offsets may alias. Cap the cumulative decoded payload so a malicious
|
|
4364
|
+
// 1 MB tail referenced by thousands of fields cannot amplify into a
|
|
4365
|
+
// multi-gigabyte allocation/UTF-8 decode in the wallet process.
|
|
4366
|
+
if (this.dynamicPayloadBytes > MAX_CALLDATA_BYTES) {
|
|
4367
|
+
throw new Error('Malformed EVM setDocuments calldata (dynamic payload amplification)');
|
|
4368
|
+
}
|
|
4369
|
+
const start = at + 32;
|
|
4370
|
+
const padded = Math.ceil(length / 32) * 32;
|
|
4371
|
+
this.range(start, padded, label);
|
|
4372
|
+
for (let i = start + length; i < start + padded; i++) {
|
|
4373
|
+
if (this.bytes[i] !== 0)
|
|
4374
|
+
throw new Error(`Malformed EVM setDocuments calldata (${label} padding)`);
|
|
4375
|
+
}
|
|
4376
|
+
return this.bytes.slice(start, start + length);
|
|
4377
|
+
}
|
|
4378
|
+
dynamicEnd(at, label) {
|
|
4379
|
+
const length = this.numberWord(at, `${label} length`, MAX_CALLDATA_BYTES);
|
|
4380
|
+
const end = at + 32 + Math.ceil(length / 32) * 32;
|
|
4381
|
+
this.range(at + 32, Math.ceil(length / 32) * 32, label);
|
|
4382
|
+
return end;
|
|
4383
|
+
}
|
|
4384
|
+
string(at, label) {
|
|
4385
|
+
try {
|
|
4386
|
+
return this.utf8.decode(this.dynamicBytes(at, label));
|
|
4387
|
+
}
|
|
4388
|
+
catch (_a) {
|
|
4389
|
+
throw new Error(`Malformed EVM setDocuments calldata (${label} utf8)`);
|
|
4390
|
+
}
|
|
4391
|
+
}
|
|
4392
|
+
finish() {
|
|
4393
|
+
if (this.maxRead !== this.bytes.length)
|
|
4394
|
+
throw new Error('Malformed EVM setDocuments calldata (unreferenced trailing bytes)');
|
|
4395
|
+
}
|
|
4396
|
+
}
|
|
4397
|
+
function assertCanonicalOffset(actual, expected, label) {
|
|
4398
|
+
if (actual !== expected)
|
|
4399
|
+
throw new Error(`Malformed EVM setDocuments calldata (${label} non-canonical offset)`);
|
|
4400
|
+
}
|
|
4401
|
+
function decodeStringArray(reader, at, label) {
|
|
4402
|
+
const count = reader.numberWord(at, `${label} count`, MAX_ARRAY_ITEMS);
|
|
4403
|
+
const head = at + 32;
|
|
4404
|
+
const min = count * 32;
|
|
4405
|
+
const out = [];
|
|
4406
|
+
let expectedTail = head + min;
|
|
4407
|
+
for (let i = 0; i < count; i++) {
|
|
4408
|
+
const itemAt = reader.offset(head, head + i * 32, min, `${label}[${i}]`);
|
|
4409
|
+
assertCanonicalOffset(itemAt, expectedTail, `${label}[${i}]`);
|
|
4410
|
+
out.push(reader.string(itemAt, `${label}[${i}]`));
|
|
4411
|
+
expectedTail = reader.dynamicEnd(itemAt, `${label}[${i}]`);
|
|
4412
|
+
}
|
|
4413
|
+
return { value: out, end: expectedTail };
|
|
4414
|
+
}
|
|
4415
|
+
function decodeFields(reader, at) {
|
|
4416
|
+
const count = reader.numberWord(at, 'fields count', MAX_ARRAY_ITEMS);
|
|
4417
|
+
const head = at + 32;
|
|
4418
|
+
const min = count * 32;
|
|
4419
|
+
const out = [];
|
|
4420
|
+
let expectedTail = head + min;
|
|
4421
|
+
for (let i = 0; i < count; i++) {
|
|
4422
|
+
const tuple = reader.offset(head, head + i * 32, min, `field[${i}]`);
|
|
4423
|
+
assertCanonicalOffset(tuple, expectedTail, `field[${i}]`);
|
|
4424
|
+
const nameAt = reader.offset(tuple, tuple, 128, `field[${i}].name`);
|
|
4425
|
+
assertCanonicalOffset(nameAt, tuple + 128, `field[${i}].name`);
|
|
4426
|
+
const name = reader.string(nameAt, `field[${i}].name`);
|
|
4427
|
+
const nameEnd = reader.dynamicEnd(nameAt, `field[${i}].name`);
|
|
4428
|
+
const ftype = reader.numberWord(tuple + 32, `field[${i}].ftype`, 255);
|
|
4429
|
+
const op = reader.numberWord(tuple + 64, `field[${i}].op`, 255);
|
|
4430
|
+
const valueAt = reader.offset(tuple, tuple + 96, 128, `field[${i}].value`);
|
|
4431
|
+
assertCanonicalOffset(valueAt, nameEnd, `field[${i}].value`);
|
|
4432
|
+
const value = reader.dynamicBytes(valueAt, `field[${i}].value`);
|
|
4433
|
+
expectedTail = reader.dynamicEnd(valueAt, `field[${i}].value`);
|
|
4434
|
+
out.push({ name, ftype, op, value });
|
|
4435
|
+
}
|
|
4436
|
+
return { value: out, end: expectedTail };
|
|
4437
|
+
}
|
|
4438
|
+
function decodeRuntimeWrite(rawData) {
|
|
4439
|
+
const bytes = hexBytes(rawData);
|
|
4440
|
+
if (bytes.length < 68)
|
|
4441
|
+
throw new Error('Malformed EVM setDocuments calldata');
|
|
4442
|
+
const selector = Array.from(bytes.slice(0, 4)).map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
4443
|
+
const kind = selector === SET_DOCUMENTS_SELECTOR
|
|
4444
|
+
? 'setDocuments'
|
|
4445
|
+
: selector === SET_DOCUMENTS_ATTESTED_SELECTOR
|
|
4446
|
+
? 'setDocumentsAttested'
|
|
4447
|
+
: null;
|
|
4448
|
+
if (!kind)
|
|
4449
|
+
throw new Error('EVM transaction is not a supported BoundedRuntime document write');
|
|
4450
|
+
const reader = new AbiReader(bytes);
|
|
4451
|
+
reader.maxRead = 4;
|
|
4452
|
+
const args = 4;
|
|
4453
|
+
// ExternalCallAttestation is a fully-static six-word tuple and is therefore
|
|
4454
|
+
// expanded inline into the top-level head: 2 dynamic offsets + 6 words.
|
|
4455
|
+
const headBytes = kind === 'setDocumentsAttested' ? 256 : 64;
|
|
4456
|
+
const appAt = reader.offset(args, args, headBytes, 'appId');
|
|
4457
|
+
assertCanonicalOffset(appAt, args + headBytes, 'appId');
|
|
4458
|
+
const opsAt = reader.offset(args, args + 32, headBytes, 'ops');
|
|
4459
|
+
const appId = reader.string(appAt, 'appId');
|
|
4460
|
+
const appEnd = reader.dynamicEnd(appAt, 'appId');
|
|
4461
|
+
assertCanonicalOffset(opsAt, appEnd, 'ops');
|
|
4462
|
+
const count = reader.numberWord(opsAt, 'ops count', MAX_ARRAY_ITEMS);
|
|
4463
|
+
const head = opsAt + 32;
|
|
4464
|
+
const min = count * 32;
|
|
4465
|
+
const ops = [];
|
|
4466
|
+
let expectedTail = head + min;
|
|
4467
|
+
for (let i = 0; i < count; i++) {
|
|
4468
|
+
const tuple = reader.offset(head, head + i * 32, min, `op[${i}]`);
|
|
4469
|
+
assertCanonicalOffset(tuple, expectedTail, `op[${i}]`);
|
|
4470
|
+
const pathAt = reader.offset(tuple, tuple, 96, `op[${i}].path`);
|
|
4471
|
+
assertCanonicalOffset(pathAt, tuple + 96, `op[${i}].path`);
|
|
4472
|
+
const deleteWord = reader.numberWord(tuple + 32, `op[${i}].isDelete`, 1);
|
|
4473
|
+
const fieldsAt = reader.offset(tuple, tuple + 64, 96, `op[${i}].fields`);
|
|
4474
|
+
const path = decodeStringArray(reader, pathAt, `op[${i}].path`);
|
|
4475
|
+
assertCanonicalOffset(fieldsAt, path.end, `op[${i}].fields`);
|
|
4476
|
+
const fields = decodeFields(reader, fieldsAt);
|
|
4477
|
+
expectedTail = fields.end;
|
|
4478
|
+
ops.push({
|
|
4479
|
+
pathSegments: path.value,
|
|
4480
|
+
isDelete: deleteWord === 1,
|
|
4481
|
+
fields: fields.value,
|
|
4482
|
+
});
|
|
4483
|
+
}
|
|
4484
|
+
let attestation;
|
|
4485
|
+
if (kind === 'setDocumentsAttested') {
|
|
4486
|
+
const callsHash = reader.word(args + 64, 'attestation.callsHash');
|
|
4487
|
+
const nonce = reader.word(args + 96, 'attestation.nonce');
|
|
4488
|
+
const deadline = reader.word(args + 128, 'attestation.deadline');
|
|
4489
|
+
const v = reader.numberWord(args + 160, 'attestation.v', 255);
|
|
4490
|
+
const r = reader.word(args + 192, 'attestation.r');
|
|
4491
|
+
const s = reader.word(args + 224, 'attestation.s');
|
|
4492
|
+
if (v !== 27 && v !== 28)
|
|
4493
|
+
throw new Error('Malformed EVM setDocuments calldata (attestation.v must be 27 or 28)');
|
|
4494
|
+
if (r === BIGINT_ZERO || r >= SECP256K1_N)
|
|
4495
|
+
throw new Error('Malformed EVM setDocuments calldata (attestation.r is not canonical)');
|
|
4496
|
+
if (s === BIGINT_ZERO || s > SECP256K1_HALF_N)
|
|
4497
|
+
throw new Error('Malformed EVM setDocuments calldata (attestation.s is not canonical low-s)');
|
|
4498
|
+
attestation = { callsHash, nonce, deadline, v, r, s };
|
|
4499
|
+
}
|
|
4500
|
+
if (expectedTail !== bytes.length)
|
|
4501
|
+
throw new Error('Malformed EVM setDocuments calldata (unreferenced trailing bytes)');
|
|
4502
|
+
reader.finish();
|
|
4503
|
+
return Object.assign({ kind, appId, ops }, (attestation ? { attestation } : {}));
|
|
4504
|
+
}
|
|
4505
|
+
/**
|
|
4506
|
+
* Fail closed unless calldata is a byte-for-byte canonical ABI encoding of one
|
|
4507
|
+
* supported BoundedRuntime write. This is the defense used by direct signer
|
|
4508
|
+
* surfaces; the higher-level write validator below additionally binds app/ops.
|
|
4509
|
+
*/
|
|
4510
|
+
function assertCanonicalBoundedRuntimeWriteCalldata(rawData) {
|
|
4511
|
+
decodeRuntimeWrite(rawData);
|
|
4512
|
+
}
|
|
4513
|
+
const MAX_EVM_DOCUMENT_PATH_UTF8_BYTES = 1024;
|
|
4514
|
+
function canonicalUtf8ByteLength(value) {
|
|
4515
|
+
let bytes = 0;
|
|
4516
|
+
for (let i = 0; i < value.length; i++) {
|
|
4517
|
+
const unit = value.charCodeAt(i);
|
|
4518
|
+
if (unit <= 0x7f)
|
|
4519
|
+
bytes += 1;
|
|
4520
|
+
else if (unit <= 0x7ff)
|
|
4521
|
+
bytes += 2;
|
|
4522
|
+
else if (unit >= 0xd800 && unit <= 0xdbff) {
|
|
4523
|
+
const low = value.charCodeAt(++i);
|
|
4524
|
+
if (!(low >= 0xdc00 && low <= 0xdfff))
|
|
4525
|
+
throw new Error(`Invalid EVM write path: ${value}`);
|
|
4526
|
+
bytes += 4;
|
|
4527
|
+
}
|
|
4528
|
+
else if (unit >= 0xdc00 && unit <= 0xdfff) {
|
|
4529
|
+
throw new Error(`Invalid EVM write path: ${value}`);
|
|
4530
|
+
}
|
|
4531
|
+
else
|
|
4532
|
+
bytes += 3;
|
|
4533
|
+
}
|
|
4534
|
+
return bytes;
|
|
4535
|
+
}
|
|
4536
|
+
function normalizedPathSegments(path) {
|
|
4537
|
+
if (typeof path !== 'string' || path.length === 0 ||
|
|
4538
|
+
canonicalUtf8ByteLength(path) > MAX_EVM_DOCUMENT_PATH_UTF8_BYTES) {
|
|
4539
|
+
throw new Error(`Invalid EVM write path: ${path}`);
|
|
4540
|
+
}
|
|
4541
|
+
const segments = path.split('/');
|
|
4542
|
+
if (segments.length < 2 || segments.length % 2 !== 0 ||
|
|
4543
|
+
segments[0].startsWith('__') ||
|
|
4544
|
+
segments.some((s) => !s || s === '.' || s === '..' || /[\\\u0000-\u001f\u007f]/.test(s))) {
|
|
4545
|
+
throw new Error(`Invalid EVM write path: ${path}`);
|
|
4546
|
+
}
|
|
4547
|
+
return segments;
|
|
4548
|
+
}
|
|
4549
|
+
function bytesBigInt(bytes, signed) {
|
|
4550
|
+
if (bytes.length !== 32)
|
|
4551
|
+
throw new Error('EVM field value is not one ABI word');
|
|
4552
|
+
let value = BIGINT_ZERO;
|
|
4553
|
+
for (const b of bytes)
|
|
4554
|
+
value = (value << BIGINT_EIGHT) | BigInt(b);
|
|
4555
|
+
if (signed && (value & (BIGINT_ONE << BigInt(255))) !== BIGINT_ZERO)
|
|
4556
|
+
value -= BIGINT_ONE << BigInt(256);
|
|
4557
|
+
return value;
|
|
4558
|
+
}
|
|
4559
|
+
function decodedAbiString(bytes) {
|
|
4560
|
+
const reader = new AbiReader(bytes);
|
|
4561
|
+
const at = reader.offset(0, 0, 32, 'field string');
|
|
4562
|
+
const value = reader.string(at, 'field string');
|
|
4563
|
+
reader.finish();
|
|
4564
|
+
return value;
|
|
4565
|
+
}
|
|
4566
|
+
function assertSetValue(field, expected) {
|
|
4567
|
+
if (field.op !== 0)
|
|
4568
|
+
throw new Error(`EVM field ${field.name} changes the requested SET action`);
|
|
4569
|
+
if (typeof expected === 'boolean') {
|
|
4570
|
+
if (field.ftype !== 3 || bytesBigInt(field.value, false) !== (expected ? BIGINT_ONE : BIGINT_ZERO))
|
|
4571
|
+
throw new Error(`EVM field ${field.name} value mismatch`);
|
|
4572
|
+
return;
|
|
4573
|
+
}
|
|
4574
|
+
if (typeof expected === 'number') {
|
|
4575
|
+
if (!Number.isFinite(expected))
|
|
4576
|
+
throw new Error(`EVM field ${field.name} is not finite`);
|
|
4577
|
+
if (Number.isInteger(expected)) {
|
|
4578
|
+
if (!Number.isSafeInteger(expected)) {
|
|
4579
|
+
throw new Error(`EVM field ${field.name} integer must be a safe number or canonical decimal string`);
|
|
4580
|
+
}
|
|
4581
|
+
const actual = field.ftype === 1 ? bytesBigInt(field.value, false) : field.ftype === 2 ? bytesBigInt(field.value, true) : null;
|
|
4582
|
+
if (actual === null || actual !== BigInt(expected))
|
|
4583
|
+
throw new Error(`EVM field ${field.name} value mismatch`);
|
|
4584
|
+
return;
|
|
4585
|
+
}
|
|
4586
|
+
if (field.ftype !== 4 || decodedAbiString(field.value) !== expected.toString())
|
|
4587
|
+
throw new Error(`EVM field ${field.name} value mismatch`);
|
|
4588
|
+
return;
|
|
4589
|
+
}
|
|
4590
|
+
if (typeof expected === 'bigint') {
|
|
4591
|
+
const actual = field.ftype === 1 ? bytesBigInt(field.value, false) : field.ftype === 2 ? bytesBigInt(field.value, true) : null;
|
|
4592
|
+
if (actual === null || actual !== expected)
|
|
4593
|
+
throw new Error(`EVM field ${field.name} value mismatch`);
|
|
4594
|
+
return;
|
|
4595
|
+
}
|
|
4596
|
+
if (typeof expected === 'string') {
|
|
4597
|
+
if (field.ftype === 4 && decodedAbiString(field.value) === expected)
|
|
4598
|
+
return;
|
|
4599
|
+
if ((field.ftype === 1 || field.ftype === 2) && expected.length <= 20 && /^-?(?:0|[1-9][0-9]*)$/.test(expected)) {
|
|
4600
|
+
const exact = BigInt(expected);
|
|
4601
|
+
if (exact >= BigInt(Number.MIN_SAFE_INTEGER) && exact <= BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
4602
|
+
throw new Error(`EVM field ${field.name} safe integer must be supplied as a number`);
|
|
4603
|
+
}
|
|
4604
|
+
const inRange = field.ftype === 1
|
|
4605
|
+
? exact >= BIGINT_ZERO && exact <= ((BIGINT_ONE << BigInt(64)) - BIGINT_ONE)
|
|
4606
|
+
: exact >= -(BIGINT_ONE << BigInt(63)) && exact <= ((BIGINT_ONE << BigInt(63)) - BIGINT_ONE);
|
|
4607
|
+
const actual = bytesBigInt(field.value, field.ftype === 2);
|
|
4608
|
+
if (inRange && actual === exact)
|
|
4609
|
+
return;
|
|
4610
|
+
throw new Error(`EVM field ${field.name} value mismatch`);
|
|
4611
|
+
}
|
|
4612
|
+
if (field.ftype === 5 && normalizeEvmAddress(expected)) {
|
|
4613
|
+
const word = field.value;
|
|
4614
|
+
if (word.length === 32 && word.slice(0, 12).every((b) => b === 0)) {
|
|
4615
|
+
const actual = `0x${Array.from(word.slice(12)).map((b) => b.toString(16).padStart(2, '0')).join('')}`;
|
|
4616
|
+
if (actual === normalizeEvmAddress(expected))
|
|
4617
|
+
return;
|
|
4618
|
+
}
|
|
4619
|
+
}
|
|
4620
|
+
throw new Error(`EVM field ${field.name} value mismatch`);
|
|
4621
|
+
}
|
|
4622
|
+
if (expected === undefined || expected === null) {
|
|
4623
|
+
throw new Error(`EVM field ${field.name} cannot be represented safely on EVM; use a document delete or omit the field`);
|
|
4624
|
+
}
|
|
4625
|
+
const json = JSON.stringify(expected);
|
|
4626
|
+
if (typeof json !== 'string' || field.ftype !== 4 || decodedAbiString(field.value) !== json) {
|
|
4627
|
+
throw new Error(`EVM field ${field.name} value mismatch`);
|
|
4628
|
+
}
|
|
4629
|
+
}
|
|
4630
|
+
function assertField(field, expected) {
|
|
4631
|
+
if (expected === null) {
|
|
4632
|
+
if (field.op !== 2 || field.ftype < 1 || field.ftype > 5 || field.value.length !== 0) {
|
|
4633
|
+
throw new Error(`EVM field ${field.name} deletion mismatch`);
|
|
4634
|
+
}
|
|
4635
|
+
return;
|
|
4636
|
+
}
|
|
4637
|
+
if (expected && typeof expected === 'object' && !Array.isArray(expected)) {
|
|
4638
|
+
const operation = expected.operation;
|
|
4639
|
+
if (operation === 'increment') {
|
|
4640
|
+
const delta = expected.value;
|
|
4641
|
+
const actual = field.ftype === 1 || field.ftype === 2
|
|
4642
|
+
? bytesBigInt(field.value, true)
|
|
4643
|
+
: null;
|
|
4644
|
+
if (!Number.isSafeInteger(delta) || field.op !== 3 || actual === null || actual !== BigInt(delta)) {
|
|
4645
|
+
throw new Error(`EVM field ${field.name} increment mismatch`);
|
|
4646
|
+
}
|
|
4647
|
+
return;
|
|
4648
|
+
}
|
|
4649
|
+
if (operation === 'time' && expected.value === 'now') {
|
|
4650
|
+
if (field.op !== 4 || (field.ftype !== 1 && field.ftype !== 2) || field.value.length !== 0) {
|
|
4651
|
+
throw new Error(`EVM field ${field.name} timestamp mismatch`);
|
|
4652
|
+
}
|
|
4653
|
+
return;
|
|
4654
|
+
}
|
|
4655
|
+
}
|
|
4656
|
+
assertSetValue(field, expected);
|
|
4657
|
+
}
|
|
4658
|
+
function assertOp(decoded, path, document) {
|
|
4659
|
+
const expectedSegments = normalizedPathSegments(path);
|
|
4660
|
+
if (decoded.pathSegments.length !== expectedSegments.length || decoded.pathSegments.some((s, i) => s !== expectedSegments[i])) {
|
|
4661
|
+
throw new Error(`EVM transaction path does not match requested write: ${path}`);
|
|
4662
|
+
}
|
|
4663
|
+
if (decoded.isDelete !== (document === null))
|
|
4664
|
+
throw new Error(`EVM transaction action does not match requested write: ${path}`);
|
|
4665
|
+
if (document === null) {
|
|
4666
|
+
if (decoded.fields.length !== 0)
|
|
4667
|
+
throw new Error(`EVM delete unexpectedly carries fields: ${path}`);
|
|
4668
|
+
return;
|
|
4669
|
+
}
|
|
4670
|
+
if (!document || typeof document !== 'object' || Array.isArray(document))
|
|
4671
|
+
throw new Error(`EVM document must be a plain object or null: ${path}`);
|
|
4672
|
+
const expectedEntries = Object.entries(document);
|
|
4673
|
+
if (expectedEntries.some(([name]) => name.startsWith('_')))
|
|
4674
|
+
throw new Error(`Reserved EVM field reached transaction validation: ${path}`);
|
|
4675
|
+
const expected = new Map(expectedEntries);
|
|
4676
|
+
const seen = new Set();
|
|
4677
|
+
for (const field of decoded.fields) {
|
|
4678
|
+
if (!field.name || seen.has(field.name) || !expected.has(field.name))
|
|
4679
|
+
throw new Error(`EVM transaction contains unexpected or duplicate field ${field.name}`);
|
|
4680
|
+
seen.add(field.name);
|
|
4681
|
+
assertField(field, expected.get(field.name));
|
|
4682
|
+
}
|
|
4683
|
+
const missing = [...expected.keys()].filter((name) => !seen.has(name));
|
|
4684
|
+
if (missing.length > 0)
|
|
4685
|
+
throw new Error(`EVM transaction omits requested fields: ${missing.join(', ')}`);
|
|
4686
|
+
}
|
|
4687
|
+
function zeroWei(raw) {
|
|
4688
|
+
if (raw === undefined || raw === null || raw === '')
|
|
4689
|
+
return true;
|
|
4690
|
+
if (typeof raw !== 'string' && typeof raw !== 'number')
|
|
4691
|
+
return false;
|
|
4692
|
+
try {
|
|
4693
|
+
return BigInt(raw) === BIGINT_ZERO;
|
|
4694
|
+
}
|
|
4695
|
+
catch (_a) {
|
|
4696
|
+
return false;
|
|
4697
|
+
}
|
|
4698
|
+
}
|
|
4699
|
+
/** Verify the untrusted 202 payload is exactly the governed write the app asked for. */
|
|
4700
|
+
function assertEvmTransactionMatchesWrite(transaction, context) {
|
|
4701
|
+
var _a, _b;
|
|
4702
|
+
if (!transaction || !Number.isSafeInteger(transaction.chainId) || transaction.chainId <= 0) {
|
|
4703
|
+
throw new Error('EVM transaction chainId is required');
|
|
4704
|
+
}
|
|
4705
|
+
const chainId = transaction.chainId;
|
|
4706
|
+
const policy = context.policy;
|
|
4707
|
+
if (!policy)
|
|
4708
|
+
throw new Error('EVM transaction signing requires an explicit runtime/chain policy');
|
|
4709
|
+
if (policy.chainId !== undefined && policy.chainId !== chainId)
|
|
4710
|
+
throw new Error(`EVM transaction chain ${chainId} does not match configured chain ${policy.chainId}`);
|
|
4711
|
+
const expectedRuntime = (_b = (_a = policy.runtimeAddresses) === null || _a === void 0 ? void 0 : _a[String(chainId)]) !== null && _b !== void 0 ? _b : (policy.chainId === chainId ? policy.runtimeAddress : undefined);
|
|
4712
|
+
const normalizedRuntime = normalizeEvmAddress(expectedRuntime);
|
|
4713
|
+
if (!normalizedRuntime)
|
|
4714
|
+
throw new Error(`No trusted BoundedRuntime address configured for EVM chain ${chainId}`);
|
|
4715
|
+
if (normalizeEvmAddress(transaction.to) !== normalizedRuntime)
|
|
4716
|
+
throw new Error('EVM transaction target is not the configured BoundedRuntime');
|
|
4717
|
+
if (!zeroWei(transaction.value))
|
|
4718
|
+
throw new Error('BoundedRuntime.setDocuments must not transfer native value');
|
|
4719
|
+
if (transaction.nonce !== undefined)
|
|
4720
|
+
throw new Error('Server-supplied EVM nonce is not accepted');
|
|
4721
|
+
const decoded = decodeRuntimeWrite(transaction.data);
|
|
4722
|
+
if (decoded.appId !== context.appId)
|
|
4723
|
+
throw new Error('EVM transaction appId does not match configured appId');
|
|
4724
|
+
if (decoded.ops.length !== context.documents.length)
|
|
4725
|
+
throw new Error('EVM transaction operation count does not match requested writes');
|
|
4726
|
+
// The worker's contract ABI groups upserts first and document deletes second.
|
|
4727
|
+
const expectedOps = [
|
|
4728
|
+
...context.documents.filter((entry) => entry.document !== null),
|
|
4729
|
+
...context.documents.filter((entry) => entry.document === null),
|
|
4730
|
+
];
|
|
4731
|
+
const paths = new Set();
|
|
4732
|
+
for (let i = 0; i < expectedOps.length; i++) {
|
|
4733
|
+
const expected = expectedOps[i];
|
|
4734
|
+
const canonicalPath = normalizedPathSegments(expected.destinationPath).join('/');
|
|
4735
|
+
if (paths.has(canonicalPath))
|
|
4736
|
+
throw new Error(`Duplicate EVM write path is not allowed: ${canonicalPath}`);
|
|
4737
|
+
paths.add(canonicalPath);
|
|
4738
|
+
assertOp(decoded.ops[i], canonicalPath, expected.document);
|
|
4739
|
+
}
|
|
4740
|
+
}
|
|
4741
|
+
|
|
4112
4742
|
var __rest$1 = (undefined && undefined.__rest) || function (s, e) {
|
|
4113
4743
|
var t = {};
|
|
4114
4744
|
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
|
|
@@ -4135,12 +4765,21 @@ class InsufficientBalanceError extends Error {
|
|
|
4135
4765
|
this.deficitSol = deficitSol;
|
|
4136
4766
|
}
|
|
4137
4767
|
}
|
|
4138
|
-
// Cache for get responses
|
|
4139
4768
|
const getCache = {};
|
|
4140
|
-
// Track in-flight requests to coalesce multiple identical requests
|
|
4141
4769
|
const pendingRequests = {};
|
|
4142
4770
|
// Default cache TTL (500ms = 0.5 seconds)
|
|
4143
4771
|
const GET_CACHE_TTL = 500; // Adjust this value as needed (in milliseconds)
|
|
4772
|
+
function getReadCacheEntry(cacheKey, principalValue) {
|
|
4773
|
+
var _a;
|
|
4774
|
+
return (_a = getCache[cacheKey]) === null || _a === void 0 ? void 0 : _a.find(entry => entry.principalValue === principalValue);
|
|
4775
|
+
}
|
|
4776
|
+
function setReadCacheEntry(cacheKey, principalValue, data, expiresAt) {
|
|
4777
|
+
var _a;
|
|
4778
|
+
const bucket = (_a = getCache[cacheKey]) !== null && _a !== void 0 ? _a : [];
|
|
4779
|
+
const reusableEntries = bucket.filter(entry => (entry.principalValue !== principalValue && entry.expiresAt >= Date.now()));
|
|
4780
|
+
reusableEntries.push({ data, expiresAt, principalValue });
|
|
4781
|
+
getCache[cacheKey] = reusableEntries;
|
|
4782
|
+
}
|
|
4144
4783
|
// Last time we cleaned up the cache
|
|
4145
4784
|
let lastCacheCleanup = Date.now();
|
|
4146
4785
|
let uncacheableReadKeyCounter = 0;
|
|
@@ -4236,6 +4875,21 @@ function hashForKey$1(value) {
|
|
|
4236
4875
|
}
|
|
4237
4876
|
return h.toString(36);
|
|
4238
4877
|
}
|
|
4878
|
+
// Memoize the compact principal hash per exact credential string. Every read
|
|
4879
|
+
// hashed the entire (~1KB) bearer/idToken on every call; a session presents
|
|
4880
|
+
// the same handful of credentials for its lifetime. Bounded size — a rotation
|
|
4881
|
+
// simply inserts the new token and stale entries fall off on clear.
|
|
4882
|
+
const principalHashCache = new Map();
|
|
4883
|
+
function hashForKeyCached(value) {
|
|
4884
|
+
const hit = principalHashCache.get(value);
|
|
4885
|
+
if (hit !== undefined)
|
|
4886
|
+
return hit;
|
|
4887
|
+
const h = hashForKey$1(value);
|
|
4888
|
+
if (principalHashCache.size >= 50)
|
|
4889
|
+
principalHashCache.clear();
|
|
4890
|
+
principalHashCache.set(value, h);
|
|
4891
|
+
return h;
|
|
4892
|
+
}
|
|
4239
4893
|
function hasAuthHeader(headers) {
|
|
4240
4894
|
if (!headers)
|
|
4241
4895
|
return false;
|
|
@@ -4246,10 +4900,10 @@ function authValueFromHeaders(headers) {
|
|
|
4246
4900
|
return (headers === null || headers === void 0 ? void 0 : headers.Authorization) || (headers === null || headers === void 0 ? void 0 : headers.authorization) || '';
|
|
4247
4901
|
}
|
|
4248
4902
|
function principalFromAuthValue(authValue) {
|
|
4249
|
-
return authValue ? `h${
|
|
4903
|
+
return authValue ? `h${hashForKeyCached(authValue)}` : null;
|
|
4250
4904
|
}
|
|
4251
4905
|
function principalFromIdToken$1(idToken) {
|
|
4252
|
-
return idToken ? `t${
|
|
4906
|
+
return idToken ? `t${hashForKeyCached(idToken)}` : null;
|
|
4253
4907
|
}
|
|
4254
4908
|
function uncacheableReadKey(appId, scope) {
|
|
4255
4909
|
uncacheableReadKeyCounter += 1;
|
|
@@ -4271,32 +4925,34 @@ async function getReadPrincipalKey(overrides) {
|
|
|
4271
4925
|
// makeApiRequest applies overrides.headers AFTER its computed auth header, so
|
|
4272
4926
|
// caller-supplied Authorization is the real request auth when present.
|
|
4273
4927
|
if (hasAuthHeader(overrides === null || overrides === void 0 ? void 0 : overrides.headers)) {
|
|
4274
|
-
const
|
|
4928
|
+
const authValue = authValueFromHeaders(overrides === null || overrides === void 0 ? void 0 : overrides.headers);
|
|
4929
|
+
const principal = principalFromAuthValue(authValue);
|
|
4275
4930
|
return principal
|
|
4276
|
-
? { key: `${appId}:${principal}`, cacheable: true }
|
|
4277
|
-
: { key: uncacheableReadKey(appId, 'h'), cacheable: false };
|
|
4931
|
+
? { key: `${appId}:${principal}`, cacheable: true, principalValue: authValue }
|
|
4932
|
+
: { key: uncacheableReadKey(appId, 'h'), cacheable: false, principalValue: null };
|
|
4278
4933
|
}
|
|
4279
4934
|
// Per-request auth-header override (wallet client). Key by the exact opaque
|
|
4280
4935
|
// header it produces, not decoded claims or the unverified _walletAddress hint.
|
|
4281
4936
|
if (overrides === null || overrides === void 0 ? void 0 : overrides._getAuthHeaders) {
|
|
4282
4937
|
try {
|
|
4283
4938
|
const headers = await overrides._getAuthHeaders();
|
|
4284
|
-
const
|
|
4939
|
+
const authValue = authValueFromHeaders(headers);
|
|
4940
|
+
const principal = principalFromAuthValue(authValue);
|
|
4285
4941
|
return principal
|
|
4286
|
-
? { key: `${appId}:o${principal}`, cacheable: true }
|
|
4287
|
-
: { key: uncacheableReadKey(appId, 'o'), cacheable: false };
|
|
4942
|
+
? { key: `${appId}:o${principal}`, cacheable: true, principalValue: authValue }
|
|
4943
|
+
: { key: uncacheableReadKey(appId, 'o'), cacheable: false, principalValue: null };
|
|
4288
4944
|
}
|
|
4289
4945
|
catch (_a) {
|
|
4290
4946
|
// If we can't resolve the override identity, do not read/write cache.
|
|
4291
|
-
return { key: uncacheableReadKey(appId, 'o'), cacheable: false };
|
|
4947
|
+
return { key: uncacheableReadKey(appId, 'o'), cacheable: false, principalValue: null };
|
|
4292
4948
|
}
|
|
4293
4949
|
}
|
|
4294
4950
|
// Ambient session principal.
|
|
4295
4951
|
const idToken = await getIdToken(config.isServer);
|
|
4296
4952
|
const principal = principalFromIdToken$1(idToken);
|
|
4297
4953
|
return principal
|
|
4298
|
-
? { key: `${appId}:${principal}`, cacheable: true }
|
|
4299
|
-
: { key: uncacheableReadKey(appId, 'a'), cacheable: false };
|
|
4954
|
+
? { key: `${appId}:${principal}`, cacheable: true, principalValue: idToken !== null && idToken !== void 0 ? idToken : null }
|
|
4955
|
+
: { key: uncacheableReadKey(appId, 'a'), cacheable: false, principalValue: null };
|
|
4300
4956
|
}
|
|
4301
4957
|
/**
|
|
4302
4958
|
* Validates that a field name is a safe identifier (alphanumeric, underscores, dots for nested paths).
|
|
@@ -4577,6 +5233,7 @@ async function search(path, query, opts = {}) {
|
|
|
4577
5233
|
return response.data;
|
|
4578
5234
|
}
|
|
4579
5235
|
async function get(path, opts = {}) {
|
|
5236
|
+
var _a, _b;
|
|
4580
5237
|
try {
|
|
4581
5238
|
let normalizedPath = path.startsWith("/") ? path.slice(1) : path;
|
|
4582
5239
|
// Remove last '*' if it exists in the path
|
|
@@ -4587,7 +5244,8 @@ async function get(path, opts = {}) {
|
|
|
4587
5244
|
throw new Error("Invalid path provided.");
|
|
4588
5245
|
}
|
|
4589
5246
|
// Create cache key combining path, prompt, filter, sort, includeSubPaths,
|
|
4590
|
-
// shape, limit, cursor — and (H1) the caller's appId + principal
|
|
5247
|
+
// shape, limit, cursor — and (H1) the caller's appId + compact principal
|
|
5248
|
+
// bucket. Exact opaque credential equality is checked inside that bucket,
|
|
4591
5249
|
// so a private read cached for one user is never served to another in a
|
|
4592
5250
|
// shared process / SSR worker / browser login-switch. The cache is opt-in
|
|
4593
5251
|
// and disabled for no-auth reads, which get an uncacheable unique key.
|
|
@@ -4602,20 +5260,25 @@ async function get(path, opts = {}) {
|
|
|
4602
5260
|
const cacheEnabled = opts.cache === true && !opts.bypassCache && principalKey.cacheable;
|
|
4603
5261
|
const now = Date.now();
|
|
4604
5262
|
// Check for valid cache entry when the caller explicitly opted in.
|
|
4605
|
-
|
|
4606
|
-
|
|
5263
|
+
const cachedEntry = getReadCacheEntry(cacheKey, principalKey.principalValue);
|
|
5264
|
+
if (cacheEnabled &&
|
|
5265
|
+
cachedEntry &&
|
|
5266
|
+
now < cachedEntry.expiresAt) {
|
|
5267
|
+
return cachedEntry.data;
|
|
4607
5268
|
}
|
|
4608
5269
|
// If we're bypassing cache, we should still coalesce identical requests
|
|
4609
5270
|
// but need a different key to avoid interfering with regular requests
|
|
4610
5271
|
const requestKey = opts.bypassCache ? `bypass:${cacheKey}` : cacheKey;
|
|
4611
|
-
//
|
|
4612
|
-
//
|
|
4613
|
-
|
|
4614
|
-
|
|
4615
|
-
return
|
|
5272
|
+
// The compact principal hash is only an index bucket. Coalesce only when
|
|
5273
|
+
// the exact opaque credential also matches, so collisions stay isolated.
|
|
5274
|
+
const existingPending = (_a = pendingRequests[requestKey]) === null || _a === void 0 ? void 0 : _a.find(entry => entry.principalValue === principalKey.principalValue);
|
|
5275
|
+
if (existingPending) {
|
|
5276
|
+
return existingPending.promise;
|
|
4616
5277
|
}
|
|
4617
5278
|
// Create a new request promise and store it
|
|
4618
|
-
|
|
5279
|
+
let requestPromise;
|
|
5280
|
+
requestPromise = (async () => {
|
|
5281
|
+
var _a;
|
|
4619
5282
|
try {
|
|
4620
5283
|
const pathIsDocument = normalizedPath.split("/").length % 2 === 0;
|
|
4621
5284
|
// Cache miss or bypass - proceed with HTTP API request
|
|
@@ -4646,10 +5309,7 @@ async function get(path, opts = {}) {
|
|
|
4646
5309
|
const responseData = normalizeReadResult(response.data, pathIsDocument);
|
|
4647
5310
|
// Cache the response only when explicitly requested and principal-bound.
|
|
4648
5311
|
if (cacheEnabled) {
|
|
4649
|
-
|
|
4650
|
-
data: responseData,
|
|
4651
|
-
expiresAt: now + GET_CACHE_TTL
|
|
4652
|
-
};
|
|
5312
|
+
setReadCacheEntry(cacheKey, principalKey.principalValue, responseData, now + GET_CACHE_TTL);
|
|
4653
5313
|
// Periodically clean up expired cache entries (every 5 seconds)
|
|
4654
5314
|
if (now - lastCacheCleanup > 5000) {
|
|
4655
5315
|
cleanupExpiredCache();
|
|
@@ -4660,12 +5320,21 @@ async function get(path, opts = {}) {
|
|
|
4660
5320
|
return responseData;
|
|
4661
5321
|
}
|
|
4662
5322
|
finally {
|
|
4663
|
-
// Remove this request
|
|
4664
|
-
|
|
5323
|
+
// Remove only this exact request. A colliding principal can legitimately
|
|
5324
|
+
// have another request in the same compact-hash bucket.
|
|
5325
|
+
const bucket = pendingRequests[requestKey];
|
|
5326
|
+
const index = (_a = bucket === null || bucket === void 0 ? void 0 : bucket.findIndex(entry => entry.promise === requestPromise)) !== null && _a !== void 0 ? _a : -1;
|
|
5327
|
+
if (index >= 0)
|
|
5328
|
+
bucket.splice(index, 1);
|
|
5329
|
+
if ((bucket === null || bucket === void 0 ? void 0 : bucket.length) === 0)
|
|
5330
|
+
delete pendingRequests[requestKey];
|
|
4665
5331
|
}
|
|
4666
5332
|
})();
|
|
4667
|
-
// Store the promise so
|
|
4668
|
-
pendingRequests[requestKey] =
|
|
5333
|
+
// Store the promise so only callers with the exact same credential join it.
|
|
5334
|
+
((_b = pendingRequests[requestKey]) !== null && _b !== void 0 ? _b : (pendingRequests[requestKey] = [])).push({
|
|
5335
|
+
principalValue: principalKey.principalValue,
|
|
5336
|
+
promise: requestPromise,
|
|
5337
|
+
});
|
|
4669
5338
|
// Return the promise
|
|
4670
5339
|
return requestPromise;
|
|
4671
5340
|
}
|
|
@@ -4677,9 +5346,12 @@ async function get(path, opts = {}) {
|
|
|
4677
5346
|
function cleanupExpiredCache() {
|
|
4678
5347
|
const now = Date.now();
|
|
4679
5348
|
Object.keys(getCache).forEach(key => {
|
|
4680
|
-
|
|
5349
|
+
var _a, _b;
|
|
5350
|
+
const activeEntries = (_b = (_a = getCache[key]) === null || _a === void 0 ? void 0 : _a.filter(entry => entry.expiresAt >= now)) !== null && _b !== void 0 ? _b : [];
|
|
5351
|
+
if (activeEntries.length > 0)
|
|
5352
|
+
getCache[key] = activeEntries;
|
|
5353
|
+
else
|
|
4681
5354
|
delete getCache[key];
|
|
4682
|
-
}
|
|
4683
5355
|
});
|
|
4684
5356
|
lastCacheCleanup = now;
|
|
4685
5357
|
}
|
|
@@ -4941,8 +5613,11 @@ async function getMany(paths, opts = {}) {
|
|
|
4941
5613
|
for (let i = 0; i < normalizedPaths.length; i++) {
|
|
4942
5614
|
const normalizedPath = normalizedPaths[i];
|
|
4943
5615
|
const cacheKey = `${principalKey.key}|${normalizedPath}:`;
|
|
4944
|
-
|
|
4945
|
-
|
|
5616
|
+
const cachedEntry = getReadCacheEntry(cacheKey, principalKey.principalValue);
|
|
5617
|
+
if (cacheEnabled &&
|
|
5618
|
+
cachedEntry &&
|
|
5619
|
+
now < cachedEntry.expiresAt) {
|
|
5620
|
+
results[i] = { path: normalizedPath, data: cachedEntry.data };
|
|
4946
5621
|
}
|
|
4947
5622
|
else {
|
|
4948
5623
|
uncachedIndices.push(i);
|
|
@@ -4979,10 +5654,7 @@ async function getMany(paths, opts = {}) {
|
|
|
4979
5654
|
results[originalIndex] = normalizedResult;
|
|
4980
5655
|
if (!normalizedResult.error && cacheEnabled) {
|
|
4981
5656
|
const cacheKey = `${principalKey.key}|${normalizedPath}:`;
|
|
4982
|
-
|
|
4983
|
-
data: normalizedResult.data,
|
|
4984
|
-
expiresAt: now + GET_CACHE_TTL
|
|
4985
|
-
};
|
|
5657
|
+
setReadCacheEntry(cacheKey, principalKey.principalValue, normalizedResult.data, now + GET_CACHE_TTL);
|
|
4986
5658
|
}
|
|
4987
5659
|
}
|
|
4988
5660
|
else {
|
|
@@ -5112,13 +5784,12 @@ async function runExpressionMany(many) {
|
|
|
5112
5784
|
* collection's policy `delete` rule and broadcasts a delete to subscribers.
|
|
5113
5785
|
*/
|
|
5114
5786
|
async function set(path, document, options) {
|
|
5115
|
-
|
|
5116
|
-
//
|
|
5117
|
-
|
|
5118
|
-
return result;
|
|
5787
|
+
// setMany already invalidates the read cache for the affected document and
|
|
5788
|
+
// collection paths — no second sweep needed here.
|
|
5789
|
+
return setMany([{ path, document }], options);
|
|
5119
5790
|
}
|
|
5120
5791
|
async function setMany(many, options) {
|
|
5121
|
-
var _a, _b, _c, _d, _e, _f, _g;
|
|
5792
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
|
|
5122
5793
|
// Returns the data that was set, or undefined if the document was already set.
|
|
5123
5794
|
try {
|
|
5124
5795
|
const config = await getConfig();
|
|
@@ -5174,6 +5845,9 @@ async function setMany(many, options) {
|
|
|
5174
5845
|
// the API did nothing and gave us the required info to run this transaction.
|
|
5175
5846
|
const { transactions, offchainTransaction } = setResponse.data;
|
|
5176
5847
|
// Handle offchain transaction flow (for 'offchain' protocol apps)
|
|
5848
|
+
if (offchainTransaction && Array.isArray(transactions) && transactions.length > 0) {
|
|
5849
|
+
throw new Error('Ambiguous write response mixes offchain and onchain transaction targets');
|
|
5850
|
+
}
|
|
5177
5851
|
if (offchainTransaction) {
|
|
5178
5852
|
const transactionResult = await handleOffchainTransaction(offchainTransaction, authProvider, options);
|
|
5179
5853
|
return Object.assign(Object.assign({}, documents.map(d => d.document)), { transactionId: transactionResult.signature, signedTransaction: transactionResult.signedTransaction });
|
|
@@ -5183,9 +5857,35 @@ async function setMany(many, options) {
|
|
|
5183
5857
|
throw new Error(`Expected exactly one on-chain transaction, received ${Array.isArray(transactions) ? transactions.length : 0}`);
|
|
5184
5858
|
}
|
|
5185
5859
|
const curTx = transactions[0];
|
|
5860
|
+
const hasEvmTarget = (curTx === null || curTx === void 0 ? void 0 : curTx.evmTransaction) != null;
|
|
5861
|
+
const hasSerializedSolanaTarget = (curTx === null || curTx === void 0 ? void 0 : curTx.serializedTransaction) != null;
|
|
5862
|
+
const hasBuiltSolanaTarget = (curTx === null || curTx === void 0 ? void 0 : curTx.transactionArgs) != null || (curTx === null || curTx === void 0 ? void 0 : curTx.remainingAccounts) != null ||
|
|
5863
|
+
(curTx === null || curTx === void 0 ? void 0 : curTx.idl) != null || (curTx === null || curTx === void 0 ? void 0 : curTx.txData) != null || (curTx === null || curTx === void 0 ? void 0 : curTx.preInstructions) != null;
|
|
5864
|
+
if ((hasEvmTarget && (hasSerializedSolanaTarget || hasBuiltSolanaTarget)) ||
|
|
5865
|
+
(hasSerializedSolanaTarget && hasBuiltSolanaTarget)) {
|
|
5866
|
+
throw new Error('Ambiguous on-chain write response mixes EVM and/or Solana transaction target formats');
|
|
5867
|
+
}
|
|
5186
5868
|
let transactionResult;
|
|
5187
5869
|
const expectedWritePaths = documents.map(d => d.destinationPath);
|
|
5188
|
-
|
|
5870
|
+
// EVM onchain write: the worker returns a PRE-BUILT EIP-1559 tx on
|
|
5871
|
+
// `transactions[0].evmTransaction` (the EVM twin of Solana's pre-built
|
|
5872
|
+
// `serializedTransaction`). WORKER 202 CONTRACT (field name + shape the
|
|
5873
|
+
// worker side must emit to match):
|
|
5874
|
+
// { transactions: [ { evmTransaction: {
|
|
5875
|
+
// to, data, value?, chainId, gas?, maxFeePerGas?, maxPriorityFeePerGas?, nonce?
|
|
5876
|
+
// } } ] }
|
|
5877
|
+
// We hand it to the EVM auth provider's runTransaction(evmTx, undefined, options)
|
|
5878
|
+
// (eth_sendTransaction — the wallet signs + submits). syncItems below is
|
|
5879
|
+
// identical to the Solana path.
|
|
5880
|
+
if (curTx.evmTransaction) {
|
|
5881
|
+
assertEvmTransactionMatchesWrite(curTx.evmTransaction, {
|
|
5882
|
+
appId: config.appId,
|
|
5883
|
+
documents,
|
|
5884
|
+
policy: (_k = (_j = (_h = options === null || options === void 0 ? void 0 : options._overrides) === null || _h === void 0 ? void 0 : _h._evmTransactionPolicy) !== null && _j !== void 0 ? _j : config.evmWalletConfig) !== null && _k !== void 0 ? _k : null,
|
|
5885
|
+
});
|
|
5886
|
+
transactionResult = await handleEvmTransaction(curTx.evmTransaction, authProvider, options);
|
|
5887
|
+
}
|
|
5888
|
+
else if (curTx.serializedTransaction) {
|
|
5189
5889
|
transactionResult = await handlePreBuiltTransaction(curTx, authProvider, options, expectedWritePaths);
|
|
5190
5890
|
}
|
|
5191
5891
|
else {
|
|
@@ -5209,7 +5909,7 @@ async function setMany(many, options) {
|
|
|
5209
5909
|
else if (setResponse.data &&
|
|
5210
5910
|
typeof setResponse.data === 'object' &&
|
|
5211
5911
|
setResponse.data.success === true) {
|
|
5212
|
-
const
|
|
5912
|
+
const _l = setResponse.data, { success: _success } = _l, rest = __rest$1(_l, ["success"]);
|
|
5213
5913
|
return Object.assign(Object.assign(Object.assign({}, documents.map(d => d.document)), rest), { transactionId: null });
|
|
5214
5914
|
}
|
|
5215
5915
|
else {
|
|
@@ -5309,6 +6009,14 @@ async function setMany(many, options) {
|
|
|
5309
6009
|
const transactionResult = await authProvider.runTransaction(undefined, solTransaction, options);
|
|
5310
6010
|
return transactionResult;
|
|
5311
6011
|
}
|
|
6012
|
+
// EVM onchain write: hand the worker's pre-built EIP-1559 tx to the EVM auth
|
|
6013
|
+
// provider, which eth_sendTransaction-s it (the wallet signs + submits in one
|
|
6014
|
+
// step). Mirrors handlePreBuiltTransaction's shape but routes through the EVM
|
|
6015
|
+
// slot of runTransaction (Solana slot undefined). shouldSubmitTx is honored by
|
|
6016
|
+
// the provider itself.
|
|
6017
|
+
async function handleEvmTransaction(evmTransaction, authProvider, options) {
|
|
6018
|
+
return authProvider.runTransaction(evmTransaction, undefined, options);
|
|
6019
|
+
}
|
|
5312
6020
|
async function handlePreBuiltTransaction(tx, authProvider, options, expectedWritePaths) {
|
|
5313
6021
|
var _a, _b, _c;
|
|
5314
6022
|
const config = await getConfig();
|
|
@@ -5549,10 +6257,37 @@ var operations = /*#__PURE__*/Object.freeze({
|
|
|
5549
6257
|
// ============ Global State ============
|
|
5550
6258
|
// One connection per routing target (appId, or appId#room#<collection>/<roomId>)
|
|
5551
6259
|
const connections = new Map();
|
|
6260
|
+
// Coalesce concurrent first-use/auth-preflight calls for the same routing target.
|
|
6261
|
+
// React commonly mounts several subscriptions in one render; without this map,
|
|
6262
|
+
// every call can pass the empty `connections` check, await auth, and create an
|
|
6263
|
+
// orphan ReconnectingWebSocket before the first call publishes its connection.
|
|
6264
|
+
// Keyed by the identity-bucket lease key (see connectionLeaseKey) so two distinct
|
|
6265
|
+
// wallet tokens never share one in-flight socket even under a compact-hash collision.
|
|
6266
|
+
const connectionFlights = new Map();
|
|
6267
|
+
// A subscriber holds a routing-target lease from connection acquisition until
|
|
6268
|
+
// its callback/subscription is registered. The last old-route unsubscribe must
|
|
6269
|
+
// not close a connection already captured by a new route awaiting auth. Keyed by
|
|
6270
|
+
// the same identity-bucket lease key as connectionFlights.
|
|
6271
|
+
const connectionAcquisitions = new Map();
|
|
6272
|
+
// Compact identity hashes remain useful bounded-size indexes, but collisions
|
|
6273
|
+
// share a bucket instead of sharing authority. Each exact bearer keeps its own
|
|
6274
|
+
// cache entry so collision isolation does not evict another principal's cache.
|
|
5552
6275
|
const responseCache = new Map();
|
|
5553
6276
|
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
5554
6277
|
const TOKEN_REFRESH_BUFFER = 10 * 60 * 1000; // Refresh token 10 minutes before expiry
|
|
5555
6278
|
const TOKEN_CHECK_INTERVAL = 5 * 60 * 1000; // Check token freshness every 5 minutes
|
|
6279
|
+
function getResponseCacheEntry(cacheKey, identityToken) {
|
|
6280
|
+
var _a;
|
|
6281
|
+
return (_a = responseCache.get(cacheKey)) === null || _a === void 0 ? void 0 : _a.find(entry => entry.identityToken === identityToken);
|
|
6282
|
+
}
|
|
6283
|
+
function setResponseCacheEntry(cacheKey, identityToken, data) {
|
|
6284
|
+
var _a;
|
|
6285
|
+
const now = Date.now();
|
|
6286
|
+
const bucket = (_a = responseCache.get(cacheKey)) !== null && _a !== void 0 ? _a : [];
|
|
6287
|
+
const reusableEntries = bucket.filter(entry => (entry.identityToken !== identityToken && now - entry.timestamp < CACHE_TTL));
|
|
6288
|
+
reusableEntries.push({ data, timestamp: now, identityToken });
|
|
6289
|
+
responseCache.set(cacheKey, reusableEntries);
|
|
6290
|
+
}
|
|
5556
6291
|
// ============ WebSocket Config ============
|
|
5557
6292
|
const BASE_MIN_RECONNECT_DELAY_MS = 1000;
|
|
5558
6293
|
const MIN_RECONNECT_DELAY_JITTER_MS = 1000;
|
|
@@ -5560,6 +6295,10 @@ const MAX_RECONNECT_DELAY_MS = 300000;
|
|
|
5560
6295
|
const RECONNECT_DELAY_GROW_FACTOR = 1.8;
|
|
5561
6296
|
const MIN_BROWSER_RECONNECT_INTERVAL_MS = 5000;
|
|
5562
6297
|
const WS_AUTH_EXPIRED_CODE = 'auth_expired';
|
|
6298
|
+
// React runs old-route effect cleanup before new-route effects. Keep an
|
|
6299
|
+
// otherwise-idle socket for one short handoff window so the new route can
|
|
6300
|
+
// cancel teardown and reuse it; genuinely idle sockets still close promptly.
|
|
6301
|
+
const IDLE_CONNECTION_CLOSE_GRACE_MS = 1000;
|
|
5563
6302
|
const WS_CONFIG = {
|
|
5564
6303
|
// Keep retrying indefinitely so long outages recover without page refresh.
|
|
5565
6304
|
maxRetries: Infinity,
|
|
@@ -5590,12 +6329,12 @@ function hashForKey(value) {
|
|
|
5590
6329
|
}
|
|
5591
6330
|
/**
|
|
5592
6331
|
* SECURITY (H1): Response-cache keys must be scoped to the caller's identity, not
|
|
5593
|
-
* just the path/filter/shape. The `identity` prefix
|
|
5594
|
-
*
|
|
5595
|
-
*
|
|
5596
|
-
* / browser login-switch; this cache has a
|
|
5597
|
-
* before entries are read or written; no-auth
|
|
5598
|
-
* implicit anonymous cache bucket.
|
|
6332
|
+
* just the path/filter/shape. The compact `identity` prefix selects a bucket;
|
|
6333
|
+
* exact opaque bearer equality is required inside it before reuse. Together they
|
|
6334
|
+
* keep a private snapshot from being delivered to a different user with the same
|
|
6335
|
+
* options (shared process / SSR worker / browser login-switch; this cache has a
|
|
6336
|
+
* 5-minute TTL). Callers must opt in before entries are read or written; no-auth
|
|
6337
|
+
* subscriptions never populate an implicit anonymous cache bucket.
|
|
5599
6338
|
*/
|
|
5600
6339
|
function getCacheKey(path, prompt, shape, limit, cursor, filter, identity) {
|
|
5601
6340
|
const normalizedPath = path.startsWith('/') ? path.slice(1) : path;
|
|
@@ -5633,16 +6372,16 @@ function livePrincipalFromIdToken(idToken) {
|
|
|
5633
6372
|
}
|
|
5634
6373
|
function authSnapshotFromToken(idToken) {
|
|
5635
6374
|
return {
|
|
5636
|
-
|
|
6375
|
+
tokenValue: idToken !== null && idToken !== void 0 ? idToken : null,
|
|
5637
6376
|
principal: livePrincipalFromIdToken(idToken),
|
|
5638
6377
|
};
|
|
5639
6378
|
}
|
|
5640
6379
|
function connectionMatchesAuthToken(connection, idToken) {
|
|
5641
6380
|
const current = authSnapshotFromToken(idToken);
|
|
5642
|
-
if (!current.
|
|
5643
|
-
return connection.
|
|
6381
|
+
if (!current.tokenValue) {
|
|
6382
|
+
return connection.authTokenValue === null && connection.authPrincipal === null;
|
|
5644
6383
|
}
|
|
5645
|
-
if (connection.
|
|
6384
|
+
if (connection.authTokenValue && connection.authTokenValue === current.tokenValue) {
|
|
5646
6385
|
return true;
|
|
5647
6386
|
}
|
|
5648
6387
|
return !!connection.authPrincipal && !!current.principal && connection.authPrincipal === current.principal;
|
|
@@ -5651,7 +6390,7 @@ async function canSendAuthenticatedLiveIntent(connection, isServer) {
|
|
|
5651
6390
|
const currentToken = await getIdToken(isServer);
|
|
5652
6391
|
if (!currentToken)
|
|
5653
6392
|
return false;
|
|
5654
|
-
if (!connection.
|
|
6393
|
+
if (!connection.authTokenValue && !connection.authPrincipal)
|
|
5655
6394
|
return false;
|
|
5656
6395
|
return connectionMatchesAuthToken(connection, currentToken);
|
|
5657
6396
|
}
|
|
@@ -5664,19 +6403,19 @@ async function getSubscriptionIdentity(effectiveAppId, isServer, overrides) {
|
|
|
5664
6403
|
const bearer = bearerFromAuthHeaders(await overrides._getAuthHeaders());
|
|
5665
6404
|
const principal = principalFromIdToken(bearer);
|
|
5666
6405
|
return principal
|
|
5667
|
-
? { key: `${effectiveAppId}:o${principal}`, cacheable: true }
|
|
5668
|
-
: { key: `${effectiveAppId}:oanon`, cacheable: false };
|
|
6406
|
+
? { key: `${effectiveAppId}:o${principal}`, cacheable: true, token: bearer }
|
|
6407
|
+
: { key: `${effectiveAppId}:oanon`, cacheable: false, token: null };
|
|
5669
6408
|
}
|
|
5670
6409
|
catch (_a) {
|
|
5671
6410
|
// Couldn't resolve the override identity — do not use response cache.
|
|
5672
|
-
return { key: `${effectiveAppId}:o-uncacheable-${safeBtoa(String(connectionEpoch++))}`, cacheable: false };
|
|
6411
|
+
return { key: `${effectiveAppId}:o-uncacheable-${safeBtoa(String(connectionEpoch++))}`, cacheable: false, token: null };
|
|
5673
6412
|
}
|
|
5674
6413
|
}
|
|
5675
6414
|
const idToken = await getIdToken(isServer);
|
|
5676
6415
|
const principal = principalFromIdToken(idToken);
|
|
5677
6416
|
return principal
|
|
5678
|
-
? { key: `${effectiveAppId}:${principal}`, cacheable: true }
|
|
5679
|
-
: { key: `${effectiveAppId}:anon`, cacheable: false };
|
|
6417
|
+
? { key: `${effectiveAppId}:${principal}`, cacheable: true, token: idToken !== null && idToken !== void 0 ? idToken : null }
|
|
6418
|
+
: { key: `${effectiveAppId}:anon`, cacheable: false, token: null };
|
|
5680
6419
|
}
|
|
5681
6420
|
/** Extract the bare bearer token from a `{ Authorization: 'Bearer <jwt>' }` map. */
|
|
5682
6421
|
function bearerFromAuthHeaders(headers) {
|
|
@@ -5732,7 +6471,7 @@ function failConnectionAuth(connection, error) {
|
|
|
5732
6471
|
connection.authFailure = error;
|
|
5733
6472
|
connection.pendingAuthToken = null;
|
|
5734
6473
|
connection.pendingAuthSnapshot = null;
|
|
5735
|
-
connection.
|
|
6474
|
+
connection.authTokenValue = null;
|
|
5736
6475
|
connection.authPrincipal = null;
|
|
5737
6476
|
connection.isConnecting = false;
|
|
5738
6477
|
connection.isConnected = false;
|
|
@@ -5743,6 +6482,7 @@ function failConnectionAuth(connection, error) {
|
|
|
5743
6482
|
connection.pendingSubscriptions.clear();
|
|
5744
6483
|
for (const subscription of connection.subscriptions.values()) {
|
|
5745
6484
|
notifyErrorCallbacks(subscription, error);
|
|
6485
|
+
rejectSubscriptionRefresh(subscription, error);
|
|
5746
6486
|
}
|
|
5747
6487
|
}
|
|
5748
6488
|
function scheduleTokenRefresh(connection, isServer) {
|
|
@@ -5935,7 +6675,136 @@ function replaySubscriptions(connection) {
|
|
|
5935
6675
|
sendSubscribe(connection, sub);
|
|
5936
6676
|
}
|
|
5937
6677
|
}
|
|
5938
|
-
|
|
6678
|
+
/**
|
|
6679
|
+
* Derive the stable identity-bucket lease key used to coalesce (singleflight)
|
|
6680
|
+
* and refcount (acquisition / idle-close) connection use. Ambient app/room
|
|
6681
|
+
* connections key by their routing target; wallet-scoped connections pin the
|
|
6682
|
+
* exact bearer so two distinct tokens never share a flight or a lease, even
|
|
6683
|
+
* when their compact `#id#<principalKey>` hashes collide.
|
|
6684
|
+
*/
|
|
6685
|
+
function connectionLeaseKey(appId, routePath, principalKey, principalToken) {
|
|
6686
|
+
const roomKey = roomKeyFromRoutePath(routePath);
|
|
6687
|
+
const base = roomKey ? `${appId}#room#${roomKey}` : appId;
|
|
6688
|
+
if (!principalKey)
|
|
6689
|
+
return base;
|
|
6690
|
+
const bucket = `${base}#id#${principalKey}`;
|
|
6691
|
+
return principalToken ? `${bucket}#tok#${principalToken}` : bucket;
|
|
6692
|
+
}
|
|
6693
|
+
function cancelIdleConnectionClose(connection) {
|
|
6694
|
+
if (!connection.idleCloseTimer)
|
|
6695
|
+
return;
|
|
6696
|
+
clearTimeout(connection.idleCloseTimer);
|
|
6697
|
+
connection.idleCloseTimer = null;
|
|
6698
|
+
}
|
|
6699
|
+
function closeConnectionNow(connection) {
|
|
6700
|
+
if (connections.get(connection.key) !== connection)
|
|
6701
|
+
return;
|
|
6702
|
+
if (!connection.ws)
|
|
6703
|
+
return;
|
|
6704
|
+
cancelIdleConnectionClose(connection);
|
|
6705
|
+
if (connection.tokenRefreshTimer) {
|
|
6706
|
+
clearInterval(connection.tokenRefreshTimer);
|
|
6707
|
+
connection.tokenRefreshTimer = null;
|
|
6708
|
+
}
|
|
6709
|
+
const ws = connection.ws;
|
|
6710
|
+
connection.ws = null;
|
|
6711
|
+
connection.isConnecting = false;
|
|
6712
|
+
connection.isConnected = false;
|
|
6713
|
+
connection.isAuthenticating = false;
|
|
6714
|
+
const closeError = new Error('WebSocket connection closed');
|
|
6715
|
+
for (const subscription of connection.subscriptions.values()) {
|
|
6716
|
+
rejectSubscriptionRefresh(subscription, closeError);
|
|
6717
|
+
}
|
|
6718
|
+
connections.delete(connection.key);
|
|
6719
|
+
try {
|
|
6720
|
+
ws.close();
|
|
6721
|
+
}
|
|
6722
|
+
catch ( /* already closing */_a) { /* already closing */ }
|
|
6723
|
+
}
|
|
6724
|
+
function scheduleConnectionCloseIfIdle(connection) {
|
|
6725
|
+
var _a;
|
|
6726
|
+
if (connections.get(connection.key) !== connection)
|
|
6727
|
+
return;
|
|
6728
|
+
if (connection.subscriptions.size > 0)
|
|
6729
|
+
return;
|
|
6730
|
+
if (((_a = connectionAcquisitions.get(connection.acquisitionKey)) !== null && _a !== void 0 ? _a : 0) > 0)
|
|
6731
|
+
return;
|
|
6732
|
+
if (!connection.ws || connection.idleCloseTimer)
|
|
6733
|
+
return;
|
|
6734
|
+
connection.idleCloseTimer = setTimeout(() => {
|
|
6735
|
+
var _a;
|
|
6736
|
+
connection.idleCloseTimer = null;
|
|
6737
|
+
if (connection.subscriptions.size > 0)
|
|
6738
|
+
return;
|
|
6739
|
+
if (((_a = connectionAcquisitions.get(connection.acquisitionKey)) !== null && _a !== void 0 ? _a : 0) > 0)
|
|
6740
|
+
return;
|
|
6741
|
+
closeConnectionNow(connection);
|
|
6742
|
+
}, IDLE_CONNECTION_CLOSE_GRACE_MS);
|
|
6743
|
+
}
|
|
6744
|
+
/**
|
|
6745
|
+
* Acquire a connection under a routing-target lease. The lease is refcounted on
|
|
6746
|
+
* the identity-bucket key and held across the caller's auth preflight so a
|
|
6747
|
+
* concurrent old-route teardown cannot close a socket a new route is about to
|
|
6748
|
+
* reuse. The lease is released once the caller has registered its
|
|
6749
|
+
* subscription/callback.
|
|
6750
|
+
*/
|
|
6751
|
+
async function acquireConnection(appId, isServer, routePath, authTokenProvider, principalKey, principalToken) {
|
|
6752
|
+
var _a;
|
|
6753
|
+
const leaseKey = connectionLeaseKey(appId, routePath, principalKey, principalToken);
|
|
6754
|
+
connectionAcquisitions.set(leaseKey, ((_a = connectionAcquisitions.get(leaseKey)) !== null && _a !== void 0 ? _a : 0) + 1);
|
|
6755
|
+
let released = false;
|
|
6756
|
+
let releaseTimer = null;
|
|
6757
|
+
const release = (connection) => {
|
|
6758
|
+
var _a;
|
|
6759
|
+
if (released)
|
|
6760
|
+
return;
|
|
6761
|
+
released = true;
|
|
6762
|
+
if (releaseTimer) {
|
|
6763
|
+
clearTimeout(releaseTimer);
|
|
6764
|
+
releaseTimer = null;
|
|
6765
|
+
}
|
|
6766
|
+
const remaining = ((_a = connectionAcquisitions.get(leaseKey)) !== null && _a !== void 0 ? _a : 1) - 1;
|
|
6767
|
+
if (remaining > 0)
|
|
6768
|
+
connectionAcquisitions.set(leaseKey, remaining);
|
|
6769
|
+
else
|
|
6770
|
+
connectionAcquisitions.delete(leaseKey);
|
|
6771
|
+
if (connection)
|
|
6772
|
+
scheduleConnectionCloseIfIdle(connection);
|
|
6773
|
+
};
|
|
6774
|
+
try {
|
|
6775
|
+
const connection = await getOrCreateConnection(appId, isServer, routePath, authTokenProvider, principalKey, principalToken);
|
|
6776
|
+
const releaseConnection = () => release(connection);
|
|
6777
|
+
// Registration is synchronous after the caller's await. This fail-safe
|
|
6778
|
+
// releases on the next task even if an option getter/callback throws
|
|
6779
|
+
// before subscribeV2 reaches its explicit release path.
|
|
6780
|
+
releaseTimer = setTimeout(releaseConnection, 0);
|
|
6781
|
+
return { connection, release: releaseConnection };
|
|
6782
|
+
}
|
|
6783
|
+
catch (error) {
|
|
6784
|
+
release();
|
|
6785
|
+
throw error;
|
|
6786
|
+
}
|
|
6787
|
+
}
|
|
6788
|
+
async function getOrCreateConnection(appId, isServer, routePath, authTokenProvider, principalKey, principalToken) {
|
|
6789
|
+
// Singleflight: concurrent first-use of the same routing target shares one
|
|
6790
|
+
// in-flight connection rather than each creating an orphan socket. Keyed by
|
|
6791
|
+
// the identity-bucket lease key so distinct wallet tokens never coalesce.
|
|
6792
|
+
const leaseKey = connectionLeaseKey(appId, routePath, principalKey, principalToken);
|
|
6793
|
+
const activeFlight = connectionFlights.get(leaseKey);
|
|
6794
|
+
if (activeFlight)
|
|
6795
|
+
return activeFlight;
|
|
6796
|
+
const flight = getOrCreateConnectionOnce(appId, isServer, routePath, authTokenProvider, principalKey, principalToken, leaseKey);
|
|
6797
|
+
connectionFlights.set(leaseKey, flight);
|
|
6798
|
+
try {
|
|
6799
|
+
return await flight;
|
|
6800
|
+
}
|
|
6801
|
+
finally {
|
|
6802
|
+
if (connectionFlights.get(leaseKey) === flight) {
|
|
6803
|
+
connectionFlights.delete(leaseKey);
|
|
6804
|
+
}
|
|
6805
|
+
}
|
|
6806
|
+
}
|
|
6807
|
+
async function getOrCreateConnectionOnce(appId, isServer, routePath, authTokenProvider, principalKey, principalToken, leaseKey) {
|
|
5939
6808
|
attachBrowserReconnectHooksOnce();
|
|
5940
6809
|
// A per-room subscription gets its OWN connection routed to the room DO; all
|
|
5941
6810
|
// other subscriptions share the app-level connection (project DO). The worker
|
|
@@ -5946,9 +6815,38 @@ async function getOrCreateConnection(appId, isServer, routePath, authTokenProvid
|
|
|
5946
6815
|
// keyed by the wallet identity, so its WS authenticates as that wallet and
|
|
5947
6816
|
// never shares a connection (or token) with any top-level server caller.
|
|
5948
6817
|
const base = roomKey ? `${appId}#room#${roomKey}` : appId;
|
|
5949
|
-
const
|
|
5950
|
-
|
|
6818
|
+
const identityBucketKey = principalKey
|
|
6819
|
+
? `${base}#id#${principalKey}`
|
|
6820
|
+
: base;
|
|
6821
|
+
let connKey = identityBucketKey;
|
|
6822
|
+
let connection;
|
|
6823
|
+
if (principalKey && principalToken) {
|
|
6824
|
+
// The 31-bit hash selects a small bucket only. Exact token equality is
|
|
6825
|
+
// authoritative, and a rare collision gets an independent map slot.
|
|
6826
|
+
for (const [candidateKey, candidate] of connections) {
|
|
6827
|
+
if ((candidateKey === identityBucketKey || candidateKey.startsWith(`${identityBucketKey}#collision#`)) &&
|
|
6828
|
+
candidate.connectionIdentityToken === principalToken) {
|
|
6829
|
+
connKey = candidateKey;
|
|
6830
|
+
connection = candidate;
|
|
6831
|
+
break;
|
|
6832
|
+
}
|
|
6833
|
+
}
|
|
6834
|
+
if (!connection && connections.has(connKey)) {
|
|
6835
|
+
let collisionIndex = 1;
|
|
6836
|
+
while (connections.has(`${identityBucketKey}#collision#${collisionIndex}`))
|
|
6837
|
+
collisionIndex += 1;
|
|
6838
|
+
connKey = `${identityBucketKey}#collision#${collisionIndex}`;
|
|
6839
|
+
}
|
|
6840
|
+
}
|
|
6841
|
+
else {
|
|
6842
|
+
connection = connections.get(connKey);
|
|
6843
|
+
}
|
|
5951
6844
|
if (connection && connection.ws) {
|
|
6845
|
+
// We may reuse this connection; cancel any pending idle-close so it is
|
|
6846
|
+
// not torn down during the auth preflight below. The held acquisition
|
|
6847
|
+
// lease also guards this window, but cancelling here avoids a needless
|
|
6848
|
+
// grace-timer round-trip on the common reuse path.
|
|
6849
|
+
cancelIdleConnectionClose(connection);
|
|
5952
6850
|
if (connection.authFailure) {
|
|
5953
6851
|
throw connection.authFailure;
|
|
5954
6852
|
}
|
|
@@ -5964,19 +6862,7 @@ async function getOrCreateConnection(appId, isServer, routePath, authTokenProvid
|
|
|
5964
6862
|
if (connection.isConnected &&
|
|
5965
6863
|
!connection.isAuthenticating &&
|
|
5966
6864
|
!connectionMatchesAuthToken(connection, currentAuthToken)) {
|
|
5967
|
-
|
|
5968
|
-
clearInterval(connection.tokenRefreshTimer);
|
|
5969
|
-
connection.tokenRefreshTimer = null;
|
|
5970
|
-
}
|
|
5971
|
-
try {
|
|
5972
|
-
connection.ws.close();
|
|
5973
|
-
}
|
|
5974
|
-
catch (_a) {
|
|
5975
|
-
// Already closing.
|
|
5976
|
-
}
|
|
5977
|
-
connection.ws = null;
|
|
5978
|
-
connection.isConnected = false;
|
|
5979
|
-
connections.delete(connection.key);
|
|
6865
|
+
closeConnectionNow(connection);
|
|
5980
6866
|
}
|
|
5981
6867
|
else {
|
|
5982
6868
|
connection.authFailure = null;
|
|
@@ -5997,13 +6883,16 @@ async function getOrCreateConnection(appId, isServer, routePath, authTokenProvid
|
|
|
5997
6883
|
isAuthenticating: false,
|
|
5998
6884
|
appId,
|
|
5999
6885
|
key: connKey,
|
|
6886
|
+
acquisitionKey: leaseKey,
|
|
6000
6887
|
routePath: roomKey ? routePath : undefined,
|
|
6001
6888
|
authTokenProvider,
|
|
6889
|
+
connectionIdentityToken: principalToken !== null && principalToken !== void 0 ? principalToken : null,
|
|
6002
6890
|
pendingAuthToken: null,
|
|
6003
6891
|
pendingAuthSnapshot: null,
|
|
6004
|
-
|
|
6892
|
+
authTokenValue: null,
|
|
6005
6893
|
authPrincipal: null,
|
|
6006
6894
|
tokenRefreshTimer: null,
|
|
6895
|
+
idleCloseTimer: null,
|
|
6007
6896
|
consecutiveAuthFailures: 0,
|
|
6008
6897
|
authFailure: null,
|
|
6009
6898
|
};
|
|
@@ -6080,7 +6969,7 @@ async function getOrCreateConnection(appId, isServer, routePath, authTokenProvid
|
|
|
6080
6969
|
}
|
|
6081
6970
|
else {
|
|
6082
6971
|
connection.isAuthenticating = false;
|
|
6083
|
-
connection.
|
|
6972
|
+
connection.authTokenValue = null;
|
|
6084
6973
|
connection.authPrincipal = null;
|
|
6085
6974
|
replaySubscriptions(connection);
|
|
6086
6975
|
}
|
|
@@ -6098,10 +6987,14 @@ async function getOrCreateConnection(appId, isServer, routePath, authTokenProvid
|
|
|
6098
6987
|
// Handle errors
|
|
6099
6988
|
ws.addEventListener('error', (event) => {
|
|
6100
6989
|
console.error('[WS v2] WebSocket error:', event);
|
|
6990
|
+
const wsError = new Error('WebSocket error');
|
|
6101
6991
|
for (const [, pending] of connection.pendingSubscriptions) {
|
|
6102
|
-
pending.reject(
|
|
6992
|
+
pending.reject(wsError);
|
|
6103
6993
|
}
|
|
6104
6994
|
connection.pendingSubscriptions.clear();
|
|
6995
|
+
for (const subscription of connection.subscriptions.values()) {
|
|
6996
|
+
rejectSubscriptionRefresh(subscription, wsError);
|
|
6997
|
+
}
|
|
6105
6998
|
});
|
|
6106
6999
|
// Handle close
|
|
6107
7000
|
ws.addEventListener('close', () => {
|
|
@@ -6125,7 +7018,7 @@ function handleServerMessage(connection, message) {
|
|
|
6125
7018
|
switch (message.type) {
|
|
6126
7019
|
case 'authenticated': {
|
|
6127
7020
|
connection.isAuthenticating = false;
|
|
6128
|
-
connection.
|
|
7021
|
+
connection.authTokenValue = (_b = (_a = connection.pendingAuthSnapshot) === null || _a === void 0 ? void 0 : _a.tokenValue) !== null && _b !== void 0 ? _b : null;
|
|
6129
7022
|
connection.authPrincipal = (_d = (_c = connection.pendingAuthSnapshot) === null || _c === void 0 ? void 0 : _c.principal) !== null && _d !== void 0 ? _d : null;
|
|
6130
7023
|
replaySubscriptions(connection);
|
|
6131
7024
|
break;
|
|
@@ -6133,15 +7026,49 @@ function handleServerMessage(connection, message) {
|
|
|
6133
7026
|
case 'subscribed': {
|
|
6134
7027
|
const subscription = connection.subscriptions.get(message.subscriptionId);
|
|
6135
7028
|
if (subscription) {
|
|
7029
|
+
const refresh = subscription.refresh;
|
|
6136
7030
|
// A data update can race ahead of subscribed during reconnect.
|
|
6137
7031
|
// If we already received data for this subscription, treat subscribed
|
|
6138
7032
|
// as an ack only and avoid regressing to an older snapshot.
|
|
6139
|
-
if (
|
|
7033
|
+
if (refresh) {
|
|
7034
|
+
const hadBaseline = subscription.lastData !== undefined;
|
|
7035
|
+
if (subscription.dataRevision === refresh.dataRevisionAtStart) {
|
|
7036
|
+
if (subscription.cache) {
|
|
7037
|
+
const cacheKey = getCacheKey(subscription.path, subscription.prompt, subscription.shape, subscription.limit, subscription.cursor, subscription.filter, subscription.identity);
|
|
7038
|
+
setResponseCacheEntry(cacheKey, subscription.identityToken, message.data);
|
|
7039
|
+
}
|
|
7040
|
+
subscription.lastData = message.data;
|
|
7041
|
+
subscription.dataRevision += 1;
|
|
7042
|
+
if (hadBaseline) {
|
|
7043
|
+
const callbacks = Array.from(refresh.callbacks)
|
|
7044
|
+
.filter((callback) => subscription.callbacks.includes(callback));
|
|
7045
|
+
notifyCallbackOptions(callbacks, message.data);
|
|
7046
|
+
}
|
|
7047
|
+
else {
|
|
7048
|
+
// The refresh joined an original registration or a
|
|
7049
|
+
// reconnect replay before its first baseline arrived.
|
|
7050
|
+
// Every active callback needs that initial snapshot.
|
|
7051
|
+
notifyCallbacks(subscription, message.data);
|
|
7052
|
+
}
|
|
7053
|
+
}
|
|
7054
|
+
else if (refresh.callbacks.size > 0 && subscription.lastData !== undefined) {
|
|
7055
|
+
// A newer authorized data frame arrived after this refresh
|
|
7056
|
+
// began. Do not regress lastData to the older subscribed
|
|
7057
|
+
// snapshot; use that newer frame for callbacks that joined
|
|
7058
|
+
// after it was broadcast.
|
|
7059
|
+
const callbacks = Array.from(refresh.callbacks)
|
|
7060
|
+
.filter((callback) => subscription.callbacks.includes(callback));
|
|
7061
|
+
notifyCallbackOptions(callbacks, subscription.lastData);
|
|
7062
|
+
}
|
|
7063
|
+
resolveSubscriptionRefresh(subscription);
|
|
7064
|
+
}
|
|
7065
|
+
else if (subscription.lastData === undefined) {
|
|
6140
7066
|
if (subscription.cache) {
|
|
6141
7067
|
const cacheKey = getCacheKey(subscription.path, subscription.prompt, subscription.shape, subscription.limit, subscription.cursor, subscription.filter, subscription.identity);
|
|
6142
|
-
|
|
7068
|
+
setResponseCacheEntry(cacheKey, subscription.identityToken, message.data);
|
|
6143
7069
|
}
|
|
6144
7070
|
subscription.lastData = message.data;
|
|
7071
|
+
subscription.dataRevision += 1;
|
|
6145
7072
|
notifyCallbacks(subscription, message.data);
|
|
6146
7073
|
}
|
|
6147
7074
|
}
|
|
@@ -6168,12 +7095,20 @@ function handleServerMessage(connection, message) {
|
|
|
6168
7095
|
// Update cache
|
|
6169
7096
|
if (subscription.cache) {
|
|
6170
7097
|
const cacheKey = getCacheKey(subscription.path, subscription.prompt, subscription.shape, subscription.limit, subscription.cursor, subscription.filter, subscription.identity);
|
|
6171
|
-
|
|
7098
|
+
setResponseCacheEntry(cacheKey, subscription.identityToken, message.data);
|
|
6172
7099
|
}
|
|
6173
7100
|
// Store last data
|
|
6174
7101
|
subscription.lastData = message.data;
|
|
7102
|
+
subscription.dataRevision += 1;
|
|
6175
7103
|
// Notify callbacks
|
|
6176
7104
|
notifyCallbacks(subscription, message.data);
|
|
7105
|
+
// A data frame is itself a full, freshly authorized snapshot.
|
|
7106
|
+
// It satisfies every joined callback even if the duplicate
|
|
7107
|
+
// subscribed ACK was lost; resolving here prevents a false
|
|
7108
|
+
// refresh timeout ten seconds after the UI already rendered.
|
|
7109
|
+
if (subscription.refresh) {
|
|
7110
|
+
resolveSubscriptionRefresh(subscription);
|
|
7111
|
+
}
|
|
6177
7112
|
}
|
|
6178
7113
|
break;
|
|
6179
7114
|
}
|
|
@@ -6260,6 +7195,7 @@ function handleServerMessage(connection, message) {
|
|
|
6260
7195
|
const subscription = connection.subscriptions.get(message.subscriptionId);
|
|
6261
7196
|
if (subscription) {
|
|
6262
7197
|
notifyErrorCallbacks(subscription, wsError);
|
|
7198
|
+
rejectSubscriptionRefresh(subscription, wsError);
|
|
6263
7199
|
}
|
|
6264
7200
|
}
|
|
6265
7201
|
break;
|
|
@@ -6296,13 +7232,10 @@ function addIdsToSubscriptionData(data) {
|
|
|
6296
7232
|
return data.map(withSubscriptionId);
|
|
6297
7233
|
return withSubscriptionId(data);
|
|
6298
7234
|
}
|
|
6299
|
-
function
|
|
7235
|
+
function notifyCallbackOptions(callbacks, data) {
|
|
6300
7236
|
var _a;
|
|
6301
7237
|
// Attach the bare `id` (leaf doc key) to every delivered row (Bug 1).
|
|
6302
7238
|
const decorated = addIdsToSubscriptionData(data);
|
|
6303
|
-
// Snapshot the callbacks array so that unsubscriptions during
|
|
6304
|
-
// notification don't cause callbacks to be skipped.
|
|
6305
|
-
const callbacks = subscription.callbacks.slice();
|
|
6306
7239
|
for (const callback of callbacks) {
|
|
6307
7240
|
try {
|
|
6308
7241
|
(_a = callback.onData) === null || _a === void 0 ? void 0 : _a.call(callback, decorated);
|
|
@@ -6312,23 +7245,33 @@ function notifyCallbacks(subscription, data) {
|
|
|
6312
7245
|
}
|
|
6313
7246
|
}
|
|
6314
7247
|
}
|
|
7248
|
+
function notifyCallbacks(subscription, data) {
|
|
7249
|
+
// Snapshot the callbacks array so that unsubscriptions during
|
|
7250
|
+
// notification don't cause callbacks to be skipped.
|
|
7251
|
+
notifyCallbackOptions(subscription.callbacks.slice(), data);
|
|
7252
|
+
}
|
|
7253
|
+
function notifyErrorCallback(callback, error) {
|
|
7254
|
+
let delivered = false;
|
|
7255
|
+
try {
|
|
7256
|
+
const onError = callback.onError;
|
|
7257
|
+
if (!onError)
|
|
7258
|
+
return false;
|
|
7259
|
+
onError(error);
|
|
7260
|
+
delivered = true;
|
|
7261
|
+
}
|
|
7262
|
+
catch (callbackError) {
|
|
7263
|
+
console.error('[WS v2] Error in subscription error callback:', callbackError);
|
|
7264
|
+
}
|
|
7265
|
+
return delivered;
|
|
7266
|
+
}
|
|
6315
7267
|
function notifyErrorCallbacks(subscription, error) {
|
|
6316
7268
|
let delivered = false;
|
|
6317
7269
|
const callbacks = subscription.callbacks.slice();
|
|
6318
7270
|
for (const callback of callbacks) {
|
|
6319
|
-
|
|
6320
|
-
continue;
|
|
6321
|
-
delivered = true;
|
|
6322
|
-
try {
|
|
6323
|
-
callback.onError(error);
|
|
6324
|
-
}
|
|
6325
|
-
catch (callbackError) {
|
|
6326
|
-
console.error('[WS v2] Error in subscription error callback:', callbackError);
|
|
6327
|
-
}
|
|
7271
|
+
delivered = notifyErrorCallback(callback, error) || delivered;
|
|
6328
7272
|
}
|
|
6329
|
-
if (delivered)
|
|
7273
|
+
if (delivered)
|
|
6330
7274
|
error.__boundedDeliveredToOnError = true;
|
|
6331
|
-
}
|
|
6332
7275
|
return delivered;
|
|
6333
7276
|
}
|
|
6334
7277
|
// WebSocket readyState constants
|
|
@@ -6359,6 +7302,71 @@ function sendSubscribe(connection, subscription) {
|
|
|
6359
7302
|
console.error('[WS v2] Error sending subscribe message:', error);
|
|
6360
7303
|
}
|
|
6361
7304
|
}
|
|
7305
|
+
function resolveSubscriptionRefresh(subscription) {
|
|
7306
|
+
const refresh = subscription.refresh;
|
|
7307
|
+
if (!refresh)
|
|
7308
|
+
return;
|
|
7309
|
+
subscription.refresh = null;
|
|
7310
|
+
if (refresh.timer)
|
|
7311
|
+
clearTimeout(refresh.timer);
|
|
7312
|
+
refresh.callbacks.clear();
|
|
7313
|
+
refresh.resolve();
|
|
7314
|
+
}
|
|
7315
|
+
function rejectSubscriptionRefresh(subscription, error) {
|
|
7316
|
+
const refresh = subscription.refresh;
|
|
7317
|
+
if (!refresh)
|
|
7318
|
+
return;
|
|
7319
|
+
subscription.refresh = null;
|
|
7320
|
+
if (refresh.timer)
|
|
7321
|
+
clearTimeout(refresh.timer);
|
|
7322
|
+
refresh.callbacks.clear();
|
|
7323
|
+
refresh.reject(error);
|
|
7324
|
+
}
|
|
7325
|
+
/**
|
|
7326
|
+
* A callback joining an already-registered non-cache subscription cannot be
|
|
7327
|
+
* seeded from `lastData`: that snapshot may predate the callback's auth
|
|
7328
|
+
* preflight. Re-send the same subscription id so the server re-runs read auth
|
|
7329
|
+
* and returns a fresh baseline, without allocating another websocket or
|
|
7330
|
+
* replacing the logical client subscription.
|
|
7331
|
+
*/
|
|
7332
|
+
function requestFreshSubscriptionSnapshot(connection, subscription, callback) {
|
|
7333
|
+
let refresh = subscription.refresh;
|
|
7334
|
+
if (!refresh) {
|
|
7335
|
+
let resolve;
|
|
7336
|
+
let reject;
|
|
7337
|
+
const promise = new Promise((resolvePromise, rejectPromise) => {
|
|
7338
|
+
resolve = resolvePromise;
|
|
7339
|
+
reject = rejectPromise;
|
|
7340
|
+
});
|
|
7341
|
+
refresh = {
|
|
7342
|
+
promise,
|
|
7343
|
+
resolve,
|
|
7344
|
+
reject,
|
|
7345
|
+
timer: null,
|
|
7346
|
+
callbacks: new Set(),
|
|
7347
|
+
dataRevisionAtStart: subscription.dataRevision,
|
|
7348
|
+
};
|
|
7349
|
+
subscription.refresh = refresh;
|
|
7350
|
+
refresh.timer = setTimeout(() => {
|
|
7351
|
+
if (subscription.refresh !== refresh)
|
|
7352
|
+
return;
|
|
7353
|
+
rejectSubscriptionRefresh(subscription, new Error('Subscription timeout'));
|
|
7354
|
+
}, 10000);
|
|
7355
|
+
}
|
|
7356
|
+
// Register before sendSubscribe: test/browser transports may synchronously
|
|
7357
|
+
// dispatch the subscribed response from send().
|
|
7358
|
+
refresh.callbacks.add(callback);
|
|
7359
|
+
// Every newly joined callback gets an idempotent same-id resend when the
|
|
7360
|
+
// authenticated socket is ready. Do not suppress this because an initial
|
|
7361
|
+
// confirmation or an earlier refresh is pending: that pending frame may
|
|
7362
|
+
// have been lost or displaced, and sharing it is exactly how an entire
|
|
7363
|
+
// route remount can inherit one doomed timeout. When connecting or
|
|
7364
|
+
// authenticating, replaySubscriptions will send after auth completes.
|
|
7365
|
+
if (connection.isConnected && !connection.isAuthenticating) {
|
|
7366
|
+
sendSubscribe(connection, subscription);
|
|
7367
|
+
}
|
|
7368
|
+
return refresh.promise;
|
|
7369
|
+
}
|
|
6362
7370
|
function sendUnsubscribe(connection, subscriptionId) {
|
|
6363
7371
|
if (!connection.ws || connection.ws.readyState !== WS_READY_STATE_OPEN) {
|
|
6364
7372
|
return;
|
|
@@ -6389,7 +7397,7 @@ function sendUnsubscribe(connection, subscriptionId) {
|
|
|
6389
7397
|
* the subscription onto, since a WS connection routes to a single DO.
|
|
6390
7398
|
*/
|
|
6391
7399
|
async function subscribeV2(path, subscriptionOptions, roomRoutePath) {
|
|
6392
|
-
var _a, _b;
|
|
7400
|
+
var _a, _b, _c, _d, _e;
|
|
6393
7401
|
const config = await getConfig();
|
|
6394
7402
|
const normalizedPath = path.startsWith('/') ? path.slice(1) : path;
|
|
6395
7403
|
// Use explicit appId override if provided, otherwise fall back to config
|
|
@@ -6407,16 +7415,27 @@ async function subscribeV2(path, subscriptionOptions, roomRoutePath) {
|
|
|
6407
7415
|
: undefined;
|
|
6408
7416
|
const identityInfo = await getSubscriptionIdentity(effectiveAppId, config.isServer, overrides);
|
|
6409
7417
|
const identity = identityInfo.key;
|
|
7418
|
+
const identityToken = identityInfo.token;
|
|
6410
7419
|
const responseCacheEnabled = subscriptionOptions.cache === true && identityInfo.cacheable;
|
|
7420
|
+
// Preserve the existing compact connection-key shape, but use the exact
|
|
7421
|
+
// token as a secondary in-memory equality check within that hash bucket.
|
|
6411
7422
|
const principalKey = authTokenProvider ? identity : undefined;
|
|
7423
|
+
const principalToken = authTokenProvider
|
|
7424
|
+
? (identityToken !== null && identityToken !== void 0 ? identityToken : identity)
|
|
7425
|
+
: undefined;
|
|
6412
7426
|
const cacheKey = responseCacheEnabled
|
|
6413
7427
|
? getCacheKey(normalizedPath, subscriptionOptions.prompt, subscriptionOptions.shape, subscriptionOptions.limit, subscriptionOptions.cursor, subscriptionOptions.filter, identity)
|
|
6414
7428
|
: null;
|
|
6415
|
-
//
|
|
6416
|
-
//
|
|
7429
|
+
// Acquire a connection for this routing target (room-scoped when a room route
|
|
7430
|
+
// is supplied by the live helper, else the app-level connection). The lease is
|
|
7431
|
+
// held across auth preflight and released once this call registers below, so a
|
|
7432
|
+
// concurrent old-route teardown cannot close a socket we are about to reuse.
|
|
6417
7433
|
let connection;
|
|
7434
|
+
let releaseConnection;
|
|
6418
7435
|
try {
|
|
6419
|
-
|
|
7436
|
+
const acquisition = await acquireConnection(effectiveAppId, config.isServer, roomRoutePath, authTokenProvider, principalKey, principalToken);
|
|
7437
|
+
connection = acquisition.connection;
|
|
7438
|
+
releaseConnection = acquisition.release;
|
|
6420
7439
|
}
|
|
6421
7440
|
catch (error) {
|
|
6422
7441
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
@@ -6426,73 +7445,135 @@ async function subscribeV2(path, subscriptionOptions, roomRoutePath) {
|
|
|
6426
7445
|
}
|
|
6427
7446
|
throw err;
|
|
6428
7447
|
}
|
|
6429
|
-
|
|
6430
|
-
// auth preflight has succeeded. An expired authenticated session must receive
|
|
6431
|
-
// an auth error, not a stale private snapshot followed by a failed connect.
|
|
6432
|
-
const cachedEntry = cacheKey ? responseCache.get(cacheKey) : undefined;
|
|
6433
|
-
if (cachedEntry && Date.now() - cachedEntry.timestamp < CACHE_TTL && subscriptionOptions.onData) {
|
|
6434
|
-
setTimeout(() => {
|
|
6435
|
-
var _a;
|
|
6436
|
-
(_a = subscriptionOptions.onData) === null || _a === void 0 ? void 0 : _a.call(subscriptionOptions, addIdsToSubscriptionData(cachedEntry.data));
|
|
6437
|
-
}, 0);
|
|
6438
|
-
}
|
|
7448
|
+
const cachedEntry = cacheKey ? getResponseCacheEntry(cacheKey, identityToken) : undefined;
|
|
6439
7449
|
// Check if we already have a subscription for this path+prompt+shape+limit+cursor+filter+sort
|
|
6440
|
-
const shapeKey = subscriptionOptions.shape ? JSON.stringify(subscriptionOptions.shape) : '';
|
|
6441
|
-
const filterKey = subscriptionOptions.filter ? JSON.stringify(subscriptionOptions.filter) : '';
|
|
6442
|
-
const sortKey = subscriptionOptions.sort ? JSON.stringify(subscriptionOptions.sort) : '';
|
|
6443
7450
|
let existingSubscription;
|
|
6444
|
-
|
|
6445
|
-
|
|
6446
|
-
|
|
6447
|
-
|
|
6448
|
-
//
|
|
6449
|
-
//
|
|
6450
|
-
|
|
6451
|
-
|
|
6452
|
-
|
|
6453
|
-
|
|
7451
|
+
let specKey = '';
|
|
7452
|
+
try {
|
|
7453
|
+
// Stringify THIS call's specs once; existing subscriptions carry their
|
|
7454
|
+
// precomputed specKey, so the scan is a string compare per entry
|
|
7455
|
+
// (previously each new subscribe re-stringified every existing
|
|
7456
|
+
// subscription's shape/filter/sort — O(N²) with many live hooks).
|
|
7457
|
+
specKey = JSON.stringify([
|
|
7458
|
+
(_a = subscriptionOptions.shape) !== null && _a !== void 0 ? _a : null,
|
|
7459
|
+
(_b = subscriptionOptions.filter) !== null && _b !== void 0 ? _b : null,
|
|
7460
|
+
(_c = subscriptionOptions.sort) !== null && _c !== void 0 ? _c : null,
|
|
7461
|
+
]);
|
|
7462
|
+
for (const sub of connection.subscriptions.values()) {
|
|
7463
|
+
// H1: also require the same principal identity (compact hash AND exact
|
|
7464
|
+
// token) — a different logged-in user must not piggy-back on (and
|
|
7465
|
+
// inherit lastData from) another user's existing subscription, even
|
|
7466
|
+
// with identical path/prompt/shape/filter/sort.
|
|
7467
|
+
if (sub.identity === identity && sub.identityToken === identityToken && sub.path === normalizedPath && sub.prompt === subscriptionOptions.prompt && sub.specKey === specKey && sub.includeSubPaths === ((_d = subscriptionOptions.includeSubPaths) !== null && _d !== void 0 ? _d : false) && sub.limit === subscriptionOptions.limit && sub.cursor === subscriptionOptions.cursor) {
|
|
7468
|
+
existingSubscription = sub;
|
|
7469
|
+
break;
|
|
7470
|
+
}
|
|
6454
7471
|
}
|
|
6455
7472
|
}
|
|
7473
|
+
catch (error) {
|
|
7474
|
+
releaseConnection();
|
|
7475
|
+
throw error;
|
|
7476
|
+
}
|
|
6456
7477
|
if (existingSubscription) {
|
|
7478
|
+
let replayData = undefined;
|
|
7479
|
+
let replayOnData;
|
|
7480
|
+
if (responseCacheEnabled) {
|
|
7481
|
+
// Read callback getters before mutating the shared subscription. If
|
|
7482
|
+
// a caller supplied a throwing getter, the acquisition fail-safe can
|
|
7483
|
+
// release cleanly without leaving a ghost callback behind.
|
|
7484
|
+
replayOnData = subscriptionOptions.onData;
|
|
7485
|
+
replayData = existingSubscription.lastData !== undefined
|
|
7486
|
+
? existingSubscription.lastData
|
|
7487
|
+
: cachedEntry && Date.now() - cachedEntry.timestamp < CACHE_TTL
|
|
7488
|
+
? cachedEntry.data
|
|
7489
|
+
: undefined;
|
|
7490
|
+
}
|
|
6457
7491
|
if (responseCacheEnabled) {
|
|
6458
7492
|
existingSubscription.cache = true;
|
|
6459
7493
|
}
|
|
6460
7494
|
// Add callback to existing subscription
|
|
6461
7495
|
existingSubscription.callbacks.push(subscriptionOptions);
|
|
6462
|
-
|
|
6463
|
-
|
|
6464
|
-
|
|
6465
|
-
|
|
6466
|
-
|
|
6467
|
-
|
|
6468
|
-
|
|
6469
|
-
|
|
6470
|
-
|
|
6471
|
-
|
|
6472
|
-
|
|
7496
|
+
let refreshPromise = null;
|
|
7497
|
+
if (responseCacheEnabled) {
|
|
7498
|
+
// Explicit, principal-bound cache opt-in may replay exactly one
|
|
7499
|
+
// snapshot. Prefer the active subscription's lastData, falling back
|
|
7500
|
+
// to the short-lived response cache while a reconnect is in flight.
|
|
7501
|
+
if (replayData !== undefined && replayOnData) {
|
|
7502
|
+
setTimeout(() => {
|
|
7503
|
+
replayOnData === null || replayOnData === void 0 ? void 0 : replayOnData(addIdsToSubscriptionData(replayData));
|
|
7504
|
+
}, 0);
|
|
7505
|
+
}
|
|
7506
|
+
}
|
|
7507
|
+
else {
|
|
7508
|
+
// Default (non-cache) subscriptions require a fresh authorized
|
|
7509
|
+
// baseline for every newly joined callback. Reuse the same logical
|
|
7510
|
+
// subscription id and socket, but make the server re-run the read.
|
|
7511
|
+
refreshPromise = requestFreshSubscriptionSnapshot(connection, existingSubscription, subscriptionOptions);
|
|
7512
|
+
}
|
|
7513
|
+
releaseConnection();
|
|
7514
|
+
if (refreshPromise) {
|
|
7515
|
+
try {
|
|
7516
|
+
await refreshPromise;
|
|
7517
|
+
}
|
|
7518
|
+
catch (error) {
|
|
7519
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
7520
|
+
let deliveredLocally = false;
|
|
7521
|
+
if (!err.__boundedDeliveredToOnError) {
|
|
7522
|
+
deliveredLocally = notifyErrorCallback(subscriptionOptions, err);
|
|
7523
|
+
}
|
|
7524
|
+
if (!subscriptionOptions.onError) {
|
|
7525
|
+
await removeCallbackFromSubscription(connection, existingSubscription.subscriptionId, subscriptionOptions);
|
|
7526
|
+
throw err;
|
|
7527
|
+
}
|
|
7528
|
+
if (!err.__boundedDeliveredToOnError && !deliveredLocally) {
|
|
7529
|
+
console.warn('[WS v2] Subscription refresh failed, keeping for reconnect recovery:', error);
|
|
7530
|
+
}
|
|
7531
|
+
}
|
|
6473
7532
|
}
|
|
6474
7533
|
return async () => {
|
|
6475
7534
|
await removeCallbackFromSubscription(connection, existingSubscription.subscriptionId, subscriptionOptions);
|
|
6476
7535
|
};
|
|
6477
7536
|
}
|
|
7537
|
+
// Deliver cached data immediately for a new logical subscription, but only
|
|
7538
|
+
// after connection auth preflight has succeeded. An expired authenticated
|
|
7539
|
+
// session must receive an auth error, not a stale private snapshot followed
|
|
7540
|
+
// by a failed connect.
|
|
7541
|
+
if (cachedEntry && Date.now() - cachedEntry.timestamp < CACHE_TTL && subscriptionOptions.onData) {
|
|
7542
|
+
setTimeout(() => {
|
|
7543
|
+
var _a;
|
|
7544
|
+
(_a = subscriptionOptions.onData) === null || _a === void 0 ? void 0 : _a.call(subscriptionOptions, addIdsToSubscriptionData(cachedEntry.data));
|
|
7545
|
+
}, 0);
|
|
7546
|
+
}
|
|
6478
7547
|
// Create new subscription
|
|
6479
7548
|
const subscriptionId = generateSubscriptionId();
|
|
6480
|
-
|
|
6481
|
-
|
|
6482
|
-
|
|
6483
|
-
|
|
6484
|
-
|
|
6485
|
-
|
|
6486
|
-
|
|
6487
|
-
|
|
6488
|
-
|
|
6489
|
-
|
|
6490
|
-
|
|
6491
|
-
|
|
6492
|
-
|
|
6493
|
-
|
|
6494
|
-
|
|
6495
|
-
|
|
7549
|
+
let subscription;
|
|
7550
|
+
try {
|
|
7551
|
+
subscription = {
|
|
7552
|
+
subscriptionId,
|
|
7553
|
+
path: normalizedPath,
|
|
7554
|
+
filter: subscriptionOptions.filter,
|
|
7555
|
+
sort: subscriptionOptions.sort,
|
|
7556
|
+
prompt: subscriptionOptions.prompt,
|
|
7557
|
+
shape: subscriptionOptions.shape,
|
|
7558
|
+
limit: subscriptionOptions.limit,
|
|
7559
|
+
cursor: subscriptionOptions.cursor,
|
|
7560
|
+
includeSubPaths: (_e = subscriptionOptions.includeSubPaths) !== null && _e !== void 0 ? _e : false,
|
|
7561
|
+
callbacks: [subscriptionOptions],
|
|
7562
|
+
lastData: undefined,
|
|
7563
|
+
dataRevision: 0,
|
|
7564
|
+
refresh: null,
|
|
7565
|
+
cache: responseCacheEnabled,
|
|
7566
|
+
identity,
|
|
7567
|
+
identityToken,
|
|
7568
|
+
specKey,
|
|
7569
|
+
};
|
|
7570
|
+
connection.subscriptions.set(subscriptionId, subscription);
|
|
7571
|
+
}
|
|
7572
|
+
catch (error) {
|
|
7573
|
+
releaseConnection();
|
|
7574
|
+
throw error;
|
|
7575
|
+
}
|
|
7576
|
+
releaseConnection();
|
|
6496
7577
|
// Send subscribe message if connected
|
|
6497
7578
|
if (connection.isConnected && !connection.isAuthenticating) {
|
|
6498
7579
|
// Create a promise to wait for subscription confirmation
|
|
@@ -6512,15 +7593,23 @@ async function subscribeV2(path, subscriptionOptions, roomRoutePath) {
|
|
|
6512
7593
|
}
|
|
6513
7594
|
catch (error) {
|
|
6514
7595
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
7596
|
+
let deliveredLocally = false;
|
|
6515
7597
|
if (!err.__boundedDeliveredToOnError) {
|
|
6516
|
-
|
|
7598
|
+
// A local confirmation timeout belongs to the caller that
|
|
7599
|
+
// originated it. Joined callbacks may already have a viable
|
|
7600
|
+
// same-id refresh and must not be poisoned by that old attempt.
|
|
7601
|
+
deliveredLocally = notifyErrorCallback(subscriptionOptions, err);
|
|
6517
7602
|
}
|
|
6518
7603
|
if (!subscriptionOptions.onError) {
|
|
6519
7604
|
connection.pendingSubscriptions.delete(subscriptionId);
|
|
6520
|
-
|
|
7605
|
+
// This registration may have acquired joined callbacks while
|
|
7606
|
+
// its confirmation was pending. Remove only the originating
|
|
7607
|
+
// callback; deleting the whole logical subscription would
|
|
7608
|
+
// orphan those remounted consumers and their refresh flight.
|
|
7609
|
+
await removeCallbackFromSubscription(connection, subscriptionId, subscriptionOptions);
|
|
6521
7610
|
throw err;
|
|
6522
7611
|
}
|
|
6523
|
-
if (!err.__boundedDeliveredToOnError) {
|
|
7612
|
+
if (!err.__boundedDeliveredToOnError && !deliveredLocally) {
|
|
6524
7613
|
console.warn('[WS v2] Subscription confirmation failed, keeping for reconnect recovery:', error);
|
|
6525
7614
|
}
|
|
6526
7615
|
}
|
|
@@ -6531,12 +7620,14 @@ async function subscribeV2(path, subscriptionOptions, roomRoutePath) {
|
|
|
6531
7620
|
};
|
|
6532
7621
|
}
|
|
6533
7622
|
async function removeCallbackFromSubscription(connection, subscriptionId, callback) {
|
|
7623
|
+
var _a;
|
|
6534
7624
|
const subscription = connection.subscriptions.get(subscriptionId);
|
|
6535
7625
|
if (!subscription) {
|
|
6536
7626
|
return;
|
|
6537
7627
|
}
|
|
6538
7628
|
// Remove this callback
|
|
6539
7629
|
subscription.callbacks = subscription.callbacks.filter(cb => cb !== callback);
|
|
7630
|
+
(_a = subscription.refresh) === null || _a === void 0 ? void 0 : _a.callbacks.delete(callback);
|
|
6540
7631
|
// If there are still callbacks, don't unsubscribe
|
|
6541
7632
|
if (subscription.callbacks.length > 0) {
|
|
6542
7633
|
return;
|
|
@@ -6558,17 +7649,9 @@ async function removeCallbackFromSubscription(connection, subscriptionId, callba
|
|
|
6558
7649
|
sendUnsubscribe(connection, subscriptionId);
|
|
6559
7650
|
await unsubscribePromise;
|
|
6560
7651
|
}
|
|
6561
|
-
// If no more subscriptions, close
|
|
6562
|
-
|
|
6563
|
-
|
|
6564
|
-
if (connection.tokenRefreshTimer) {
|
|
6565
|
-
clearInterval(connection.tokenRefreshTimer);
|
|
6566
|
-
connection.tokenRefreshTimer = null;
|
|
6567
|
-
}
|
|
6568
|
-
connection.ws.close();
|
|
6569
|
-
connection.ws = null;
|
|
6570
|
-
connections.delete(connection.key);
|
|
6571
|
-
}
|
|
7652
|
+
// If no more subscriptions, let the connection close after a short idle
|
|
7653
|
+
// grace so an immediately-following route remount can reclaim the socket.
|
|
7654
|
+
scheduleConnectionCloseIfIdle(connection);
|
|
6572
7655
|
}
|
|
6573
7656
|
/**
|
|
6574
7657
|
* Close all v2 subscriptions.
|
|
@@ -6576,6 +7659,11 @@ async function removeCallbackFromSubscription(connection, subscriptionId, callba
|
|
|
6576
7659
|
async function closeAllSubscriptionsV2() {
|
|
6577
7660
|
const closePromises = [];
|
|
6578
7661
|
for (const [appId, connection] of connections) {
|
|
7662
|
+
cancelIdleConnectionClose(connection);
|
|
7663
|
+
const closeError = new Error('Subscriptions closed');
|
|
7664
|
+
for (const subscription of connection.subscriptions.values()) {
|
|
7665
|
+
rejectSubscriptionRefresh(subscription, closeError);
|
|
7666
|
+
}
|
|
6579
7667
|
if (connection.tokenRefreshTimer) {
|
|
6580
7668
|
clearInterval(connection.tokenRefreshTimer);
|
|
6581
7669
|
connection.tokenRefreshTimer = null;
|
|
@@ -6584,15 +7672,18 @@ async function closeAllSubscriptionsV2() {
|
|
|
6584
7672
|
const ws = connection.ws;
|
|
6585
7673
|
connection.ws = null;
|
|
6586
7674
|
connection.isConnected = false;
|
|
6587
|
-
|
|
6588
|
-
|
|
6589
|
-
|
|
6590
|
-
|
|
6591
|
-
|
|
6592
|
-
|
|
7675
|
+
if (ws.readyState !== WS_READY_STATE_CLOSED) {
|
|
7676
|
+
closePromises.push(new Promise((resolve) => {
|
|
7677
|
+
ws.addEventListener('close', () => {
|
|
7678
|
+
resolve();
|
|
7679
|
+
});
|
|
7680
|
+
ws.close();
|
|
7681
|
+
}));
|
|
7682
|
+
}
|
|
6593
7683
|
}
|
|
6594
7684
|
connections.delete(appId);
|
|
6595
7685
|
}
|
|
7686
|
+
connectionAcquisitions.clear();
|
|
6596
7687
|
await Promise.all(closePromises);
|
|
6597
7688
|
}
|
|
6598
7689
|
/**
|
|
@@ -6625,10 +7716,12 @@ function getCachedDataV2(path, prompt) {
|
|
|
6625
7716
|
// opt-in. Resolve the caller's identity from an active cache-enabled
|
|
6626
7717
|
// subscription for this path. No matching sub → null (safe).
|
|
6627
7718
|
let identity;
|
|
7719
|
+
let identityToken;
|
|
6628
7720
|
outer: for (const connection of connections.values()) {
|
|
6629
7721
|
for (const sub of connection.subscriptions.values()) {
|
|
6630
7722
|
if (sub.cache && sub.path === normalizedPath && sub.prompt === prompt) {
|
|
6631
7723
|
identity = sub.identity;
|
|
7724
|
+
identityToken = sub.identityToken;
|
|
6632
7725
|
break outer;
|
|
6633
7726
|
}
|
|
6634
7727
|
}
|
|
@@ -6637,7 +7730,7 @@ function getCachedDataV2(path, prompt) {
|
|
|
6637
7730
|
return null;
|
|
6638
7731
|
}
|
|
6639
7732
|
const cacheKey = getCacheKey(path, prompt, undefined, undefined, undefined, undefined, identity);
|
|
6640
|
-
const cachedEntry =
|
|
7733
|
+
const cachedEntry = getResponseCacheEntry(cacheKey, identityToken !== null && identityToken !== void 0 ? identityToken : null);
|
|
6641
7734
|
if (cachedEntry && Date.now() - cachedEntry.timestamp < CACHE_TTL) {
|
|
6642
7735
|
return cachedEntry.data;
|
|
6643
7736
|
}
|
|
@@ -6699,10 +7792,15 @@ async function doReconnectWithNewAuth() {
|
|
|
6699
7792
|
if (!connection.ws) {
|
|
6700
7793
|
continue;
|
|
6701
7794
|
}
|
|
7795
|
+
cancelIdleConnectionClose(connection);
|
|
7796
|
+
const reconnectError = new Error('Connection reconnecting due to auth change');
|
|
6702
7797
|
for (const [, pending] of connection.pendingSubscriptions) {
|
|
6703
|
-
pending.reject(
|
|
7798
|
+
pending.reject(reconnectError);
|
|
6704
7799
|
}
|
|
6705
7800
|
connection.pendingSubscriptions.clear();
|
|
7801
|
+
for (const subscription of connection.subscriptions.values()) {
|
|
7802
|
+
rejectSubscriptionRefresh(subscription, reconnectError);
|
|
7803
|
+
}
|
|
6706
7804
|
for (const [, pending] of connection.pendingUnsubscriptions) {
|
|
6707
7805
|
pending.resolve();
|
|
6708
7806
|
}
|
|
@@ -6762,10 +7860,10 @@ async function wsIntent(appId, roomRoutePath, intent, isServer) {
|
|
|
6762
7860
|
const currentToken = await getIdToken(isServer).catch(() => null);
|
|
6763
7861
|
if (!currentToken) {
|
|
6764
7862
|
// Mid-refresh: the socket principal still governs server-side.
|
|
6765
|
-
if (!connection.
|
|
7863
|
+
if (!connection.authTokenValue && !connection.authPrincipal)
|
|
6766
7864
|
return false;
|
|
6767
7865
|
}
|
|
6768
|
-
else if (!connection.
|
|
7866
|
+
else if (!connection.authTokenValue && !connection.authPrincipal) {
|
|
6769
7867
|
// Anonymous socket + authenticated user: do not attribute this user's
|
|
6770
7868
|
// input to the anon principal. Reauth the socket; caller may fall back.
|
|
6771
7869
|
void maybeReauthRoomConnection(connection);
|
|
@@ -6785,7 +7883,7 @@ async function wsIntent(appId, roomRoutePath, intent, isServer) {
|
|
|
6785
7883
|
}
|
|
6786
7884
|
}
|
|
6787
7885
|
// Debounced background reauth: when the client's current token no longer
|
|
6788
|
-
// matches the connection's
|
|
7886
|
+
// matches the connection's authenticated identity (token rotation), refresh the
|
|
6789
7887
|
// socket auth ONCE rather than degrading every subsequent send.
|
|
6790
7888
|
const reauthInFlight = new WeakSet();
|
|
6791
7889
|
async function maybeReauthRoomConnection(connection, isServer) {
|
|
@@ -7468,5 +8566,5 @@ function defineLiveModule(mod) {
|
|
|
7468
8566
|
return mod;
|
|
7469
8567
|
}
|
|
7470
8568
|
|
|
7471
|
-
export { BoundedDeclineError, DECLINE_EVENT_NAME, EFFECT_INTENT_ADDRESS, FunctionInvokeError, InsufficientBalanceError, LiveIntentError, ReactNativeSessionManager, ServerSessionManager, WebSessionManager, aggregate, buildSetDocumentsTransaction, clearCache, closeAllSubscriptions, convertRemainingAccounts, count, createSessionWithSignature, defineLiveModule, deriveDecline, deriveUserIdentityFromIdToken, dispatchDeclineEvent, docId, functions, genAuthNonce, genSolanaMessage, get, getActiveSessionManager, getCachedData, getConfig, getFiles, getIdToken, getMany, getWebhookKeysUrl, hasActiveConnection, increment, init, invoke as invokeFunction, isEffectResult, live, intent as liveIntent, status as liveStatus, now, queryAggregate, reconnectWithNewAuth, refreshSession, revokeSession, runExpression, runExpressionMany, runQuery, runQueryMany, search, serverTimestamp, set, setFile, setMany, signAndSubmitTransaction, signMessage, signSessionCreateMessage, signTransaction, subscribe, subscribeView as subscribeLiveView, toMillis, toSeconds, withEffects };
|
|
8569
|
+
export { BOUNDED_RUNTIME_SET_DOCUMENTS_ATTESTED_SELECTOR, BOUNDED_RUNTIME_SET_DOCUMENTS_SELECTOR, BoundedDeclineError, DECLINE_EVENT_NAME, EFFECT_INTENT_ADDRESS, FunctionInvokeError, InsufficientBalanceError, LiveIntentError, ReactNativeSessionManager, ServerSessionManager, WebSessionManager, aggregate, assertCanonicalBoundedRuntimeWriteCalldata, assertEvmTransactionMatchesWrite, buildSetDocumentsTransaction, clearCache, closeAllSubscriptions, convertRemainingAccounts, count, createSessionWithSignature, defineLiveModule, deriveDecline, deriveUserIdentityFromIdToken, dispatchDeclineEvent, docId, functions, genAuthNonce, genSiweMessage, genSolanaMessage, get, getActiveSessionManager, getCachedData, getConfig, getFiles, getIdToken, getMany, getWebhookKeysUrl, hasActiveConnection, increment, init, invoke as invokeFunction, isEffectResult, live, intent as liveIntent, status as liveStatus, normalizeEvmAddress, now, queryAggregate, reconnectWithNewAuth, refreshSession, revokeSession, runExpression, runExpressionMany, runQuery, runQueryMany, search, serverTimestamp, set, setFile, setMany, signAndSubmitTransaction, signMessage, signSessionCreateMessage, signTransaction, subscribe, subscribeView as subscribeLiveView, toLowerEvmAddress, toMillis, toSeconds, withEffects };
|
|
7472
8570
|
//# sourceMappingURL=index.mjs.map
|