@payez/next-mvp 4.9.0 → 4.10.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,105 @@
1
+ # Changelog — @payez/next-mvp
2
+
3
+ ## 4.10.0 (to be published under dist-tag `next` only, after 4.9.0; `latest` stays 4.8.9 until Jon says otherwise)
4
+
5
+ Carries PRs 1432, 1435 and 1438. A minor, not a patch, because a removed export breaks importers (BREAKING below).
6
+
7
+ - **FIX: a refresh sends only the token read AFTER it holds the lock (BAPert 76298 (b2); PR 1432).** `api-handlers/auth/refresh` read the session before the lock and sent that read's refresh token; if another request rotated the token in between and the row it left was not fresh by the handler's 5-minute test, the handler sent a SPENT token, and the IdP treats reuse as compromise. It now sends `latestAfterLock.idpRefreshToken`, as `ensureFreshAccessToken` already did. A session row that vanished while waiting for the lock answers the 'No session available' 401; a row with no refresh token (for example one the IdP just discarded) answers the terminal `NO_REFRESH_TOKEN`, with NO IdP call and the row untouched. When the IdP's answer carries no new refresh token, the write-back keeps the token that was SENT.
8
+ - **FIX: one meaning for an IdP answer of HTTP 200 with `success:false`, in both refresh implementations (BAPert 76298 (c); PR 1435).** `refresh.ts` used to answer the IdP's own status, i.e. HTTP 200 with an error body, so `internalRefresh` reported `ok` and `idpFetchJSON`'s retry trusted a refresh that had failed; it also wrote a different marker (`IDP_SUCCESS_FALSE_DISCARD`) than the 401 branch, which the logout dead-grant check does not recognise. `ensureFreshAccessToken` never discarded. Now, in both: `success:false` WITH `discard_token` discards the stored token (the same compare-and-skip as the 401 branch) with the `IDP_DISCARD_TOKEN:<code>` marker and answers 401; WITHOUT it, a retryable 502 with the token KEPT. If a concurrent request already rotated the token, the discard is skipped and the fresh result is returned. The old marker `IDP_SUCCESS_FALSE_DISCARD` is no longer written (old rows that carry it are not rewritten).
9
+ - **FIX: the "already rotated" branches no longer count an expired access token as fresh (PR 1435).** When a refresh was answered 401 or `success:false` and the stored refresh token differs from the one sent, a concurrent request rotated it. The handler used to treat any row with an access token present as a completed refresh, so a rotated row whose access token was already EXPIRED was answered as a success carrying an expired token. One shared predicate (`lib/refresh-rotation` `rotatedRowIsUsable`: access token present AND `idpAccessTokenExpires > now`) now applies to all four such branches (both implementations, each for the 401 and for `success:false`). A rotated row that is not usable (expired, or no access token, which the 401 branches used to answer as an upstream 401) now answers the retryable 502, with the rotated token kept and no marker written. A 401/`success:false` against a token that is still the stored one is unchanged.
10
+ - **FIX: storing a refresh token clears a stale discard marker (PR 1435).** `session-store` `updateSession` and `setSession` drop `refreshTokenClearedReason` and `refreshTokenClearedAt` whenever a non-empty `idpRefreshToken` is stored, so a session that was discarded and then re-established cannot be mistaken later for a dead grant by the logout check.
11
+ - **BREAKING: `@payez/next-mvp/api/auth-handler` and `@payez/next-mvp/api` are REMOVED** (PR 1438: the exports-map entries, `src/api/auth-handler.ts`, and the `createAuthHandler` / `AuthContext` / `AuthHandlerOptions` / `HandlerFunction` exports with the module's default export). It was a third refresh implementation that read the legacy session field names (`refreshToken`, `accessToken`, `accessTokenExpires`) which the session store does not write, so it could not refresh a real session; it had no `discard_token` handling and its own lock. Zero importers on the live portal head (Scout 76371), and `npx payez init` no longer names it (`@payez/cli` fix, PR 1436). Use `createRefreshHandler` (`api-handlers/auth/refresh`) or `ensureFreshAccessToken` (`lib/ensure-fresh-access-token`). The PAY-1427 "upstream body not logged" row that covered it now covers those two.
12
+ - **Packaging: `CHANGELOG.md` is now in the published `files`.** `lib/idp-logout` (new in 4.9.0) is internal: it is NOT in the exports map and is not a supported import path.
13
+ - Rows: `test/next-mvp-4900-refresh-sends-token-read-after-lock.test.cjs`, `test/next-mvp-4900-one-meaning-and-marker.test.cjs` (with mutants M1-M6), the updated `test/upstream-body-not-logged-outside-api-handlers.test.cjs`, and `packages/cli/test/init-auth-mount.test.mjs`. Core and the IdP are contract doubles in them; no live Core, Redis or IdP run.
14
+
15
+ ## 4.9.0 (to be published under dist-tag `next` only; `latest` stays 4.8.9 until Jon says otherwise)
16
+
17
+ - **BREAKING: `SESSION_NOT_ENDED_MESSAGE` is REMOVED from `routes/auth/logout` (no alias kept; an import error is the loud failure).** Use `SESSION_NOT_ENDED_IDP_MESSAGE` / `SESSION_NOT_ENDED_LOCAL_MESSAGE` and the typed `cause` (`SessionNotEndedCause`) on the `SESSION_NOT_ENDED` 500. This is why the release is a minor, not a patch: a patch must not break an importer.
18
+ - **The sentence in the 4.8.9 entry below ("...Try signing out again.") is SUPERSEDED by this release.** The 4.8.9 entry is left as it was.
19
+ - **FIX: the `ensureFreshAccessToken` refresh lock id is server-generated (BAPert 76298 (b); PR 1430).** It used to be the caller's `requestId`, and `server/auth.ts` passed the request's `x-request-id` header, with a branch that proceeded WITHOUT the lock when the lock holder's id equalled it, so a request header could skip the lock that protects against refresh-token reuse (the IdP treats reuse as compromise). The lock id is now `ensure_fresh_<randomUUID>`, the equality branch is deleted, `server/auth.ts` no longer passes the header, and the caller's `requestId` option is kept for log correlation only (`traceId`). Rows: `test/next-mvp-4810-ensure-fresh-lock-id.test.cjs`; with the old code restored, two concurrent callers sharing one id both refresh at the IdP.
20
+ - **FIX: `POST /api/auth/logout` now ends the IdP grant, not only the portal session (BAPert 76255 item 1; PR 1428).** Until 4.8.9 the route deleted the local session and made NO call to the IdP although its own comment said it revoked tokens there, so the IdP refresh token stayed valid until it expired. It now calls Core `POST /api/Account/logout` with the session's refresh token in the body (`{refresh_token}`, Bearer) BEFORE deleting the local session. The token is always sent: Core revokes the caller's NEWEST session when it is omitted. A session with no IdP refresh token (a provisional, pre-2FA session) makes no call.
21
+ - **Outcomes.** The IdP answered 2xx: signed out. The revoke was refused AND the refresh that followed was answered 401 AND the session carries the IdP's own discard marker (`refreshTokenClearedReason` starting `IDP_DISCARD_TOKEN`): the grant is already dead, which is a successful sign-out. Anything else unknown (transport error, 5xx, a refresh that failed any other way, an empty token without the marker) is a failure.
22
+ - **CHANGE: the `SESSION_NOT_ENDED` answer.** 4.8.9 exported `SESSION_NOT_ENDED_MESSAGE` from `routes/auth/logout` and its sentence promised a retry. That export is REMOVED. The 500 body keeps `code: 'SESSION_NOT_ENDED'` and gains a typed `cause`: `'idp_unconfirmed'` (the IdP outcome is unknown; `SESSION_NOT_ENDED_IDP_MESSAGE` = "We could not confirm the sign-out with the identity service.") or `'local_delete_failed'` (the local store delete failed, also when both fail; `SESSION_NOT_ENDED_LOCAL_MESSAGE` = "We could not finish signing you out on this device."). Neither sentence promises a retry. The local session and the browser's cookies are cleared on every path. Neither success answer (clean, or dead grant) carries a `cause`. Exports from `routes/auth/logout`: `SESSION_NOT_ENDED_CODE`, `SESSION_NOT_ENDED_IDP_MESSAGE`, `SESSION_NOT_ENDED_LOCAL_MESSAGE`, type `SessionNotEndedCause`. A consumer keyed on `code` alone keeps working; one that imported `SESSION_NOT_ENDED_MESSAGE` must switch to the two constants or to `cause`.
23
+ - **FIX (inside the same change): a refresh no longer retries with a stale body.** `idpFetchJSON` takes an optional fourth argument `{ initFor(sessionToken) }` that builds the request AFTER its own pre-call refresh and again after the refresh that follows a 401/403, because a refresh ROTATES the refresh token; its result gains `skipped` and `refreshStatus`. Absent the option, behaviour is unchanged. New module `lib/idp-logout` (`revokeSessionAtIdp`).
24
+ - Rows: `test/next-mvp-4810-logout-revokes-at-idp.test.cjs`, `test/next-mvp-4810-logout-retry-names-new-token.test.cjs`, and the updated `test/next-mvp-489-logout-honest.test.cjs` (30 in all). Core is a contract double in them; no live Core run. Mutants RED: revoke skipped, a failed revoke swallowed, the body built once, a status-only dead-grant check, an empty-token dead-grant check, the cause hard-coded either way.
25
+ - Known, not in this release: `api-handlers/auth/signout.ts` still makes no IdP call.
26
+
27
+ ## 4.8.9 (to be published under dist-tag `next` only)
28
+
29
+ - **FIX: `POST /api/auth/logout` no longer says success when the server session was not deleted (BAPert 76110 item 3).** A failed `deleteSessionEverywhere` was caught, logged as a warning, and the route still answered `{success:true,message:'Logged out successfully'}` although the server-side session was still live. Now the cookies are still cleared (the browser is signed out either way), but the answer is a typed failure: status 500, `{success:false, code:'SESSION_NOT_ENDED', message:'You were signed out on this device, but we could not end your session on the server. Try signing out again.'}`, and the log line is an error that says the server session is STILL LIVE. The thrown text is never returned. `SESSION_NOT_ENDED_CODE` and `SESSION_NOT_ENDED_MESSAGE` are exported from the route module. A caller that read only `success` now sees `false`; the no-session answer is unchanged.
30
+ - Rows: `test/next-mvp-489-logout-honest.test.cjs` (7): the real route, a store that throws, status + code + the fixed sentence and nothing else, every session cookie expired, logged at error, recovery with a working store, no-session unchanged. Mutants RED: the failure swallowed again, cookies not cleared on failure, the log downgraded to a warning, the sentence changed.
31
+
32
+ ## 4.8.8 (item 24; to be published under dist-tag `next` only)
33
+
34
+ - **CHANGE: the app requires `IDP_URL` and refuses to START without it (Jon, item 24: "fast and loud are how i like my config errors").** New leaf export `@payez/next-mvp/lib/require-idp-url` with `requireIdpUrl()`: when `IDP_URL` is missing, blank, or not a full http(s) address it prints one fixed sentence ("IDP_URL is not set to a full http(s) address. This app has no default sign-in service: set IDP_URL in .env.local and start again.") and exits 1; with a valid value it returns true and prints nothing. There is no default in code and there never was one in the runtime (`src/config/env.ts` is a bare passthrough); four route headers that claimed "default: http://localhost:32785" now say REQUIRED. The quickstart generator (next release of `@payez/create-next-app`) writes the production sign-in service address into the generated app's `.env.local` at generation time and adds `src/instrumentation.ts`, which calls this helper, so a generated app with the value removed does not come up. Rigs override the file with their own `IDP_URL`.
35
+ - **Consumers (idealresume, idealvibe, the portal):** nothing changes until you call `requireIdpUrl()` from your own `instrumentation.ts`; the package does not call it for you. Calling it is the way to get the loud start.
36
+ - Rows: `test/next-mvp-488-require-idp-url.test.cjs` (8): a real child process refuses with exit 1, the fixed sentence and nothing else for unset / empty / blank / scheme-less / ftp / host-less values, and starts (exit 0, silent) with a valid one; no source file falls back to a host. Mutants RED: guard removed, a scheme-less value accepted, exit not called, the sentence changed.
37
+
38
+ ## 4.8.7 (to be published under dist-tag `next` only; the first publish under the new health check)
39
+
40
+ - **FIX: the onboarding proxies forwarded an EMPTY body (QAPert 75864; present since 4.5.0, PAY-2121, through 4.8.6).** `api-handlers/onboarding/{verify-welcome-email,send-email-code,verify-code,set-password}` read the request body with `req.json()` to check their fields, then `lib/onboarding-proxy` read the SAME request again with `req.text()`. A Request body is single-read: the second read threw "Body has already been read", the proxy swallowed it and POSTed `{}` to the IdP, so every onboarding step from a generated app reached Core with no token (Core answered 400 "The OnboardToken field is required") and no generated app could onboard an invitee end to end. `onboardingProxy(req, idpPath, body)` now takes the handler's already-parsed body and never re-reads the request; a caller that does not pass an object body gets a loud 500 `BODY_NOT_FORWARDED` and nothing reaches the IdP (it used to become `{}`).
41
+ - **FIX: the onboarding code step no longer claims a code was emailed when none was.** It says "We emailed a 6-digit code" only after `send-email-code` answered 2xx (`codeStepSentence`); a failed send says "We could not send a code" and points at "Send a new code".
42
+ - Rows: `test/next-mvp-487-onboarding-body.test.cjs` runs all four handlers on a REAL `Request` (a double whose `text()` still works after `json()` cannot catch this class, and did not, through four versions) plus a row that the body-forwarding helpers never hand a double to a handler; the older `pay-2121` handler rows now use a real Request too. Mutants RED: the proxy re-reading the consumed request, the swallow restored, a handler that stops passing its body, a failed send that reads as sent. Package suite 1210 tests, 1208 pass, 0 fail, 2 skipped.
43
+
44
+ ### Tooling in 4.8.7
45
+
46
+ - **TOOLING: the postpublish health check now polls the registry for the dist-tag AND the integrity (PR 1386, BAPert 75831).** It passes only when the dist-tag points at the version just published and the registry's `dist.integrity` equals the integrity of `npm pack --dry-run --json --ignore-scripts`; a different integrity fails at once, a timeout fails and prints what it saw and what it wanted, an unreadable registry exits 2 (inconclusive). The bound defaults to 10 minutes (the registry was stale 5-7 minutes after every publish 4.8.3-4.8.6): `--timeout-ms <n>` or `PUBLISH_HEALTH_TIMEOUT_MS` change it, 0 means one read. The `postversion` hook no longer runs the check (nothing is published at that point, so any read there is a false alarm).
47
+ - **Run the check only in the tree that published.** The packed integrity is deterministic per tree but differs between trees (a CRLF Windows checkout versus the clean LF worktree the release is published from, or a different `dist`). A hand run of `npm run check:publish-health` from another checkout will say "the registry's <version> is NOT the tarball packed here" for a good publish; that is not a bad publish, do not publish again (NightHawk, PR 1386).
48
+
49
+ ## 4.8.6 (published under dist-tag `next` only)
50
+
51
+ - **NEW: the "I was invited" entry (PR 1382).** `@payez/next-mvp/pages/invited` (mount at `app/account-auth/signup/page.tsx`) takes the address the invitation went to and posts it to the app's own `/api/onboarding/signup` (`@payez/next-mvp/routes/onboarding/signup`), which forwards EXACTLY `{ email, client_id }` to the IdP's anonymous `Onboarding/signup` (the client id is the app's own `IDP_CLIENT_ID`/`CLIENT_ID`, never the browser's; any other key is dropped) and answers ONE fixed 202 whatever the IdP said. The page shows the same sentence afterwards and never reads the answer, so it is not an oracle for which addresses were invited. `@payez/next-mvp/components/auth/InvitedEntryLink` is the link for the sign-in page; the generator mounts all of it.
52
+ - **CHANGE: the onboarding page requests its own code.** It asks the IdP to email the code once per token on load, offers "Send a new code", and shows `DevCodeNotice` only from that answer's `dev_verification_code` (no environment flag; it shows once Core passes the echo through, Core PR 1379). Its text no longer says the code is in the invitation email.
53
+ - Rows: `test/next-mvp-486-invited-entry.test.cjs` (15; mutants RED: extra field forwarded, client id from the browser, an outage or the IdP status changing the answer, the page reading the answer, an env-gated echo, a second code source, a re-send on every render). Package suite 1181 tests, 1180 pass, 0 fail, 1 skipped.
54
+
55
+ ## 4.8.5 (AUTH-045; published under dist-tag `next` only)
56
+
57
+ - **`CredentialSignInForm` shows the development sign-in code when the identity service echoes it.** On a dev identity service the emailed code goes to nobody; when the IdP runs with `Email:DevCodeEcho` (Development only) it returns the code as `DevVerificationCode`, the app's sign-in door passes it through as `devVerificationCode` (it already did), and the form now prints `Development sign-in code: <code>` under the code box. It is a pure passthrough of that field: the notice renders nothing unless it is given a non-empty string, only a string is ever read as a dev code, and there is NO environment flag in the app, so an IdP with the echo off (production) can never make it appear. `credentialSignIn` results of kind `two_factor` gain `devCode: string | null`.
58
+ - Rows: `test/auth-045-dev-code-echo.test.cjs` (6; mutants RED: render when absent, a non-string accepted as a code, rendering from another source, an environment flag). Package suite 1166 tests, 1164 pass, 0 fail, 2 skipped.
59
+
60
+ ## 4.8.4 (REG-002; published under dist-tag `next` only)
61
+
62
+ - **NEW: `@payez/next-mvp/agents`, the SDK half of REG-002 (PR 1335, follow-ups PR 1343).** `createAgentRegistryClient({ baseUrl, accessToken })` with `register({ user_name, owner_user_id, host_class })`, `list()` and `get(id)` over the tenant-admin agent door (`POST/GET api/client/agents`, PayEz-Core `ClientAgentsController` @ 22d44a34c). The client comes from the caller's token: there is no client id to pass, and the body is exactly the three fields.
63
+ - Refusals are thrown as `AgentRegistryError` with the server's `code` as-is (`VALIDATION_ERROR`, `OWNER_NOT_ELIGIBLE`, `OWNER_NOT_MEMBER`, `AGENT_NAME_TAKEN`, `AUDIT_WRITE_FAILED_NOT_APPLIED`, ...). **Nothing is retried, of any status.** A transport failure on `register` says the outcome is unknown (read the list); `get(id)` percent-encodes the id so `get('1/2')` requests `/api/client/agents/1%2F2`. The bearer goes through `bearerFetch` (never follows a redirect) and never appears in an error.
64
+ - A newly registered agent is PENDING until an administrator activates it; its first mint is refused `AGENT_PENDING`.
65
+ - Additive: a new export subpath and a README example. No existing export changes. Rows: `test/reg-002-agent-registry-client.test.cjs` (19; mutants RED: forward-all body, code mapping, token in message, 409 retry, get without encoding, old register text).
66
+
67
+ ## 4.8.3 (PAY-1810, PAY-2041; published under dist-tag `next` only)
68
+
69
+ - **FIX: the admin handlers `admin/sessions.ts` (list search and `revoke_all`), `admin/users.ts` and `admin/audit.ts` send filters in a shape Core accepts.** They sent `{ operator, conditions: [...] }` for two or more conditions; Core's compound form is `{ operator, filters: [...] }`, and with no `filters` key Core read `operator` / `conditions` as column names and returned ZERO rows: a broken user search, an audit filter with two or more fields that returned nothing, and `revoke_all` that found no sessions, revoked 0 and answered success. They also sent operator `like` (not a Core operator; in a flat filter it is dropped and every row comes back) and `orderBy` as a string (binds nothing). Now: compound with `filters`, substring as `contains` (case-insensitive), `orderBy` as a `{ field: dir }` map, via the new internal helper `src/vibe/filters.ts` (`eq`, `gte`, `lte`, `contains`, `allOf`, `anyOf`, `orderByMap`). `admin/stats.ts` `orderBy` is a map too.
70
+ - **`revoke_all` now revokes EVERY active session of the user, not the first page.** Core returns one page per `/query` call (default 20). The handler re-reads page 1 of 50 (revoked sessions leave the `status = active` filter) until a read is empty, stops if a round revokes nothing (a failing write never loops) or after 20 rounds, and the response adds `complete` (true only when the last read was empty); `success` now equals `complete`, so a total or partial write failure is no longer reported as success. A failing FIRST read is still a plain upstream error.
71
+ - **The session writes in `admin/sessions.ts` are PATCH, not PUT** (single `revoke` and each `revoke_all` write). A PUT to a Vibe document REPLACES it (Core `UpdateAsync` sets `data` to the body), so the 3-field revoke body erased `idp_user_id`, `session_token_hash` and the rest of the row; once the filter fix made `revoke_all` find rows, it would have destroyed them (NightHawk 75394). The helper's method type gains `PATCH`.
72
+ - Behaviour change for consumers: an admin session search by email is now a case-insensitive substring match that really filters (it used to return every session), and a user search or a multi-filter audit query returns rows (it used to return none).
73
+ - Rows: `test/pay-1810-admin-filter-shapes.test.cjs` (12, against dist; mutants 1-10 RED). Package suite 1137 tests, 0 fail, 2 skipped.
74
+ - **PAY-2041: `VibeTable.update`, the generic `GenericTableDelegate.update` and the `admin/users.ts` `update_tier` / `update_credits` writes are PATCH, not PUT.** Same defect as above: a PUT replaces the Vibe document, and these send a Partial or a single field, so each erased every field it did not name (a tier change wiped the rest of the user row). PATCH merges (Core `DocumentsController.Patch`; the proxy forwards PATCH; the signature is over PATCH). The request method type gains `PATCH`. Behaviour change for consumers: `update({ where, data })` now MERGES `data` into the document; a field can no longer be removed by omitting it from `data`.
75
+ - Deliberately unchanged: `admin/vibe-data.ts` `createUpdateRecordHandler` stays a PUT (an admin editor save is a replace; documented in the handler, BAPert 75440).
76
+ - Rows: `test/pay-2041-vibe-update-patch.test.cjs` (4; 3 RED on a PUT mutant). Package suite 1141 tests, 0 fail, 2 skipped (PR 1333).
77
+
78
+ ## 4.8.2 (PAY-2362; published under dist-tag `next` only)
79
+
80
+ - **FIX: the per-session revocation decision (`findRevokingSession`, behind `getRevocationStatus` and `checkSessionRevocation`) now treats a session as revoked if ANY `login_sessions` row carrying its `session_token_hash` is revoked.** It used to take the FIRST row for the hash (`find`). A second record-login for the same session (another tab, a cleared sessionStorage, every open session when an app first runs a version that changes its login-recording key) writes a second row with the same hash, and an admin revoke PATCHes ONE row by id, so an active duplicate listed first hid the revoke (a per-session revocation bypass, PAY-1882). The verdict reason is still `own_row_revoked`.
81
+ - Unchanged: a session whose hash matches only active rows is `active`; another session's revoked row (a different hash) still never revokes this one when this one has its own row; the legacy rules (no row carries the hash) are untouched.
82
+ - Rows: `test/pay-2362-any-row-for-hash-revoked.test.cjs` (6; RED on 4.8.1: A, D, F). Package suite 1119 tests, 0 fail, 2 skipped.
83
+ - **WRITE SIDE: `createLoginSession` is idempotent by `session_token_hash`.** When a row for the hash already exists it PATCHes `last_activity` on that row (never `status`, so it cannot un-revoke) and returns it instead of inserting a second row; a failed lookup falls through to the insert, so a login is never blocked. It costs one extra `login_sessions` read when `sessionTokenHash` is passed. Rows: `test/pay-2362-create-login-session-by-hash.test.cjs` (6; RED on the first 4.8.2 commit: A, B). Existing duplicate rows are not cleaned up; the any-row read covers them.
84
+ - **Consumers with their own copy of this decision must change it too.** idealresume.online `lib/vibe-sessions.ts` `checkSessionRevocation` has its own `find` (separate PR).
85
+
86
+ ## 4.8.1 (PAY-2345; published under dist-tag `next` only)
87
+
88
+ - **DEFAULT CHANGE: the viability-FAILURE arm (viability read answered 500 / timed out / threw, or the circuit breaker is open or refuses a refresh) now answers a page request inline too: `503` HTML with a "Try again" link, `cache-control: no-store`, `retry-after: 30`, no `Set-Cookie`. It no longer 307s to `/service-unavailable`, which an app that never mounted that page served as a 404 during an outage.** To keep a redirect, name your page with `unavailablePage` (now honoured by BOTH arms). `/api` is unchanged: `503 { error: 'ServiceUnavailable', unavailable: true, check: 'viability' }`.
89
+ - The inline sentence depends on the check: the revocation hold keeps "We could not check your session just now. You are still signed in."; the viability-failure arm says "We could not reach the sign-in service just now. Please try again." (it can be shown to a visitor with no session, and a session it could not verify is not one it can call signed in). `revocationFailMode` governs revocation answers only: naming `'closed'` or `'open'` does NOT keep the old redirect on this arm; only `unavailablePage` does (BAPert 74739 D1/D2).
90
+ - The call sites are unchanged; the two arms still differ only by `check`, so they can be diffed independently. The session is still never touched from this arm (PAY-2147).
91
+
92
+ ## 4.8.0 (PAY-2339 Phase 1; published under dist-tag `next` only)
93
+
94
+ - **DEFAULT CHANGE: when the revocation check cannot answer (`revocationStatus: 'unknown'`), the package no longer signs the member out; it holds. To keep the old sign-out, pass `revocationFailMode: 'closed'` to `createMvpMiddleware`.**
95
+ - Held request: a page request is answered inline by the middleware with a minimal `503` HTML page ("Temporarily unavailable", a "Try again" link to the same-site callback, no `Set-Cookie`; no page needs to exist in the app, so no 404 is possible). To redirect to your own page instead, name it: `unavailablePage: '/service-unavailable'` (then mount `export { default } from '@payez/next-mvp/pages/service-unavailable';` or your own page at that route). An `/api` request gets `503 { error: 'ServiceUnavailable', unavailable: true, check: 'revocation' }`. No cookie is touched, nothing protected is served, and the member is still signed in when they come back.
96
+ - Both viability producers (`api-handlers/session/viability`, `routes/auth/viability`) stop setting `forceInvalid` for `'unknown'` (and for any unrecognised string) and emit `unavailable: true`; `revocationStatus` is unchanged. `forceInvalid` is now true only for `'revoked'`. The exported `makeAuthDecision` (root and `/edge`) holds on `sessionStatus.unavailable` (returns `service_error`, `check: 'revocation'`) instead of signing out.
97
+ - Optional page `@payez/next-mvp/pages/service-unavailable` for apps that set `unavailablePage` (mount: `export { default } from '@payez/next-mvp/pages/service-unavailable';` in `app/service-unavailable/page.tsx`). `callbackUrl` is followed only when it is a same-site relative path. (PAY-2345: the viability `service_error` arm now answers inline as well; see Unreleased.)
98
+ - Unchanged: `'revoked'` signs out; `'active'`, an absent status, and `'not_configured'` behave as before; an app naming `'closed'` or `'open'` keeps exactly that.
99
+ - Log: one `revocation_unavailable` warn line (`{ check: 'revocation' }`, no session value) per held request. No sampling in Phase 1, so a backend outage logs one line per protected request.
100
+ - Existing 503 bodies from the viability `service_error` arm now also carry `unavailable: true, check: 'viability'`.
101
+ - **Move together:** producers and decision paths must be on 4.8.0 together. A 4.8.0 producer no longer sets `forceInvalid` for `'unknown'`, so a consumer on an older boolean-only reader of the viability JSON now reads it as viable; read `unavailable` (or `revocationStatus`) instead.
102
+ - `unavailablePage` affects the revocation hold on 4.8.0 (PAY-2345 extends it to the viability-failure arm); register the named route with `configurePublicRoutes`; a held request for the named page itself is answered inline, never redirected to itself.
103
+ - The inline 503 carries `cache-control: no-store` and `retry-after: 30`.
104
+ - `'open'` by name is unchanged and is broader than 'unknown': it keeps BOTH `'unknown'` and `'not_configured'` viable, exactly as on 4.7.10.
105
+ - `unavailablePage` is accepted only as a plain absolute path (`/` then letters, digits, `_`, `-`, `/`): a query, fragment, `%`-encoding, dot segment or `//` is ignored and the inline 503 is used; a trailing slash on the request is normalised when comparing.
@@ -66,6 +66,7 @@ export declare function createRefreshHandler(config: RefreshConfig): (req: NextR
66
66
  discardToken: boolean;
67
67
  retryable: boolean;
68
68
  resolution: any;
69
+ status: number;
69
70
  }> | NextResponse<{
70
71
  refreshed: boolean;
71
72
  accessTokenExpires: number;
@@ -84,6 +85,7 @@ export declare const POST: (req: NextRequest) => Promise<NextResponse<{
84
85
  discardToken: boolean;
85
86
  retryable: boolean;
86
87
  resolution: any;
88
+ status: number;
87
89
  }> | NextResponse<{
88
90
  refreshed: boolean;
89
91
  accessTokenExpires: number;
@@ -44,6 +44,7 @@ const session_store_1 = require("../../lib/session-store");
44
44
  const token_expiry_1 = require("../../lib/token-expiry");
45
45
  const token_utils_1 = require("../../auth/utils/token-utils");
46
46
  const token_log_id_1 = require("../../lib/token-log-id");
47
+ const refresh_rotation_1 = require("../../lib/refresh-rotation");
47
48
  /**
48
49
  * Creates a refresh token handler for Next.js API routes
49
50
  *
@@ -305,7 +306,43 @@ function createRefreshHandler(config) {
305
306
  // Note: Token is NOT cleared preemptively - it will be replaced on success.
306
307
  // The lock mechanism prevents concurrent refresh attempts within the same session.
307
308
  // If IDP call fails, user can retry with the same token.
308
- const originalRefreshToken = currentSession.idpRefreshToken;
309
+ // 4.9.0 (BAPert 76298 (b2), 76330): the token we SEND is the one read AFTER the lock was taken (`latestAfterLock`), and it is the ONLY
310
+ // source: there is NO fallback to `currentSession`, which was read BEFORE the lock. Between those reads another request can rotate the
311
+ // refresh token (sending the pre-lock value would send a SPENT token) or DISCARD it (the IdP answered 401 for it; re-sending it is a
312
+ // reuse). The IdP treats reuse as compromise and revokes descendants across devices. So:
313
+ // - the post-lock read has NO ROW: the session vanished while we waited; it is not ours to refresh -> the 'No session' 401, no IdP call
314
+ // - the row has NO refresh token: -> the same terminal NO_REFRESH_TOKEN as the pre-lock guard, no IdP call; the row is left exactly as
315
+ // it is (a discard marker stays on it)
316
+ // (A row whose access token is still fresh was answered `already_fresh` above and never reaches here.)
317
+ if (!latestAfterLock) {
318
+ console.warn('[AUTH_REFRESH] Session vanished while waiting for the refresh lock; not refreshing', { userId });
319
+ if (weAcquiredLock) {
320
+ await (0, session_store_1.releaseRefreshLock)(sessionToken, requestId, releaseLockVersion);
321
+ weAcquiredLock = false;
322
+ }
323
+ return server_1.NextResponse.json({
324
+ error: 'No session available for refresh',
325
+ code: 'UNAUTHORIZED'
326
+ }, { status: 401 });
327
+ }
328
+ if (!latestAfterLock.idpRefreshToken) {
329
+ console.warn('[AUTH_REFRESH] No refresh token after the lock was taken; not calling the IdP', { userId });
330
+ if (weAcquiredLock) {
331
+ await (0, session_store_1.releaseRefreshLock)(sessionToken, requestId, releaseLockVersion);
332
+ weAcquiredLock = false;
333
+ }
334
+ return server_1.NextResponse.json({
335
+ error: 'No refresh token available',
336
+ code: 'NO_REFRESH_TOKEN',
337
+ terminal: true,
338
+ resolution: 'User must re-authenticate'
339
+ }, { status: 401 });
340
+ }
341
+ const originalRefreshToken = latestAfterLock.idpRefreshToken;
342
+ // The base of everything WRITTEN BACK after a successful exchange is the same post-lock read (BAPert 76318 item 4): writing
343
+ // `...currentSession` over a rotated row would put a SPENT token back. With no new refresh token from the IdP the stored token is the
344
+ // one that was SENT (`originalRefreshToken`).
345
+ const baseSession = latestAfterLock;
309
346
  try {
310
347
  // Extract 2FA claims from current session
311
348
  let authMethods = [];
@@ -525,7 +562,7 @@ function createRefreshHandler(config) {
525
562
  // (race-fix follow-up #1).
526
563
  discardToken = false;
527
564
  console.warn('[AUTH_REFRESH] Discard suppressed: refresh token already rotated by a concurrent refresh — returning the fresh token (race-safe).');
528
- if (postRefresh?.idpAccessToken && postRefresh.idpAccessTokenExpires) {
565
+ if ((0, refresh_rotation_1.rotatedRowIsUsable)(postRefresh)) {
529
566
  return server_1.NextResponse.json({
530
567
  refreshed: true,
531
568
  reason: 'completed_by_concurrent_request',
@@ -533,6 +570,16 @@ function createRefreshHandler(config) {
533
570
  hasRefreshToken: !!postRefresh.idpRefreshToken,
534
571
  });
535
572
  }
573
+ // Rotated by a concurrent refresh but the row it left is NOT usable (BAPert 76359): the retryable 502, the rotated token kept,
574
+ // no marker. Same meaning in every branch (lib/refresh-rotation).
575
+ return server_1.NextResponse.json({
576
+ error: errorMessage,
577
+ code: errorCode,
578
+ discardToken: false,
579
+ retryable: true,
580
+ resolution,
581
+ status: 502
582
+ }, { status: 502 });
536
583
  }
537
584
  }
538
585
  return server_1.NextResponse.json({
@@ -585,29 +632,58 @@ function createRefreshHandler(config) {
585
632
  errorHasMessage: !!idpError.message,
586
633
  errorKeys: Object.keys(idpError),
587
634
  });
635
+ // 4.9.0 (BAPert 76298 (c)): ONE IdP answer, ONE meaning, in both refresh implementations. This branch used to answer the upstream
636
+ // status, i.e. HTTP 200, with an error body, so `internalRefresh` reported `ok` and idpFetchJSON's 401-retry trusted a refresh that
637
+ // had failed; and it wrote a DIFFERENT marker (IDP_SUCCESS_FALSE_DISCARD) than the IdP-401 branch, which the logout dead-grant check
638
+ // does not recognise. Now:
639
+ // success:false + discard_token -> the stored token is discarded (same CAS as the 401 branch) with the IDP_DISCARD_TOKEN marker, and the
640
+ // answer is 401 (never 200 with an error body)
641
+ // success:false, no discard_token -> 502 retryable, the token is KEPT
588
642
  if (discardToken) {
589
- console.error('[AUTH_REFRESH] ⚠️ REFRESH_TOKEN_REMOVAL: IDP success:false with discard_token', {
590
- reason: 'IDP_SUCCESS_FALSE_DISCARD',
591
- errorCode,
592
- userId,
593
- sessionToken: (0, token_log_id_1.tokenLogId)(sessionToken),
594
- hadRefreshToken: !!currentSession.idpRefreshToken,
595
- stack: new Error().stack
596
- });
597
- await (0, session_store_1.updateSession)(sessionToken, {
598
- idpRefreshToken: '',
599
- idpRefreshTokenExpires: undefined,
600
- refreshTokenClearedAt: Date.now(),
601
- refreshTokenClearedReason: `IDP_SUCCESS_FALSE_DISCARD:${errorCode}`
602
- });
643
+ const postRefresh = await (0, session_store_1.getSession)(sessionToken);
644
+ const tokenUnchanged = !!originalRefreshToken && postRefresh?.idpRefreshToken === originalRefreshToken;
645
+ if (tokenUnchanged) {
646
+ console.error('[AUTH_REFRESH] ⚠️ REFRESH_TOKEN_REMOVAL: IDP success:false with discard_token', {
647
+ reason: 'IDP_DISCARD_TOKEN',
648
+ errorCode,
649
+ userId,
650
+ sessionToken: (0, token_log_id_1.tokenLogId)(sessionToken),
651
+ hadRefreshToken: !!currentSession.idpRefreshToken,
652
+ stack: new Error().stack
653
+ });
654
+ await (0, session_store_1.updateSession)(sessionToken, {
655
+ idpRefreshToken: '',
656
+ idpRefreshTokenExpires: undefined,
657
+ refreshTokenClearedAt: Date.now(),
658
+ refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${errorCode}`
659
+ });
660
+ return server_1.NextResponse.json({
661
+ error: idpError.message || 'Token refresh failed',
662
+ code: errorCode,
663
+ discardToken: true,
664
+ retryable: false,
665
+ resolution: idpError.resolution || null,
666
+ status: 401
667
+ }, { status: 401 });
668
+ }
669
+ // A concurrent refresh already rotated the token: ours is stale. Return the fresh tokens if there are any (as the 401 branch does).
670
+ if ((0, refresh_rotation_1.rotatedRowIsUsable)(postRefresh)) {
671
+ return server_1.NextResponse.json({
672
+ refreshed: true,
673
+ reason: 'completed_by_concurrent_request',
674
+ accessTokenExpires: postRefresh.idpAccessTokenExpires,
675
+ hasRefreshToken: !!postRefresh.idpRefreshToken,
676
+ });
677
+ }
603
678
  }
604
679
  return server_1.NextResponse.json({
605
680
  error: idpError.message || 'Token refresh failed',
606
681
  code: errorCode,
607
- discardToken,
608
- retryable,
609
- resolution: idpError.resolution || null
610
- }, { status: refreshResponse.status || 400 });
682
+ discardToken: false,
683
+ retryable: true,
684
+ resolution: idpError.resolution || null,
685
+ status: 502
686
+ }, { status: 502 });
611
687
  }
612
688
  // Extract tokens from response
613
689
  const payload = responseData.data;
@@ -651,22 +727,22 @@ function createRefreshHandler(config) {
651
727
  }
652
728
  catch (e) {
653
729
  console.warn('[AUTH_REFRESH] Failed to parse AMR claims', { amr: decodedAccessToken.amr });
654
- amrClaims = currentSession.authenticationMethods || [];
730
+ amrClaims = baseSession.authenticationMethods || [];
655
731
  }
656
732
  }
657
733
  else {
658
- amrClaims = currentSession.authenticationMethods || [];
734
+ amrClaims = baseSession.authenticationMethods || [];
659
735
  }
660
- const acrLevel = String(decodedAccessToken.acr || currentSession.authenticationLevel || '1');
736
+ const acrLevel = String(decodedAccessToken.acr || baseSession.authenticationLevel || '1');
661
737
  // Extract MFA timing claims
662
- const mfaTime = decodedAccessToken.mfa_time ? parseInt(decodedAccessToken.mfa_time) * 1000 : currentSession.mfaCompletedAt;
663
- const mfaExpires = decodedAccessToken.mfa_expires ? parseInt(decodedAccessToken.mfa_expires) * 1000 : currentSession.mfaExpiresAt;
664
- const mfaValidityHours = decodedAccessToken.mfa_validity_hours ? parseInt(decodedAccessToken.mfa_validity_hours) : currentSession.mfaValidityHours;
738
+ const mfaTime = decodedAccessToken.mfa_time ? parseInt(decodedAccessToken.mfa_time) * 1000 : baseSession.mfaCompletedAt;
739
+ const mfaExpires = decodedAccessToken.mfa_expires ? parseInt(decodedAccessToken.mfa_expires) * 1000 : baseSession.mfaExpiresAt;
740
+ const mfaValidityHours = decodedAccessToken.mfa_validity_hours ? parseInt(decodedAccessToken.mfa_validity_hours) : baseSession.mfaValidityHours;
665
741
  // Update session with new tokens
666
742
  const hasNewRefresh = typeof refreshToken === 'string' && refreshToken.length > 0;
667
743
  const newRefreshTokenExpires = hasNewRefresh ? computedRefreshTokenExpires : undefined;
668
744
  // CRITICAL: Log if we're about to clear the refresh token due to missing new token
669
- if (!hasNewRefresh && currentSession.idpRefreshToken) {
745
+ if (!hasNewRefresh && baseSession.idpRefreshToken) {
670
746
  console.error('[AUTH_REFRESH] ⚠️ REFRESH_TOKEN_REMOVAL: No new refresh token in IDP response', {
671
747
  reason: 'NO_NEW_REFRESH_TOKEN_IN_RESPONSE',
672
748
  userId,
@@ -693,14 +769,14 @@ function createRefreshHandler(config) {
693
769
  console.warn('[AUTH_REFRESH] No kid found in new JWT header');
694
770
  }
695
771
  const sessionUpdate = {
696
- ...currentSession,
772
+ ...baseSession,
697
773
  idpAccessToken: accessToken,
698
774
  idpAccessTokenExpires: accessTokenExpires,
699
- idpRefreshToken: hasNewRefresh ? refreshToken : currentSession.idpRefreshToken,
700
- idpRefreshTokenExpires: hasNewRefresh ? newRefreshTokenExpires : currentSession.idpRefreshTokenExpires,
775
+ idpRefreshToken: hasNewRefresh ? refreshToken : originalRefreshToken,
776
+ idpRefreshTokenExpires: hasNewRefresh ? newRefreshTokenExpires : baseSession.idpRefreshTokenExpires,
701
777
  decodedAccessToken: decodedAccessToken,
702
778
  // Bearer key ID from JWT header (may change on key rotation)
703
- bearerKeyId: newBearerKeyId || currentSession.bearerKeyId,
779
+ bearerKeyId: newBearerKeyId || baseSession.bearerKeyId,
704
780
  authenticationMethods: amrClaims,
705
781
  authenticationLevel: acrLevel,
706
782
  // 170007 rule 2 (BAPert ruling on 121350): '||' is the wrong operator
@@ -20,6 +20,7 @@ const crypto_1 = require("crypto");
20
20
  const session_store_1 = require("./session-store");
21
21
  const token_expiry_1 = require("./token-expiry");
22
22
  const token_utils_1 = require("../auth/utils/token-utils");
23
+ const refresh_rotation_1 = require("./refresh-rotation");
23
24
  const DEFAULT_SAFETY_WINDOW_MS = 60_000;
24
25
  const DEFAULT_LOCK_WAIT_MS = 5000;
25
26
  function decodeJwtExp(token) {
@@ -278,7 +279,7 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
278
279
  // freshly-rotated token directly so the loser succeeds without a retry round-trip
279
280
  // (race-fix follow-up #1).
280
281
  discardToken = false;
281
- if (postRefresh?.idpAccessToken && postRefresh.idpAccessTokenExpires) {
282
+ if ((0, refresh_rotation_1.rotatedRowIsUsable)(postRefresh)) {
282
283
  return {
283
284
  ok: true,
284
285
  accessToken: postRefresh.idpAccessToken,
@@ -288,6 +289,17 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
288
289
  persisted: true,
289
290
  };
290
291
  }
292
+ // Rotated by a concurrent refresh but the row it left is NOT usable (BAPert 76359): the retryable 502, the rotated token kept, no
293
+ // marker. Same meaning in every branch (lib/refresh-rotation).
294
+ return {
295
+ ok: false,
296
+ code,
297
+ message: idpError.message || 'Token refresh failed',
298
+ status: 502,
299
+ discardToken: false,
300
+ retryable: true,
301
+ resolution: idpError.resolution,
302
+ };
291
303
  }
292
304
  }
293
305
  return {
@@ -302,6 +314,54 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
302
314
  };
303
315
  }
304
316
  // Validate canonical envelope.
317
+ // 4.9.0 (BAPert 76298 (c)): ONE IdP answer, ONE meaning, in both refresh implementations (api-handlers/auth/refresh.ts has the twin of
318
+ // this block). A 200 envelope with success:false used to be a generic 502 here, with discard_token ignored, while the other twin
319
+ // honoured it. Now: success:false + discard_token discards the stored token (same CAS as the HTTP-401 branch above, same
320
+ // IDP_DISCARD_TOKEN marker) and answers terminal 401; success:false without discard_token is a retryable 502 and the token is KEPT.
321
+ if (responseData && typeof responseData === 'object' && responseData.success === false) {
322
+ const idpError = responseData.error || {};
323
+ const code = idpError.code || 'UPSTREAM_VALIDATION_ERROR';
324
+ if (idpError.discard_token === true) {
325
+ const postRefresh = await (0, session_store_1.getSession)(sessionToken);
326
+ const tokenUnchanged = !!sentRefreshToken && postRefresh?.idpRefreshToken === sentRefreshToken;
327
+ if (tokenUnchanged) {
328
+ await (0, session_store_1.updateSession)(sessionToken, {
329
+ idpRefreshToken: '',
330
+ idpRefreshTokenExpires: undefined,
331
+ refreshTokenClearedAt: Date.now(),
332
+ refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${code}`,
333
+ });
334
+ return {
335
+ ok: false,
336
+ code,
337
+ message: idpError.message || 'Token refresh failed',
338
+ status: 401,
339
+ discardToken: true,
340
+ retryable: false,
341
+ resolution: idpError.resolution,
342
+ terminal: true,
343
+ };
344
+ }
345
+ if ((0, refresh_rotation_1.rotatedRowIsUsable)(postRefresh)) {
346
+ return {
347
+ ok: true,
348
+ accessToken: postRefresh.idpAccessToken,
349
+ accessTokenExpires: postRefresh.idpAccessTokenExpires,
350
+ refreshed: true,
351
+ persisted: true,
352
+ };
353
+ }
354
+ }
355
+ return {
356
+ ok: false,
357
+ code,
358
+ message: idpError.message || 'Token refresh failed',
359
+ status: 502,
360
+ discardToken: false,
361
+ retryable: true,
362
+ resolution: idpError.resolution,
363
+ };
364
+ }
305
365
  if (!responseData ||
306
366
  typeof responseData !== 'object' ||
307
367
  responseData.success !== true ||
@@ -10,8 +10,10 @@ import type { NextRequest } from 'next/server';
10
10
  * 'revoked' the IdP answered 2xx.
11
11
  * 'nothing-to-revoke' the session holds no IdP refresh token (a provisional pre-2FA session has none). No call is made, because
12
12
  * without a token Core would pick the newest session.
13
- * 'grant-already-dead' the revoke was refused (401/403) and the refresh that followed was answered 401: refresh.ts calls that
14
- * "definitively invalid", the token is expired or revoked, so there is nothing left to end (BAPert 76289 item 3).
13
+ * 'grant-already-dead' the revoke was refused AND the refresh that followed was answered 401 AND the session carries the IdP's own
14
+ * discard marker (refreshTokenClearedReason starting 'IDP_DISCARD_TOKEN'). A refresh 401 alone is NOT proof
15
+ * (refresh.ts also answers 401 for 'no session', the x-session-token rejection and NO_REFRESH_TOKEN), and neither
16
+ * is an empty token (BAPert 76297). Anything else is UNKNOWN.
15
17
  * THROWS for an UNKNOWN outcome: a transport error, a 5xx, a refresh that could not be attempted or failed some other way. The caller
16
18
  * turns that into SESSION_NOT_ENDED.
17
19
  *
@@ -0,0 +1,19 @@
1
+ /**
2
+ * 4.9.0 (BAPert 76359): ONE predicate for "a concurrent refresh already rotated the refresh token, and the row it left is usable", shared by
3
+ * all four places that decide it (api-handlers/auth/refresh.ts and lib/ensure-fresh-access-token.ts, each for the HTTP-401 branch and the
4
+ * 200 success:false branch). Before, two said "usable" meant PRESENT (an access token and an expiry exist) and two said UNEXPIRED, so a rotated
5
+ * row whose access token had already expired was answered as a successful refresh by some branches and not by others.
6
+ *
7
+ * Usable = an access token exists AND its recorded expiry is in the future. A row that is rotated but NOT usable is answered with the
8
+ * retryable 502, its (rotated) refresh token KEPT and NO discard marker, in every branch. The CAS that decides "rotated" (a different
9
+ * stored refresh token than the one we sent) is unchanged: a different token is never discarded.
10
+ *
11
+ * A leaf module: no imports.
12
+ */
13
+ export declare function rotatedRowIsUsable<T extends {
14
+ idpAccessToken?: string | null;
15
+ idpAccessTokenExpires?: number | null;
16
+ }>(session: T | null | undefined, now?: number): session is T & {
17
+ idpAccessToken: string;
18
+ idpAccessTokenExpires: number;
19
+ };
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.rotatedRowIsUsable = rotatedRowIsUsable;
4
+ /**
5
+ * 4.9.0 (BAPert 76359): ONE predicate for "a concurrent refresh already rotated the refresh token, and the row it left is usable", shared by
6
+ * all four places that decide it (api-handlers/auth/refresh.ts and lib/ensure-fresh-access-token.ts, each for the HTTP-401 branch and the
7
+ * 200 success:false branch). Before, two said "usable" meant PRESENT (an access token and an expiry exist) and two said UNEXPIRED, so a rotated
8
+ * row whose access token had already expired was answered as a successful refresh by some branches and not by others.
9
+ *
10
+ * Usable = an access token exists AND its recorded expiry is in the future. A row that is rotated but NOT usable is answered with the
11
+ * retryable 502, its (rotated) refresh token KEPT and NO discard marker, in every branch. The CAS that decides "rotated" (a different
12
+ * stored refresh token than the one we sent) is unchanged: a different token is never discarded.
13
+ *
14
+ * A leaf module: no imports.
15
+ */
16
+ function rotatedRowIsUsable(session, now = Date.now()) {
17
+ return !!(session && session.idpAccessToken && session.idpAccessTokenExpires && session.idpAccessTokenExpires > now);
18
+ }
@@ -93,11 +93,6 @@ export declare function deleteSession(sessionToken: string): Promise<void>;
93
93
  * them differently, pass appSlug explicitly.
94
94
  */
95
95
  export declare function deleteSessionEverywhere(sessionToken: string, appSlug?: string): Promise<void>;
96
- /**
97
- * Sets a session directly (for testing or migrations).
98
- * @param sessionToken The session token.
99
- * @param data The session data.
100
- */
101
96
  export declare function setSession(sessionToken: string, data: SessionData): Promise<void>;
102
97
  /**
103
98
  * Updates tokens within an existing session.
@@ -271,7 +271,24 @@ async function deleteSessionEverywhere(sessionToken, appSlug) {
271
271
  * @param sessionToken The session token.
272
272
  * @param data The session data.
273
273
  */
274
+ /**
275
+ * 4.9.0 (BAPert 76308 item 2): A LEFTOVER DISCARD MARKER MUST NEVER OUTLIVE A TOKEN. A discard (`refresh.ts`, `ensure-fresh-access-token.ts`)
276
+ * writes `idpRefreshToken: ''` TOGETHER WITH `refreshTokenClearedReason: 'IDP_DISCARD_TOKEN:<code>'`; logout's dead-grant check reads that marker
277
+ * as proof the IdP's grant is gone. If a later write stores a NEW refresh token on the same row (a successful refresh, a re-sign-in into the
278
+ * same row) and the marker stays, a later own-auth 401 would read as "grant already dead" while the grant is live. So any write that leaves a
279
+ * non-empty refresh token on the row drops the marker, in the same write. No code path was found that gives a discarded row a new token
280
+ * today; this is the defence BAPert ruled to keep anyway.
281
+ */
282
+ function dropStaleDiscardMarker(session) {
283
+ const s = session;
284
+ if (typeof s.idpRefreshToken === 'string' && s.idpRefreshToken.length > 0 && (s.refreshTokenClearedReason !== undefined || s.refreshTokenClearedAt !== undefined)) {
285
+ const { refreshTokenClearedReason: _reason, refreshTokenClearedAt: _at, ...rest } = s;
286
+ return rest;
287
+ }
288
+ return session;
289
+ }
274
290
  async function setSession(sessionToken, data) {
291
+ data = dropStaleDiscardMarker(data);
275
292
  const key = getSessionKey(sessionToken);
276
293
  const versionKey = getSessionVersionKey(sessionToken);
277
294
  await redis_1.default.multi()
@@ -405,7 +422,7 @@ async function updateSession(sessionToken, updates) {
405
422
  });
406
423
  }
407
424
  // Merge current session with updates
408
- const updatedSession = { ...currentSession, ...updates };
425
+ const updatedSession = dropStaleDiscardMarker({ ...currentSession, ...updates });
409
426
  // Write the entire updated session back to Redis with version increment
410
427
  await redis_1.default.multi()
411
428
  .setex(key, exports.SESSION_TTL, JSON.stringify(updatedSession))
@@ -23,6 +23,7 @@ export declare function POST(req: NextRequest): Promise<import("next/server").Ne
23
23
  discardToken: boolean;
24
24
  retryable: boolean;
25
25
  resolution: any;
26
+ status: number;
26
27
  }> | import("next/server").NextResponse<{
27
28
  refreshed: boolean;
28
29
  accessTokenExpires: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payez/next-mvp",
3
- "version": "4.9.0",
3
+ "version": "4.10.0",
4
4
  "sideEffects": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -8,7 +8,8 @@
8
8
  "dist",
9
9
  "scripts",
10
10
  "src",
11
- "POST-INSTALL.md"
11
+ "POST-INSTALL.md",
12
+ "CHANGELOG.md"
12
13
  ],
13
14
  "scripts": {
14
15
  "build": "node -e \"const fs=require('fs');fs.rmSync('dist',{recursive:true,force:true});fs.rmSync('tsconfig.tsbuildinfo',{force:true})\" && npx tsc --project tsconfig.json",
@@ -669,16 +670,6 @@
669
670
  "require": "./dist/stores/authStore.js",
670
671
  "default": "./stores/authStore.js"
671
672
  },
672
- "./api": {
673
- "types": "./dist/api/index.d.ts",
674
- "require": "./dist/api/index.js",
675
- "default": "./dist/api/index.js"
676
- },
677
- "./api/auth-handler": {
678
- "types": "./dist/api/auth-handler.d.ts",
679
- "require": "./dist/api/auth-handler.js",
680
- "default": "./dist/api/auth-handler.js"
681
- },
682
673
  "./routes/auth/refresh": {
683
674
  "types": "./dist/routes/auth/refresh.d.ts",
684
675
  "require": "./dist/routes/auth/refresh.js",