@zeyolabs/kit 2026.11.5 → 2026.11.7

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.
@@ -0,0 +1,149 @@
1
+ import { and, asc, eq, gt, isNull } from 'drizzle-orm';
2
+
3
+ import type { AuthDb } from '../config';
4
+ import type { AccountSummary, Membership } from './accounts';
5
+ import type { PermissionCatalogue } from './permissions';
6
+
7
+ import { Crypto } from '../../utils/crypto';
8
+ import { Error400, Error403, Error404, Error409 } from '../../utils/errors';
9
+ import { accountInvites, accountMembers, accounts, users } from '../schema';
10
+ import { Accounts } from './accounts';
11
+
12
+ // Invitations to join an account: an emailed single-use link, accepted only by a session for the
13
+ // invited email.
14
+
15
+ const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
16
+
17
+ const OWNER = 'owner';
18
+
19
+ /** Invites `email` with `policy`; replaces any earlier pending invite for that email. Returns the
20
+ * token for the emailed link. */
21
+ async function createInvite(
22
+ db: AuthDb,
23
+ catalogue: PermissionCatalogue,
24
+ actor: Membership,
25
+ input: { email: string; policy: string },
26
+ now = Date.now()
27
+ ): Promise<string> {
28
+ Accounts.assertCan(actor, 'members.invite');
29
+ const email = input.email.trim().toLowerCase();
30
+ if (input.policy === OWNER && actor.policy.key !== OWNER) {
31
+ throw Error403('Only an owner can invite an owner');
32
+ }
33
+ if (!(await Accounts.findPolicy(db, catalogue, actor.accountId, input.policy))) {
34
+ throw Error400('Unknown policy');
35
+ }
36
+ const [member] = await db
37
+ .select({ userId: accountMembers.userId })
38
+ .from(accountMembers)
39
+ .innerJoin(users, eq(users.id, accountMembers.userId))
40
+ .where(and(eq(accountMembers.accountId, actor.accountId), eq(users.email, email)))
41
+ .limit(1);
42
+ if (member) throw Error409('Already a member');
43
+
44
+ await db
45
+ .delete(accountInvites)
46
+ .where(
47
+ and(
48
+ eq(accountInvites.accountId, actor.accountId),
49
+ eq(accountInvites.email, email),
50
+ isNull(accountInvites.acceptedAt)
51
+ )
52
+ );
53
+ const token = Crypto.getRandomSecret(32);
54
+ await db.insert(accountInvites).values({
55
+ accountId: actor.accountId,
56
+ createdAt: now,
57
+ email,
58
+ expiresAt: now + INVITE_TTL_MS,
59
+ invitedBy: actor.userId,
60
+ policy: input.policy,
61
+ tokenHash: Crypto.hashSecret(token),
62
+ });
63
+ return token;
64
+ }
65
+
66
+ async function listInvites(db: AuthDb, actor: Membership, now = Date.now()) {
67
+ Accounts.assertCan(actor, 'members.read');
68
+ return db
69
+ .select({
70
+ email: accountInvites.email,
71
+ expiresAt: accountInvites.expiresAt,
72
+ policy: accountInvites.policy,
73
+ })
74
+ .from(accountInvites)
75
+ .where(
76
+ and(
77
+ eq(accountInvites.accountId, actor.accountId),
78
+ isNull(accountInvites.acceptedAt),
79
+ gt(accountInvites.expiresAt, now)
80
+ )
81
+ )
82
+ .orderBy(asc(accountInvites.email));
83
+ }
84
+
85
+ async function revokeInvite(db: AuthDb, actor: Membership, email: string): Promise<void> {
86
+ Accounts.assertCan(actor, 'members.invite');
87
+ await db
88
+ .delete(accountInvites)
89
+ .where(
90
+ and(
91
+ eq(accountInvites.accountId, actor.accountId),
92
+ eq(accountInvites.email, email.trim().toLowerCase()),
93
+ isNull(accountInvites.acceptedAt)
94
+ )
95
+ );
96
+ }
97
+
98
+ /** A live (pending, unexpired) invite and its account, for the invite page. */
99
+ async function findInvite(db: AuthDb, token: string, now = Date.now()) {
100
+ if (!/^[0-9a-f]{64}$/.test(token)) return null;
101
+ const [row] = await db
102
+ .select({ account: accounts, email: accountInvites.email, policy: accountInvites.policy })
103
+ .from(accountInvites)
104
+ .innerJoin(accounts, eq(accounts.id, accountInvites.accountId))
105
+ .where(
106
+ and(
107
+ eq(accountInvites.tokenHash, Crypto.hashSecret(token)),
108
+ isNull(accountInvites.acceptedAt),
109
+ gt(accountInvites.expiresAt, now)
110
+ )
111
+ )
112
+ .limit(1);
113
+ if (!row) return null;
114
+ const { id, name, slug } = row.account;
115
+ return { account: { id, name, slug }, email: row.email, policy: row.policy };
116
+ }
117
+
118
+ /** Accepts an invite for the signed-in `user`, whose email must be the invited one. Single use. */
119
+ async function acceptInvite(
120
+ db: AuthDb,
121
+ token: string,
122
+ user: { email: string; id: number },
123
+ now = Date.now()
124
+ ): Promise<AccountSummary> {
125
+ const invite = await findInvite(db, token, now);
126
+ if (!invite) throw Error404('This invite has expired or was already used');
127
+ if (invite.email !== user.email.toLowerCase()) {
128
+ throw Error403(`This invite is for ${invite.email}. Sign in with that email to accept it.`);
129
+ }
130
+ const [used] = await db
131
+ .update(accountInvites)
132
+ .set({ acceptedAt: now })
133
+ .where(
134
+ and(eq(accountInvites.tokenHash, Crypto.hashSecret(token)), isNull(accountInvites.acceptedAt))
135
+ )
136
+ .returning({ policy: accountInvites.policy });
137
+ if (!used) throw Error404('This invite has expired or was already used');
138
+ await Accounts.addMember(db, invite.account.id, user.id, used.policy, now);
139
+ return invite.account;
140
+ }
141
+
142
+ export const Invites = {
143
+ accept: acceptInvite,
144
+ create: createInvite,
145
+ find: findInvite,
146
+ list: listInvites,
147
+ revoke: revokeInvite,
148
+ TTL_MS: INVITE_TTL_MS,
149
+ };
@@ -0,0 +1,90 @@
1
+ // Permissions and policies. Pure (no db), so pages can use them too, e.g. to hide a button.
2
+ //
3
+ // A permission is a key like `projects.edit`: `<resource>.<action>`. A policy is a named set of
4
+ // keys; every account member has exactly one. A key may be a wildcard: `*` grants everything,
5
+ // `projects.*` every action on projects.
6
+ //
7
+ // Three policies are built in and exist in every account:
8
+ // owner - everything, including policies and conferring ownership; can't be edited
9
+ // admin - everything except managing policies
10
+ // member - the app's `memberPermissions`
11
+ // An account can add its own policies (`account_policies`), made of the app's permission keys.
12
+
13
+ /** The permissions this module itself checks, for account management. */
14
+ const ACCOUNT_PERMISSIONS = [
15
+ 'account.edit',
16
+ 'members.read',
17
+ 'members.invite',
18
+ 'members.manage',
19
+ 'policies.manage',
20
+ ] as const;
21
+
22
+ export const BUILT_IN_POLICY_KEYS = ['owner', 'admin', 'member'] as const;
23
+ export type BuiltInPolicyKey = (typeof BUILT_IN_POLICY_KEYS)[number];
24
+
25
+ export type Policy = {
26
+ builtIn: boolean;
27
+ key: string;
28
+ name: string;
29
+ permissions: readonly string[];
30
+ };
31
+
32
+ /** The app's permission catalogue: every key a policy may grant, besides the account ones. */
33
+ export type PermissionCatalogue = {
34
+ /** What a plain member gets. */
35
+ memberPermissions: readonly string[];
36
+ /** Every app permission key, e.g. `['projects.read', 'projects.edit', ...]`. */
37
+ permissions: readonly string[];
38
+ };
39
+
40
+ function isBuiltInPolicy(key: string): key is BuiltInPolicyKey {
41
+ return (BUILT_IN_POLICY_KEYS as readonly string[]).includes(key);
42
+ }
43
+
44
+ function builtInPolicies(catalogue: PermissionCatalogue): Record<BuiltInPolicyKey, Policy> {
45
+ const everything = [...ACCOUNT_PERMISSIONS, ...catalogue.permissions];
46
+ return {
47
+ admin: {
48
+ builtIn: true,
49
+ key: 'admin',
50
+ name: 'Admin',
51
+ permissions: everything.filter((p) => p !== 'policies.manage'),
52
+ },
53
+ member: {
54
+ builtIn: true,
55
+ key: 'member',
56
+ name: 'Member',
57
+ permissions: catalogue.memberPermissions,
58
+ },
59
+ owner: { builtIn: true, key: 'owner', name: 'Owner', permissions: ['*'] },
60
+ };
61
+ }
62
+
63
+ /** Whether `granted` (a policy's keys, wildcards allowed) includes `permission`. */
64
+ function grants(granted: readonly string[], permission: string): boolean {
65
+ const resource = permission.split('.')[0];
66
+ return granted.some((g) => g === '*' || g === permission || g === `${resource}.*`);
67
+ }
68
+
69
+ /** Every key a custom policy may contain: exact catalogue keys, or `<resource>.*` for a resource
70
+ * in it. Never `*`, which only the owner policy holds. */
71
+ function isGrantableKey(catalogue: PermissionCatalogue, key: string): boolean {
72
+ const all = [...ACCOUNT_PERMISSIONS, ...catalogue.permissions];
73
+ if (all.includes(key)) return true;
74
+ const match = /^([a-z][a-z0-9-]*)\.\*$/.exec(key);
75
+ return !!match && all.some((p) => p.startsWith(`${match[1]}.`));
76
+ }
77
+
78
+ /** Keys and names for a custom policy: `[a-z0-9-]`, 1-40 chars, not a built-in key. */
79
+ function isValidPolicyKey(key: string): boolean {
80
+ return /^[a-z0-9][a-z0-9-]{0,39}$/.test(key) && !isBuiltInPolicy(key);
81
+ }
82
+
83
+ export const Permissions = {
84
+ ACCOUNT: ACCOUNT_PERMISSIONS,
85
+ builtIns: builtInPolicies,
86
+ grants,
87
+ isBuiltIn: isBuiltInPolicy,
88
+ isGrantable: isGrantableKey,
89
+ isValidKey: isValidPolicyKey,
90
+ };
@@ -0,0 +1,22 @@
1
+ import { z } from 'zod';
2
+
3
+ import { Schema } from '../../utils/schema';
4
+
5
+ // Inputs of the account-management remote functions, shared with the app's forms (a remote module
6
+ // may only export remote functions).
7
+
8
+ const InviteSchema = z.object({
9
+ email: Schema.Email,
10
+ policy: z.string().min(1).max(40),
11
+ });
12
+
13
+ const PolicySchema = z.object({
14
+ key: z.string().min(1).max(40),
15
+ name: z.string().trim().min(1).max(60),
16
+ permissions: z.array(z.string().max(80)).max(200),
17
+ });
18
+
19
+ export const AccountSchemas = {
20
+ Invite: InviteSchema,
21
+ Policy: PolicySchema,
22
+ };
package/auth/config.ts ADDED
@@ -0,0 +1,82 @@
1
+ import type { BaseSQLiteDatabase } from 'drizzle-orm/sqlite-core';
2
+
3
+ import type { IEmailClient } from '../cloudflare/email/base';
4
+ import type { EmailBrand } from '../utils/email';
5
+ import type { PermissionCatalogue } from './accounts/permissions';
6
+ import type { AuthUser } from './sessions/sessions';
7
+
8
+ /** Any async drizzle SQLite client (D1 in prod). A consumer's larger, schema-bound `Db` is
9
+ * assignable. */
10
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
11
+ export type AuthDb = BaseSQLiteDatabase<'async', any, any>;
12
+
13
+ export type MagicLinkMessage = {
14
+ /** The email the link was requested for (already lower-cased). */
15
+ email: string;
16
+ expiresInMinutes: number;
17
+ url: string;
18
+ };
19
+
20
+ export type AuthConfig = {
21
+ /** The app's permission keys and what plain members get (see `accounts/permissions.ts`). */
22
+ accounts: PermissionCatalogue;
23
+ /** Shown by the browser's passkey prompt and in the magic-link email. */
24
+ appName: string;
25
+ /** Share the session cookie with every subdomain of this domain (e.g. `example.com` for
26
+ * `app.example.com` and `mcp.example.com`), so one sign-in covers them all. Every subdomain's
27
+ * server then receives the cookie, so only set it when you control all of them. Default:
28
+ * host-only. Changing it signs everyone out once (the old cookie no longer matches). */
29
+ cookieDomain?: string;
30
+ db: () => AuthDb;
31
+ /** Sends the sign-in email (`magicLinkEmail`): any `IEmailClient`, e.g. `SmtpClient`, or
32
+ * `DevEmailClient` locally (it prints the link). Called per request. */
33
+ email: () => IEmailClient;
34
+ /** Name and colours for the sign-in email. */
35
+ emailBrand: EmailBrand;
36
+ /** Who may sign in at all. Checked when a link is requested, when it is used, and on every
37
+ * request, so removing someone signs them out. Unknown emails get the same "check your email"
38
+ * answer as known ones but no email. Invite-only apps use `hasAccountAccess` (a member of
39
+ * some account, or invited to one). */
40
+ isAllowed: (email: string) => boolean | Promise<boolean>;
41
+ /** Runs after each sign-in, e.g. to make bootstrap emails owners of the app's account. */
42
+ onSignIn?: (user: AuthUser) => Promise<void>;
43
+ /** The passkey domain (WebAuthn RP id). Default: the request's hostname. Set a parent domain
44
+ * (e.g. `example.com` for `app.example.com`) to share passkeys across its subdomains; it can't
45
+ * change later without orphaning every passkey. */
46
+ passkeyRpId?: string;
47
+ };
48
+
49
+ export type ResolvedAuthConfig = AuthConfig & {
50
+ /** Where an accepted invite lands. Default: `/<slug>`. */
51
+ accountHome: (slug: string) => string;
52
+ cookieName: string;
53
+ /** The page that shows an invite (`inviteLoad`). Default: `/invite`. */
54
+ invitePath: string;
55
+ loginPath: string;
56
+ };
57
+
58
+ let current: null | ResolvedAuthConfig = null;
59
+
60
+ /** Registers the app's auth config. Call once at module scope (e.g. next to `secretNames`), so
61
+ * importing the hooks file registers it. */
62
+ function configureAuth(
63
+ config: AuthConfig & Partial<Omit<ResolvedAuthConfig, keyof AuthConfig>>
64
+ ): void {
65
+ current = {
66
+ accountHome: (slug) => `/${slug}`,
67
+ cookieName: 'session',
68
+ invitePath: '/invite',
69
+ loginPath: '/login',
70
+ ...config,
71
+ };
72
+ }
73
+
74
+ function auth(): ResolvedAuthConfig {
75
+ if (!current) throw new Error('configureAuth() has not been called');
76
+ return current;
77
+ }
78
+
79
+ export const Auth = {
80
+ config: auth,
81
+ configure: configureAuth,
82
+ };
package/auth/emails.ts ADDED
@@ -0,0 +1,57 @@
1
+ import type { EmailBrand } from '../utils/email';
2
+ import type { MagicLinkMessage } from './config';
3
+
4
+ import { renderEmail, renderEmailText } from '../utils/email';
5
+
6
+ /** The sign-in email: subject, HTML and plain text. */
7
+ function magicLinkEmail(brand: EmailBrand, message: MagicLinkMessage) {
8
+ const params = {
9
+ brand,
10
+ cta: { href: message.url, label: `Sign in to ${brand.name}` },
11
+ eyebrow: 'Sign in',
12
+ footnote: `If you didn't ask to sign in, ignore this email: nobody can use the link without access to your inbox.`,
13
+ heading: `Sign in to ${brand.name}`,
14
+ intro: [
15
+ `Use the button below to sign in as ${message.email}.`,
16
+ `The link works once and expires in ${message.expiresInMinutes} minutes.`,
17
+ ],
18
+ preheader: `Your sign-in link for ${brand.name}`,
19
+ sections: [],
20
+ };
21
+ return {
22
+ html: renderEmail(params),
23
+ subject: `Sign in to ${brand.name}`,
24
+ text: renderEmailText(params),
25
+ };
26
+ }
27
+
28
+ /** An invitation to join an account. */
29
+ function inviteEmail(
30
+ brand: EmailBrand,
31
+ invite: { accountName: string; inviter: string; url: string }
32
+ ) {
33
+ const params = {
34
+ brand,
35
+ cta: { href: invite.url, label: `Join ${invite.accountName}` },
36
+ eyebrow: 'Invitation',
37
+ footnote:
38
+ 'The invitation works once and expires in 7 days. Ignore this email if you were not expecting it.',
39
+ heading: `Join ${invite.accountName} on ${brand.name}`,
40
+ intro: [
41
+ `${invite.inviter} invited you to ${invite.accountName}.`,
42
+ 'Sign in with this email address to accept.',
43
+ ],
44
+ preheader: `${invite.inviter} invited you to ${invite.accountName}`,
45
+ sections: [],
46
+ };
47
+ return {
48
+ html: renderEmail(params),
49
+ subject: `Join ${invite.accountName} on ${brand.name}`,
50
+ text: renderEmailText(params),
51
+ };
52
+ }
53
+
54
+ export const AuthEmails = {
55
+ invite: inviteEmail,
56
+ magicLink: magicLinkEmail,
57
+ };
package/auth/schema.ts ADDED
@@ -0,0 +1,152 @@
1
+ import {
2
+ blob,
3
+ index,
4
+ integer,
5
+ primaryKey,
6
+ sqliteTable,
7
+ text,
8
+ uniqueIndex,
9
+ } from 'drizzle-orm/sqlite-core';
10
+
11
+ // Auth tables (D1/SQLite). Timestamps are epoch-ms integers. The app includes this file in its
12
+ // drizzle-kit `schema` list and generates the migrations; nothing here runs DDL itself.
13
+
14
+ export const users = sqliteTable('users', {
15
+ avatarUrl: text('avatar_url'),
16
+ createdAt: integer('created_at')
17
+ .notNull()
18
+ .$defaultFn(() => Date.now()),
19
+ /** Always lower-cased: the one identity both sign-in methods resolve to. */
20
+ email: text('email').notNull().unique(),
21
+ id: integer('id').primaryKey({ autoIncrement: true }),
22
+ name: text('name').notNull(),
23
+ });
24
+
25
+ /** A signed-in browser. The cookie holds `<id>.<secret>`; only sha256(secret) is stored. */
26
+ export const sessions = sqliteTable(
27
+ 'sessions',
28
+ {
29
+ createdAt: integer('created_at')
30
+ .notNull()
31
+ .$defaultFn(() => Date.now()),
32
+ expiresAt: integer('expires_at').notNull(),
33
+ id: text('id').primaryKey(),
34
+ lastUsedAt: integer('last_used_at')
35
+ .notNull()
36
+ .$defaultFn(() => Date.now()),
37
+ secretHash: text('secret_hash').notNull(),
38
+ userId: integer('user_id')
39
+ .notNull()
40
+ .references(() => users.id),
41
+ },
42
+ (table) => [index('sessions_user_id_idx').on(table.userId)]
43
+ );
44
+
45
+ /** Magic-link tokens: sha256 of the emailed secret, single use, short-lived. Keyed by email rather
46
+ * than user, so a first sign-in can create the user only once the link is used. */
47
+ export const authTokens = sqliteTable(
48
+ 'auth_tokens',
49
+ {
50
+ createdAt: integer('created_at').notNull(),
51
+ email: text('email').notNull(),
52
+ expiresAt: integer('expires_at').notNull(),
53
+ tokenHash: text('token_hash').primaryKey(),
54
+ usedAt: integer('used_at'),
55
+ },
56
+ (table) => [index('auth_tokens_email_idx').on(table.email)]
57
+ );
58
+
59
+ /** WebAuthn credentials. `publicKey` is SEC1 (ES256) or PKCS#1 (RS256), per `algorithm`. */
60
+ export const passkeys = sqliteTable(
61
+ 'passkeys',
62
+ {
63
+ algorithm: integer('algorithm').notNull(),
64
+ createdAt: integer('created_at').notNull(),
65
+ /** The credential id, base64url without padding. */
66
+ id: text('id').primaryKey(),
67
+ lastUsedAt: integer('last_used_at'),
68
+ name: text('name').notNull(),
69
+ publicKey: blob('public_key', { mode: 'buffer' }).$type<Uint8Array>().notNull(),
70
+ signCount: integer('sign_count').notNull(),
71
+ /** Comma-separated `AuthenticatorTransport` hints from registration. */
72
+ transports: text('transports').notNull().default(''),
73
+ userId: integer('user_id')
74
+ .notNull()
75
+ .references(() => users.id, { onDelete: 'cascade' }),
76
+ },
77
+ (table) => [index('passkeys_user_id_idx').on(table.userId)]
78
+ );
79
+
80
+ /** What owns resources. Users sign in; accounts own things, and users reach them by membership.
81
+ * Resource tables carry `account_id`, never `user_id`. */
82
+ export const accounts = sqliteTable('accounts', {
83
+ createdAt: integer('created_at').notNull(),
84
+ id: integer('id').primaryKey({ autoIncrement: true }),
85
+ name: text('name').notNull(),
86
+ /** URL id, `[a-z0-9-]`, unique. */
87
+ slug: text('slug').notNull().unique(),
88
+ });
89
+
90
+ /** A user's membership of an account. `policy` is a built-in policy key (`owner`, `admin`,
91
+ * `member`) or the key of one of the account's custom `account_policies`. */
92
+ export const accountMembers = sqliteTable(
93
+ 'account_members',
94
+ {
95
+ accountId: integer('account_id')
96
+ .notNull()
97
+ .references(() => accounts.id, { onDelete: 'cascade' }),
98
+ createdAt: integer('created_at').notNull(),
99
+ policy: text('policy').notNull(),
100
+ userId: integer('user_id')
101
+ .notNull()
102
+ .references(() => users.id, { onDelete: 'cascade' }),
103
+ },
104
+ (table) => [
105
+ primaryKey({ columns: [table.accountId, table.userId] }),
106
+ index('account_members_user_id_idx').on(table.userId),
107
+ ]
108
+ );
109
+
110
+ /** An account's own policies: a named set of permission keys, on top of the built-in ones. */
111
+ export const accountPolicies = sqliteTable(
112
+ 'account_policies',
113
+ {
114
+ accountId: integer('account_id')
115
+ .notNull()
116
+ .references(() => accounts.id, { onDelete: 'cascade' }),
117
+ createdAt: integer('created_at').notNull(),
118
+ id: integer('id').primaryKey({ autoIncrement: true }),
119
+ /** `[a-z0-9-]`, unique per account, never a built-in key. */
120
+ key: text('key').notNull(),
121
+ name: text('name').notNull(),
122
+ permissions: text('permissions', { mode: 'json' }).$type<string[]>().notNull(),
123
+ },
124
+ (table) => [uniqueIndex('account_policies_account_key_idx').on(table.accountId, table.key)]
125
+ );
126
+
127
+ /** An emailed invitation to join an account with a policy. Only sha256(token) is stored; it is
128
+ * single use and can only be accepted by a session for the invited email. */
129
+ export const accountInvites = sqliteTable(
130
+ 'account_invites',
131
+ {
132
+ acceptedAt: integer('accepted_at'),
133
+ accountId: integer('account_id')
134
+ .notNull()
135
+ .references(() => accounts.id, { onDelete: 'cascade' }),
136
+ createdAt: integer('created_at').notNull(),
137
+ email: text('email').notNull(),
138
+ expiresAt: integer('expires_at').notNull(),
139
+ invitedBy: integer('invited_by').references(() => users.id, { onDelete: 'set null' }),
140
+ policy: text('policy').notNull(),
141
+ tokenHash: text('token_hash').primaryKey(),
142
+ },
143
+ (table) => [
144
+ index('account_invites_account_id_idx').on(table.accountId),
145
+ index('account_invites_email_idx').on(table.email),
146
+ ]
147
+ );
148
+
149
+ export type AccountRow = typeof accounts.$inferSelect;
150
+ export type PasskeyRow = typeof passkeys.$inferSelect;
151
+ export type SessionRow = typeof sessions.$inferSelect;
152
+ export type UserRow = typeof users.$inferSelect;
@@ -0,0 +1,125 @@
1
+ import type { RequestEvent } from '@sveltejs/kit';
2
+ import type { Handle } from '@sveltejs/kit/hooks';
3
+
4
+ import { redirect } from '@sveltejs/kit';
5
+
6
+ import type { AccessConfig } from '../../cloudflare/access';
7
+ import type { AccountLocals } from '../accounts/handle';
8
+ import type { AuthSession, AuthUser } from './sessions';
9
+
10
+ import { ACCESS_JWT_HEADER, verifyAccessJwt } from '../../cloudflare/access';
11
+ import { Error401 } from '../../utils/errors';
12
+ import { Auth } from '../config';
13
+ import { Users } from '../users';
14
+ import { Sessions } from './sessions';
15
+
16
+ /** What the handle puts on `event.locals`. Apps extend it: `interface Locals extends AuthLocals`. */
17
+ export type AuthLocals = AccountLocals & {
18
+ /** The Cloudflare Access email when Access sits in front, else null. */
19
+ accessEmail: null | string;
20
+ session: AuthSession | null;
21
+ user: AuthUser | null;
22
+ };
23
+
24
+ export type AuthHandleOptions = {
25
+ /** Cloudflare Access in front of the app: return its config to require a valid Access JWT whose
26
+ * email matches the session user's, or `null` when the app is public. */
27
+ access?: () => AccessConfig | null;
28
+ /** Local dev only (no Access JWT on localhost): the email Access would have vouched for. */
29
+ devAccessEmail?: () => null | string;
30
+ /** Reachable without a session. Default: the login page and `/auth/*` (the auth routes). */
31
+ publicPaths?: readonly string[];
32
+ };
33
+
34
+ function authLocals(event: RequestEvent): AuthLocals {
35
+ return event.locals as unknown as AuthLocals;
36
+ }
37
+
38
+ const matches = (pathname: string, paths: readonly string[]) =>
39
+ paths.some((p) => pathname === p || pathname.startsWith(`${p}/`));
40
+
41
+ /** Remote calls, non-GET requests, WebSockets and other fetches (anything that isn't a browser
42
+ * asking for a page) get a 401, not a redirect to a login page. */
43
+ const isApiRequest = (event: RequestEvent) =>
44
+ event.request.method !== 'GET' ||
45
+ event.url.pathname.startsWith('/_app/remote') ||
46
+ !(event.request.headers.get('accept') ?? '').includes('text/html');
47
+
48
+ /**
49
+ * The auth gate, for `sequence(...)` after the context handle:
50
+ * 1. With Access configured, its JWT must verify (403 otherwise, even on public paths).
51
+ * 2. The session cookie resolves to a live session whose user is still allowed (and matches the
52
+ * Access email); anything else is dropped and the cookie cleared.
53
+ * 3. Without a session, public paths render; pages redirect to the login page with `next`; API
54
+ * requests get a 401.
55
+ * A session in its second half is renewed (sliding expiry).
56
+ */
57
+ function createAuthHandle(options: AuthHandleOptions = {}): Handle {
58
+ return async ({ event, resolve }) => {
59
+ const config = Auth.config();
60
+ const locals = authLocals(event);
61
+ locals.accessEmail = null;
62
+ locals.session = null;
63
+ locals.user = null;
64
+ locals.account = null;
65
+ locals.membership = null;
66
+
67
+ const devEmail = options.devAccessEmail?.() ?? null;
68
+ const access = devEmail ? null : (options.access?.() ?? null);
69
+ if (devEmail) locals.accessEmail = devEmail;
70
+ if (access) {
71
+ const jwt = event.request.headers.get(ACCESS_JWT_HEADER);
72
+ if (!jwt) return new Response('Forbidden', { status: 403 });
73
+ try {
74
+ locals.accessEmail = (await verifyAccessJwt(jwt, access)).email;
75
+ } catch {
76
+ return new Response('Forbidden', { status: 403 });
77
+ }
78
+ }
79
+
80
+ const db = config.db();
81
+ const token = event.cookies.get(config.cookieName);
82
+ if (token) {
83
+ const live = await Sessions.validate(db, token);
84
+ const ok =
85
+ live &&
86
+ (await config.isAllowed(live.user.email)) &&
87
+ (!locals.accessEmail || Users.emailsMatch(locals.accessEmail, live.user.email));
88
+ if (ok) {
89
+ locals.session = live.session;
90
+ locals.user = live.user;
91
+ if (Sessions.needsRenewal(live.session.expiresAt, Date.now())) {
92
+ const expiresAt = await Sessions.renew(db, live.session.id);
93
+ locals.session = { ...live.session, expiresAt };
94
+ event.cookies.set(config.cookieName, token, {
95
+ ...Sessions.cookieAttributes,
96
+ domain: config.cookieDomain,
97
+ expires: new Date(expiresAt),
98
+ });
99
+ }
100
+ } else {
101
+ if (live) await Sessions.delete(db, live.session.id);
102
+ event.cookies.delete(config.cookieName, { domain: config.cookieDomain, path: '/' });
103
+ }
104
+ }
105
+
106
+ const publicPaths = options.publicPaths ?? [config.loginPath, '/auth'];
107
+ if (locals.user || matches(event.url.pathname, publicPaths)) return resolve(event);
108
+ if (isApiRequest(event)) return new Response('Unauthorized', { status: 401 });
109
+ const next = event.url.pathname + event.url.search;
110
+ redirect(303, `${config.loginPath}?next=${encodeURIComponent(next)}`);
111
+ };
112
+ }
113
+
114
+ /** The signed-in user, or a thrown 401 `ServiceError`. For remote functions behind the handle. */
115
+ function requireUser(event: RequestEvent): AuthUser {
116
+ const { user } = authLocals(event);
117
+ if (!user) throw Error401('Sign in first');
118
+ return user;
119
+ }
120
+
121
+ export const AuthHandle = {
122
+ create: createAuthHandle,
123
+ locals: authLocals,
124
+ requireUser,
125
+ };