@aooth/user 0.1.2 → 0.1.4

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.
@@ -2,6 +2,14 @@
2
2
  interface UserCredentials {
3
3
  id: string;
4
4
  username: string;
5
+ /**
6
+ * Server-managed optimistic-concurrency counter. Bumped by `UserStore.update`
7
+ * on every successful write; checked against `UserStoreUpdate.expectedVersion`
8
+ * for CAS. Callers MUST NOT write it directly — atscript-db rejects direct
9
+ * writes with `DbError("VERSION_COLUMN_WRITE")`. Optional in TS so pre-OCC
10
+ * fixtures keep compiling; the store seeds `0` on insert.
11
+ */
12
+ version?: number;
5
13
  password: PasswordData;
6
14
  account: AccountData;
7
15
  mfa: MfaData;
@@ -49,8 +57,8 @@ interface AccountData {
49
57
  * True while the user record exists from an admin-issued invite but the
50
58
  * invitee has not yet accepted (set password + activate). Used by
51
59
  * `InviteWorkflow` to gate the accept tail, reject duplicate invites, and
52
- * power `auth.reInvite` / `auth.cancelInvite`. Absent / `false` once the
53
- * invite has been accepted.
60
+ * power `auth/invite/resend` / `auth/invite/cancel`. Absent / `false` once
61
+ * the invite has been accepted.
54
62
  */
55
63
  pendingInvitation?: boolean;
56
64
  }
@@ -69,6 +77,12 @@ interface MfaMethod {
69
77
  confirmed: boolean;
70
78
  /** The method's value: email address, phone number, or TOTP secret */
71
79
  value: string;
80
+ /**
81
+ * Last HOTP counter accepted for this method (TOTP only). Server-managed
82
+ * replay guard — `verifyMfa` rejects any code whose matched counter is
83
+ * `<= lastUsedWindow`. Never written from user-facing input.
84
+ */
85
+ lastUsedWindow?: number;
72
86
  }
73
87
  interface UserServiceConfig {
74
88
  password?: PasswordConfig;
@@ -107,7 +121,23 @@ interface LockoutConfig {
107
121
  duration?: number;
108
122
  }
109
123
  interface PasswordPolicyDef {
110
- rule: string | PasswordPolicyEvalFn;
124
+ /**
125
+ * Backend evaluator. Function only — executed directly with no sandbox.
126
+ * String rules were removed: `@prostojs/ftring`'s sandbox does NOT block
127
+ * prototype-chain escapes (`constructor.constructor("return process")()`,
128
+ * `__proto__.x = ...`), so accepting strings was an RCE vector. Authors
129
+ * use `definePasswordPolicy({ rule, args })` to get both this fn AND the
130
+ * serialized form for free.
131
+ */
132
+ rule: PasswordPolicyEvalFn;
133
+ /**
134
+ * Pre-baked function-literal text shipped to clients for cross-tier
135
+ * validation via `getTransferablePolicies()`. Authored as
136
+ * `(v) => (${ruleSource})(v, ${args.map(JSON.stringify).join(', ')})` by
137
+ * `definePasswordPolicy`. Absent → the policy is backend-only (frontend
138
+ * skips it; server-side check remains authoritative).
139
+ */
140
+ serialized?: string;
111
141
  description?: string;
112
142
  errorMessage?: string;
113
143
  }
@@ -127,8 +157,15 @@ interface UserStoreUpdate {
127
157
  set?: DeepPartial<UserCredentials>;
128
158
  /** Dot-paths to atomically increment: e.g. {'account.failedLoginAttempts': 1} */
129
159
  inc?: Record<string, number>;
160
+ /**
161
+ * Optimistic concurrency control: when supplied, the store applies the
162
+ * update iff the row's current `version` equals this value. On mismatch
163
+ * the store returns `false` (same shape as "not found") and does NOT
164
+ * mutate. Service callers treat both states as "stale read, retry".
165
+ */
166
+ expectedVersion?: number;
130
167
  }
131
- type UserAuthErrorType = "NOT_FOUND" | "ALREADY_EXISTS" | "LOCKED" | "INACTIVE" | "INVALID_CREDENTIALS" | "POLICY_VIOLATION" | "PASSWORDS_MISMATCH" | "PASSWORD_IN_HISTORY" | "MFA_REQUIRED" | "MFA_INVALID" | "MFA_NOT_CONFIGURED";
168
+ type UserAuthErrorType = "NOT_FOUND" | "ALREADY_EXISTS" | "LOCKED" | "INACTIVE" | "INVALID_CREDENTIALS" | "POLICY_VIOLATION" | "PASSWORDS_MISMATCH" | "PASSWORD_IN_HISTORY" | "MFA_REQUIRED" | "MFA_INVALID" | "MFA_NOT_CONFIGURED" | "CAS_EXHAUSTED";
132
169
  interface LoginResult<T extends object = object> {
133
170
  user: UserCredentials & T;
134
171
  /** Whether MFA verification is required before granting full access */
@@ -171,6 +208,15 @@ interface TotpConfig {
171
208
  }
172
209
  //#endregion
173
210
  //#region src/store/user-store.d.ts
211
+ interface WithCasOptions {
212
+ /**
213
+ * Total attempts (1 initial + retries). Default `2` = one retry. Each
214
+ * attempt re-reads the row so the mutator runs against fresh state — that
215
+ * is the whole point of retry under OCC. Bump for high-contention writers
216
+ * (bulk admin scripts); leave at default for normal per-user request flow.
217
+ */
218
+ maxAttempts?: number;
219
+ }
174
220
  declare abstract class UserStore<T extends object = object> {
175
221
  abstract exists(username: string): Promise<boolean>;
176
222
  abstract findByUsername(username: string): Promise<(UserCredentials & T) | null>;
@@ -179,9 +225,22 @@ declare abstract class UserStore<T extends object = object> {
179
225
  /**
180
226
  * Hard-delete the row. Returns `true` when a row was removed, `false` when
181
227
  * the username was not found. Used by `UserService.deleteUser` (and in turn
182
- * by the invite workflow's `auth.cancelInvite` step).
228
+ * by the invite workflow's `auth/invite/cancel` step).
183
229
  */
184
230
  abstract delete(username: string): Promise<boolean>;
231
+ /**
232
+ * Run a read-modify-write cycle under optimistic concurrency. Each attempt
233
+ * fetches the current row, calls `mutator` with it, and applies the returned
234
+ * patch under CAS (`expectedVersion = current.version`). On CAS miss the
235
+ * cycle repeats up to `opts.maxAttempts`. The mutator MAY return `null` to
236
+ * exit early without writing — used for "race-loser detects nothing left to
237
+ * do" paths (e.g. the backup code was already consumed by the winner).
238
+ *
239
+ * Throws `UserAuthError("NOT_FOUND")` when no row matches `username`, or
240
+ * `UserAuthError("CAS_EXHAUSTED")` when retries are saturated. Errors
241
+ * thrown from inside `mutator` propagate immediately without retry.
242
+ */
243
+ abstract withCas(username: string, mutator: (current: UserCredentials & T) => UserStoreUpdate | null, opts?: WithCasOptions): Promise<void>;
185
244
  }
186
245
  //#endregion
187
- export { UserStoreUpdate as C, UserServiceConfig as S, TotpConfig as _, LockoutConfig as a, UserAuthErrorType as b, MfaMethod as c, PasswordData as d, PasswordPolicyContext as f, PolicyCheckResult as g, PasswordPolicyInstance as h, LockStatus as i, MfaMethodInfo as l, PasswordPolicyEvalFn as m, AccountData as n, LoginResult as o, PasswordPolicyDef as p, DeepPartial as r, MfaData as s, UserStore as t, PasswordConfig as u, TransferablePolicy as v, UserCredentials as x, TrustedDeviceRecord as y };
246
+ export { UserServiceConfig as C, UserCredentials as S, PolicyCheckResult as _, LockStatus as a, TrustedDeviceRecord as b, MfaData as c, PasswordConfig as d, PasswordData as f, PasswordPolicyInstance as g, PasswordPolicyEvalFn as h, DeepPartial as i, MfaMethod as l, PasswordPolicyDef as m, WithCasOptions as n, LockoutConfig as o, PasswordPolicyContext as p, AccountData as r, LoginResult as s, UserStore as t, MfaMethodInfo as u, TotpConfig as v, UserStoreUpdate as w, UserAuthErrorType as x, TransferablePolicy as y };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aooth/user",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "User credential primitives for aoothjs",
5
5
  "keywords": [
6
6
  "aoothjs",
@@ -45,18 +45,15 @@
45
45
  "publishConfig": {
46
46
  "access": "public"
47
47
  },
48
- "dependencies": {
49
- "@prostojs/ftring": "^0.0.3"
50
- },
51
48
  "devDependencies": {
52
- "@atscript/core": "^0.1.56",
53
- "@atscript/db": "^0.1.80",
54
- "@atscript/db-sql-tools": "^0.1.80",
55
- "@atscript/db-sqlite": "^0.1.80",
56
- "@atscript/typescript": "^0.1.56",
49
+ "@atscript/core": "^0.1.61",
50
+ "@atscript/db": "^0.1.87",
51
+ "@atscript/db-sql-tools": "^0.1.87",
52
+ "@atscript/db-sqlite": "^0.1.87",
53
+ "@atscript/typescript": "^0.1.61",
57
54
  "@types/better-sqlite3": "^7.6.13",
58
55
  "better-sqlite3": "^12.6.2",
59
- "unplugin-atscript": "^0.1.56"
56
+ "unplugin-atscript": "^0.1.61"
60
57
  },
61
58
  "peerDependencies": {
62
59
  "@atscript/db": ">=0.1.79"
@@ -70,6 +67,8 @@
70
67
  "build": "vp pack",
71
68
  "dev": "vp pack --watch",
72
69
  "test": "vp test",
73
- "check": "vp check"
70
+ "check": "vp check",
71
+ "gen:atscript": "asc",
72
+ "postinstall": "asc"
74
73
  }
75
74
  }
@@ -2,6 +2,9 @@ export interface AoothUserCredentials {
2
2
  @db.index.unique 'username_idx'
3
3
  username: string
4
4
 
5
+ @db.column.version
6
+ version: number.int
7
+
5
8
  @db.patch.strategy 'merge'
6
9
  password: {
7
10
  hash: string
@@ -25,7 +28,7 @@ export interface AoothUserCredentials {
25
28
 
26
29
  @db.patch.strategy 'merge'
27
30
  mfa: {
28
- methods: { name: string, confirmed: boolean, value: string }[]
31
+ methods: { name: string, confirmed: boolean, value: string, lastUsedWindow?: number.int }[]
29
32
 
30
33
  defaultMethod: string
31
34
  autoSend: boolean
@@ -39,4 +42,6 @@ export interface AoothUserCredentials {
39
42
  expiresAt: number.timestamp
40
43
  name?: string
41
44
  }[]
45
+
46
+ backupCodes?: string[]
42
47
  }