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
2 changes: 2 additions & 0 deletions .reposkein/.gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
local/
nodes.jsonl
edges.jsonl
177 changes: 0 additions & 177 deletions .reposkein/edges.jsonl

This file was deleted.

120 changes: 0 additions & 120 deletions .reposkein/nodes.jsonl

This file was deleted.

88 changes: 79 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,32 @@ Powers OTP auth in: `producer-dashboard`, `cellarnode-importer-dashboard`, `cell
## Commands

```bash
pnpm install
pnpm build # tsc only
pnpm test # vitest run
make build # clean + lint + typecheck + compile (PREFERRED pre-publish gate)
npm install
npm run typecheck # tsc --noEmit
npm test # vitest run
npm run build # tsc
npx publint # package.json / exports lint
```

Always `make build` before opening a PR or publishing.
Those four are exactly the CI steps — run all four before opening a PR. There is
no Makefile in this repo; an earlier revision of this file recommended
`make build`, which never existed here.

## Exports

```
@cellarnode/auth # Core: store + client + api + types
@cellarnode/auth/react # LoginForm, RegisterForm, UnauthorizedPage, SquircleShift
@cellarnode/auth # Core: store + client + api + guard helpers + types
@cellarnode/auth/react # LoginForm, RegisterForm, UnauthorizedPage, SquircleShift,
# InputOTP (+ Group / Slot / Separator)
```

Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
The dev-bypass internals (`DevSignInBypass`, `DEV_LOGIN_EMAIL_STORAGE_KEY`,
`readDevLoginEmail`, `rememberDevLoginEmail`) are NOT exported from
`@cellarnode/auth/react` — see "Dev sign-in bypass" below.

### Core API

- `createAuthStore({ baseUrl })` — token persistence (localStorage in browser; mobile uses an `expo-secure-store` adapter on the consumer side).
- `createAuthStore({ baseUrl })` — token persistence (localStorage in browser; mobile uses an `expo-secure-store` adapter on the consumer side). Also exposes `devLogin(email)` (CEL-1364) — see "Dev sign-in bypass".
- `createAuthClient({ baseUrl, store, onAuthFailure })` — fetch wrapper, auto-attaches Bearer, calls `onAuthFailure` on 401.
- `createAuthApi({ client, store })` — typed login/register/logout helpers.
- `validateUserType(userType)` — `"producer" | "importer" | "distributor" | "admin"`.
Expand All @@ -52,9 +60,70 @@ src/
├── auth-store.ts # Token storage abstraction
├── extract-token.ts # JWT extraction helpers
├── types.ts # AuthStore, AuthClient, UserType, ...
└── react/ # LoginForm, RegisterForm, UnauthorizedPage, SquircleShift
└── react/ # LoginForm, RegisterForm, UnauthorizedPage, SquircleShift,
# InputOTP; plus dev-sign-in.tsx (INTERNAL — not in the
# react barrel)
```

## Dev sign-in bypass (CEL-1364)

`LoginForm` renders a "Dev sign-in (skip the code)" control **alongside** the
email form, and `AuthStore.devLogin(email)` backs it by POSTing the backend's
`POST /test/login` and adopting the returned JWE through `setAccessToken()` —
the same adoption path `verifyOtp` uses (same identity fetch, same refresh
scheduling, same listener fan-out).

Rules that must not drift:

- **Additive only.** The OTP flow is untouched, always rendered, never
auto-skipped, never auto-redirected. The OTP page stays testable.
- **The two affordances must not race, in BOTH directions.** The bypass button
is disabled while the OTP form is busy, AND the OTP "Continue" button is
disabled while the bypass is in flight. Drop the second half and a developer
can advance to the OTP step mid-`devLogin`, after which the resolving bypass
calls `onLoginSuccess()` from a step that no longer renders it.
- **Every failure reaches the UI.** `handleDevLogin` catches as well as
`finally`s. `devLogin` is optional on `AuthStore`, so a custom store may
reject; from a click handler that would be an unhandled rejection and the
button would silently re-enable with no message.
- **Fails closed.** The dev path resolves the user type through a separate
`GET /auth/me` (verify-otp gets it inline). If that call fails or answers
without a `userType`, the bypass clears the token and errors — an
unresolvable type is never treated as a passing portal check.
- **Not exported.** `DevSignInBypass` and the three storage symbols are absent
from `src/react/index.ts` on purpose. The gate is the single
`import.meta.env.DEV` call site inside `LoginForm`; an exported symbol has no
gate, and a consumer could render the bypass or write to `localStorage` from a
production build. Tests import `../src/react/dev-sign-in.js` directly.
- **No new env vars.** The frontend gate is the literal `import.meta.env.DEV`.
The backend gate stays `ENABLE_TEST_ENDPOINTS`. Do not add a `VITE_*` flag.
- **Be exact about what production drops.** Only the `DevSignInBypass`
COMPONENT is statically eliminated (Vite folds the literal, Rollup drops the
branch and the module; pinned by `__tests__/dev-bypass-treeshake.test.ts`,
which bundles the form both ways through esbuild and greps the output).
`authStore.devLogin`, its failure copy, and `readDevLoginEmail` /
`rememberDevLoginEmail` all sit in live function bodies behind runtime guards
and SHIP. Do not write docs or comments claiming otherwise.
- **The gate is the server, not the bundle.** `POST /test/login` is only mounted
when `!isProdLike() && ENABLE_TEST_ENDPOINTS === "true"`, and each handler
re-checks the same predicate, so in production the route does not exist and a
shipped `devLogin` can only resolve `test-endpoints-disabled`. A runtime
`import.meta.env.DEV` check inside `devLogin` was considered and declined: the
core entry is bundler-agnostic (`import.meta.env` is `undefined` under plain
Node ESM, so reading `.DEV` would THROW out of a method contracted never to),
and it would eliminate no code, since the guard sits in the same live body.
- **Anti-enumeration (backend T3-1).** `/test/login` returns the SAME 404 for
"gate off" and "no such account". `devLogin()` maps it to the single reason
`"test-endpoints-disabled"` and frames the copy as "set
`ENABLE_TEST_ENDPOINTS=true`" — never as a claim about the address.
- **`devLogin()` never rejects.** Every outcome, including a body that parses as
literal `null`, comes back as a `DevLoginResult`; its only caller is a click
handler with no other error channel.
- `devLogin` is **optional** on the `AuthStore` interface so custom store
implementations stay source-compatible; `createAuthStore()` always provides it.
- Optional prefill: `localStorage["cellarnode.dev.login-email"]`, read and
written only behind `import.meta.env.DEV`.

## Tailwind v4 content scan (consumer step)

When using `@cellarnode/auth/react`, consumers must register the lib's compiled JS so Tailwind picks up the utility classes inside the React components:
Expand All @@ -75,5 +144,6 @@ OTP flow against backend V2 public API (port 4000):
| POST | `/auth/refresh` | Rotate access token (replay-detection revokes session) |
| POST | `/auth/logout` | Revoke session in Redis (`cellarnode:session:*`) |
| GET | `/auth/me` | Current user; backend `authGuard()` accepts EITHER Bearer JWE (OTP path) OR cookie (admin BFF path). Cookie wins. |
| POST | `/test/login` | LOCAL DEV ONLY (CEL-1364). Body `{ email }` → `{ accessToken, userId, orgId }` + the OTP flow's refresh cookies. 404s uniformly unless the API runs with `ENABLE_TEST_ENDPOINTS=true` outside production. |

Session TTL defaults: access 15min, refresh 7d. See `cellarnode-backend-v2/AGENTS.md` for the full server-side schema.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## 0.14.0

### Added
- `AuthStore.devLogin(email)` (CEL-1364) — LOCAL-DEV helper that mints a session from the backend's `POST /test/login` and adopts the JWE through the same path `verifyOtp` uses (identity fetch, refresh scheduling, `onAccessTokenSet` / `onOrgChange` fan-out). Returns a `DevLoginResult` instead of throwing; the backend's uniform 404 maps to `reason: "test-endpoints-disabled"` with a "set `ENABLE_TEST_ENDPOINTS=true`" hint, never a claim about the address. Optional on the interface, so custom `AuthStore` implementations stay source-compatible.
- `LoginForm` renders a DEV-only "Dev sign-in (skip the code)" control **alongside** the email form — additive, never a replacement, no auto-redirect. Gated on the literal `import.meta.env.DEV`, so production builds tree-shake the control away (asserted against real bundler output, not just the runtime conditional). It applies the same portal guard as the OTP path and fails closed: if `/auth/me` cannot resolve a `userType`, the token is cleared instead of the session standing. While the bypass is in flight the OTP "Continue" button is disabled, so the two affordances cannot race. No new env vars.
- `DevLoginResult` / `DevLoginSuccess` / `DevLoginFailure` / `DevLoginFailureReason` types from `@cellarnode/auth`. The bypass internals (`DevSignInBypass`, `readDevLoginEmail`, `rememberDevLoginEmail`, `DEV_LOGIN_EMAIL_STORAGE_KEY`) are intentionally NOT exported from `@cellarnode/auth/react` — the DEV gate lives at `LoginForm`'s single call site, and an exported symbol would carry none. Note that `devLogin` itself and its failure copy do ship in production bundles; the gate is the server-side `ENABLE_TEST_ENDPOINTS` mount check, so the route simply does not exist there.

## 0.13.3

### Fixed
Expand Down
52 changes: 49 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,48 @@ const authApi = createAuthApi({ client: authClient, store: authStore });
import { LoginForm, RegisterForm, UnauthorizedPage } from "@cellarnode/auth/react";
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
```

### Tailwind CSS Content Scan
### Dev sign-in bypass (local development only)

`LoginForm` renders an extra "Dev sign-in (skip the code)" control **beside** the
email form when `import.meta.env.DEV` is true. It calls
`authStore.devLogin(email)`, which POSTs the backend's `/test/login` and adopts
the returned JWE through the same path `verifyOtp` uses. The OTP flow is
unchanged and remains the only path in production builds. There is no env var to
set on the frontend.

What is and is not dropped from a production bundle — the distinction matters,
so do not compress it:

- **Dropped.** The `DevSignInBypass` component and its markup. Vite folds
`import.meta.env.DEV` to `false`, Rollup removes the branch, and the module
goes with it. Pinned by `__tests__/dev-bypass-treeshake.test.ts`, which
bundles the form both ways and greps the output.
- **Kept.** `authStore.devLogin` and its failure copy, plus the
`readDevLoginEmail` / `rememberDevLoginEmail` helpers. All are reached from
live function bodies behind runtime `if` guards, so no bundler can prove them
unreachable. They are inert — the helpers only run inside the DEV branch, and
`devLogin` calls a route that is not mounted in production.

The security boundary is the **server**, not the bundle. `/test/login` is only
mounted when `NODE_ENV`/`MODE` is non-production **and**
`ENABLE_TEST_ENDPOINTS=true`, and every handler re-checks the same predicate.
When it is off, `/test/login` returns a uniform 404 and `devLogin()` resolves to
`{ ok: false, reason: "test-endpoints-disabled" }`. That 404 is deliberately
identical to the "no local account for this address" case, so neither the helper
nor the UI may present it as a statement about the account.

`devLogin()` never rejects; every outcome is a `DevLoginResult`. Callers should
still wrap it, because `devLogin` is optional on the `AuthStore` interface and a
custom store may reject.

```ts
const result = await authStore.devLogin?.("producer@example.com");
if (result?.ok) {
// session adopted: token set, refresh scheduled, listeners fired
}
```

### Tailwind CSS Content Scan
Add this to your CSS file so Tailwind picks up utility classes from the package:

```css
Expand All @@ -44,8 +84,14 @@ Add this to your CSS file so Tailwind picks up utility classes from the package:

## Exports

- `@cellarnode/auth` — Core: `createAuthStore`, `createAuthClient`, `createAuthApi`, `validateUserType`, types
- `@cellarnode/auth/react` — React: `LoginForm`, `RegisterForm`, `UnauthorizedPage`, `SquircleShift`
- `@cellarnode/auth` — Core: `createAuthStore`, `createAuthClient`, `createAuthApi`, `validateUserType`, `hasEntitlement`, `extractAccessToken`, types (incl. `DevLoginResult`)
- `@cellarnode/auth/react` — React: `LoginForm`, `RegisterForm`, `UnauthorizedPage`, `SquircleShift`, `InputOTP` (+ `Group` / `Slot` / `Separator`)

`DevSignInBypass`, `DEV_LOGIN_EMAIL_STORAGE_KEY`, `readDevLoginEmail` and
`rememberDevLoginEmail` are **deliberately not exported**. They are internals of
`LoginForm`'s `import.meta.env.DEV` branch; the gate lives at that one call site,
and an exported symbol carries no gate — a consumer importing it could render the
bypass UI, or write a sign-in address to `localStorage`, from a production build.

## License

Expand Down
85 changes: 85 additions & 0 deletions __tests__/dev-bypass-treeshake.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { fileURLToPath } from "node:url";
import * as esbuild from "esbuild";
import { describe, expect, it } from "vitest";

/**
* CEL-1364 — build-output proof that the DEV-only sign-in bypass leaves
* production bundles.
*
* The behavioural tests in `login-form-dev-bypass.test.tsx` pin the RUNTIME
* conditional: with `import.meta.env.DEV` stubbed false, nothing renders. That
* is not the same claim. A refactor that reads the flag through an indirection
* the bundler cannot fold statically would keep those tests green while
* shipping the bypass UI to production.
*
* So this bundles `login-form.tsx` the way a consumer's Vite production build
* does — `import.meta.env.DEV` statically defined to `false`, tree-shaking on —
* and asserts the `DevSignInBypass` markup is absent from the emitted code,
* while the same bundle built with DEV=true contains it. Building both
* directions is what stops the assertion going inert if the sentinels drift.
*
* esbuild stands in for Rollup here: it applies the same `define` + DCE that
* makes the elision work, and it is already installed as the transform half of
* vitest's own toolchain.
*
* SCOPE — read before widening. Only the COMPONENT is statically eliminated.
* `readDevLoginEmail` / `rememberDevLoginEmail` / `DEV_LOGIN_EMAIL_STORAGE_KEY`
* are called from live function bodies behind runtime `if` guards, so they
* survive into production bundles as unreachable code. That is a handful of
* bytes and no behaviour (both are no-ops unless called), but it means the
* storage-key literal is NOT a valid sentinel for this test.
*/

const ROOT = fileURLToPath(new URL("..", import.meta.url));

/** Strings that exist only inside `DevSignInBypass`. */
const DEV_ONLY_MARKUP = ["Dev sign-in (skip the code)", "Development only"];

/** Peers a consumer app supplies; irrelevant to what we are measuring. */
const EXTERNALS = [
"react",
"react/jsx-runtime",
"react-dom",
"lucide-react",
"input-otp",
"clsx",
"three",
"@react-three/fiber",
];

async function bundleLoginForm(dev: boolean): Promise<string> {
const result = await esbuild.build({
entryPoints: [`${ROOT}src/react/login-form.tsx`],
bundle: true,
write: false,
format: "esm",
treeShaking: true,
jsx: "automatic",
define: {
"import.meta.env.DEV": String(dev),
"import.meta.env.PROD": String(!dev),
},
external: EXTERNALS,
});

return result.outputFiles[0].text;
}

describe("dev sign-in bypass tree-shaking (CEL-1364)", () => {
it("drops the bypass UI from a production bundle and keeps it in a dev one", async () => {
const [prod, dev] = await Promise.all([
bundleLoginForm(false),
bundleLoginForm(true),
]);

// Guards against a stale sentinel: if these strings ever stop existing, the
// production assertion below would pass for the wrong reason.
for (const marker of DEV_ONLY_MARKUP) {
expect(dev, `DEV bundle should contain ${JSON.stringify(marker)}`).toContain(marker);
}

for (const marker of DEV_ONLY_MARKUP) {
expect(prod, `PROD bundle must not contain ${JSON.stringify(marker)}`).not.toContain(marker);
}
}, 60_000);
});
Loading
Loading