@zeyolabs/kit 0.0.0-stage → 2026.11.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +102 -2
  2. package/auth/accounts/accounts.remote.ts +116 -0
  3. package/auth/accounts/accounts.ts +332 -0
  4. package/auth/accounts/handle.ts +102 -0
  5. package/auth/accounts/invites.ts +149 -0
  6. package/auth/accounts/permissions.ts +90 -0
  7. package/auth/accounts/schemas.ts +22 -0
  8. package/auth/config.ts +77 -0
  9. package/auth/emails.ts +57 -0
  10. package/auth/schema.ts +152 -0
  11. package/auth/sessions/handle.ts +124 -0
  12. package/auth/sessions/magic-link/magic-link.ts +71 -0
  13. package/auth/sessions/passkeys/client.ts +115 -0
  14. package/auth/sessions/passkeys/passkeys.ts +295 -0
  15. package/auth/sessions/sessions.ts +101 -0
  16. package/auth/sveltekit/loads.ts +59 -0
  17. package/auth/sveltekit/login.ts +28 -0
  18. package/auth/sveltekit/routes.ts +264 -0
  19. package/auth/users.ts +49 -0
  20. package/cloudflare/access.ts +134 -0
  21. package/cloudflare/amqp/client.ts +276 -0
  22. package/cloudflare/amqp/testing.ts +9 -0
  23. package/cloudflare/context.ts +66 -0
  24. package/cloudflare/dispatch/client.ts +30 -0
  25. package/cloudflare/email/base.ts +59 -0
  26. package/cloudflare/email/client.ts +51 -0
  27. package/cloudflare/email/dev.ts +14 -0
  28. package/cloudflare/email/smtp.ts +65 -0
  29. package/cloudflare/email/testing.ts +18 -0
  30. package/cloudflare/r2/client.ts +154 -0
  31. package/cloudflare/r2/testing.ts +43 -0
  32. package/cloudflare/rate-limiter.ts +65 -0
  33. package/cloudflare/wait-until.ts +12 -0
  34. package/cloudflare/workers/index.ts +311 -0
  35. package/cloudflare/workers/testing.ts +94 -0
  36. package/cloudflare/workers/ticking-do.ts +104 -0
  37. package/cloudflare/workers-ai/client.ts +30 -0
  38. package/cloudflare/workers-ai/testing.ts +17 -0
  39. package/cloudflare/workers-env.ts +12 -0
  40. package/eslint-config.js +115 -0
  41. package/monitoring/alerts/client.ts +48 -0
  42. package/monitoring/alerts/event-config.ts +33 -0
  43. package/monitoring/alerts/policy.ts +39 -0
  44. package/monitoring/alerts/registry.ts +44 -0
  45. package/monitoring/gates/client.ts +90 -0
  46. package/monitoring/gates/dev.ts +29 -0
  47. package/monitoring/gates/testing.ts +30 -0
  48. package/monitoring/logger/client.ts +66 -0
  49. package/monitoring/prometheus/client.ts +147 -0
  50. package/monitoring/prometheus/dev.ts +23 -0
  51. package/monitoring/prometheus/gen/remote_write_pb.ts +105 -0
  52. package/monitoring/prometheus/metrics.ts +180 -0
  53. package/monitoring/prometheus/testing.ts +19 -0
  54. package/monitoring/slack/api.ts +68 -0
  55. package/monitoring/slack/client.ts +33 -0
  56. package/monitoring/slack/events.ts +15 -0
  57. package/package.json +156 -5
  58. package/server/logger.ts +63 -0
  59. package/server/rate-limit-client.ts +88 -0
  60. package/server/rate-limit.ts +24 -0
  61. package/server/remote-functions.ts +139 -0
  62. package/server/result.ts +18 -0
  63. package/server/turnstile.ts +19 -0
  64. package/storage/clickhouse/client.ts +441 -0
  65. package/storage/clickhouse/compiler.ts +218 -0
  66. package/storage/clickhouse/config.ts +13 -0
  67. package/storage/clickhouse/dev.ts +18 -0
  68. package/storage/clickhouse/error.ts +61 -0
  69. package/storage/clickhouse/index.ts +4 -0
  70. package/storage/clickhouse/native-decoder.ts +192 -0
  71. package/storage/clickhouse/readers.ts +167 -0
  72. package/storage/clickhouse/retry.ts +30 -0
  73. package/storage/clickhouse/schema.ts +163 -0
  74. package/storage/clickhouse/testing.ts +81 -0
  75. package/storage/manticore/client.ts +165 -0
  76. package/storage/manticore/schema.ts +23 -0
  77. package/storage/postgres/client.ts +44 -0
  78. package/storage/postgres/testing.ts +275 -0
  79. package/storage/redis/client.ts +219 -0
  80. package/storage/redis/protocol.ts +119 -0
  81. package/storage/redis/schema.ts +17 -0
  82. package/storage/redis/testing.ts +122 -0
  83. package/utils/crypto.ts +36 -0
  84. package/utils/datetime.ts +338 -0
  85. package/utils/email.ts +173 -0
  86. package/utils/env.ts +171 -0
  87. package/utils/errors.ts +67 -0
  88. package/utils/form.ts +135 -0
  89. package/utils/http-status.ts +42 -0
  90. package/utils/money.ts +10 -0
  91. package/utils/public-host.ts +66 -0
  92. package/utils/schema.ts +176 -0
  93. package/utils/strings/format.ts +51 -0
  94. package/utils/strings/normalize.ts +129 -0
  95. package/utils/transport.ts +25 -0
@@ -0,0 +1,264 @@
1
+ import type { RequestEvent, RequestHandler } from '@sveltejs/kit';
2
+
3
+ import { json, redirect } from '@sveltejs/kit';
4
+ import { eq } from 'drizzle-orm';
5
+ import { z } from 'zod';
6
+
7
+ import type { AuthUser } from '../sessions/sessions';
8
+ import type { LoginError } from './login';
9
+
10
+ import { logger } from '../../server/logger';
11
+ import { ServiceError } from '../../utils/errors';
12
+ import { Schema } from '../../utils/schema';
13
+ import { Invites } from '../accounts/invites';
14
+ import { Auth } from '../config';
15
+ import { AuthEmails } from '../emails';
16
+ import { users } from '../schema';
17
+ import { AuthHandle } from '../sessions/handle';
18
+ import { MagicLink } from '../sessions/magic-link/magic-link';
19
+ import {
20
+ AuthenticationResponse,
21
+ PasskeyError,
22
+ RegistrationResponse,
23
+ type RelyingParty,
24
+ } from '../sessions/passkeys/passkeys';
25
+ import { Passkeys } from '../sessions/passkeys/passkeys';
26
+ import { Sessions } from '../sessions/sessions';
27
+ import { Users } from '../users';
28
+ import { Login } from './login';
29
+
30
+ // The auth endpoints, mounted by the app with one file:
31
+ // src/routes/auth/[...path]/+server.ts: export { POST } from '@zeyolabs/kit/auth/sveltekit/routes';
32
+ //
33
+ // POST /auth/email form {email, next} -> emails a link, 303 login?sent=1
34
+ // POST /auth/verify form {token, next} -> uses the link, signs in, 303 next
35
+ // POST /auth/logout form -> 303 login
36
+ // POST /auth/passkey/login-options json -> request options
37
+ // POST /auth/passkey/login json {credential, next} -> signs in, {redirect}
38
+ // POST /auth/passkey/register-options json -> creation options (fresh session)
39
+ // POST /auth/passkey/register json {credential, name} -> {ok}
40
+ // POST /auth/passkey/delete form {id, next} -> 303 next
41
+ // POST /auth/invite/accept form {token} -> joins the account, 303 its home
42
+ //
43
+ // The emailed link opens the login page (`?token=`), which posts it here from a button: mail
44
+ // scanners that prefetch links can't burn the token or sign anyone in.
45
+
46
+ const CHALLENGE_MAX_AGE = 5 * 60;
47
+
48
+ const challengeCookie = () => `${Auth.config().cookieName}_challenge`;
49
+
50
+ function relyingParty(event: RequestEvent): RelyingParty {
51
+ const { appName, passkeyRpId } = Auth.config();
52
+ return { id: passkeyRpId ?? event.url.hostname, name: appName, origin: event.url.origin };
53
+ }
54
+
55
+ function toLogin(error: LoginError | null, next: string, sent = false): never {
56
+ const params = new URLSearchParams();
57
+ if (error) params.set('error', error);
58
+ if (sent) params.set('sent', '1');
59
+ if (next !== '/') params.set('next', next);
60
+ const query = params.toString();
61
+ redirect(303, `${Auth.config().loginPath}${query ? `?${query}` : ''}`);
62
+ }
63
+
64
+ /** Checks a would-be session user against the allowlist and the Access identity. */
65
+ async function refusal(event: RequestEvent, email: string): Promise<LoginError | null> {
66
+ if (!(await Auth.config().isAllowed(email))) return 'not_allowed';
67
+ const { accessEmail } = AuthHandle.locals(event);
68
+ if (accessEmail && !Users.emailsMatch(accessEmail, email)) return 'mismatch';
69
+ return null;
70
+ }
71
+
72
+ async function signIn(event: RequestEvent, user: AuthUser): Promise<void> {
73
+ const { expiresAt, token } = await Sessions.create(Auth.config().db(), user.id);
74
+ event.cookies.set(Auth.config().cookieName, token, {
75
+ ...Sessions.cookieAttributes,
76
+ expires: new Date(expiresAt),
77
+ });
78
+ await Auth.config().onSignIn?.(user);
79
+ }
80
+
81
+ function setChallenge(event: RequestEvent): string {
82
+ const challenge = Passkeys.newChallenge();
83
+ event.cookies.set(challengeCookie(), challenge, {
84
+ ...Sessions.cookieAttributes,
85
+ maxAge: CHALLENGE_MAX_AGE,
86
+ path: '/auth/passkey',
87
+ sameSite: 'strict',
88
+ });
89
+ return challenge;
90
+ }
91
+
92
+ /** The pending challenge, used up: each one answers exactly one ceremony. */
93
+ function takeChallenge(event: RequestEvent): string {
94
+ const challenge = event.cookies.get(challengeCookie());
95
+ event.cookies.delete(challengeCookie(), { path: '/auth/passkey' });
96
+ if (!challenge) throw new PasskeyError('No pending challenge');
97
+ return challenge;
98
+ }
99
+
100
+ /** Adding or removing a passkey needs a session, and adding one needs a recent sign-in. */
101
+ function sessionUser(event: RequestEvent, fresh: boolean): AuthUser | Response {
102
+ const { session, user } = AuthHandle.locals(event);
103
+ if (!session || !user) return json({ message: 'Sign in first' }, { status: 401 });
104
+ if (fresh && Date.now() - session.createdAt > Passkeys.FRESH_SESSION_MS) {
105
+ return json(
106
+ { message: 'For security, sign in again before adding a passkey', reauth: true },
107
+ { status: 403 }
108
+ );
109
+ }
110
+ return user;
111
+ }
112
+
113
+ async function emailLink(event: RequestEvent): Promise<never> {
114
+ const form = await event.request.formData();
115
+ const next = Login.safeNext(form.get('next'));
116
+ const parsed = Schema.Email.safeParse(form.get('email'));
117
+ if (!parsed.success) toLogin('send', next);
118
+ const email = parsed.data;
119
+
120
+ const refused = await refusal(event, email);
121
+ // Unknown emails get the same answer as known ones, so the form can't probe the allowlist.
122
+ if (refused === 'not_allowed') toLogin(null, next, true);
123
+ if (refused) toLogin(refused, next);
124
+
125
+ const token = await MagicLink.issue(Auth.config().db(), email);
126
+ if (token) {
127
+ const url = new URL(Auth.config().loginPath, event.url.origin);
128
+ url.searchParams.set('token', token);
129
+ if (next !== '/') url.searchParams.set('next', next);
130
+ try {
131
+ const message = { email, expiresInMinutes: MagicLink.TTL_MS / 60_000, url: url.href };
132
+ await Auth.config()
133
+ .email()
134
+ .deliver({ to: email, ...AuthEmails.magicLink(Auth.config().emailBrand, message) });
135
+ } catch (err) {
136
+ logger.error(err, { op: 'auth.magic-link.send' });
137
+ toLogin('send', next);
138
+ }
139
+ }
140
+ toLogin(null, next, true);
141
+ }
142
+
143
+ async function verifyLink(event: RequestEvent): Promise<never> {
144
+ const form = await event.request.formData();
145
+ const next = Login.safeNext(form.get('next'));
146
+ const email = await MagicLink.consume(Auth.config().db(), String(form.get('token') ?? ''));
147
+ if (!email) toLogin('expired', next);
148
+ const refused = await refusal(event, email);
149
+ if (refused) toLogin(refused, next);
150
+ await signIn(event, await Users.findOrCreate(Auth.config().db(), email));
151
+ redirect(303, next);
152
+ }
153
+
154
+ async function logout(event: RequestEvent): Promise<never> {
155
+ const { session } = AuthHandle.locals(event);
156
+ if (session) await Sessions.delete(Auth.config().db(), session.id);
157
+ event.cookies.delete(Auth.config().cookieName, { path: '/' });
158
+ redirect(303, Auth.config().loginPath);
159
+ }
160
+
161
+ async function passkeyLogin(event: RequestEvent): Promise<Response> {
162
+ const body = z
163
+ .object({ credential: AuthenticationResponse, next: z.string().optional() })
164
+ .safeParse(await event.request.json().catch(() => null));
165
+ if (!body.success) return json({ message: 'Malformed request' }, { status: 400 });
166
+ const db = Auth.config().db();
167
+ try {
168
+ const challenge = takeChallenge(event);
169
+ const passkey = await Passkeys.find(db, body.data.credential.rawId);
170
+ if (!passkey) throw new PasskeyError('Unknown passkey');
171
+ const signCount = Passkeys.verifyAuthentication(
172
+ body.data.credential,
173
+ challenge,
174
+ relyingParty(event),
175
+ passkey
176
+ );
177
+ const [user] = await db.select().from(users).where(eq(users.id, passkey.userId)).limit(1);
178
+ const refused = await refusal(event, user.email);
179
+ if (refused) return json({ code: refused, message: 'Not allowed' }, { status: 403 });
180
+ await Passkeys.recordUse(db, passkey.id, signCount);
181
+ await signIn(event, user);
182
+ return json({ redirect: Login.safeNext(body.data.next) });
183
+ } catch (err) {
184
+ if (!(err instanceof PasskeyError)) throw err;
185
+ logger.warn(`passkey sign-in refused: ${err.message}`);
186
+ return json({ message: 'That passkey could not be verified' }, { status: 400 });
187
+ }
188
+ }
189
+
190
+ async function passkeyRegisterOptions(event: RequestEvent): Promise<Response> {
191
+ const user = sessionUser(event, true);
192
+ if (user instanceof Response) return user;
193
+ const existing = await Passkeys.list(Auth.config().db(), user.id);
194
+ const ids = existing.map((p) => p.id);
195
+ return json(Passkeys.registrationOptions(relyingParty(event), user, setChallenge(event), ids));
196
+ }
197
+
198
+ async function passkeyRegister(event: RequestEvent): Promise<Response> {
199
+ const user = sessionUser(event, true);
200
+ if (user instanceof Response) return user;
201
+ const body = z
202
+ .object({ credential: RegistrationResponse, name: z.string().trim().max(60).optional() })
203
+ .safeParse(await event.request.json().catch(() => null));
204
+ if (!body.success) return json({ message: 'Malformed request' }, { status: 400 });
205
+ try {
206
+ const credential = Passkeys.verifyRegistration(
207
+ body.data.credential,
208
+ takeChallenge(event),
209
+ relyingParty(event)
210
+ );
211
+ await Passkeys.save(Auth.config().db(), user.id, body.data.name || 'Passkey', credential);
212
+ return json({ ok: true });
213
+ } catch (err) {
214
+ if (!(err instanceof PasskeyError)) throw err;
215
+ logger.warn(`passkey registration refused: ${err.message}`);
216
+ return json({ message: 'That passkey could not be added' }, { status: 400 });
217
+ }
218
+ }
219
+
220
+ async function passkeyDelete(event: RequestEvent): Promise<never> {
221
+ const form = await event.request.formData();
222
+ const next = Login.safeNext(form.get('next'));
223
+ const user = sessionUser(event, false);
224
+ if (user instanceof Response) toLogin(null, next);
225
+ await Passkeys.delete(Auth.config().db(), user.id, String(form.get('id') ?? ''));
226
+ redirect(303, next);
227
+ }
228
+
229
+ /** Accepts an invite for the signed-in user (the invite page posts here) and opens the account. */
230
+ async function invite(event: RequestEvent): Promise<never> {
231
+ const form = await event.request.formData();
232
+ const token = String(form.get('token') ?? '');
233
+ const { user } = AuthHandle.locals(event);
234
+ const invitePage = `${Auth.config().invitePath}?token=${encodeURIComponent(token)}`;
235
+ if (!user) toLogin(null, invitePage);
236
+ const account = await Invites.accept(Auth.config().db(), token, user).catch((err: unknown) => {
237
+ if (err instanceof ServiceError) redirect(303, `${invitePage}&error=${err.status}`);
238
+ throw err;
239
+ });
240
+ redirect(303, Auth.config().accountHome(account.slug));
241
+ }
242
+
243
+ const handlers: Record<string, (event: RequestEvent) => Promise<Response>> = {
244
+ email: emailLink,
245
+ 'invite/accept': invite,
246
+ logout,
247
+ 'passkey/delete': passkeyDelete,
248
+ 'passkey/login': passkeyLogin,
249
+ 'passkey/login-options': async (event) =>
250
+ json(Passkeys.authenticationOptions(relyingParty(event), setChallenge(event))),
251
+ 'passkey/register': passkeyRegister,
252
+ 'passkey/register-options': passkeyRegisterOptions,
253
+ verify: verifyLink,
254
+ };
255
+
256
+ export const POST: RequestHandler = async (event) => {
257
+ // SvelteKit's CSRF check only covers form posts; the JSON endpoints need the same guard.
258
+ if (event.request.headers.get('origin') !== event.url.origin) {
259
+ return new Response('Cross-site request refused', { status: 403 });
260
+ }
261
+ const handler = handlers[event.params.path ?? ''];
262
+ if (!handler) return new Response('Not found', { status: 404 });
263
+ return handler(event);
264
+ };
package/auth/users.ts ADDED
@@ -0,0 +1,49 @@
1
+ import { eq } from 'drizzle-orm';
2
+
3
+ import type { AuthDb } from './config';
4
+ import type { AuthUser } from './sessions/sessions';
5
+
6
+ import { users } from './schema';
7
+
8
+ /** Parses a comma-separated allowlist (e.g. an `ALLOWED_EMAILS` var) into lower-cased emails. */
9
+ function parseAllowlist(raw: string | undefined): string[] {
10
+ return (raw ?? '')
11
+ .split(',')
12
+ .map((email) => email.trim().toLowerCase())
13
+ .filter(Boolean);
14
+ }
15
+
16
+ /** Case-insensitive allowlist check. An empty list allows nobody (fail closed). */
17
+ function isEmailAllowed(email: string, allowlist: readonly string[] | string | undefined) {
18
+ const list =
19
+ typeof allowlist === 'string' || allowlist === undefined
20
+ ? parseAllowlist(allowlist)
21
+ : allowlist.map((e) => e.trim().toLowerCase());
22
+ return list.includes(email.trim().toLowerCase());
23
+ }
24
+
25
+ function emailsMatch(a: string, b: string): boolean {
26
+ return a.trim().toLowerCase() === b.trim().toLowerCase();
27
+ }
28
+
29
+ /** The user for `email`, created on first sign-in with the email's local part as their name. */
30
+ async function findOrCreateUser(db: AuthDb, email: string): Promise<AuthUser> {
31
+ const normalized = email.trim().toLowerCase();
32
+ await db
33
+ .insert(users)
34
+ .values({ email: normalized, name: normalized.split('@')[0] })
35
+ .onConflictDoNothing({ target: users.email });
36
+ const [user] = await db.select().from(users).where(eq(users.email, normalized)).limit(1);
37
+ const { avatarUrl, id, name } = user;
38
+ return { avatarUrl, email: normalized, id, name };
39
+ }
40
+
41
+ export const Allowlist = {
42
+ includes: isEmailAllowed,
43
+ parse: parseAllowlist,
44
+ };
45
+
46
+ export const Users = {
47
+ emailsMatch,
48
+ findOrCreate: findOrCreateUser,
49
+ };
@@ -0,0 +1,134 @@
1
+ // Cloudflare Access: verify the signed `Cf-Access-Jwt-Assertion` header an Access-protected
2
+ // Worker receives. Access already blocks unauthenticated requests at the edge; this is the
3
+ // defence in depth the docs ask for, so the app still fails closed if the Access policy is ever
4
+ // removed, or the Worker is reached some other way than through the protected hostname.
5
+ // https://developers.cloudflare.com/cloudflare-one/identity/authorization-cookie/validating-json/
6
+
7
+ export type AccessConfig = {
8
+ /** The AUD tag of the Access application (a 64-char hex string, not a secret). */
9
+ aud: string;
10
+ /** e.g. `my-team.cloudflareaccess.com` */
11
+ teamDomain: string;
12
+ };
13
+
14
+ export type AccessIdentity = { email: string; sub: string };
15
+
16
+ type Jwk = JsonWebKey & { kid?: string };
17
+
18
+ export const ACCESS_JWT_HEADER = 'Cf-Access-Jwt-Assertion';
19
+
20
+ const KEY_TTL_MS = 60 * 60 * 1000;
21
+ const keyCache = new Map<string, { fetchedAt: number; keys: Jwk[] }>();
22
+
23
+ export class AccessError extends Error {
24
+ override name = 'AccessError';
25
+ }
26
+
27
+ function decodePart<T>(part: string): T {
28
+ const base64 = part.replaceAll('-', '+').replaceAll('_', '/');
29
+ const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
30
+ return JSON.parse(
31
+ new TextDecoder().decode(Uint8Array.from(atob(padded), (c) => c.charCodeAt(0)))
32
+ );
33
+ }
34
+
35
+ function decodeBytes(part: string): Uint8Array<ArrayBuffer> {
36
+ const base64 = part.replaceAll('-', '+').replaceAll('_', '/');
37
+ const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
38
+ return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
39
+ }
40
+
41
+ async function fetchAccessKeys(teamDomain: string): Promise<Jwk[]> {
42
+ const response = await fetch(`https://${teamDomain}/cdn-cgi/access/certs`);
43
+ if (!response.ok)
44
+ throw new AccessError(`Could not load Access signing keys (${response.status})`);
45
+ const body = (await response.json()) as { keys?: Jwk[] };
46
+ return body.keys ?? [];
47
+ }
48
+
49
+ async function keyFor(
50
+ kid: string,
51
+ teamDomain: string,
52
+ loadKeys: (teamDomain: string) => Promise<Jwk[]>,
53
+ now: number
54
+ ): Promise<Jwk> {
55
+ const cached = keyCache.get(teamDomain);
56
+ const fresh = cached && now - cached.fetchedAt < KEY_TTL_MS;
57
+ let keys = fresh ? cached.keys : undefined;
58
+ let found = keys?.find((k) => k.kid === kid);
59
+ if (!found) {
60
+ // Unknown kid (or stale cache): Access rotates keys, so refetch once before giving up.
61
+ keys = await loadKeys(teamDomain);
62
+ keyCache.set(teamDomain, { fetchedAt: now, keys });
63
+ found = keys.find((k) => k.kid === kid);
64
+ }
65
+ if (!found) throw new AccessError('Unknown signing key');
66
+ return found;
67
+ }
68
+
69
+ /**
70
+ * Verifies an Access JWT (RS256 signature, issuer, audience, expiry) and returns who it is for.
71
+ * Throws `AccessError` for anything that is not a valid token for this application.
72
+ * `loadKeys` and `now` exist for tests.
73
+ */
74
+ export async function verifyAccessJwt(
75
+ token: string,
76
+ config: AccessConfig,
77
+ {
78
+ loadKeys = fetchAccessKeys,
79
+ now = Date.now(),
80
+ }: { loadKeys?: (teamDomain: string) => Promise<Jwk[]>; now?: number } = {}
81
+ ): Promise<AccessIdentity> {
82
+ const parts = token.split('.');
83
+ if (parts.length !== 3) throw new AccessError('Malformed token');
84
+ const [rawHeader, rawPayload, rawSignature] = parts;
85
+
86
+ let header: { alg?: string; kid?: string };
87
+ let payload: {
88
+ aud?: string | string[];
89
+ email?: string;
90
+ exp?: number;
91
+ iss?: string;
92
+ nbf?: number;
93
+ sub?: string;
94
+ };
95
+ try {
96
+ header = decodePart(rawHeader);
97
+ payload = decodePart(rawPayload);
98
+ } catch {
99
+ throw new AccessError('Malformed token');
100
+ }
101
+ if (header.alg !== 'RS256' || !header.kid) throw new AccessError('Unsupported token');
102
+
103
+ const jwk = await keyFor(header.kid, config.teamDomain, loadKeys, now);
104
+ const key = await crypto.subtle.importKey(
105
+ 'jwk',
106
+ jwk,
107
+ { hash: 'SHA-256', name: 'RSASSA-PKCS1-v1_5' },
108
+ false,
109
+ ['verify']
110
+ );
111
+ const valid = await crypto.subtle.verify(
112
+ 'RSASSA-PKCS1-v1_5',
113
+ key,
114
+ decodeBytes(rawSignature),
115
+ new TextEncoder().encode(`${rawHeader}.${rawPayload}`)
116
+ );
117
+ if (!valid) throw new AccessError('Bad signature');
118
+
119
+ if (payload.iss !== `https://${config.teamDomain}`) throw new AccessError('Wrong issuer');
120
+ const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
121
+ if (!audiences.includes(config.aud)) throw new AccessError('Wrong audience');
122
+ const seconds = Math.floor(now / 1000);
123
+ if (typeof payload.exp !== 'number' || payload.exp <= seconds) throw new AccessError('Expired');
124
+ if (typeof payload.nbf === 'number' && payload.nbf > seconds)
125
+ throw new AccessError('Not yet valid');
126
+ if (!payload.email || !payload.sub) throw new AccessError('Not a user token');
127
+
128
+ return { email: payload.email, sub: payload.sub };
129
+ }
130
+
131
+ /** Test helper: forget cached signing keys. */
132
+ export function resetAccessKeyCache(): void {
133
+ keyCache.clear();
134
+ }