File size: 9,666 Bytes
bf8519f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
// ---------------------------------------------------------------------------
// shell/session.ts β€” the CLIENT half of X2's auth contract (EXIT wave 1, X5).
//
// WHAT THIS REPLACES. The shell's door was HTTP Basic on a shared APP_PASSWORD
// (`api/main.py`'s BasicAuth middleware): no per-user identity, no BU isolation,
// no `may_open` gate β€” which is why SHELL.md says this frame must not be
// reachable by a real user until EXIT-3 lands. This module is the per-user door:
// the SAME `core/users` accounts the Streamlit `gate()` verifies, reached over
// `POST /api/v1/auth/login` and carried by the X3 signed cookie.
//
// ⚠ THE COOKIE IS HttpOnly (X3), SO THIS CODE CANNOT SEE IT β€” and that is the
// point. There is no client-side "am I logged in?" bit to cache, mirror into
// localStorage, or trust: the only way to know is to ASK the server, which is
// also the only party that can tell whether the user's `epoch` was bumped out
// from under an outstanding cookie. So `me()` runs on every boot, and its
// answer is never persisted. A cached session flag would keep painting the
// frame for a revoked user, which is the whole reason X3 has an epoch.
//
// WHY THIS FILE HOLDS NO import.meta. It is compiled to CommonJS and run under
// node by `verify_login.py`; `import.meta` is a hard error there. Anything
// environment-shaped (the current app's base URL) is a PARAMETER, never a
// module-level read β€” see nav.ts for the same rule.
// ---------------------------------------------------------------------------

// The prefix and the credentials word live in `apiContract.ts` β€” ONE copy, read
// by this module, by nav.ts and by the grid's apiBridge. `verify_login.py`
// mutates them there to prove the gate can see them.
import { API_V1, CREDENTIALS as CREDS, checkTenant } from "../apiContract";

export { API_V1 };

/**
 * The session's user, per X2's login/me payload. Deliberately narrow: the shell
 * needs identity for the account row and nothing else. **Scope is NOT decided
 * here** β€” the row pool is BU-scoped server-side (EXIT-3b) and the nav is
 * `may_open`-filtered server-side (X6), so `bus`/`modules` are for DISPLAY, and
 * a client that re-derived permission from them would be the permission system
 * trusting its own subject (the `Field.createdBy` precedent in types.ts).
 */
export interface SessionUser {
  username: string;
  name: string;
  role: string;
  /** BU labels this user may view β€” `core.users.allowed_bus_labels` semantics. */
  bus: string[];
  /** Granted registry keys, or the literal `'all'` β€” `core/users`' own encoding. */
  modules: string[] | "all";
  /** Wave 14 C-AVATAR β€” the user's OWN profile photo as a data URL, or absent. Everyone
   *  else's photos ride `workspace.userAvatars`, keyed by display name. */
  avatar?: string | null;
}

export function isAdmin(user: SessionUser | null): boolean {
  return user?.role === "admin";
}

/**
 * Normalise the server's user object.
 *
 * DEFENSIVE ON THE DISPLAY FIELDS, STRICT ON IDENTITY. A missing `name` falls
 * back to the username (a person with no display name still gets a door); a
 * missing `username` returns null, because an authenticated session with no
 * subject is not a session. The distinction matters: hard-failing on a cosmetic
 * field would turn a harmless server change into every user locked out, while
 * inventing a subject would be worse than the 401 it papers over.
 */
export function parseUser(raw: unknown): SessionUser | null {
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
  const r = raw as Record<string, unknown>;
  const username = typeof r.username === "string" ? r.username.trim() : "";
  if (!username) return null;
  const name = typeof r.name === "string" && r.name.trim() !== "" ? r.name.trim() : username;
  const role = typeof r.role === "string" ? r.role : "";
  const bus = Array.isArray(r.bus) ? r.bus.filter((b): b is string => typeof b === "string") : [];
  const modules =
    r.modules === "all"
      ? "all"
      : Array.isArray(r.modules)
        ? r.modules.filter((m): m is string => typeof m === "string")
        : [];
  // Defensive like the display fields: only a data: image URL is carried; anything
  // else renders the initials disc rather than a broken <img>.
  const avatar =
    typeof r.avatar === "string" && r.avatar.startsWith("data:image/") ? r.avatar : null;
  return { username, name, role, bus, modules, avatar };
}

/** The `{user: {...}}` envelope both `/auth/login` and `/auth/me` return. */
export function parseUserEnvelope(body: unknown): SessionUser | null {
  if (!body || typeof body !== "object") return null;
  return parseUser((body as { user?: unknown }).user);
}

// --- error copy ------------------------------------------------------------

/** Shown when the server gives us nothing better on a rejected credential. Same
 *  words as the Streamlit `gate()` β€” one product, one wrong-password sentence. */
export const BAD_CREDENTIALS = "Incorrect username or password.";
export const SERVER_UNAVAILABLE = "Sign-in is unavailable right now. Try again in a moment.";
export const NETWORK_UNAVAILABLE = "Cannot reach the server. Check your connection and try again.";

/**
 * X2's error envelope is `{"error": {"code", "message"}}`. Which message the
 * user sees is a policy question, so the rule is explicit rather than "show
 * whatever came back":
 *
 *   - **4xx β†’ prefer the SERVER's message.** A 4xx message is policy the client
 *     cannot know β€” "too many attempts, try again in 30 seconds" is the server's
 *     backoff talking (X5: *the server owns backoff*), and replacing it with our
 *     generic line would hide the only actionable thing on the screen.
 *   - **5xx β†’ OUR generic line.** A 500's message describes the server's
 *     internals to someone who is, by definition, not authenticated yet.
 *   - **status 0 β†’ a transport failure**, which never reached a server at all.
 *
 * `status` 0 is our own encoding for "fetch threw" β€” there is no HTTP 0.
 */
export function errorMessage(status: number, body: unknown): string {
  if (status === 0) return NETWORK_UNAVAILABLE;
  if (status >= 500) return SERVER_UNAVAILABLE;
  const err = (body as { error?: { message?: unknown } } | null)?.error;
  const msg = typeof err?.message === "string" ? err.message.trim() : "";
  if (msg) return msg;
  return status === 401 ? BAD_CREDENTIALS : SERVER_UNAVAILABLE;
}

// --- the three calls -------------------------------------------------------

export type LoginResult =
  | { ok: true; user: SessionUser }
  | { ok: false; message: string };

async function readJson(res: Response): Promise<unknown> {
  try {
    return await res.json();
  } catch {
    return null;
  }
}

/**
 * X2 `POST /api/v1/auth/login`. NO client-side backoff, deliberately: X5 gives
 * the delay to the server, because a delay a browser imposes on itself is a
 * delay an attacker's script simply does not import.
 *
 * `tenant` is omitted rather than defaulted β€” X2 types it optional with the
 * server's own default, and a client that pins `'royal-imports'` into every
 * login is a client that has to be re-shipped for tenant #2.
 */
export async function login(username: string, password: string): Promise<LoginResult> {
  let res: Response;
  try {
    res = await fetch(`${API_V1}/auth/login`, {
      method: "POST",
      credentials: CREDS,
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ username, password }),
    });
  } catch {
    return { ok: false, message: errorMessage(0, null) };
  }
  const body = await readJson(res);
  if (!res.ok) return { ok: false, message: errorMessage(res.status, body) };
  const user = parseUserEnvelope(body);
  // A 200 whose body we cannot read as a user is NOT a session. Treating it as
  // one would paint the frame for a subject nobody can name.
  if (!user) return { ok: false, message: SERVER_UNAVAILABLE };
  return { ok: true, user };
}

/**
 * X2 `GET /api/v1/auth/me`. FAIL-CLOSED: anything that is not a 200 carrying a
 * readable user β€” 401, 404 (the route not built yet), a proxy's HTML error
 * page, a dropped connection β€” reads as NO SESSION. There is no state in which
 * a failure to confirm the session is treated as having one.
 */
export async function me(): Promise<SessionUser | null> {
  try {
    const res = await fetch(`${API_V1}/auth/me`, { credentials: CREDS });
    // ⭐ THE BOOT SIDE OF THE TENANT GUARD. `me()` runs on every boot, so this is what ARMS
    // the comparator with the tenant this frame belongs to β€” every later call then only has to
    // notice a change. It also catches the swap directly on a re-check.
    // ⚠ Deliberately BEFORE the `!res.ok` return: a repointed tab still gets a 200 here, and
    // the guard must see the header on exactly the request that establishes identity.
    if (checkTenant(res)) return null;
    if (!res.ok) return null;
    return parseUserEnvelope(await readJson(res));
  } catch {
    return null;
  }
}

/**
 * X2 `POST /api/v1/auth/logout` β†’ 204, cookie cleared.
 *
 * Returns nothing and swallows failures ON PURPOSE: the caller drops to the
 * login screen either way. A logout button that reports failure and leaves the
 * user sitting in the app is worse than one that always closes the door on this
 * browser β€” and the cookie is the server's to invalidate, so the honest local
 * action is "stop showing the frame".
 */
export async function logout(): Promise<void> {
  try {
    await fetch(`${API_V1}/auth/logout`, { method: "POST", credentials: CREDS });
  } catch {
    // deliberate: see above
  }
}