@ultimat3/auth 23.0.0 → 25.0.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.
@@ -28,7 +28,12 @@ export interface ServiceIdentity {
28
28
  /** The four `ActorKind`s, as the four things that can be holding a credential. */
29
29
  export type AuthIdentity =
30
30
  | { readonly kind: 'user'; readonly user: AuthUser; readonly session: AuthSession }
31
- | { readonly kind: 'agent'; readonly apiKey: AuthApiKeyRecord }
31
+ | {
32
+ readonly kind: 'agent';
33
+ readonly apiKey: AuthApiKeyRecord;
34
+ /** What the key's owner may do — `null` for a key no user owns. See `actorFromApiKey`. */
35
+ readonly ownerGrants: readonly string[] | null;
36
+ }
32
37
  | { readonly kind: 'service'; readonly service: ServiceIdentity }
33
38
  | { readonly kind: 'anonymous' };
34
39
 
@@ -64,20 +69,58 @@ export function actorFromUser(user: AuthUser, session: AuthSession): PolicyActor
64
69
  );
65
70
  }
66
71
 
72
+ /** `*` and `<resource>:*` — the two spellings a role uses to be granted everything. */
73
+ export const isWildcardScope = (scope: string): boolean => scope === '*' || scope.endsWith(':*');
74
+
75
+ // `@ultimat3/policy`'s own reading of a grant's resource, mirrored: same tier, so not imported.
76
+ const resourceOf = (grant: string): string => grant.split(':')[0] ?? grant;
77
+
78
+ /** Whether one of the owner's grants reaches a named permission. A grant MAY be a wildcard. */
79
+ const covers = (grant: string, wanted: string): boolean =>
80
+ grant === '*' ||
81
+ grant === wanted ||
82
+ (grant.endsWith(':*') && resourceOf(grant) === resourceOf(wanted));
83
+
84
+ /**
85
+ * What a key may carry of what it names. Two cuts, and both only ever remove:
86
+ *
87
+ * - a wildcard scope is dropped. `issueApiKey` refuses to mint one, and a row written before that
88
+ * refusal (or by hand) must not keep the reach the refusal exists to deny;
89
+ * - a key a user OWNS keeps only the scopes that owner could exercise themselves. A key was
90
+ * minted with whatever list its issuer typed, so without this a member could hold a key
91
+ * reaching further than their own account — and it kept that reach after a demotion.
92
+ *
93
+ * `ownerGrants` is `null` for a key no user owns: there is nobody to be narrower than.
94
+ */
95
+ export function apiKeyScopes(
96
+ scopes: readonly string[],
97
+ ownerGrants: readonly string[] | null,
98
+ ): readonly string[] {
99
+ return scopes.filter(
100
+ (scope) =>
101
+ !isWildcardScope(scope) &&
102
+ (ownerGrants === null || ownerGrants.some((grant) => covers(grant, scope))),
103
+ );
104
+ }
105
+
67
106
  /**
68
- * An MCP/LLM caller. The actor's scopes are **exactly** the key's scopes — never the owning
69
- * user's roles, never a default set. An agent that can do more than its key says is the whole
70
- * failure mode this bridge exists to prevent.
107
+ * An MCP/LLM caller. The actor's scopes are the key's scopes cut by `apiKeyScopes` — never the
108
+ * owning user's roles, never a default set. An agent that can do more than its key says, or more
109
+ * than its owner can, is the whole failure mode this bridge exists to prevent.
71
110
  */
72
- export function actorFromApiKey(key: AuthApiKeyRecord): PolicyActor {
111
+ export function actorFromApiKey(
112
+ key: AuthApiKeyRecord,
113
+ ownerGrants: readonly string[] | null,
114
+ ): PolicyActor {
115
+ const scopes = apiKeyScopes(key.scopes, ownerGrants);
73
116
  return withPermissions(
74
117
  agentActor({
75
118
  id: key.id,
76
119
  orgId: key.orgId ?? undefined,
77
120
  roles: [],
78
- scopes: key.scopes,
121
+ scopes,
79
122
  }),
80
- key.scopes,
123
+ scopes,
81
124
  );
82
125
  }
83
126
 
@@ -100,7 +143,7 @@ export function resolveActor(identity: AuthIdentity): PolicyActor {
100
143
  case 'user':
101
144
  return actorFromUser(identity.user, identity.session);
102
145
  case 'agent':
103
- return actorFromApiKey(identity.apiKey);
146
+ return actorFromApiKey(identity.apiKey, identity.ownerGrants);
104
147
  case 'service':
105
148
  return actorFromService(identity.service);
106
149
  case 'anonymous':
package/src/privileges.ts CHANGED
@@ -1,14 +1,16 @@
1
- // Single responsibility: changing what a user may do, and rotating the credential that was issued
2
- // under the old answer. `packages/auth/CLAUDE.md` has listed "rotate the session id on any
3
- // privilege change (`rotateSession`)" as a non-negotiable since 1.0, and
4
- // `SessionPolicy.rotateOnPrivilegeChange` has defaulted `true` — while `rotateSession` had no
5
- // caller anywhere outside its own test and the flag was read by nothing. Per axiom 3 the rule did
6
- // not exist. This is the caller that makes it exist.
1
+ // Single responsibility: changing what a user may do, and ending the credentials issued under the
2
+ // old answer — the caller's session is rotated on any privilege change, and a changed password
3
+ // ends every other session the user holds.
7
4
 
8
5
  import type { AuthUser, UserPatch } from './adapter';
9
6
  import type { Auth } from './auth';
10
7
  import { authWriteFailed } from './errors';
11
- import { type IssuedSession, rotateSession, sessionCookie } from './session';
8
+ import {
9
+ type IssuedSession,
10
+ remainingMaxAgeSeconds,
11
+ rotateSession,
12
+ sessionCookie,
13
+ } from './session';
12
14
 
13
15
  /**
14
16
  * The fields whose change invalidates whatever the current cookie was issued under. `roles`,
@@ -28,6 +30,8 @@ export interface UpdatePrivilegesResult {
28
30
  readonly cookie?: string | undefined;
29
31
  /** Which of `PRIVILEGE_FIELDS` the patch actually named. Empty means nothing rotated. */
30
32
  readonly changed: readonly string[];
33
+ /** Sessions a `passwordHash` change ended. Zero for every other patch. */
34
+ readonly sessionsRevoked: number;
31
35
  }
32
36
 
33
37
  const changedFields = (patch: UserPatch): readonly string[] =>
@@ -44,6 +48,12 @@ const changedFields = (patch: UserPatch): readonly string[] =>
44
48
  * there is no cookie of theirs to rotate. That case wants `revokeUserSessions()` instead, and the
45
49
  * two are deliberately separate calls — silently killing an operator's own session mid-request is
46
50
  * not something a role edit should decide on its own.
51
+ *
52
+ * A `passwordHash` change is the exception, and it decides for itself: the password is what every
53
+ * OTHER session of this user was issued under, so they all end here. The caller's own session —
54
+ * passed in, and this user's — is the one that survives (rotated, or kept when rotation is off);
55
+ * with no session of the user's own, as in a reset or an admin's change, none survives. A changed
56
+ * password that left a lifted cookie working would not have changed anything for whoever holds it.
47
57
  */
48
58
  export async function updatePrivileges(
49
59
  auth: Auth,
@@ -51,24 +61,42 @@ export async function updatePrivileges(
51
61
  patch: UserPatch,
52
62
  session?: IssuedSession['session'] | undefined,
53
63
  ): Promise<UpdatePrivilegesResult> {
64
+ const changed = changedFields(patch);
65
+ const own = session !== undefined && session.userId === userId ? session : undefined;
66
+ const credentialChanged = changed.includes('passwordHash');
54
67
  const user = await auth.adapter.updateUser(userId, patch);
55
68
  if (user === null) throw authWriteFailed('updateUser', 'x_users');
69
+ if (changed.length === 0) return { user, changed, sessionsRevoked: 0 };
56
70
 
57
- const changed = changedFields(patch);
58
- if (
59
- changed.length === 0 ||
60
- session === undefined ||
61
- session.userId !== userId ||
62
- !auth.sessions.policy.rotateOnPrivilegeChange
63
- ) {
64
- return { user, changed };
71
+ // Every OTHER session ends BEFORE the rotation, keyed on the caller's current id (which the
72
+ // rotation deletes anyway). Rotation refuses a session that is already gone, and a password
73
+ // change must not leave the lifted cookies it was made to kill alive because of that refusal.
74
+ let sessionsRevoked = 0;
75
+ if (credentialChanged) {
76
+ sessionsRevoked =
77
+ own === undefined
78
+ ? await auth.adapter.deleteSessionsForUser(userId)
79
+ : await auth.adapter.deleteOtherSessions(userId, own.id);
65
80
  }
66
-
67
- const issued = await rotateSession(auth.sessions, session);
81
+ const issued =
82
+ own !== undefined && auth.sessions.policy.rotateOnPrivilegeChange
83
+ ? await rotateSession(auth.sessions, own)
84
+ : undefined;
85
+ // And once more AFTER it: a login that landed between the early sweep and the rotation was minted
86
+ // under the old password too. The early sweep stays — it is what still runs when rotation
87
+ // refuses a session that is already gone — and this one keeps only the replacement.
88
+ if (credentialChanged && own !== undefined) {
89
+ sessionsRevoked += await auth.adapter.deleteOtherSessions(userId, issued?.session.id ?? own.id);
90
+ }
91
+ if (issued === undefined) return { user, changed, sessionsRevoked };
68
92
  return {
69
93
  user,
70
94
  changed,
95
+ sessionsRevoked,
71
96
  session: issued,
72
- cookie: sessionCookie(issued.token, auth.sessions.policy),
97
+ // The rotated session keeps the old ceiling, so the cookie's `Max-Age` counts down to it too.
98
+ cookie: sessionCookie(issued.token, auth.sessions.policy, {
99
+ maxAgeSeconds: remainingMaxAgeSeconds(issued.session, auth.sessions.clock.now()),
100
+ }),
73
101
  };
74
102
  }
@@ -1,104 +1,104 @@
1
- // The shared credential limiter: two Postgres tables, so N replicas count one spray once and a
1
+ // The shared credential limiter: one Postgres table, so N replicas count one spray once and a
2
2
  // lockout one pod established is visible to the rest. Without it `rateLimit.scope: 'shared'` is a
3
3
  // declaration nothing can satisfy, while `x new` scaffolds `replicas: 2` — which is
4
4
  // `maxAttempts × 2` guesses per account.
5
- import type { Clock } from '@ultimat3/core';
6
- import { accountLocked } from './errors';
7
- import type { AuthLimiter, AuthRateLimitPolicy } from './rate-limit';
8
-
9
- /**
10
- * The one thing this limiter needs from the DB layer, declared structurally rather than imported.
11
- * `@ultimat3/action`'s `idempotency-postgres.ts` and `@ultimat3/http`'s rate-limit store declare
12
- * the same shape for the same reason: the connection belongs to the HOST, not to any of them, so
13
- * a limiter takes the pool the boot already opened instead of opening a second one against a URL
14
- * that was resolved once.
15
- *
16
- * **`Bun.sql` does not satisfy it** — `Bun.sql.query` is `undefined`; it is a tagged template
17
- * whose positional form is `unsafe`. `@ultimat3/db`'s `DbClient.query({ text, values })` does,
18
- * wrapped in one line, and so does a transaction handle.
19
- */
20
- export interface PgExecutor {
21
- query<R>(sql: string, params: readonly unknown[]): Promise<readonly R[]>;
22
- }
5
+ import type { Clock, PgExecutor } from '@ultimat3/core';
6
+ import { accountLocked, authWriteFailed } from './errors';
7
+ import type { AuthLimiter, AuthRateLimitPolicy, AuthReservation } from './rate-limit';
23
8
 
24
9
  /**
25
10
  * Applied by the boot, never by an app migration — the rule `SQL_IDEMPOTENCY_TABLE` follows, so
26
11
  * `x dev`, the container's `web` role and `ROLE=migrate` all install it.
27
12
  *
28
- * A row per FAILURE rather than a counter per key, because the window this package enforces is a
29
- * SLIDING one: a counter plus a window end is a fixed window, which admits `maxAttempts` at the
30
- * end of one window and `maxAttempts` again at the start of the next — twice the declared
31
- * allowance, under the same declared numbers, on the credential path.
13
+ * ONE row per key, holding every attempt still inside the window. The window this package enforces
14
+ * is a SLIDING one, so the instants are kept (a counter plus a window end is a fixed window, which
15
+ * admits `maxAttempts` at the end of one and `maxAttempts` again at the start of the next). They
16
+ * are kept IN the row because the row is what a concurrent take waits on: a count over a second
17
+ * table reads its statement's snapshot, and that snapshot predates the wait.
18
+ *
19
+ * The two `add column` lines are the upgrade of a table made before the reservation, on the rule
20
+ * `x_users` follows: additive, defaulted, idempotent, applied at every boot. The name and the
21
+ * first two columns are unchanged on purpose — a replica still running the previous release reads
22
+ * and writes this table as it always did, so a lockout holds on both sides of a rolling deploy.
32
23
  */
33
24
  export const SQL_AUTH_LIMIT_TABLES = `
34
- create table if not exists x_auth_failures (
35
- key text not null,
36
- at_ms bigint not null
37
- );
38
-
39
- create index if not exists x_auth_failures_key_idx on x_auth_failures (key, at_ms);
40
-
41
25
  create table if not exists x_auth_lockouts (
42
- key text primary key,
43
- locked_until_ms bigint not null
26
+ key text primary key,
27
+ locked_until_ms bigint not null,
28
+ attempts_ms bigint[] not null default '{}',
29
+ admitted boolean not null default true
44
30
  );
45
31
 
32
+ alter table x_auth_lockouts add column if not exists attempts_ms bigint[] not null default '{}';
33
+ alter table x_auth_lockouts add column if not exists admitted boolean not null default true;
34
+
46
35
  create index if not exists x_auth_lockouts_until_idx on x_auth_lockouts (locked_until_ms);
47
36
  `;
48
37
 
49
38
  /**
50
- * The per-key serializer, spelled from documented functions only: `md5` and a hex bit-string cast,
51
- * never `hashtext`, which is an internal with no compatibility promise.
39
+ * The attempts still inside the window, read off the row as it is AFTER its lock is taken. It is
40
+ * repeated in the statement below rather than computed once in a CTE, and the repetition is
41
+ * required: only a direct `x_auth_lockouts.<column>` reference inside `on conflict do update` sees
42
+ * what a concurrent take just committed. `$2` nowMs, `$3` windowMs.
52
43
  */
53
- export const SQL_AUTH_KEY_LOCK = "pg_advisory_xact_lock(('x' || md5($1))::bit(64)::bigint)";
44
+ const LIVE =
45
+ 'array(select at_ms from unnest(x_auth_lockouts.attempts_ms) as at_ms ' +
46
+ 'where at_ms > $2::bigint - $3::bigint)';
47
+
48
+ const LOCKED = 'x_auth_lockouts.locked_until_ms > $2::bigint';
54
49
 
55
50
  /**
56
- * `$1` key, `$2` nowMs — and a **transaction-scoped advisory lock on the key**, taken in the same
57
- * statement, before the row lands.
58
- *
59
- * `PgExecutor` accepts a transaction handle (see its doc comment), and the insert and the count
60
- * below are two statements. Without this lock two OUTER transactions recording a failure for one
61
- * account each counted only what had COMMITTED plus their own row: with `maxAttempts: 3` and one
62
- * failure already committed, both read two, neither locked, and both committed — three failures
63
- * and an open account. The lock makes the second transaction wait at the insert until the first
64
- * commits, so its count is taken against a snapshot that already holds the first's row.
51
+ * `$1` key, `$2` nowMs, `$3` windowMs, `$4` lockoutMs, `$5` maxAttempts — the reservation, in ONE
52
+ * statement: decide and count together, so N concurrent takes are admitted one at a time.
65
53
  *
66
- * Autocommit is unaffected: the lock is taken and released inside the statement's own implicit
67
- * transaction, which is one extra no-op per failure and no extra round trip. The guarantee is
68
- * READ COMMITTED, which is what `withTransaction` opens with no `isolation:` — a caller that opts
69
- * into `'repeatable read'` or `'serializable'` pins the count's snapshot at transaction start, and
70
- * no lock can make a statement see a commit its own snapshot precedes.
54
+ * A locked key is left untouched and answers `admitted = false`. Otherwise the attempt joins the
55
+ * window and, if it fills it, starts the lockout. `admitted` is persisted because `returning`
56
+ * cannot see the row as it was: the verdict has to be a column the statement wrote.
71
57
  *
72
- * `recordFailure` always locks account → ip → org (`auth.ts`), one fixed order, so two concurrent
73
- * sign-ins cannot take two of these locks in opposite orders and deadlock.
58
+ * A live lockout is never rewritten, so nothing arriving during one can shorten it. Inside a
59
+ * caller's own transaction the row lock is held to that commit, which is what serialises two outer
60
+ * transactions; `auth.ts` takes account → ip → org in that fixed order, so two sign-ins cannot
61
+ * hold two of these rows in opposite orders.
74
62
  */
75
- export const SQL_AUTH_RECORD_FAILURE = `
76
- with locked as (select ${SQL_AUTH_KEY_LOCK})
77
- insert into x_auth_failures (key, at_ms)
78
- select $1, $2::bigint from locked
63
+ export const SQL_AUTH_TAKE = `
64
+ insert into x_auth_lockouts (key, attempts_ms, locked_until_ms, admitted)
65
+ values (
66
+ $1,
67
+ array[$2::bigint],
68
+ case when 1 >= $5::bigint then $2::bigint + $4::bigint else 0 end,
69
+ true
70
+ )
71
+ on conflict (key) do update
72
+ set attempts_ms = case when ${LOCKED} then x_auth_lockouts.attempts_ms
73
+ else ${LIVE} || $2::bigint end,
74
+ locked_until_ms = case
75
+ when ${LOCKED} then x_auth_lockouts.locked_until_ms
76
+ when cardinality(${LIVE}) + 1 >= $5::bigint then $2::bigint + $4::bigint
77
+ else 0 end,
78
+ admitted = not (${LOCKED})
79
+ returning admitted, locked_until_ms
79
80
  `;
80
81
 
82
+ /** The window with the first entry equal to `$2` taken out — one reservation, never every equal one. */
83
+ const WITHOUT =
84
+ '(attempts_ms[:array_position(attempts_ms, $2::bigint) - 1] || ' +
85
+ 'attempts_ms[array_position(attempts_ms, $2::bigint) + 1:])';
86
+
81
87
  /**
82
- * `$1` key, `$2` nowMs, `$3` windowMs, `$4` lockoutMs, `$5` maxAttempts.
88
+ * `$1` key, `$2` the reservation's `atMs`, `$3` nowMs, `$4` windowMs, `$5` maxAttempts.
83
89
  *
84
- * A SECOND statement, deliberately, and not a CTE beside the insert above: every CTE in one
85
- * statement reads that statement's snapshot, so a `count(*)` sharing it cannot see the failure
86
- * being inserted beside it and the lock would fire one attempt late. Run afterwards, the count
87
- * sees this caller's own row and every other replica's that has committed — and, because the
88
- * insert holds `SQL_AUTH_KEY_LOCK`, every row a concurrent transaction on this key committed too.
89
- *
90
- * `greatest` on conflict EXTENDS a live lockout and never shortens one — a spray arriving during
91
- * a lockout must not be able to reset it to a nearer deadline.
90
+ * Gives ONE attempt back, and lifts the lockout when what is left no longer fills the window — a
91
+ * lockout the refunded attempt itself completed. A reservation that is no longer in the row (the
92
+ * key was cleared, or purged) matches nothing and changes nothing.
92
93
  */
93
- export const SQL_AUTH_LOCK = `
94
- insert into x_auth_lockouts (key, locked_until_ms)
95
- select $1, $2::bigint + $4::bigint
96
- from x_auth_failures
97
- where key = $1 and at_ms > $2::bigint - $3::bigint
98
- having count(*) >= $5::bigint
99
- on conflict (key) do update
100
- set locked_until_ms = greatest(x_auth_lockouts.locked_until_ms, excluded.locked_until_ms)
101
- returning locked_until_ms
94
+ export const SQL_AUTH_REFUND = `
95
+ update x_auth_lockouts
96
+ set attempts_ms = ${WITHOUT},
97
+ locked_until_ms = case
98
+ when (select count(*) from unnest(${WITHOUT}) as at_ms
99
+ where at_ms > $3::bigint - $4::bigint) < $5::bigint then 0
100
+ else locked_until_ms end
101
+ where key = $1 and array_position(attempts_ms, $2::bigint) is not null
102
102
  `;
103
103
 
104
104
  /** `$2` is the caller's clock: an expired lockout answers exactly as a missing one. */
@@ -106,30 +106,27 @@ export const SQL_AUTH_LOCKED_UNTIL = `
106
106
  select locked_until_ms from x_auth_lockouts where key = $1 and locked_until_ms > $2::bigint
107
107
  `;
108
108
 
109
- /** A success clears the window AND the lockout: one round trip, because both must go together. */
110
- export const SQL_AUTH_FORGET_KEY = `
111
- with cleared as (delete from x_auth_failures where key = $1 returning key)
112
- delete from x_auth_lockouts where key = $1
113
- `;
109
+ /** A success clears the window AND the lockout: they are one row, so they go together. */
110
+ export const SQL_AUTH_FORGET_KEY = 'delete from x_auth_lockouts where key = $1';
114
111
 
115
- export const SQL_AUTH_RESET = `
116
- with cleared as (delete from x_auth_failures returning key)
117
- delete from x_auth_lockouts
118
- `;
112
+ export const SQL_AUTH_RESET = 'delete from x_auth_lockouts';
119
113
 
120
114
  /**
121
- * `$1` nowMs, `$2` windowMs — the CALLER's clock, never `now()`. Every instant in these tables is
115
+ * `$1` nowMs, `$2` windowMs — the CALLER's clock, never `now()`. Every instant in this table is
122
116
  * written from the caller's clock, so a purge measuring against the SERVER's would delete rows by
123
- * the offset between the two: failures that are still inside the window, and lockouts that are
117
+ * the offset between the two: attempts that are still inside the window, and lockouts that are
124
118
  * still live. The second one hands a sprayer its account back.
125
119
  */
126
120
  export const SQL_AUTH_PURGE = `
127
- with dropped_failures as (
128
- delete from x_auth_failures where at_ms <= $1::bigint - $2::bigint returning key
129
- ), dropped_lockouts as (
130
- delete from x_auth_lockouts where locked_until_ms <= $1::bigint returning key
121
+ with dropped as (
122
+ delete from x_auth_lockouts
123
+ where locked_until_ms <= $1::bigint
124
+ and not exists (
125
+ select 1 from unnest(attempts_ms) as at_ms where at_ms > $1::bigint - $2::bigint
126
+ )
127
+ returning key
131
128
  )
132
- select (select count(*) from dropped_failures) + (select count(*) from dropped_lockouts) as removed
129
+ select count(*) as removed from dropped
133
130
  `;
134
131
 
135
132
  export interface PostgresAuthLimiterOptions {
@@ -146,9 +143,9 @@ export interface PostgresAuthLimiterOptions {
146
143
 
147
144
  export interface PostgresAuthLimiter extends AuthLimiter {
148
145
  /**
149
- * Drop every failure past the window and every expired lockout, and answer how many rows went.
150
- * Neither table bounds itself — `ipKey` mints one key per source address, so a spray from an
151
- * IPv6 /64 is a row per attempt — and Postgres forgets nothing on its own. An app runs this
146
+ * Drop every key whose window has emptied and whose lockout has expired, and answer how many
147
+ * rows went. The table does not bound itself — `ipKey` mints one key per source address, so a
148
+ * spray from an IPv6 /64 is a row per attempt — and Postgres forgets nothing on its own. An app runs this
152
149
  * from a `task`; a row this deletes answers exactly as a missing one, so it changes no decision.
153
150
  */
154
151
  purgeExpired(): Promise<number>;
@@ -159,6 +156,10 @@ interface LockRow {
159
156
  readonly locked_until_ms: number | string;
160
157
  }
161
158
 
159
+ interface TakeRow extends LockRow {
160
+ readonly admitted: boolean;
161
+ }
162
+
162
163
  /**
163
164
  * **Install it at `defineAuth`, beside the declaration it satisfies.** Two limiters, one table:
164
165
  * the keys are prefixed (`account:`, `ip:`, `org:`) and every limit travels as a parameter, so
@@ -192,22 +193,33 @@ export function postgresAuthLimiter(options: PostgresAuthLimiterOptions): Postgr
192
193
  // bound this limiter does not enforce is the thing `assertAuthLimiterPolicy` exists to catch.
193
194
  policy: { ...policy, maxKeys: undefined, scope: 'shared' },
194
195
 
195
- async assertAllowed(key): Promise<void> {
196
- const until = await lockedUntilMs(key);
197
- if (until === null) return;
198
- throw accountLocked(key, Math.ceil((until - nowMs()) / 1000));
199
- },
200
-
201
- async recordFailure(key): Promise<void> {
196
+ async reserve(key): Promise<AuthReservation> {
202
197
  const at = nowMs();
203
- await exec.query(SQL_AUTH_RECORD_FAILURE, [key, at]);
204
- await exec.query(SQL_AUTH_LOCK, [
198
+ const rows = await exec.query<TakeRow>(SQL_AUTH_TAKE, [
205
199
  key,
206
200
  at,
207
201
  policy.windowMs,
208
202
  policy.lockoutMs,
209
203
  policy.maxAttempts,
210
204
  ]);
205
+ const row = rows[0];
206
+ // No row is no verdict, and no verdict is not an admission: an executor that answered
207
+ // nothing must not be read as "allowed" on the credential path.
208
+ if (row === undefined) throw authWriteFailed('reserve', 'x_auth_lockouts');
209
+ if (row.admitted !== true) {
210
+ throw accountLocked(key, Math.ceil((Number(row.locked_until_ms) - at) / 1000));
211
+ }
212
+ return { key, atMs: at };
213
+ },
214
+
215
+ async refund(reservation): Promise<void> {
216
+ await exec.query(SQL_AUTH_REFUND, [
217
+ reservation.key,
218
+ reservation.atMs,
219
+ nowMs(),
220
+ policy.windowMs,
221
+ policy.maxAttempts,
222
+ ]);
211
223
  },
212
224
 
213
225
  async recordSuccess(key): Promise<void> {
package/src/rate-limit.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  accountLocked,
11
11
  authLimiterNotShared,
12
12
  authLimiterPolicyMismatch,
13
+ ORG_KEY_PREFIX,
13
14
  } from './errors';
14
15
  import { assertFiniteAuthCount } from './policy-numbers';
15
16
 
@@ -65,11 +66,22 @@ export const DEFAULT_AUTH_RATE_LIMIT: AuthRateLimitPolicy = Object.freeze({
65
66
  scope: 'process',
66
67
  });
67
68
 
69
+ /** One counted attempt, as `reserve` took it — what `refund` gives back, and nothing else. */
70
+ export interface AuthReservation {
71
+ readonly key: string;
72
+ /** The limiter's own clock at the take. It identifies the entry inside the window. */
73
+ readonly atMs: number;
74
+ }
75
+
68
76
  /**
69
77
  * Async on every member, so a shared implementation can exist at all: a lockout that holds across
70
78
  * replicas is a network round trip, and a synchronous signature has no way to wait for one. The
71
79
  * in-memory limiter below answers immediately — the cost is one already-settled promise per call,
72
80
  * on a path that is about to run a KDF.
81
+ *
82
+ * The allowance is RESERVED, never checked: `reserve` counts the attempt in the same step that
83
+ * decides whether it may run, so N concurrent guesses spend N of the allowance before any of them
84
+ * reaches the KDF. A check followed later by a record let every one of them pass the check.
73
85
  */
74
86
  export interface AuthLimiter {
75
87
  /**
@@ -79,23 +91,32 @@ export interface AuthLimiter {
79
91
  * operator reads and nothing enforces.
80
92
  */
81
93
  readonly policy: AuthRateLimitPolicy;
82
- /** Rejects with `X_ACCOUNT_LOCKED` if the key is inside a lockout. Call before any KDF work. */
83
- assertAllowed(key: string): Promise<void>;
84
- recordFailure(key: string): Promise<void>;
94
+ /**
95
+ * Count one attempt against the key, atomically, BEFORE any KDF work. Rejects with
96
+ * `X_ACCOUNT_LOCKED` — counting nothing — while the key is inside a lockout; the attempt that
97
+ * fills the window is admitted and starts the lockout. A failed attempt needs no further call:
98
+ * the reservation IS the recorded failure.
99
+ */
100
+ reserve(key: string): Promise<AuthReservation>;
101
+ /**
102
+ * Give one reservation back — the attempt succeeded, or never reached a verdict. Removes that
103
+ * one entry and lifts a lockout the window no longer justifies. Never clears the key.
104
+ */
105
+ refund(reservation: AuthReservation): Promise<void>;
85
106
  /** A success clears the window: a legitimate user is not punished for a typo yesterday. */
86
107
  recordSuccess(key: string): Promise<void>;
87
108
  lockedUntil(key: string): Promise<Date | null>;
88
109
  reset(): Promise<void>;
89
110
  /**
90
111
  * Drop every expired row this limiter is keeping, and answer how many went. Optional because a
91
- * limiter that bounds itself has nothing to sweep — `createAuthLimiter` evicts on write, so it
112
+ * limiter that bounds itself has nothing to sweep — `memoryAuthLimiter` evicts on write, so it
92
113
  * omits this and `purgeAuthLimits()` skips it. A limiter backed by a table declares it, and
93
114
  * that is what makes the framework's purge job able to reach one without knowing it is Postgres.
94
115
  */
95
116
  purgeExpired?(): Promise<number>;
96
117
  }
97
118
 
98
- /** What `createAuthLimiter` returns: the interface, plus the bound it keeps, observable. */
119
+ /** What `memoryAuthLimiter` returns: the interface, plus the bound it keeps, observable. */
99
120
  export interface MemoryAuthLimiter extends AuthLimiter {
100
121
  readonly size: number;
101
122
  }
@@ -113,7 +134,7 @@ export const ipKey = (ip: string): string => `ip:${ip}`;
113
134
  * in `@ultimat3/http` and `@ultimat3/jobs`: three limiters that spell one tenant three ways cannot
114
135
  * be read together during an incident.
115
136
  */
116
- export const orgKey = (orgId: string): string => `org:${orgId}`;
137
+ export const orgKey = (orgId: string): string => `${ORG_KEY_PREFIX}${orgId}`;
117
138
 
118
139
  /**
119
140
  * The policy the tenant limiter enforces — the same window and lockout, a wider allowance. One
@@ -153,7 +174,7 @@ const SWEEP_EVERY_MS = 60_000;
153
174
  * forgotten anyway go first: a locked account is the last key to go, so filling the table is not
154
175
  * a way to buy back attempts against one.
155
176
  */
156
- export function createAuthLimiter(
177
+ export function memoryAuthLimiter(
157
178
  clock: Clock,
158
179
  policy: AuthRateLimitPolicy = DEFAULT_AUTH_RATE_LIMIT,
159
180
  ): MemoryAuthLimiter {
@@ -204,25 +225,38 @@ export function createAuthLimiter(
204
225
  get size() {
205
226
  return buckets.size;
206
227
  },
207
- async assertAllowed(key) {
208
- const bucket = buckets.get(key);
209
- if (bucket === undefined) return;
210
- const nowMs = clock.now().getTime();
211
- if (bucket.lockedUntilMs <= nowMs) return;
212
- throw accountLocked(key, Math.ceil((bucket.lockedUntilMs - nowMs) / 1000));
213
- },
214
- async recordFailure(key) {
228
+ // No `await` anywhere in the body: the check and the count are one synchronous step, which
229
+ // is the whole guarantee — concurrent callers are admitted one at a time, in order.
230
+ async reserve(key) {
215
231
  const nowMs = clock.now().getTime();
232
+ const existing = buckets.get(key);
233
+ if (existing !== undefined && existing.lockedUntilMs > nowMs) {
234
+ throw accountLocked(key, Math.ceil((existing.lockedUntilMs - nowMs) / 1000));
235
+ }
216
236
  const bucket = bucketFor(key);
217
237
  bucket.failures = bucket.failures.filter((at) => at > nowMs - policy.windowMs);
218
238
  bucket.failures.push(nowMs);
219
239
  if (bucket.failures.length >= policy.maxAttempts) {
220
240
  bucket.lockedUntilMs = nowMs + policy.lockoutMs;
221
241
  }
222
- // The newest failure leaves the window last, so that is the earliest this entry is free —
242
+ // The newest attempt leaves the window last, so that is the earliest this entry is free —
223
243
  // unless a lockout outlives it. Recorded here because this is the only growth path.
224
244
  bucket.forgetAtMs = Math.max(bucket.lockedUntilMs, nowMs + policy.windowMs);
225
245
  if (buckets.size > maxKeys || nowMs - lastSweepMs >= SWEEP_EVERY_MS) sweep(nowMs);
246
+ return { key, atMs: nowMs };
247
+ },
248
+ async refund(reservation) {
249
+ const bucket = buckets.get(reservation.key);
250
+ if (bucket === undefined) return;
251
+ const index = bucket.failures.indexOf(reservation.atMs);
252
+ if (index < 0) return;
253
+ bucket.failures.splice(index, 1);
254
+ const nowMs = clock.now().getTime();
255
+ const live = bucket.failures.filter((at) => at > nowMs - policy.windowMs).length;
256
+ // A lockout this reservation completed is not one the remaining failures justify.
257
+ if (live < policy.maxAttempts) bucket.lockedUntilMs = 0;
258
+ if (bucket.failures.length === 0 && bucket.lockedUntilMs === 0)
259
+ buckets.delete(reservation.key);
226
260
  },
227
261
  async recordSuccess(key) {
228
262
  buckets.delete(key);