@almadar/orb 16.9.1 → 17.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +5 -0
  2. package/package.json +9 -6
  3. package/scripts/postinstall.js +38 -0
  4. package/shells/almadar-shell/package.json +17 -3
  5. package/shells/almadar-shell/packages/client/package.json +7 -7
  6. package/shells/almadar-shell/packages/client/src/App.tsx +23 -2
  7. package/shells/almadar-shell/packages/client/src/config/firebase.ts +23 -2
  8. package/shells/almadar-shell/packages/client/src/config/mockAuth.ts +257 -0
  9. package/shells/almadar-shell/packages/client/src/features/auth/AuthContext.tsx +22 -9
  10. package/shells/almadar-shell/packages/client/src/features/auth/authService.ts +28 -7
  11. package/shells/almadar-shell/packages/client/src/features/auth/components/PersonaSwitcher.tsx +50 -0
  12. package/shells/almadar-shell/packages/client/src/features/auth/components/ProtectedRoute.tsx +9 -0
  13. package/shells/almadar-shell/packages/client/src/features/auth/index.ts +2 -1
  14. package/shells/almadar-shell/packages/client/src/features/auth/types.ts +18 -2
  15. package/shells/almadar-shell/packages/server/package.json +9 -7
  16. package/shells/almadar-shell/packages/server/pnpm-lock.yaml +4723 -0
  17. package/shells/almadar-shell/packages/server/src/app.ts +58 -3
  18. package/shells/almadar-shell/packages/server/src/hooks-providers.ts +11 -0
  19. package/shells/almadar-shell/packages/server/src/index.ts +41 -2
  20. package/shells/almadar-shell/packages/server/src/services/clients.ts +17 -0
  21. package/shells/almadar-shell/packages/server/src/sse.ts +20 -0
  22. package/shells/almadar-shell/packages/server/src/types/express.d.ts +2 -8
  23. package/shells/almadar-shell/packages/server/tsconfig.json +0 -1
  24. package/shells/almadar-shell/packages/shared/package.json +1 -1
  25. package/shells/almadar-shell/pnpm-lock.yaml +3963 -1119
  26. package/shells/almadar-shell-hono/package.json +12 -6
  27. package/shells/almadar-shell-hono/packages/client/package.json +6 -6
  28. package/shells/almadar-shell-hono/packages/client/src/App.tsx +1 -5
  29. package/shells/almadar-shell-hono/packages/client/src/config/firebase.ts +23 -2
  30. package/shells/almadar-shell-hono/packages/client/src/features/auth/AuthContext.tsx +11 -6
  31. package/shells/almadar-shell-hono/packages/client/src/features/auth/authService.ts +10 -7
  32. package/shells/almadar-shell-hono/packages/client/src/features/auth/components/ProtectedRoute.tsx +9 -0
  33. package/shells/almadar-shell-hono/packages/server/package.json +5 -5
  34. package/shells/almadar-shell-hono/packages/server/src/app.ts +39 -0
  35. package/shells/almadar-shell-hono/packages/server/src/hooks-providers.ts +11 -0
  36. package/shells/almadar-shell-hono/packages/server/src/index.ts +41 -2
  37. package/shells/almadar-shell-hono/packages/server/src/serve.ts +4 -2
  38. package/shells/almadar-shell-hono/packages/server/src/services/clients.ts +17 -0
  39. package/shells/almadar-shell-hono/packages/server/src/sse.ts +20 -0
  40. package/shells/almadar-shell-hono/packages/server/tsconfig.json +0 -1
  41. package/shells/almadar-shell-hono/pnpm-lock.yaml +4370 -1238
package/README.md CHANGED
@@ -14,6 +14,11 @@ Or use the curl installer:
14
14
  curl -fsSL https://orb.almadar.io/install.sh | sh
15
15
  ```
16
16
 
17
+ The installer populates the user store (`~/.orb`) with `@almadar/std` so `orb validate`/`orb verify`
18
+ resolve std behaviors right away; a project can pin its own std instead with
19
+ `orb behaviors install @almadar/std`. Offline, install from a tarball:
20
+ `orb behaviors install --global ./almadar-std-<v>.tgz`.
21
+
17
22
  ## Usage
18
23
 
19
24
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@almadar/orb",
3
- "version": "16.9.1",
3
+ "version": "17.0.0",
4
4
  "description": "Orb CLI - deterministic when it can be, intelligent when it needs to be",
5
5
  "license": "BSL-1.1",
6
6
  "repository": {
@@ -49,10 +49,13 @@
49
49
  "node": ">=16.0.0"
50
50
  },
51
51
  "optionalDependencies": {
52
- "@almadar/orb-darwin-x64": "16.9.1",
53
- "@almadar/orb-darwin-arm64": "16.9.1",
54
- "@almadar/orb-linux-x64": "16.9.1",
55
- "@almadar/orb-linux-arm64": "16.9.1",
56
- "@almadar/orb-windows-x64": "16.9.1"
52
+ "@almadar/orb-darwin-x64": "17.0.0",
53
+ "@almadar/orb-darwin-arm64": "17.0.0",
54
+ "@almadar/orb-linux-x64": "17.0.0",
55
+ "@almadar/orb-linux-arm64": "17.0.0",
56
+ "@almadar/orb-windows-x64": "17.0.0"
57
+ },
58
+ "almadar": {
59
+ "stdRange": "^16"
57
60
  }
58
61
  }
@@ -2,6 +2,7 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const { spawnSync } = require('child_process');
5
6
 
6
7
  const platform = process.platform;
7
8
  const arch = process.arch;
@@ -49,3 +50,40 @@ console.log(`Orb CLI installed for ${platform}-${arch}:`);
49
50
  console.log(` Binary: ${hasBinary ? 'ok' : 'missing'}`);
50
51
  console.log(` Bun: ${hasBun ? 'ok' : 'not bundled (agent features require bun on PATH)'}`);
51
52
  console.log(` Agent: ${hasAgent ? 'ok' : 'not bundled (agent features unavailable)'}`);
53
+
54
+ // Behaviors are installed packages, not baked into the binary (Phase 2): populate
55
+ // the user store (~/.orb) with @almadar/std so `orb validate`/`orb verify` resolve
56
+ // std behaviors out of the box. `stdRange` is `^<major>` of the std this release
57
+ // was built against (almadar.stdRange in package.json, written by build-orb-cli.yml
58
+ // from the orbital-rust pin) so a later std major never silently swaps underneath
59
+ // an already-installed CLI.
60
+ const stdRange = (require('../package.json').almadar || {}).stdRange;
61
+
62
+ if (hasBinary && hasBun && stdRange) {
63
+ const binaryPath = path.join(platformDir, binaryName);
64
+ const bunPath = path.join(platformDir, bunName);
65
+ const manualCommand = 'orb behaviors install --global @almadar/std';
66
+
67
+ let result;
68
+ try {
69
+ result = spawnSync(binaryPath, ['behaviors', 'install', '--global', `@almadar/std@${stdRange}`], {
70
+ env: { ...process.env, ORB_BUN_PATH: bunPath },
71
+ stdio: 'inherit',
72
+ timeout: 600000,
73
+ });
74
+ } catch (err) {
75
+ console.warn(`\n Orb CLI: failed to install @almadar/std (${err.message})`);
76
+ console.warn(` Run manually: ${manualCommand}\n`);
77
+ process.exit(0);
78
+ }
79
+
80
+ if (!result || result.status !== 0) {
81
+ console.warn('\n Orb CLI: @almadar/std install did not complete');
82
+ console.warn(` Run manually: ${manualCommand}\n`);
83
+ }
84
+ } else if (hasBinary && !hasBun) {
85
+ console.warn('\n Orb CLI: bun not bundled, skipping @almadar/std install');
86
+ console.warn(' Run manually once bun is available: orb behaviors install --global @almadar/std\n');
87
+ }
88
+
89
+ process.exit(0);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@almadar/shell",
3
- "version": "2.15.0",
3
+ "version": "2.115.0",
4
4
  "private": false,
5
5
  "description": "Minimal full-stack shell template for Almadar applications",
6
6
  "packageManager": "pnpm@10.30.3",
@@ -9,6 +9,7 @@
9
9
  "build": "pnpm run -r build",
10
10
  "typecheck": "turbo run typecheck",
11
11
  "lint": "turbo run lint",
12
+ "prepack": "node -e \"const fs=require('fs');if(fs.existsSync('packages'))for(const p of fs.readdirSync('packages'))for(const j of ['.turbo','package-lock.json']){const t='packages/'+p+'/'+j;if(fs.existsSync(t))fs.rmSync(t,{recursive:true,force:true});}\"",
12
13
  "prepare": "git rev-parse --git-dir > /dev/null 2>&1 && git config core.hooksPath .githooks || true"
13
14
  },
14
15
  "devDependencies": {
@@ -19,11 +20,24 @@
19
20
  "overrides": {
20
21
  "@tootallnate/once": "^3.0.1",
21
22
  "esbuild": "^0.25.0",
22
- "flatted": "^3.4.1"
23
+ "flatted": "^3.4.1",
24
+ "protobufjs": ">=7.5.8 <8",
25
+ "node-forge": "^1.4.0",
26
+ "fast-xml-parser": "^5.7.0",
27
+ "fast-xml-builder": ">=1.1.7",
28
+ "express>path-to-regexp": "0.1.13",
29
+ "qs": "^6.15.2",
30
+ "lodash-es": "^4.18.0"
23
31
  }
24
32
  },
25
33
  "files": [
26
- "dist"
34
+ "packages",
35
+ "locales",
36
+ "pnpm-workspace.yaml",
37
+ "turbo.json",
38
+ "pnpm-lock.yaml",
39
+ ".env.example",
40
+ ".gitignore"
27
41
  ],
28
42
  "repository": {
29
43
  "type": "git",
@@ -13,12 +13,12 @@
13
13
  "test:watch": "vitest"
14
14
  },
15
15
  "dependencies": {
16
- "@almadar/core": "^7.14.3",
17
- "@almadar/evaluator": "^2.11.2",
18
- "@almadar/logger": "^1.3.0",
19
- "@almadar/patterns": "^2.30.2",
20
- "@almadar/syntax": "^1.4.0",
21
- "@almadar/ui": "^4.55.0",
16
+ "@almadar/core": "^10.99.0",
17
+ "@almadar/evaluator": "^2.47.0",
18
+ "@almadar/logger": "^1.12.0",
19
+ "@almadar/patterns": "^2.101.0",
20
+ "@almadar/syntax": "^1.17.0",
21
+ "@almadar/ui": "^6.31.0",
22
22
  "@monaco-editor/react": "^4.7.0",
23
23
  "@react-three/drei": "^9.92.0",
24
24
  "@react-three/fiber": "^9.0.0",
@@ -43,7 +43,7 @@
43
43
  "zustand": "^5.0.3"
44
44
  },
45
45
  "devDependencies": {
46
- "@almadar/eslint-plugin": "^2.8.1",
46
+ "@almadar/eslint-plugin": "^2.18.0",
47
47
  "@testing-library/jest-dom": "^6.6.3",
48
48
  "@testing-library/react": "^16.1.0",
49
49
  "@types/react": "^19.0.0",
@@ -11,14 +11,19 @@
11
11
  * - react-router is optional for URL bookmarkability
12
12
  */
13
13
 
14
+ import React from 'react';
14
15
  import { BrowserRouter, Routes, Route } from 'react-router-dom';
15
16
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
16
17
  import { ThemeProvider, UISlotProvider } from '@almadar/ui/context';
17
- import { UISlotComponent, NotifyListener } from '@almadar/ui/components';
18
+ import { UISlotComponent } from '@almadar/ui/components';
18
19
  import {
19
20
  EventBusProvider,
21
+ UserProvider,
20
22
  VerificationProvider,
21
23
  } from '@almadar/ui/providers';
24
+ import { normalizeUserContext } from '@almadar/core';
25
+ import { AuthProvider, useAuthContext } from './features/auth';
26
+ import { PersonaSwitcher } from './features/auth/components/PersonaSwitcher';
22
27
  import { NavigationProvider } from '@almadar/ui/renderer';
23
28
  import { I18nProvider, createTranslate } from '@almadar/ui/hooks';
24
29
  import defaultLocale from '@almadar/ui/locales/en.json';
@@ -43,11 +48,25 @@ const queryClient = new QueryClient({
43
48
  },
44
49
  });
45
50
 
51
+ /**
52
+ * Bridges the signed-in viewer into `UserProvider`. Generated trait hooks read
53
+ * `@user.x` through `useUser()`, so without this every role gate takes its
54
+ * negative branch and every "only mine" list renders empty — with no error, since
55
+ * `useUser()` falls back to anonymous. `normalizeUserContext` maps the provider's
56
+ * `uid`/`displayName` onto the `id`/`name` the behaviors read.
57
+ */
58
+ function ViewerProvider({ children }: { children: React.ReactNode }) {
59
+ const { user } = useAuthContext();
60
+ return <UserProvider user={normalizeUserContext(user) ?? null}>{children}</UserProvider>;
61
+ }
62
+
46
63
  function App() {
47
64
  return (
48
65
  <I18nProvider value={i18nValue}>
49
66
  <QueryClientProvider client={queryClient}>
50
67
  <ThemeProvider defaultTheme="minimalist">
68
+ <AuthProvider>
69
+ <ViewerProvider>
51
70
  <EventBusProvider>
52
71
  <VerificationProvider>
53
72
  <UISlotProvider>
@@ -66,12 +85,14 @@ function App() {
66
85
  {/* Portal slots rendered by compiled trait views via CompiledPortal */}
67
86
  {/* Toast notifications (non-overlapping, always safe to render here) */}
68
87
  <UISlotComponent slot="toast" portal />
69
- <NotifyListener />
88
+ <PersonaSwitcher />
70
89
  </BrowserRouter>
71
90
  </NavigationProvider>
72
91
  </UISlotProvider>
73
92
  </VerificationProvider>
74
93
  </EventBusProvider>
94
+ </ViewerProvider>
95
+ </AuthProvider>
75
96
  </ThemeProvider>
76
97
  </QueryClientProvider>
77
98
  </I18nProvider>
@@ -1,8 +1,8 @@
1
1
  import { initializeApp, getApps, getApp, FirebaseApp } from 'firebase/app';
2
2
  import { getAuth, Auth } from 'firebase/auth';
3
3
 
4
- let app: FirebaseApp;
5
- let auth: Auth;
4
+ let app: FirebaseApp | undefined;
5
+ let auth: Auth | undefined;
6
6
 
7
7
  export async function initializeFirebase(): Promise<void> {
8
8
  if (getApps().length > 0) {
@@ -30,8 +30,29 @@ export async function initializeFirebase(): Promise<void> {
30
30
  };
31
31
  }
32
32
 
33
+ // No credentials anywhere — auth stays disabled instead of crashing
34
+ // the app boot with auth/invalid-api-key.
35
+ if (!config.apiKey) {
36
+ console.warn('Firebase not configured — auth disabled (set VITE_APP_FIREBASE_* env vars)');
37
+ return;
38
+ }
39
+
33
40
  app = initializeApp(config);
34
41
  auth = getAuth(app);
35
42
  }
36
43
 
44
+ export function requireAuth(): Auth {
45
+ if (!auth) {
46
+ throw new Error('Firebase auth is not configured — set VITE_APP_FIREBASE_* env vars');
47
+ }
48
+ return auth;
49
+ }
50
+
51
+ // True once Firebase initialized with real credentials. When false the
52
+ // app runs in auth-disabled mode: route guards pass through and sign-in
53
+ // actions report the missing config instead of crashing the boot.
54
+ export function isAuthEnabled(): boolean {
55
+ return auth !== undefined;
56
+ }
57
+
37
58
  export { auth, app };
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Mock auth — a working sign-up / sign-in when Firebase is not configured.
3
+ *
4
+ * Without credentials `requireAuth()` throws and every sign-in reports "not
5
+ * configured", so a generated app cannot be signed into at all. That matters
6
+ * beyond convenience: `@user.id` and `@user.role` are what ownership scoping and
7
+ * role gates resolve against, so with no viewer every "only mine" list is empty
8
+ * and every role gate takes its negative branch — the app can only be seen as
9
+ * nobody. Bypassed the moment real Firebase credentials are present.
10
+ */
11
+
12
+ import {
13
+ DEFAULT_VIEWER,
14
+ encodeDevIdentityToken,
15
+ findPersonaInRoster,
16
+ isFieldValue,
17
+ type FieldValue,
18
+ type UserContext,
19
+ } from '@almadar/core';
20
+
21
+ export interface MockUser {
22
+ uid: string;
23
+ email: string | null;
24
+ displayName: string | null;
25
+ photoURL: string | null;
26
+ role?: string;
27
+ getIdToken(): Promise<string>;
28
+ }
29
+
30
+ type Listener = (user: MockUser | null) => void;
31
+
32
+ const STORAGE_KEY = 'almadar.mockAuth.session';
33
+ const REGISTERED_KEY = 'almadar.mockAuth.registered';
34
+
35
+ /** The role a brand-new or unknown account gets — never `admin`. */
36
+ const DEFAULT_ROLE = 'member';
37
+
38
+ /** Empty in dev (the Vite proxy forwards /api); the full server URL in prod. */
39
+ const API_BASE: string =
40
+ typeof import.meta.env.VITE_API_URL === 'string' ? import.meta.env.VITE_API_URL : '';
41
+
42
+ const listeners = new Set<Listener>();
43
+ const rosterListeners = new Set<() => void>();
44
+ let current: MockUser | null = null;
45
+ let registered: UserContext[] = [];
46
+ let roster: UserContext[] = [];
47
+
48
+ function toMockUser(p: UserContext): MockUser {
49
+ return {
50
+ uid: p.id,
51
+ email: typeof p.email === 'string' ? p.email : null,
52
+ displayName: typeof p.name === 'string' ? p.name : null,
53
+ photoURL: null,
54
+ role: typeof p.role === 'string' ? p.role : undefined,
55
+ // The server trusts this only under ALLOW_DEV_AUTH_BYPASS; it carries the
56
+ // whole persona so server-side `@user.role` matches the client's.
57
+ getIdToken: () => Promise.resolve(encodeDevIdentityToken(p)),
58
+ };
59
+ }
60
+
61
+ /** A stored account, validated field by field — never trust localStorage shape. */
62
+ function readPersona(raw: FieldValue | undefined): UserContext | null {
63
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw) || raw instanceof Date) {
64
+ return null;
65
+ }
66
+ const record: { [key: string]: FieldValue | undefined } = { ...raw };
67
+ const id = record['id'];
68
+ if (typeof id !== 'string' || id.length === 0) return null;
69
+ const persona: UserContext = { id };
70
+ for (const key of ['email', 'name', 'role'] as const) {
71
+ const value = record[key];
72
+ if (typeof value === 'string' && isFieldValue(value)) persona[key] = value;
73
+ }
74
+ return persona;
75
+ }
76
+
77
+ function readRegistered(): UserContext[] {
78
+ try {
79
+ const raw = localStorage.getItem(REGISTERED_KEY);
80
+ if (!raw) return [];
81
+ const parsed: FieldValue = JSON.parse(raw);
82
+ if (!Array.isArray(parsed)) return [];
83
+ return parsed.map(readPersona).filter((p): p is UserContext => p !== null);
84
+ } catch {
85
+ return [];
86
+ }
87
+ }
88
+
89
+ function writeRegistered(list: UserContext[]): void {
90
+ registered = list;
91
+ try {
92
+ localStorage.setItem(REGISTERED_KEY, JSON.stringify(list));
93
+ } catch {
94
+ /* storage unavailable — the session stays in memory for this tab */
95
+ }
96
+ }
97
+
98
+ /**
99
+ * The app's personas, fetched from its own server.
100
+ *
101
+ * They are the LIVE seeded rows of the app's `[identity]` entity, not a
102
+ * hardcoded list and not a re-derivation: `@user.id` is what ownership scoping
103
+ * compares against, so a persona whose id is not literally one of those rows
104
+ * owns nothing and every "only mine" list renders empty — indistinguishable
105
+ * from a working filter over no data.
106
+ *
107
+ * An app that declares no `[identity]` entity has no roster to serve, so the
108
+ * shell's own default viewer stands in and sign-in still works.
109
+ */
110
+ async function loadRoster(): Promise<void> {
111
+ try {
112
+ const response = await fetch(`${API_BASE}/api/personas`);
113
+ if (response.ok) {
114
+ const data = (await response.json()) as { personas?: UserContext[] };
115
+ roster = Array.isArray(data.personas) ? data.personas : [];
116
+ }
117
+ } catch {
118
+ /* server unreachable — fall through to the default viewer */
119
+ }
120
+ if (roster.length === 0) roster = [DEFAULT_VIEWER];
121
+ for (const fn of rosterListeners) fn();
122
+ }
123
+
124
+ function allAccounts(): UserContext[] {
125
+ return [...roster, ...registered];
126
+ }
127
+
128
+ function emit(): void {
129
+ for (const fn of listeners) fn(current);
130
+ }
131
+
132
+ function persist(user: MockUser | null): void {
133
+ try {
134
+ if (user) localStorage.setItem(STORAGE_KEY, JSON.stringify({ uid: user.uid }));
135
+ else localStorage.removeItem(STORAGE_KEY);
136
+ } catch {
137
+ /* storage unavailable */
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Load the app's roster, then restore a persisted session. Safe to call more
143
+ * than once.
144
+ *
145
+ * The roster is awaited first because a restored session names a persona by id:
146
+ * resolved before the rows arrive, every stored session would silently fail to
147
+ * restore and the app would open signed out.
148
+ */
149
+ export async function initMockAuth(): Promise<void> {
150
+ registered = readRegistered();
151
+ await loadRoster();
152
+ try {
153
+ const raw = localStorage.getItem(STORAGE_KEY);
154
+ if (!raw) return;
155
+ const parsed: FieldValue = JSON.parse(raw);
156
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return;
157
+ if (parsed instanceof Date) return;
158
+ const session: { [key: string]: FieldValue | undefined } = { ...parsed };
159
+ const uid = session['uid'];
160
+ if (typeof uid !== 'string') return;
161
+ const found = allAccounts().find((a) => a.id === uid);
162
+ if (found) {
163
+ current = toMockUser(found);
164
+ emit();
165
+ }
166
+ } catch {
167
+ /* ignore a corrupt session */
168
+ }
169
+ }
170
+
171
+ /** Every persona that can be signed into — for the dev persona picker. */
172
+ export function listMockAccounts(): readonly UserContext[] {
173
+ return allAccounts();
174
+ }
175
+
176
+ /**
177
+ * Subscribe to roster arrival. The roster loads over HTTP after first paint, so
178
+ * the persona picker renders empty and must be told when the rows land — the
179
+ * auth listener cannot carry it, since the signed-out viewer does not change.
180
+ */
181
+ export function onMockRosterChanged(fn: () => void): () => void {
182
+ rosterListeners.add(fn);
183
+ return () => {
184
+ rosterListeners.delete(fn);
185
+ };
186
+ }
187
+
188
+ function newAccount(email: string, displayName?: string): UserContext {
189
+ return {
190
+ id: `user-${allAccounts().length + 1}`,
191
+ email,
192
+ name: displayName ?? email.split('@')[0] ?? email,
193
+ role: DEFAULT_ROLE,
194
+ };
195
+ }
196
+
197
+ function byEmail(email: string): UserContext | undefined {
198
+ return allAccounts().find(
199
+ (a) => typeof a.email === 'string' && a.email.toLowerCase() === email.toLowerCase(),
200
+ );
201
+ }
202
+
203
+ function signInAs(account: UserContext): MockUser {
204
+ current = toMockUser(account);
205
+ persist(current);
206
+ emit();
207
+ return current;
208
+ }
209
+
210
+ export const mockAuth = {
211
+ get currentUser(): MockUser | null {
212
+ return current;
213
+ },
214
+
215
+ onAuthStateChanged(fn: Listener): () => void {
216
+ listeners.add(fn);
217
+ // Match Firebase: the listener fires immediately with the current state.
218
+ fn(current);
219
+ return () => listeners.delete(fn);
220
+ },
221
+
222
+ /** Any password is accepted; an unknown email signs in as a new end-user. */
223
+ signInWithEmail(email: string, _password: string): Promise<MockUser> {
224
+ return Promise.resolve(signInAs(byEmail(email) ?? newAccount(email)));
225
+ },
226
+
227
+ signUpWithEmail(email: string, _password: string, displayName?: string): Promise<MockUser> {
228
+ if (byEmail(email)) {
229
+ return Promise.reject(new Error('An account with that email already exists'));
230
+ }
231
+ const account = newAccount(email, displayName);
232
+ writeRegistered([...registered, account]);
233
+ return Promise.resolve(signInAs(account));
234
+ },
235
+
236
+ /** Sign in directly as a named persona — the dev persona switch. */
237
+ signInAsPersona(idOrRole: string): Promise<MockUser> {
238
+ const account =
239
+ findPersonaInRoster(roster, idOrRole) ??
240
+ registered.find((a) => a.id === idOrRole || a.role === idOrRole);
241
+ if (!account) {
242
+ return Promise.reject(
243
+ new Error(
244
+ `Unknown persona "${idOrRole}". Known: ${allAccounts().map((a) => `${a.id}/${String(a.role)}`).join(', ')}`,
245
+ ),
246
+ );
247
+ }
248
+ return Promise.resolve(signInAs(account));
249
+ },
250
+
251
+ signOut(): Promise<void> {
252
+ current = null;
253
+ persist(null);
254
+ emit();
255
+ return Promise.resolve();
256
+ },
257
+ };
@@ -1,8 +1,8 @@
1
1
  import React, { createContext, useContext, useEffect, useState } from 'react';
2
- import { User } from 'firebase/auth';
3
2
  import { auth, initializeFirebase } from '../../config/firebase';
3
+ import { initMockAuth, mockAuth } from '../../config/mockAuth';
4
4
  import { authService } from './authService';
5
- import { AuthContextType } from './types';
5
+ import { AuthContextType, AuthViewer } from './types';
6
6
 
7
7
  const AuthContext = createContext<AuthContextType | undefined>(undefined);
8
8
 
@@ -19,7 +19,7 @@ interface AuthProviderProps {
19
19
  }
20
20
 
21
21
  export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
22
- const [user, setUser] = useState<User | null>(null);
22
+ const [user, setUser] = useState<AuthViewer | null>(null);
23
23
  const [loading, setLoading] = useState(true);
24
24
  const [error, setError] = useState<string | null>(null);
25
25
 
@@ -27,6 +27,19 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
27
27
  let unsubscribe: (() => void) | undefined;
28
28
 
29
29
  initializeFirebase().then(() => {
30
+ // No Firebase config — subscribe to the persona-backed mock instead of
31
+ // rendering signed-out forever, so the app can be viewed as each persona.
32
+ if (!auth) {
33
+ // Subscribe first: the listener fires immediately with the current
34
+ // (signed-out) state so the app paints, then again once the roster has
35
+ // arrived and a persisted session is restored.
36
+ unsubscribe = mockAuth.onAuthStateChanged((mockUser) => {
37
+ setUser(mockUser);
38
+ setLoading(false);
39
+ });
40
+ void initMockAuth();
41
+ return;
42
+ }
30
43
  unsubscribe = auth.onAuthStateChanged((firebaseUser) => {
31
44
  setUser(firebaseUser);
32
45
  setLoading(false);
@@ -43,7 +56,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
43
56
  setLoading(true);
44
57
  clearError();
45
58
  await authService.signInWithGoogle();
46
- } catch (err: unknown) {
59
+ } catch (err) {
47
60
  setLoading(false);
48
61
  const firebaseErr = err as { code?: string; message?: string };
49
62
  const isCancel =
@@ -61,7 +74,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
61
74
  setLoading(true);
62
75
  clearError();
63
76
  await authService.signInWithEmail(email, password);
64
- } catch (err: unknown) {
77
+ } catch (err) {
65
78
  setLoading(false);
66
79
  setError((err as { message?: string }).message ?? 'Sign-in failed');
67
80
  }
@@ -72,7 +85,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
72
85
  setLoading(true);
73
86
  clearError();
74
87
  await authService.signUpWithEmail(email, password, displayName);
75
- } catch (err: unknown) {
88
+ } catch (err) {
76
89
  setLoading(false);
77
90
  setError((err as { message?: string }).message ?? 'Sign-up failed');
78
91
  }
@@ -88,7 +101,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
88
101
  };
89
102
  await authService.sendSignInLinkToEmail(email, actionCodeSettings);
90
103
  setLoading(false);
91
- } catch (err: unknown) {
104
+ } catch (err) {
92
105
  setLoading(false);
93
106
  setError((err as { message?: string }).message ?? 'Failed to send sign-in link');
94
107
  }
@@ -99,7 +112,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
99
112
  setLoading(true);
100
113
  clearError();
101
114
  await authService.signInWithEmailLink(email, emailLink);
102
- } catch (err: unknown) {
115
+ } catch (err) {
103
116
  setLoading(false);
104
117
  setError((err as { message?: string }).message ?? 'Email link sign-in failed');
105
118
  }
@@ -115,7 +128,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
115
128
  await authService.signOut();
116
129
  setUser(null);
117
130
  setLoading(false);
118
- } catch (err: unknown) {
131
+ } catch (err) {
119
132
  setLoading(false);
120
133
  setError((err as { message?: string }).message ?? 'Sign-out failed');
121
134
  }