@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/dist/index.d.mts +365 -2
- package/dist/index.d.ts +365 -2
- package/dist/index.js +1772 -1330
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1747 -1329
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/acs/ws.ts +46 -2
- package/src/api/tenants.ts +17 -1
- package/src/authSession.ts +241 -0
- package/src/chat/store.ts +26 -0
- package/src/consumerAuth.ts +216 -0
- package/src/deepLinks.ts +38 -0
- package/src/gate.ts +67 -0
- package/src/index.ts +66 -0
- package/src/lifecycle.ts +43 -0
- package/src/pushRegistration.ts +71 -0
- package/src/workspace.ts +127 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ateam-ai-mobile/core",
|
|
3
|
-
"version": "0.1.
|
|
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; }
|
package/src/api/tenants.ts
CHANGED
|
@@ -8,12 +8,26 @@ export interface TenantMembership {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* List the tenants the CURRENT user belongs to
|
|
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
|
+
}
|
package/src/deepLinks.ts
ADDED
|
@@ -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
|
+
}
|