@zeyolabs/kit 2026.11.5 → 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.
package/README.md CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  Shared server toolkit for SvelteKit 3 on Cloudflare Workers: remote-function wrappers
4
4
  (`Remote.Query/Cmd/Form`), errors and results, the per-request Cloudflare context
5
- (`runWithContext`, `workersEnv`), rate limiting, Turnstile, logging and monitoring, storage
6
- clients, utils, and the shared ESLint config.
5
+ (`runWithContext`, `workersEnv`), passwordless auth, email (binding or SMTP), rate limiting, Turnstile, logging
6
+ and monitoring, storage clients, utils, and the shared ESLint config.
7
7
 
8
8
  Published as source (TypeScript, no build step): the consuming SvelteKit app compiles it.
9
9
 
@@ -29,6 +29,71 @@ git push origin HEAD vYYYY.M.D # the tag publishes to npm (publish.yaml)
29
29
  Publishing uses npm trusted publishing: the package's npmjs.com settings trust this repo's
30
30
  `publish.yaml`, so no npm token is stored anywhere.
31
31
 
32
+ ## Auth (`@zeyolabs/kit/auth/*`)
33
+
34
+ `config`, `schema`, `users`, `emails` at the top; one folder per part: `sessions/` (cookie
35
+ sessions, the auth handle, and the two ways in: `sessions/magic-link/` and `sessions/passkeys/`
36
+ with its browser `client`),
37
+ `accounts/` (accounts, invites, permissions, the account handle, remote functions), `sveltekit/`
38
+ (the `/auth/*` routes, page loads, login helpers). Each file exports one library object, like
39
+ `Crypto`: `Sessions`, `MagicLink`, `Passkeys`, `PasskeyClient`, `Users`, `Allowlist`, `Accounts`,
40
+ `Invites`, `Permissions`, `AuthHandle`, `AccountHandle`, `AuthLoads`, `AuthEmails`, `Login`, and
41
+ `Auth.configure` for the config.
42
+
43
+ Passwordless sign-in: an emailed magic link or a passkey. No passwords anywhere. Sessions are
44
+ `<id>.<secret>` cookies (sha256 at rest, 30 days, sliding); magic links are single-use and expire
45
+ in 15 minutes; passkeys are verified server-side (ES256/RS256, user verification required).
46
+
47
+ 1. Tables: add `node_modules/@zeyolabs/kit/auth/schema.ts` to the app's drizzle-kit `schema` and
48
+ generate a migration (`users`, `sessions`, `auth_tokens`, `passkeys`).
49
+ 2. Config (`Auth.configure`), once at module scope (e.g. next to `secretNames`):
50
+ ```ts
51
+ Auth.configure({
52
+ appName: 'My app',
53
+ db: () => getDb(),
54
+ email: () => (dev ? new DevEmailClient() : SmtpClient.fromEnv(smtpKeys, { from })),
55
+ emailBrand: brand,
56
+ isAllowed: (email) => Allowlist.includes(email, env().ALLOWED_EMAILS),
57
+ });
58
+ ```
59
+ 3. Hook, after the context handle: `AuthHandle.create({ access })` (pass `access` to also require
60
+ a matching Cloudflare Access JWT). Type `App.Locals` as `interface Locals extends AuthLocals`.
61
+ 4. Routes: `src/routes/auth/[...path]/+server.ts` is
62
+ `export { POST } from '@zeyolabs/kit/auth/sveltekit/routes';`, and the login page's `+page.server.ts` is
63
+ `export const load = AuthLoads.login;` (from `@zeyolabs/kit/auth/sveltekit/loads`).
64
+ 5. The login page (the app's own markup) posts `email` to `/auth/email`; with `?token=` it shows a
65
+ button posting `token` to `/auth/verify` (so mail scanners can't use the link); its passkey
66
+ button calls `PasskeyClient.signIn()` from `@zeyolabs/kit/auth/sessions/passkeys/client`. A settings page
67
+ uses `AuthLoads.passkeys` and `PasskeyClient.add(name)`, and posts `id` to `/auth/passkey/delete`.
68
+
69
+ ### Accounts and policies
70
+
71
+ Accounts own resources; users reach them through a membership. Resource tables carry
72
+ `account_id`, never `user_id`. Tables: `accounts`, `account_members`, `account_policies`,
73
+ `account_invites` (same `schema.ts`).
74
+
75
+ - **Policies.** Every member has one. Built in: `owner` (everything, can't be edited), `admin`
76
+ (all but `policies.manage`), `member` (the app's `memberPermissions`). An account can add its own
77
+ from permission keys (`resource.action`, or `resource.*`); the app lists its keys in
78
+ `Auth.configure({ accounts: { permissions, memberPermissions } })`. Only owners confer ownership,
79
+ and an account always keeps at least one owner.
80
+ - **Which account.** `AccountHandle.create({ slug: AccountHandle.slugFromRoute('account') })` after the auth handle
81
+ sets `locals.account` and `locals.membership` (404 for non-members). Remote functions call
82
+ `AccountHandle.requirePermission(event, 'projects.edit')`, or `Accounts.can(membership, key)`.
83
+ - **Invite only.** `isAllowed: (email) => Accounts.hasAccess(db, email)`; invites are emailed links
84
+ to the app's `/invite` page (`AuthLoads.invite`), accepted by posting to `/auth/invite/accept`.
85
+ `onSignIn` can bootstrap the first owners.
86
+ - **Management.** `@zeyolabs/kit/auth/accounts/accounts.remote` has the queries and commands a members page
87
+ needs (members, invites, policies), each checking the caller's permissions.
88
+
89
+ ## Email clients
90
+
91
+ `IEmailClient` (`deliver` throws, `send` logs and returns `false`) has two transports on
92
+ `BaseEmailClient`: `EmailClient` (`cloudflare/email`, a Workers `send_email` binding) and
93
+ `SmtpClient` (`cloudflare/email/smtp`, e.g. Amazon SES, any recipient; `SmtpClient.fromEnv(keys, sender)`
94
+ reads the settings from the request context). `DevEmailClient` prints instead of sending and
95
+ `FakeEmailClient` records messages for tests; both work in place of either transport.
96
+
32
97
  ## Developing against an app
33
98
 
34
99
  `pnpm link ../kit` in the app while working on both; publish, then pin the new version.
@@ -0,0 +1,116 @@
1
+ import { getRequestEvent } from '$app/server';
2
+ import { z } from 'zod';
3
+
4
+ import { Remote } from '../../server/remote-functions';
5
+ import { FormResult } from '../../utils/form';
6
+ import { Auth } from '../config';
7
+ import { AuthEmails } from '../emails';
8
+ import { AuthHandle } from '../sessions/handle';
9
+ import { Accounts } from './accounts';
10
+ import { AccountHandle } from './handle';
11
+ import { Invites } from './invites';
12
+ import { Permissions } from './permissions';
13
+ import { AccountSchemas } from './schemas';
14
+
15
+ // Account management for the app's pages, acting in the request's account (`AccountHandle.create`).
16
+ // Each call checks the caller's permissions in `accounts.ts`; these only adapt them to the wire.
17
+
18
+ /** The accounts the signed-in user belongs to, for an account switcher. */
19
+ export const getMyAccounts = Remote.Query(async () => {
20
+ const { user } = AuthHandle.locals(getRequestEvent());
21
+ return user ? Accounts.listForUser(Auth.config().db(), user.id) : [];
22
+ });
23
+
24
+ /** Everything a members page needs, shaped by what the caller may do. */
25
+ export const getAccountMembers = Remote.Query(async () => {
26
+ const event = getRequestEvent();
27
+ const { account, membership } = AccountHandle.require(event);
28
+ const db = Auth.config().db();
29
+ return {
30
+ account,
31
+ can: {
32
+ invite: Accounts.can(membership, 'members.invite'),
33
+ manage: Accounts.can(membership, 'members.manage'),
34
+ managePolicies: Accounts.can(membership, 'policies.manage'),
35
+ },
36
+ invites: await Invites.list(db, membership),
37
+ me: { policy: membership.policy.key, userId: membership.userId },
38
+ members: await Accounts.listMembers(db, membership),
39
+ permissions: [...Permissions.ACCOUNT, ...Auth.config().accounts.permissions],
40
+ policies: await Accounts.listPolicies(db, Auth.config().accounts, membership.accountId),
41
+ };
42
+ });
43
+
44
+ /** Invites someone by email; the link lands on the app's invite page. */
45
+ export const inviteMemberForm = Remote.Form(AccountSchemas.Invite, async (input) => {
46
+ const event = getRequestEvent();
47
+ const membership = AccountHandle.requirePermission(event, 'members.invite');
48
+ const { account } = AccountHandle.require(event);
49
+ const token = await Invites.create(Auth.config().db(), Auth.config().accounts, membership, input);
50
+ const url = new URL(Auth.config().invitePath, event.url.origin);
51
+ url.searchParams.set('token', token);
52
+ const inviter = AuthHandle.locals(event).user?.email ?? 'Someone';
53
+ await Auth.config()
54
+ .email()
55
+ .deliver({
56
+ to: input.email,
57
+ ...AuthEmails.invite(Auth.config().emailBrand, {
58
+ accountName: account.name,
59
+ inviter,
60
+ url: url.href,
61
+ }),
62
+ });
63
+ void getAccountMembers().refresh();
64
+ return FormResult.ok(`Invited ${input.email}`);
65
+ });
66
+
67
+ export const revokeInviteCmd = Remote.Cmd(z.object({ email: z.string() }), async ({ email }) => {
68
+ await Invites.revoke(
69
+ Auth.config().db(),
70
+ AccountHandle.require(getRequestEvent()).membership,
71
+ email
72
+ );
73
+ void getAccountMembers().refresh();
74
+ });
75
+
76
+ export const setMemberPolicyCmd = Remote.Cmd(
77
+ z.object({ policy: z.string().min(1).max(40), userId: z.number().int() }),
78
+ async ({ policy, userId }) => {
79
+ const { membership } = AccountHandle.require(getRequestEvent());
80
+ await Accounts.setMemberPolicy(
81
+ Auth.config().db(),
82
+ Auth.config().accounts,
83
+ membership,
84
+ userId,
85
+ policy
86
+ );
87
+ void getAccountMembers().refresh();
88
+ }
89
+ );
90
+
91
+ export const removeMemberCmd = Remote.Cmd(
92
+ z.object({ userId: z.number().int() }),
93
+ async ({ userId }) => {
94
+ await Accounts.removeMember(
95
+ Auth.config().db(),
96
+ AccountHandle.require(getRequestEvent()).membership,
97
+ userId
98
+ );
99
+ void getAccountMembers().refresh();
100
+ }
101
+ );
102
+
103
+ export const savePolicyCmd = Remote.Cmd(AccountSchemas.Policy, async (input) => {
104
+ const { membership } = AccountHandle.require(getRequestEvent());
105
+ await Accounts.savePolicy(Auth.config().db(), Auth.config().accounts, membership, input);
106
+ void getAccountMembers().refresh();
107
+ });
108
+
109
+ export const deletePolicyCmd = Remote.Cmd(z.object({ key: z.string() }), async ({ key }) => {
110
+ await Accounts.deletePolicy(
111
+ Auth.config().db(),
112
+ AccountHandle.require(getRequestEvent()).membership,
113
+ key
114
+ );
115
+ void getAccountMembers().refresh();
116
+ });
@@ -0,0 +1,332 @@
1
+ import { and, asc, eq, gt, isNull, ne, or, sql } from 'drizzle-orm';
2
+
3
+ import type { AuthDb } from '../config';
4
+ import type { PermissionCatalogue, Policy } from './permissions';
5
+
6
+ import { Error400, Error403, Error404, Error409 } from '../../utils/errors';
7
+ import { accountInvites, accountMembers, accountPolicies, accounts, users } from '../schema';
8
+ import { Permissions } from './permissions';
9
+
10
+ // Accounts own resources; users reach them through a membership whose policy says what they may
11
+ // do. Framework-agnostic: every function takes the db (and the app's permission catalogue), so a
12
+ // plain Worker can use it too. Mutations take the acting member and check its permissions; the
13
+ // "at least one owner" rule is enforced inside the same SQL statement (D1 has no row locks).
14
+
15
+ export type AccountSummary = { id: number; name: string; slug: string };
16
+ export type Membership = { accountId: number; policy: Policy; userId: number };
17
+
18
+ const OWNER = 'owner';
19
+
20
+ function can(membership: Membership | null, permission: string): boolean {
21
+ return !!membership && Permissions.grants(membership.policy.permissions, permission);
22
+ }
23
+
24
+ /** Throws a 403 unless `membership` grants `permission`. */
25
+ function assertCan(membership: Membership, permission: string): void {
26
+ if (!can(membership, permission)) throw Error403('You do not have permission to do that');
27
+ }
28
+
29
+ function isValidSlug(slug: string): boolean {
30
+ return /^[a-z0-9][a-z0-9-]{1,39}$/.test(slug);
31
+ }
32
+
33
+ /** Creates an account; with `ownerUserId`, that user becomes its owner. */
34
+ async function createAccount(
35
+ db: AuthDb,
36
+ input: { name: string; slug: string },
37
+ ownerUserId?: number,
38
+ now = Date.now()
39
+ ): Promise<AccountSummary> {
40
+ if (!isValidSlug(input.slug)) throw Error400('Use 2-40 lowercase letters, digits and dashes');
41
+ const [account] = await db
42
+ .insert(accounts)
43
+ .values({ createdAt: now, name: input.name.trim(), slug: input.slug })
44
+ .onConflictDoNothing({ target: accounts.slug })
45
+ .returning({ id: accounts.id, name: accounts.name, slug: accounts.slug });
46
+ if (!account) throw Error409('That address is taken');
47
+ if (ownerUserId !== undefined) await addMember(db, account.id, ownerUserId, OWNER, now);
48
+ return account;
49
+ }
50
+
51
+ async function findAccountBySlug(db: AuthDb, slug: string): Promise<AccountSummary | null> {
52
+ const [account] = await db
53
+ .select({ id: accounts.id, name: accounts.name, slug: accounts.slug })
54
+ .from(accounts)
55
+ .where(eq(accounts.slug, slug))
56
+ .limit(1);
57
+ return account ?? null;
58
+ }
59
+
60
+ /** The accounts a user belongs to, with their policy key in each, oldest membership first. */
61
+ async function listAccountsForUser(db: AuthDb, userId: number) {
62
+ return db
63
+ .select({
64
+ id: accounts.id,
65
+ name: accounts.name,
66
+ policy: accountMembers.policy,
67
+ slug: accounts.slug,
68
+ })
69
+ .from(accountMembers)
70
+ .innerJoin(accounts, eq(accounts.id, accountMembers.accountId))
71
+ .where(eq(accountMembers.userId, userId))
72
+ .orderBy(asc(accountMembers.createdAt));
73
+ }
74
+
75
+ /** The built-in policies plus the account's own. */
76
+ async function listPolicies(
77
+ db: AuthDb,
78
+ catalogue: PermissionCatalogue,
79
+ accountId: number
80
+ ): Promise<Policy[]> {
81
+ const custom = await db
82
+ .select()
83
+ .from(accountPolicies)
84
+ .where(eq(accountPolicies.accountId, accountId))
85
+ .orderBy(asc(accountPolicies.name));
86
+ const builtIn = Permissions.builtIns(catalogue);
87
+ return [
88
+ builtIn.owner,
89
+ builtIn.admin,
90
+ builtIn.member,
91
+ ...custom.map((p) => ({
92
+ builtIn: false,
93
+ key: p.key,
94
+ name: p.name,
95
+ permissions: p.permissions,
96
+ })),
97
+ ];
98
+ }
99
+
100
+ /** A built-in or custom policy of the account by key, or `null`. */
101
+ async function findPolicy(
102
+ db: AuthDb,
103
+ catalogue: PermissionCatalogue,
104
+ accountId: number,
105
+ key: string
106
+ ): Promise<null | Policy> {
107
+ if (Permissions.isBuiltIn(key)) return Permissions.builtIns(catalogue)[key];
108
+ const [row] = await db
109
+ .select()
110
+ .from(accountPolicies)
111
+ .where(and(eq(accountPolicies.accountId, accountId), eq(accountPolicies.key, key)))
112
+ .limit(1);
113
+ return row
114
+ ? { builtIn: false, key: row.key, name: row.name, permissions: row.permissions }
115
+ : null;
116
+ }
117
+
118
+ /** The user's membership of the account with its resolved policy, or `null` if not a member. */
119
+ async function resolveMembership(
120
+ db: AuthDb,
121
+ catalogue: PermissionCatalogue,
122
+ accountId: number,
123
+ userId: number
124
+ ): Promise<Membership | null> {
125
+ const [row] = await db
126
+ .select({ policy: accountMembers.policy })
127
+ .from(accountMembers)
128
+ .where(and(eq(accountMembers.accountId, accountId), eq(accountMembers.userId, userId)))
129
+ .limit(1);
130
+ if (!row) return null;
131
+ // A member whose custom policy was somehow removed keeps no permissions rather than failing.
132
+ const policy = (await findPolicy(db, catalogue, accountId, row.policy)) ?? {
133
+ builtIn: false,
134
+ key: row.policy,
135
+ name: row.policy,
136
+ permissions: [],
137
+ };
138
+ return { accountId, policy, userId };
139
+ }
140
+
141
+ /** Adds a member (no-op if already one). For bootstrapping and invites; no permission check. */
142
+ async function addMember(
143
+ db: AuthDb,
144
+ accountId: number,
145
+ userId: number,
146
+ policy: string,
147
+ now = Date.now()
148
+ ): Promise<void> {
149
+ await db
150
+ .insert(accountMembers)
151
+ .values({ accountId, createdAt: now, policy, userId })
152
+ .onConflictDoNothing();
153
+ }
154
+
155
+ async function listMembers(db: AuthDb, actor: Membership) {
156
+ assertCan(actor, 'members.read');
157
+ return db
158
+ .select({
159
+ email: users.email,
160
+ joinedAt: accountMembers.createdAt,
161
+ name: users.name,
162
+ policy: accountMembers.policy,
163
+ userId: users.id,
164
+ })
165
+ .from(accountMembers)
166
+ .innerJoin(users, eq(users.id, accountMembers.userId))
167
+ .where(eq(accountMembers.accountId, actor.accountId))
168
+ .orderBy(asc(users.email));
169
+ }
170
+
171
+ /** SQL: another owner of the account remains besides `userId`. */
172
+ const anotherOwner = (accountId: number, userId: number) =>
173
+ sql`exists (select 1 from ${accountMembers} where ${accountMembers.accountId} = ${accountId} and ${accountMembers.policy} = ${OWNER} and ${accountMembers.userId} != ${userId})`;
174
+
175
+ async function currentPolicy(db: AuthDb, accountId: number, userId: number) {
176
+ const [row] = await db
177
+ .select({ policy: accountMembers.policy })
178
+ .from(accountMembers)
179
+ .where(and(eq(accountMembers.accountId, accountId), eq(accountMembers.userId, userId)))
180
+ .limit(1);
181
+ if (!row) throw Error404('Not a member of this account');
182
+ return row.policy;
183
+ }
184
+
185
+ /** Gives a member another policy. Only owners confer or take away ownership; nobody changes their
186
+ * own policy; the last owner stays an owner. */
187
+ async function setMemberPolicy(
188
+ db: AuthDb,
189
+ catalogue: PermissionCatalogue,
190
+ actor: Membership,
191
+ userId: number,
192
+ policy: string
193
+ ): Promise<void> {
194
+ assertCan(actor, 'members.manage');
195
+ if (userId === actor.userId) throw Error400('You cannot change your own policy');
196
+ if (!(await findPolicy(db, catalogue, actor.accountId, policy))) throw Error400('Unknown policy');
197
+ const previous = await currentPolicy(db, actor.accountId, userId);
198
+ if ((policy === OWNER || previous === OWNER) && actor.policy.key !== OWNER) {
199
+ throw Error403('Only an owner can change ownership');
200
+ }
201
+ const updated = await db
202
+ .update(accountMembers)
203
+ .set({ policy })
204
+ .where(
205
+ and(
206
+ eq(accountMembers.accountId, actor.accountId),
207
+ eq(accountMembers.userId, userId),
208
+ or(ne(accountMembers.policy, OWNER), anotherOwner(actor.accountId, userId))
209
+ )
210
+ )
211
+ .returning({ userId: accountMembers.userId });
212
+ if (!updated.length) throw Error409('An account needs at least one owner');
213
+ }
214
+
215
+ /** Removes a member, or lets a member leave. Only owners remove owners; the last owner can't go. */
216
+ async function removeMember(db: AuthDb, actor: Membership, userId: number): Promise<void> {
217
+ if (userId !== actor.userId) assertCan(actor, 'members.manage');
218
+ const previous = await currentPolicy(db, actor.accountId, userId);
219
+ if (previous === OWNER && userId !== actor.userId && actor.policy.key !== OWNER) {
220
+ throw Error403('Only an owner can remove an owner');
221
+ }
222
+ const removed = await db
223
+ .delete(accountMembers)
224
+ .where(
225
+ and(
226
+ eq(accountMembers.accountId, actor.accountId),
227
+ eq(accountMembers.userId, userId),
228
+ or(ne(accountMembers.policy, OWNER), anotherOwner(actor.accountId, userId))
229
+ )
230
+ )
231
+ .returning({ userId: accountMembers.userId });
232
+ if (!removed.length) throw Error409('An account needs at least one owner');
233
+ }
234
+
235
+ /** Creates or updates one of the account's own policies. */
236
+ async function savePolicy(
237
+ db: AuthDb,
238
+ catalogue: PermissionCatalogue,
239
+ actor: Membership,
240
+ input: { key: string; name: string; permissions: string[] },
241
+ now = Date.now()
242
+ ): Promise<void> {
243
+ assertCan(actor, 'policies.manage');
244
+ if (!Permissions.isValidKey(input.key))
245
+ throw Error400('Use 1-40 lowercase letters, digits and dashes');
246
+ const bad = input.permissions.filter((p) => !Permissions.isGrantable(catalogue, p));
247
+ if (bad.length) throw Error400(`Unknown permissions: ${bad.join(', ')}`);
248
+ const permissions = [...new Set(input.permissions)].sort();
249
+ await db
250
+ .insert(accountPolicies)
251
+ .values({
252
+ accountId: actor.accountId,
253
+ createdAt: now,
254
+ key: input.key,
255
+ name: input.name.trim(),
256
+ permissions,
257
+ })
258
+ .onConflictDoUpdate({
259
+ set: { name: input.name.trim(), permissions },
260
+ target: [accountPolicies.accountId, accountPolicies.key],
261
+ });
262
+ }
263
+
264
+ /** Deletes one of the account's own policies, unless a member or a pending invite still uses it. */
265
+ async function deletePolicy(db: AuthDb, actor: Membership, key: string, now = Date.now()) {
266
+ assertCan(actor, 'policies.manage');
267
+ if (Permissions.isBuiltIn(key)) throw Error400('Built-in policies cannot be deleted');
268
+ const [used] = await db
269
+ .select({ userId: accountMembers.userId })
270
+ .from(accountMembers)
271
+ .where(and(eq(accountMembers.accountId, actor.accountId), eq(accountMembers.policy, key)))
272
+ .limit(1);
273
+ const [invited] = await db
274
+ .select({ email: accountInvites.email })
275
+ .from(accountInvites)
276
+ .where(
277
+ and(
278
+ eq(accountInvites.accountId, actor.accountId),
279
+ eq(accountInvites.policy, key),
280
+ isNull(accountInvites.acceptedAt),
281
+ gt(accountInvites.expiresAt, now)
282
+ )
283
+ )
284
+ .limit(1);
285
+ if (used || invited) throw Error409('Move its members and invites to another policy first');
286
+ await db
287
+ .delete(accountPolicies)
288
+ .where(and(eq(accountPolicies.accountId, actor.accountId), eq(accountPolicies.key, key)));
289
+ }
290
+
291
+ /** Whether `email` belongs to any account or holds a live invite: the invite-only sign-in rule. */
292
+ async function hasAccountAccess(db: AuthDb, email: string, now = Date.now()) {
293
+ const normalized = email.trim().toLowerCase();
294
+ const [member] = await db
295
+ .select({ userId: accountMembers.userId })
296
+ .from(accountMembers)
297
+ .innerJoin(users, eq(users.id, accountMembers.userId))
298
+ .where(eq(users.email, normalized))
299
+ .limit(1);
300
+ if (member) return true;
301
+ const [invite] = await db
302
+ .select({ email: accountInvites.email })
303
+ .from(accountInvites)
304
+ .where(
305
+ and(
306
+ eq(accountInvites.email, normalized),
307
+ isNull(accountInvites.acceptedAt),
308
+ gt(accountInvites.expiresAt, now)
309
+ )
310
+ )
311
+ .limit(1);
312
+ return !!invite;
313
+ }
314
+
315
+ export const Accounts = {
316
+ addMember,
317
+ assertCan,
318
+ can,
319
+ create: createAccount,
320
+ deletePolicy,
321
+ findBySlug: findAccountBySlug,
322
+ findPolicy,
323
+ hasAccess: hasAccountAccess,
324
+ isValidSlug,
325
+ listForUser: listAccountsForUser,
326
+ listMembers,
327
+ listPolicies,
328
+ removeMember,
329
+ resolveMembership,
330
+ savePolicy,
331
+ setMemberPolicy,
332
+ };
@@ -0,0 +1,102 @@
1
+ import type { RequestEvent } from '@sveltejs/kit';
2
+ import type { Handle } from '@sveltejs/kit/hooks';
3
+
4
+ import { error } from '@sveltejs/kit';
5
+
6
+ import type { AccountSummary, Membership } from './accounts';
7
+
8
+ import { Error403, Error404 } from '../../utils/errors';
9
+ import { Auth } from '../config';
10
+ import { AuthHandle } from '../sessions/handle';
11
+ import { Accounts } from './accounts';
12
+
13
+ // Which account a request acts in, and the signed-in user's membership of it. Runs after
14
+ // `createAuthHandle`. The account comes from the request (a route param, a fixed slug, ...) and
15
+ // never from the session, so switching accounts is just navigating.
16
+
17
+ export type AccountLocals = {
18
+ /** The account this request acts in, or null where the route has none. */
19
+ account: AccountSummary | null;
20
+ /** The signed-in user's membership of `account`. */
21
+ membership: Membership | null;
22
+ };
23
+
24
+ function accountLocals(event: RequestEvent): AccountLocals {
25
+ return event.locals as unknown as AccountLocals;
26
+ }
27
+
28
+ /**
29
+ * The account slug from the route param `param` (e.g. `/[account]/...`). Remote-function calls
30
+ * have no route params, so for those it reads the same path segment of the calling page (the
31
+ * `Referer`), which SvelteKit always sends for same-origin fetches.
32
+ */
33
+ function slugFromRoute(param = 'account', segment = 0) {
34
+ return (event: RequestEvent): null | string => {
35
+ const fromParam = event.params[param as keyof typeof event.params];
36
+ if (typeof fromParam === 'string') return fromParam;
37
+ if (!event.url.pathname.startsWith('/_app/remote')) return null;
38
+ const referer = event.request.headers.get('referer');
39
+ if (!referer) return null;
40
+ const page = new URL(referer);
41
+ if (page.origin !== event.url.origin) return null;
42
+ return page.pathname.split('/').filter(Boolean)[segment] ?? null;
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Sets `locals.account` and `locals.membership`. A slug for an account that doesn't exist, or that
48
+ * the user doesn't belong to, is a 404 (the same answer, so slugs can't be probed).
49
+ */
50
+ function createAccountHandle(options: {
51
+ /** The account slug for a request, or null when it isn't in an account. */
52
+ slug: (event: RequestEvent) => null | string;
53
+ }): Handle {
54
+ return async ({ event, resolve }) => {
55
+ const locals = accountLocals(event);
56
+ locals.account = null;
57
+ locals.membership = null;
58
+ const { user } = AuthHandle.locals(event);
59
+ const slug = user ? options.slug(event) : null;
60
+ if (slug && user) {
61
+ const account = await Accounts.findBySlug(Auth.config().db(), slug);
62
+ const membership =
63
+ account &&
64
+ (await Accounts.resolveMembership(
65
+ Auth.config().db(),
66
+ Auth.config().accounts,
67
+ account.id,
68
+ user.id
69
+ ));
70
+ if (!account || !membership) error(404, 'Not found');
71
+ locals.account = account;
72
+ locals.membership = membership;
73
+ }
74
+ return resolve(event);
75
+ };
76
+ }
77
+
78
+ /** The request's account and membership, or a 404 `ServiceError`. */
79
+ function requireAccount(event: RequestEvent): {
80
+ account: AccountSummary;
81
+ membership: Membership;
82
+ } {
83
+ const { account, membership } = accountLocals(event);
84
+ if (!account || !membership) throw Error404('Not found');
85
+ return { account, membership };
86
+ }
87
+
88
+ /** The request's membership if it grants `permission`, else a 403 `ServiceError`. */
89
+ function requirePermission(event: RequestEvent, permission: string): Membership {
90
+ const { membership } = requireAccount(event);
91
+ if (!Accounts.can(membership, permission))
92
+ throw Error403('You do not have permission to do that');
93
+ return membership;
94
+ }
95
+
96
+ export const AccountHandle = {
97
+ create: createAccountHandle,
98
+ locals: accountLocals,
99
+ require: requireAccount,
100
+ requirePermission,
101
+ slugFromRoute,
102
+ };