@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.
- package/README.md +72 -9
- package/auth/accounts/accounts.remote.ts +116 -0
- package/auth/accounts/accounts.ts +332 -0
- package/auth/accounts/handle.ts +102 -0
- package/auth/accounts/invites.ts +149 -0
- package/auth/accounts/permissions.ts +90 -0
- package/auth/accounts/schemas.ts +22 -0
- package/auth/config.ts +82 -0
- package/auth/emails.ts +57 -0
- package/auth/schema.ts +152 -0
- package/auth/sessions/handle.ts +125 -0
- package/auth/sessions/magic-link/magic-link.ts +71 -0
- package/auth/sessions/passkeys/client.ts +115 -0
- package/auth/sessions/passkeys/passkeys.ts +295 -0
- package/auth/sessions/sessions.ts +102 -0
- package/auth/sveltekit/loads.ts +59 -0
- package/auth/sveltekit/login.ts +28 -0
- package/auth/sveltekit/routes.ts +265 -0
- package/auth/users.ts +49 -0
- package/cloudflare/email/base.ts +59 -0
- package/cloudflare/email/client.ts +25 -51
- package/cloudflare/email/dev.ts +9 -5
- package/cloudflare/email/smtp.ts +65 -0
- package/cloudflare/email/testing.ts +8 -3
- package/package.json +10 -5
- package/server/remote-functions.ts +1 -1
- package/utils/schema.ts +0 -35
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
|
|
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
|
|
|
@@ -18,17 +18,80 @@ Peer dependencies: `@sveltejs/kit` 3, `drizzle-orm`, `zod`, `@sentry/sveltekit`;
|
|
|
18
18
|
|
|
19
19
|
## Versions
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
`
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
git push origin HEAD vYYYY.M.D # the tag publishes to npm (publish.yaml)
|
|
27
|
-
```
|
|
21
|
+
Every merge to `main` publishes a new version (`.github/workflows/publish.yaml`). There is no
|
|
22
|
+
release step. Versions are `YYYY.M.N` in London time: `N` counts that month's releases and resets
|
|
23
|
+
each month (`2026.11.7`, `2026.11.8`, then `2026.12.0`). CI sets the version and pushes the
|
|
24
|
+
`vYYYY.M.N` tag; `package.json`'s version in the repo is not updated. Changes to only docs or CI
|
|
25
|
+
don't publish.
|
|
28
26
|
|
|
29
27
|
Publishing uses npm trusted publishing: the package's npmjs.com settings trust this repo's
|
|
30
28
|
`publish.yaml`, so no npm token is stored anywhere.
|
|
31
29
|
|
|
30
|
+
## Auth (`@zeyolabs/kit/auth/*`)
|
|
31
|
+
|
|
32
|
+
`config`, `schema`, `users`, `emails` at the top; one folder per part: `sessions/` (cookie
|
|
33
|
+
sessions, the auth handle, and the two ways in: `sessions/magic-link/` and `sessions/passkeys/`
|
|
34
|
+
with its browser `client`),
|
|
35
|
+
`accounts/` (accounts, invites, permissions, the account handle, remote functions), `sveltekit/`
|
|
36
|
+
(the `/auth/*` routes, page loads, login helpers). Each file exports one library object, like
|
|
37
|
+
`Crypto`: `Sessions`, `MagicLink`, `Passkeys`, `PasskeyClient`, `Users`, `Allowlist`, `Accounts`,
|
|
38
|
+
`Invites`, `Permissions`, `AuthHandle`, `AccountHandle`, `AuthLoads`, `AuthEmails`, `Login`, and
|
|
39
|
+
`Auth.configure` for the config.
|
|
40
|
+
|
|
41
|
+
Passwordless sign-in: an emailed magic link or a passkey. No passwords anywhere. Sessions are
|
|
42
|
+
`<id>.<secret>` cookies (sha256 at rest, 30 days, sliding); magic links are single-use and expire
|
|
43
|
+
in 15 minutes; passkeys are verified server-side (ES256/RS256, user verification required).
|
|
44
|
+
|
|
45
|
+
1. Tables: add `node_modules/@zeyolabs/kit/auth/schema.ts` to the app's drizzle-kit `schema` and
|
|
46
|
+
generate a migration (`users`, `sessions`, `auth_tokens`, `passkeys`).
|
|
47
|
+
2. Config (`Auth.configure`), once at module scope (e.g. next to `secretNames`):
|
|
48
|
+
```ts
|
|
49
|
+
Auth.configure({
|
|
50
|
+
appName: 'My app',
|
|
51
|
+
db: () => getDb(),
|
|
52
|
+
email: () => (dev ? new DevEmailClient() : SmtpClient.fromEnv(smtpKeys, { from })),
|
|
53
|
+
emailBrand: brand,
|
|
54
|
+
isAllowed: (email) => Allowlist.includes(email, env().ALLOWED_EMAILS),
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
3. Hook, after the context handle: `AuthHandle.create({ access })` (pass `access` to also require
|
|
58
|
+
a matching Cloudflare Access JWT). Type `App.Locals` as `interface Locals extends AuthLocals`.
|
|
59
|
+
4. Routes: `src/routes/auth/[...path]/+server.ts` is
|
|
60
|
+
`export { POST } from '@zeyolabs/kit/auth/sveltekit/routes';`, and the login page's `+page.server.ts` is
|
|
61
|
+
`export const load = AuthLoads.login;` (from `@zeyolabs/kit/auth/sveltekit/loads`).
|
|
62
|
+
5. The login page (the app's own markup) posts `email` to `/auth/email`; with `?token=` it shows a
|
|
63
|
+
button posting `token` to `/auth/verify` (so mail scanners can't use the link); its passkey
|
|
64
|
+
button calls `PasskeyClient.signIn()` from `@zeyolabs/kit/auth/sessions/passkeys/client`. A settings page
|
|
65
|
+
uses `AuthLoads.passkeys` and `PasskeyClient.add(name)`, and posts `id` to `/auth/passkey/delete`.
|
|
66
|
+
|
|
67
|
+
### Accounts and policies
|
|
68
|
+
|
|
69
|
+
Accounts own resources; users reach them through a membership. Resource tables carry
|
|
70
|
+
`account_id`, never `user_id`. Tables: `accounts`, `account_members`, `account_policies`,
|
|
71
|
+
`account_invites` (same `schema.ts`).
|
|
72
|
+
|
|
73
|
+
- **Policies.** Every member has one. Built in: `owner` (everything, can't be edited), `admin`
|
|
74
|
+
(all but `policies.manage`), `member` (the app's `memberPermissions`). An account can add its own
|
|
75
|
+
from permission keys (`resource.action`, or `resource.*`); the app lists its keys in
|
|
76
|
+
`Auth.configure({ accounts: { permissions, memberPermissions } })`. Only owners confer ownership,
|
|
77
|
+
and an account always keeps at least one owner.
|
|
78
|
+
- **Which account.** `AccountHandle.create({ slug: AccountHandle.slugFromRoute('account') })` after the auth handle
|
|
79
|
+
sets `locals.account` and `locals.membership` (404 for non-members). Remote functions call
|
|
80
|
+
`AccountHandle.requirePermission(event, 'projects.edit')`, or `Accounts.can(membership, key)`.
|
|
81
|
+
- **Invite only.** `isAllowed: (email) => Accounts.hasAccess(db, email)`; invites are emailed links
|
|
82
|
+
to the app's `/invite` page (`AuthLoads.invite`), accepted by posting to `/auth/invite/accept`.
|
|
83
|
+
`onSignIn` can bootstrap the first owners.
|
|
84
|
+
- **Management.** `@zeyolabs/kit/auth/accounts/accounts.remote` has the queries and commands a members page
|
|
85
|
+
needs (members, invites, policies), each checking the caller's permissions.
|
|
86
|
+
|
|
87
|
+
## Email clients
|
|
88
|
+
|
|
89
|
+
`IEmailClient` (`deliver` throws, `send` logs and returns `false`) has two transports on
|
|
90
|
+
`BaseEmailClient`: `EmailClient` (`cloudflare/email`, a Workers `send_email` binding) and
|
|
91
|
+
`SmtpClient` (`cloudflare/email/smtp`, e.g. Amazon SES, any recipient; `SmtpClient.fromEnv(keys, sender)`
|
|
92
|
+
reads the settings from the request context). `DevEmailClient` prints instead of sending and
|
|
93
|
+
`FakeEmailClient` records messages for tests; both work in place of either transport.
|
|
94
|
+
|
|
32
95
|
## Developing against an app
|
|
33
96
|
|
|
34
97
|
`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
|
+
};
|