@shipfox/api-auth 11.0.0 → 12.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.
Files changed (33) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +26 -0
  3. package/README.md +19 -19
  4. package/dist/core/job-lease-token.d.ts +1 -1
  5. package/dist/core/job-lease-token.d.ts.map +1 -1
  6. package/dist/core/job-lease-token.js +1 -1
  7. package/dist/core/job-lease-token.js.map +1 -1
  8. package/dist/core/runner-session-token.d.ts +1 -1
  9. package/dist/core/runner-session-token.d.ts.map +1 -1
  10. package/dist/core/runner-session-token.js +1 -1
  11. package/dist/core/runner-session-token.js.map +1 -1
  12. package/dist/db/rate-limits.d.ts +6 -15
  13. package/dist/db/rate-limits.d.ts.map +1 -1
  14. package/dist/db/rate-limits.js +17 -18
  15. package/dist/db/rate-limits.js.map +1 -1
  16. package/dist/db/refresh-tokens.d.ts +3 -3
  17. package/dist/db/refresh-tokens.js +3 -3
  18. package/dist/db/refresh-tokens.js.map +1 -1
  19. package/dist/presentation/auth/lease-token-auth.d.ts +1 -1
  20. package/dist/presentation/auth/lease-token-auth.js +1 -1
  21. package/dist/presentation/auth/lease-token-auth.js.map +1 -1
  22. package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
  23. package/dist/presentation/routes/rate-limit.js +32 -53
  24. package/dist/presentation/routes/rate-limit.js.map +1 -1
  25. package/dist/tsconfig.test.tsbuildinfo +1 -1
  26. package/package.json +13 -13
  27. package/src/core/job-lease-token.ts +1 -1
  28. package/src/core/runner-session-token.ts +1 -1
  29. package/src/db/rate-limits.ts +28 -42
  30. package/src/db/refresh-tokens.ts +3 -3
  31. package/src/presentation/auth/lease-token-auth.ts +1 -1
  32. package/src/presentation/routes/rate-limit.ts +31 -64
  33. package/tsconfig.build.tsbuildinfo +1 -1
@@ -1,2 +1,2 @@
1
1
  $ shipfox-swc
2
- Successfully compiled: 67 files with swc (446.61ms)
2
+ Successfully compiled: 67 files with swc (326.91ms)
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # @shipfox/api-auth
2
2
 
3
+ ## 12.0.0
4
+
5
+ ### Patch Changes
6
+
7
+ - f78740d: Remove Unicode dash punctuation from package prose and source comments.
8
+ - 9ebc5b4: Add an authenticated, rate-limited workspace slug availability endpoint.
9
+ - Updated dependencies [f78740d]
10
+ - Updated dependencies [94aba88]
11
+ - Updated dependencies [f13e8bb]
12
+ - Updated dependencies [9ebc5b4]
13
+ - Updated dependencies [34a5639]
14
+ - Updated dependencies [e1efaee]
15
+ - @shipfox/api-auth-dto@12.0.0
16
+ - @shipfox/api-common-dto@12.0.0
17
+ - @shipfox/inter-module@0.2.3
18
+ - @shipfox/node-fastify@0.4.1
19
+ - @shipfox/node-module@1.0.5
20
+ - @shipfox/api-workspaces-dto@12.0.0
21
+ - @shipfox/node-jwt@0.4.0
22
+ - @shipfox/node-postgres@0.5.0
23
+ - @shipfox/node-rate-limit@0.4.0
24
+ - @shipfox/api-auth-context@12.0.0
25
+ - @shipfox/api-email-challenges@1.1.7
26
+ - @shipfox/node-drizzle@0.3.5
27
+ - @shipfox/node-outbox@0.2.6
28
+
3
29
  ## 11.0.0
4
30
 
5
31
  ### Major Changes
package/README.md CHANGED
@@ -60,7 +60,7 @@ Required environment:
60
60
  | `AUTH_JOB_LEASE_TOKEN_EXPIRES_IN` | `90m` | Job lease token lifetime. |
61
61
  | `AUTH_RUNNER_SESSION_TOKEN_EXPIRES_IN` | `1h` | Runner session token lifetime. |
62
62
  | `AUTH_REFRESH_TOKEN_EXPIRES_IN_DAYS` | `14` | Refresh token and cookie lifetime. |
63
- | `AUTH_REFRESH_ROTATION_GRACE_SECONDS` | `30` | Grace window for accepting a just-rotated refresh token during concurrent refreshes. |
63
+ | `AUTH_REFRESH_ROTATION_GRACE_SECONDS` | `30` | Grace window for accepting a recently rotated refresh token during concurrent refreshes. |
64
64
  | `AUTH_REFRESH_COOKIE_NAME` | `shipfox_refresh_token` | HTTP cookie name for refresh sessions. |
65
65
  | `AUTH_PASSWORD_ENABLED` | `true` | Enables password and email-verification routes. Set to `false` when another module provides login. Server construction fails if no login method is available. |
66
66
  | `AUTH_SIGNUP_GATE_ENABLED` | `false` | Restricts new account creation to the configured signup email allowlist. Requires `AUTH_SIGNUP_ALLOWED_EMAIL_DOMAINS` or `AUTH_SIGNUP_ALLOWED_EMAILS` to be set. |
@@ -116,16 +116,16 @@ itself.
116
116
  - **Audience:** signed-in users on first-party clients (web app, CLI).
117
117
  - **Grants:** the user's own account routes and, downstream, any route that
118
118
  authorizes against the membership claims. Its scope is "this user, with these
119
- memberships" never a single resource.
119
+ memberships", never a single resource.
120
120
  - **Lifecycle:**
121
- - *Emit* issued on login, signup verification, and password reset.
122
- - *Exchange* sent on each request; a longer-lived refresh session mints a
121
+ - *Emit*: issued on login, signup verification, and password reset.
122
+ - *Exchange*: sent on each request; a longer-lived refresh session mints a
123
123
  fresh token when it expires.
124
- - *Store* held in client memory only, never persisted to disk or local
124
+ - *Store*: held in client memory only, never persisted to disk or local
125
125
  storage. Refresh sessions are stored server-side as hashes, never in raw form.
126
- - *Discard* expires at the configured `exp` time. Revoking the refresh
126
+ - *Discard*: expires at the configured `exp` time. Revoking the refresh
127
127
  session blocks renewal but does not invalidate the issued access token.
128
- - **Tradeoff stale memberships:** because memberships ride in the claims, a
128
+ - **Tradeoff: stale memberships:** because memberships ride in the claims, a
129
129
  membership or workspace-status change only takes effect on the next token
130
130
  issuance or refresh. Accepted because the recommended lifetime is 15 minutes,
131
131
  so the staleness window is bounded and a per-request membership read is avoided.
@@ -137,7 +137,7 @@ The runner exchanges its long-lived registration token at startup, then uses the
137
137
  short-lived session token to claim jobs. Heartbeat and step/report/log operations
138
138
  use the per-job lease token instead.
139
139
 
140
- - **Scope one workspace's runner data plane.** The token names one runner
140
+ - **Scope: one workspace's runner data plane.** The token names one runner
141
141
  session, one workspace, the fixed `workspace` scope, and the session's immutable
142
142
  label set. It can claim jobs for that workspace. It is not a user identity and
143
143
  does not authorize dashboard management routes.
@@ -148,7 +148,7 @@ use the per-job lease token instead.
148
148
  - **Mechanics.** Signed with HMAC-SHA256 using the derived runner-session key
149
149
  and the `runner-session` audience. The default lifetime is `1h`, short enough
150
150
  to bound the residual claim window when a registration token is revoked.
151
- - **Tradeoff no per-session revocation in v1.** Claim stays stateless and does
151
+ - **Tradeoff: no per-session revocation in v1.** Claim stays stateless and does
152
152
  not check the runner session row on each poll. Revoking the registration token
153
153
  blocks new sessions, but an existing session can keep claiming until
154
154
  `AUTH_RUNNER_SESSION_TOKEN_EXPIRES_IN` elapses. A job execution claimed before
@@ -163,7 +163,7 @@ A single-job **capability** token, not a session. It is the means by which a
163
163
  runner proves it is the legitimate holder of one specific job while it reports
164
164
  progress and drives that job to completion.
165
165
 
166
- - **Scope exactly one job execution.** The token authorizes action on the
166
+ - **Scope: exactly one job execution.** The token authorizes action on the
167
167
  single job execution it names, for the runner holding it. It is **not** a runner
168
168
  identity, it is **not** workspace-wide, and it is **not** a substitute for the
169
169
  long-lived runner credential used to claim work in the first place. The
@@ -175,9 +175,9 @@ progress and drives that job to completion.
175
175
  - **Trust boundary.** There is exactly one issuer: the scheduling side mints a
176
176
  lease when a runner claims a job execution, next-step re-scopes it to the
177
177
  dispatched step attempt, and heartbeat re-mints the same narrow capability
178
- after server state accepts the heartbeat. Everything
179
- downstream only *verifies* an in-process signature check, with no callback to
180
- the issuer. The runner, and the untrusted agent workload it hosts, never mints
178
+ after server state accepts the heartbeat. Everything downstream only *verifies*
179
+ the token with an in-process signature check. It makes no callback to the
180
+ issuer. The runner, and the untrusted agent workload it hosts, never mints
181
181
  or modifies a token; it only presents the one it was handed.
182
182
  - **Mechanics.** Signed with HMAC-SHA256 using the job-lease key derived from
183
183
  `AUTH_ROOT_KEY`. Its claims name the job, job execution, and surrounding
@@ -187,13 +187,13 @@ progress and drives that job to completion.
187
187
  execution. The root key is supplied through configuration, never embedded in
188
188
  code or committed. The raw token must **never** be written to logs, traces, or
189
189
  error payloads. There is no automatic redaction to fall back on.
190
- - **Defense in depth server state is the final authority.** A valid token is
190
+ - **Defense in depth: server state is the final authority.** A valid token is
191
191
  never sufficient on its own to advance work. On the lease's own request path the
192
192
  gate is server-side step and progression state: a report against a step that has
193
193
  already reached a terminal state (finished, failed, or cancelled) is ignored, so
194
194
  a still-valid token cannot re-drive work that is already done. Job-level
195
195
  finalization is enforced outside the lease path. Cancellation flows the other way
196
- as well the server can ask the runner to stop at any point, and that request
196
+ as well: the server can ask the runner to stop at any point, and that request
197
197
  rides on the response to each heartbeat rather than depending on the token.
198
198
  - **Log append scope.** A step-scoped lease is a signed membership and attempt
199
199
  check for append-log authorization. It is not an active-step proof: until the
@@ -207,7 +207,7 @@ progress and drives that job to completion.
207
207
  other workspaces. If `AUTH_ROOT_KEY` leaks, rotate it. This invalidates every
208
208
  live token and email challenge derived from the old root, including every live
209
209
  lease, and forces fresh claims.
210
- - **Tradeoff no per-token revocation.** A single outstanding lease cannot be
210
+ - **Tradeoff: no per-token revocation.** A single outstanding lease cannot be
211
211
  revoked on its own by token ID. The claiming runner's credential is not
212
212
  re-checked on each request, so a lease for already-claimed work can continue to
213
213
  be renewed by heartbeat until server state cancels, completes, times out, or
@@ -217,7 +217,7 @@ progress and drives that job to completion.
217
217
  - **Guidelines for future changes.** Keep the authority narrow: do not add claims
218
218
  that grant access beyond the single job, keep the lifetime bounded, keep a single
219
219
  issuer, and keep every other side verify-only. If the no-revocation window ever
220
- proves too wide, the right fix is to bind the lease to live runner or job state
220
+ proves too wide, the right fix is to bind the lease to live runner or job state,
221
221
  not to broaden what the token itself can authorize.
222
222
 
223
223
  ## Routes
@@ -433,7 +433,7 @@ unauthorized` error when the verified request context is missing, inconsistent,
433
433
  or has no refresh-session ID. It exposes neither refresh-token material nor
434
434
  storage details.
435
435
 
436
- ## Data Model
436
+ ## Data model
437
437
 
438
438
  The module creates tables with the `auth_` prefix:
439
439
 
@@ -446,7 +446,7 @@ The module creates tables with the `auth_` prefix:
446
446
 
447
447
  Passwords use Argon2id. Password reset tokens and refresh tokens are opaque tokens stored as hashes. Email verification uses the shared email-challenges module.
448
448
 
449
- ## Behavior Notes
449
+ ## Behavior notes
450
450
 
451
451
  - Signup sends an eight-digit verification code and returns the new user with its challenge ID.
452
452
  - Signup gating blocks account creation only. Existing users keep signing in, even when their address is not on the current allowlist.
@@ -6,7 +6,7 @@ export declare function jobLeaseParamsFrom(source: JobLeaseParamSource, stepScop
6
6
  currentStepAttempt: number;
7
7
  }): IssueJobLeaseTokenParams;
8
8
  export declare function issueJobLeaseToken(claims: IssueJobLeaseTokenParams): Promise<string>;
9
- /** Returns the claims on success, or `null` for any invalid input never throws. */
9
+ /** Returns the claims on success, or `null` for any invalid input. Never throws. */
10
10
  export declare function verifyJobLeaseToken(token: string): Promise<JobLeaseTokenClaims | null>;
11
11
  export {};
12
12
  //# sourceMappingURL=job-lease-token.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"job-lease-token.d.ts","sourceRoot":"","sources":["../../src/core/job-lease-token.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,mBAAmB,EAEzB,MAAM,uBAAuB,CAAC;AAO/B,MAAM,MAAM,wBAAwB,GAAG,IAAI,CAAC,mBAAmB,EAAE,KAAK,GAAG,KAAK,GAAG,KAAK,CAAC,CAAC;AAExF,KAAK,mBAAmB,GAAG,IAAI,CAC7B,mBAAmB,EACjB,eAAe,GACf,sBAAsB,GACtB,OAAO,GACP,gBAAgB,GAChB,WAAW,GACX,aAAa,GACb,iBAAiB,CACpB,CAAC;AAEF,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,mBAAmB,EAC3B,SAAS,CAAC,EAAE;IAAC,aAAa,EAAE,MAAM,CAAC;IAAC,kBAAkB,EAAE,MAAM,CAAA;CAAC,GAC9D,wBAAwB,CAW1B;AAED,wBAAsB,kBAAkB,CAAC,MAAM,EAAE,wBAAwB,GAAG,OAAO,CAAC,MAAM,CAAC,CA0B1F;AAED,qFAAqF;AACrF,wBAAsB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC,CAc5F"}
1
+ {"version":3,"file":"job-lease-token.d.ts","sourceRoot":"","sources":["../../src/core/job-lease-token.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,mBAAmB,EAEzB,MAAM,uBAAuB,CAAC;AAO/B,MAAM,MAAM,wBAAwB,GAAG,IAAI,CAAC,mBAAmB,EAAE,KAAK,GAAG,KAAK,GAAG,KAAK,CAAC,CAAC;AAExF,KAAK,mBAAmB,GAAG,IAAI,CAC7B,mBAAmB,EACjB,eAAe,GACf,sBAAsB,GACtB,OAAO,GACP,gBAAgB,GAChB,WAAW,GACX,aAAa,GACb,iBAAiB,CACpB,CAAC;AAEF,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,mBAAmB,EAC3B,SAAS,CAAC,EAAE;IAAC,aAAa,EAAE,MAAM,CAAC;IAAC,kBAAkB,EAAE,MAAM,CAAA;CAAC,GAC9D,wBAAwB,CAW1B;AAED,wBAAsB,kBAAkB,CAAC,MAAM,EAAE,wBAAwB,GAAG,OAAO,CAAC,MAAM,CAAC,CA0B1F;AAED,oFAAoF;AACpF,wBAAsB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC,CAc5F"}
@@ -40,7 +40,7 @@ export async function issueJobLeaseToken(claims) {
40
40
  recordTokenIssued('job_lease');
41
41
  return token;
42
42
  }
43
- /** Returns the claims on success, or `null` for any invalid input never throws. */ export async function verifyJobLeaseToken(token) {
43
+ /** Returns the claims on success, or `null` for any invalid input. Never throws. */ export async function verifyJobLeaseToken(token) {
44
44
  try {
45
45
  const claims = await verifyHs256({
46
46
  token,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/core/job-lease-token.ts"],"sourcesContent":["import {\n JOB_LEASE_TOKEN_AUDIENCE,\n type JobLeaseTokenClaims,\n jobLeaseTokenClaimsSchema,\n} from '@shipfox/api-auth-dto';\nimport {jobLeaseTokenKey} from '@shipfox/node-auth-root-key';\nimport {signHs256, verifyHs256} from '@shipfox/node-jwt';\nimport {config} from '#config.js';\nimport {recordTokenIssued, recordTokenVerified} from '#metrics/index.js';\n\n// `aud`, `iat` and `exp` are set by the codec (jose); callers supply the business ids only.\nexport type IssueJobLeaseTokenParams = Omit<JobLeaseTokenClaims, 'aud' | 'iat' | 'exp'>;\n\ntype JobLeaseParamSource = Pick<\n JobLeaseTokenClaims,\n | 'workflowRunId'\n | 'workflowRunAttemptId'\n | 'jobId'\n | 'jobExecutionId'\n | 'projectId'\n | 'workspaceId'\n | 'runnerSessionId'\n>;\n\nexport function jobLeaseParamsFrom(\n source: JobLeaseParamSource,\n stepScope?: {currentStepId: string; currentStepAttempt: number},\n): IssueJobLeaseTokenParams {\n return {\n workflowRunId: source.workflowRunId,\n workflowRunAttemptId: source.workflowRunAttemptId,\n jobId: source.jobId,\n jobExecutionId: source.jobExecutionId,\n projectId: source.projectId,\n workspaceId: source.workspaceId,\n runnerSessionId: source.runnerSessionId,\n ...(stepScope ? stepScope : {}),\n };\n}\n\nexport async function issueJobLeaseToken(claims: IssueJobLeaseTokenParams): Promise<string> {\n const token = await signHs256({\n payload: {\n workflowRunId: claims.workflowRunId,\n ...(claims.workflowRunAttempt === undefined\n ? {}\n : {workflowRunAttempt: claims.workflowRunAttempt}),\n workflowRunAttemptId: claims.workflowRunAttemptId,\n jobId: claims.jobId,\n jobExecutionId: claims.jobExecutionId,\n projectId: claims.projectId,\n workspaceId: claims.workspaceId,\n runnerSessionId: claims.runnerSessionId,\n ...(claims.currentStepId && claims.currentStepAttempt !== undefined\n ? {\n currentStepId: claims.currentStepId,\n currentStepAttempt: claims.currentStepAttempt,\n }\n : {}),\n },\n secret: jobLeaseTokenKey(),\n expiresIn: config.AUTH_JOB_LEASE_TOKEN_EXPIRES_IN,\n audience: JOB_LEASE_TOKEN_AUDIENCE,\n });\n recordTokenIssued('job_lease');\n return token;\n}\n\n/** Returns the claims on success, or `null` for any invalid input never throws. */\nexport async function verifyJobLeaseToken(token: string): Promise<JobLeaseTokenClaims | null> {\n try {\n const claims = await verifyHs256({\n token,\n secret: jobLeaseTokenKey(),\n schema: jobLeaseTokenClaimsSchema,\n audience: JOB_LEASE_TOKEN_AUDIENCE,\n });\n recordTokenVerified('job_lease', 'ok');\n return claims;\n } catch {\n recordTokenVerified('job_lease', 'rejected');\n return null;\n }\n}\n"],"names":["JOB_LEASE_TOKEN_AUDIENCE","jobLeaseTokenClaimsSchema","jobLeaseTokenKey","signHs256","verifyHs256","config","recordTokenIssued","recordTokenVerified","jobLeaseParamsFrom","source","stepScope","workflowRunId","workflowRunAttemptId","jobId","jobExecutionId","projectId","workspaceId","runnerSessionId","issueJobLeaseToken","claims","token","payload","workflowRunAttempt","undefined","currentStepId","currentStepAttempt","secret","expiresIn","AUTH_JOB_LEASE_TOKEN_EXPIRES_IN","audience","verifyJobLeaseToken","schema"],"mappings":"AAAA,SACEA,wBAAwB,EAExBC,yBAAyB,QACpB,wBAAwB;AAC/B,SAAQC,gBAAgB,QAAO,8BAA8B;AAC7D,SAAQC,SAAS,EAAEC,WAAW,QAAO,oBAAoB;AACzD,SAAQC,MAAM,QAAO,aAAa;AAClC,SAAQC,iBAAiB,EAAEC,mBAAmB,QAAO,oBAAoB;AAgBzE,OAAO,SAASC,mBACdC,MAA2B,EAC3BC,SAA+D;IAE/D,OAAO;QACLC,eAAeF,OAAOE,aAAa;QACnCC,sBAAsBH,OAAOG,oBAAoB;QACjDC,OAAOJ,OAAOI,KAAK;QACnBC,gBAAgBL,OAAOK,cAAc;QACrCC,WAAWN,OAAOM,SAAS;QAC3BC,aAAaP,OAAOO,WAAW;QAC/BC,iBAAiBR,OAAOQ,eAAe;QACvC,GAAIP,YAAYA,YAAY,CAAC,CAAC;IAChC;AACF;AAEA,OAAO,eAAeQ,mBAAmBC,MAAgC;IACvE,MAAMC,QAAQ,MAAMjB,UAAU;QAC5BkB,SAAS;YACPV,eAAeQ,OAAOR,aAAa;YACnC,GAAIQ,OAAOG,kBAAkB,KAAKC,YAC9B,CAAC,IACD;gBAACD,oBAAoBH,OAAOG,kBAAkB;YAAA,CAAC;YACnDV,sBAAsBO,OAAOP,oBAAoB;YACjDC,OAAOM,OAAON,KAAK;YACnBC,gBAAgBK,OAAOL,cAAc;YACrCC,WAAWI,OAAOJ,SAAS;YAC3BC,aAAaG,OAAOH,WAAW;YAC/BC,iBAAiBE,OAAOF,eAAe;YACvC,GAAIE,OAAOK,aAAa,IAAIL,OAAOM,kBAAkB,KAAKF,YACtD;gBACEC,eAAeL,OAAOK,aAAa;gBACnCC,oBAAoBN,OAAOM,kBAAkB;YAC/C,IACA,CAAC,CAAC;QACR;QACAC,QAAQxB;QACRyB,WAAWtB,OAAOuB,+BAA+B;QACjDC,UAAU7B;IACZ;IACAM,kBAAkB;IAClB,OAAOc;AACT;AAEA,mFAAmF,GACnF,OAAO,eAAeU,oBAAoBV,KAAa;IACrD,IAAI;QACF,MAAMD,SAAS,MAAMf,YAAY;YAC/BgB;YACAM,QAAQxB;YACR6B,QAAQ9B;YACR4B,UAAU7B;QACZ;QACAO,oBAAoB,aAAa;QACjC,OAAOY;IACT,EAAE,OAAM;QACNZ,oBAAoB,aAAa;QACjC,OAAO;IACT;AACF"}
1
+ {"version":3,"sources":["../../src/core/job-lease-token.ts"],"sourcesContent":["import {\n JOB_LEASE_TOKEN_AUDIENCE,\n type JobLeaseTokenClaims,\n jobLeaseTokenClaimsSchema,\n} from '@shipfox/api-auth-dto';\nimport {jobLeaseTokenKey} from '@shipfox/node-auth-root-key';\nimport {signHs256, verifyHs256} from '@shipfox/node-jwt';\nimport {config} from '#config.js';\nimport {recordTokenIssued, recordTokenVerified} from '#metrics/index.js';\n\n// `aud`, `iat` and `exp` are set by the codec (jose); callers supply the business ids only.\nexport type IssueJobLeaseTokenParams = Omit<JobLeaseTokenClaims, 'aud' | 'iat' | 'exp'>;\n\ntype JobLeaseParamSource = Pick<\n JobLeaseTokenClaims,\n | 'workflowRunId'\n | 'workflowRunAttemptId'\n | 'jobId'\n | 'jobExecutionId'\n | 'projectId'\n | 'workspaceId'\n | 'runnerSessionId'\n>;\n\nexport function jobLeaseParamsFrom(\n source: JobLeaseParamSource,\n stepScope?: {currentStepId: string; currentStepAttempt: number},\n): IssueJobLeaseTokenParams {\n return {\n workflowRunId: source.workflowRunId,\n workflowRunAttemptId: source.workflowRunAttemptId,\n jobId: source.jobId,\n jobExecutionId: source.jobExecutionId,\n projectId: source.projectId,\n workspaceId: source.workspaceId,\n runnerSessionId: source.runnerSessionId,\n ...(stepScope ? stepScope : {}),\n };\n}\n\nexport async function issueJobLeaseToken(claims: IssueJobLeaseTokenParams): Promise<string> {\n const token = await signHs256({\n payload: {\n workflowRunId: claims.workflowRunId,\n ...(claims.workflowRunAttempt === undefined\n ? {}\n : {workflowRunAttempt: claims.workflowRunAttempt}),\n workflowRunAttemptId: claims.workflowRunAttemptId,\n jobId: claims.jobId,\n jobExecutionId: claims.jobExecutionId,\n projectId: claims.projectId,\n workspaceId: claims.workspaceId,\n runnerSessionId: claims.runnerSessionId,\n ...(claims.currentStepId && claims.currentStepAttempt !== undefined\n ? {\n currentStepId: claims.currentStepId,\n currentStepAttempt: claims.currentStepAttempt,\n }\n : {}),\n },\n secret: jobLeaseTokenKey(),\n expiresIn: config.AUTH_JOB_LEASE_TOKEN_EXPIRES_IN,\n audience: JOB_LEASE_TOKEN_AUDIENCE,\n });\n recordTokenIssued('job_lease');\n return token;\n}\n\n/** Returns the claims on success, or `null` for any invalid input. Never throws. */\nexport async function verifyJobLeaseToken(token: string): Promise<JobLeaseTokenClaims | null> {\n try {\n const claims = await verifyHs256({\n token,\n secret: jobLeaseTokenKey(),\n schema: jobLeaseTokenClaimsSchema,\n audience: JOB_LEASE_TOKEN_AUDIENCE,\n });\n recordTokenVerified('job_lease', 'ok');\n return claims;\n } catch {\n recordTokenVerified('job_lease', 'rejected');\n return null;\n }\n}\n"],"names":["JOB_LEASE_TOKEN_AUDIENCE","jobLeaseTokenClaimsSchema","jobLeaseTokenKey","signHs256","verifyHs256","config","recordTokenIssued","recordTokenVerified","jobLeaseParamsFrom","source","stepScope","workflowRunId","workflowRunAttemptId","jobId","jobExecutionId","projectId","workspaceId","runnerSessionId","issueJobLeaseToken","claims","token","payload","workflowRunAttempt","undefined","currentStepId","currentStepAttempt","secret","expiresIn","AUTH_JOB_LEASE_TOKEN_EXPIRES_IN","audience","verifyJobLeaseToken","schema"],"mappings":"AAAA,SACEA,wBAAwB,EAExBC,yBAAyB,QACpB,wBAAwB;AAC/B,SAAQC,gBAAgB,QAAO,8BAA8B;AAC7D,SAAQC,SAAS,EAAEC,WAAW,QAAO,oBAAoB;AACzD,SAAQC,MAAM,QAAO,aAAa;AAClC,SAAQC,iBAAiB,EAAEC,mBAAmB,QAAO,oBAAoB;AAgBzE,OAAO,SAASC,mBACdC,MAA2B,EAC3BC,SAA+D;IAE/D,OAAO;QACLC,eAAeF,OAAOE,aAAa;QACnCC,sBAAsBH,OAAOG,oBAAoB;QACjDC,OAAOJ,OAAOI,KAAK;QACnBC,gBAAgBL,OAAOK,cAAc;QACrCC,WAAWN,OAAOM,SAAS;QAC3BC,aAAaP,OAAOO,WAAW;QAC/BC,iBAAiBR,OAAOQ,eAAe;QACvC,GAAIP,YAAYA,YAAY,CAAC,CAAC;IAChC;AACF;AAEA,OAAO,eAAeQ,mBAAmBC,MAAgC;IACvE,MAAMC,QAAQ,MAAMjB,UAAU;QAC5BkB,SAAS;YACPV,eAAeQ,OAAOR,aAAa;YACnC,GAAIQ,OAAOG,kBAAkB,KAAKC,YAC9B,CAAC,IACD;gBAACD,oBAAoBH,OAAOG,kBAAkB;YAAA,CAAC;YACnDV,sBAAsBO,OAAOP,oBAAoB;YACjDC,OAAOM,OAAON,KAAK;YACnBC,gBAAgBK,OAAOL,cAAc;YACrCC,WAAWI,OAAOJ,SAAS;YAC3BC,aAAaG,OAAOH,WAAW;YAC/BC,iBAAiBE,OAAOF,eAAe;YACvC,GAAIE,OAAOK,aAAa,IAAIL,OAAOM,kBAAkB,KAAKF,YACtD;gBACEC,eAAeL,OAAOK,aAAa;gBACnCC,oBAAoBN,OAAOM,kBAAkB;YAC/C,IACA,CAAC,CAAC;QACR;QACAC,QAAQxB;QACRyB,WAAWtB,OAAOuB,+BAA+B;QACjDC,UAAU7B;IACZ;IACAM,kBAAkB;IAClB,OAAOc;AACT;AAEA,kFAAkF,GAClF,OAAO,eAAeU,oBAAoBV,KAAa;IACrD,IAAI;QACF,MAAMD,SAAS,MAAMf,YAAY;YAC/BgB;YACAM,QAAQxB;YACR6B,QAAQ9B;YACR4B,UAAU7B;QACZ;QACAO,oBAAoB,aAAa;QACjC,OAAOY;IACT,EAAE,OAAM;QACNZ,oBAAoB,aAAa;QACjC,OAAO;IACT;AACF"}
@@ -1,6 +1,6 @@
1
1
  import { type RunnerSessionTokenClaims } from '@shipfox/api-auth-dto';
2
2
  export type IssueRunnerSessionTokenParams = Omit<RunnerSessionTokenClaims, 'aud' | 'iat' | 'exp'>;
3
3
  export declare function issueRunnerSessionToken(claims: IssueRunnerSessionTokenParams): Promise<string>;
4
- /** Returns the claims on success, or `null` for any invalid input never throws. */
4
+ /** Returns the claims on success, or `null` for any invalid input. Never throws. */
5
5
  export declare function verifyRunnerSessionToken(token: string): Promise<RunnerSessionTokenClaims | null>;
6
6
  //# sourceMappingURL=runner-session-token.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"runner-session-token.d.ts","sourceRoot":"","sources":["../../src/core/runner-session-token.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,wBAAwB,EAE9B,MAAM,uBAAuB,CAAC;AAO/B,MAAM,MAAM,6BAA6B,GAAG,IAAI,CAAC,wBAAwB,EAAE,KAAK,GAAG,KAAK,GAAG,KAAK,CAAC,CAAC;AAElG,wBAAsB,uBAAuB,CAC3C,MAAM,EAAE,6BAA6B,GACpC,OAAO,CAAC,MAAM,CAAC,CAejB;AAED,qFAAqF;AACrF,wBAAsB,wBAAwB,CAC5C,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,wBAAwB,GAAG,IAAI,CAAC,CAc1C"}
1
+ {"version":3,"file":"runner-session-token.d.ts","sourceRoot":"","sources":["../../src/core/runner-session-token.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,wBAAwB,EAE9B,MAAM,uBAAuB,CAAC;AAO/B,MAAM,MAAM,6BAA6B,GAAG,IAAI,CAAC,wBAAwB,EAAE,KAAK,GAAG,KAAK,GAAG,KAAK,CAAC,CAAC;AAElG,wBAAsB,uBAAuB,CAC3C,MAAM,EAAE,6BAA6B,GACpC,OAAO,CAAC,MAAM,CAAC,CAejB;AAED,oFAAoF;AACpF,wBAAsB,wBAAwB,CAC5C,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,wBAAwB,GAAG,IAAI,CAAC,CAc1C"}
@@ -19,7 +19,7 @@ export async function issueRunnerSessionToken(claims) {
19
19
  recordTokenIssued('runner_session');
20
20
  return token;
21
21
  }
22
- /** Returns the claims on success, or `null` for any invalid input never throws. */ export async function verifyRunnerSessionToken(token) {
22
+ /** Returns the claims on success, or `null` for any invalid input. Never throws. */ export async function verifyRunnerSessionToken(token) {
23
23
  try {
24
24
  const claims = await verifyHs256({
25
25
  token,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/core/runner-session-token.ts"],"sourcesContent":["import {\n RUNNER_SESSION_TOKEN_AUDIENCE,\n type RunnerSessionTokenClaims,\n runnerSessionTokenClaimsSchema,\n} from '@shipfox/api-auth-dto';\nimport {runnerSessionTokenKey} from '@shipfox/node-auth-root-key';\nimport {signHs256, verifyHs256} from '@shipfox/node-jwt';\nimport {config} from '#config.js';\nimport {recordTokenIssued, recordTokenVerified} from '#metrics/index.js';\n\n// `aud`, `iat` and `exp` are set by the codec (jose); callers supply the business ids only.\nexport type IssueRunnerSessionTokenParams = Omit<RunnerSessionTokenClaims, 'aud' | 'iat' | 'exp'>;\n\nexport async function issueRunnerSessionToken(\n claims: IssueRunnerSessionTokenParams,\n): Promise<string> {\n const token = await signHs256({\n payload: {\n runnerSessionId: claims.runnerSessionId,\n workspaceId: claims.workspaceId,\n scope: claims.scope,\n labels: claims.labels,\n maxClaims: claims.maxClaims,\n },\n secret: runnerSessionTokenKey(),\n expiresIn: config.AUTH_RUNNER_SESSION_TOKEN_EXPIRES_IN,\n audience: RUNNER_SESSION_TOKEN_AUDIENCE,\n });\n recordTokenIssued('runner_session');\n return token;\n}\n\n/** Returns the claims on success, or `null` for any invalid input never throws. */\nexport async function verifyRunnerSessionToken(\n token: string,\n): Promise<RunnerSessionTokenClaims | null> {\n try {\n const claims = await verifyHs256({\n token,\n secret: runnerSessionTokenKey(),\n schema: runnerSessionTokenClaimsSchema,\n audience: RUNNER_SESSION_TOKEN_AUDIENCE,\n });\n recordTokenVerified('runner_session', 'ok');\n return claims;\n } catch {\n recordTokenVerified('runner_session', 'rejected');\n return null;\n }\n}\n"],"names":["RUNNER_SESSION_TOKEN_AUDIENCE","runnerSessionTokenClaimsSchema","runnerSessionTokenKey","signHs256","verifyHs256","config","recordTokenIssued","recordTokenVerified","issueRunnerSessionToken","claims","token","payload","runnerSessionId","workspaceId","scope","labels","maxClaims","secret","expiresIn","AUTH_RUNNER_SESSION_TOKEN_EXPIRES_IN","audience","verifyRunnerSessionToken","schema"],"mappings":"AAAA,SACEA,6BAA6B,EAE7BC,8BAA8B,QACzB,wBAAwB;AAC/B,SAAQC,qBAAqB,QAAO,8BAA8B;AAClE,SAAQC,SAAS,EAAEC,WAAW,QAAO,oBAAoB;AACzD,SAAQC,MAAM,QAAO,aAAa;AAClC,SAAQC,iBAAiB,EAAEC,mBAAmB,QAAO,oBAAoB;AAKzE,OAAO,eAAeC,wBACpBC,MAAqC;IAErC,MAAMC,QAAQ,MAAMP,UAAU;QAC5BQ,SAAS;YACPC,iBAAiBH,OAAOG,eAAe;YACvCC,aAAaJ,OAAOI,WAAW;YAC/BC,OAAOL,OAAOK,KAAK;YACnBC,QAAQN,OAAOM,MAAM;YACrBC,WAAWP,OAAOO,SAAS;QAC7B;QACAC,QAAQf;QACRgB,WAAWb,OAAOc,oCAAoC;QACtDC,UAAUpB;IACZ;IACAM,kBAAkB;IAClB,OAAOI;AACT;AAEA,mFAAmF,GACnF,OAAO,eAAeW,yBACpBX,KAAa;IAEb,IAAI;QACF,MAAMD,SAAS,MAAML,YAAY;YAC/BM;YACAO,QAAQf;YACRoB,QAAQrB;YACRmB,UAAUpB;QACZ;QACAO,oBAAoB,kBAAkB;QACtC,OAAOE;IACT,EAAE,OAAM;QACNF,oBAAoB,kBAAkB;QACtC,OAAO;IACT;AACF"}
1
+ {"version":3,"sources":["../../src/core/runner-session-token.ts"],"sourcesContent":["import {\n RUNNER_SESSION_TOKEN_AUDIENCE,\n type RunnerSessionTokenClaims,\n runnerSessionTokenClaimsSchema,\n} from '@shipfox/api-auth-dto';\nimport {runnerSessionTokenKey} from '@shipfox/node-auth-root-key';\nimport {signHs256, verifyHs256} from '@shipfox/node-jwt';\nimport {config} from '#config.js';\nimport {recordTokenIssued, recordTokenVerified} from '#metrics/index.js';\n\n// `aud`, `iat` and `exp` are set by the codec (jose); callers supply the business ids only.\nexport type IssueRunnerSessionTokenParams = Omit<RunnerSessionTokenClaims, 'aud' | 'iat' | 'exp'>;\n\nexport async function issueRunnerSessionToken(\n claims: IssueRunnerSessionTokenParams,\n): Promise<string> {\n const token = await signHs256({\n payload: {\n runnerSessionId: claims.runnerSessionId,\n workspaceId: claims.workspaceId,\n scope: claims.scope,\n labels: claims.labels,\n maxClaims: claims.maxClaims,\n },\n secret: runnerSessionTokenKey(),\n expiresIn: config.AUTH_RUNNER_SESSION_TOKEN_EXPIRES_IN,\n audience: RUNNER_SESSION_TOKEN_AUDIENCE,\n });\n recordTokenIssued('runner_session');\n return token;\n}\n\n/** Returns the claims on success, or `null` for any invalid input. Never throws. */\nexport async function verifyRunnerSessionToken(\n token: string,\n): Promise<RunnerSessionTokenClaims | null> {\n try {\n const claims = await verifyHs256({\n token,\n secret: runnerSessionTokenKey(),\n schema: runnerSessionTokenClaimsSchema,\n audience: RUNNER_SESSION_TOKEN_AUDIENCE,\n });\n recordTokenVerified('runner_session', 'ok');\n return claims;\n } catch {\n recordTokenVerified('runner_session', 'rejected');\n return null;\n }\n}\n"],"names":["RUNNER_SESSION_TOKEN_AUDIENCE","runnerSessionTokenClaimsSchema","runnerSessionTokenKey","signHs256","verifyHs256","config","recordTokenIssued","recordTokenVerified","issueRunnerSessionToken","claims","token","payload","runnerSessionId","workspaceId","scope","labels","maxClaims","secret","expiresIn","AUTH_RUNNER_SESSION_TOKEN_EXPIRES_IN","audience","verifyRunnerSessionToken","schema"],"mappings":"AAAA,SACEA,6BAA6B,EAE7BC,8BAA8B,QACzB,wBAAwB;AAC/B,SAAQC,qBAAqB,QAAO,8BAA8B;AAClE,SAAQC,SAAS,EAAEC,WAAW,QAAO,oBAAoB;AACzD,SAAQC,MAAM,QAAO,aAAa;AAClC,SAAQC,iBAAiB,EAAEC,mBAAmB,QAAO,oBAAoB;AAKzE,OAAO,eAAeC,wBACpBC,MAAqC;IAErC,MAAMC,QAAQ,MAAMP,UAAU;QAC5BQ,SAAS;YACPC,iBAAiBH,OAAOG,eAAe;YACvCC,aAAaJ,OAAOI,WAAW;YAC/BC,OAAOL,OAAOK,KAAK;YACnBC,QAAQN,OAAOM,MAAM;YACrBC,WAAWP,OAAOO,SAAS;QAC7B;QACAC,QAAQf;QACRgB,WAAWb,OAAOc,oCAAoC;QACtDC,UAAUpB;IACZ;IACAM,kBAAkB;IAClB,OAAOI;AACT;AAEA,kFAAkF,GAClF,OAAO,eAAeW,yBACpBX,KAAa;IAEb,IAAI;QACF,MAAMD,SAAS,MAAML,YAAY;YAC/BM;YACAO,QAAQf;YACRoB,QAAQrB;YACRmB,UAAUpB;QACZ;QACAO,oBAAoB,kBAAkB;QACtC,OAAOE;IACT,EAAE,OAAM;QACNF,oBAAoB,kBAAkB;QACtC,OAAO;IACT;AACF"}
@@ -1,18 +1,9 @@
1
- export interface ConsumeAuthRateLimitParams {
2
- action: string;
3
- scope: string;
4
- identifierHmac: string;
5
- windowStart: Date;
6
- expiresAt: Date;
7
- timeoutMs: number;
8
- }
9
- export interface ConsumeAuthRateLimitResult {
10
- count: number;
11
- expiresAt: Date;
12
- }
13
- export declare function consumeAuthRateLimit(params: ConsumeAuthRateLimitParams): Promise<ConsumeAuthRateLimitResult>;
14
- export declare function pruneExpiredAuthRateLimits(params?: {
1
+ import { type ConsumeRateLimitParams, type ConsumeRateLimitResult } from '@shipfox/node-rate-limit';
2
+ export type ConsumeAuthRateLimitParams = ConsumeRateLimitParams<string, string>;
3
+ export type ConsumeAuthRateLimitResult = ConsumeRateLimitResult;
4
+ export declare const consumeAuthRateLimit: (params: ConsumeRateLimitParams<string, string>) => Promise<ConsumeRateLimitResult>;
5
+ export declare const pruneExpiredAuthRateLimits: (params?: {
15
6
  now?: Date | undefined;
16
7
  minIntervalMs?: number | undefined;
17
- }): Promise<number | undefined>;
8
+ }) => Promise<number | undefined>;
18
9
  //# sourceMappingURL=rate-limits.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"rate-limits.d.ts","sourceRoot":"","sources":["../../src/db/rate-limits.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,0BAA0B;IACzC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE,IAAI,CAAC;IAClB,SAAS,EAAE,IAAI,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,0BAA0B;IACzC,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,IAAI,CAAC;CACjB;AAED,wBAAsB,oBAAoB,CACxC,MAAM,EAAE,0BAA0B,GACjC,OAAO,CAAC,0BAA0B,CAAC,CAgCrC;AAID,wBAAsB,0BAA0B,CAC9C,MAAM,GAAE;IAAC,GAAG,CAAC,EAAE,IAAI,GAAG,SAAS,CAAC;IAAC,aAAa,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CAAM,GACxE,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAU7B"}
1
+ {"version":3,"file":"rate-limits.d.ts","sourceRoot":"","sources":["../../src/db/rate-limits.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,sBAAsB,EAC3B,KAAK,sBAAsB,EAE5B,MAAM,0BAA0B,CAAC;AA+ClC,MAAM,MAAM,0BAA0B,GAAG,sBAAsB,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAChF,MAAM,MAAM,0BAA0B,GAAG,sBAAsB,CAAC;AAChE,eAAO,MAAM,oBAAoB,qFAAsB,CAAC;AACxD,eAAO,MAAM,0BAA0B;;;iCAAoB,CAAC"}
@@ -1,10 +1,14 @@
1
+ import { createRateLimitPersistence } from '@shipfox/node-rate-limit';
1
2
  import { lt, sql } from 'drizzle-orm';
2
3
  import { db } from './db.js';
3
4
  import { authRateLimits } from './schema/rate-limits.js';
4
- export async function consumeAuthRateLimit(params) {
5
- return await db().transaction(async (tx)=>{
6
- await tx.execute(sql`select set_config('statement_timeout', ${`${params.timeoutMs}ms`}, true)`);
7
- const rows = await tx.insert(authRateLimits).values({
5
+ const persistence = createRateLimitPersistence({
6
+ transaction: (callback)=>db().transaction(callback),
7
+ setStatementTimeout: async (transaction, timeoutMs)=>{
8
+ await transaction.execute(sql`select set_config('statement_timeout', ${`${timeoutMs}ms`}, true)`);
9
+ },
10
+ consume: async (transaction, params)=>{
11
+ const rows = await transaction.insert(authRateLimits).values({
8
12
  action: params.action,
9
13
  scope: params.scope,
10
14
  identifierHmac: params.identifierHmac,
@@ -26,19 +30,14 @@ export async function consumeAuthRateLimit(params) {
26
30
  count: authRateLimits.count,
27
31
  expiresAt: authRateLimits.expiresAt
28
32
  });
29
- const row = rows[0];
30
- if (!row) throw new Error('Rate limit upsert returned no rows');
31
- return row;
32
- });
33
- }
34
- let nextPruneAt = 0;
35
- export async function pruneExpiredAuthRateLimits(params = {}) {
36
- const now = params.now ?? new Date();
37
- const minIntervalMs = params.minIntervalMs ?? 60_000;
38
- if (minIntervalMs > 0 && now.getTime() < nextPruneAt) return undefined;
39
- nextPruneAt = now.getTime() + minIntervalMs;
40
- const result = await db().delete(authRateLimits).where(lt(authRateLimits.expiresAt, now));
41
- return result.rowCount ?? 0;
42
- }
33
+ return rows[0];
34
+ },
35
+ prune: async (now)=>{
36
+ const result = await db().delete(authRateLimits).where(lt(authRateLimits.expiresAt, now));
37
+ return result.rowCount ?? 0;
38
+ }
39
+ });
40
+ export const consumeAuthRateLimit = persistence.consume;
41
+ export const pruneExpiredAuthRateLimits = persistence.prune;
43
42
 
44
43
  //# sourceMappingURL=rate-limits.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/db/rate-limits.ts"],"sourcesContent":["import {lt, sql} from 'drizzle-orm';\nimport {db} from './db.js';\nimport {authRateLimits} from './schema/rate-limits.js';\n\nexport interface ConsumeAuthRateLimitParams {\n action: string;\n scope: string;\n identifierHmac: string;\n windowStart: Date;\n expiresAt: Date;\n timeoutMs: number;\n}\n\nexport interface ConsumeAuthRateLimitResult {\n count: number;\n expiresAt: Date;\n}\n\nexport async function consumeAuthRateLimit(\n params: ConsumeAuthRateLimitParams,\n): Promise<ConsumeAuthRateLimitResult> {\n return await db().transaction(async (tx) => {\n await tx.execute(sql`select set_config('statement_timeout', ${`${params.timeoutMs}ms`}, true)`);\n\n const rows = await tx\n .insert(authRateLimits)\n .values({\n action: params.action,\n scope: params.scope,\n identifierHmac: params.identifierHmac,\n windowStart: params.windowStart,\n count: 1,\n expiresAt: params.expiresAt,\n })\n .onConflictDoUpdate({\n target: [\n authRateLimits.action,\n authRateLimits.scope,\n authRateLimits.identifierHmac,\n authRateLimits.windowStart,\n ],\n set: {\n count: sql`${authRateLimits.count} + 1`,\n updatedAt: sql`now()`,\n },\n })\n .returning({count: authRateLimits.count, expiresAt: authRateLimits.expiresAt});\n\n const row = rows[0];\n if (!row) throw new Error('Rate limit upsert returned no rows');\n return row;\n });\n}\n\nlet nextPruneAt = 0;\n\nexport async function pruneExpiredAuthRateLimits(\n params: {now?: Date | undefined; minIntervalMs?: number | undefined} = {},\n): Promise<number | undefined> {\n const now = params.now ?? new Date();\n const minIntervalMs = params.minIntervalMs ?? 60_000;\n if (minIntervalMs > 0 && now.getTime() < nextPruneAt) return undefined;\n\n nextPruneAt = now.getTime() + minIntervalMs;\n\n const result = await db().delete(authRateLimits).where(lt(authRateLimits.expiresAt, now));\n\n return result.rowCount ?? 0;\n}\n"],"names":["lt","sql","db","authRateLimits","consumeAuthRateLimit","params","transaction","tx","execute","timeoutMs","rows","insert","values","action","scope","identifierHmac","windowStart","count","expiresAt","onConflictDoUpdate","target","set","updatedAt","returning","row","Error","nextPruneAt","pruneExpiredAuthRateLimits","now","Date","minIntervalMs","getTime","undefined","result","delete","where","rowCount"],"mappings":"AAAA,SAAQA,EAAE,EAAEC,GAAG,QAAO,cAAc;AACpC,SAAQC,EAAE,QAAO,UAAU;AAC3B,SAAQC,cAAc,QAAO,0BAA0B;AAgBvD,OAAO,eAAeC,qBACpBC,MAAkC;IAElC,OAAO,MAAMH,KAAKI,WAAW,CAAC,OAAOC;QACnC,MAAMA,GAAGC,OAAO,CAACP,GAAG,CAAC,uCAAuC,EAAE,GAAGI,OAAOI,SAAS,CAAC,EAAE,CAAC,CAAC,OAAO,CAAC;QAE9F,MAAMC,OAAO,MAAMH,GAChBI,MAAM,CAACR,gBACPS,MAAM,CAAC;YACNC,QAAQR,OAAOQ,MAAM;YACrBC,OAAOT,OAAOS,KAAK;YACnBC,gBAAgBV,OAAOU,cAAc;YACrCC,aAAaX,OAAOW,WAAW;YAC/BC,OAAO;YACPC,WAAWb,OAAOa,SAAS;QAC7B,GACCC,kBAAkB,CAAC;YAClBC,QAAQ;gBACNjB,eAAeU,MAAM;gBACrBV,eAAeW,KAAK;gBACpBX,eAAeY,cAAc;gBAC7BZ,eAAea,WAAW;aAC3B;YACDK,KAAK;gBACHJ,OAAOhB,GAAG,CAAC,EAAEE,eAAec,KAAK,CAAC,IAAI,CAAC;gBACvCK,WAAWrB,GAAG,CAAC,KAAK,CAAC;YACvB;QACF,GACCsB,SAAS,CAAC;YAACN,OAAOd,eAAec,KAAK;YAAEC,WAAWf,eAAee,SAAS;QAAA;QAE9E,MAAMM,MAAMd,IAAI,CAAC,EAAE;QACnB,IAAI,CAACc,KAAK,MAAM,IAAIC,MAAM;QAC1B,OAAOD;IACT;AACF;AAEA,IAAIE,cAAc;AAElB,OAAO,eAAeC,2BACpBtB,SAAuE,CAAC,CAAC;IAEzE,MAAMuB,MAAMvB,OAAOuB,GAAG,IAAI,IAAIC;IAC9B,MAAMC,gBAAgBzB,OAAOyB,aAAa,IAAI;IAC9C,IAAIA,gBAAgB,KAAKF,IAAIG,OAAO,KAAKL,aAAa,OAAOM;IAE7DN,cAAcE,IAAIG,OAAO,KAAKD;IAE9B,MAAMG,SAAS,MAAM/B,KAAKgC,MAAM,CAAC/B,gBAAgBgC,KAAK,CAACnC,GAAGG,eAAee,SAAS,EAAEU;IAEpF,OAAOK,OAAOG,QAAQ,IAAI;AAC5B"}
1
+ {"version":3,"sources":["../../src/db/rate-limits.ts"],"sourcesContent":["import {\n type ConsumeRateLimitParams,\n type ConsumeRateLimitResult,\n createRateLimitPersistence,\n} from '@shipfox/node-rate-limit';\nimport {lt, sql} from 'drizzle-orm';\nimport {db} from './db.js';\nimport {authRateLimits} from './schema/rate-limits.js';\n\ntype AuthRateLimitTransaction = Parameters<Parameters<ReturnType<typeof db>['transaction']>[0]>[0];\n\nconst persistence = createRateLimitPersistence<AuthRateLimitTransaction>({\n transaction: (callback) => db().transaction(callback),\n setStatementTimeout: async (transaction, timeoutMs) => {\n await transaction.execute(\n sql`select set_config('statement_timeout', ${`${timeoutMs}ms`}, true)`,\n );\n },\n consume: async (transaction, params) => {\n const rows = await transaction\n .insert(authRateLimits)\n .values({\n action: params.action,\n scope: params.scope,\n identifierHmac: params.identifierHmac,\n windowStart: params.windowStart,\n count: 1,\n expiresAt: params.expiresAt,\n })\n .onConflictDoUpdate({\n target: [\n authRateLimits.action,\n authRateLimits.scope,\n authRateLimits.identifierHmac,\n authRateLimits.windowStart,\n ],\n set: {\n count: sql`${authRateLimits.count} + 1`,\n updatedAt: sql`now()`,\n },\n })\n .returning({count: authRateLimits.count, expiresAt: authRateLimits.expiresAt});\n\n return rows[0];\n },\n prune: async (now) => {\n const result = await db().delete(authRateLimits).where(lt(authRateLimits.expiresAt, now));\n return result.rowCount ?? 0;\n },\n});\n\nexport type ConsumeAuthRateLimitParams = ConsumeRateLimitParams<string, string>;\nexport type ConsumeAuthRateLimitResult = ConsumeRateLimitResult;\nexport const consumeAuthRateLimit = persistence.consume;\nexport const pruneExpiredAuthRateLimits = persistence.prune;\n"],"names":["createRateLimitPersistence","lt","sql","db","authRateLimits","persistence","transaction","callback","setStatementTimeout","timeoutMs","execute","consume","params","rows","insert","values","action","scope","identifierHmac","windowStart","count","expiresAt","onConflictDoUpdate","target","set","updatedAt","returning","prune","now","result","delete","where","rowCount","consumeAuthRateLimit","pruneExpiredAuthRateLimits"],"mappings":"AAAA,SAGEA,0BAA0B,QACrB,2BAA2B;AAClC,SAAQC,EAAE,EAAEC,GAAG,QAAO,cAAc;AACpC,SAAQC,EAAE,QAAO,UAAU;AAC3B,SAAQC,cAAc,QAAO,0BAA0B;AAIvD,MAAMC,cAAcL,2BAAqD;IACvEM,aAAa,CAACC,WAAaJ,KAAKG,WAAW,CAACC;IAC5CC,qBAAqB,OAAOF,aAAaG;QACvC,MAAMH,YAAYI,OAAO,CACvBR,GAAG,CAAC,uCAAuC,EAAE,GAAGO,UAAU,EAAE,CAAC,CAAC,OAAO,CAAC;IAE1E;IACAE,SAAS,OAAOL,aAAaM;QAC3B,MAAMC,OAAO,MAAMP,YAChBQ,MAAM,CAACV,gBACPW,MAAM,CAAC;YACNC,QAAQJ,OAAOI,MAAM;YACrBC,OAAOL,OAAOK,KAAK;YACnBC,gBAAgBN,OAAOM,cAAc;YACrCC,aAAaP,OAAOO,WAAW;YAC/BC,OAAO;YACPC,WAAWT,OAAOS,SAAS;QAC7B,GACCC,kBAAkB,CAAC;YAClBC,QAAQ;gBACNnB,eAAeY,MAAM;gBACrBZ,eAAea,KAAK;gBACpBb,eAAec,cAAc;gBAC7Bd,eAAee,WAAW;aAC3B;YACDK,KAAK;gBACHJ,OAAOlB,GAAG,CAAC,EAAEE,eAAegB,KAAK,CAAC,IAAI,CAAC;gBACvCK,WAAWvB,GAAG,CAAC,KAAK,CAAC;YACvB;QACF,GACCwB,SAAS,CAAC;YAACN,OAAOhB,eAAegB,KAAK;YAAEC,WAAWjB,eAAeiB,SAAS;QAAA;QAE9E,OAAOR,IAAI,CAAC,EAAE;IAChB;IACAc,OAAO,OAAOC;QACZ,MAAMC,SAAS,MAAM1B,KAAK2B,MAAM,CAAC1B,gBAAgB2B,KAAK,CAAC9B,GAAGG,eAAeiB,SAAS,EAAEO;QACpF,OAAOC,OAAOG,QAAQ,IAAI;IAC5B;AACF;AAIA,OAAO,MAAMC,uBAAuB5B,YAAYM,OAAO,CAAC;AACxD,OAAO,MAAMuB,6BAA6B7B,YAAYsB,KAAK,CAAC"}
@@ -11,7 +11,7 @@ export interface CreateRefreshTokenParams {
11
11
  export declare function createRefreshToken(params: CreateRefreshTokenParams): Promise<RefreshToken>;
12
12
  export declare function createRefreshTokenForActiveUser(params: CreateRefreshTokenParams): Promise<RefreshToken | undefined>;
13
13
  /**
14
- * Looks up the live session token by hash one that can still authenticate as
14
+ * Looks up the live session token by hash. It can still authenticate as
15
15
  * the current session. Returns `undefined` for revoked, expired, or already
16
16
  * rotated tokens; rotated rows survive in the table only for the grace window
17
17
  * and are not "active".
@@ -22,8 +22,8 @@ export declare function findActiveRefreshTokenByHash(params: {
22
22
  /**
23
23
  * Looks up a non-revoked, non-expired token by hash, including ones already
24
24
  * rotated. Rotation reads {@link RefreshToken.rotatedAt} to decide whether to
25
- * rotate, honour the grace window, or reject reuse, so unlike
26
- * {@link findActiveRefreshTokenByHash} it must still see rotated rows.
25
+ * rotate, honour the grace window, or reject reuse, so, unlike
26
+ * {@link findActiveRefreshTokenByHash}, it must still see rotated rows.
27
27
  */
28
28
  export declare function findRefreshTokenByHash(params: {
29
29
  hashedToken: string;
@@ -35,7 +35,7 @@ export async function createRefreshTokenForActiveUser(params) {
35
35
  });
36
36
  }
37
37
  /**
38
- * Looks up the live session token by hash one that can still authenticate as
38
+ * Looks up the live session token by hash. It can still authenticate as
39
39
  * the current session. Returns `undefined` for revoked, expired, or already
40
40
  * rotated tokens; rotated rows survive in the table only for the grace window
41
41
  * and are not "active".
@@ -48,8 +48,8 @@ export async function createRefreshTokenForActiveUser(params) {
48
48
  /**
49
49
  * Looks up a non-revoked, non-expired token by hash, including ones already
50
50
  * rotated. Rotation reads {@link RefreshToken.rotatedAt} to decide whether to
51
- * rotate, honour the grace window, or reject reuse, so unlike
52
- * {@link findActiveRefreshTokenByHash} it must still see rotated rows.
51
+ * rotate, honour the grace window, or reject reuse, so, unlike
52
+ * {@link findActiveRefreshTokenByHash}, it must still see rotated rows.
53
53
  */ export async function findRefreshTokenByHash(params) {
54
54
  const rows = await db().select().from(refreshTokens).where(and(eq(refreshTokens.hashedToken, params.hashedToken), isNull(refreshTokens.revokedAt), gt(refreshTokens.expiresAt, sql`now()`))).limit(1);
55
55
  const row = rows[0];
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/db/refresh-tokens.ts"],"sourcesContent":["import {and, eq, gt, isNull, ne, sql} from 'drizzle-orm';\nimport type {RefreshToken} from '#core/entities/refresh-token.js';\nimport {db} from './db.js';\nimport {refreshTokens, toRefreshToken} from './schema/refresh-tokens.js';\nimport {users} from './schema/users.js';\n\ntype Tx = Parameters<Parameters<ReturnType<typeof db>['transaction']>[0]>[0];\n\nexport async function lockUserSessionMutations(tx: Tx, userId: string): Promise<void> {\n await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`auth_user_sessions:${userId}`}))`);\n}\n\nexport interface CreateRefreshTokenParams {\n sessionId?: string | undefined;\n userId: string;\n hashedToken: string;\n expiresAt: Date;\n}\n\nexport async function createRefreshToken(params: CreateRefreshTokenParams): Promise<RefreshToken> {\n const rows = await db()\n .insert(refreshTokens)\n .values({\n sessionId: params.sessionId,\n userId: params.userId,\n hashedToken: params.hashedToken,\n expiresAt: params.expiresAt,\n })\n .returning();\n\n const row = rows[0];\n if (!row) throw new Error('Insert returned no rows');\n return toRefreshToken(row);\n}\n\nexport async function createRefreshTokenForActiveUser(\n params: CreateRefreshTokenParams,\n): Promise<RefreshToken | undefined> {\n return await db().transaction(async (tx) => {\n await lockUserSessionMutations(tx, params.userId);\n const userRows = await tx\n .select({status: users.status})\n .from(users)\n .where(eq(users.id, params.userId))\n .limit(1);\n if (userRows[0]?.status !== 'active') return undefined;\n\n const rows = await tx\n .insert(refreshTokens)\n .values({\n sessionId: params.sessionId,\n userId: params.userId,\n hashedToken: params.hashedToken,\n expiresAt: params.expiresAt,\n })\n .returning();\n const row = rows[0];\n if (!row) throw new Error('Insert returned no rows');\n return toRefreshToken(row);\n });\n}\n\n/**\n * Looks up the live session token by hash — one that can still authenticate as\n * the current session. Returns `undefined` for revoked, expired, or already\n * rotated tokens; rotated rows survive in the table only for the grace window\n * and are not \"active\".\n */\nexport async function findActiveRefreshTokenByHash(params: {\n hashedToken: string;\n}): Promise<RefreshToken | undefined> {\n const rows = await db()\n .select()\n .from(refreshTokens)\n .where(\n and(\n eq(refreshTokens.hashedToken, params.hashedToken),\n isNull(refreshTokens.revokedAt),\n isNull(refreshTokens.rotatedAt),\n gt(refreshTokens.expiresAt, sql`now()`),\n ),\n )\n .limit(1);\n\n const row = rows[0];\n if (!row) return undefined;\n return toRefreshToken(row);\n}\n\n/**\n * Looks up a non-revoked, non-expired token by hash, including ones already\n * rotated. Rotation reads {@link RefreshToken.rotatedAt} to decide whether to\n * rotate, honour the grace window, or reject reuse, so — unlike\n * {@link findActiveRefreshTokenByHash} — it must still see rotated rows.\n */\nexport async function findRefreshTokenByHash(params: {\n hashedToken: string;\n}): Promise<RefreshToken | undefined> {\n const rows = await db()\n .select()\n .from(refreshTokens)\n .where(\n and(\n eq(refreshTokens.hashedToken, params.hashedToken),\n isNull(refreshTokens.revokedAt),\n gt(refreshTokens.expiresAt, sql`now()`),\n ),\n )\n .limit(1);\n\n const row = rows[0];\n if (!row) return undefined;\n return toRefreshToken(row);\n}\n\n/**\n * Atomically claims rotation and inserts the successor token. The predecessor is\n * marked rather than deleted so later reuse of the retired token can be detected.\n * If the successor insert fails, the predecessor rotation rolls back.\n */\nexport async function rotateRefreshToken(params: {\n userId?: string | undefined;\n id: string;\n currentHashedToken: string;\n nextHashedToken: string;\n expiresAt: Date;\n}): Promise<RefreshToken | undefined> {\n return await db().transaction(async (tx) => {\n const userId =\n params.userId ??\n (\n await tx\n .select({userId: refreshTokens.userId})\n .from(refreshTokens)\n .where(eq(refreshTokens.id, params.id))\n .limit(1)\n )[0]?.userId;\n if (!userId) return undefined;\n\n await lockUserSessionMutations(tx, userId);\n const userRows = await tx\n .select({status: users.status})\n .from(users)\n .where(eq(users.id, userId))\n .limit(1);\n if (userRows[0]?.status !== 'active') return undefined;\n\n const rotatedRows = await tx\n .update(refreshTokens)\n .set({\n rotatedAt: sql`now()`,\n lastUsedAt: sql`now()`,\n updatedAt: sql`now()`,\n })\n .where(\n and(\n eq(refreshTokens.id, params.id),\n eq(refreshTokens.hashedToken, params.currentHashedToken),\n isNull(refreshTokens.revokedAt),\n isNull(refreshTokens.rotatedAt),\n gt(refreshTokens.expiresAt, sql`now()`),\n ),\n )\n .returning();\n\n const rotated = rotatedRows[0];\n if (!rotated) return undefined;\n\n const successorRows = await tx\n .insert(refreshTokens)\n .values({\n sessionId: rotated.sessionId,\n userId: rotated.userId,\n hashedToken: params.nextHashedToken,\n expiresAt: params.expiresAt,\n })\n .returning();\n\n if (!successorRows[0]) throw new Error('Insert returned no rows');\n\n return toRefreshToken(rotated);\n });\n}\n\nexport async function revokeRefreshTokenByHash(params: {hashedToken: string}): Promise<void> {\n await db()\n .update(refreshTokens)\n .set({revokedAt: sql`now()`, updatedAt: sql`now()`})\n .where(and(eq(refreshTokens.hashedToken, params.hashedToken), isNull(refreshTokens.revokedAt)));\n}\n\nexport async function revokeRefreshSession(params: {\n sessionId: string;\n userId: string;\n}): Promise<void> {\n await db()\n .update(refreshTokens)\n .set({revokedAt: sql`now()`, updatedAt: sql`now()`})\n .where(\n and(\n eq(refreshTokens.sessionId, params.sessionId),\n eq(refreshTokens.userId, params.userId),\n isNull(refreshTokens.revokedAt),\n ),\n );\n}\n\nexport async function revokeRefreshTokensForUser(params: {\n userId: string;\n exceptRefreshTokenId?: string | undefined;\n}): Promise<void> {\n await db()\n .update(refreshTokens)\n .set({revokedAt: sql`now()`, updatedAt: sql`now()`})\n .where(\n and(\n eq(refreshTokens.userId, params.userId),\n isNull(refreshTokens.revokedAt),\n params.exceptRefreshTokenId ? ne(refreshTokens.id, params.exceptRefreshTokenId) : undefined,\n ),\n );\n}\n"],"names":["and","eq","gt","isNull","ne","sql","db","refreshTokens","toRefreshToken","users","lockUserSessionMutations","tx","userId","execute","createRefreshToken","params","rows","insert","values","sessionId","hashedToken","expiresAt","returning","row","Error","createRefreshTokenForActiveUser","transaction","userRows","select","status","from","where","id","limit","undefined","findActiveRefreshTokenByHash","revokedAt","rotatedAt","findRefreshTokenByHash","rotateRefreshToken","rotatedRows","update","set","lastUsedAt","updatedAt","currentHashedToken","rotated","successorRows","nextHashedToken","revokeRefreshTokenByHash","revokeRefreshSession","revokeRefreshTokensForUser","exceptRefreshTokenId"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/db/refresh-tokens.ts"],"sourcesContent":["import {and, eq, gt, isNull, ne, sql} from 'drizzle-orm';\nimport type {RefreshToken} from '#core/entities/refresh-token.js';\nimport {db} from './db.js';\nimport {refreshTokens, toRefreshToken} from './schema/refresh-tokens.js';\nimport {users} from './schema/users.js';\n\ntype Tx = Parameters<Parameters<ReturnType<typeof db>['transaction']>[0]>[0];\n\nexport async function lockUserSessionMutations(tx: Tx, userId: string): Promise<void> {\n await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`auth_user_sessions:${userId}`}))`);\n}\n\nexport interface CreateRefreshTokenParams {\n sessionId?: string | undefined;\n userId: string;\n hashedToken: string;\n expiresAt: Date;\n}\n\nexport async function createRefreshToken(params: CreateRefreshTokenParams): Promise<RefreshToken> {\n const rows = await db()\n .insert(refreshTokens)\n .values({\n sessionId: params.sessionId,\n userId: params.userId,\n hashedToken: params.hashedToken,\n expiresAt: params.expiresAt,\n })\n .returning();\n\n const row = rows[0];\n if (!row) throw new Error('Insert returned no rows');\n return toRefreshToken(row);\n}\n\nexport async function createRefreshTokenForActiveUser(\n params: CreateRefreshTokenParams,\n): Promise<RefreshToken | undefined> {\n return await db().transaction(async (tx) => {\n await lockUserSessionMutations(tx, params.userId);\n const userRows = await tx\n .select({status: users.status})\n .from(users)\n .where(eq(users.id, params.userId))\n .limit(1);\n if (userRows[0]?.status !== 'active') return undefined;\n\n const rows = await tx\n .insert(refreshTokens)\n .values({\n sessionId: params.sessionId,\n userId: params.userId,\n hashedToken: params.hashedToken,\n expiresAt: params.expiresAt,\n })\n .returning();\n const row = rows[0];\n if (!row) throw new Error('Insert returned no rows');\n return toRefreshToken(row);\n });\n}\n\n/**\n * Looks up the live session token by hash. It can still authenticate as\n * the current session. Returns `undefined` for revoked, expired, or already\n * rotated tokens; rotated rows survive in the table only for the grace window\n * and are not \"active\".\n */\nexport async function findActiveRefreshTokenByHash(params: {\n hashedToken: string;\n}): Promise<RefreshToken | undefined> {\n const rows = await db()\n .select()\n .from(refreshTokens)\n .where(\n and(\n eq(refreshTokens.hashedToken, params.hashedToken),\n isNull(refreshTokens.revokedAt),\n isNull(refreshTokens.rotatedAt),\n gt(refreshTokens.expiresAt, sql`now()`),\n ),\n )\n .limit(1);\n\n const row = rows[0];\n if (!row) return undefined;\n return toRefreshToken(row);\n}\n\n/**\n * Looks up a non-revoked, non-expired token by hash, including ones already\n * rotated. Rotation reads {@link RefreshToken.rotatedAt} to decide whether to\n * rotate, honour the grace window, or reject reuse, so, unlike\n * {@link findActiveRefreshTokenByHash}, it must still see rotated rows.\n */\nexport async function findRefreshTokenByHash(params: {\n hashedToken: string;\n}): Promise<RefreshToken | undefined> {\n const rows = await db()\n .select()\n .from(refreshTokens)\n .where(\n and(\n eq(refreshTokens.hashedToken, params.hashedToken),\n isNull(refreshTokens.revokedAt),\n gt(refreshTokens.expiresAt, sql`now()`),\n ),\n )\n .limit(1);\n\n const row = rows[0];\n if (!row) return undefined;\n return toRefreshToken(row);\n}\n\n/**\n * Atomically claims rotation and inserts the successor token. The predecessor is\n * marked rather than deleted so later reuse of the retired token can be detected.\n * If the successor insert fails, the predecessor rotation rolls back.\n */\nexport async function rotateRefreshToken(params: {\n userId?: string | undefined;\n id: string;\n currentHashedToken: string;\n nextHashedToken: string;\n expiresAt: Date;\n}): Promise<RefreshToken | undefined> {\n return await db().transaction(async (tx) => {\n const userId =\n params.userId ??\n (\n await tx\n .select({userId: refreshTokens.userId})\n .from(refreshTokens)\n .where(eq(refreshTokens.id, params.id))\n .limit(1)\n )[0]?.userId;\n if (!userId) return undefined;\n\n await lockUserSessionMutations(tx, userId);\n const userRows = await tx\n .select({status: users.status})\n .from(users)\n .where(eq(users.id, userId))\n .limit(1);\n if (userRows[0]?.status !== 'active') return undefined;\n\n const rotatedRows = await tx\n .update(refreshTokens)\n .set({\n rotatedAt: sql`now()`,\n lastUsedAt: sql`now()`,\n updatedAt: sql`now()`,\n })\n .where(\n and(\n eq(refreshTokens.id, params.id),\n eq(refreshTokens.hashedToken, params.currentHashedToken),\n isNull(refreshTokens.revokedAt),\n isNull(refreshTokens.rotatedAt),\n gt(refreshTokens.expiresAt, sql`now()`),\n ),\n )\n .returning();\n\n const rotated = rotatedRows[0];\n if (!rotated) return undefined;\n\n const successorRows = await tx\n .insert(refreshTokens)\n .values({\n sessionId: rotated.sessionId,\n userId: rotated.userId,\n hashedToken: params.nextHashedToken,\n expiresAt: params.expiresAt,\n })\n .returning();\n\n if (!successorRows[0]) throw new Error('Insert returned no rows');\n\n return toRefreshToken(rotated);\n });\n}\n\nexport async function revokeRefreshTokenByHash(params: {hashedToken: string}): Promise<void> {\n await db()\n .update(refreshTokens)\n .set({revokedAt: sql`now()`, updatedAt: sql`now()`})\n .where(and(eq(refreshTokens.hashedToken, params.hashedToken), isNull(refreshTokens.revokedAt)));\n}\n\nexport async function revokeRefreshSession(params: {\n sessionId: string;\n userId: string;\n}): Promise<void> {\n await db()\n .update(refreshTokens)\n .set({revokedAt: sql`now()`, updatedAt: sql`now()`})\n .where(\n and(\n eq(refreshTokens.sessionId, params.sessionId),\n eq(refreshTokens.userId, params.userId),\n isNull(refreshTokens.revokedAt),\n ),\n );\n}\n\nexport async function revokeRefreshTokensForUser(params: {\n userId: string;\n exceptRefreshTokenId?: string | undefined;\n}): Promise<void> {\n await db()\n .update(refreshTokens)\n .set({revokedAt: sql`now()`, updatedAt: sql`now()`})\n .where(\n and(\n eq(refreshTokens.userId, params.userId),\n isNull(refreshTokens.revokedAt),\n params.exceptRefreshTokenId ? ne(refreshTokens.id, params.exceptRefreshTokenId) : undefined,\n ),\n );\n}\n"],"names":["and","eq","gt","isNull","ne","sql","db","refreshTokens","toRefreshToken","users","lockUserSessionMutations","tx","userId","execute","createRefreshToken","params","rows","insert","values","sessionId","hashedToken","expiresAt","returning","row","Error","createRefreshTokenForActiveUser","transaction","userRows","select","status","from","where","id","limit","undefined","findActiveRefreshTokenByHash","revokedAt","rotatedAt","findRefreshTokenByHash","rotateRefreshToken","rotatedRows","update","set","lastUsedAt","updatedAt","currentHashedToken","rotated","successorRows","nextHashedToken","revokeRefreshTokenByHash","revokeRefreshSession","revokeRefreshTokensForUser","exceptRefreshTokenId"],"mappings":"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"}
@@ -1,6 +1,6 @@
1
1
  import type { AuthMethod } from '@shipfox/node-fastify';
2
2
  /**
3
- * Trust boundary: the signed token is the sole authority `claims.jobId` scopes
3
+ * Trust boundary: the signed token is the sole authority. `claims.jobId` scopes
4
4
  * every runner request to exactly one job. `workflowRunId`/`workflowRunAttemptId`/`workspaceId`
5
5
  * are carried for consumers but are NOT verified against the database here.
6
6
  *
@@ -2,7 +2,7 @@ import { AUTH_LEASED_JOB, setLeasedJobContext } from '@shipfox/api-auth-context'
2
2
  import { verifyJobLeaseToken } from '#core/job-lease-token.js';
3
3
  import { createBearerTokenAuthMethod } from './bearer-token-auth.js';
4
4
  /**
5
- * Trust boundary: the signed token is the sole authority `claims.jobId` scopes
5
+ * Trust boundary: the signed token is the sole authority. `claims.jobId` scopes
6
6
  * every runner request to exactly one job. `workflowRunId`/`workflowRunAttemptId`/`workspaceId`
7
7
  * are carried for consumers but are NOT verified against the database here.
8
8
  *
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/presentation/auth/lease-token-auth.ts"],"sourcesContent":["import {AUTH_LEASED_JOB, setLeasedJobContext} from '@shipfox/api-auth-context';\nimport type {AuthMethod} from '@shipfox/node-fastify';\nimport {verifyJobLeaseToken} from '#core/job-lease-token.js';\nimport {createBearerTokenAuthMethod} from './bearer-token-auth.js';\n\n/**\n * Trust boundary: the signed token is the sole authority `claims.jobId` scopes\n * every runner request to exactly one job. `workflowRunId`/`workflowRunAttemptId`/`workspaceId`\n * are carried for consumers but are NOT verified against the database here.\n *\n * Revocation tradeoff: `runnerSessionId` is carried in the claims but is not checked\n * against runner-session storage, so a lease minted before its registration token is\n * revoked stays usable until it expires (`AUTH_JOB_LEASE_TOKEN_EXPIRES_IN`, 90m).\n * Accepted deliberately for a short-lived, job-scoped capability token; tightening\n * this would mean a per-request DB lookup on the hot status-reporting path. See the\n * \"Security model\" section in the package README for the full rationale.\n */\nexport function createLeaseTokenAuthMethod(): AuthMethod {\n return createBearerTokenAuthMethod({\n name: AUTH_LEASED_JOB,\n verifyToken: verifyJobLeaseToken,\n invalidTokenError: {message: 'Invalid or expired job lease token', code: 'unauthorized'},\n setContext: setLeasedJobContext,\n });\n}\n"],"names":["AUTH_LEASED_JOB","setLeasedJobContext","verifyJobLeaseToken","createBearerTokenAuthMethod","createLeaseTokenAuthMethod","name","verifyToken","invalidTokenError","message","code","setContext"],"mappings":"AAAA,SAAQA,eAAe,EAAEC,mBAAmB,QAAO,4BAA4B;AAE/E,SAAQC,mBAAmB,QAAO,2BAA2B;AAC7D,SAAQC,2BAA2B,QAAO,yBAAyB;AAEnE;;;;;;;;;;;CAWC,GACD,OAAO,SAASC;IACd,OAAOD,4BAA4B;QACjCE,MAAML;QACNM,aAAaJ;QACbK,mBAAmB;YAACC,SAAS;YAAsCC,MAAM;QAAc;QACvFC,YAAYT;IACd;AACF"}
1
+ {"version":3,"sources":["../../../src/presentation/auth/lease-token-auth.ts"],"sourcesContent":["import {AUTH_LEASED_JOB, setLeasedJobContext} from '@shipfox/api-auth-context';\nimport type {AuthMethod} from '@shipfox/node-fastify';\nimport {verifyJobLeaseToken} from '#core/job-lease-token.js';\nimport {createBearerTokenAuthMethod} from './bearer-token-auth.js';\n\n/**\n * Trust boundary: the signed token is the sole authority. `claims.jobId` scopes\n * every runner request to exactly one job. `workflowRunId`/`workflowRunAttemptId`/`workspaceId`\n * are carried for consumers but are NOT verified against the database here.\n *\n * Revocation tradeoff: `runnerSessionId` is carried in the claims but is not checked\n * against runner-session storage, so a lease minted before its registration token is\n * revoked stays usable until it expires (`AUTH_JOB_LEASE_TOKEN_EXPIRES_IN`, 90m).\n * Accepted deliberately for a short-lived, job-scoped capability token; tightening\n * this would mean a per-request DB lookup on the hot status-reporting path. See the\n * \"Security model\" section in the package README for the full rationale.\n */\nexport function createLeaseTokenAuthMethod(): AuthMethod {\n return createBearerTokenAuthMethod({\n name: AUTH_LEASED_JOB,\n verifyToken: verifyJobLeaseToken,\n invalidTokenError: {message: 'Invalid or expired job lease token', code: 'unauthorized'},\n setContext: setLeasedJobContext,\n });\n}\n"],"names":["AUTH_LEASED_JOB","setLeasedJobContext","verifyJobLeaseToken","createBearerTokenAuthMethod","createLeaseTokenAuthMethod","name","verifyToken","invalidTokenError","message","code","setContext"],"mappings":"AAAA,SAAQA,eAAe,EAAEC,mBAAmB,QAAO,4BAA4B;AAE/E,SAAQC,mBAAmB,QAAO,2BAA2B;AAC7D,SAAQC,2BAA2B,QAAO,yBAAyB;AAEnE;;;;;;;;;;;CAWC,GACD,OAAO,SAASC;IACd,OAAOD,4BAA4B;QACjCE,MAAML;QACNM,aAAaJ;QACbK,mBAAmB;YAACC,SAAS;YAAsCC,MAAM;QAAc;QACvFC,YAAYT;IACd;AACF"}
@@ -1 +1 @@
1
- {"version":3,"file":"rate-limit.d.ts","sourceRoot":"","sources":["../../../src/presentation/routes/rate-limit.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,KAAK,YAAY,EAAE,KAAK,cAAc,EAAC,MAAM,uBAAuB,CAAC;AAC1F,OAAO,EACL,KAAK,mBAAmB,EAMzB,MAAM,qBAAqB,CAAC;AAyB7B,UAAU,SAAS;IACjB,KAAK,EAAE,MAAM,CAAC;CACf;AAgFD,wBAAgB,6BAA6B,CAAC,MAAM,EAAE,mBAAmB,IACzD,SAAS,cAAc,CAAC;IAAC,IAAI,EAAE,SAAS,CAAA;CAAC,CAAC,EAAE,OAAO,YAAY,KAAG,OAAO,CAAC,IAAI,CAAC,CAiB9F;AAED,wBAAgB,+BAA+B,CAAC,MAAM,EAAE,mBAAmB,IAC3D,SAAS,cAAc,EAAE,OAAO,YAAY,KAAG,OAAO,CAAC,IAAI,CAAC,CAS3E"}
1
+ {"version":3,"file":"rate-limit.d.ts","sourceRoot":"","sources":["../../../src/presentation/routes/rate-limit.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,KAAK,YAAY,EAAE,KAAK,cAAc,EAAC,MAAM,uBAAuB,CAAC;AAE1F,OAAO,EACL,KAAK,mBAAmB,EAIzB,MAAM,qBAAqB,CAAC;AAyB7B,UAAU,SAAS;IACjB,KAAK,EAAE,MAAM,CAAC;CACf;AAgDD,wBAAgB,6BAA6B,CAAC,MAAM,EAAE,mBAAmB,IACzD,SAAS,cAAc,CAAC;IAAC,IAAI,EAAE,SAAS,CAAA;CAAC,CAAC,EAAE,OAAO,YAAY,KAAG,OAAO,CAAC,IAAI,CAAC,CAiB9F;AAED,wBAAgB,+BAA+B,CAAC,MAAM,EAAE,mBAAmB,IAC3D,SAAS,cAAc,EAAE,OAAO,YAAY,KAAG,OAAO,CAAC,IAAI,CAAC,CAS3E"}
@@ -1,5 +1,6 @@
1
1
  import { ClientError } from '@shipfox/node-fastify';
2
- import { AuthRateLimitExceededError, AuthRateLimitUnavailableError, checkAuthRateLimit } from '#core/rate-limit.js';
2
+ import { enforceRateLimit as enforceSharedRateLimit } from '@shipfox/node-rate-limit';
3
+ import { checkAuthRateLimit } from '#core/rate-limit.js';
3
4
  const policies = {
4
5
  login: {
5
6
  ip: {
@@ -46,58 +47,36 @@ function routeName(request) {
46
47
  async function enforceRateLimit(params) {
47
48
  const policy = policies[params.action][params.scope];
48
49
  if (!policy) return;
49
- try {
50
- await checkAuthRateLimit({
51
- action: params.action,
52
- scope: params.scope,
53
- identifier: params.identifier,
54
- ...policy
55
- });
56
- } catch (error) {
57
- if (error instanceof AuthRateLimitExceededError) {
58
- params.request.log.warn({
59
- action: error.action,
60
- scope: error.scope,
61
- route: routeName(params.request),
62
- retryAfterSeconds: error.retryAfterSeconds,
63
- identifierHmacPrefix: error.identifierHmacPrefix
64
- }, 'Auth rate limit blocked request');
65
- params.reply.header('Retry-After', String(error.retryAfterSeconds));
66
- throw new ClientError('Rate limit exceeded', 'rate-limited', {
67
- status: 429,
68
- details: {
69
- retry_after_seconds: error.retryAfterSeconds
70
- },
71
- data: {
72
- action: error.action,
73
- scope: error.scope,
74
- route: routeName(params.request),
75
- identifierHmacPrefix: error.identifierHmacPrefix
76
- },
77
- cause: error
78
- });
79
- }
80
- if (error instanceof AuthRateLimitUnavailableError) {
81
- params.request.log.error({
82
- action: error.action,
83
- scope: error.scope,
84
- route: routeName(params.request),
85
- identifierHmacPrefix: error.identifierHmacPrefix,
86
- err: error
87
- }, 'Auth rate limiter unavailable');
88
- throw new ClientError('Authentication rate limiter unavailable', 'auth-rate-limit-unavailable', {
89
- status: 503,
90
- data: {
91
- action: error.action,
92
- scope: error.scope,
93
- route: routeName(params.request),
94
- identifierHmacPrefix: error.identifierHmacPrefix
95
- },
96
- cause: error
97
- });
98
- }
99
- throw error;
100
- }
50
+ await enforceSharedRateLimit({
51
+ request: params.request,
52
+ reply: params.reply,
53
+ check: ()=>checkAuthRateLimit({
54
+ action: params.action,
55
+ scope: params.scope,
56
+ identifier: params.identifier,
57
+ ...policy
58
+ }),
59
+ route: routeName(params.request),
60
+ unavailableCode: 'auth-rate-limit-unavailable',
61
+ unavailableMessage: 'Authentication rate limiter unavailable',
62
+ setRetryAfter: (reply, retryAfterSeconds)=>{
63
+ reply.header('Retry-After', String(retryAfterSeconds));
64
+ },
65
+ logWarn: (request, context)=>{
66
+ request.log.warn(context, 'Auth rate limit blocked request');
67
+ },
68
+ logError: (request, context)=>{
69
+ request.log.error(context, 'Auth rate limiter unavailable');
70
+ },
71
+ createClientError: (presentation, cause)=>new ClientError(presentation.message, presentation.code, {
72
+ status: presentation.status,
73
+ ...presentation.details ? {
74
+ details: presentation.details
75
+ } : {},
76
+ data: presentation.data,
77
+ cause
78
+ })
79
+ });
101
80
  }
102
81
  export function createAuthRateLimitPreHandler(action) {
103
82
  return async (request, reply)=>{