@almadar/orb 4.3.0 → 4.7.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 (84) hide show
  1. package/README.md +57 -19
  2. package/bin/{almadar → orb} +30 -24
  3. package/package.json +9 -9
  4. package/scripts/postinstall.js +25 -15
  5. package/shells/almadar-shell/package.json +17 -3
  6. package/shells/almadar-shell/packages/client/eslint.config.cjs +34 -1
  7. package/shells/almadar-shell/packages/client/package.json +28 -26
  8. package/shells/almadar-shell/packages/client/src/App.tsx +24 -6
  9. package/shells/almadar-shell/packages/client/src/config/firebase.ts +23 -2
  10. package/shells/almadar-shell/packages/client/src/config/mockAuth.ts +257 -0
  11. package/shells/almadar-shell/packages/client/src/features/auth/AuthContext.tsx +22 -9
  12. package/shells/almadar-shell/packages/client/src/features/auth/authService.ts +28 -7
  13. package/shells/almadar-shell/packages/client/src/features/auth/components/PersonaSwitcher.tsx +50 -0
  14. package/shells/almadar-shell/packages/client/src/features/auth/components/ProtectedRoute.tsx +9 -0
  15. package/shells/almadar-shell/packages/client/src/features/auth/index.ts +2 -1
  16. package/shells/almadar-shell/packages/client/src/features/auth/types.ts +18 -2
  17. package/shells/almadar-shell/packages/client/src/index.css +4 -20
  18. package/shells/almadar-shell/packages/client/tailwind.config.js +12 -2
  19. package/shells/almadar-shell/packages/server/eslint.config.cjs +4 -0
  20. package/shells/almadar-shell/packages/server/package.json +13 -9
  21. package/shells/almadar-shell/packages/server/pnpm-lock.yaml +4723 -0
  22. package/shells/almadar-shell/packages/server/src/app.ts +58 -3
  23. package/shells/almadar-shell/packages/server/src/hooks-providers.ts +11 -0
  24. package/shells/almadar-shell/packages/server/src/index.ts +41 -2
  25. package/shells/almadar-shell/packages/server/src/services/clients.ts +17 -0
  26. package/shells/almadar-shell/packages/server/src/sse.ts +20 -0
  27. package/shells/almadar-shell/packages/server/src/types/express.d.ts +3 -7
  28. package/shells/almadar-shell/packages/server/tsconfig.json +0 -1
  29. package/shells/almadar-shell/packages/shared/package.json +1 -0
  30. package/shells/almadar-shell/packages/shared/pnpm-lock.yaml +22 -0
  31. package/shells/almadar-shell/pnpm-lock.yaml +3996 -1110
  32. package/shells/almadar-shell-hono/LICENSE +72 -0
  33. package/shells/almadar-shell-hono/README.md +25 -0
  34. package/shells/almadar-shell-hono/locales/en.json +120 -0
  35. package/shells/almadar-shell-hono/package.json +37 -0
  36. package/shells/almadar-shell-hono/packages/client/eslint.config.cjs +23 -0
  37. package/shells/almadar-shell-hono/packages/client/index.html +13 -0
  38. package/shells/almadar-shell-hono/packages/client/package-lock.json +9750 -0
  39. package/shells/almadar-shell-hono/packages/client/package.json +61 -0
  40. package/shells/almadar-shell-hono/packages/client/postcss.config.js +6 -0
  41. package/shells/almadar-shell-hono/packages/client/src/App.tsx +80 -0
  42. package/shells/almadar-shell-hono/packages/client/src/config/firebase.ts +58 -0
  43. package/shells/almadar-shell-hono/packages/client/src/features/auth/AuthContext.tsx +144 -0
  44. package/shells/almadar-shell-hono/packages/client/src/features/auth/authService.ts +86 -0
  45. package/shells/almadar-shell-hono/packages/client/src/features/auth/components/Login.tsx +218 -0
  46. package/shells/almadar-shell-hono/packages/client/src/features/auth/components/ProtectedRoute.tsx +36 -0
  47. package/shells/almadar-shell-hono/packages/client/src/features/auth/components/UserProfile.tsx +68 -0
  48. package/shells/almadar-shell-hono/packages/client/src/features/auth/components/index.ts +3 -0
  49. package/shells/almadar-shell-hono/packages/client/src/features/auth/index.ts +13 -0
  50. package/shells/almadar-shell-hono/packages/client/src/features/auth/types.ts +24 -0
  51. package/shells/almadar-shell-hono/packages/client/src/index.css +35 -0
  52. package/shells/almadar-shell-hono/packages/client/src/main.tsx +8 -0
  53. package/shells/almadar-shell-hono/packages/client/src/navigation/index.ts +55 -0
  54. package/shells/almadar-shell-hono/packages/client/src/pages/index.ts +12 -0
  55. package/shells/almadar-shell-hono/packages/client/tailwind-preset.cjs +259 -0
  56. package/shells/almadar-shell-hono/packages/client/tailwind.config.js +24 -0
  57. package/shells/almadar-shell-hono/packages/client/tsconfig.json +33 -0
  58. package/shells/almadar-shell-hono/packages/client/vite.config.ts +50 -0
  59. package/shells/almadar-shell-hono/packages/server/eslint.config.cjs +19 -0
  60. package/shells/almadar-shell-hono/packages/server/package.json +38 -0
  61. package/shells/almadar-shell-hono/packages/server/pnpm-lock.yaml +4665 -0
  62. package/shells/almadar-shell-hono/packages/server/src/app.ts +70 -0
  63. package/shells/almadar-shell-hono/packages/server/src/hooks-providers.ts +11 -0
  64. package/shells/almadar-shell-hono/packages/server/src/index.ts +70 -0
  65. package/shells/almadar-shell-hono/packages/server/src/routes.ts +12 -0
  66. package/shells/almadar-shell-hono/packages/server/src/serve.ts +47 -0
  67. package/shells/almadar-shell-hono/packages/server/src/services/clients.ts +17 -0
  68. package/shells/almadar-shell-hono/packages/server/src/sse.ts +20 -0
  69. package/shells/almadar-shell-hono/packages/server/tsconfig.json +22 -0
  70. package/shells/almadar-shell-hono/packages/shared/package.json +25 -0
  71. package/shells/almadar-shell-hono/packages/shared/pnpm-lock.yaml +919 -0
  72. package/shells/almadar-shell-hono/packages/shared/src/index.ts +2 -0
  73. package/shells/almadar-shell-hono/packages/shared/tsconfig.json +17 -0
  74. package/shells/almadar-shell-hono/packages/shared/tsup.config.ts +10 -0
  75. package/shells/almadar-shell-hono/pnpm-lock.yaml +12539 -0
  76. package/shells/almadar-shell-hono/pnpm-workspace.yaml +2 -0
  77. package/shells/almadar-shell-hono/tsup.config.ts +13 -0
  78. package/shells/almadar-shell-hono/turbo.json +17 -0
  79. package/shells/almadar-shell-hono/vitest.config.ts +8 -0
  80. package/shells/orb-shell/README.md +1 -1
  81. package/shells/orb-shell/packages/client/package-lock.json +227 -27
  82. package/shells/orb-shell/packages/client/package.json +1 -1
  83. package/shells/orb-shell/packages/server/package-lock.json +1 -0
  84. package/shells/orb-shell/pnpm-lock.yaml +196 -10
@@ -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
  }
@@ -10,15 +10,24 @@ import {
10
10
  signInWithEmailLink as firebaseSignInWithEmailLink,
11
11
  ActionCodeSettings,
12
12
  } from 'firebase/auth';
13
- import { auth } from '../../config/firebase';
13
+ import { auth, isAuthEnabled, requireAuth } from '../../config/firebase';
14
+ import { mockAuth } from '../../config/mockAuth';
15
+
16
+ // With no Firebase credentials the real calls throw "not configured", which
17
+ // leaves a generated app impossible to sign into — and therefore impossible to
18
+ // view as anyone, since @user.id/@user.role drive ownership and role gates.
19
+ // Route to the persona-backed mock in exactly that case.
20
+ const useMock = (): boolean => !isAuthEnabled();
14
21
 
15
22
  const googleProvider = new GoogleAuthProvider();
16
23
 
17
24
  export const authService = {
18
25
  // Google Sign In
19
26
  signInWithGoogle: async () => {
27
+ // No popup to show without Firebase — sign in as the seeded end-user.
28
+ if (useMock()) return mockAuth.signInAsPersona('member');
20
29
  try {
21
- const result = await signInWithPopup(auth, googleProvider);
30
+ const result = await signInWithPopup(requireAuth(), googleProvider);
22
31
  return result.user;
23
32
  } catch (error) {
24
33
  throw error;
@@ -27,8 +36,9 @@ export const authService = {
27
36
 
28
37
  // Email/Password Sign In
29
38
  signInWithEmail: async (email: string, password: string) => {
39
+ if (useMock()) return mockAuth.signInWithEmail(email, password);
30
40
  try {
31
- const result = await signInWithEmailAndPassword(auth, email, password);
41
+ const result = await signInWithEmailAndPassword(requireAuth(), email, password);
32
42
  return result.user;
33
43
  } catch (error) {
34
44
  throw error;
@@ -37,8 +47,9 @@ export const authService = {
37
47
 
38
48
  // Email/Password Sign Up
39
49
  signUpWithEmail: async (email: string, password: string, displayName?: string) => {
50
+ if (useMock()) return mockAuth.signUpWithEmail(email, password, displayName);
40
51
  try {
41
- const result = await createUserWithEmailAndPassword(auth, email, password);
52
+ const result = await createUserWithEmailAndPassword(requireAuth(), email, password);
42
53
 
43
54
  if (displayName) {
44
55
  await updateProfile(result.user, { displayName });
@@ -52,8 +63,9 @@ export const authService = {
52
63
 
53
64
  // Sign Out
54
65
  signOut: async () => {
66
+ if (useMock()) return mockAuth.signOut();
55
67
  try {
56
- await firebaseSignOut(auth);
68
+ await firebaseSignOut(requireAuth());
57
69
  } catch (error) {
58
70
  throw error;
59
71
  }
@@ -62,22 +74,31 @@ export const authService = {
62
74
  // Email Link Authentication
63
75
  sendSignInLinkToEmail: async (email: string, actionCodeSettings: ActionCodeSettings) => {
64
76
  try {
65
- await firebaseSendSignInLinkToEmail(auth, email, actionCodeSettings);
77
+ await firebaseSendSignInLinkToEmail(requireAuth(), email, actionCodeSettings);
66
78
  } catch (error) {
67
79
  throw error;
68
80
  }
69
81
  },
70
82
 
71
83
  isSignInWithEmailLink: (emailLink: string): boolean => {
84
+ // Predicate evaluated during Login render — degrade, don't throw,
85
+ // when auth is unconfigured: it simply isn't an email-link sign-in.
86
+ if (!auth) return false;
72
87
  return firebaseIsSignInWithEmailLink(auth, emailLink);
73
88
  },
74
89
 
75
90
  signInWithEmailLink: async (email: string, emailLink: string) => {
76
91
  try {
77
- const result = await firebaseSignInWithEmailLink(auth, email, emailLink);
92
+ const result = await firebaseSignInWithEmailLink(requireAuth(), email, emailLink);
78
93
  return result.user;
79
94
  } catch (error) {
80
95
  throw error;
81
96
  }
82
97
  },
98
+
99
+ /** Sign in as a named seeded persona (dev only; no-op with real Firebase). */
100
+ signInAsPersona: async (idOrRole: string) => {
101
+ if (useMock()) return mockAuth.signInAsPersona(idOrRole);
102
+ throw new Error('Persona sign-in is a dev-only affordance; real auth is configured');
103
+ },
83
104
  };
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Dev persona switch — view the app as each identity its domain implies.
3
+ *
4
+ * Renders only when auth is mocked (no Firebase credentials), so it cannot appear
5
+ * in a real deployment. Role gates and ownership-scoped lists are the parts of an
6
+ * app that look identical whether they work or are simply empty; switching viewer
7
+ * in one click is how you tell the difference.
8
+ */
9
+
10
+ import React from 'react';
11
+ import { HStack, Select, Typography } from '@almadar/ui';
12
+ import { isAuthEnabled } from '../../../config/firebase';
13
+ import { listMockAccounts, onMockRosterChanged } from '../../../config/mockAuth';
14
+ import { authService } from '../authService';
15
+ import { useAuthContext } from '../AuthContext';
16
+
17
+ export function PersonaSwitcher(): React.ReactElement | null {
18
+ const { user } = useAuthContext();
19
+ // The roster arrives over HTTP after first paint. Without this the picker
20
+ // renders its empty pre-fetch state and never updates, because the signed-out
21
+ // viewer never changes and so the auth listener never fires.
22
+ const [accounts, setAccounts] = React.useState(() => listMockAccounts());
23
+
24
+ React.useEffect(() => onMockRosterChanged(() => setAccounts(listMockAccounts())), []);
25
+
26
+ if (isAuthEnabled()) return null;
27
+
28
+ const options = accounts.map((p) => ({
29
+ value: p.id,
30
+ label: `${String(p.name ?? p.id)} — ${String(p.role ?? 'no role')}`,
31
+ }));
32
+
33
+ return (
34
+ <HStack gap="sm" align="center">
35
+ <Typography variant="caption" color="muted">
36
+ Viewing as
37
+ </Typography>
38
+ <Select
39
+ options={options}
40
+ value={user?.uid ?? ''}
41
+ placeholder="Signed out"
42
+ onChange={(event) => {
43
+ void authService.signInAsPersona(event.target.value);
44
+ }}
45
+ />
46
+ </HStack>
47
+ );
48
+ }
49
+
50
+ export default PersonaSwitcher;
@@ -1,6 +1,7 @@
1
1
  import React from 'react';
2
2
  import { Navigate } from 'react-router-dom';
3
3
  import { useAuthContext } from '../AuthContext';
4
+ import { isAuthEnabled } from '../../../config/firebase';
4
5
 
5
6
  interface ProtectedRouteProps {
6
7
  children: React.ReactNode;
@@ -17,6 +18,14 @@ const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
17
18
  );
18
19
  }
19
20
 
21
+ // Auth-disabled mode (no Firebase config): nothing can sign in, so
22
+ // gating would strand every route on /login — pass straight through.
23
+ // Checked after `loading` so a configured-but-initializing Firebase
24
+ // still shows the spinner instead of flashing protected content.
25
+ if (!isAuthEnabled()) {
26
+ return <>{children}</>;
27
+ }
28
+
20
29
  if (!user) {
21
30
  return <Navigate to="/login" replace />;
22
31
  }
@@ -5,9 +5,10 @@ export { AuthProvider, useAuthContext } from './AuthContext';
5
5
  export { default as Login } from './components/Login';
6
6
  export { default as UserProfile } from './components/UserProfile';
7
7
  export { default as ProtectedRoute } from './components/ProtectedRoute';
8
+ export { PersonaSwitcher } from './components/PersonaSwitcher';
8
9
 
9
10
  // Service
10
11
  export { authService } from './authService';
11
12
 
12
13
  // Types
13
- export type { AuthContextType, LoginCredentials, SignUpCredentials } from './types';
14
+ export type { AuthContextType, AuthViewer, LoginCredentials, SignUpCredentials } from './types';
@@ -1,4 +1,20 @@
1
- import { User } from 'firebase/auth';
1
+ /**
2
+ * The signed-in viewer, as the auth UI reads it. Firebase's `User` and the mocked
3
+ * `MockUser` are both structurally assignable to this, so the provider carries
4
+ * either without a cast and without the UI knowing which one is live.
5
+ *
6
+ * A type alias, not an interface: only aliases get TS's implicit index signature,
7
+ * which is what lets this be passed to `normalizeUserContext(claims: RawUserClaims)`
8
+ * without a cast.
9
+ */
10
+ export type AuthViewer = {
11
+ uid: string;
12
+ email: string | null;
13
+ displayName: string | null;
14
+ photoURL: string | null;
15
+ /** Present only for mocked personas; real Firebase claims carry no role. */
16
+ role?: string;
17
+ };
2
18
 
3
19
  export interface LoginCredentials {
4
20
  email: string;
@@ -10,7 +26,7 @@ export interface SignUpCredentials extends LoginCredentials {
10
26
  }
11
27
 
12
28
  export interface AuthContextType {
13
- user: User | null;
29
+ user: AuthViewer | null;
14
30
  loading: boolean;
15
31
  error: string | null;
16
32
  signInWithGoogle: () => Promise<void>;
@@ -1,29 +1,13 @@
1
1
  /* Import Almadar theme - @import must precede @tailwind directives */
2
- @import '@almadar/ui/themes/almadar.css';
2
+ @import '@almadar/ui/themes/minimalist.css';
3
3
 
4
4
  @tailwind base;
5
5
  @tailwind components;
6
6
  @tailwind utilities;
7
7
 
8
- /* Page-level layout: padding and max-width for main content area */
9
- .ui-slot-main {
10
- padding: 1.5rem 1rem;
11
- max-width: 80rem;
12
- margin-left: auto;
13
- margin-right: auto;
14
- }
15
-
16
- @media (min-width: 768px) {
17
- .ui-slot-main {
18
- padding: 1.5rem 1.5rem;
19
- }
20
- }
21
-
22
- @media (min-width: 1024px) {
23
- .ui-slot-main {
24
- padding: 2rem 2rem;
25
- }
26
- }
8
+ /* Main slot fills the viewport edge-to-edge. Variant trees control their
9
+ * own width and padding via className utilities (max-w-*, mx-auto, px-*).
10
+ * The shell stays out of the way. */
27
11
 
28
12
  /* Global transition baseline: smooth state changes on interactive elements */
29
13
  button,
@@ -6,9 +6,19 @@ const require = createRequire(import.meta.url);
6
6
  const uiEntry = require.resolve('@almadar/ui');
7
7
  const uiDist = join(dirname(uiEntry), '..', '**', '*.js');
8
8
 
9
+ // @almadar/ui's DashboardLayout (sidebar / topnav / bottomnav modes) and
10
+ // several molecules use container-query Tailwind variants (`@md/dashboard:flex`,
11
+ // `@sm/dashboard:block`, etc.). Without this plugin Tailwind silently drops
12
+ // every `@*/dashboard:*` class and the chrome renders as a blank top-bar with
13
+ // action icons collapsed to the left (no nav, no search, no app title).
14
+ const containerQueries = require('@tailwindcss/container-queries');
15
+
9
16
  /** @type {import('tailwindcss').Config} */
10
17
  export default {
11
- presets: [require('./tailwind-preset.cjs')],
18
+ // Single source of truth for Layer 1 + Layer 2 token mappings is
19
+ // @almadar/ui's published preset. Don't fork a local copy — it goes
20
+ // stale silently and breaks look variants without any build error.
21
+ presets: [require('@almadar/ui/tailwind-preset')],
12
22
  content: [
13
23
  "./index.html",
14
24
  "./src/**/*.{js,ts,jsx,tsx}",
@@ -17,5 +27,5 @@ export default {
17
27
  theme: {
18
28
  extend: {},
19
29
  },
20
- plugins: [],
30
+ plugins: [containerQueries],
21
31
  }
@@ -12,7 +12,11 @@ module.exports = [
12
12
  },
13
13
  plugins: { almadar: almadarPlugin },
14
14
  rules: {
15
+ // Zero escape hatches in emitted shell code — same rule set as client;
16
+ // see docs/Almadar_Compiler_Gaps.md for the per-rule codegen fix.
15
17
  "almadar/no-as-any": "error",
18
+ "almadar/no-unknown-type": "error",
19
+ "almadar/no-record-string-unknown": "error",
16
20
  "almadar/no-import-generated": "error",
17
21
  },
18
22
  },
@@ -8,31 +8,35 @@
8
8
  "build": "esbuild src/index.ts --bundle --platform=node --format=esm --packages=external --outfile=dist/index.js",
9
9
  "start": "node dist/index.js",
10
10
  "typecheck": "tsc --noEmit",
11
- "lint": "eslint src/",
11
+ "lint": "eslint --max-warnings 0 src/",
12
12
  "test": "vitest run --passWithNoTests",
13
13
  "test:watch": "vitest"
14
14
  },
15
15
  "dependencies": {
16
- "@almadar/server": ">=2.1.5",
17
- "@almadar/evaluator": ">=2.6.0",
18
- "@almadar/core": ">=2.9.0",
19
- "@almadar/integrations": ">=2.3.0",
16
+ "@almadar/core": "^10.92.0",
17
+ "@almadar/evaluator": "^2.45.0",
18
+ "@almadar/integrations": "^2.36.0",
19
+ "@almadar/logger": "^1.12.0",
20
+ "@almadar/runtime": "^6.78.0",
21
+ "@almadar/server": "^2.41.0",
20
22
  "cors": "^2.8.5",
21
23
  "dotenv": "^16.4.7",
22
24
  "express": "^4.21.2",
25
+ "express-rate-limit": "^7.4.0",
23
26
  "firebase-admin": "^12.7.0",
27
+ "helmet": "^8.0.0",
24
28
  "zod": "^3.22.0"
25
29
  },
26
30
  "devDependencies": {
27
- "@almadar/eslint-plugin": ">=2.3.0",
28
- "@typescript-eslint/parser": "8.56.0",
31
+ "@almadar/eslint-plugin": "^2.18.0",
29
32
  "@types/cors": "^2.8.17",
30
33
  "@types/express": "^5.0.0",
31
34
  "@types/node": "^20.17.19",
32
- "eslint": "10.0.0",
35
+ "@typescript-eslint/parser": "8.56.0",
33
36
  "esbuild": "^0.25.0",
37
+ "eslint": "10.0.0",
34
38
  "tsx": "^4.19.3",
35
39
  "typescript": "^5.7.3",
36
40
  "vitest": "^2.1.9"
37
41
  }
38
- }
42
+ }