@ateam-ai-mobile/core 0.1.6 → 0.1.8

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ateam-ai-mobile/core",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Headless ADAS SDK core — auth bridge, API client, chat/job streaming, MCP calls, theme tokens. No UI, no Expo dependencies.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/acs/ws.ts CHANGED
@@ -39,6 +39,19 @@ const TOPIC = 'acs.artifacts';
39
39
  const TOPIC_HOME = 'acs.home-actions';
40
40
  const MAX_BACKOFF_MS = 30_000;
41
41
 
42
+ // Application close codes the SERVER means as PERMANENT — reconnecting with the
43
+ // same credentials will just be refused again, so we stop the retry loop instead
44
+ // of hammering the socket every 30s forever (silently, on the user's mobile
45
+ // data). We do NOT interpret what the code MEANS for the UI — that is the
46
+ // server's job via GET /api/session; here we only decide "retrying is pointless."
47
+ // Extend as the backend documents more terminal codes.
48
+ // 4003 = invalid/inactive tenant (e.g. an expired trial workspace).
49
+ const TERMINAL_CLOSE_CODES = new Set<number>([4003]);
50
+
51
+ // Set when a terminal close arrives; suppresses BOTH the backoff loop and the
52
+ // foreground re-poke until an explicit connectAcsWs() (new creds / reactivation).
53
+ let _terminated = false;
54
+
42
55
  function backoffMs(attempt: number): number {
43
56
  // 0.5s, 1s, 2s, 4s, ... capped at 30s. Tiny jitter to avoid thundering herd.
44
57
  const base = Math.min(MAX_BACKOFF_MS, 500 * Math.pow(2, attempt));
@@ -125,12 +138,23 @@ function openSocket() {
125
138
  const ws = new WebSocket(url);
126
139
  _ws = ws;
127
140
 
141
+ // IDENTITY GUARD. `connectAcsWs()` (tenant switch / re-auth) and reconnects
142
+ // replace `_ws` while an OLD socket may still be mid-teardown. A late callback
143
+ // from that old socket must NOT touch the singleton state of the CURRENT one:
144
+ // its `onclose` could null out the live `_ws`, schedule a spurious reconnect,
145
+ // or poison `_terminated` for a connection that is perfectly healthy. Every
146
+ // callback proves it is still the current socket before doing anything — the
147
+ // captured `ws` vs the live `_ws`. A superseded socket's events are inert.
148
+ const isCurrent = () => ws === _ws;
149
+
128
150
  ws.onopen = () => {
151
+ if (!isCurrent()) return;
129
152
  _retryAttempt = 0;
130
153
  send({ type: 'subscribe', topic: TOPIC });
131
154
  send({ type: 'subscribe', topic: TOPIC_HOME });
132
155
  };
133
156
  ws.onmessage = (e) => {
157
+ if (!isCurrent()) return;
134
158
  try {
135
159
  const msg = JSON.parse(typeof e.data === 'string' ? e.data : '');
136
160
  handleEvent(msg);
@@ -140,13 +164,28 @@ function openSocket() {
140
164
  }
141
165
  };
142
166
  ws.onerror = (e: any) => {
167
+ if (!isCurrent()) return;
143
168
  console.warn('[acsWs] error', e?.message || e);
144
169
  };
145
- ws.onclose = () => {
170
+ ws.onclose = (ev: any) => {
171
+ // A superseded socket closing is expected and inert — do not clear the live
172
+ // `_ws`, schedule a retry, or set `_terminated`. Only the CURRENT socket's
173
+ // close drives the reconnect/terminate state machine.
174
+ if (!isCurrent()) return;
146
175
  _ws = null;
147
176
  if (!_wantOpen) return;
177
+ const code = typeof ev?.code === 'number' ? ev.code : 0;
178
+ if (TERMINAL_CLOSE_CODES.has(code)) {
179
+ // Permanent refusal — stop. Only an explicit connectAcsWs() (re-auth or
180
+ // tenant change) clears this; the automatic loop and foreground re-poke
181
+ // both stay off so we never loop against a socket that will keep refusing.
182
+ _terminated = true;
183
+ if (_retryTimer) { clearTimeout(_retryTimer); _retryTimer = null; }
184
+ console.warn(`[acsWs] server refused permanently (close ${code}); stopping reconnects until re-auth/tenant change`);
185
+ return;
186
+ }
148
187
  const delay = backoffMs(_retryAttempt++);
149
- console.log(`[acsWs] closed; reconnecting in ${Math.round(delay)}ms (attempt ${_retryAttempt})`);
188
+ console.log(`[acsWs] closed (${code}); reconnecting in ${Math.round(delay)}ms (attempt ${_retryAttempt})`);
150
189
  if (_retryTimer) clearTimeout(_retryTimer);
151
190
  _retryTimer = setTimeout(openSocket, delay);
152
191
  };
@@ -158,6 +197,7 @@ function openSocket() {
158
197
  * immediately rather than waiting out the backoff timer.
159
198
  */
160
199
  export function notifyAcsWsForeground() {
200
+ if (_terminated) return; // permanent refusal — don't re-poke a socket that keeps refusing
161
201
  if (_wantOpen && !_ws) {
162
202
  if (_retryTimer) { clearTimeout(_retryTimer); _retryTimer = null; }
163
203
  _retryAttempt = 0;
@@ -171,6 +211,9 @@ export function notifyAcsWsForeground() {
171
211
  * (JWT available).
172
212
  */
173
213
  export function connectAcsWs(token: string, tenant: string) {
214
+ // Explicit connect (sign-in, tenant switch, reactivation) always gets a fresh
215
+ // chance — clear any terminal state a prior refusal left behind.
216
+ _terminated = false;
174
217
  if (_ws && _wantOpen && _token === token && _tenant === tenant) return;
175
218
  // Token or tenant changed → tear down + reopen.
176
219
  if (_ws) {
@@ -188,6 +231,7 @@ export function connectAcsWs(token: string, tenant: string) {
188
231
  */
189
232
  export function disconnectAcsWs() {
190
233
  _wantOpen = false;
234
+ _terminated = false;
191
235
  _token = null;
192
236
  _tenant = null;
193
237
  if (_retryTimer) { clearTimeout(_retryTimer); _retryTimer = null; }
@@ -8,12 +8,26 @@ export interface TenantMembership {
8
8
  }
9
9
 
10
10
  /**
11
- * List the tenants the CURRENT user belongs to (GET /api/tenants/list).
11
+ * List the tenants the CURRENT user belongs to.
12
12
  *
13
13
  * The list is membership-scoped: for consumer JWTs the backend returns ONLY
14
14
  * tenants where the user's login identity (google/apple oauth or embed id)
15
15
  * has an active actor; builder JWTs get their user-doc memberships. It never
16
16
  * leaks the full tenant list. Returns [] when signed out or unknown.
17
+ *
18
+ * IT CALLS CONSUMER-AUTH, NOT CORE. This used to be
19
+ * `apiFetch('/api/tenants/list')` against the Core API, which is the wrong
20
+ * service: the membership a consumer JWT proves is held by consumer-auth, so
21
+ * Core could not answer it for the case that matters — a signed-in consumer
22
+ * asking which teams they are in. Hence the explicit fetch with the bearer
23
+ * token rather than the shared `apiFetch` helper, which is Core-scoped.
24
+ *
25
+ * EVERY FAILURE PATH RETURNS [] deliberately: no token, non-OK response,
26
+ * unparseable body, or a thrown fetch. "I could not find out" and "you belong
27
+ * to nothing" are genuinely different, but this signature has no room to say
28
+ * so — the caller renders a team list, and an empty one is the safe render.
29
+ * If that distinction ever matters, widen the return type rather than
30
+ * inventing a tenant.
17
31
  */
18
32
  export async function listMyTenants(): Promise<TenantMembership[]> {
19
33
  const token = getToken();
@@ -26,6 +40,8 @@ export async function listMyTenants(): Promise<TenantMembership[]> {
26
40
  if (!res.ok || !data?.ok || !Array.isArray(data.tenants)) return [];
27
41
  return data.tenants.map((t: any) => ({
28
42
  id: t.id,
43
+ // Fall back to the id rather than to an empty string: a nameless row in
44
+ // a team switcher is unpickable.
29
45
  name: t.name || t.id,
30
46
  role: t.role ?? null,
31
47
  }));
@@ -0,0 +1,241 @@
1
+ /**
2
+ * SDK-owned session state machine (R6).
3
+ *
4
+ * Until R6 the JWT lifecycle lived in the app (apps/mobile/src/stores/
5
+ * authStore.ts): token storage, the decode/user projection, the startup
6
+ * restore + silent refresh, and the refresh decision table. A second host
7
+ * (host-demo, a third-party app) had to re-implement all of it. R6 moves the
8
+ * MECHANISM here; the host keeps only what is genuinely its own — secure
9
+ * storage and the side effects of signing out.
10
+ *
11
+ * Two seams keep this free of any app/Expo import and free of an auth<->bridge
12
+ * cycle (the plan's explicit constraint):
13
+ * - `persistence` the host's secure JWT storage (get/save/clear). The SDK
14
+ * never imports expo-secure-store.
15
+ * - `onLogout` the host's teardown, run in ONE place so the order is
16
+ * owned here: stop the device/relay bridge, unregister push
17
+ * (while the token still exists), clear plugin storage +
18
+ * bundle cache + device-tool registry, close chat. The SDK
19
+ * calls it; it never imports the stores that do the work.
20
+ *
21
+ * The refresh decision table and JWT projection are moved VERBATIM from the app
22
+ * so behaviour is identical (the R0.5 authRefresh baseline is the oracle).
23
+ */
24
+ import { getCoreApiUrl, type RefreshOutcome } from './config';
25
+
26
+ export interface SessionUser {
27
+ actorId: string;
28
+ email?: string;
29
+ displayName?: string;
30
+ // Explicit role axes (Phase 4). `roles` is legacy (empty for newer tokens).
31
+ tenantRole?: string | null;
32
+ serviceRole?: string | null;
33
+ platformRoles?: string[];
34
+ roles: string[];
35
+ }
36
+
37
+ export type SessionStatus = 'loading' | 'authenticated' | 'signed_out';
38
+
39
+ export interface AuthSessionState {
40
+ token: string | null;
41
+ user: SessionUser | null;
42
+ status: SessionStatus;
43
+ }
44
+
45
+ /**
46
+ * The host's secure JWT storage. Only the token lives here — tenant selection
47
+ * and env are resolved through the config getters, not stored by the session
48
+ * (derive-never-store).
49
+ */
50
+ export interface SessionPersistence {
51
+ loadToken(): Promise<string | null>;
52
+ saveToken(token: string): Promise<void>;
53
+ clearToken(): Promise<void>;
54
+ }
55
+
56
+ export interface AuthSessionDeps {
57
+ persistence: SessionPersistence;
58
+ /**
59
+ * Host teardown for sign-out, run by `logout()` WHILE THE TOKEN STILL EXISTS
60
+ * (push unregister needs the JWT) and BEFORE the SDK clears it. The host does
61
+ * the app-specific work — stop the bridge, unregister push, clear the tenant
62
+ * keys, evict plugin storage / bundle cache / device-tool registry, close
63
+ * chat — in this one hook, so there is no auth->bridge import cycle.
64
+ */
65
+ onLogout?: () => void | Promise<void>;
66
+ /**
67
+ * Called when a token is adopted (login, or a successful refresh) with the
68
+ * projected user, so the host can persist side data (e.g. its TENANT key)
69
+ * without the session reaching into app storage.
70
+ */
71
+ onToken?: (token: string, user: SessionUser | null) => void | Promise<void>;
72
+ }
73
+
74
+ // ── JWT projection (verbatim from the app) ──────────────────────────────────
75
+
76
+ function decodeJwtPayload(token: string): Record<string, any> | null {
77
+ try {
78
+ const segs = token.split('.');
79
+ if (segs.length !== 3) return null;
80
+ // base64url → base64
81
+ const b64 = segs[1].replace(/-/g, '+').replace(/_/g, '/');
82
+ // Pad to a multiple of 4
83
+ const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
84
+ const json = typeof atob === 'function'
85
+ ? atob(padded)
86
+ // Hermes always has atob; fallback for older runtimes
87
+ : Buffer.from(padded, 'base64').toString('utf8');
88
+ return JSON.parse(json);
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ export function userFromJwt(token: string): SessionUser | null {
95
+ const claims = decodeJwtPayload(token);
96
+ if (!claims?.sub) return null;
97
+ return {
98
+ actorId: String(claims.sub),
99
+ email: claims.email,
100
+ displayName: claims.name || claims.email,
101
+ tenantRole: claims.tenantRole ?? null,
102
+ serviceRole: claims.serviceRole ?? null,
103
+ platformRoles: Array.isArray(claims.platformRoles) ? claims.platformRoles : [],
104
+ roles: Array.isArray(claims.roles) ? claims.roles : [],
105
+ };
106
+ }
107
+
108
+ // ── Refresh decision table (verbatim from the app) ──────────────────────────
109
+ //
110
+ // Three outcomes so a caller can tell "log out" from "transient blip":
111
+ // ok — a fresh JWT, adopt it.
112
+ // auth_failed — 401/403 or a malformed 2xx: session genuinely broken.
113
+ // transient — network / 5xx / other non-auth: keep the session, retry later.
114
+
115
+ type RefreshResult =
116
+ | { ok: 'ok'; token: string }
117
+ | { ok: 'auth_failed'; status: number; reason: string }
118
+ | { ok: 'transient'; status?: number; reason: string };
119
+
120
+ /**
121
+ * Hit Core's POST /api/auth/refresh with the (possibly expired) token. The
122
+ * endpoint accepts expired tokens within a grace period. Standalone so
123
+ * `restore()` can call it before anything reads `getToken()`.
124
+ */
125
+ async function attemptRefresh(expiredToken: string): Promise<RefreshResult> {
126
+ let res: Response;
127
+ try {
128
+ res = await fetch(`${getCoreApiUrl()}/api/auth/refresh`, {
129
+ method: 'POST',
130
+ headers: {
131
+ 'Authorization': `Bearer ${expiredToken}`,
132
+ 'Content-Type': 'application/json',
133
+ },
134
+ });
135
+ } catch (e: any) {
136
+ console.warn('[adas/session] refresh fetch threw (network):', e?.message || e);
137
+ return { ok: 'transient', reason: `network: ${e?.message || 'unknown'}` };
138
+ }
139
+ if (res.status === 401 || res.status === 403) {
140
+ console.warn(`[adas/session] refresh rejected with ${res.status} — auth genuinely broken`);
141
+ return { ok: 'auth_failed', status: res.status, reason: `core ${res.status}` };
142
+ }
143
+ if (!res.ok) {
144
+ console.warn(`[adas/session] refresh server error ${res.status} — treating as transient`);
145
+ return { ok: 'transient', status: res.status, reason: `core ${res.status}` };
146
+ }
147
+ const body = await res.json().catch(() => null);
148
+ if (typeof body?.token === 'string') return { ok: 'ok', token: body.token };
149
+ console.warn('[adas/session] refresh returned 2xx but no token — treating as auth_failed');
150
+ return { ok: 'auth_failed', status: res.status, reason: 'malformed refresh response' };
151
+ }
152
+
153
+ export interface AuthSession {
154
+ /** Current token (synchronous, in-memory) — the TokenProvider read path. */
155
+ getToken(): string | null;
156
+ getUser(): SessionUser | null;
157
+ getState(): AuthSessionState;
158
+ subscribe(cb: (s: AuthSessionState) => void): () => void;
159
+ /** Startup: load the persisted token, project the user, and silently refresh
160
+ * an expired JWT (ok → adopt, auth_failed → sign out, transient → keep). */
161
+ restore(): Promise<void>;
162
+ /** Adopt a freshly-minted token (host authenticated the user). */
163
+ login(token: string): Promise<void>;
164
+ /** Sign out: host teardown (with token still present) → clear token → state. */
165
+ logout(): Promise<void>;
166
+ /** The 401 refresh path. Adopts a new token on success; returns the API
167
+ * client's outcome shape ('ok' → retry, 'auth_failed' → sign out). */
168
+ refresh(): Promise<RefreshOutcome>;
169
+ }
170
+
171
+ export function createAuthSession(deps: AuthSessionDeps): AuthSession {
172
+ let state: AuthSessionState = { token: null, user: null, status: 'loading' };
173
+ const subs = new Set<(s: AuthSessionState) => void>();
174
+ const set = (patch: Partial<AuthSessionState>) => {
175
+ state = { ...state, ...patch };
176
+ for (const cb of subs) cb(state);
177
+ };
178
+
179
+ async function adopt(token: string): Promise<void> {
180
+ await deps.persistence.saveToken(token);
181
+ const user = userFromJwt(token);
182
+ set({ token, user, status: 'authenticated' });
183
+ await deps.onToken?.(token, user);
184
+ }
185
+
186
+ async function refresh(): Promise<RefreshOutcome> {
187
+ const current = state.token;
188
+ if (!current) return { ok: 'auth_failed', status: 0, reason: 'no token in store' };
189
+ const outcome = await attemptRefresh(current);
190
+ if (outcome.ok === 'ok') { await adopt(outcome.token); return { ok: 'ok', status: 200 }; }
191
+ if (outcome.ok === 'auth_failed') return { ok: 'auth_failed', status: outcome.status, reason: outcome.reason };
192
+ return { ok: 'transient', status: outcome.status ?? 0, reason: outcome.reason };
193
+ }
194
+
195
+ async function logout(): Promise<void> {
196
+ // Host teardown runs FIRST, while the token still exists — push unregister
197
+ // needs the JWT — then the SDK clears the token and flips to signed_out.
198
+ await deps.onLogout?.();
199
+ await deps.persistence.clearToken();
200
+ set({ token: null, user: null, status: 'signed_out' });
201
+ }
202
+
203
+ return {
204
+ getToken: () => state.token,
205
+ getUser: () => state.user,
206
+ getState: () => state,
207
+ subscribe(cb) { subs.add(cb); return () => { subs.delete(cb); }; },
208
+
209
+ async restore() {
210
+ const token = await deps.persistence.loadToken();
211
+ if (!token) { set({ status: 'signed_out' }); return; }
212
+ const user = userFromJwt(token);
213
+ if (!user) { await deps.persistence.clearToken(); set({ status: 'signed_out' }); return; }
214
+ const claims = decodeJwtPayload(token);
215
+ const exp = typeof claims?.exp === 'number' ? claims.exp * 1000 : 0;
216
+ if (exp && Date.now() >= exp) {
217
+ const outcome = await attemptRefresh(token);
218
+ if (outcome.ok === 'ok') { await adopt(outcome.token); return; }
219
+ if (outcome.ok === 'auth_failed') {
220
+ await deps.persistence.clearToken();
221
+ set({ token: null, user: null, status: 'signed_out' });
222
+ return;
223
+ }
224
+ // transient — keep the stale token; apiFetch retries and next launch
225
+ // refreshes again. Better than a re-login on a server blip.
226
+ set({ token, user, status: 'authenticated' });
227
+ return;
228
+ }
229
+ set({ token, user, status: 'authenticated' });
230
+ },
231
+
232
+ async login(token: string) {
233
+ const user = userFromJwt(token);
234
+ if (!user) throw new Error('Invalid JWT — could not decode subject');
235
+ await adopt(token);
236
+ },
237
+
238
+ logout,
239
+ refresh,
240
+ };
241
+ }
package/src/chat/store.ts CHANGED
@@ -725,6 +725,20 @@ function dispatchPluginCommands(cmds: any[]) {
725
725
  // Persistent WS connection — stays open across jobs
726
726
  let _ws: WebSocket | null = null;
727
727
  let _wsReady = false;
728
+
729
+ /** What this client can execute, supplied by the host (see setClientDeclarationProvider). */
730
+ type ClientDeclaration = { device_host: boolean; tools: Array<{ name: string }> };
731
+ let _clientDeclarationProvider: (() => ClientDeclaration | null) | null = null;
732
+
733
+ /**
734
+ * Host DI: what to tell the server about this client on connect.
735
+ *
736
+ * Kept as a provider rather than a value because the answer changes — plugins
737
+ * load after boot, so the tool list is not known when the socket first opens.
738
+ */
739
+ export function setClientDeclarationProvider(fn: (() => ClientDeclaration | null) | null): void {
740
+ _clientDeclarationProvider = fn;
741
+ }
728
742
  let _currentJobId: string | null = null;
729
743
 
730
744
  function ensureWs(tenant: string, token: string | null): WebSocket {
@@ -737,6 +751,18 @@ function ensureWs(tenant: string, token: string | null): WebSocket {
737
751
 
738
752
  ws.onopen = () => {
739
753
  _wsReady = true;
754
+
755
+ // Tell the server what this client IS. A device tool executes here, so the
756
+ // server has to reach the right connection: the same person may also have a
757
+ // browser tab on this stream, and "read the GPS" has one correct answer.
758
+ // Without this the server can only broadcast and hope.
759
+ try {
760
+ const declared = _clientDeclarationProvider?.();
761
+ if (declared) ws.send(JSON.stringify({ type: 'declare_client', ...declared }));
762
+ } catch {
763
+ // Never let a declaration failure cost us the connection.
764
+ }
765
+
740
766
  // If a job is waiting for the connection, subscribe now
741
767
  if (_currentJobId) {
742
768
  ws.send(JSON.stringify({ type: 'subscribe', jobId: _currentJobId }));
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Consumer-auth service client (R9 — moved from the app).
3
+ *
4
+ * Wraps the public consumer-auth service: config fetch, Google/Apple sign-in,
5
+ * the web→app handoff exchange, the invite/open-join flows, and channel-link OTP.
6
+ * Generic auth-flow mechanism — the host's native layer acquires the provider
7
+ * id_token; this trades it for a tenant-scoped session JWT. It reads only the
8
+ * SDK config getters (consumer-auth URL + the resolved tenant, which pre-auth
9
+ * resolves to the host's active pick), so any host reuses it unchanged.
10
+ */
11
+ import { getConsumerAuthApiUrl, getTenant } from './config';
12
+
13
+ export interface ConsumerProviderConfig {
14
+ google?: {
15
+ // Platform-specific client IDs. Each is its own Google OAuth client
16
+ // (Web / iOS / Android — registered separately in Google Cloud Console).
17
+ // The id_token's `aud` claim is whichever client minted it, so the
18
+ // backend accepts any of them as a valid audience. The host picks the
19
+ // right one for the current Platform.OS.
20
+ web_client_id?: string;
21
+ ios_client_id?: string;
22
+ android_client_id?: string;
23
+ // Legacy single field — kept for backward compat with older configs.
24
+ client_id?: string;
25
+ };
26
+ apple?: { service_id: string };
27
+ }
28
+
29
+ export interface ConsumerCapabilityConfig {
30
+ ok: boolean;
31
+ tenant: string;
32
+ providers: ConsumerProviderConfig;
33
+ linked_channels: string[];
34
+ }
35
+
36
+ export interface ConsumerSignInResult {
37
+ ok: boolean;
38
+ jwt: string;
39
+ actor: { id: string; email: string; role: string };
40
+ }
41
+
42
+ export interface OtpStartResult {
43
+ ok: boolean;
44
+ challenge_id: string;
45
+ sent_to: string;
46
+ provider: string;
47
+ debug_code?: string; // present only in stub mode
48
+ }
49
+
50
+ /**
51
+ * Build a user-facing error message that NEVER includes raw HTML response
52
+ * bodies. Cloudflare's 502 page is several KB of `<html>...` markup; bubbling
53
+ * it through `Error.message` dumps it onto the login screen as raw text. We
54
+ * trim aggressively and detect HTML so the user sees something useful.
55
+ */
56
+ function cleanErrorMessage(status: number, rawText: string, fallback: string): string {
57
+ const trimmed = (rawText || '').trim();
58
+ // Cloudflare / nginx / generic web-server error pages all start with `<`.
59
+ if (trimmed.startsWith('<')) {
60
+ return `${fallback} (${status}) — backend unreachable`;
61
+ }
62
+ if (!trimmed) return `${fallback} (${status})`;
63
+ // Cap to 200 chars so a verbose JSON error doesn't still dump a wall of text.
64
+ const short = trimmed.length > 200 ? `${trimmed.slice(0, 200)}…` : trimmed;
65
+ return `${fallback} (${status}): ${short}`;
66
+ }
67
+
68
+ async function postJson<T>(path: string, body: unknown, jwt?: string): Promise<T> {
69
+ const res = await fetch(`${getConsumerAuthApiUrl()}${path}`, {
70
+ method: 'POST',
71
+ headers: {
72
+ 'Content-Type': 'application/json',
73
+ ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}),
74
+ },
75
+ body: JSON.stringify(body),
76
+ });
77
+ const text = await res.text();
78
+ let parsed: any = null;
79
+ try { parsed = JSON.parse(text); } catch { /* leave as null — text isn't JSON */ }
80
+ if (!res.ok || parsed?.ok === false) {
81
+ if (parsed?.error) throw new Error(parsed.error);
82
+ throw new Error(cleanErrorMessage(res.status, text, `consumer-auth ${path} failed`));
83
+ }
84
+ return parsed as T;
85
+ }
86
+
87
+ /**
88
+ * Fetch the public consumer config for the active tenant. Returns the public
89
+ * OAuth client IDs needed to render Google/Apple sign-in buttons.
90
+ */
91
+ export async function fetchConsumerConfig(): Promise<ConsumerCapabilityConfig> {
92
+ const url = `${getConsumerAuthApiUrl()}/platform/consumer/config?tenant=${encodeURIComponent(getTenant())}`;
93
+ const res = await fetch(url);
94
+ // Read as text first so a Cloudflare 502 HTML page doesn't crash res.json()
95
+ // with a multi-KB error message that ends up rendered on the login screen.
96
+ const text = await res.text();
97
+ let data: any = null;
98
+ try { data = JSON.parse(text); } catch { /* not JSON — leave null, fall through */ }
99
+ if (!res.ok || !data?.ok) {
100
+ if (data?.error) throw new Error(data.error);
101
+ throw new Error(cleanErrorMessage(res.status, text, 'Failed to load sign-in config'));
102
+ }
103
+ return data;
104
+ }
105
+
106
+ /**
107
+ * Exchange a Google id_token for a tenant-scoped consumer JWT. The id_token
108
+ * comes from the host's native Google sign-in flow.
109
+ */
110
+ export async function signInWithGoogle(idToken: string): Promise<ConsumerSignInResult> {
111
+ return postJson<ConsumerSignInResult>('/platform/consumer/oauth/google', {
112
+ tenant: getTenant(),
113
+ id_token: idToken,
114
+ });
115
+ }
116
+
117
+ /** Exchange an Apple id_token for a tenant-scoped consumer JWT. */
118
+ export async function signInWithApple(idToken: string): Promise<ConsumerSignInResult> {
119
+ return postJson<ConsumerSignInResult>('/platform/consumer/oauth/apple', {
120
+ tenant: getTenant(),
121
+ id_token: idToken,
122
+ });
123
+ }
124
+
125
+ // ─── Web → app sign-in handoff ─────────────────────────────────────────
126
+ // An already-authenticated WEB session mints a short-lived, single-use code
127
+ // (QR / deep link: <scheme>://handoff?handoff=<code>). The app trades that code
128
+ // for a normal session JWT here — NO auth header: the code IS the credential.
129
+ // The code is single-use and 60s-lived; the returned JWT is a normal session
130
+ // token. Second use / expired → the server 410s (surface as a friendly retry).
131
+ //
132
+ // `tenant` echoes the tenant the code was bound to. The session sets the active
133
+ // tenant from the host, NOT from the JWT, so the caller must adopt this tenant
134
+ // before login() or a cross-tenant handoff would mislabel the session.
135
+ export interface HandoffExchangeResult extends ConsumerSignInResult {
136
+ tenant?: string;
137
+ }
138
+ export async function exchangeHandoff(code: string): Promise<HandoffExchangeResult> {
139
+ return postJson<HandoffExchangeResult>('/platform/consumer/handoff/exchange', { code });
140
+ }
141
+
142
+ // ─── Invite join flow ──────────────────────────────────────────────────
143
+ // A tenant admin issues an invite; the code IS the tenant proof (no slug is
144
+ // ever sent).
145
+
146
+ export interface InvitePreview {
147
+ ok: boolean;
148
+ org: string; // display name only — never the tenant slug
149
+ role: string | null;
150
+ }
151
+
152
+ /**
153
+ * Preview an invite (no auth) to render the "Join <Org> as <role>?" screen.
154
+ * Throws with a friendly message on 404 (invalid/expired).
155
+ */
156
+ export async function previewInvite(code: string): Promise<InvitePreview> {
157
+ const url = `${getConsumerAuthApiUrl()}/platform/consumer/invite/${encodeURIComponent(code)}`;
158
+ const res = await fetch(url);
159
+ const text = await res.text();
160
+ let data: any = null;
161
+ try { data = JSON.parse(text); } catch { /* not JSON */ }
162
+ if (res.status === 404) throw new Error('This invite is invalid or has expired.');
163
+ if (!res.ok || !data?.ok) {
164
+ if (data?.error) throw new Error(data.error);
165
+ throw new Error(cleanErrorMessage(res.status, text, 'Could not load invite'));
166
+ }
167
+ return data;
168
+ }
169
+
170
+ /**
171
+ * Exchange an invite + Google id_token for a tenant-scoped consumer JWT.
172
+ * NOTE: no `tenant` field — the invite carries the tenant. Sending `tenant`
173
+ * instead would be the open-signup path (403 on invite-only tenants).
174
+ */
175
+ export async function joinWithGoogle(invite: string, idToken: string): Promise<ConsumerSignInResult> {
176
+ return postJson<ConsumerSignInResult>('/platform/consumer/oauth/google', { invite, id_token: idToken });
177
+ }
178
+
179
+ /** Exchange an invite + Apple id_token for a tenant-scoped consumer JWT. */
180
+ export async function joinWithApple(invite: string, idToken: string): Promise<ConsumerSignInResult> {
181
+ return postJson<ConsumerSignInResult>('/platform/consumer/oauth/apple', { invite, id_token: idToken });
182
+ }
183
+
184
+ // ─── Open self-signup (no invite) ──────────────────────────────────────
185
+ // For tenants the admin marked FREE-TO-JOIN. The user names the team (slug),
186
+ // we send { tenant, id_token } with no invite. The server accepts only if that
187
+ // tenant allows open signup, else 403 — the client just surfaces the message.
188
+
189
+ export async function openJoinWithGoogle(tenant: string, idToken: string): Promise<ConsumerSignInResult> {
190
+ return postJson<ConsumerSignInResult>('/platform/consumer/oauth/google', { tenant, id_token: idToken });
191
+ }
192
+
193
+ export async function openJoinWithApple(tenant: string, idToken: string): Promise<ConsumerSignInResult> {
194
+ return postJson<ConsumerSignInResult>('/platform/consumer/oauth/apple', { tenant, id_token: idToken });
195
+ }
196
+
197
+ /**
198
+ * Start a channel-link OTP challenge. The consumer must be authenticated
199
+ * (their JWT is passed via the Authorization header).
200
+ */
201
+ export async function linkChannelStart(
202
+ jwt: string,
203
+ channel: string,
204
+ handle: string,
205
+ ): Promise<OtpStartResult> {
206
+ return postJson<OtpStartResult>('/platform/consumer/link-channel/start', { channel, handle }, jwt);
207
+ }
208
+
209
+ /** Verify the OTP and complete the channel link. */
210
+ export async function linkChannelVerify(
211
+ jwt: string,
212
+ challengeId: string,
213
+ code: string,
214
+ ): Promise<{ ok: boolean; channel: string; handle: string; actorId: string; transferred: boolean }> {
215
+ return postJson('/platform/consumer/link-channel/verify', { challenge_id: challengeId, code }, jwt);
216
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Deep-link parsing (R9) — the generic navigation-orchestration half of the
3
+ * host's invite/handoff capture. Extracting a code from a link's query string
4
+ * is the same for any host; only the URL SCHEME (adas://, universal-link
5
+ * domains) is host-specific and stays in the host's Expo Router config.
6
+ */
7
+
8
+ /**
9
+ * Pull a query param's value out of a deep link, URL-decoded. Returns null when
10
+ * the link is empty, the param is absent, or the value is empty. Tolerant of a
11
+ * malformed encoding (falls back to the raw match) so a valid code is never
12
+ * dropped on a stray `%`.
13
+ */
14
+ export function parseDeepLinkParam(url: string | null | undefined, param: string): string | null {
15
+ if (!url) return null;
16
+ const re = new RegExp(`[?&]${param}=([^&#\\s]+)`, 'i');
17
+ const m = url.match(re);
18
+ if (!m) return null;
19
+ try {
20
+ const v = decodeURIComponent(m[1]);
21
+ return v.length > 0 ? v : null;
22
+ } catch {
23
+ return m[1].length > 0 ? m[1] : null;
24
+ }
25
+ }
26
+
27
+ /** A tenant-invite code from `?invite=<code>`. */
28
+ export function parseInviteCode(url: string | null | undefined): string | null {
29
+ return parseDeepLinkParam(url, 'invite');
30
+ }
31
+
32
+ /**
33
+ * A web→app handoff code from `?handoff=<code>`. Deliberately keyed on `handoff=`
34
+ * and NOT `code=`, so it never collides with an OAuth `?code=` redirect.
35
+ */
36
+ export function parseHandoffCode(url: string | null | undefined): string | null {
37
+ return parseDeepLinkParam(url, 'handoff');
38
+ }