Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .changeset/anonymous-get-session-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
'@objectstack/plugin-auth': minor
---

**BREAKING** — `GET /api/v1/auth/get-session` answers an anonymous caller with the
declared ADR-0112 failure envelope and HTTP 401, instead of HTTP 200 wrapping a JSON `null`.

Until now an unauthenticated session read answered:

```
HTTP 200
null
```

`ObjectStackClient.auth.me()` declares `Promise<SessionResponse>`, and
`SessionResponseSchema` requires `data.session` and `data.user` — so no value of that type
means "nobody is signed in", and the most ordinary call a logged-out caller can make
resolved to something outside the method's own declared type. Ruled by the director seat
(decision batch #117 item 4) under the charter rule
「spec 与代码不一致默认改代码,改协议单独立卡非选项」: the implementation is corrected to
the published contract. `SessionResponseSchema` is untouched.

What changes on the wire:

- **An anonymous or unresolvable credential ⇒ `401` with `error.code: 'UNAUTHENTICATED'`**
and the message `Sign in first`, the same body a raw `/admin/` mount already answers the
same caller with. No error code is minted: `UNAUTHENTICATED` is an existing
`StandardErrorCode` member, derived from the status through ADR-0112's own map, so
`ERROR_CODE_LEDGER` is unchanged.
- **Unchanged:** a signed-in read still answers `200` with `{ user, session }`,
byte-identical. Every other `/auth/*` route is untouched, and so is the `404` that a
method this route does not serve already answered — this change never invents a route.
- **Also unchanged:** better-auth's JS API. `auth.api.getSession()` still returns `null` for
an anonymous caller, so every internal identity read — execution-context resolution, the
platform-admin gates, the SSO bridges — behaves exactly as before. Only the wire moves.

**`@objectstack/client`:** `client.auth.me()` now **rejects** for an anonymous caller
instead of resolving with `null` — the SDK throws on every non-2xx before unwrapping. Every
value the method resolves with is now inside its declared `SessionResponse`. Callers that
inspected the resolved value must move to a `catch`:

```ts
try {
const session = await client.auth.me();
// …signed in
} catch (err: any) {
if (err.code === 'UNAUTHENTICATED') {
// …signed out; err.httpStatus is 401
}
}
```

A caller that branches on the HTTP status directly reads `401` plus
`error.code: 'UNAUTHENTICATED'` where it used to read `200` plus an empty body.

<!-- adr-0087: not-required (no-migration-prescription) retires no metadata surface: no Zod schema, no authorable key, no export, no config field, and no stored sys_metadata row changes shape, so `objectstack migrate meta` has nothing to rewrite and no ledger entry can be written for it. What changes is an HTTP status plus an SDK method's promise contract, and the only channel that reaches those consumers is this changeset itself. -->
59 changes: 45 additions & 14 deletions packages/client/src/auth-get-session-envelope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,13 @@
// - `② the raw keys survive` — `.user` is what the field reads today, while
// the declared `.data.user` was `undefined`. The fix must not buy the
// declared shape by breaking the workaround callers were pushed onto.
// - `③ anonymous stays anonymous` — the route serves the literal `null` at
// 200. Pinned as the KNOWN residue: it is still outside `SessionResponse`,
// and this case exists so that stays a measured fact rather than a surprise.
// - `③ anonymous is REFUSED` — REVERSED by #17238. This block used to pin the
// route serving the literal `null` at 200, as the known residue #16760 could
// not close. The residue is now closed at the producer: `/get-session`
// answers an anonymous caller the declared ADR-0112 envelope at 401, so
// `me()` REJECTS and every value it resolves with is inside
// `SessionResponse`. The case is kept, not deleted, because a reversed pin is
// the record that the gap was closed deliberately rather than drifting shut.
// - `④ refreshToken captures a credential that actually works` — the card's
// second consequence, and the one that was NOT a consequence of the envelope
// at all. The firing control is the credential's SPELLING: the client starts
Expand Down Expand Up @@ -240,16 +244,43 @@ describe('[#16760] /get-session is lifted into the SessionResponse envelope it d
});
});

describe('③ anonymous stays anonymous — the known residue', () => {
it('answers the literal null rather than a signed-in-looking envelope', async () => {
describe('③ anonymous is REFUSED — the residue #16760 measured, now closed', () => {
// ⚠️ THE REVERSAL, named. Until #17238 this block asserted `res` was the
// literal `null` — and its own comment said it pinned the RESIDUE, not a
// fix: it was green with the lift and without it, so it could never redden
// on the lift's ablation.
//
// What changed is the PRODUCER, not the lift. There is still no
// `SessionResponse` value meaning "nobody is signed in"; the server stopped
// needing one by refusing instead of answering. Ruled by the director seat
// (batch #117 item 4) under 「spec 与代码不一致默认改代码」.
//
// ⛔ What the SDK must still never do is manufacture `{ success: true,
// data: {} }` here — an empty session that reads as a real one. A rejection
// is the opposite of that, and this case is what says so.
it('rejects with the declared refusal instead of resolving outside its type', async () => {
const { client } = await anonymous();
const res = await client.auth.me();
// ⚠️ Still outside `SessionResponse`, deliberately: there is no value of
// that type meaning "nobody is signed in", and widening the published
// return annotation is a contract-review change, not this card's. What
// the lift must never do is manufacture `{ success: true, data: {} }`
// here — an empty session that reads as a real one.
expect(res).toBeNull();

// BOTH halves: the status is what stops a caller reading the answer as a
// session, the code is what it may branch on.
await expect(client.auth.me()).rejects.toMatchObject({
code: 'UNAUTHENTICATED',
httpStatus: 401,
});
});

it('and the refusal is the only way out — nothing resolves to a falsy session', async () => {
// The control for the reversal: a `me()` that silently started resolving
// `null`/`undefined` again would satisfy no assertion above, and this is
// what turns that into a failure rather than a gap.
const { client } = await anonymous();

const settled = await client.auth
.me()
.then((value) => ({ outcome: 'resolved' as const, value }))
.catch((error) => ({ outcome: 'rejected' as const, value: error }));

expect(settled.outcome).toBe('rejected');
});
});

Expand All @@ -264,8 +295,8 @@ describe('[#16760] /get-session is lifted into the SessionResponse envelope it d
// that captures nothing leaves it exactly where it started.
//
// ⛔ Not "seed a deliberately wrong token": that unauthenticates the
// client, `/get-session` then answers `null` for the anonymous reason,
// and the case would fail against a CORRECT implementation.
// client, `/get-session` then REFUSES for the anonymous reason (401 since
// #17238), and the case would fail against a CORRECT implementation.
expect(signed, 'the two spellings coincide — this control cannot fire').not.toBe(unsigned);
const before = storedToken(client);
expect(before).toBe(signed);
Expand Down
25 changes: 20 additions & 5 deletions packages/client/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -877,11 +877,26 @@ describe('Auth enhancements', () => {
expect((client as any).token).toBeUndefined();
});

// [#16760] The anonymous answer is the literal `null` at 200. The lift must
// pass it through rather than manufacture a signed-in-looking envelope.
it('me() passes the anonymous null through untouched', async () => {
const { client } = createMockClient(null);
expect(await client.auth.me()).toBeNull();
// [#17238, reversing #16760] The anonymous answer WAS the literal `null` at
// 200, and this case pinned the SDK passing it through. The server no longer
// serves that: `/get-session` refuses an anonymous caller with the declared
// ADR-0112 envelope at 401, so `me()` rejects and every value it RESOLVES
// with is inside its declared `SessionResponse`.
//
// ⚠️ This case is fetch-MOCK driven, which is why the reversal had to be
// made by hand. Left alone it would have stayed GREEN — pinning a wire shape
// no server produces any more, against a mock that keeps producing it. Its
// real-server twin (`auth-get-session-envelope.test.ts` block ③) went red on
// the same change and announced itself; this one could not.
it('me() rejects the anonymous refusal rather than resolving outside its type', async () => {
const { client } = createMockClient(
{ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in first' } },
401,
);
await expect(client.auth.me()).rejects.toMatchObject({
code: 'UNAUTHENTICATED',
httpStatus: 401,
});
});

it('signInWithProvider defaults callbackURL to the current page (base-path-correct)', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,14 @@ const cookieFrom = (response: Response): string =>
.join('; ');

/**
* Is this cookie still authenticated? better-auth answers `/get-session` with
* HTTP 200 and a JSON `null` body when the session is gone — NOT a 401 — so a
* status-only assertion would pass against a fully revoked session.
* Is this cookie still authenticated?
*
* Reads the BODY, and that is deliberate: `/get-session` answers a live
* session `200` with `{ user, session }` and an unauthenticated caller `401`
* with the ADR-0112 refusal envelope (#17238), so both legs below are real.
* ⛔ Do not reduce this to a status check — the point of the helper is that a
* session which is gone is proven gone by the absence of a user, not by a
* status this file would then be trusting a single seam to keep emitting.
*/
const isAuthenticated = async (manager: AuthManager, cookie: string): Promise<boolean> => {
const res = await getSession(manager, cookie);
Expand Down
Loading
Loading