@stjoseph/prumo 0.0.1 → 0.0.3

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.
@@ -7,6 +7,9 @@ router render the authenticated group or redirect to login.
7
7
 
8
8
  Express protection with an authenticated layout group. Do not check for a session inside a screen.
9
9
 
10
+ **In that layout, render nothing while the session is pending.** Redirect to sign in only once it has arrived
11
+ empty or failed to load.
12
+
10
13
  When a deep link targets a protected route without a session, keep the intended destination **in memory**
11
14
  and navigate to it after a successful login. Do not persist it.
12
15
 
@@ -27,6 +30,13 @@ moment) while the alternative flash reads as a defect and can show somebody else
27
30
 
28
31
  Checking inside each screen fails differently: the screen written tomorrow does not check.
29
32
 
33
+ **A pending session is not a missing one.** The natural check, redirect when there is no session data, works
34
+ at a cold start only because the splash holds rendering until the session arrives, so the layout never sees
35
+ it pending. Signing out empties the query cache, and the next sign-in mounts the layout with the session still
36
+ on its way: the check reads no data, and sends the person who just signed in straight back to sign in. Nothing
37
+ in normal use shows it until somebody signs out. Waiting while pending must not become a way in either, so a
38
+ session that failed to load redirects just like an empty one.
39
+
30
40
  A dropped deep-link destination is the most common reason a notification link "does not work": the person
31
41
  taps a specific order, signs in, and lands on the home screen with the order to find by hand. Keeping the
32
42
  destination in memory rather than on disk matters because `orders/123` describes intent: it says the order
@@ -51,6 +61,15 @@ Protection:
51
61
  ❌ app/orders.tsx → if (!session) router.replace('/login')
52
62
  ```
53
63
 
64
+ Deciding in the layout:
65
+
66
+ ```
67
+ ✅ if (session.isPending) return null
68
+ if (session.data === null || session.data === undefined) return <Redirect href="/sign-in" />
69
+ ❌ if (!session.data) return <Redirect href="/sign-in" />
70
+ // bounces the next sign-in back once the cache has been cleared
71
+ ```
72
+
54
73
  Navigation:
55
74
 
56
75
  ```
@@ -65,6 +84,10 @@ before it renders.
65
84
 
66
85
  **Compiler.** Typed routes reject a misspelled path and a missing or wrongly typed param.
67
86
 
87
+ **Tests.** Render the router with an empty cache and three session answers: one that arrives late expects
88
+ the protected screen, an empty one and a failed one expect sign in. Rendering with a session already cached
89
+ passes with the defect in place.
90
+
68
91
  **Review only.** That the splash is released only after the session resolves, and that a deep-link
69
92
  destination is not written to storage.
70
93
 
@@ -2,10 +2,14 @@
2
2
 
3
3
  ## Rule
4
4
 
5
- Give the root `package.json` three scripts (`lint`, `typecheck` and `test`), each running through
6
- `pnpm -r`.
5
+ Give the root `package.json` a `dev` script that starts every app with `pnpm -r --parallel dev`, and one script
6
+ per app, named after it, that starts only that app with `pnpm --filter <app> dev`. Give it `lint`, `typecheck`
7
+ and `test` as well, each running through `pnpm -r`.
7
8
 
8
- **Do not add a `dev` script at the root.** Start development with `pnpm --filter <app> dev`.
9
+ **Every app answers to `dev`.** When a framework names its development command differently, keep its name and
10
+ alias it: `"dev": "pnpm start:dev"` in the API, `"dev": "pnpm start"` in mobile.
11
+
12
+ **Give every app its own port.** The API holds 3000, so the site runs on 3200.
9
13
 
10
14
  Keep compiler options in `tsconfig.base.json` at the workspace root. Extend it by relative path from each
11
15
  app and package, overriding only what differs.
@@ -21,9 +25,17 @@ its own `biome.jsonc` with `"extends": "//"`, holding only what differs.
21
25
  What does not exist is caching, and caching pays once a build hurts, which a freshly generated workspace
22
26
  does not.
23
27
 
24
- The missing `dev` script is deliberate. `pnpm dev` in a workspace with four apps would start four
25
- processes, one of them interactive, when whoever typed it expected the app they are working on. **Its
26
- absence is what teaches that `dev` is per app**; if it existed, nobody would learn.
28
+ `pnpm dev` is safe because the one-app command is just as short. Without `pnpm web` beside it, typing
29
+ `pnpm dev` while expecting the app you work on would start four processes; with it, starting everything is a
30
+ choice rather than an accident. The root scripts call `dev` and nothing else, which is why every app answers to
31
+ that name: the root never needs to know how each framework spells its development command.
32
+
33
+ **Mobile gives up its terminal under `pnpm dev`.** Metro starts and serves, so an open simulator connects, but
34
+ the QR code, the `exp://` URL and the keyboard shortcuts appear only when Expo owns the terminal. When you need
35
+ them, run `pnpm mobile` in a terminal of its own.
36
+
37
+ Ports are fixed per app because `pnpm dev` starts them together, and a collision there fails only when two apps
38
+ run at once: `next dev` defaults to 3000, which the API already holds.
27
39
 
28
40
  The other three belong at the root because that is what the pre-push hook calls. Leaving them out would
29
41
  mean each person assembling their own `--filter`, and several versions of one command.
@@ -52,8 +64,10 @@ The workspace root, and every `tsconfig.json` beneath it.
52
64
  Starting work:
53
65
 
54
66
  ```
55
- ✅ pnpm --filter web dev
56
- ❌ pnpm dev // four apps, one of them interactive
67
+ ✅ pnpm web // the app you are working on
68
+ ✅ pnpm dev // every app; mobile without its QR code
69
+ ✅ pnpm mobile // mobile with its QR code and shortcuts
70
+ ❌ "dev" missing from an app // pnpm dev silently skips it
57
71
  ```
58
72
 
59
73
  Extending the base:
@@ -67,8 +81,9 @@ Extending the base:
67
81
 
68
82
  **The hook.** Pre-push runs the root `typecheck`, so a type error in any package stops the push.
69
83
 
70
- **Review only.** That a new app extends the base rather than copying it, and that a root `dev` script has
71
- not appeared.
84
+ **Review only.** That a new app extends the base rather than copying it, answers to `dev`, runs on a port no
85
+ other app uses, and has a root script named after it. A missing `dev` fails nothing: `pnpm -r` skips a package
86
+ without the script, so the app simply does not start.
72
87
 
73
88
  **The reversal condition, stated so it is measurable rather than a feeling:** without caching,
74
89
  `pnpm -r typecheck` runs `tsc` in every package from scratch every time. Turborepo belongs on the table the
@@ -14,6 +14,12 @@ types the router derives from the file name.
14
14
  Let TanStack Query own the cache. Use a route loader only to call `ensureQueryData`, and read the data in
15
15
  the component through the Query hook. Never pass loader data down as props.
16
16
 
17
+ **After signing in or signing up, `await` a `refetchQueries` of the session before navigating.** Never
18
+ `invalidateQueries` for it.
19
+
20
+ **On signing out, `clear()` the whole query cache**, whether or not the sign-out request succeeded, then navigate
21
+ to sign in.
22
+
17
23
  ## Rationale
18
24
 
19
25
  The generated route tree is committed because application code imports it, so without it `tsc` fails on a
@@ -36,6 +42,21 @@ The loader prefetches rather than fetches because two caches holding the same da
36
42
  screen not update* becomes unanswerable. Data handed down as props sits in no cache, so invalidating
37
43
  after a mutation never reaches it.
38
44
 
45
+ **The session is the one query where invalidating fails silently.** The authenticated layout reads it
46
+ with `ensureQueryData`, which fetches only when the cache is empty, and a visitor without a session leaves
47
+ `null` there, which is not empty. `invalidateQueries` only marks it stale and refetches what a component
48
+ observes, and nothing observes the session. So the sign-in succeeds, the server holds a session, and the
49
+ layout reads the cached `null` and sends the visitor back to sign in. `refetchQueries` includes queries
50
+ nobody observes and resolves once the new value is cached. Following the loader rule above and the
51
+ invalidation rule in `data.md` to the letter is exactly how this defect is written.
52
+
53
+ **Signing out needs more than the session.** A query about *me* is keyed by what it means, not by who asked:
54
+ `['profile', 'me']` holds whoever was signed in. Refetching only the session lets the next person on the same tab
55
+ sign in and read the previous person's profile, because the loader's `ensureQueryData` finds it cached. Clearing
56
+ the cache is the only choice that stays correct when somebody adds the next query without thinking about signing
57
+ out; listing the user's queries one by one fails the day one is forgotten. It clears even when the request fails,
58
+ because a server session left alive is a smaller harm than a previous user's data left readable.
59
+
39
60
  ## Applies to
40
61
 
41
62
  Every file under `routes/`.
@@ -66,6 +87,26 @@ Loading data:
66
87
  // component: props.orders
67
88
  ```
68
89
 
90
+ After the session changes:
91
+
92
+ ```
93
+ ✅ await queryClient.refetchQueries({ queryKey: sessionQuery(auth).queryKey })
94
+ await navigate({ to: redirect ?? '/' })
95
+ ❌ await queryClient.invalidateQueries({ queryKey: sessionQuery(auth).queryKey })
96
+ // the layout still reads the cached null
97
+ ```
98
+
99
+ Signing out:
100
+
101
+ ```
102
+ ✅ await auth.signOut()
103
+ queryClient.clear()
104
+ await navigate({ to: '/sign-in' })
105
+ ❌ await auth.signOut()
106
+ await queryClient.refetchQueries({ queryKey: sessionQuery(auth).queryKey })
107
+ // the next person signs in and reads the previous profile
108
+ ```
109
+
69
110
  ## Enforcement
70
111
 
71
112
  **Structural.** A route inside the authenticated layout cannot skip the redirect, because the layout runs
@@ -74,8 +115,14 @@ first.
74
115
  **Compiler.** `validateSearch` makes the search params' types reflect what survived validation, so a
75
116
  component cannot read a field the schema does not produce.
76
117
 
77
- **Review only.** That the loader prefetches rather than returning data the component consumes, and that a
78
- route needing a session was not placed outside the layout.
118
+ **Tests.** A test that renders the app at a protected route, signs in, and expects the protected page. It
119
+ must start from the protected route: starting on the sign-in page never caches the `null`, and passes with
120
+ the defect in place. A second test signs one person out, signs another in on the same app, and expects the
121
+ second person's data rather than the first's.
122
+
123
+ **Review only.** That the loader prefetches rather than returning data the component consumes, that a
124
+ route needing a session was not placed outside the layout, that every form signing in refetches the session,
125
+ and that signing out clears the cache.
79
126
 
80
127
  **A tie worth knowing:** the query key is shared between the loader and the component. If they differ, the
81
128
  prefetch fills one cache entry and the component reads another, paying for the request twice and gaining
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <p align="center">
6
6
  <a href="https://www.npmjs.com/package/@stjoseph/prumo"><img src="https://img.shields.io/npm/v/%40stjoseph%2Fprumo?color=1e3a5f&label=npm" alt="npm"></a>
7
- <a href="https://github.com/stjosephworks/prumo/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/%40stjoseph%2Fprumo?color=1e3a5f" alt="MIT"></a>
7
+ <a href="https://github.com/stjosephworks/prumo/blob/main/LICENSE"><img src="https://img.shields.io/github/license/stjosephworks/prumo?color=1e3a5f" alt="MIT"></a>
8
8
  </p>
9
9
 
10
10
  Prumo is a set of choices a team has already made, packaged so that starting a new TypeScript
@@ -36,9 +36,14 @@ Node 22.17 or later, pnpm, and Docker for anything with an API.
36
36
  ## Running it
37
37
 
38
38
  ```sh
39
- pnpm dlx @stjoseph/prumo new my-app
39
+ npx @stjoseph/prumo new my-app # npm
40
+ pnpm dlx @stjoseph/prumo new my-app # pnpm
40
41
  ```
41
42
 
43
+ Either runner starts it, and the generated project installs with pnpm, which it needs anyway. The
44
+ published package is [`@stjoseph/prumo`](https://www.npmjs.com/package/@stjoseph/prumo), and the
45
+ command it installs is `prumo`.
46
+
42
47
  It asks what to build, writes the project into `my-app/`, runs `git init`, and installs. Nothing is
43
48
  committed, so the first commit is yours and the diff shows everything Prumo wrote.
44
49
 
@@ -47,7 +52,7 @@ terminal a missing answer is an error rather than a default, so nothing importan
47
52
  silence:
48
53
 
49
54
  ```sh
50
- pnpm dlx @stjoseph/prumo new my-app --types api,web --single-tenant
55
+ npx @stjoseph/prumo new my-app --types api,web --single-tenant
51
56
  ```
52
57
 
53
58
  | Flag | What it answers |
package/dist/assets.js ADDED
@@ -0,0 +1,16 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+ // Decided by where the CLI runs from, never by what exists on disk: after a build, `cli/templates` is a stale copy
4
+ // sitting right beside the source, and probing for it first made the source CLI generate from yesterday's templates.
5
+ export function assetsFor(here) {
6
+ const root = basename(here) === 'dist' ? join(here, '..') : join(here, '..', '..');
7
+ return { templates: join(root, 'templates'), knowledge: join(root, '.prumo-templates') };
8
+ }
9
+ export function requireAssets(assets) {
10
+ for (const path of Object.values(assets)) {
11
+ if (!existsSync(path)) {
12
+ throw new Error(`Cannot find ${path}`);
13
+ }
14
+ }
15
+ return assets;
16
+ }
package/dist/cli.js CHANGED
@@ -1,21 +1,14 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync } from 'node:fs';
3
- import { join, resolve } from 'node:path';
2
+ import { resolve } from 'node:path';
4
3
  import { parseArgs } from 'node:util';
5
4
  import * as prompt from '@clack/prompts';
6
5
  import { terminalAsker } from "./asker.js";
6
+ import { assetsFor, requireAssets } from "./assets.js";
7
7
  import { generate } from "./generate.js";
8
8
  import { validateProjectName } from "./names.js";
9
9
  import { resolveAnswers } from "./questions.js";
10
10
  const USAGE = `Usage: prumo new [name] [--types api,web,mobile,site] [--alone | --monorepo]
11
11
  [--multi-tenant | --single-tenant] [--skip-install]`;
12
- function firstExisting(...candidates) {
13
- const found = candidates.find((candidate) => existsSync(candidate));
14
- if (found === undefined) {
15
- throw new Error(`Cannot find ${candidates.join(' or ')}`);
16
- }
17
- return found;
18
- }
19
12
  async function main() {
20
13
  const { values, positionals } = parseArgs({
21
14
  allowPositionals: true,
@@ -48,8 +41,7 @@ async function main() {
48
41
  }
49
42
  const here = import.meta.dirname;
50
43
  await generate({
51
- templates: firstExisting(join(here, '../templates'), join(here, '../../templates')),
52
- knowledge: firstExisting(join(here, '../.prumo-templates'), join(here, '../../.prumo-templates')),
44
+ ...requireAssets(assetsFor(here)),
53
45
  target: resolve(answers.name),
54
46
  answers,
55
47
  install: !values['skip-install'],
package/dist/compose.js CHANGED
@@ -203,6 +203,8 @@ export async function composeWorkspace({ templates, target, types, mobileScheme
203
203
  return text.replace('# MOBILE_APP_SCHEME=app\n', `MOBILE_APP_SCHEME=${mobileScheme}\n`);
204
204
  });
205
205
  }
206
+ // One command for everything and one per app. Each app answers to `dev`, so the root never needs to know how.
207
+ await rewriteText(join(target, 'package.json'), (text) => types.reduce((pkg, type) => setJsonc(pkg, ['scripts', type], `pnpm --filter ${type} dev`), setJsonc(text, ['scripts', 'dev'], 'pnpm -r --parallel dev')));
206
208
  const manifests = await Promise.all(apps.map(async (app) => readJsonc(await readFile(join(app, 'package.json'), 'utf8'))));
207
209
  const catalogs = collectCatalog(manifests);
208
210
  if (clients.length > 0) {
package/dist/generate.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { spawnSync } from 'node:child_process';
2
+ import { randomBytes } from 'node:crypto';
2
3
  import { existsSync } from 'node:fs';
3
4
  import { readdir, readFile, writeFile } from 'node:fs/promises';
4
5
  import { join } from 'node:path';
@@ -20,6 +21,23 @@ async function nameMobileApp(app, name) {
20
21
  return setJsonc(result, ['expo', 'scheme'], schemeFor(name));
21
22
  });
22
23
  }
24
+ const SECRET_LINE = /^BETTER_AUTH_SECRET=.*$/m;
25
+ // `.env` stays out of git, so the example is the committed truth and a fresh project gets a copy it can start with.
26
+ // Only the secret differs: a sample value is public, so every generated API draws its own.
27
+ async function writeLocalEnv(app, type) {
28
+ const example = join(app, '.env.example');
29
+ if (!existsSync(example)) {
30
+ return;
31
+ }
32
+ let env = await readFile(example, 'utf8');
33
+ if (type === 'api') {
34
+ if (!SECRET_LINE.test(env)) {
35
+ throw new Error(`${example} no longer carries a BETTER_AUTH_SECRET line`);
36
+ }
37
+ env = env.replace(SECRET_LINE, `BETTER_AUTH_SECRET=${randomBytes(32).toString('base64url')}`);
38
+ }
39
+ await writeFile(join(app, '.env'), env);
40
+ }
23
41
  function run(command, args, cwd) {
24
42
  const result = spawnSync(command, args, { cwd, stdio: 'inherit' });
25
43
  if (result.status !== 0) {
@@ -34,6 +52,7 @@ export async function generate({ templates, knowledge, target, answers, install,
34
52
  if (answers.architecture === 'alone' && only !== undefined) {
35
53
  await copyTemplate(join(templates, only), target);
36
54
  await nameProject(target, answers.name);
55
+ await writeLocalEnv(target, only);
37
56
  if (only === 'mobile') {
38
57
  await nameMobileApp(target, answers.name);
39
58
  }
@@ -46,6 +65,9 @@ export async function generate({ templates, knowledge, target, answers, install,
46
65
  mobileScheme: schemeFor(answers.name),
47
66
  });
48
67
  await nameProject(target, answers.name);
68
+ for (const type of answers.types) {
69
+ await writeLocalEnv(join(target, 'apps', type), type);
70
+ }
49
71
  if (answers.types.includes('mobile')) {
50
72
  await nameMobileApp(join(target, 'apps', 'mobile'), answers.name);
51
73
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stjoseph/prumo",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "Scaffolds a TypeScript project and ships it the conventions it follows, in a .prumo/ folder any AI assistant reads.",
5
5
  "keywords": [
6
6
  "scaffolding",
@@ -23,6 +23,7 @@
23
23
  "license": "MIT",
24
24
  "author": "Leonardo Freitas",
25
25
  "type": "module",
26
+ "packageManager": "pnpm@12.3.4",
26
27
  "bin": {
27
28
  "prumo": "dist/cli.js"
28
29
  },
@@ -8,12 +8,14 @@ Node 22.17 or later, pnpm, and Docker.
8
8
 
9
9
  ```sh
10
10
  pnpm install
11
- cp .env.example .env # then replace BETTER_AUTH_SECRET with 32 or more random characters
12
11
  docker compose up -d --wait
13
12
  pnpm db:migrate
14
- pnpm start:dev
13
+ pnpm dev
15
14
  ```
16
15
 
16
+ `.env` comes with the generated project, copied from `.env.example` with a random `BETTER_AUTH_SECRET`. It is not
17
+ committed, so on a fresh clone run `cp .env.example .env` and replace the secret with 32 or more random characters.
18
+
17
19
  The API listens on `http://localhost:3000`. Outside production, its documentation is at `/api/docs`.
18
20
 
19
21
  `pnpm db:migrate` builds first, then runs Better Auth's migrations and then the application's. Before
@@ -6,6 +6,7 @@
6
6
  "build": "nest build",
7
7
  "start": "nest start",
8
8
  "start:dev": "nest start --watch",
9
+ "dev": "pnpm start:dev",
9
10
  "start:prod": "node dist/main",
10
11
  "lint": "biome check",
11
12
  "typecheck": "tsc --noEmit",
@@ -9,10 +9,12 @@ to open the app.
9
9
 
10
10
  ```sh
11
11
  pnpm install
12
- cp .env.example .env # EXPO_PUBLIC_API_URL points at the API
13
- pnpm start
12
+ pnpm dev
14
13
  ```
15
14
 
15
+ `.env` comes with the generated project, copied from `.env.example`; `EXPO_PUBLIC_API_URL` points at the API. It is
16
+ not committed, so on a fresh clone run `cp .env.example .env`.
17
+
16
18
  On an Android emulator `localhost` is the emulator itself; point `EXPO_PUBLIC_API_URL` at your machine's address.
17
19
 
18
20
  ## Everyday commands
@@ -5,6 +5,7 @@
5
5
  "main": "expo-router/entry",
6
6
  "scripts": {
7
7
  "start": "expo start",
8
+ "dev": "pnpm start",
8
9
  "ios": "expo start --ios",
9
10
  "android": "expo start --android",
10
11
  "bundle": "expo export --platform ios --platform android",
@@ -9,6 +9,12 @@ export default function AuthenticatedLayout() {
9
9
  const session = useQuery(sessionQuery(auth))
10
10
  const pathname = usePathname()
11
11
 
12
+ // Pending is not signed out: after the cache is cleared, the session takes a request to arrive, and redirecting on
13
+ // that would send whoever just signed in straight back to sign in. A failed request still redirects.
14
+ if (session.isPending) {
15
+ return null
16
+ }
17
+
12
18
  if (session.data === null || session.data === undefined) {
13
19
  rememberIntendedRoute(pathname)
14
20
  return <Redirect href="/sign-in" />
@@ -1,18 +1,22 @@
1
1
  import { useQuery } from '@tanstack/react-query'
2
+ import { useRouter } from 'expo-router'
2
3
  import { Text } from 'react-native'
3
4
  import { SafeAreaView } from 'react-native-safe-area-context'
4
5
  import { profileQuery } from '@/api-contract'
6
+ import { SignOutButton } from '@/features/auth/sign-out-button'
5
7
  import { useClients } from '@/features/clients/clients-context'
6
8
  import { ProfileForm } from '@/features/profile/profile-form'
7
9
 
8
10
  export default function ProfileScreen() {
9
- const { api } = useClients()
11
+ const { api, auth } = useClients()
12
+ const router = useRouter()
10
13
  const profile = useQuery(profileQuery(api))
11
14
 
12
15
  return (
13
16
  <SafeAreaView className="flex-1 justify-center gap-6 p-6">
14
17
  <Text className="text-2xl font-semibold text-neutral-900">Your profile</Text>
15
18
  {profile.data === undefined ? null : <ProfileForm api={api} profile={profile.data} />}
19
+ <SignOutButton auth={auth} onSignedOut={() => router.replace('/sign-in')} />
16
20
  </SafeAreaView>
17
21
  )
18
22
  }
@@ -0,0 +1,69 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
2
+ import { createAuthClient } from 'better-auth/react'
3
+ import { renderRouter, screen } from 'expo-router/testing-library'
4
+ import type { ReactNode } from 'react'
5
+ import { Text } from 'react-native'
6
+ import { createClient } from '@/api-contract'
7
+ import AuthenticatedLayout from '@/app/(app)/_layout'
8
+ import type { AuthClient } from '@/features/auth/auth-client'
9
+ import { ClientsProvider } from '@/features/clients/clients-context'
10
+ import { API_URL, fakeTransport, json } from '../../../test/fake-transport'
11
+
12
+ const user = { id: 'user-1', email: 'ana@example.com', name: 'Ana' }
13
+
14
+ async function renderAt(getSession: () => Response | Promise<Response>) {
15
+ const transport = fakeTransport({ 'GET /api/auth/get-session': getSession })
16
+ // jest-expo leaves the app manifest empty, and the Expo plugin needs it to build an origin; the layout depends only on
17
+ // what getSession returns, which the plugin does not change.
18
+ const auth = createAuthClient({
19
+ baseURL: API_URL,
20
+ basePath: '/api/auth',
21
+ fetchOptions: { customFetchImpl: transport },
22
+ }) as unknown as AuthClient
23
+ const clients = { auth, api: createClient({ baseUrl: API_URL, fetch: transport }) }
24
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
25
+
26
+ function Providers({ children }: { children: ReactNode }) {
27
+ return (
28
+ <QueryClientProvider client={queryClient}>
29
+ <ClientsProvider clients={clients}>{children}</ClientsProvider>
30
+ </QueryClientProvider>
31
+ )
32
+ }
33
+
34
+ await renderRouter(
35
+ {
36
+ '(app)/_layout': AuthenticatedLayout,
37
+ '(app)/index': () => <Text>Your profile</Text>,
38
+ 'sign-in': () => <Text>Sign in</Text>,
39
+ },
40
+ { initialUrl: '/', wrapper: Providers },
41
+ )
42
+ }
43
+
44
+ describe('AuthenticatedLayout', () => {
45
+ // An empty cache is what signing out leaves behind, and the session then takes a request to arrive.
46
+ it('waits for a session still on its way instead of sending a signed-in visitor to sign in', async () => {
47
+ await renderAt(async () => {
48
+ await new Promise((resolve) => setTimeout(resolve, 50))
49
+ return json(200, { session: { id: 'session-1', userId: user.id }, user })
50
+ })
51
+
52
+ expect(await screen.findByText('Your profile')).toBeOnTheScreen()
53
+ expect(screen.queryByText('Sign in')).toBeNull()
54
+ })
55
+
56
+ it('sends a visitor without a session to sign in', async () => {
57
+ await renderAt(() => json(200, null))
58
+
59
+ expect(await screen.findByText('Sign in')).toBeOnTheScreen()
60
+ })
61
+
62
+ // Waiting on a pending session must not become a way in: a session that cannot be read is not a session.
63
+ it('sends a visitor to sign in when the session cannot be read', async () => {
64
+ await renderAt(() => json(500, { message: 'Internal server error' }))
65
+
66
+ expect(await screen.findByText('Sign in')).toBeOnTheScreen()
67
+ expect(screen.queryByText('Your profile')).toBeNull()
68
+ })
69
+ })
@@ -0,0 +1,55 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
2
+ import { render, screen, userEvent, waitFor } from '@testing-library/react-native'
3
+ import { createAuthClient } from 'better-auth/react'
4
+ import { clearUserData } from '@/features/storage/storage'
5
+ import { API_URL, fakeTransport, json } from '../../../test/fake-transport'
6
+ import { SignOutButton } from './sign-out-button'
7
+
8
+ // MMKV needs its native module, which Jest does not have. What clearUserData removes is the persister's own key.
9
+ jest.mock('@/features/storage/storage', () => ({ clearUserData: jest.fn() }))
10
+
11
+ async function signOutWith(signOut: () => Response | Promise<Response>) {
12
+ const queryClient = new QueryClient()
13
+ const onSignedOut = jest.fn()
14
+ // jest-expo leaves the app manifest empty, and the Expo plugin needs it to build an origin; the button depends only on
15
+ // signOut resolving or failing, which the plugin does not change.
16
+ const auth = createAuthClient({
17
+ baseURL: API_URL,
18
+ basePath: '/api/auth',
19
+ fetchOptions: { customFetchImpl: fakeTransport({ 'POST /api/auth/sign-out': signOut }) },
20
+ })
21
+
22
+ queryClient.setQueryData(['session'], { session: { id: 'session-1' }, user: { id: 'user-1' } })
23
+ queryClient.setQueryData(['profile', 'me'], { id: 'profile-1', displayName: 'Ana' })
24
+
25
+ await render(
26
+ <QueryClientProvider client={queryClient}>
27
+ <SignOutButton auth={auth} onSignedOut={onSignedOut} />
28
+ </QueryClientProvider>,
29
+ )
30
+
31
+ await userEvent.setup().press(screen.getByRole('button', { name: 'Sign out' }))
32
+ await waitFor(() => expect(onSignedOut).toHaveBeenCalled())
33
+
34
+ return queryClient
35
+ }
36
+
37
+ describe('SignOutButton', () => {
38
+ beforeEach(() => jest.mocked(clearUserData).mockClear())
39
+
40
+ it('leaves nothing of the user behind for the next person on the device', async () => {
41
+ const queryClient = await signOutWith(() => json(200, { success: true }))
42
+
43
+ expect(queryClient.getQueryCache().getAll()).toHaveLength(0)
44
+ expect(clearUserData).toHaveBeenCalledTimes(1)
45
+ })
46
+
47
+ it('still clears the device when the sign-out request never arrives', async () => {
48
+ const queryClient = await signOutWith(() => {
49
+ throw new TypeError('Network request failed')
50
+ })
51
+
52
+ expect(queryClient.getQueryCache().getAll()).toHaveLength(0)
53
+ expect(clearUserData).toHaveBeenCalledTimes(1)
54
+ })
55
+ })
@@ -0,0 +1,33 @@
1
+ import { useQueryClient } from '@tanstack/react-query'
2
+ import { Button } from '@/components/ui/button'
3
+ import { clearUserData } from '@/features/storage/storage'
4
+ import type { AuthClient } from './auth-client'
5
+
6
+ export type SignOutAuth = Pick<AuthClient, 'signOut'>
7
+
8
+ export function SignOutButton({
9
+ auth,
10
+ onSignedOut,
11
+ }: {
12
+ auth: SignOutAuth
13
+ onSignedOut: () => void
14
+ }) {
15
+ const queryClient = useQueryClient()
16
+
17
+ async function signOut() {
18
+ try {
19
+ await auth.signOut()
20
+ } catch {
21
+ // A failed request leaves nothing on the device to act on: the Expo plugin deletes the stored cookie before it
22
+ // sends. The server's session outlives it until it expires, and signing out locally is still what was asked.
23
+ }
24
+
25
+ // What the plugin cannot know is the rest of this user's data: the query cache and every user-scoped key, or the
26
+ // next person on this device reads them.
27
+ queryClient.clear()
28
+ clearUserData()
29
+ onSignedOut()
30
+ }
31
+
32
+ return <Button label="Sign out" variant="outline" onPress={signOut} />
33
+ }
@@ -11,7 +11,7 @@ pnpm install
11
11
  pnpm dev
12
12
  ```
13
13
 
14
- The site runs on `http://localhost:3000`. It calls no API yet; when a page needs data, it fetches it on the server.
14
+ The site runs on `http://localhost:3200`, leaving 3000 to the API. It calls no API yet; when a page needs data, it fetches it on the server.
15
15
 
16
16
  ## Everyday commands
17
17
 
@@ -4,9 +4,9 @@
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
7
- "dev": "next dev",
7
+ "dev": "next dev --port 3200",
8
8
  "build": "next build",
9
- "start": "next start",
9
+ "start": "next start --port 3200",
10
10
  "lint": "biome check",
11
11
  "typecheck": "next typegen && tsc --noEmit",
12
12
  "test": "vitest run",
@@ -8,12 +8,18 @@ Node 22.17 or later, pnpm, and the API running.
8
8
 
9
9
  ```sh
10
10
  pnpm install
11
- cp .env.example .env # VITE_API_URL points at the API
12
11
  pnpm dev
13
12
  ```
14
13
 
14
+ `.env` comes with the generated project, copied from `.env.example`; `VITE_API_URL` points at the API. It is not
15
+ committed, so on a fresh clone run `cp .env.example .env`.
16
+
15
17
  The app runs on `http://localhost:5173`, which is the origin the API trusts by default (`WEB_ORIGIN`).
16
18
 
19
+ Open it and you are sent to sign in, because everything but the sign-in and sign-up pages needs a session. Follow
20
+ **Sign up** to create an account; you land on your profile, which you can edit. The API must be running and
21
+ migrated first, or the session check has nothing to answer it.
22
+
17
23
  ## Everyday commands
18
24
 
19
25
  | Command | What it does |
@@ -1,12 +1,142 @@
1
1
  import { screen } from '@testing-library/react'
2
+ import userEvent from '@testing-library/user-event'
2
3
  import { describe, expect, it } from 'vitest'
4
+ import type { Profile } from '@/api-contract'
3
5
  import { fakeTransport, json } from '../test/fake-transport'
4
6
  import { renderApp } from '../test/render-app'
5
7
 
8
+ const user = { id: 'user-1', email: 'ana@example.com', name: 'Ana' }
9
+
10
+ const session = { session: { id: 'session-1', userId: user.id }, user }
11
+
12
+ const profile: Profile = {
13
+ id: 'profile-1',
14
+ userId: user.id,
15
+ displayName: 'Ana',
16
+ locale: 'en',
17
+ timezone: 'UTC',
18
+ createdAt: '2026-09-14T12:57:24.057Z',
19
+ updatedAt: '2026-09-14T12:57:24.057Z',
20
+ }
21
+
6
22
  describe('App', () => {
7
23
  it('sends a visitor without a session to sign in', async () => {
8
24
  renderApp('/', fakeTransport({ 'GET /api/auth/get-session': () => json(200, null) }))
9
25
 
10
26
  expect(await screen.findByRole('heading', { name: 'Sign in' })).toBeInTheDocument()
11
27
  })
28
+
29
+ // The protected route reads the session through ensureQueryData, which keeps a cached null. Found in a real browser:
30
+ // signing in succeeded, a session existed, and the visitor was sent straight back to sign in.
31
+ it('takes a visitor who signs in to the page they were turned away from', async () => {
32
+ const visitor = userEvent.setup()
33
+ let signedIn = false
34
+
35
+ renderApp(
36
+ '/',
37
+ fakeTransport({
38
+ 'GET /api/auth/get-session': () => json(200, signedIn ? session : null),
39
+ 'POST /api/auth/sign-in/email': () => {
40
+ signedIn = true
41
+ return json(200, { redirect: false, token: 'token-1', user })
42
+ },
43
+ 'GET /api/v1/users/me': () => json(200, profile),
44
+ }),
45
+ )
46
+
47
+ await visitor.type(await screen.findByLabelText('Email'), user.email)
48
+ await visitor.type(screen.getByLabelText('Password'), 'correct-horse-battery')
49
+ await visitor.click(screen.getByRole('button', { name: 'Sign in' }))
50
+
51
+ expect(await screen.findByRole('heading', { name: 'Your profile' })).toBeInTheDocument()
52
+ })
53
+
54
+ // Starting from sign-up would hide the defect: only a protected route caches the null session first.
55
+ it('takes a visitor turned away to sign in, who then creates an account, into the app', async () => {
56
+ const visitor = userEvent.setup()
57
+ let registered = false
58
+
59
+ renderApp(
60
+ '/',
61
+ fakeTransport({
62
+ 'GET /api/auth/get-session': () => json(200, registered ? session : null),
63
+ 'POST /api/auth/sign-up/email': () => {
64
+ registered = true
65
+ return json(200, { token: 'token-1', user })
66
+ },
67
+ 'GET /api/v1/users/me': () => json(200, profile),
68
+ }),
69
+ )
70
+
71
+ await visitor.click(await screen.findByRole('link', { name: 'Sign up' }))
72
+ await visitor.type(await screen.findByLabelText('Name'), user.name)
73
+ await visitor.type(screen.getByLabelText('Email'), user.email)
74
+ await visitor.type(screen.getByLabelText('Password'), 'correct-horse-battery')
75
+ await visitor.click(screen.getByRole('button', { name: 'Create account' }))
76
+
77
+ expect(await screen.findByRole('heading', { name: 'Your profile' })).toBeInTheDocument()
78
+ })
79
+
80
+ it('shows the next person on the tab their own profile, not the previous one', async () => {
81
+ const visitor = userEvent.setup()
82
+ const people = {
83
+ ana: { user, profile },
84
+ bruno: {
85
+ user: { id: 'user-2', email: 'bruno@example.com', name: 'Bruno' },
86
+ profile: { ...profile, id: 'profile-2', userId: 'user-2', displayName: 'Bruno' },
87
+ },
88
+ }
89
+ let current: keyof typeof people | null = 'ana'
90
+
91
+ renderApp(
92
+ '/',
93
+ fakeTransport({
94
+ 'GET /api/auth/get-session': () =>
95
+ json(
96
+ 200,
97
+ current === null
98
+ ? null
99
+ : { session: { id: `session-${current}` }, user: people[current].user },
100
+ ),
101
+ 'POST /api/auth/sign-out': () => {
102
+ current = null
103
+ return json(200, { success: true })
104
+ },
105
+ 'POST /api/auth/sign-in/email': () => {
106
+ current = 'bruno'
107
+ return json(200, { redirect: false, token: 'token-2', user: people.bruno.user })
108
+ },
109
+ 'GET /api/v1/users/me': () => json(200, current === null ? null : people[current].profile),
110
+ }),
111
+ )
112
+
113
+ expect(await screen.findByLabelText('Display name')).toHaveValue('Ana')
114
+
115
+ await visitor.click(screen.getByRole('button', { name: 'Sign out' }))
116
+ await visitor.type(await screen.findByLabelText('Email'), people.bruno.user.email)
117
+ await visitor.type(screen.getByLabelText('Password'), 'correct-horse-battery')
118
+ await visitor.click(screen.getByRole('button', { name: 'Sign in' }))
119
+
120
+ await screen.findByRole('heading', { name: 'Your profile' })
121
+ expect(screen.getByLabelText('Display name')).toHaveValue('Bruno')
122
+ })
123
+
124
+ it('signs a visitor out of this tab even when the sign-out request never arrives', async () => {
125
+ const visitor = userEvent.setup()
126
+
127
+ renderApp(
128
+ '/',
129
+ fakeTransport({
130
+ 'GET /api/auth/get-session': () => json(200, session),
131
+ 'GET /api/v1/users/me': () => json(200, profile),
132
+ 'POST /api/auth/sign-out': () => {
133
+ throw new TypeError('Failed to fetch')
134
+ },
135
+ }),
136
+ )
137
+
138
+ await visitor.click(await screen.findByRole('button', { name: 'Sign out' }))
139
+
140
+ expect(await screen.findByRole('heading', { name: 'Sign in' })).toBeInTheDocument()
141
+ })
12
142
  })
@@ -38,7 +38,9 @@ export function SignInForm({ auth, redirect }: { auth: AuthClient; redirect: str
38
38
  return
39
39
  }
40
40
 
41
- await queryClient.invalidateQueries({ queryKey: sessionQuery(auth).queryKey })
41
+ // Invalidating is not enough: nothing observes the session, and the protected route's ensureQueryData
42
+ // would keep returning the cached null. Refetching replaces it before the route reads it.
43
+ await queryClient.refetchQueries({ queryKey: sessionQuery(auth).queryKey })
42
44
  await navigate({ to: redirect ?? '/' })
43
45
  }
44
46
 
@@ -0,0 +1,28 @@
1
+ import { useQueryClient } from '@tanstack/react-query'
2
+ import { useNavigate } from '@tanstack/react-router'
3
+ import { Button } from '@/components/ui/button'
4
+ import type { AuthClient } from './auth-client'
5
+
6
+ export function SignOutButton({ auth }: { auth: AuthClient }) {
7
+ const queryClient = useQueryClient()
8
+ const navigate = useNavigate()
9
+
10
+ async function signOut() {
11
+ try {
12
+ await auth.signOut()
13
+ } catch {
14
+ // Better Auth throws when the request never arrives. The cache is cleared regardless, because data left behind
15
+ // for the next person on this tab is the worse error; the cookie survives until the server's session expires.
16
+ }
17
+
18
+ // The whole cache, not only the session: a query about "me" belongs to whoever was signed in.
19
+ queryClient.clear()
20
+ await navigate({ to: '/sign-in' })
21
+ }
22
+
23
+ return (
24
+ <Button type="button" variant="outline" onClick={signOut}>
25
+ Sign out
26
+ </Button>
27
+ )
28
+ }
@@ -39,7 +39,9 @@ export function SignUpForm({ auth }: { auth: AuthClient }) {
39
39
  return
40
40
  }
41
41
 
42
- await queryClient.invalidateQueries({ queryKey: sessionQuery(auth).queryKey })
42
+ // Invalidating is not enough: nothing observes the session, and the protected route's ensureQueryData
43
+ // would keep returning the cached null. Refetching replaces it before the route reads it.
44
+ await queryClient.refetchQueries({ queryKey: sessionQuery(auth).queryKey })
43
45
  await navigate({ to: '/' })
44
46
  }
45
47
 
@@ -1,6 +1,7 @@
1
1
  import { useSuspenseQuery } from '@tanstack/react-query'
2
2
  import { createFileRoute } from '@tanstack/react-router'
3
3
  import { profileQuery } from '@/api-contract'
4
+ import { SignOutButton } from '@/features/auth/sign-out-button'
4
5
  import { ProfileForm } from '@/features/profile/profile-form'
5
6
 
6
7
  export const Route = createFileRoute('/_authenticated/')({
@@ -9,13 +10,14 @@ export const Route = createFileRoute('/_authenticated/')({
9
10
  })
10
11
 
11
12
  function ProfilePage() {
12
- const { api } = Route.useRouteContext()
13
+ const { api, auth } = Route.useRouteContext()
13
14
  const { data: profile } = useSuspenseQuery(profileQuery(api))
14
15
 
15
16
  return (
16
17
  <>
17
18
  <h1 className="text-2xl font-semibold">Your profile</h1>
18
19
  <ProfileForm api={api} profile={profile} />
20
+ <SignOutButton auth={auth} />
19
21
  </>
20
22
  )
21
23
  }
@@ -16,9 +16,16 @@ Node 22.17 or later, pnpm, and Docker for the API.
16
16
  | Command | What it does |
17
17
  |---|---|
18
18
  | `pnpm install` | Installs every app and package |
19
- | `pnpm --filter <app> dev` | Starts one app. There is no root `dev` on purpose |
19
+ | `pnpm dev` | Starts every app at once, their output prefixed by app. Mobile runs without its QR code there; use `pnpm mobile` for that |
20
+ | `pnpm api` · `pnpm web` · `pnpm mobile` · `pnpm site` | Starts one app; the workspace has a script for each app it holds |
20
21
  | `pnpm lint` · `pnpm typecheck` · `pnpm test` | Runs across every app and package |
21
22
 
23
+ ## Trying it
24
+
25
+ With the API migrated, run `pnpm dev`. If the workspace holds `web`, open `http://localhost:5173`: you are sent to
26
+ sign in, and **Sign up** creates an account and takes you to your profile. Each app's README says what it needs
27
+ before it starts.
28
+
22
29
  ## Conventions
23
30
 
24
31
  The rules this project follows live in `.prumo/`, and `AGENTS.md` points to them. Read those before