@mccaskie-labs/workbench-supabase 0.1.0 → 0.3.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/CHANGELOG.md +11 -0
- package/README.md +29 -0
- package/dist/cli.js +0 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/plain-error.d.ts +21 -0
- package/dist/plain-error.js +76 -0
- package/dist/sessions.d.ts +120 -0
- package/dist/sessions.js +399 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# @mccaskie-labs/workbench-supabase
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
- `createSessions({ url, publishableKey, secret })`: app sign-in by typed emailed code with the session held by the server (authentication standard §4, §6, §10). `sendCode`, `verifyCode`, `read`, `signOut`, and a pending-sign-in cookie that grants nothing. The session is one AES-GCM sealed `__Host-session` cookie (Secure, HttpOnly, SameSite=Lax) with four dials: idle on a private device (14 days), on a shared one (30 minutes), an absolute cap (30 days, never moved by a refresh) and a freshness window (15 minutes). The access token is refreshed by the server and never reaches page script. Sign-out clears the cookie first, with every attribute, then ends this session only at Supabase.
|
|
6
|
+
- `limitKey(kind, value)`: a keyed hash for rate-limit keys, so a counter never holds an address or an IP.
|
|
7
|
+
- `safeReturnPath(next)`, `sameOrigin(request)`, `normaliseEmail`, `normaliseCode`, `readCookie`.
|
|
8
|
+
|
|
9
|
+
## 0.2.0
|
|
10
|
+
|
|
11
|
+
- `plainError(error, { appName })`: raw Postgres and PostgREST wording (permission denied, row-level security, missing relation, PGRST codes and so on) becomes one plain line naming the app, and the full message is logged. The app's own words pass unchanged. From Imprint's `plain-error.ts` (sign-in kit, 11 November 2026).
|
|
12
|
+
- `plainAuthError(error, { appName })`: a plain line for each common Supabase Auth error code. None says whether an account exists for an address.
|
|
13
|
+
|
|
3
14
|
## 0.1.0
|
|
4
15
|
|
|
5
16
|
- First version (step 4.11): `supabaseConfig`, `createBrowserClient`, `createServerClient`, `createUserClient`, `createServiceClient` (logs its reason once per isolate), `WorkbenchSupabaseError`, and the CLI's `check:migrations`, `check:prod-schema` and `prod-schema-sql` (`docs/spec.md` §3).
|
package/README.md
CHANGED
|
@@ -22,10 +22,39 @@ const admin = createServiceClient({ url: cfg.url, secretKey: workerSecret(env, '
|
|
|
22
22
|
| `createServerClient({ ...cfg, cookies })` | On the server, as the signed-in user from the request's cookies. `cookies` is `{ getAll, setAll }` |
|
|
23
23
|
| `createUserClient({ ...cfg, accessToken })` | As the caller, with the token they sent (audit F7). No session is stored or refreshed |
|
|
24
24
|
| `createServiceClient({ url, secretKey, reason })` | The `sb_secret_` key: **bypasses RLS**. `reason` (one line) is logged once per isolate: `[workbench-supabase] service client (bypasses RLS): <reason>`. Refused in a browser and for a publishable key |
|
|
25
|
+
| `plainError(error, { appName, log? })` | The message to show a person: the app's own words unchanged, or for raw Postgres or PostgREST wording ("permission denied for function …", RLS, missing relation, `PGRST116`) one plain line, "Something went wrong on <app>'s side …". The full message is logged |
|
|
26
|
+
| `plainAuthError(error, { appName, log? })` | A plain line for a Supabase Auth error by its `code` (`otp_expired`, `invalid_credentials`, rate limits, `weak_password` …). None says whether an account exists; an unknown code gets the plain line and is logged |
|
|
25
27
|
| `WorkbenchSupabaseError` | A client built wrongly: `code` is `invalid_config` or `forbidden`. Never holds a key, URL or token |
|
|
26
28
|
|
|
27
29
|
Every client takes an optional `fetch`, for tests against a fake Supabase.
|
|
28
30
|
|
|
31
|
+
## Sign-in and sessions
|
|
32
|
+
|
|
33
|
+
App sign-in by a typed emailed code (no link, no password reset), with the session held by the Worker. The browser never talks to Supabase Auth and never sees the access token (Robert's authentication standard §4).
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { createSessions, safeReturnPath, sameOrigin } from '@mccaskie-labs/workbench-supabase'
|
|
37
|
+
|
|
38
|
+
const sessions = createSessions({ url, publishableKey, secret: env.SESSION_SECRET }) // once per isolate
|
|
39
|
+
await sessions.sendCode(email) // same answer whether or not the account exists
|
|
40
|
+
const r = await sessions.verifyCode(email, code, { keep: true }) // { ok, user, cookie } or { ok: false, code } for plainAuthError
|
|
41
|
+
const s = await sessions.read(request) // none | signed_in | ended | unavailable
|
|
42
|
+
if (s.state === 'signed_in') createUserClient({ url, publishableKey, accessToken: s.accessToken })
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
| Export | What it does |
|
|
46
|
+
| --- | --- |
|
|
47
|
+
| `createSessions(cfg)` | `sendCode`, `verifyCode`, `read`, `signOut`, `rememberSignIn` / `pendingSignIn` / `clearSignIn`, `clearSession`, `limitKey`. `secret` is `SESSION_SECRET` (Control: `cf_worker_secret_put source=generate:32`) |
|
|
48
|
+
| `SESSION_DEFAULTS` | Idle 14 days (private device), 30 minutes (shared: "keep me signed in" unticked), absolute cap 30 days, fresh 15 minutes. Change with `timers`; the shared idle can only be shorter |
|
|
49
|
+
| `safeReturnPath(next, fallback)` | `next` when it is a path on this site, else `fallback` |
|
|
50
|
+
| `sameOrigin(request)` | The request's Origin (or Referer) is its own origin. Check it on every write |
|
|
51
|
+
|
|
52
|
+
Cookies: `__Host-session` holds the sealed tokens and timers; `__Host-signin` holds which address has a code outstanding, for an hour, and grants nothing. Both are `Secure; HttpOnly; SameSite=Lax; Path=/`, sealed with AES-GCM under a key derived from `SESSION_SECRET` with the cookie's name bound in, so one can't stand in for the other. Replacing `SESSION_SECRET` signs everybody out.
|
|
53
|
+
|
|
54
|
+
`read` refreshes the access token a minute before it expires; a refresh never moves the absolute cap or the freshness window. When `read` or `verifyCode` returns a `cookie`, set it on the response. When Supabase can't be reached, a still-valid token keeps working and an expired one reads as `unavailable`: say so, and keep everything that doesn't need an account working.
|
|
55
|
+
|
|
56
|
+
What it doesn't do, so the app must: rate limiting. Supabase sees every call coming from Cloudflare, not from the person, so its own per-IP limits don't protect a six-digit code. Limit `sendCode` and `verifyCode` per IP and per address with `limitKey` (the project template uses Workers rate limiting). Accepted, recorded: the cookie is the session, so a copy taken before sign-out keeps working until its access token expires (at most an hour) or its refresh token is used after sign-out; and replacing `SESSION_SECRET` signs everybody out (no key rotation yet).
|
|
57
|
+
|
|
29
58
|
## Worker secrets
|
|
30
59
|
|
|
31
60
|
Control writes them with `cf_worker_secret_put`, from the Worker's own database (`<id>-staging` Worker: `<id>-staging`; `<id>` Worker: `<id>-production`). Nobody types a value.
|
package/dist/cli.js
CHANGED
|
File without changes
|
package/dist/index.d.ts
CHANGED
|
@@ -5,3 +5,5 @@ export { migrationProblems, type MigrationProblem } from "./migrations.ts";
|
|
|
5
5
|
export { PRODUCTION_SCHEMA_SQL, createdBy, missingOnProduction, findUses, unusedAllows, parseProductionSchema, parseAllow, type ProductionSchema, type AllowEntry, type MissingName, type Use, } from "./prod-schema.ts";
|
|
6
6
|
export { stringPieces, type StringPiece } from "./strings.ts";
|
|
7
7
|
export type { SupabaseClient } from "@supabase/supabase-js";
|
|
8
|
+
export { plainError, plainAuthError, plainServerError, isRawDbError, type PlainErrorOptions } from "./plain-error.ts";
|
|
9
|
+
export { createSessions, normaliseEmail, normaliseCode, safeReturnPath, sameOrigin, readCookie, SESSION_COOKIE, SIGN_IN_COOKIE, SESSION_DEFAULTS, type Sessions, type SessionsConfig, type SessionTimers, type SessionRead, type SessionUser, type AuthFailure, } from "./sessions.ts";
|
package/dist/index.js
CHANGED
|
@@ -4,3 +4,5 @@ export { WorkbenchSupabaseError } from "./errors.js";
|
|
|
4
4
|
export { migrationProblems } from "./migrations.js";
|
|
5
5
|
export { PRODUCTION_SCHEMA_SQL, createdBy, missingOnProduction, findUses, unusedAllows, parseProductionSchema, parseAllow, } from "./prod-schema.js";
|
|
6
6
|
export { stringPieces } from "./strings.js";
|
|
7
|
+
export { plainError, plainAuthError, plainServerError, isRawDbError } from "./plain-error.js";
|
|
8
|
+
export { createSessions, normaliseEmail, normaliseCode, safeReturnPath, sameOrigin, readCookie, SESSION_COOKIE, SIGN_IN_COOKIE, SESSION_DEFAULTS, } from "./sessions.js";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface PlainErrorOptions {
|
|
2
|
+
/** The app's name as people see it (brand.json `name`). */
|
|
3
|
+
appName: string;
|
|
4
|
+
/** Where the full message is logged. Defaults to console.error. */
|
|
5
|
+
log?: (line: string) => void;
|
|
6
|
+
}
|
|
7
|
+
/** True for wording from Postgres or PostgREST that a person shouldn't see. */
|
|
8
|
+
export declare function isRawDbError(message: string): boolean;
|
|
9
|
+
/** The line shown in place of a raw error. */
|
|
10
|
+
export declare function plainServerError(appName: string): string;
|
|
11
|
+
/**
|
|
12
|
+
* The message to show: as it is, or the plain line for a raw database error
|
|
13
|
+
* (logged in full). Takes a string, an Error or a Supabase/PostgREST error
|
|
14
|
+
* object; anything without a message is treated as raw.
|
|
15
|
+
*/
|
|
16
|
+
export declare function plainError(error: unknown, opts: PlainErrorOptions): string;
|
|
17
|
+
/**
|
|
18
|
+
* A plain line for a Supabase Auth error, by its code. An unknown code (or
|
|
19
|
+
* none) gets the app's plain server line, and the error is logged in full.
|
|
20
|
+
*/
|
|
21
|
+
export declare function plainAuthError(error: unknown, opts: PlainErrorOptions): string;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Raw database and Auth errors never reach a person's screen. Carried across
|
|
2
|
+
// from Imprint's apps/admin/lib/plain-error.ts (sign-in kit, agreed
|
|
3
|
+
// 11 November 2026): a technical message is logged in full for us and
|
|
4
|
+
// replaced with one plain line naming the app. The app's own refusals,
|
|
5
|
+
// including its database functions' own words, pass unchanged.
|
|
6
|
+
/** Postgres and PostgREST wording that only makes sense to a developer. */
|
|
7
|
+
const RAW_DB = /permission denied for|violates (row-level security|foreign key|check|not-null|unique|exclusion)|duplicate key value|(relation|column|function|schema|type) "?[\w.]+(\([^)]*\))?"? does not exist|invalid input (syntax|value) for|syntax error at or near|could not serialize access|deadlock detected|null value in column|value too long for type|\bPGRST\d{3}\b|\bJWT\b|42501|23505|23503|42883|42P01/i;
|
|
8
|
+
/** True for wording from Postgres or PostgREST that a person shouldn't see. */
|
|
9
|
+
export function isRawDbError(message) {
|
|
10
|
+
return RAW_DB.test(message);
|
|
11
|
+
}
|
|
12
|
+
/** The line shown in place of a raw error. */
|
|
13
|
+
export function plainServerError(appName) {
|
|
14
|
+
return `Something went wrong on ${appName}’s side. Try again, and if it happens again, tell ${appName} support.`;
|
|
15
|
+
}
|
|
16
|
+
function messageOf(error) {
|
|
17
|
+
if (typeof error === "string")
|
|
18
|
+
return error;
|
|
19
|
+
if (error && typeof error === "object" && typeof error.message === "string") {
|
|
20
|
+
return error.message;
|
|
21
|
+
}
|
|
22
|
+
return "";
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The message to show: as it is, or the plain line for a raw database error
|
|
26
|
+
* (logged in full). Takes a string, an Error or a Supabase/PostgREST error
|
|
27
|
+
* object; anything without a message is treated as raw.
|
|
28
|
+
*/
|
|
29
|
+
export function plainError(error, opts) {
|
|
30
|
+
const message = messageOf(error);
|
|
31
|
+
if (message !== "" && !isRawDbError(message))
|
|
32
|
+
return message;
|
|
33
|
+
(opts.log ?? console.error)(`[error shown as plain] ${message || String(error)}`);
|
|
34
|
+
return plainServerError(opts.appName);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Supabase Auth error codes (`AuthError.code`) and the plain line for each.
|
|
38
|
+
* None says whether an account exists for an address, so a sign-in form
|
|
39
|
+
* can't be used to find out who has an account.
|
|
40
|
+
*/
|
|
41
|
+
const AUTH_LINES = {
|
|
42
|
+
invalid_credentials: "That email address and password don't match. Check them and try again.",
|
|
43
|
+
otp_expired: "That code has expired or was already used. Ask for a new one.",
|
|
44
|
+
otp_disabled: "Signing in with a code isn't turned on.",
|
|
45
|
+
over_email_send_rate_limit: "Too many emails were sent to this address. Wait a few minutes, then try again.",
|
|
46
|
+
over_request_rate_limit: "Too many tries. Wait a few minutes, then try again.",
|
|
47
|
+
over_sms_send_rate_limit: "Too many messages were sent. Wait a few minutes, then try again.",
|
|
48
|
+
weak_password: "Choose a longer password that's harder to guess.",
|
|
49
|
+
same_password: "Choose a password different from your current one.",
|
|
50
|
+
reauthentication_needed: "For your safety, confirm it's you first. We'll send you a code.",
|
|
51
|
+
reauthentication_not_valid: "That code isn't right or has expired. Ask for a new one.",
|
|
52
|
+
mfa_verification_failed: "That code isn't right. Check your authenticator app and try again.",
|
|
53
|
+
mfa_challenge_expired: "That took too long. Start signing in again.",
|
|
54
|
+
session_expired: "You've been signed out. Sign in again.",
|
|
55
|
+
session_not_found: "You've been signed out. Sign in again.",
|
|
56
|
+
refresh_token_not_found: "You've been signed out. Sign in again.",
|
|
57
|
+
user_banned: "This login can't be used. Contact support.",
|
|
58
|
+
signup_disabled: "New accounts can't be made here. Ask the person who runs it for an invite.",
|
|
59
|
+
// These would say an account exists: the same neutral line as a new sign-up.
|
|
60
|
+
email_exists: "Check your email for a code to carry on.",
|
|
61
|
+
user_already_exists: "Check your email for a code to carry on.",
|
|
62
|
+
// An address Supabase won't take, said without saying why.
|
|
63
|
+
email_address_invalid: "Check the email address and try again.",
|
|
64
|
+
email_address_not_authorized: "Check the email address and try again.",
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* A plain line for a Supabase Auth error, by its code. An unknown code (or
|
|
68
|
+
* none) gets the app's plain server line, and the error is logged in full.
|
|
69
|
+
*/
|
|
70
|
+
export function plainAuthError(error, opts) {
|
|
71
|
+
const code = error && typeof error === "object" ? error.code : undefined;
|
|
72
|
+
if (typeof code === "string" && Object.hasOwn(AUTH_LINES, code))
|
|
73
|
+
return AUTH_LINES[code];
|
|
74
|
+
(opts.log ?? console.error)(`[auth error shown as plain] ${typeof code === "string" ? `${code}: ` : ""}${messageOf(error) || String(error)}`);
|
|
75
|
+
return plainServerError(opts.appName);
|
|
76
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
type Fetch = typeof fetch;
|
|
2
|
+
export declare const SESSION_COOKIE = "__Host-session";
|
|
3
|
+
export declare const SIGN_IN_COOKIE = "__Host-signin";
|
|
4
|
+
/** The four dials, in seconds. */
|
|
5
|
+
export interface SessionTimers {
|
|
6
|
+
/** Signed out after this long without a request, on a private device. */
|
|
7
|
+
idlePrivate: number;
|
|
8
|
+
/** The same, when "keep me signed in" was left unticked. Shorter than idlePrivate. */
|
|
9
|
+
idleShared: number;
|
|
10
|
+
/** Signed out this long after the code was typed, however active. */
|
|
11
|
+
absolute: number;
|
|
12
|
+
/** `fresh` is true for this long after the code was typed. */
|
|
13
|
+
fresh: number;
|
|
14
|
+
}
|
|
15
|
+
/** Weeks with an absolute cap (§10): the defaults a project starts with. */
|
|
16
|
+
export declare const SESSION_DEFAULTS: Readonly<SessionTimers>;
|
|
17
|
+
export interface SessionUser {
|
|
18
|
+
id: string;
|
|
19
|
+
email: string;
|
|
20
|
+
}
|
|
21
|
+
/** A Supabase Auth refusal: its `code` is what plainAuthError reads. Never holds a token. */
|
|
22
|
+
export interface AuthFailure {
|
|
23
|
+
ok: false;
|
|
24
|
+
/** Supabase's error_code, or ours: invalid_email, invalid_code, auth_unavailable, session_too_large. */
|
|
25
|
+
code: string;
|
|
26
|
+
status: number;
|
|
27
|
+
}
|
|
28
|
+
export type SessionRead =
|
|
29
|
+
/** No session cookie. */
|
|
30
|
+
{
|
|
31
|
+
state: "none";
|
|
32
|
+
}
|
|
33
|
+
/** Signed in. `cookie` is set when the session changed (a refresh, or "last seen" moved): put it on the response. */
|
|
34
|
+
| {
|
|
35
|
+
state: "signed_in";
|
|
36
|
+
user: SessionUser;
|
|
37
|
+
accessToken: string;
|
|
38
|
+
fresh: boolean;
|
|
39
|
+
cookie?: string;
|
|
40
|
+
}
|
|
41
|
+
/** The session was there and has ended (idle, cap, refresh refused, or tampered): put `cookie` on the response to clear it. */
|
|
42
|
+
| {
|
|
43
|
+
state: "ended";
|
|
44
|
+
reason: "idle" | "absolute" | "refused" | "invalid";
|
|
45
|
+
cookie: string;
|
|
46
|
+
}
|
|
47
|
+
/** Supabase couldn't be reached to refresh it. Say so plainly; the cookie is kept. */
|
|
48
|
+
| {
|
|
49
|
+
state: "unavailable";
|
|
50
|
+
};
|
|
51
|
+
export interface SessionsConfig {
|
|
52
|
+
/** SUPABASE_URL */
|
|
53
|
+
url: string;
|
|
54
|
+
/** SUPABASE_PUBLISHABLE_KEY */
|
|
55
|
+
publishableKey: string;
|
|
56
|
+
/** SESSION_SECRET: at least 32 characters (Control writes 32 random bytes, base64url). */
|
|
57
|
+
secret: string;
|
|
58
|
+
/** Change a dial; each must stay positive, and idleShared no longer than idlePrivate. */
|
|
59
|
+
timers?: Partial<SessionTimers>;
|
|
60
|
+
/** For tests: a fake Supabase. */
|
|
61
|
+
fetch?: Fetch;
|
|
62
|
+
/** For tests: the clock, in seconds. */
|
|
63
|
+
now?: () => number;
|
|
64
|
+
}
|
|
65
|
+
/** The address as Supabase stores it (trimmed, lower case), or null when it isn't one. */
|
|
66
|
+
export declare function normaliseEmail(input: unknown): string | null;
|
|
67
|
+
/** Six to ten digits, as Supabase's Email OTP Length allows; spaces and dashes typed between them are dropped. */
|
|
68
|
+
export declare function normaliseCode(input: unknown): string | null;
|
|
69
|
+
/**
|
|
70
|
+
* Where to go after signing in: `next` when it is a path on this site, else
|
|
71
|
+
* `fallback` (§7: an unchecked return target is an open redirect). Refuses
|
|
72
|
+
* other hosts, scheme-relative `//host`, backslashes and control characters.
|
|
73
|
+
*/
|
|
74
|
+
export declare function safeReturnPath(next: unknown, fallback?: string): string;
|
|
75
|
+
/**
|
|
76
|
+
* True when a state-changing request came from this site's own pages: its
|
|
77
|
+
* Origin (or, without one, its Referer) is the request's own origin. A
|
|
78
|
+
* cross-site form post never passes, whatever cookies it carries.
|
|
79
|
+
*/
|
|
80
|
+
export declare function sameOrigin(request: Request): boolean;
|
|
81
|
+
/** The value of one cookie on a request, or null. */
|
|
82
|
+
export declare function readCookie(request: Request, name: string): string | null;
|
|
83
|
+
export interface Sessions {
|
|
84
|
+
/** Ask Supabase to email a sign-in code. Answers the same whether or not the address has an account (§8.1). */
|
|
85
|
+
sendCode(email: string, opts?: {
|
|
86
|
+
createUser?: boolean;
|
|
87
|
+
}): Promise<{
|
|
88
|
+
ok: true;
|
|
89
|
+
} | AuthFailure>;
|
|
90
|
+
/** Check a typed code; on success, the session cookie to set. `keep` false marks a shared device (§10.2). */
|
|
91
|
+
verifyCode(email: string, code: string, opts: {
|
|
92
|
+
keep: boolean;
|
|
93
|
+
}): Promise<{
|
|
94
|
+
ok: true;
|
|
95
|
+
user: SessionUser;
|
|
96
|
+
cookie: string;
|
|
97
|
+
} | AuthFailure>;
|
|
98
|
+
/** The request's session, refreshed if its access token is near expiry. */
|
|
99
|
+
read(request: Request): Promise<SessionRead>;
|
|
100
|
+
/** Clear our cookie first and unconditionally, then tell Supabase (§10.3). Returns the clearing cookie. */
|
|
101
|
+
signOut(request: Request): Promise<string>;
|
|
102
|
+
/** The clearing cookie for the session. */
|
|
103
|
+
clearSession(): string;
|
|
104
|
+
/** Remember which address has a code outstanding (grants nothing). */
|
|
105
|
+
rememberSignIn(email: string): Promise<string>;
|
|
106
|
+
/** The outstanding sign-in, or null. */
|
|
107
|
+
pendingSignIn(request: Request): Promise<{
|
|
108
|
+
email: string;
|
|
109
|
+
sent: number;
|
|
110
|
+
} | null>;
|
|
111
|
+
/** The clearing cookie for the pending sign-in. */
|
|
112
|
+
clearSignIn(): string;
|
|
113
|
+
/** A keyed hash for a rate-limit key, so the counter store never holds an address or an IP (§9). */
|
|
114
|
+
limitKey(kind: string, value: string): Promise<string>;
|
|
115
|
+
/** The dials in force. */
|
|
116
|
+
timers: Readonly<SessionTimers>;
|
|
117
|
+
}
|
|
118
|
+
/** Sign-in and sessions for one Worker env. Make it once per isolate. */
|
|
119
|
+
export declare function createSessions(cfg: SessionsConfig): Sessions;
|
|
120
|
+
export {};
|
package/dist/sessions.js
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
// App sign-in for Workbench apps: typed emailed codes and a server-held
|
|
2
|
+
// session (authentication standard §4, §6, §10, §12; sign-in thread,
|
|
3
|
+
// 11 November 2026).
|
|
4
|
+
//
|
|
5
|
+
// The browser never talks to Supabase Auth. The app's own endpoints call
|
|
6
|
+
// these functions, which call Supabase Auth's REST API with the publishable
|
|
7
|
+
// key, and the session lives in one sealed cookie:
|
|
8
|
+
//
|
|
9
|
+
// __Host-session AES-GCM sealed: Supabase's access and refresh tokens,
|
|
10
|
+
// the user's id and email, and four timestamps (session
|
|
11
|
+
// start, last seen, last proof, access-token expiry)
|
|
12
|
+
// __Host-signin AES-GCM sealed: which address has a code outstanding and
|
|
13
|
+
// when it was sent. It grants nothing (§5.4): forging it
|
|
14
|
+
// earns a pre-filled address of your own choosing.
|
|
15
|
+
//
|
|
16
|
+
// Both are host-prefixed, Secure, HttpOnly and SameSite=Lax, and each is
|
|
17
|
+
// sealed with the cookie's own name as associated data, so one can't stand in
|
|
18
|
+
// for the other. The access token never reaches page script.
|
|
19
|
+
//
|
|
20
|
+
// Four dials (§10.1): idle on a private device, a much shorter idle on a
|
|
21
|
+
// shared one, an absolute cap counted from the code that started the session
|
|
22
|
+
// (a token refresh never moves it), and a freshness window that says whether
|
|
23
|
+
// the session proved itself recently (for disclosure, not access).
|
|
24
|
+
//
|
|
25
|
+
// The key comes from one Worker secret (SESSION_SECRET, Control writes it with
|
|
26
|
+
// cf_worker_secret_put source=generate:32). Two keys are derived from it: one
|
|
27
|
+
// seals cookies, the other hashes rate-limit keys so no address is ever stored.
|
|
28
|
+
import { WorkbenchSupabaseError } from "./errors.js";
|
|
29
|
+
export const SESSION_COOKIE = "__Host-session";
|
|
30
|
+
export const SIGN_IN_COOKIE = "__Host-signin";
|
|
31
|
+
/** Weeks with an absolute cap (§10): the defaults a project starts with. */
|
|
32
|
+
export const SESSION_DEFAULTS = Object.freeze({
|
|
33
|
+
idlePrivate: 14 * 86_400,
|
|
34
|
+
idleShared: 30 * 60,
|
|
35
|
+
absolute: 30 * 86_400,
|
|
36
|
+
fresh: 15 * 60,
|
|
37
|
+
});
|
|
38
|
+
/** How long a pending sign-in (an address with a code outstanding) is remembered. */
|
|
39
|
+
const PENDING_SECONDS = 60 * 60;
|
|
40
|
+
/** The access token is refreshed this close to its expiry. */
|
|
41
|
+
const REFRESH_EARLY = 60;
|
|
42
|
+
/** "Last seen" is rewritten at most this often, so most requests set no cookie. */
|
|
43
|
+
const SEEN_STEP = 5 * 60;
|
|
44
|
+
/** How long one call to Supabase Auth may take: nothing, sign-out above all, waits on a hung Supabase. */
|
|
45
|
+
const AUTH_TIMEOUT_MS = 5_000;
|
|
46
|
+
/** Browsers drop a cookie over 4096 bytes without a word. */
|
|
47
|
+
const COOKIE_LIMIT = 4000;
|
|
48
|
+
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
49
|
+
/** The address as Supabase stores it (trimmed, lower case), or null when it isn't one. */
|
|
50
|
+
export function normaliseEmail(input) {
|
|
51
|
+
if (typeof input !== "string")
|
|
52
|
+
return null;
|
|
53
|
+
const e = input.trim().toLowerCase();
|
|
54
|
+
if (e.length < 3 || e.length > 254 || !EMAIL.test(e))
|
|
55
|
+
return null;
|
|
56
|
+
return e;
|
|
57
|
+
}
|
|
58
|
+
/** Six to ten digits, as Supabase's Email OTP Length allows; spaces and dashes typed between them are dropped. */
|
|
59
|
+
export function normaliseCode(input) {
|
|
60
|
+
if (typeof input !== "string")
|
|
61
|
+
return null;
|
|
62
|
+
const c = input.replace(/[\s-]/g, "");
|
|
63
|
+
return /^\d{6,10}$/.test(c) ? c : null;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Where to go after signing in: `next` when it is a path on this site, else
|
|
67
|
+
* `fallback` (§7: an unchecked return target is an open redirect). Refuses
|
|
68
|
+
* other hosts, scheme-relative `//host`, backslashes and control characters.
|
|
69
|
+
*/
|
|
70
|
+
export function safeReturnPath(next, fallback = "/") {
|
|
71
|
+
if (typeof next !== "string" || next.length === 0 || next.length > 2000)
|
|
72
|
+
return fallback;
|
|
73
|
+
if (!next.startsWith("/") || next.startsWith("//") || /[\\\u0000-\u001f\u007f]/.test(next))
|
|
74
|
+
return fallback;
|
|
75
|
+
try {
|
|
76
|
+
const u = new URL(next, "https://same.invalid");
|
|
77
|
+
if (u.origin !== "https://same.invalid")
|
|
78
|
+
return fallback;
|
|
79
|
+
const out = `${u.pathname}${u.search}${u.hash}`;
|
|
80
|
+
// Dot segments can normalise to a leading "//" (/.//evil.example), which a browser reads as another host.
|
|
81
|
+
if (out.startsWith("//") || out.includes("\\"))
|
|
82
|
+
return fallback;
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return fallback;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* True when a state-changing request came from this site's own pages: its
|
|
91
|
+
* Origin (or, without one, its Referer) is the request's own origin. A
|
|
92
|
+
* cross-site form post never passes, whatever cookies it carries.
|
|
93
|
+
*/
|
|
94
|
+
export function sameOrigin(request) {
|
|
95
|
+
const own = new URL(request.url).origin;
|
|
96
|
+
const origin = request.headers.get("origin");
|
|
97
|
+
if (origin !== null)
|
|
98
|
+
return origin === own;
|
|
99
|
+
const referer = request.headers.get("referer");
|
|
100
|
+
if (referer === null)
|
|
101
|
+
return false;
|
|
102
|
+
try {
|
|
103
|
+
return new URL(referer).origin === own;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function checkTimers(t) {
|
|
110
|
+
for (const [k, v] of Object.entries(t)) {
|
|
111
|
+
if (!Number.isInteger(v) || v <= 0)
|
|
112
|
+
throw new WorkbenchSupabaseError("invalid_config", `Session timer ${k} must be a whole number of seconds above 0.`);
|
|
113
|
+
}
|
|
114
|
+
if (t.idleShared > t.idlePrivate)
|
|
115
|
+
throw new WorkbenchSupabaseError("invalid_config", "idleShared can only be shorter than idlePrivate (§10.2: the shared-device control only shortens).");
|
|
116
|
+
if (t.idleShared < 2 * SEEN_STEP)
|
|
117
|
+
throw new WorkbenchSupabaseError("invalid_config", `idleShared must be at least ${2 * SEEN_STEP} seconds: "last seen" moves every ${SEEN_STEP}.`);
|
|
118
|
+
if (t.idlePrivate > t.absolute)
|
|
119
|
+
throw new WorkbenchSupabaseError("invalid_config", "idlePrivate can't be longer than the absolute cap.");
|
|
120
|
+
return t;
|
|
121
|
+
}
|
|
122
|
+
const enc = new TextEncoder();
|
|
123
|
+
const dec = new TextDecoder();
|
|
124
|
+
function b64url(bytes) {
|
|
125
|
+
let s = "";
|
|
126
|
+
for (const b of bytes)
|
|
127
|
+
s += String.fromCharCode(b);
|
|
128
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
129
|
+
}
|
|
130
|
+
function unb64url(s) {
|
|
131
|
+
if (!/^[A-Za-z0-9_-]*$/.test(s))
|
|
132
|
+
return null;
|
|
133
|
+
try {
|
|
134
|
+
const b64 = s.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(s.length / 4) * 4, "=");
|
|
135
|
+
return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** The value of one cookie on a request, or null. */
|
|
142
|
+
export function readCookie(request, name) {
|
|
143
|
+
const header = request.headers.get("cookie");
|
|
144
|
+
if (!header)
|
|
145
|
+
return null;
|
|
146
|
+
for (const part of header.split(";")) {
|
|
147
|
+
const i = part.indexOf("=");
|
|
148
|
+
if (i < 0)
|
|
149
|
+
continue;
|
|
150
|
+
if (part.slice(0, i).trim() === name)
|
|
151
|
+
return part.slice(i + 1).trim();
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
/** A host-prefixed cookie with every attribute, setting or clearing (§10.3: the clear needs them too). */
|
|
156
|
+
function cookie(name, value, maxAge) {
|
|
157
|
+
return `${name}=${value}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=${Math.max(0, Math.floor(maxAge))}`;
|
|
158
|
+
}
|
|
159
|
+
/** Sign-in and sessions for one Worker env. Make it once per isolate. */
|
|
160
|
+
export function createSessions(cfg) {
|
|
161
|
+
if (typeof cfg.secret !== "string" || cfg.secret.length < 32) {
|
|
162
|
+
throw new WorkbenchSupabaseError("invalid_config", "SESSION_SECRET must be at least 32 characters (cf_worker_secret_put source=generate:32).");
|
|
163
|
+
}
|
|
164
|
+
if (typeof cfg.url !== "string" || !/^https?:\/\//.test(cfg.url))
|
|
165
|
+
throw new WorkbenchSupabaseError("invalid_config", "The Supabase URL is missing or invalid.");
|
|
166
|
+
if (typeof cfg.publishableKey !== "string" || !cfg.publishableKey.startsWith("sb_publishable_")) {
|
|
167
|
+
throw new WorkbenchSupabaseError("invalid_config", "Sign-in needs the sb_publishable_ key: never the secret key.");
|
|
168
|
+
}
|
|
169
|
+
const timers = checkTimers({ ...SESSION_DEFAULTS, ...(cfg.timers ?? {}) });
|
|
170
|
+
const base = cfg.url.replace(/\/+$/, "");
|
|
171
|
+
const doFetch = cfg.fetch ?? ((input, init) => fetch(input, init));
|
|
172
|
+
const now = cfg.now ?? (() => Math.floor(Date.now() / 1000));
|
|
173
|
+
let keysP = null;
|
|
174
|
+
const keys = () => (keysP ??= (async () => {
|
|
175
|
+
const raw = await crypto.subtle.importKey("raw", enc.encode(cfg.secret), "HKDF", false, ["deriveKey"]);
|
|
176
|
+
const hkdf = (info) => ({ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: enc.encode(info) });
|
|
177
|
+
return {
|
|
178
|
+
seal: await crypto.subtle.deriveKey(hkdf("workbench-session-seal-v1"), raw, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]),
|
|
179
|
+
hash: await crypto.subtle.deriveKey(hkdf("workbench-session-limit-v1"), raw, { name: "HMAC", hash: "SHA-256", length: 256 }, false, ["sign"]),
|
|
180
|
+
};
|
|
181
|
+
})().catch((e) => {
|
|
182
|
+
keysP = null; // don't cache a failure for the isolate's life
|
|
183
|
+
throw e;
|
|
184
|
+
}));
|
|
185
|
+
async function seal(name, data) {
|
|
186
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
187
|
+
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: enc.encode(name) }, (await keys()).seal, enc.encode(JSON.stringify(data))));
|
|
188
|
+
const out = new Uint8Array(iv.length + ct.length);
|
|
189
|
+
out.set(iv);
|
|
190
|
+
out.set(ct, iv.length);
|
|
191
|
+
return b64url(out);
|
|
192
|
+
}
|
|
193
|
+
async function open(name, value) {
|
|
194
|
+
if (!value || value.length > COOKIE_LIMIT)
|
|
195
|
+
return null;
|
|
196
|
+
const bytes = unb64url(value);
|
|
197
|
+
if (!bytes || bytes.length < 12 + 16)
|
|
198
|
+
return null;
|
|
199
|
+
try {
|
|
200
|
+
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: bytes.slice(0, 12), additionalData: enc.encode(name) }, (await keys()).seal, bytes.slice(12));
|
|
201
|
+
return JSON.parse(dec.decode(pt));
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
async function auth(path, body, bearer) {
|
|
208
|
+
let res;
|
|
209
|
+
try {
|
|
210
|
+
res = await doFetch(`${base}/auth/v1/${path}`, {
|
|
211
|
+
method: "POST",
|
|
212
|
+
headers: { apikey: cfg.publishableKey, "content-type": "application/json", ...(bearer ? { authorization: `Bearer ${bearer}` } : {}) },
|
|
213
|
+
body: JSON.stringify(body ?? {}),
|
|
214
|
+
signal: AbortSignal.timeout(AUTH_TIMEOUT_MS),
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return { ok: false, code: "auth_unavailable", status: 503 };
|
|
219
|
+
}
|
|
220
|
+
let json = null;
|
|
221
|
+
let text = "";
|
|
222
|
+
try {
|
|
223
|
+
text = await res.text();
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return { ok: false, code: "auth_unavailable", status: 503 };
|
|
227
|
+
}
|
|
228
|
+
if (text) {
|
|
229
|
+
try {
|
|
230
|
+
json = JSON.parse(text);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
json = null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (res.ok)
|
|
237
|
+
return { ok: true, status: res.status, json };
|
|
238
|
+
const j = (json ?? {});
|
|
239
|
+
const code = typeof j.error_code === "string" ? j.error_code : typeof j.code === "string" ? j.code : res.status >= 500 ? "auth_unavailable" : "unknown";
|
|
240
|
+
return { ok: false, code, status: res.status };
|
|
241
|
+
}
|
|
242
|
+
function sessionCookie(s, value) {
|
|
243
|
+
const idle = s.shared ? timers.idleShared : timers.idlePrivate;
|
|
244
|
+
return cookie(SESSION_COOKIE, value, Math.min(idle, s.iat + timers.absolute - now()));
|
|
245
|
+
}
|
|
246
|
+
async function issue(s) {
|
|
247
|
+
const value = await seal(SESSION_COOKIE, s);
|
|
248
|
+
if (value.length > COOKIE_LIMIT) {
|
|
249
|
+
// A Supabase access token that has grown (large user_metadata) would be dropped by the browser without a word.
|
|
250
|
+
console.error(`[workbench-supabase] session cookie is ${value.length} bytes, over ${COOKIE_LIMIT}: keep user_metadata small`);
|
|
251
|
+
return { ok: false, code: "session_too_large", status: 500 };
|
|
252
|
+
}
|
|
253
|
+
return sessionCookie(s, value);
|
|
254
|
+
}
|
|
255
|
+
function tokens(json) {
|
|
256
|
+
const t = (json ?? {});
|
|
257
|
+
if (typeof t.access_token !== "string" || typeof t.refresh_token !== "string")
|
|
258
|
+
return null;
|
|
259
|
+
const id = t.user?.id;
|
|
260
|
+
const email = t.user?.email;
|
|
261
|
+
if (typeof id !== "string" || typeof email !== "string")
|
|
262
|
+
return null;
|
|
263
|
+
const exp = typeof t.expires_at === "number" ? t.expires_at : now() + (typeof t.expires_in === "number" ? t.expires_in : 3600);
|
|
264
|
+
return { at: t.access_token, rt: t.refresh_token, exp, user: { id, email } };
|
|
265
|
+
}
|
|
266
|
+
const clearSession = () => cookie(SESSION_COOKIE, "", 0);
|
|
267
|
+
const clearSignIn = () => cookie(SIGN_IN_COOKIE, "", 0);
|
|
268
|
+
return {
|
|
269
|
+
timers,
|
|
270
|
+
clearSession,
|
|
271
|
+
clearSignIn,
|
|
272
|
+
async sendCode(email, opts = {}) {
|
|
273
|
+
const e = normaliseEmail(email);
|
|
274
|
+
if (!e)
|
|
275
|
+
return { ok: false, code: "invalid_email", status: 400 };
|
|
276
|
+
const r = await auth("otp", { email: e, create_user: opts.createUser ?? true });
|
|
277
|
+
if (!r.ok) {
|
|
278
|
+
// With sign-ups off, Supabase refuses an unknown address: answer as if a code went out (§8.1).
|
|
279
|
+
// A real account's resend cooldown would then tell the two apart, so it reads the same too.
|
|
280
|
+
// (Timing still differs with sign-ups off, since only a real account gets an email: §8.2's residual.)
|
|
281
|
+
const hidden = ["otp_disabled", "signup_disabled", "user_not_found", ...(opts.createUser === false ? ["over_email_send_rate_limit"] : [])];
|
|
282
|
+
if (hidden.includes(r.code)) {
|
|
283
|
+
console.info(JSON.stringify({ event: "sign_in_code_not_sent", reason: r.code }));
|
|
284
|
+
return { ok: true };
|
|
285
|
+
}
|
|
286
|
+
return r;
|
|
287
|
+
}
|
|
288
|
+
return { ok: true };
|
|
289
|
+
},
|
|
290
|
+
async verifyCode(email, code, { keep }) {
|
|
291
|
+
const e = normaliseEmail(email);
|
|
292
|
+
if (!e)
|
|
293
|
+
return { ok: false, code: "invalid_email", status: 400 };
|
|
294
|
+
const c = normaliseCode(code);
|
|
295
|
+
if (!c)
|
|
296
|
+
return { ok: false, code: "invalid_code", status: 400 };
|
|
297
|
+
const r = await auth("verify", { type: "email", email: e, token: c });
|
|
298
|
+
if (!r.ok)
|
|
299
|
+
return r;
|
|
300
|
+
const t = tokens(r.json);
|
|
301
|
+
if (!t)
|
|
302
|
+
return { ok: false, code: "auth_unavailable", status: 502 };
|
|
303
|
+
const at = now();
|
|
304
|
+
const s = { v: 1, uid: t.user.id, email: t.user.email, at: t.at, rt: t.rt, exp: t.exp, iat: at, seen: at, auth: at, shared: !keep };
|
|
305
|
+
const set = await issue(s);
|
|
306
|
+
if (typeof set !== "string")
|
|
307
|
+
return set;
|
|
308
|
+
return { ok: true, user: t.user, cookie: set };
|
|
309
|
+
},
|
|
310
|
+
async read(request) {
|
|
311
|
+
const raw = readCookie(request, SESSION_COOKIE);
|
|
312
|
+
if (raw === null || raw === "")
|
|
313
|
+
return { state: "none" };
|
|
314
|
+
const s = await open(SESSION_COOKIE, raw);
|
|
315
|
+
if (!s || s.v !== 1 || typeof s.at !== "string" || typeof s.iat !== "number" || typeof s.seen !== "number") {
|
|
316
|
+
return { state: "ended", reason: "invalid", cookie: clearSession() };
|
|
317
|
+
}
|
|
318
|
+
const t = now();
|
|
319
|
+
if (t - s.iat >= timers.absolute)
|
|
320
|
+
return { state: "ended", reason: "absolute", cookie: clearSession() };
|
|
321
|
+
if (t - s.seen >= (s.shared ? timers.idleShared : timers.idlePrivate))
|
|
322
|
+
return { state: "ended", reason: "idle", cookie: clearSession() };
|
|
323
|
+
let changed = false;
|
|
324
|
+
if (s.exp - t <= REFRESH_EARLY) {
|
|
325
|
+
const r = await auth("token?grant_type=refresh_token", { refresh_token: s.rt });
|
|
326
|
+
if (!r.ok) {
|
|
327
|
+
if (r.code === "auth_unavailable" || r.status >= 500 || r.status === 429) {
|
|
328
|
+
// Supabase is down or busy: a token that is still valid keeps working; an expired one can't.
|
|
329
|
+
if (s.exp > t)
|
|
330
|
+
return { state: "signed_in", user: { id: s.uid, email: s.email }, accessToken: s.at, fresh: t - s.auth < timers.fresh };
|
|
331
|
+
return { state: "unavailable" };
|
|
332
|
+
}
|
|
333
|
+
return { state: "ended", reason: "refused", cookie: clearSession() };
|
|
334
|
+
}
|
|
335
|
+
const n = tokens(r.json);
|
|
336
|
+
if (!n)
|
|
337
|
+
return { state: "unavailable" };
|
|
338
|
+
// A refresh never moves iat or auth: that is what keeps the cap a cap (§10.1).
|
|
339
|
+
Object.assign(s, { at: n.at, rt: n.rt, exp: n.exp, uid: n.user.id, email: n.user.email });
|
|
340
|
+
changed = true;
|
|
341
|
+
}
|
|
342
|
+
if (t - s.seen >= SEEN_STEP) {
|
|
343
|
+
s.seen = t;
|
|
344
|
+
changed = true;
|
|
345
|
+
}
|
|
346
|
+
const base = { state: "signed_in", user: { id: s.uid, email: s.email }, accessToken: s.at, fresh: t - s.auth < timers.fresh };
|
|
347
|
+
if (!changed)
|
|
348
|
+
return base;
|
|
349
|
+
const set = await issue(s);
|
|
350
|
+
if (typeof set !== "string")
|
|
351
|
+
return { state: "ended", reason: "invalid", cookie: clearSession() };
|
|
352
|
+
return { ...base, cookie: set };
|
|
353
|
+
},
|
|
354
|
+
async signOut(request) {
|
|
355
|
+
const clear = clearSession();
|
|
356
|
+
try {
|
|
357
|
+
const s = await open(SESSION_COOKIE, readCookie(request, SESSION_COOKIE));
|
|
358
|
+
// Ends this session only (scope=local), never the person's other devices.
|
|
359
|
+
if (s && typeof s.at === "string" && typeof s.rt === "string") {
|
|
360
|
+
let token = s.at;
|
|
361
|
+
// An expired access token can't log out, and the refresh token would stay live: swap it first.
|
|
362
|
+
if (typeof s.exp !== "number" || s.exp - now() <= REFRESH_EARLY) {
|
|
363
|
+
const r = await auth("token?grant_type=refresh_token", { refresh_token: s.rt });
|
|
364
|
+
const n = r.ok ? tokens(r.json) : null;
|
|
365
|
+
if (!n)
|
|
366
|
+
return clear; // refused: the refresh token is already dead
|
|
367
|
+
token = n.at;
|
|
368
|
+
}
|
|
369
|
+
await auth("logout?scope=local", {}, token);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
// Nothing may block somebody signing out (§10.3): the cookie is cleared whatever Supabase says.
|
|
374
|
+
}
|
|
375
|
+
return clear;
|
|
376
|
+
},
|
|
377
|
+
async rememberSignIn(email) {
|
|
378
|
+
const e = normaliseEmail(email);
|
|
379
|
+
if (!e)
|
|
380
|
+
return clearSignIn();
|
|
381
|
+
const p = { v: 1, email: e, sent: now() };
|
|
382
|
+
return cookie(SIGN_IN_COOKIE, await seal(SIGN_IN_COOKIE, p), PENDING_SECONDS);
|
|
383
|
+
},
|
|
384
|
+
async pendingSignIn(request) {
|
|
385
|
+
const p = await open(SIGN_IN_COOKIE, readCookie(request, SIGN_IN_COOKIE));
|
|
386
|
+
if (!p || p.v !== 1 || typeof p.email !== "string" || typeof p.sent !== "number")
|
|
387
|
+
return null;
|
|
388
|
+
if (now() - p.sent > PENDING_SECONDS)
|
|
389
|
+
return null;
|
|
390
|
+
return { email: p.email, sent: p.sent };
|
|
391
|
+
},
|
|
392
|
+
async limitKey(kind, value) {
|
|
393
|
+
if (kind === "email")
|
|
394
|
+
value = value.trim().toLowerCase();
|
|
395
|
+
const mac = await crypto.subtle.sign("HMAC", (await keys()).hash, enc.encode(`${kind}\u0000${value}`));
|
|
396
|
+
return `${kind}:${b64url(new Uint8Array(mac)).slice(0, 32)}`;
|
|
397
|
+
},
|
|
398
|
+
};
|
|
399
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mccaskie-labs/workbench-supabase",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Supabase clients for Workbench apps (browser, server, the caller's own token, and a service client that logs its reason), plus check:migrations and check:prod-schema.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|