@everystack/auth 0.4.9 → 0.4.11
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 +20 -5
- package/README.md +10 -0
- package/package.json +4 -4
- package/src/models/functions.ts +91 -2
- package/src/models/user.ts +0 -13
- package/src/sql.ts +30 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,17 +4,32 @@ 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.
|
|
7
|
+
## [0.4.11] - 2026-11-04
|
|
8
|
+
|
|
9
|
+
### BREAKING CHANGES
|
|
10
|
+
|
|
11
|
+
- softDelete is removed; lifecycle is the one soft delete
|
|
12
|
+
|
|
13
|
+
## [0.4.10] - 2026-10-30
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- accountTrigger ends an account's sessions in its soft-delete transaction
|
|
8
18
|
|
|
9
19
|
### Fixed
|
|
10
20
|
|
|
11
|
-
-
|
|
21
|
+
- accountTrigger keeps an admin's earlier disable
|
|
22
|
+
- review nits: cap accountTrigger's column at 39 characters; docs state the column the trigger trusts
|
|
12
23
|
|
|
13
|
-
###
|
|
24
|
+
### Documentation
|
|
14
25
|
|
|
15
|
-
-
|
|
26
|
+
- accountTrigger in the lifecycle guide, the auth README, upgrading and the MCP resource
|
|
27
|
+
|
|
28
|
+
## [0.4.9] - 2026-10-14
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
16
31
|
|
|
17
|
-
|
|
32
|
+
- break the model and auth import cycles; gate new ones
|
|
18
33
|
|
|
19
34
|
### Changed
|
|
20
35
|
|
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.
|
|
3
|
+
"version": "0.4.11",
|
|
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.
|
|
111
|
+
"@everystack/model": "0.4.27"
|
|
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.
|
|
141
|
-
"@everystack/api": "0.4.
|
|
140
|
+
"@everystack/server": "0.4.43",
|
|
141
|
+
"@everystack/api": "0.4.14"
|
|
142
142
|
},
|
|
143
143
|
"scripts": {
|
|
144
144
|
"test": "jest",
|
package/src/models/functions.ts
CHANGED
|
@@ -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/models/user.ts
CHANGED
|
@@ -51,19 +51,6 @@ export const User = defineModel('users', {
|
|
|
51
51
|
updatedAt: field.timestamptz().defaultNow().notNull(),
|
|
52
52
|
},
|
|
53
53
|
constraints: [index('instanceId')],
|
|
54
|
-
// `deletedAt` on a user means a soft delete: the row stays, because auth rows are referenced
|
|
55
|
-
// by half the schema and destroying one breaks those references rather than cleaning them up.
|
|
56
|
-
// Every reader in this package already assumes that shape — the sign-in path and the auth
|
|
57
|
-
// SQL functions gate on `deleted_at IS NULL` rather than on the row's absence.
|
|
58
|
-
//
|
|
59
|
-
// Declared rather than defaulted because the admin `manage` ability below grants DELETE
|
|
60
|
-
// ("all actions"), which is the axis that matters here. It is INERT in both directions today,
|
|
61
|
-
// which is what makes it safe to add to a published package: no public read, so the compiled
|
|
62
|
-
// contract is unchanged (no fingerprint movement for any consumer); and `private: true` keeps
|
|
63
|
-
// this model out of the generic data API, so none of the handler's soft-delete behaviour
|
|
64
|
-
// applies either. It states the intent that the readers already depend on, and it stops the
|
|
65
|
-
// model from silently acquiring the opposite one.
|
|
66
|
-
softDelete: true,
|
|
67
54
|
abilities: [
|
|
68
55
|
can('manage', { role: 'admin' }),
|
|
69
56
|
// Self-read, column-scoped: id/email/role of your own row, never the hash.
|
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';
|