@everystack/auth 0.4.8 → 0.4.10

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 CHANGED
@@ -4,6 +4,31 @@ All notable changes to `@everystack/auth` are documented here.
4
4
 
5
5
  Format based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [0.4.10] - 2026-10-30
8
+
9
+ ### Added
10
+
11
+ - accountTrigger ends an account's sessions in its soft-delete transaction
12
+
13
+ ### Fixed
14
+
15
+ - accountTrigger keeps an admin's earlier disable
16
+ - review nits: cap accountTrigger's column at 39 characters; docs state the column the trigger trusts
17
+
18
+ ### Documentation
19
+
20
+ - accountTrigger in the lifecycle guide, the auth README, upgrading and the MCP resource
21
+
22
+ ## [0.4.9] - 2026-10-14
23
+
24
+ ### Fixed
25
+
26
+ - break the model and auth import cycles; gate new ones
27
+
28
+ ### Changed
29
+
30
+ - share refresh token extraction
31
+
7
32
  ## [0.4.8] - 2026-09-26
8
33
 
9
34
  ### Fixed
package/README.md CHANGED
@@ -185,6 +185,16 @@ Edge verification trusts the signature; it cannot consult a revocation list with
185
185
  - Keep **refresh tokens long-lived in DB** with revocation
186
186
  - Sign-out / password change → revoke refresh tokens, access tokens expire on their own
187
187
 
188
+ ## Account deletion
189
+
190
+ When an app deletes an account by soft-deleting a lifecycle row (a profile whose `id` is the
191
+ `auth.users` id), put `accountTrigger({ userId: 'id' })` in that model's `triggers`. In the same
192
+ transaction it marks the `auth.users` row deleted and removes the user's refresh and one-time tokens,
193
+ so sign-in and refresh stop at once. An access token already issued lives out its TTL (default 3600s).
194
+ Restoring the row clears the mark only: the user signs in again, and no old session comes back.
195
+ Identities and MFA factors are kept. It replaces an app's own token `DELETE` in its delete-account
196
+ code. See [docs/lifecycle.md](../../docs/lifecycle.md), "When the deleted row is an account".
197
+
188
198
  ## SQL Twin
189
199
 
190
200
  `@everystack/auth/jwt/sql` ships pgcrypto-based functions that produce byte-identical tokens to the JS path:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/auth",
3
- "version": "0.4.8",
3
+ "version": "0.4.10",
4
4
  "description": "JWT authentication handlers, password hashing, and React auth context",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -108,7 +108,7 @@
108
108
  },
109
109
  "dependencies": {
110
110
  "bcryptjs": "3.0.3",
111
- "@everystack/model": "0.4.20"
111
+ "@everystack/model": "0.4.25"
112
112
  },
113
113
  "peerDependencies": {
114
114
  "@everystack/api": ">=0.1.0",
@@ -137,8 +137,8 @@
137
137
  "postgres": "3.4.9",
138
138
  "ts-jest": "29.4.9",
139
139
  "typescript": "5.9.3",
140
- "@everystack/server": "0.4.33",
141
- "@everystack/api": "0.4.2"
140
+ "@everystack/api": "0.4.13",
141
+ "@everystack/server": "0.4.40"
142
142
  },
143
143
  "scripts": {
144
144
  "test": "jest",
package/src/account.ts ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Account-state and password-column helpers shared by index.ts and postgres.ts.
3
+ * A leaf module: index.ts imports postgres.ts, so these cannot live in index.ts.
4
+ */
5
+
6
+ /**
7
+ * Why a user row may not hold a session, or null when it may. Reads row fields, not table
8
+ * columns, so an app schema without `bannedUntil`/`deletedAt` simply never trips it.
9
+ * Mirrors the SQL side: auth.sign_in filters deleted_at, auth.validate_refresh_token
10
+ * rejects deleted_at (P0004) and an unexpired banned_until (P0007).
11
+ */
12
+ export function accountBlock(user: any): 'deleted' | 'banned' | null {
13
+ if (user.deletedAt) return 'deleted';
14
+ if (user.bannedUntil && new Date(user.bannedUntil).getTime() > Date.now()) return 'banned';
15
+ return null;
16
+ }
17
+
18
+ /** The refusal for a sign-in path that already proved identity (OAuth), or null to proceed.
19
+ * A missing row is treated as a deleted account. */
20
+ export function accountBlockedResponse(user: any): Response | null {
21
+ const block = user ? accountBlock(user) : 'deleted';
22
+ if (block === 'banned') return Response.json({ message: 'Account suspended' }, { status: 403 });
23
+ if (block === 'deleted') return Response.json({ message: 'Account disabled' }, { status: 401 });
24
+ return null;
25
+ }
26
+
27
+ /** Signout must always clear cookies. A failed server-side revoke is logged, never thrown:
28
+ * only the error's code or message is logged, never the token. */
29
+ export function warnRevokeFailed(err: unknown): void {
30
+ const e = err as { code?: string; message?: string } | undefined;
31
+ // eslint-disable-next-line no-console
32
+ console.warn('[everystack/auth] signout: refresh token revocation failed:', e?.code ?? e?.message ?? 'unknown error');
33
+ }
34
+
35
+ /**
36
+ * Build insert values with the correct password column name.
37
+ * Checks which column exists on the Drizzle table definition.
38
+ */
39
+ export function withPassword(users: any, values: Record<string, any>, hash: string | null): Record<string, any> {
40
+ if ('encryptedPassword' in users) {
41
+ return { ...values, encryptedPassword: hash };
42
+ }
43
+ return { ...values, passwordHash: hash };
44
+ }
package/src/index.ts CHANGED
@@ -38,7 +38,9 @@ import type { OAuthProvider, OAuthUserInfo } from './oauth';
38
38
  export type { OAuthProvider, OAuthUserInfo } from './oauth';
39
39
  export { createAppleProvider, createGoogleProvider } from './oauth';
40
40
  import { createPostgresAuthHandlers } from './postgres';
41
- import { getRefreshFromCookies, readPresentedRefreshToken } from './cookies';
41
+ import { accountBlock, accountBlockedResponse, warnRevokeFailed, withPassword } from './account';
42
+ export { accountBlock, accountBlockedResponse, warnRevokeFailed, withPassword } from './account';
43
+ import { readPresentedRefreshToken } from './cookies';
42
44
  import { authResponse, signoutResponse } from './response';
43
45
  import type { CookieConfig } from './response';
44
46
  import type { EnrichClaimsFn } from './plugin';
@@ -63,45 +65,6 @@ export function getPasswordHash(user: any): string | null {
63
65
  return user.encryptedPassword ?? user.passwordHash ?? null;
64
66
  }
65
67
 
66
- /**
67
- * Build insert values with the correct password column name.
68
- * Checks which column exists on the Drizzle table definition.
69
- */
70
- /**
71
- * Why a user row may not hold a session, or null when it may. Reads row fields, not table
72
- * columns, so an app schema without `bannedUntil`/`deletedAt` simply never trips it.
73
- * Mirrors the SQL side: auth.sign_in filters deleted_at, auth.validate_refresh_token
74
- * rejects deleted_at (P0004) and an unexpired banned_until (P0007).
75
- */
76
- export function accountBlock(user: any): 'deleted' | 'banned' | null {
77
- if (user.deletedAt) return 'deleted';
78
- if (user.bannedUntil && new Date(user.bannedUntil).getTime() > Date.now()) return 'banned';
79
- return null;
80
- }
81
-
82
- /** The refusal for a sign-in path that already proved identity (OAuth), or null to proceed.
83
- * A missing row is treated as a deleted account. */
84
- export function accountBlockedResponse(user: any): Response | null {
85
- const block = user ? accountBlock(user) : 'deleted';
86
- if (block === 'banned') return Response.json({ message: 'Account suspended' }, { status: 403 });
87
- if (block === 'deleted') return Response.json({ message: 'Account disabled' }, { status: 401 });
88
- return null;
89
- }
90
-
91
- /** Signout must always clear cookies. A failed server-side revoke is logged, never thrown:
92
- * only the error's code or message is logged, never the token. */
93
- export function warnRevokeFailed(err: unknown): void {
94
- const e = err as { code?: string; message?: string } | undefined;
95
- // eslint-disable-next-line no-console
96
- console.warn('[everystack/auth] signout: refresh token revocation failed:', e?.code ?? e?.message ?? 'unknown error');
97
- }
98
-
99
- export function withPassword(users: any, values: Record<string, any>, hash: string | null): Record<string, any> {
100
- if ('encryptedPassword' in users) {
101
- return { ...values, encryptedPassword: hash };
102
- }
103
- return { ...values, passwordHash: hash };
104
- }
105
68
 
106
69
  export interface TokenPayload {
107
70
  sub: string;
@@ -656,17 +619,7 @@ export function createAuthHandlers(
656
619
  const csrfError = csrfCheck(request, options?.cookies);
657
620
  if (csrfError) return csrfError;
658
621
 
659
- // Read refresh token from JSON body (mobile) or cookie (web SSR)
660
- let refreshToken: string | undefined;
661
- try {
662
- const body = await request.clone().json();
663
- refreshToken = body?.refreshToken;
664
- } catch {
665
- // Empty body is fine — fall through to cookie
666
- }
667
- if (!refreshToken) {
668
- refreshToken = getRefreshFromCookies(request.headers.get('cookie')) ?? undefined;
669
- }
622
+ const refreshToken = await readPresentedRefreshToken(request);
670
623
  if (!refreshToken) {
671
624
  return Response.json(
672
625
  { message: 'refreshToken is required' },
@@ -39,7 +39,8 @@
39
39
  * hand-patch a body here.
40
40
  */
41
41
 
42
- import { defineFunction, defineTrigger, arg, can, sql } from '@everystack/model';
42
+ import { defineFunction, defineTrigger, trigger, arg, can, sql } from '@everystack/model';
43
+ import type { FunctionDescriptor, RawSql, TriggerSpec } from '@everystack/model';
43
44
  import { DEFAULT_ACCESS_TOKEN_TTL_SEC, resolveAccessTokenTtl } from '../sql';
44
45
  import { User } from './user';
45
46
 
@@ -880,6 +881,94 @@ export const revokeRefreshTokensOnUserBlockTrigger = defineTrigger('users_revoke
880
881
  execute: revokeRefreshTokensOnUserBlockFn,
881
882
  });
882
883
 
884
+ /** A column name the account trigger may read: lower snake case, at most 39 characters, so
885
+ * `end_account_sessions_by_<col>` stays within PostgreSQL's 63-character identifier limit. */
886
+ const ACCOUNT_USER_ID = /^[a-z_][a-z0-9_]{0,38}$/;
887
+
888
+ /** The function name for one user-id column. `id` keeps the plain name; the auth module declares it. */
889
+ function accountSessionsName(userId: string): string {
890
+ return userId === 'id' ? 'auth.end_account_sessions' : `auth.end_account_sessions_by_${userId}`;
891
+ }
892
+
893
+ function accountSessionsBody(userId: string): RawSql {
894
+ // The column is baked into the body: TriggerSpec carries no trigger arguments. It is validated
895
+ // as a lower snake case identifier before it gets here, so it needs no quoting.
896
+ return sql`
897
+ DECLARE
898
+ v_user_id pg_catalog.uuid;
899
+ BEGIN
900
+ IF TG_OP <> 'UPDATE' OR TG_WHEN <> 'AFTER' OR TG_LEVEL <> 'ROW' THEN
901
+ RAISE EXCEPTION 'end_account_sessions runs only as an AFTER UPDATE row trigger' USING ERRCODE = '42501';
902
+ END IF;
903
+ v_user_id := NEW.${userId}::pg_catalog.uuid;
904
+ IF OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL THEN
905
+ UPDATE auth.users SET deleted_at = NEW.deleted_at WHERE id = v_user_id AND deleted_at IS NULL;
906
+ PERFORM auth.revoke_refresh_tokens(v_user_id);
907
+ DELETE FROM auth.one_time_tokens WHERE user_id = v_user_id;
908
+ ELSIF OLD.deleted_at IS NOT NULL AND NEW.deleted_at IS NULL THEN
909
+ -- A restore brings the account back, never its sessions: the user signs in again.
910
+ -- It clears only the stamp this account set; an earlier disable stays.
911
+ UPDATE auth.users SET deleted_at = NULL WHERE id = v_user_id AND deleted_at = OLD.deleted_at;
912
+ END IF;
913
+ RETURN NULL;
914
+ END`;
915
+ }
916
+
917
+ const accountSessionsFunctions = new Map<string, FunctionDescriptor>();
918
+
919
+ /**
920
+ * The trigger function that carries an account's soft delete to auth.users, for an account table
921
+ * whose `userId` column holds the auth.users id. Deleting the account soft-deletes the user and
922
+ * removes its refresh and one-time tokens in the same transaction; restoring it clears the user's
923
+ * deleted_at and nothing else. Any other update writes nothing.
924
+ * It stamps only a live user and clears only its own stamp, so an earlier admin disable survives both.
925
+ *
926
+ * It runs inside the lifecycle functions' pinned search_path, so it pins `pg_catalog, pg_temp` and
927
+ * qualifies every name. No role but its owner may EXECUTE it (the compiler revokes PUBLIC).
928
+ *
929
+ * The auth module declares the `id` one. Declare any other in your own module's `derived`.
930
+ */
931
+ export function accountSessionsFunction(userId: string): FunctionDescriptor {
932
+ if (!ACCOUNT_USER_ID.test(userId)) {
933
+ throw new Error(
934
+ `accountTrigger: userId '${userId}' is not a column name — pass the account table's column ` +
935
+ '(lower snake case, at most 39 characters), for example { userId: \'id\' } or { userId: \'user_id\' }.',
936
+ );
937
+ }
938
+ let fn = accountSessionsFunctions.get(userId);
939
+ if (!fn) {
940
+ fn = defineFunction(accountSessionsName(userId), {
941
+ returns: 'trigger',
942
+ language: 'plpgsql',
943
+ security: 'definer',
944
+ searchPath: ['pg_catalog', 'pg_temp'],
945
+ body: accountSessionsBody(userId),
946
+ });
947
+ accountSessionsFunctions.set(userId, fn);
948
+ }
949
+ return fn;
950
+ }
951
+
952
+ /** The `id` account function, which the auth module declares. */
953
+ export const endAccountSessionsFn = accountSessionsFunction('id');
954
+
955
+ /**
956
+ * Ends an account's sessions when the account is soft-deleted. Add it to the lifecycle model that
957
+ * IS the account (`lifecycle: true`), whose `userId` column holds the auth.users id:
958
+ * `triggers: [accountTrigger({ userId: 'id' })]`.
959
+ *
960
+ * AFTER UPDATE OF deleted_at, so it fires on a direct delete, a cascaded one and a restore alike.
961
+ */
962
+ export function accountTrigger(opts: { userId: string }): TriggerSpec {
963
+ return trigger('end_account_sessions', {
964
+ timing: 'after',
965
+ events: ['update'],
966
+ of: ['deleted_at'],
967
+ forEach: 'row',
968
+ execute: accountSessionsFunction(opts.userId),
969
+ });
970
+ }
971
+
883
972
  export const verifyJwtFn = defineFunction('auth.verify_jwt', {
884
973
  args: [arg('p_token', 'text')],
885
974
  returns: 'jsonb',
@@ -1010,7 +1099,7 @@ export const coreFunctions = [hashPasswordFn, verifyPasswordFn, signJwtFn, verif
1010
1099
  /** The refresh-token family (RFC 6819 rotation). Always on. */
1011
1100
  export const refreshTokenFunctions = [
1012
1101
  issueRefreshTokenFn, validateRefreshTokenFn, revokeRefreshTokensFn, signOutFn, cleanupRefreshTokensFn,
1013
- revokeRefreshTokensOnUserBlockFn,
1102
+ revokeRefreshTokensOnUserBlockFn, endAccountSessionsFn,
1014
1103
  ];
1015
1104
 
1016
1105
  /** `auth.uid()` / `auth.role()` / `auth.jwt()` — called FROM RLS policies, so granted to the
package/src/postgres.ts CHANGED
@@ -18,9 +18,9 @@ import { createJwt } from './jwt';
18
18
  import { hmacNode } from './jwt/hmac-node';
19
19
  import type { RefreshTokenOptions } from './refresh';
20
20
  import type { AuthHandlers, AuthOptions, TokenPayload } from './index';
21
- import { accountBlock, accountBlockedResponse, warnRevokeFailed, withPassword } from './index';
21
+ import { accountBlock, accountBlockedResponse, warnRevokeFailed, withPassword } from './account';
22
22
  import type { OAuthProvider } from './oauth';
23
- import { getRefreshFromCookies, readPresentedRefreshToken } from './cookies';
23
+ import { readPresentedRefreshToken } from './cookies';
24
24
  import { authResponse, signoutResponse } from './response';
25
25
  import type { CookieConfig } from './response';
26
26
  import { validateCsrfOrigin } from './csrf';
@@ -436,17 +436,7 @@ export function createPostgresAuthHandlers(
436
436
  const csrfError = csrfCheck(request, options?.cookies);
437
437
  if (csrfError) return csrfError;
438
438
 
439
- // Read refresh token from JSON body (mobile) or cookie (web SSR)
440
- let refreshToken: string | undefined;
441
- try {
442
- const body = await request.clone().json();
443
- refreshToken = body?.refreshToken;
444
- } catch {
445
- // Empty body is fine — fall through to cookie
446
- }
447
- if (!refreshToken) {
448
- refreshToken = getRefreshFromCookies(request.headers.get('cookie')) ?? undefined;
449
- }
439
+ const refreshToken = await readPresentedRefreshToken(request);
450
440
  if (!refreshToken) {
451
441
  return Response.json(
452
442
  { message: 'refreshToken is required' },
package/src/sql.ts CHANGED
@@ -385,6 +385,35 @@ WHEN (
385
385
  )
386
386
  EXECUTE FUNCTION auth.revoke_refresh_tokens_on_user_block();`;
387
387
 
388
+ const END_ACCOUNT_SESSIONS = `\
389
+ CREATE OR REPLACE FUNCTION auth.end_account_sessions()
390
+ RETURNS TRIGGER
391
+ LANGUAGE plpgsql
392
+ SECURITY DEFINER
393
+ SET search_path = pg_catalog, pg_temp
394
+ AS $$
395
+ DECLARE
396
+ v_user_id pg_catalog.uuid;
397
+ BEGIN
398
+ IF TG_OP <> 'UPDATE' OR TG_WHEN <> 'AFTER' OR TG_LEVEL <> 'ROW' THEN
399
+ RAISE EXCEPTION 'end_account_sessions runs only as an AFTER UPDATE row trigger' USING ERRCODE = '42501';
400
+ END IF;
401
+ v_user_id := NEW.id::pg_catalog.uuid;
402
+ IF OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL THEN
403
+ UPDATE auth.users SET deleted_at = NEW.deleted_at WHERE id = v_user_id AND deleted_at IS NULL;
404
+ PERFORM auth.revoke_refresh_tokens(v_user_id);
405
+ DELETE FROM auth.one_time_tokens WHERE user_id = v_user_id;
406
+ ELSIF OLD.deleted_at IS NOT NULL AND NEW.deleted_at IS NULL THEN
407
+ -- A restore brings the account back, never its sessions: the user signs in again.
408
+ -- It clears only the stamp this account set; an earlier disable stays.
409
+ UPDATE auth.users SET deleted_at = NULL WHERE id = v_user_id AND deleted_at = OLD.deleted_at;
410
+ END IF;
411
+ RETURN NULL;
412
+ END
413
+ $$;
414
+
415
+ REVOKE EXECUTE ON FUNCTION auth.end_account_sessions FROM PUBLIC;`;
416
+
388
417
  const REVOKE_REFRESH_TOKENS = `\
389
418
  CREATE OR REPLACE FUNCTION auth.revoke_refresh_tokens(
390
419
  p_user_id UUID,
@@ -1120,7 +1149,7 @@ export function generateCoreSQL(): string {
1120
1149
  export function generateRefreshTokenSQL(): string {
1121
1150
  return [
1122
1151
  ISSUE_REFRESH_TOKEN, '', VALIDATE_REFRESH_TOKEN, '', REVOKE_REFRESH_TOKENS_ON_USER_BLOCK, '',
1123
- REVOKE_REFRESH_TOKENS, '', SIGN_OUT, '', CLEANUP_REFRESH_TOKENS,
1152
+ REVOKE_REFRESH_TOKENS, '', SIGN_OUT, '', CLEANUP_REFRESH_TOKENS, '', END_ACCOUNT_SESSIONS,
1124
1153
  ]
1125
1154
  .join('\n')
1126
1155
  .trimEnd() + '\n';