@merijnslegers/admin 0.0.0-stage → 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Merijn Slegers
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,225 @@
1
- # Temporary Holding Version
1
+ # @merijnslegers/admin
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The admin login of the software factory (F-0011), and image resizing in the
4
+ browser (F-0008). Each person signs in to `/admin` with their own email address
5
+ and password through Supabase Auth. Who may enter is an allow list the project
6
+ passes in, checked on every request.
7
+
8
+ The package holds the login, the access rules and the headers. The screens, the
9
+ components, the navigation and every sentence a user reads stay in the project,
10
+ in its own look and language.
11
+
12
+ ```bash
13
+ pnpm add @merijnslegers/admin @supabase/ssr @supabase/supabase-js
14
+ ```
15
+
16
+ | Import | What it holds | Where it runs |
17
+ | ----------------------------- | ------------------------------------------------------------------------ | ------------- |
18
+ | `@merijnslegers/admin/server` | `createAdmin`: the hook and the functions for the login screens | server only |
19
+ | `@merijnslegers/admin/rules` | The pure rules: allow list, return path, Origin check, headers, password | anywhere |
20
+ | `@merijnslegers/admin/resize` | `resizeImage` and `resizeFormFiles` | browser |
21
+
22
+ ## Connecting a project
23
+
24
+ **The settings.** Four variables, declared in `src/env.ts` and read at runtime
25
+ (F-0005). The package reads none of them itself.
26
+
27
+ | Variable | What it is |
28
+ | --------------------------- | ----------------------------------------------------------------------- |
29
+ | `SUPABASE_URL` | The project's Supabase address (F-0009) |
30
+ | `SUPABASE_ANON_KEY` | Only for logging in; it reads or writes no table |
31
+ | `SUPABASE_SERVICE_ROLE_KEY` | Already there for the database (F-0004); here only to create a new user |
32
+ | `ADMIN_EMAILS` | Who may enter: addresses separated by commas, spaces or new lines |
33
+
34
+ Without one of them nobody gets in, the build still works, and
35
+ `missingSettings()` names what is missing so the login screen can show it.
36
+ A project whose variables are named differently passes `settingNames`.
37
+
38
+ **One module**, `src/lib/server/admin.ts`:
39
+
40
+ ```ts
41
+ import { createAdmin } from '@merijnslegers/admin/server';
42
+ import {
43
+ ADMIN_EMAILS,
44
+ SUPABASE_ANON_KEY,
45
+ SUPABASE_SERVICE_ROLE_KEY,
46
+ SUPABASE_URL
47
+ } from '$app/env/private';
48
+
49
+ export const admin = createAdmin({
50
+ supabaseUrl: SUPABASE_URL,
51
+ anonKey: SUPABASE_ANON_KEY,
52
+ serviceRoleKey: SUPABASE_SERVICE_ROLE_KEY,
53
+ allowList: ADMIN_EMAILS,
54
+ // Optional: a project in Dutch names its screens in Dutch.
55
+ paths: { login: '/admin/inloggen', password: '/admin/wachtwoord' }
56
+ });
57
+ ```
58
+
59
+ **The hook**, in `src/hooks.server.ts`. With other hooks, combine them with
60
+ `sequence`.
61
+
62
+ ```ts
63
+ import { admin } from '#lib/server/admin.js';
64
+
65
+ export const handle = admin.handle;
66
+ ```
67
+
68
+ For everything under the admin path it:
69
+
70
+ - lets the login and password screens through without a session;
71
+ - sends a page, a link from a mail or a form without a session to the login
72
+ screen, and back to where it was afterwards (only within the admin path);
73
+ - answers any other request without a session with a 401;
74
+ - answers a request that can change something with a 403 when it does not come
75
+ from the site's own origin, even when SvelteKit's own check is off for a
76
+ webhook;
77
+ - puts `ADMIN_HEADERS` on every response: no framing, `noindex`, `no-store`,
78
+ a referrer that stays on the site.
79
+
80
+ Someone signed in is in `event.locals.admin` (`{ email }`). Declare it in
81
+ `src/app.d.ts`:
82
+
83
+ ```ts
84
+ declare global {
85
+ namespace App {
86
+ interface Locals {
87
+ admin?: import('@merijnslegers/admin/server').Admin;
88
+ }
89
+ }
90
+ }
91
+ ```
92
+
93
+ A webhook belongs under `/api`, never under the admin path.
94
+
95
+ **Remote functions** are called at `/_app/remote/...`, outside the admin path,
96
+ so the hook does not guard them. An admin remote function checks
97
+ `admin.currentAdmin(cookies)` itself, first thing.
98
+
99
+ **The screens.** The login and password screens use `+page@.svelte`, so they do
100
+ not hang under the admin layout, which belongs to someone signed in. Their
101
+ actions call the package and turn its outcome into the project's own words:
102
+
103
+ ```ts
104
+ // src/routes/admin/inloggen/+page.server.ts
105
+ import { fail, redirect } from '@sveltejs/kit';
106
+ import { safeReturnPath } from '@merijnslegers/admin/rules';
107
+ import { admin } from '#lib/server/admin.js';
108
+
109
+ const TEXT = {
110
+ invalid_credentials: 'Dit e-mailadres en wachtwoord horen niet bij elkaar.',
111
+ rate_limited: 'Te veel pogingen. Wacht een paar minuten.',
112
+ missing_setting: 'Dit e-mailadres en wachtwoord horen niet bij elkaar.'
113
+ };
114
+
115
+ export const load = async ({ cookies, url }) => {
116
+ const to = safeReturnPath(url.searchParams.get('to'), admin.paths);
117
+ if (await admin.currentAdmin(cookies)) redirect(303, to);
118
+ return { to, missing: admin.missingSettings() };
119
+ };
120
+
121
+ export const actions = {
122
+ signIn: async ({ request, cookies }) => {
123
+ const data = await request.formData();
124
+ const email = String(data.get('email') ?? '');
125
+ const outcome = await admin.signIn(cookies, email, String(data.get('password') ?? ''));
126
+ if (outcome !== 'ok')
127
+ return fail(outcome === 'rate_limited' ? 429 : 400, { email, error: TEXT[outcome] });
128
+ redirect(303, safeReturnPath(String(data.get('to') ?? ''), admin.paths));
129
+ }
130
+ };
131
+ ```
132
+
133
+ | Function | Outcomes |
134
+ | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
135
+ | `signIn(cookies, email, password)` | `ok`, `invalid_credentials`, `rate_limited`, `missing_setting` |
136
+ | `sendPasswordLink(cookies, email, origin)` | `ok`, `invalid_email`, `rate_limited`, `send_failed` |
137
+ | `setPassword(cookies, input)` | `ok`, `password_too_short`, `passwords_differ`, `link_invalid`, `not_signed_in`, `weak_password`, `save_failed`, `missing_setting` |
138
+ | `signOut(cookies)` | nothing |
139
+
140
+ Some outcomes say nothing on purpose:
141
+
142
+ - An address that is not on the list gets `invalid_credentials`, just like a
143
+ wrong password, and Supabase is not called.
144
+ - `sendPasswordLink` answers `ok` for every valid address, and only one on the
145
+ list gets a mail.
146
+
147
+ So no screen gives away who has access.
148
+
149
+ **A password link** works for a first password and a forgotten one alike:
150
+
151
+ - `sendPasswordLink` creates the user if they do not exist yet. You never create
152
+ one by hand.
153
+ - On the password screen, `readPasswordLink(url)` reads the link in `load` and
154
+ puts it in the form.
155
+ - `setPassword` uses the link only once the password is saved, because mail
156
+ programs and virus scanners open links in advance.
157
+ - Both kinds of link work: `token_hash` (your own mail template, any browser)
158
+ and `code` (Supabase's default template, only in the browser that asked for
159
+ it).
160
+ - Someone signed in sets a new password on the same screen, without a link.
161
+ - A password is at least `PASSWORD_MIN_LENGTH` (10) characters.
162
+
163
+ ## Settings in Supabase
164
+
165
+ Under Authentication:
166
+
167
+ - **URL Configuration, Redirect URLs:** the password screen on the production
168
+ domain, such as `https://www.<domain>/admin/password`, and on the previews.
169
+ - Mind `www`: the link is built from the address the person is on.
170
+ - Supabase replaces an address that is not listed by the Site URL, and then
171
+ the link does not work.
172
+ - **"Allow new users to sign up"** may be off. The package creates users with
173
+ the `service_role` key, which does not look at it.
174
+ - **Emails, SMTP Settings:** your own mail server, through Resend
175
+ (`smtp.resend.com`, port 465, user `resend`, an API key of its own as
176
+ password). Without it, Supabase only mails members of its own organisation, a
177
+ few per hour, and you cannot change the templates.
178
+ - **Emails, template "Reset password":** the link as
179
+ `{{ .RedirectTo }}?token_hash={{ .TokenHash }}` instead of
180
+ `{{ .ConfirmationURL }}`. It then goes straight to your own screen and is used
181
+ only on saving.
182
+
183
+ ## Resizing images
184
+
185
+ In the browser, before the upload (F-0008). The project sets the width and the
186
+ format per kind of image, in one place:
187
+
188
+ ```ts
189
+ import { resizeFormFiles, resizeImage } from '@merijnslegers/admin/resize';
190
+
191
+ const SCREENSHOT = { maxWidth: 2000 }; // webp, for the site
192
+ const MAIL_PHOTO = { maxWidth: 1200, format: 'mail' } as const; // JPEG, PNG kept
193
+
194
+ await resizeFormFiles(formData, 'images', SCREENSHOT);
195
+ const photo = await resizeImage(file, MAIL_PHOTO);
196
+ ```
197
+
198
+ - **`webp`** (the default) for images on the site.
199
+ - **`mail`** for images in an email. Many mail clients (Outlook) do not show
200
+ webp, so the image becomes JPEG, and a PNG stays PNG because it often holds
201
+ transparency.
202
+ - A phone photo stays upright (its EXIF orientation).
203
+ - An image that is already narrow enough and under 1 MB is left alone.
204
+ - SVG and GIF are never drawn again.
205
+ - When the browser cannot do it, or the result is not smaller, the original
206
+ goes and the server decides. With `mail`, a webp or AVIF is always turned
207
+ into JPEG, even when that is larger.
208
+ - A browser without `OffscreenCanvas` draws on an ordinary canvas instead.
209
+
210
+ ## Tests
211
+
212
+ The rules and the resize plan are pure; `npm test` at the repo root runs their
213
+ tests. A project's own tests use the same rules without Supabase, for instance
214
+ for its own screen names:
215
+
216
+ ```ts
217
+ // F-0011
218
+ import { adminPaths, safeReturnPath } from '@merijnslegers/admin/rules';
219
+
220
+ const paths = adminPaths({ login: '/admin/inloggen', password: '/admin/wachtwoord' });
221
+
222
+ it('never sends you back to the login screen', () => {
223
+ expect(safeReturnPath('/admin/inloggen', paths)).toBe('/admin');
224
+ });
225
+ ```
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
- "name": "@merijnslegers/admin",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@merijnslegers/admin",
3
+ "version": "0.1.0",
4
+ "description": "The admin login of the software factory: Supabase Auth with an allow list, the access rules and headers, and image resizing in the browser.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ "./rules": {
9
+ "types": "./src/rules.d.ts",
10
+ "default": "./src/rules.js"
11
+ },
12
+ "./server": {
13
+ "types": "./src/server.d.ts",
14
+ "default": "./src/server.js"
15
+ },
16
+ "./resize": {
17
+ "types": "./src/resize.d.ts",
18
+ "default": "./src/resize.js"
19
+ }
20
+ },
21
+ "files": [
22
+ "src",
23
+ "README.md"
24
+ ],
25
+ "engines": {
26
+ "node": ">=22.17"
27
+ },
28
+ "peerDependencies": {
29
+ "@supabase/ssr": "^0.12.7",
30
+ "@supabase/supabase-js": "^2.118.0",
31
+ "@sveltejs/kit": "^3.0.0"
32
+ },
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/Merijnslegers90/software-factory.git",
36
+ "directory": "packages/admin"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ }
41
+ }
@@ -0,0 +1,33 @@
1
+ /** `webp` for images on the site; `mail` (JPEG, PNG kept) for images in an email. */
2
+ export type ImageFormat = 'webp' | 'mail';
3
+
4
+ export interface ResizeOptions {
5
+ /** The maximum width for this kind of image, in pixels. */
6
+ maxWidth: number;
7
+ format?: ImageFormat;
8
+ /** 0 to 1, for webp and JPEG. 0.85 by default. */
9
+ quality?: number;
10
+ }
11
+
12
+ export interface ResizePlan {
13
+ width: number;
14
+ height: number;
15
+ type: 'image/webp' | 'image/jpeg' | 'image/png';
16
+ extension: 'webp' | 'jpg' | 'png';
17
+ }
18
+
19
+ export const SKIP_BELOW_BYTES: number;
20
+ export function keepsAsIs(type: string): boolean;
21
+ export function mustConvert(type: string, format: ImageFormat): boolean;
22
+ export function targetType(type: string, format: ImageFormat): ResizePlan['type'];
23
+ export function resizePlan(
24
+ image: { type: string; size: number; width: number; height: number },
25
+ options: { maxWidth: number; format?: ImageFormat }
26
+ ): ResizePlan | null;
27
+ export function renamed(name: string, extension: string): string;
28
+ export function resizeImage(file: File, options: ResizeOptions): Promise<File>;
29
+ export function resizeFormFiles(
30
+ formData: FormData,
31
+ field: string,
32
+ options: ResizeOptions
33
+ ): Promise<void>;
package/src/resize.js ADDED
@@ -0,0 +1,113 @@
1
+ // Resizes images in the browser before they are uploaded (F-0008). A function
2
+ // on Vercel accepts about 4.5 MB per request, and a photo from a phone is a few
3
+ // megabytes. The plan (what to do with which file) is pure and tested; the
4
+ // drawing itself needs a browser.
5
+
6
+ /** Leave an image alone that is already narrow enough and smaller than this: compressing it again only makes it worse. */
7
+ export const SKIP_BELOW_BYTES = 1024 * 1024;
8
+
9
+ /** SVG is not drawn on a canvas; a GIF would lose its animation. */
10
+ export function keepsAsIs(type) {
11
+ return !/^image\//.test(type) || type === 'image/gif' || type === 'image/svg+xml';
12
+ }
13
+
14
+ /**
15
+ * The type a resized image gets. `webp` for the site. `mail` for an image in an
16
+ * email: many mail clients (Outlook) do not show webp, so it becomes JPEG, and
17
+ * a PNG stays PNG because it often holds transparency (a logo) that turns
18
+ * black in a JPEG.
19
+ */
20
+ export function targetType(type, format) {
21
+ if (format === 'mail') return type === 'image/png' ? 'image/png' : 'image/jpeg';
22
+ return 'image/webp';
23
+ }
24
+
25
+ /**
26
+ * Whether the original may not go as it is, whatever its size: a webp or AVIF
27
+ * in a mail would not show in many mail clients.
28
+ */
29
+ export function mustConvert(type, format) {
30
+ return format === 'mail' && type !== 'image/jpeg' && type !== 'image/png';
31
+ }
32
+
33
+ const EXTENSIONS = { 'image/webp': 'webp', 'image/jpeg': 'jpg', 'image/png': 'png' };
34
+
35
+ /**
36
+ * What to do with an image of this type, size and width: null to keep the
37
+ * original, or the size and type to draw it at.
38
+ */
39
+ export function resizePlan({ type, size, width, height }, { maxWidth, format = 'webp' }) {
40
+ if (keepsAsIs(type)) return null;
41
+ if (width <= maxWidth && size <= SKIP_BELOW_BYTES && !mustConvert(type, format)) return null;
42
+ const scale = Math.min(1, maxWidth / width);
43
+ const target = targetType(type, format);
44
+ return {
45
+ width: Math.max(1, Math.round(width * scale)),
46
+ height: Math.max(1, Math.round(height * scale)),
47
+ type: target,
48
+ extension: EXTENSIONS[target]
49
+ };
50
+ }
51
+
52
+ /** The file name with the extension of the new type; the server derives the storage path from it. */
53
+ export function renamed(name, extension) {
54
+ return `${String(name).replace(/\.[^.]+$/, '') || 'image'}.${extension}`;
55
+ }
56
+
57
+ /**
58
+ * Resizes one image. When it cannot (a format the browser cannot unpack, no
59
+ * canvas) or the result is not smaller, the original goes and the server
60
+ * decides.
61
+ */
62
+ export async function resizeImage(file, { maxWidth, format = 'webp', quality = 0.85 }) {
63
+ if (keepsAsIs(file.type)) return file;
64
+ let bitmap;
65
+ try {
66
+ // from-image: otherwise an upright phone photo comes back lying down,
67
+ // because the rotation is only in its EXIF data.
68
+ bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' });
69
+ const plan = resizePlan(
70
+ { type: file.type, size: file.size, width: bitmap.width, height: bitmap.height },
71
+ { maxWidth, format }
72
+ );
73
+ if (!plan) return file;
74
+
75
+ const blob = await draw(bitmap, plan, plan.type === 'image/png' ? undefined : quality);
76
+ // A browser that cannot write the type (webp in an older Safari) quietly
77
+ // gives a PNG instead; then the original is the better choice.
78
+ if (!blob || blob.type !== plan.type) return file;
79
+ if (blob.size >= file.size && !mustConvert(file.type, format)) return file;
80
+ return new File([blob], renamed(file.name, plan.extension), { type: plan.type });
81
+ } catch (err) {
82
+ console.error('Resizing failed, the original is used:', err);
83
+ return file;
84
+ } finally {
85
+ bitmap?.close();
86
+ }
87
+ }
88
+
89
+ async function draw(bitmap, plan, quality) {
90
+ if (typeof OffscreenCanvas !== 'undefined') {
91
+ const canvas = new OffscreenCanvas(plan.width, plan.height);
92
+ const context = canvas.getContext('2d');
93
+ if (!context) return null;
94
+ context.drawImage(bitmap, 0, 0, plan.width, plan.height);
95
+ return canvas.convertToBlob({ type: plan.type, quality });
96
+ }
97
+ const canvas = document.createElement('canvas');
98
+ canvas.width = plan.width;
99
+ canvas.height = plan.height;
100
+ const context = canvas.getContext('2d');
101
+ if (!context) return null;
102
+ context.drawImage(bitmap, 0, 0, plan.width, plan.height);
103
+ return new Promise((done) => canvas.toBlob(done, plan.type, quality));
104
+ }
105
+
106
+ /** Replaces the files in one field of a FormData by resized versions. */
107
+ export async function resizeFormFiles(formData, field, options) {
108
+ const files = formData.getAll(field).filter((v) => v instanceof File && v.size > 0);
109
+ formData.delete(field);
110
+ for (const file of await Promise.all(files.map((f) => resizeImage(f, options)))) {
111
+ formData.append(field, file);
112
+ }
113
+ }
package/src/rules.d.ts ADDED
@@ -0,0 +1,65 @@
1
+ export interface AdminPaths {
2
+ /** The admin path, `/admin` by default. */
3
+ base: string;
4
+ /** The login screen, `<base>/login` by default. */
5
+ login: string;
6
+ /** The password screen, `<base>/password` by default. */
7
+ password: string;
8
+ }
9
+
10
+ export type AdminDecision =
11
+ | { kind: 'pass' }
12
+ | { kind: 'redirect'; location: string }
13
+ | { kind: 'unauthorized' }
14
+ | { kind: 'forbidden' };
15
+
16
+ export interface SettingNames {
17
+ supabaseUrl: string;
18
+ anonKey: string;
19
+ serviceRoleKey: string;
20
+ allowList: string;
21
+ }
22
+
23
+ export const PASSWORD_MIN_LENGTH: number;
24
+ export const ADMIN_HEADERS: Readonly<Record<string, string>>;
25
+
26
+ export function adminPaths(paths?: Partial<AdminPaths>): AdminPaths;
27
+ export function decodePath(pathname: string): string | null;
28
+ export function isAdminPath(pathname: string, paths: AdminPaths): boolean;
29
+ export function isOpenPath(pathname: string, paths: AdminPaths): boolean;
30
+ export function normaliseEmail(email: string | null | undefined): string;
31
+ export function parseAllowList(list: string | null | undefined): Set<string>;
32
+ export function mayManage(email: string | null | undefined, allowList: Set<string>): boolean;
33
+ export function passwordProblem(
34
+ password: string,
35
+ repeat: string
36
+ ): 'password_too_short' | 'passwords_differ' | null;
37
+ export function safeReturnPath(to: string | null | undefined, paths: AdminPaths): string;
38
+ export function loginAddress(from: URL, paths: AdminPaths): string;
39
+ export function fromOtherSite(method: string, origin: string | null, ownOrigin: string): boolean;
40
+ export function adminDecision(request: {
41
+ url: URL;
42
+ method: string;
43
+ contentType: string | null;
44
+ origin: string | null;
45
+ signedIn: boolean;
46
+ paths: AdminPaths;
47
+ }): AdminDecision;
48
+ export function missingSettings(
49
+ settings: {
50
+ supabaseUrl: string;
51
+ anonKey: string;
52
+ allowList: Set<string>;
53
+ },
54
+ names: SettingNames
55
+ ): string[];
56
+ export function signInOutcome(
57
+ error: { status?: number; code?: string } | null
58
+ ): 'ok' | 'rate_limited' | 'invalid_credentials';
59
+ export function linkOutcome(
60
+ error: { status?: number; code?: string } | null
61
+ ): 'ok' | 'rate_limited' | 'send_failed';
62
+ export function passwordOutcome(
63
+ error: { status?: number; code?: string } | null
64
+ ): 'ok' | 'weak_password' | 'save_failed';
65
+ export function readPasswordLink(url: URL): { tokenHash: string; code: string; linkError: boolean };
package/src/rules.js ADDED
@@ -0,0 +1,195 @@
1
+ // The rules around the admin login (F-0011). Pure, so they are tested without
2
+ // Supabase or SvelteKit (F-0006). The calls to Supabase are in server.js.
3
+
4
+ /** Longer than the six characters Supabase asks by default. A short sentence or a password manager makes it easily. */
5
+ export const PASSWORD_MIN_LENGTH = 10;
6
+
7
+ /**
8
+ * Every response under the admin path gets these headers. Not in a frame, so
9
+ * no other site can lay our buttons under a page of its own; not in a search
10
+ * engine; not in a cache; and no admin address (with an email or an invoice in
11
+ * it) as referrer to another service.
12
+ */
13
+ export const ADMIN_HEADERS = Object.freeze({
14
+ 'Content-Security-Policy': "frame-ancestors 'none'",
15
+ 'X-Frame-Options': 'DENY',
16
+ 'X-Robots-Tag': 'noindex, nofollow',
17
+ 'Referrer-Policy': 'same-origin',
18
+ 'X-Content-Type-Options': 'nosniff',
19
+ 'Cache-Control': 'private, no-store'
20
+ });
21
+
22
+ /** The admin path and its two screens that open without a session. */
23
+ export function adminPaths({ base = '/admin', login, password } = {}) {
24
+ const root = base.replace(/\/+$/, '') || '/admin';
25
+ return {
26
+ base: root,
27
+ login: login ?? `${root}/login`,
28
+ password: password ?? `${root}/password`
29
+ };
30
+ }
31
+
32
+ /**
33
+ * The path as SvelteKit decodes it to pick a route. Comparing the raw path
34
+ * would let `/%61dmin/orders` reach the route `/admin/orders` past the hook.
35
+ * Null when it cannot be decoded.
36
+ */
37
+ export function decodePath(pathname) {
38
+ try {
39
+ return pathname.split('%25').map(decodeURI).join('%25');
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Whether a path is the admin path or below it, after decoding. `/administration`
47
+ * is not. A path that cannot be decoded counts as admin, so it stays closed.
48
+ */
49
+ export function isAdminPath(pathname, paths) {
50
+ const path = decodePath(pathname);
51
+ return path === null || path === paths.base || path.startsWith(paths.base + '/');
52
+ }
53
+
54
+ /** The screens under the admin path you must reach without a session, after decoding. */
55
+ export function isOpenPath(pathname, paths) {
56
+ const path = decodePath(pathname);
57
+ return path !== null && (path === paths.login || path === paths.password);
58
+ }
59
+
60
+ /** An email address as we compare it: without spaces, in lower case. */
61
+ export function normaliseEmail(email) {
62
+ return String(email ?? '')
63
+ .trim()
64
+ .toLowerCase();
65
+ }
66
+
67
+ /**
68
+ * The allow list from `ADMIN_EMAILS`: addresses separated by commas,
69
+ * semicolons, spaces or new lines. An entry without `@` is a typo and does not
70
+ * count.
71
+ */
72
+ export function parseAllowList(list) {
73
+ return new Set(
74
+ String(list ?? '')
75
+ .split(/[\s,;]+/)
76
+ .map(normaliseEmail)
77
+ .filter((email) => email.includes('@'))
78
+ );
79
+ }
80
+
81
+ /** Whether this address may manage. Without an address, never. */
82
+ export function mayManage(email, allowList) {
83
+ return !!email && allowList.has(normaliseEmail(email));
84
+ }
85
+
86
+ /** What is wrong with a new password, or null when it is fine. */
87
+ export function passwordProblem(password, repeat) {
88
+ if (String(password ?? '').length < PASSWORD_MIN_LENGTH) return 'password_too_short';
89
+ if (password !== repeat) return 'passwords_differ';
90
+ return null;
91
+ }
92
+
93
+ /**
94
+ * Where to go after logging in. The address comes from a link, so only a path
95
+ * within the admin path on our own site counts; otherwise a link such as
96
+ * `?to=https://elsewhere` would turn our login screen into a relay.
97
+ */
98
+ export function safeReturnPath(to, paths) {
99
+ if (!to || !to.startsWith('/') || to.startsWith('//') || to.includes('\\')) return paths.base;
100
+ const origin = 'https://admin.invalid';
101
+ let url;
102
+ try {
103
+ url = new URL(to, origin);
104
+ } catch {
105
+ return paths.base;
106
+ }
107
+ if (
108
+ url.origin !== origin ||
109
+ !isAdminPath(url.pathname, paths) ||
110
+ isOpenPath(url.pathname, paths)
111
+ ) {
112
+ return paths.base;
113
+ }
114
+ return url.pathname + url.search;
115
+ }
116
+
117
+ /** The login screen, with the path you came from. Read back with `safeReturnPath`. */
118
+ export function loginAddress(from, paths) {
119
+ const to = from.pathname + from.search;
120
+ return to === paths.base ? paths.login : `${paths.login}?to=${encodeURIComponent(to)}`;
121
+ }
122
+
123
+ /**
124
+ * Whether a request comes from another site. Every method that can change
125
+ * something, a fetch with JSON too, must come from our own origin. A browser
126
+ * always sets an Origin on such a request; without one, it was not a form on
127
+ * our screen. This holds even when SvelteKit's own check is off for a webhook.
128
+ */
129
+ export function fromOtherSite(method, origin, ownOrigin) {
130
+ if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') return false;
131
+ return origin !== ownOrigin;
132
+ }
133
+
134
+ const FORM = /^(multipart\/form-data|application\/x-www-form-urlencoded)/;
135
+
136
+ /**
137
+ * What the hook does with a request under the admin path.
138
+ *
139
+ * - `pass`: let it through.
140
+ * - `redirect`: a page, a link from a mail or a form without a session goes to
141
+ * the login screen (`location`), and back afterwards.
142
+ * - `unauthorized`: a fetch to an endpoint without a session gets a 401; there
143
+ * is nowhere to send it.
144
+ * - `forbidden`: it can change something and does not come from our site.
145
+ */
146
+ export function adminDecision({ url, method, contentType, origin, signedIn, paths }) {
147
+ if (signedIn || isOpenPath(url.pathname, paths)) {
148
+ return fromOtherSite(method, origin, url.origin) ? { kind: 'forbidden' } : { kind: 'pass' };
149
+ }
150
+ if (method === 'GET' || method === 'HEAD' || FORM.test(contentType ?? '')) {
151
+ return { kind: 'redirect', location: loginAddress(url, paths) };
152
+ }
153
+ return { kind: 'unauthorized' };
154
+ }
155
+
156
+ /**
157
+ * The settings logging in needs that are missing, by the names the project
158
+ * gave them. Empty when everything is there. Without them nobody gets in
159
+ * (F-0005). The service_role key is not among them: only a password link
160
+ * needs it, and the database has it anyway (F-0004).
161
+ */
162
+ export function missingSettings(settings, names) {
163
+ const missing = [];
164
+ if (!settings.supabaseUrl) missing.push(names.supabaseUrl);
165
+ if (!settings.anonKey) missing.push(names.anonKey);
166
+ if (settings.allowList.size === 0) missing.push(names.allowList);
167
+ return missing;
168
+ }
169
+
170
+ /** The outcome of signing in, from Supabase's error. */
171
+ export function signInOutcome(error) {
172
+ if (!error) return 'ok';
173
+ return error.status === 429 ? 'rate_limited' : 'invalid_credentials';
174
+ }
175
+
176
+ /** The outcome of sending a password link, from Supabase's error. */
177
+ export function linkOutcome(error) {
178
+ if (!error) return 'ok';
179
+ return error.status === 429 ? 'rate_limited' : 'send_failed';
180
+ }
181
+
182
+ /** The outcome of saving a password, from Supabase's error. The same password as now is fine: you know it already. */
183
+ export function passwordOutcome(error) {
184
+ if (!error || error.code === 'same_password') return 'ok';
185
+ return error.code === 'weak_password' ? 'weak_password' : 'save_failed';
186
+ }
187
+
188
+ /** The link parameters on the password screen. They are used only on saving, never on opening. */
189
+ export function readPasswordLink(url) {
190
+ return {
191
+ tokenHash: url.searchParams.get('token_hash') ?? '',
192
+ code: url.searchParams.get('code') ?? '',
193
+ linkError: url.searchParams.has('error')
194
+ };
195
+ }
@@ -0,0 +1,52 @@
1
+ import type { Cookies } from '@sveltejs/kit';
2
+ import type { Handle } from '@sveltejs/kit/hooks';
3
+ import type { AdminPaths, SettingNames } from './rules.js';
4
+
5
+ export interface AdminOptions {
6
+ /** Read at runtime in the project's src/env.ts (F-0005). An empty string means missing. */
7
+ supabaseUrl: string;
8
+ anonKey: string;
9
+ serviceRoleKey: string;
10
+ /** `ADMIN_EMAILS`: addresses separated by commas, semicolons, spaces or new lines. */
11
+ allowList: string;
12
+ /** Where the admin lives and its two open screens. */
13
+ paths?: Partial<AdminPaths>;
14
+ /** The names `missingSettings()` reports, if the project's variables are named differently. */
15
+ settingNames?: Partial<SettingNames>;
16
+ }
17
+
18
+ export interface Admin {
19
+ email: string;
20
+ }
21
+
22
+ export type SignInOutcome = 'ok' | 'invalid_credentials' | 'rate_limited' | 'missing_setting';
23
+ export type LinkOutcome = 'ok' | 'invalid_email' | 'rate_limited' | 'send_failed';
24
+ export type PasswordOutcome =
25
+ | 'ok'
26
+ | 'password_too_short'
27
+ | 'passwords_differ'
28
+ | 'link_invalid'
29
+ | 'not_signed_in'
30
+ | 'weak_password'
31
+ | 'save_failed'
32
+ | 'missing_setting';
33
+
34
+ export interface AdminLogin {
35
+ paths: AdminPaths;
36
+ /** For hooks.server.ts. Sets `event.locals.admin` when someone is signed in. */
37
+ handle: Handle;
38
+ currentAdmin(cookies: Cookies): Promise<Admin | null>;
39
+ mayLogIn(email: string): boolean;
40
+ /** The names of the settings that are missing; empty when everything is there. */
41
+ missingSettings(): string[];
42
+ signIn(cookies: Cookies, email: string, password: string): Promise<SignInOutcome>;
43
+ /** `ok` for every valid address, on the list or not; only one on the list gets a mail. */
44
+ sendPasswordLink(cookies: Cookies, email: string, origin: string): Promise<LinkOutcome>;
45
+ setPassword(
46
+ cookies: Cookies,
47
+ input: { tokenHash?: string; code?: string; password: string; repeat: string }
48
+ ): Promise<PasswordOutcome>;
49
+ signOut(cookies: Cookies): Promise<void>;
50
+ }
51
+
52
+ export function createAdmin(options: AdminOptions): AdminLogin;
package/src/server.js ADDED
@@ -0,0 +1,214 @@
1
+ // The admin login with Supabase Auth (F-0011). Server-only: the keys and the
2
+ // cookie stay on the server, the browser never talks to Supabase itself.
3
+ //
4
+ // The client with the anon key is only for logging in and reads or writes no
5
+ // table. The database stays with the service_role key, as everywhere (F-0004);
6
+ // that key is used here only to create a user on their first password link.
7
+ //
8
+ // The package reads no environment variable. The project reads them in its
9
+ // src/env.ts at runtime (F-0005) and passes them in; a missing one is an empty
10
+ // string, and then nobody gets in.
11
+
12
+ import { redirect } from '@sveltejs/kit';
13
+ import { createServerClient } from '@supabase/ssr';
14
+ import { createClient } from '@supabase/supabase-js';
15
+ import {
16
+ ADMIN_HEADERS,
17
+ adminDecision,
18
+ adminPaths,
19
+ isAdminPath,
20
+ linkOutcome,
21
+ mayManage,
22
+ missingSettings,
23
+ normaliseEmail,
24
+ parseAllowList,
25
+ passwordOutcome,
26
+ passwordProblem,
27
+ signInOutcome
28
+ } from './rules.js';
29
+
30
+ const DEFAULT_NAMES = {
31
+ supabaseUrl: 'SUPABASE_URL',
32
+ anonKey: 'SUPABASE_ANON_KEY',
33
+ serviceRoleKey: 'SUPABASE_SERVICE_ROLE_KEY',
34
+ allowList: 'ADMIN_EMAILS'
35
+ };
36
+
37
+ export function createAdmin(options) {
38
+ const paths = adminPaths(options.paths);
39
+ const settings = {
40
+ supabaseUrl: options.supabaseUrl ?? '',
41
+ anonKey: options.anonKey ?? '',
42
+ serviceRoleKey: options.serviceRoleKey ?? '',
43
+ allowList: parseAllowList(options.allowList)
44
+ };
45
+ const names = { ...DEFAULT_NAMES, ...options.settingNames };
46
+ const missing = missingSettings(settings, names);
47
+ const ready = missing.length === 0;
48
+
49
+ function mayLogIn(email) {
50
+ return mayManage(email, settings.allowList);
51
+ }
52
+
53
+ /** The login client for this request. The cookie belongs to the admin path only, and no script in the browser needs it. */
54
+ function authClient(cookies) {
55
+ return createServerClient(settings.supabaseUrl, settings.anonKey, {
56
+ cookies: {
57
+ getAll: () => cookies.getAll(),
58
+ setAll: (toSet) => {
59
+ for (const { name, value, options: cookieOptions } of toSet) {
60
+ cookies.set(name, value, {
61
+ ...cookieOptions,
62
+ path: paths.base,
63
+ httpOnly: true,
64
+ sameSite: 'lax'
65
+ });
66
+ }
67
+ }
68
+ }
69
+ });
70
+ }
71
+
72
+ /**
73
+ * Who is signed in with this cookie, or null. `getClaims` checks the
74
+ * token's signature and refreshes an expired one. An address no longer on
75
+ * the allow list does not count, even with a valid session: that is how
76
+ * someone's access is taken away.
77
+ */
78
+ async function currentAdmin(cookies) {
79
+ if (!ready) return null;
80
+ const { data, error } = await authClient(cookies).auth.getClaims();
81
+ if (error || !data) return null;
82
+ const email = data.claims.email;
83
+ return typeof email === 'string' && mayLogIn(email) ? { email: normaliseEmail(email) } : null;
84
+ }
85
+
86
+ /** The hook for hooks.server.ts. Combine it with others through `sequence`. */
87
+ async function handle({ event, resolve }) {
88
+ if (!isAdminPath(event.url.pathname, paths)) return resolve(event);
89
+
90
+ const admin = await currentAdmin(event.cookies);
91
+ if (admin) event.locals.admin = admin;
92
+
93
+ const decision = adminDecision({
94
+ url: event.url,
95
+ method: event.request.method,
96
+ contentType: event.request.headers.get('content-type'),
97
+ origin: event.request.headers.get('origin'),
98
+ signedIn: !!admin,
99
+ paths
100
+ });
101
+ // SvelteKit turns this redirect into the right answer for a page load
102
+ // and for a form with use:enhance. It has no content that could end up
103
+ // in a frame, a search engine or a cache.
104
+ if (decision.kind === 'redirect') redirect(303, decision.location);
105
+
106
+ const response =
107
+ decision.kind === 'pass'
108
+ ? await resolve(event)
109
+ : new Response(decision.kind === 'forbidden' ? 'Forbidden' : 'Unauthorized', {
110
+ status: decision.kind === 'forbidden' ? 403 : 401
111
+ });
112
+ for (const [name, value] of Object.entries(ADMIN_HEADERS)) response.headers.set(name, value);
113
+ return response;
114
+ }
115
+
116
+ /**
117
+ * An address not on the list gets the same outcome as a wrong password,
118
+ * and Supabase is not called. So the screen gives away nothing about who
119
+ * has access.
120
+ */
121
+ async function signIn(cookies, email, password) {
122
+ if (!ready) return 'missing_setting';
123
+ if (!mayLogIn(email) || !password) return 'invalid_credentials';
124
+ const { error } = await authClient(cookies).auth.signInWithPassword({
125
+ email: normaliseEmail(email),
126
+ password
127
+ });
128
+ if (error && error.status !== 429 && error.code !== 'invalid_credentials') {
129
+ console.error('Admin sign in failed:', error.message);
130
+ }
131
+ return signInOutcome(error);
132
+ }
133
+
134
+ /**
135
+ * Sends a link to set a password, the first time or when it is forgotten.
136
+ * Supabase only mails a user that exists, so someone on the list who is no
137
+ * user yet is created first, without a password. The outcome is the same
138
+ * for every address, and only one on the list gets a mail.
139
+ *
140
+ * The link goes to the project's own password screen, which uses it only
141
+ * once the password is saved.
142
+ */
143
+ async function sendPasswordLink(cookies, email, origin) {
144
+ if (!normaliseEmail(email).includes('@')) return 'invalid_email';
145
+ if (!ready || !mayLogIn(email)) return 'ok';
146
+ if (!settings.serviceRoleKey) {
147
+ console.error(`Sending password link failed: ${names.serviceRoleKey} is missing.`);
148
+ return 'send_failed';
149
+ }
150
+
151
+ const service = createClient(settings.supabaseUrl, settings.serviceRoleKey, {
152
+ auth: { autoRefreshToken: false, persistSession: false }
153
+ });
154
+ const { error: createError } = await service.auth.admin.createUser({
155
+ email: normaliseEmail(email),
156
+ email_confirm: true
157
+ });
158
+ // Existing already is exactly right.
159
+ if (createError && createError.code !== 'email_exists') {
160
+ console.error('Creating admin user failed:', createError.message);
161
+ return linkOutcome(createError);
162
+ }
163
+ const { error } = await authClient(cookies).auth.resetPasswordForEmail(normaliseEmail(email), {
164
+ redirectTo: origin + paths.password
165
+ });
166
+ if (error) console.error('Sending password link failed:', error.message);
167
+ return linkOutcome(error);
168
+ }
169
+
170
+ /**
171
+ * Saves a new password, with the link from the mail (`tokenHash` or `code`)
172
+ * or while signed in. The password is checked first: a link works only
173
+ * once, so it is spent only when the rest is right.
174
+ */
175
+ async function setPassword(cookies, { tokenHash = '', code = '', password, repeat }) {
176
+ const problem = passwordProblem(password, repeat);
177
+ if (problem) return problem;
178
+ if (!ready) return 'missing_setting';
179
+
180
+ const supabase = authClient(cookies);
181
+ if (tokenHash || code) {
182
+ const { data, error } = tokenHash
183
+ ? await supabase.auth.verifyOtp({ token_hash: tokenHash, type: 'recovery' })
184
+ : await supabase.auth.exchangeCodeForSession(code);
185
+ if (error || !mayLogIn(data.user?.email ?? '')) {
186
+ if (!error) await supabase.auth.signOut({ scope: 'local' });
187
+ return 'link_invalid';
188
+ }
189
+ } else if (!(await currentAdmin(cookies))) {
190
+ return 'not_signed_in';
191
+ }
192
+
193
+ const { error } = await supabase.auth.updateUser({ password });
194
+ const outcome = passwordOutcome(error);
195
+ if (outcome === 'save_failed') console.error('Saving password failed:', error.message);
196
+ return outcome;
197
+ }
198
+
199
+ async function signOut(cookies) {
200
+ if (ready) await authClient(cookies).auth.signOut({ scope: 'local' });
201
+ }
202
+
203
+ return {
204
+ paths,
205
+ handle,
206
+ currentAdmin,
207
+ mayLogIn,
208
+ missingSettings: () => [...missing],
209
+ signIn,
210
+ sendPasswordLink,
211
+ setPassword,
212
+ signOut
213
+ };
214
+ }