Skip to content

Commit 184792f

Browse files
colinclerknikosdouvlisbrkalow
authored andcommitted
feat(repo): Introduce tests for client handshake (#2265)
* Test suite start * feat(backend,nextjs,utils): Fix jest * first test * Fix bug in jwks cache for multiple runtime keys * Add all the tests, including many failing * Add all the tests, including many failing * fix(shared): Correctly construct proxy URL --------- Co-authored-by: Nikos Douvlis <[email protected]> Co-authored-by: Bryce Kalow <[email protected]>
1 parent 88f877a commit 184792f

18 files changed

+899
-196
lines changed

handshake.test.ts

Lines changed: 653 additions & 0 deletions
Large diffs are not rendered by default.

handshakeTestConfigs.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// @ts-ignore ignore types
2+
import * as jwt from 'jsonwebtoken';
3+
// @ts-ignore ignore types
4+
import * as uuid from 'uuid';
5+
6+
const rsaPairs = {
7+
a: {
8+
private: `-----BEGIN RSA PRIVATE KEY-----
9+
MIIEowIBAAKCAQEAlRVgJiQJ0nfuctIVSLnFJlAC76YPKly8Y5xrY36ADo472G1w
10+
FpeiykQRyDdGOwrkJBEVmLpAybV4yTgQFpQ0A4YzeDlKKkOxBhCmuANZXluAm2MW
11+
3ehNAm0svievMfKtG6UjjYz6v67U9Om/oMt1ehsOmR8MrDYvs3Wy+dxYpZaxyn6w
12+
ajL7GkICHxc8cGsI/MBZr9jKtKyzFY++r8TQKAJwn9TcSQljRivomz1wQvjtdLnq
13+
ZSLP3BFQB7e7DuM6SBsodIHhkVEVK2EaGVLOY+ifAITt7MqEcvast14AP0rICBSq
14+
vbQQjZuwLgIrlJgqvJ4YBRfIaIx/qQzs0+eFtwIDAQABAoIBAD1H4xTqfWsZR1fF
15+
SWBylDqSaxKNRPCZ3ApqEq58IjFZf/oPyiJPRGg2IMUXC3RbnrnAmAsGjHkdcj/s
16+
HpjZZKQKNv/1NKo41vxyPcWoAsVJgYzd51liEr2rmNe1QkuawFN7xyh5Sd0fBYSC
17+
zPVQjMKbep2waKolP+hZui8AxyORLtu6aUQawaCdWFyiyHtqEnlcb/YSTtGl/3W/
18+
/LqYyv60dG0QdcAO37MAE42vp3R4GGcJelsFo/lxSKg+KiLn7NdsNr7bCJrqbVXz
19+
93Fu5jgHQD9+BeVyvHJ/R2yg+utYEvIMiFvwX7z4MLh9PsWJbf9vbDNlw9ErWpf5
20+
r1xUiqECgYEA/lLJP+qla0vd+ocYNe3ufOG4kaUFsrqRoChiS1JxwQr/WGTmV8sT
21+
ZyTPwyxnsHtbzn4lwuI6CpAeyvd9O6G3FfTzUqsyPaknsGlymf8LEwL4AVo0BVY0
22+
YGodRnDISBBU/yPQ2kvq6c72ouq5cQxWF45f8Z/Z+fFDjuHG6Q44hYcCgYEAlhD6
23+
sm8wTWVklMAxOnhQJoseWQ1vcl6VCxRVv4QBiX9CQm/4ANVOWun4KRC5qqTdoNHc
24+
RyuiWpZVgGblqUu4sWSQgi3CZyyLbHOJ9wTPTeo0oDVaFa9MMwS8rq35HXjpgREz
25+
JtTRi6c9WVsjBygYiE5IYO0FGbEjI9qIiD5CClECgYA+wtVRRamu0dkk0yPhYycg
26+
gF+Y6Z1/XtVDLdQb/GuAFSOwf63sanwOTyJKavHntnmQesb80fE63BgNRIgOKDlT
27+
XNCTTRYn60+VFGCoqizkcy4av1TpID3qsSUqVfjG9+jR0dffly6Qpnds+vnqcP3p
28+
8EOzEByttqFSaFs69jxyjwKBgFCQbQa+isACXy08wTESxnTq2zAT9nEANiPsltxq
29+
kiivGXNxiUNpQNeuJHxnbkYenJ1qDUhoNJFNhDmbBFEPReh2hN5ekq+xSmi+3qKv
30+
AlxiED6yZdqecdoyANoGrGcWMsYH5d5DAvxmnJkMRJHjBMiovlLK7KIOZz8oY4RB
31+
aFMBAoGBAJ8UoGHwz7AdOLAldGY3c0HzaH1NaGrZoocSH19XI3ZYr8Qvl2XUww9G
32+
UC1OG4e2PtZ8PINQGIYPacUaab5Yg1tOmxBoAx4gUkpgyjtSm2ZPd4EUVOdylU3E
33+
aFa08+0FF7mqqJTgz5XlvHMrCcUTsJ9u+e05rr1G1PHsATuuMD9m
34+
-----END RSA PRIVATE KEY-----`,
35+
public: {
36+
kty: 'RSA',
37+
n: 'lRVgJiQJ0nfuctIVSLnFJlAC76YPKly8Y5xrY36ADo472G1wFpeiykQRyDdGOwrkJBEVmLpAybV4yTgQFpQ0A4YzeDlKKkOxBhCmuANZXluAm2MW3ehNAm0svievMfKtG6UjjYz6v67U9Om_oMt1ehsOmR8MrDYvs3Wy-dxYpZaxyn6wajL7GkICHxc8cGsI_MBZr9jKtKyzFY--r8TQKAJwn9TcSQljRivomz1wQvjtdLnqZSLP3BFQB7e7DuM6SBsodIHhkVEVK2EaGVLOY-ifAITt7MqEcvast14AP0rICBSqvbQQjZuwLgIrlJgqvJ4YBRfIaIx_qQzs0-eFtw',
38+
e: 'AQAB',
39+
},
40+
},
41+
b: {
42+
private: `-----BEGIN RSA PRIVATE KEY-----
43+
MIIEowIBAAKCAQEAt9zSYl1hFhFKXvv8uJcT2X15iOqi1mTtxqVxNDnzPQSj1RSa
44+
Jryjhkzpyd16c+PDo+FFtMgZTUv6Z2hr5QYMuAjlsM+apHmfE8MRMQRQHXNF0+sE
45+
Bd1241W0mL7fId2ZChaGgufFOGFl2Obby56FH4Z86lCFi7Z4Ow7TBSpVSN598OKH
46+
oVKwbYOVPKtmWBar0JeCPVpng4Ntx7kvuHGdFSoJ8z8+Uy5ybLlk1qSlQ5lsymfW
47+
hxs0C9j7/x/h24n9jUbq51pzx2URcsEi0Wbuv26Ba0Q4v1ySl0I6IM5Xemwrzjo1
48+
H6kz6IYldqPtTwkzhJSnJvJFzKJZn3hH9N+rGwIDAQABAoIBAGLGdx/xGp9IWrP8
49+
nCBuyXMmPYyYwTJ8tmDpsI9mMo6tV3a5wrbc0NztpQuVuJtZ2VjJRTGB7lXgY336
50+
UzyOq3aTERKT9Xg2/ocXXL0AnCm2K+VVdKvR9nTbLlKA+E6xRe5te4YIDaPkb1q/
51+
a4VQfCQblDAtYhFUzfKsXCGCRJ8IPlhZxiA9RHfQTmQUSoBW+12IovyMdMxVLoPT
52+
qjdnwL1TS3iARim+eV+buHW+8Drn8eldeSFoTJd0B9eRf7pMpRH/X8G7X0YYdDjF
53+
ADWI770CQj45QQeuVsZYIuONIPmzai4nGiNQ85v+Yy0L47lYUp5XsDvwYO5tMCQK
54+
v1og8cECgYEA7zIfBWM1AIY763FGJ6F5ym41A4i7WSRaWusfQx6fOdGTyHJ3hXu9
55+
+1kQS97IKElJ1V7oK69dJGxq+aupsd/AaRJb4oVZCBSby4Fo6VeJoKyJSdNSCks6
56+
tonT3hGUsJO1ER2ItWcgiCxgGY+vrK0rkacX/VgNZKGIjlGv8pQUpaUCgYEAxMeL
57+
2jyx+5odGM51e/1G4Vn6t+ffNNC/03NbwZeJ93+0NPgdagIo1kErZQrFPEfoExOr
58+
KMkwnAsnR/xfn4X21voK1pUc7VhzzoODb8l9LA9kB7efWtRZA79gcsbOH5wNkp9Z
59+
i76AtaVU/p1grFKNcnes1lbFfcRUnO880g5dsb8CgYBacuuEEAWk0x2pZEYRCmCR
60+
iacGVRfzF2oLY0mJCfVP2c42SAKmOSqX9w/QgMfTZBNFWgQVMNTZxx2Ul7Mtjdym
61+
XsjcGWyXP6PCCodvZSin11Z60iv9tIDZMbkqCh/dvZ0EgdSGNB77HzyfrdPSShFl
62+
nHfX1woJeYO3vW/5HMHJ+QKBgQCNema7pq3Ulq5a2n2vgp9GgJn5RXW+lGOG1Mbg
63+
vmJMlv1qpAUJ5bmUqdBYWlEKkSxzIs4JifUwC/jXEcVyfS/GyommVBkzMEg672U9
64+
pyEe34Xs4oFpHYlOX3cprnQeV+WOSJFqHrKNZuxgD6ik3MmjxhV3GXXugYzQNFWH
65+
NRr6IwKBgH9aN5mY4fcVL76mMEVZ5BIHE+JpPMZ6OOamOHAiA5jrWRX4aRMICq3t
66+
cKVfcj/M4dyBuRV5EW1y1m2QhRECFPSKpScykpD9nyCb+XqbMSLH+f+j1BGfLKWl
67+
t5o8u/dlwJ1fGGday48gs/hA4V/F9zDjecNkYWUB/wUwVStqZljn
68+
-----END RSA PRIVATE KEY-----`,
69+
public: {
70+
kty: 'RSA',
71+
n: 't9zSYl1hFhFKXvv8uJcT2X15iOqi1mTtxqVxNDnzPQSj1RSaJryjhkzpyd16c-PDo-FFtMgZTUv6Z2hr5QYMuAjlsM-apHmfE8MRMQRQHXNF0-sEBd1241W0mL7fId2ZChaGgufFOGFl2Obby56FH4Z86lCFi7Z4Ow7TBSpVSN598OKHoVKwbYOVPKtmWBar0JeCPVpng4Ntx7kvuHGdFSoJ8z8-Uy5ybLlk1qSlQ5lsymfWhxs0C9j7_x_h24n9jUbq51pzx2URcsEi0Wbuv26Ba0Q4v1ySl0I6IM5Xemwrzjo1H6kz6IYldqPtTwkzhJSnJvJFzKJZn3hH9N-rGw',
72+
e: 'AQAB',
73+
},
74+
},
75+
};
76+
77+
const allConfigs: any = [];
78+
79+
export function generateConfig({ mode, matchedKeys = true }: { mode: 'test' | 'live'; matchedKeys?: boolean }) {
80+
const ins_id = uuid.v4();
81+
const pkHost = `clerk.${uuid.v4()}.com`;
82+
const pk = `pk_${mode}_${btoa(`${pkHost}$`)}`;
83+
const sk = `sk_${mode}_${uuid.v4()}`;
84+
const rsa = matchedKeys
85+
? rsaPairs.a
86+
: {
87+
private: rsaPairs.a.private,
88+
public: rsaPairs.b.public,
89+
};
90+
const jwks = {
91+
keys: [
92+
{
93+
...rsa.public,
94+
kid: ins_id,
95+
use: 'sig',
96+
alg: 'RS256',
97+
},
98+
],
99+
};
100+
101+
type Claims = {
102+
sub: string;
103+
iat: number;
104+
exp: number;
105+
nbf: number;
106+
};
107+
const generateToken = ({ state }: { state: 'active' | 'expired' | 'early' }) => {
108+
let claims = { sub: 'user_12345' } as Claims;
109+
110+
const now = Math.floor(Date.now() / 1000);
111+
if (state === 'active') {
112+
claims.iat = now;
113+
claims.nbf = now - 10;
114+
claims.exp = now + 60;
115+
} else if (state === 'expired') {
116+
claims.iat = now - 600;
117+
claims.nbf = now - 10 - 600;
118+
claims.exp = now + 60 - 600;
119+
} else if (state === 'early') {
120+
claims.iat = now + 600;
121+
claims.nbf = now - 10 + 600;
122+
claims.exp = now + 60 + 600;
123+
}
124+
return {
125+
token: jwt.sign(claims, rsa.private, {
126+
algorithm: 'RS256',
127+
header: { kid: ins_id },
128+
}),
129+
claims,
130+
};
131+
};
132+
const config = Object.freeze({
133+
pk,
134+
sk,
135+
generateToken,
136+
jwks,
137+
pkHost,
138+
});
139+
allConfigs.push(config);
140+
return config;
141+
}
142+
143+
export function getJwksFromSecretKey(sk: any) {
144+
return allConfigs.find((x: any) => x.sk === sk)?.jwks;
145+
}

integration/templates/next-app-router/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@
99
"start": "next start"
1010
},
1111
"dependencies": {
12+
"@clerk/backend": "file:.yalc/@clerk/backend",
13+
"@clerk/clerk-react": "file:.yalc/@clerk/clerk-react",
14+
"@clerk/nextjs": "file:.yalc/@clerk/nextjs",
15+
"@clerk/shared": "file:.yalc/@clerk/shared",
16+
"@clerk/types": "file:.yalc/@clerk/types",
1217
"@types/node": "^18.17.0",
1318
"@types/react": "18.2.14",
1419
"@types/react-dom": "18.2.6",

jest.config.handshake.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/** @type {import('ts-jest').JestConfigWithTsJest} */
2+
module.exports = {
3+
extensionsToTreatAsEsm: ['.ts'],
4+
testRegex: ['handshake.test.tsx?$'],
5+
moduleNameMapper: {
6+
'^(\\.{1,2}/.*)\\.js$': '$1',
7+
},
8+
transform: {
9+
// '^.+\\.[tj]sx?$' to process js/ts with `ts-jest`
10+
// '^.+\\.m?[tj]sx?$' to process js/ts/mjs/mts with `ts-jest`
11+
'^.+\\.tsx?$': [
12+
'ts-jest',
13+
{
14+
diagnostics: false,
15+
useESM: true,
16+
},
17+
],
18+
},
19+
};

packages/backend/src/tokens/authStatus.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ type LoadResourcesOptions = {
105105
};
106106

107107
type RequestStateParams = {
108-
publishableKey?: string;
108+
publishableKey: string;
109109
domain?: string;
110110
isSatellite?: boolean;
111111
proxyUrl?: string;

packages/backend/src/tokens/errors.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ export enum TokenVerificationErrorReason {
99
TokenInvalid = 'token-invalid',
1010
TokenInvalidAlgorithm = 'token-invalid-algorithm',
1111
TokenInvalidAuthorizedParties = 'token-invalid-authorized-parties',
12-
TokenInvalidIssuer = 'token-invalid-issuer',
1312
TokenInvalidSignature = 'token-invalid-signature',
1413
TokenNotActiveYet = 'token-not-active-yet',
1514
TokenVerificationFailed = 'token-verification-failed',

packages/backend/src/tokens/interstitialRule.ts

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export type InterstitialRuleOptions = AuthStatusOptionsType & {
2525
headerToken?: string;
2626
/* Request search params value */
2727
searchParams?: URLSearchParams;
28+
/* Derived Request URL */
29+
derivedRequestUrl?: URL;
2830
};
2931

3032
type InterstitialRuleResult = RequestState | undefined;
@@ -171,17 +173,7 @@ export async function runInterstitialRules<T extends InterstitialRuleOptions>(
171173
}
172174

173175
async function verifyRequestState(options: InterstitialRuleOptions, token: string) {
174-
const { isSatellite, proxyUrl } = options;
175-
let issuer;
176-
if (isSatellite) {
177-
issuer = null;
178-
} else if (proxyUrl) {
179-
issuer = proxyUrl;
180-
} else {
181-
issuer = (iss: string) => iss.startsWith('https://clerk.') || iss.includes('.clerk.accounts');
182-
}
183-
184-
return verifyToken(token, { ...options, issuer });
176+
return verifyToken(token, { ...options });
185177
}
186178

187179
/**

packages/backend/src/tokens/jwt/assertions.test.ts

Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -232,42 +232,6 @@ export default (QUnit: QUnit) => {
232232
});
233233
});
234234

235-
module('assertIssuerClaim(iss, issuer)', () => {
236-
test('does not throw if issuer is null', assert => {
237-
assert.equal(undefined, assertIssuerClaim('', null));
238-
});
239-
240-
test('throws error if iss does not match with issuer string', assert => {
241-
assert.raises(
242-
() => assertIssuerClaim('issuer', ''),
243-
new Error(`Invalid JWT issuer claim (iss) "issuer". Expected "".`),
244-
);
245-
assert.raises(
246-
() => assertIssuerClaim('issuer', 'issuer-2'),
247-
new Error(`Invalid JWT issuer claim (iss) "issuer". Expected "issuer-2".`),
248-
);
249-
});
250-
251-
test('throws error if iss does not match with issuer function result', assert => {
252-
assert.raises(
253-
() => assertIssuerClaim('issuer', () => false),
254-
new Error(`Failed JWT issuer resolver. Make sure that the resolver returns a truthy value.`),
255-
);
256-
});
257-
258-
test('does not throw if iss matches issuer ', assert => {
259-
assert.equal(undefined, assertIssuerClaim('issuer', 'issuer'));
260-
assert.equal(
261-
undefined,
262-
assertIssuerClaim('issuer', s => s === 'issuer'),
263-
);
264-
assert.equal(
265-
undefined,
266-
assertIssuerClaim('issuer', () => true),
267-
);
268-
});
269-
});
270-
271235
module('assertExpirationClaim(exp, clockSkewInMs)', hooks => {
272236
let fakeClock;
273237
hooks.beforeEach(() => {

packages/backend/src/tokens/jwt/assertions.ts

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -94,20 +94,6 @@ export const assertAuthorizedPartiesClaim = (azp?: string, authorizedParties?: s
9494
}
9595
};
9696

97-
export const assertIssuerClaim = (iss: string, issuer: IssuerResolver | null) => {
98-
if (typeof issuer === 'function' && !issuer(iss)) {
99-
throw new TokenVerificationError({
100-
reason: TokenVerificationErrorReason.TokenInvalidIssuer,
101-
message: 'Failed JWT issuer resolver. Make sure that the resolver returns a truthy value.',
102-
});
103-
} else if (typeof issuer === 'string' && iss && iss !== issuer) {
104-
throw new TokenVerificationError({
105-
reason: TokenVerificationErrorReason.TokenInvalidIssuer,
106-
message: `Invalid JWT issuer claim (iss) ${JSON.stringify(iss)}. Expected "${issuer}".`,
107-
});
108-
}
109-
};
110-
11197
export const assertExpirationClaim = (exp: number, clockSkewInMs: number) => {
11298
if (typeof exp !== 'number') {
11399
throw new TokenVerificationError({

packages/backend/src/tokens/jwt/verifyJwt.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import runtime from '../../runtime';
66
import { base64url } from '../../util/rfc4648';
77
import { TokenVerificationError, TokenVerificationErrorAction, TokenVerificationErrorReason } from '../errors';
88
import { getCryptoAlgorithm } from './algorithms';
9-
import type { IssuerResolver } from './assertions';
109
import {
1110
assertActivationClaim,
1211
assertAudienceClaim,
@@ -15,7 +14,6 @@ import {
1514
assertHeaderAlgorithm,
1615
assertHeaderType,
1716
assertIssuedAtClaim,
18-
assertIssuerClaim,
1917
assertSubClaim,
2018
} from './assertions';
2119
import { importKey } from './cryptoKeys';
@@ -82,13 +80,12 @@ export type VerifyJwtOptions = {
8280
audience?: string | string[];
8381
authorizedParties?: string[];
8482
clockSkewInMs?: number;
85-
issuer: IssuerResolver | string | null;
8683
key: JsonWebKey | string;
8784
};
8885

8986
export async function verifyJwt(
9087
token: string,
91-
{ audience, authorizedParties, clockSkewInMs, issuer, key }: VerifyJwtOptions,
88+
{ audience, authorizedParties, clockSkewInMs, key }: VerifyJwtOptions,
9289
): Promise<JwtPayload> {
9390
const clockSkew = clockSkewInMs || DEFAULT_CLOCK_SKEW_IN_SECONDS;
9491

@@ -108,7 +105,6 @@ export async function verifyJwt(
108105
assertSubClaim(sub);
109106
assertAudienceClaim([aud], [audience]);
110107
assertAuthorizedPartiesClaim(azp, authorizedParties);
111-
assertIssuerClaim(iss, issuer);
112108
assertExpirationClaim(exp, clockSkew);
113109
assertActivationClaim(nbf, clockSkew);
114110
assertIssuedAtClaim(iat, clockSkew);

packages/backend/src/tokens/keys.ts

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,6 @@ export type LoadClerkJWKFromRemoteOptions = {
9898
secretKey?: string;
9999
apiUrl?: string;
100100
apiVersion?: string;
101-
issuer?: string;
102101
};
103102

104103
/**
@@ -108,7 +107,6 @@ export type LoadClerkJWKFromRemoteOptions = {
108107
* The cache lasts 1 hour by default.
109108
*
110109
* @param {Object} options
111-
* @param {string} options.issuer - The issuer origin of the JWT
112110
* @param {string} options.kid - The id of the key that the JWT was signed with
113111
* @param {string} options.alg - The algorithm of the JWT
114112
* @param {number} options.jwksCacheTtlInMs - The TTL of the jwks cache (defaults to 1 hour)
@@ -118,27 +116,20 @@ export async function loadClerkJWKFromRemote({
118116
secretKey,
119117
apiUrl = API_URL,
120118
apiVersion = API_VERSION,
121-
issuer,
122119
kid,
123120
jwksCacheTtlInMs = JWKS_CACHE_TTL_MS,
124121
skipJwksCache,
125122
}: LoadClerkJWKFromRemoteOptions): Promise<JsonWebKey> {
126-
const shouldRefreshCache = !getFromCache(kid) && reachedMaxCacheUpdatedAt();
127-
if (skipJwksCache || shouldRefreshCache) {
128-
let fetcher;
129-
130-
if (secretKey) {
131-
fetcher = () => fetchJWKSFromBAPI(apiUrl, secretKey, apiVersion);
132-
} else if (issuer) {
133-
fetcher = () => fetchJWKSFromFAPI(issuer);
134-
} else {
123+
const needsFetch = !getFromCache(kid) || cacheHasExpired();
124+
if (skipJwksCache || needsFetch) {
125+
if (!secretKey) {
135126
throw new TokenVerificationError({
136127
action: TokenVerificationErrorAction.ContactSupport,
137128
message: 'Failed to load JWKS from Clerk Backend or Frontend API.',
138129
reason: TokenVerificationErrorReason.RemoteJWKFailedToLoad,
139130
});
140131
}
141-
132+
const fetcher = () => fetchJWKSFromBAPI(apiUrl, secretKey, apiVersion);
142133
const { keys } = await callWithRetry<{ keys: JsonWebKeyWithKid[] }>(fetcher);
143134

144135
if (!keys || !keys.length) {
@@ -231,6 +222,6 @@ async function fetchJWKSFromBAPI(apiUrl: string, key: string, apiVersion: string
231222
return response.json();
232223
}
233224

234-
function reachedMaxCacheUpdatedAt() {
225+
function cacheHasExpired() {
235226
return Date.now() - lastUpdatedAt >= MAX_CACHE_LAST_UPDATED_AT_SECONDS * 1000;
236227
}

0 commit comments

Comments
 (0)