@everystack/auth 0.4.9 → 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,17 +4,26 @@ 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.9] - 2026-10-14
7
+ ## [0.4.10] - 2026-10-30
8
+
9
+ ### Added
10
+
11
+ - accountTrigger ends an account's sessions in its soft-delete transaction
8
12
 
9
13
  ### Fixed
10
14
 
11
- - break the model and auth import cycles; gate new ones
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
12
17
 
13
- ### Changed
18
+ ### Documentation
14
19
 
15
- - share refresh token extraction
20
+ - accountTrigger in the lifecycle guide, the auth README, upgrading and the MCP resource
21
+
22
+ ## [0.4.9] - 2026-10-14
16
23
 
17
- ## [Unreleased]
24
+ ### Fixed
25
+
26
+ - break the model and auth import cycles; gate new ones
18
27
 
19
28
  ### Changed
20
29
 
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.9",
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.21"
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.35",
141
- "@everystack/api": "0.4.3"
140
+ "@everystack/api": "0.4.13",
141
+ "@everystack/server": "0.4.40"
142
142
  },
143
143
  "scripts": {
144
144
  "test": "jest",
@@ -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/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';