@ateam-ai-mobile/core 0.1.0 → 0.1.2
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 +375 -7
- package/dist/index.d.ts +375 -7
- package/dist/index.js +661 -5
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +641 -4
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/acs/api.ts +345 -0
- package/src/acs/homeActionsStore.ts +178 -0
- package/src/acs/providers.ts +44 -0
- package/src/acs/widgetsStore.ts +229 -0
- package/src/acs/ws.ts +198 -0
- package/src/api/tenants.ts +75 -0
- package/src/config.ts +47 -9
- package/src/index.ts +39 -1
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracks the user's ACS widget tiles for the home carousel. Single source of
|
|
3
|
+
* truth for /api/acs/surface state. Optimistic ops with rollback on failure
|
|
4
|
+
* — backend contract treats 404 / already:true as success (per spec), so the
|
|
5
|
+
* store mirrors that policy: a delete that the server says was already done
|
|
6
|
+
* stays removed locally.
|
|
7
|
+
*
|
|
8
|
+
* Cross-device freshness: the WS subscription (./ws) forwards `acs.artifacts`
|
|
9
|
+
* events into the applyServer* methods; hosts should also refresh() on app
|
|
10
|
+
* foreground ("WS is best-effort; the GET is the source of truth").
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { create } from 'zustand';
|
|
14
|
+
import {
|
|
15
|
+
listSurface,
|
|
16
|
+
deleteArtifact,
|
|
17
|
+
patchArtifact,
|
|
18
|
+
setOrder,
|
|
19
|
+
recordEvent,
|
|
20
|
+
type AcsArtifact,
|
|
21
|
+
type AcsDeleteReason,
|
|
22
|
+
} from './api';
|
|
23
|
+
|
|
24
|
+
interface WidgetsState {
|
|
25
|
+
/** Tiles to render in the home carousel. Order matches what /surface
|
|
26
|
+
* returned; sort by pinOrder when present. */
|
|
27
|
+
tiles: AcsArtifact[];
|
|
28
|
+
/** True after the first /surface fetch completes (success OR empty). */
|
|
29
|
+
hydrated: boolean;
|
|
30
|
+
/** True while a /surface request is in flight. */
|
|
31
|
+
loading: boolean;
|
|
32
|
+
/** Last error string from /surface; null on success or pre-fetch. */
|
|
33
|
+
error: string | null;
|
|
34
|
+
|
|
35
|
+
/** First load — idempotent; safe to call from multiple effects. */
|
|
36
|
+
hydrate: () => Promise<void>;
|
|
37
|
+
/** Force re-fetch; call on app foreground and after destructive ops. */
|
|
38
|
+
refresh: () => Promise<void>;
|
|
39
|
+
/** Reset to empty (sign-out, env switch). */
|
|
40
|
+
clear: () => void;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Optimistic remove. Returns { ok: false, error } only on real network /
|
|
44
|
+
* 5xx failures — 404 and already:true rollback to ok:true per the backend
|
|
45
|
+
* contract (no existence leak; the row is gone either way).
|
|
46
|
+
*/
|
|
47
|
+
remove: (artifactId: string, reason?: AcsDeleteReason) => Promise<{ ok: boolean; error?: string }>;
|
|
48
|
+
|
|
49
|
+
/** Optimistic hide (visibility → 'hidden'). Tile disappears from carousel. */
|
|
50
|
+
hide: (artifactId: string) => Promise<{ ok: boolean; error?: string }>;
|
|
51
|
+
|
|
52
|
+
/** Optimistic unhide (visibility → 'pinned'). Tile reappears in carousel. */
|
|
53
|
+
unhide: (artifactId: string) => Promise<{ ok: boolean; error?: string }>;
|
|
54
|
+
|
|
55
|
+
/** Optimistic rename. Trims input and rejects empty / overlong names locally. */
|
|
56
|
+
rename: (artifactId: string, name: string) => Promise<{ ok: boolean; error?: string }>;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Persist a new pin order to the server. `pinnedIds` is the visible order
|
|
60
|
+
* of currently-pinned tiles. Optimistic — applies locally first, rolls
|
|
61
|
+
* back on server rejection.
|
|
62
|
+
*/
|
|
63
|
+
reorder: (pinnedIds: string[]) => Promise<{ ok: boolean; error?: string }>;
|
|
64
|
+
|
|
65
|
+
// ─── Server-driven mutations (no API call) ─────────────────────────────
|
|
66
|
+
// The WS subscription (./ws) forwards `acs.artifacts` events into these
|
|
67
|
+
// methods. They ONLY mutate local state — they DO NOT call the API (the
|
|
68
|
+
// server is the source of the event, calling back would loop).
|
|
69
|
+
|
|
70
|
+
/** WS 'updated' event — replace the tile by id (hide/unhide/rename land here). */
|
|
71
|
+
applyServerUpdate: (artifact: AcsArtifact) => void;
|
|
72
|
+
/** WS 'retired' event — remove the tile by id. */
|
|
73
|
+
applyServerRetire: (artifactId: string) => void;
|
|
74
|
+
/** WS 'order' event — re-rank tiles to match the pinned ID array. */
|
|
75
|
+
applyServerOrder: (pinnedIds: string[]) => void;
|
|
76
|
+
|
|
77
|
+
/** Fire-and-forget usage event. Caller doesn't await. */
|
|
78
|
+
track: (artifactId: string, event: 'clicked' | 'dismissed' | 'acted' | 'ignored') => void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const useWidgetsStore = create<WidgetsState>((set, get) => ({
|
|
82
|
+
tiles: [],
|
|
83
|
+
hydrated: false,
|
|
84
|
+
loading: false,
|
|
85
|
+
error: null,
|
|
86
|
+
|
|
87
|
+
hydrate: async () => {
|
|
88
|
+
if (get().hydrated || get().loading) return;
|
|
89
|
+
await get().refresh();
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
refresh: async () => {
|
|
93
|
+
if (get().loading) return;
|
|
94
|
+
set({ loading: true, error: null });
|
|
95
|
+
try {
|
|
96
|
+
const tiles = await listSurface('home');
|
|
97
|
+
set({ tiles, loading: false, hydrated: true });
|
|
98
|
+
} catch (e: any) {
|
|
99
|
+
set({ loading: false, hydrated: true, error: e?.message || 'Failed to load widgets' });
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
clear: () => set({ tiles: [], hydrated: false, loading: false, error: null }),
|
|
104
|
+
|
|
105
|
+
remove: async (artifactId, reason = 'user_long_press_delete') => {
|
|
106
|
+
const before = get().tiles;
|
|
107
|
+
// Optimistic: snap removed immediately so the user sees the tile vanish.
|
|
108
|
+
set({ tiles: before.filter(t => t.id !== artifactId) });
|
|
109
|
+
|
|
110
|
+
const result = await deleteArtifact(artifactId, reason);
|
|
111
|
+
if (result.ok) {
|
|
112
|
+
// Includes 200, 200 already:true, and 404 — all "gone server-side too".
|
|
113
|
+
return { ok: true };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Real failure — rollback.
|
|
117
|
+
set({ tiles: before });
|
|
118
|
+
return { ok: false, error: result.error || `HTTP ${result.status}` };
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
hide: async (artifactId) => {
|
|
122
|
+
const before = get().tiles;
|
|
123
|
+
set({
|
|
124
|
+
tiles: before.map(t =>
|
|
125
|
+
t.id === artifactId ? { ...t, visibility: 'hidden' as const } : t,
|
|
126
|
+
),
|
|
127
|
+
});
|
|
128
|
+
const r = await patchArtifact(artifactId, { visibility: 'hidden' }, 'user_long_press_hide');
|
|
129
|
+
if (r.ok) {
|
|
130
|
+
// If the server returned the updated artifact, sync our copy to it
|
|
131
|
+
// (covers race conditions and server-side normalisation).
|
|
132
|
+
if (r.artifact) {
|
|
133
|
+
set({
|
|
134
|
+
tiles: get().tiles.map(t => (t.id === artifactId ? { ...t, ...r.artifact! } : t)),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return { ok: true };
|
|
138
|
+
}
|
|
139
|
+
set({ tiles: before });
|
|
140
|
+
return { ok: false, error: r.error || `HTTP ${r.status}` };
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
unhide: async (artifactId) => {
|
|
144
|
+
const before = get().tiles;
|
|
145
|
+
set({
|
|
146
|
+
tiles: before.map(t =>
|
|
147
|
+
t.id === artifactId ? { ...t, visibility: 'pinned' as const } : t,
|
|
148
|
+
),
|
|
149
|
+
});
|
|
150
|
+
const r = await patchArtifact(artifactId, { visibility: 'pinned' }, 'user_long_press_unhide');
|
|
151
|
+
if (r.ok) {
|
|
152
|
+
if (r.artifact) {
|
|
153
|
+
set({
|
|
154
|
+
tiles: get().tiles.map(t => (t.id === artifactId ? { ...t, ...r.artifact! } : t)),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return { ok: true };
|
|
158
|
+
}
|
|
159
|
+
set({ tiles: before });
|
|
160
|
+
return { ok: false, error: r.error || `HTTP ${r.status}` };
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
rename: async (artifactId, name) => {
|
|
164
|
+
// Local validation mirrors backend's 60-char cap from the spec. Keep
|
|
165
|
+
// this strict so we don't hit the API with garbage that will 400 anyway.
|
|
166
|
+
const trimmed = name.trim();
|
|
167
|
+
if (!trimmed) return { ok: false, error: 'Name cannot be empty' };
|
|
168
|
+
if (trimmed.length > 60) return { ok: false, error: 'Name must be 60 characters or less' };
|
|
169
|
+
|
|
170
|
+
const before = get().tiles;
|
|
171
|
+
set({
|
|
172
|
+
tiles: before.map(t => (t.id === artifactId ? { ...t, name: trimmed } : t)),
|
|
173
|
+
});
|
|
174
|
+
const r = await patchArtifact(artifactId, { name: trimmed }, 'user_long_press_rename');
|
|
175
|
+
if (r.ok) {
|
|
176
|
+
if (r.artifact) {
|
|
177
|
+
set({
|
|
178
|
+
tiles: get().tiles.map(t => (t.id === artifactId ? { ...t, ...r.artifact! } : t)),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
return { ok: true };
|
|
182
|
+
}
|
|
183
|
+
set({ tiles: before });
|
|
184
|
+
return { ok: false, error: r.error || `HTTP ${r.status}` };
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
reorder: async (pinnedIds) => {
|
|
188
|
+
const before = get().tiles;
|
|
189
|
+
// Optimistic: assign pinOrder by position in pinnedIds. Hidden tiles
|
|
190
|
+
// are not touched. If the server rejects, we roll back to `before`.
|
|
191
|
+
const idToOrder = new Map(pinnedIds.map((id, i) => [id, i] as const));
|
|
192
|
+
set({
|
|
193
|
+
tiles: before.map(t => {
|
|
194
|
+
const o = idToOrder.get(t.id);
|
|
195
|
+
return o == null ? t : { ...t, pinOrder: o };
|
|
196
|
+
}),
|
|
197
|
+
});
|
|
198
|
+
const r = await setOrder(pinnedIds);
|
|
199
|
+
if (r.ok) return { ok: true };
|
|
200
|
+
set({ tiles: before });
|
|
201
|
+
return { ok: false, error: r.error || `HTTP ${r.status}` };
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
applyServerUpdate: (artifact) => {
|
|
205
|
+
set({
|
|
206
|
+
tiles: get().tiles.map(t => (t.id === artifact.id ? { ...t, ...artifact } : t)),
|
|
207
|
+
});
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
applyServerRetire: (artifactId) => {
|
|
211
|
+
set({ tiles: get().tiles.filter(t => t.id !== artifactId) });
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
applyServerOrder: (pinnedIds) => {
|
|
215
|
+
const idToOrder = new Map(pinnedIds.map((id, i) => [id, i] as const));
|
|
216
|
+
set({
|
|
217
|
+
tiles: get().tiles.map(t => {
|
|
218
|
+
const o = idToOrder.get(t.id);
|
|
219
|
+
return o == null ? t : { ...t, pinOrder: o };
|
|
220
|
+
}),
|
|
221
|
+
});
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
track: (artifactId, event) => {
|
|
225
|
+
// Fire-and-forget. Surface tagged so backend analytics knows where the
|
|
226
|
+
// click happened (home carousel vs library, etc.).
|
|
227
|
+
recordEvent(artifactId, event, 'home');
|
|
228
|
+
},
|
|
229
|
+
}));
|
package/src/acs/ws.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Long-lived WebSocket subscription to the `acs.artifacts` and
|
|
3
|
+
* `acs.home-actions` topics. Protocol (backend websocket.js):
|
|
4
|
+
*
|
|
5
|
+
* client → server : { type: "subscribe", topic: "acs.artifacts" }
|
|
6
|
+
* server → client : { event: "subscribed", data: { topic: "acs.artifacts" } }
|
|
7
|
+
* server → client : { topic: "acs.artifacts", event: "updated", artifact: {...} }
|
|
8
|
+
* server → client : { topic: "acs.artifacts", event: "retired", artifactId: "..." }
|
|
9
|
+
* server → client : { topic: "acs.artifacts", event: "order", pinned: [...] }
|
|
10
|
+
*
|
|
11
|
+
* Events flow into the stores' applyServer* methods so we don't loop (those
|
|
12
|
+
* methods mutate local state only — no API calls).
|
|
13
|
+
*
|
|
14
|
+
* Lifecycle:
|
|
15
|
+
* connectAcsWs(token, tenant) starts a single connection + subscribe.
|
|
16
|
+
* On disconnect, exponential-backoff reconnect (capped). Call
|
|
17
|
+
* notifyAcsWsForeground() from the host's AppState listener to
|
|
18
|
+
* force-reconnect when the socket dropped while backgrounded (this module
|
|
19
|
+
* is host-agnostic — no react-native import — so the host owns AppState).
|
|
20
|
+
* On signout, disconnectAcsWs() closes the socket and stops reconnects.
|
|
21
|
+
*
|
|
22
|
+
* Backend's spec note: "WS is best-effort; the GET is the source of truth"
|
|
23
|
+
* — so widgetsStore.refresh() on foreground stays the safety net even when
|
|
24
|
+
* the WS is healthy. This file is the realtime cherry on top.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { getCoreWsUrl } from '../config';
|
|
28
|
+
import { useWidgetsStore } from './widgetsStore';
|
|
29
|
+
import { useHomeActionsStore } from './homeActionsStore';
|
|
30
|
+
|
|
31
|
+
let _ws: WebSocket | null = null;
|
|
32
|
+
let _wantOpen = false;
|
|
33
|
+
let _retryAttempt = 0;
|
|
34
|
+
let _retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
35
|
+
let _token: string | null = null;
|
|
36
|
+
let _tenant: string | null = null;
|
|
37
|
+
|
|
38
|
+
const TOPIC = 'acs.artifacts';
|
|
39
|
+
const TOPIC_HOME = 'acs.home-actions';
|
|
40
|
+
const MAX_BACKOFF_MS = 30_000;
|
|
41
|
+
|
|
42
|
+
function backoffMs(attempt: number): number {
|
|
43
|
+
// 0.5s, 1s, 2s, 4s, ... capped at 30s. Tiny jitter to avoid thundering herd.
|
|
44
|
+
const base = Math.min(MAX_BACKOFF_MS, 500 * Math.pow(2, attempt));
|
|
45
|
+
const jitter = Math.random() * 250;
|
|
46
|
+
return base + jitter;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function send(obj: any) {
|
|
50
|
+
if (_ws && _ws.readyState === 1) {
|
|
51
|
+
try { _ws.send(JSON.stringify(obj)); } catch (e) { console.warn('[acsWs] send failed', e); }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function handleEvent(msg: any) {
|
|
56
|
+
// The subscribe confirmation comes as { event: "subscribed", data: {...} }
|
|
57
|
+
// (no `topic` field). Real events come as { topic, event, ...payload }.
|
|
58
|
+
if (msg?.event === 'subscribed') {
|
|
59
|
+
console.log('[acsWs] subscribed to', msg?.data?.topic);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Home-actions feed events route to its own store.
|
|
64
|
+
if (msg?.topic === TOPIC_HOME) {
|
|
65
|
+
const home = useHomeActionsStore.getState();
|
|
66
|
+
switch (msg.event) {
|
|
67
|
+
case 'updated':
|
|
68
|
+
if (msg.action && typeof msg.action.actionId === 'string') home.applyServerUpsert(msg.action);
|
|
69
|
+
break;
|
|
70
|
+
case 'removed':
|
|
71
|
+
if (typeof msg.actionId === 'string') home.applyServerRemove(msg.actionId);
|
|
72
|
+
break;
|
|
73
|
+
case 'order':
|
|
74
|
+
if (Array.isArray(msg.order)) home.applyServerOrder(msg.order.filter((id: any) => typeof id === 'string'));
|
|
75
|
+
break;
|
|
76
|
+
case 'refreshed':
|
|
77
|
+
home.refresh();
|
|
78
|
+
break;
|
|
79
|
+
default:
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (msg?.topic !== TOPIC) return;
|
|
86
|
+
|
|
87
|
+
const store = useWidgetsStore.getState();
|
|
88
|
+
switch (msg.event) {
|
|
89
|
+
case 'updated':
|
|
90
|
+
if (msg.artifact && typeof msg.artifact.id === 'string') {
|
|
91
|
+
store.applyServerUpdate(msg.artifact);
|
|
92
|
+
}
|
|
93
|
+
break;
|
|
94
|
+
case 'retired':
|
|
95
|
+
if (typeof msg.artifactId === 'string') {
|
|
96
|
+
store.applyServerRetire(msg.artifactId);
|
|
97
|
+
}
|
|
98
|
+
break;
|
|
99
|
+
case 'order':
|
|
100
|
+
if (Array.isArray(msg.pinned)) {
|
|
101
|
+
store.applyServerOrder(msg.pinned.filter((id: any) => typeof id === 'string'));
|
|
102
|
+
}
|
|
103
|
+
break;
|
|
104
|
+
case 'created':
|
|
105
|
+
// Backend spec lists `created` as TODO. Until it ships, host foreground
|
|
106
|
+
// refresh catches new widgets. When it lands the WS will also fire
|
|
107
|
+
// `updated` for the new tile, which is already handled. For now, treat
|
|
108
|
+
// any inbound created the same as updated.
|
|
109
|
+
if (msg.artifact && typeof msg.artifact.id === 'string') {
|
|
110
|
+
store.applyServerUpdate(msg.artifact);
|
|
111
|
+
}
|
|
112
|
+
break;
|
|
113
|
+
default:
|
|
114
|
+
// Unknown event — ignore so we stay forward-compatible with new types.
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function openSocket() {
|
|
120
|
+
if (_ws) return;
|
|
121
|
+
if (!_token || !_tenant) return;
|
|
122
|
+
|
|
123
|
+
const url = `${getCoreWsUrl()}/ws?token=${encodeURIComponent(_token)}&tenant=${encodeURIComponent(_tenant)}`;
|
|
124
|
+
console.log(`[acsWs] opening`);
|
|
125
|
+
const ws = new WebSocket(url);
|
|
126
|
+
_ws = ws;
|
|
127
|
+
|
|
128
|
+
ws.onopen = () => {
|
|
129
|
+
_retryAttempt = 0;
|
|
130
|
+
send({ type: 'subscribe', topic: TOPIC });
|
|
131
|
+
send({ type: 'subscribe', topic: TOPIC_HOME });
|
|
132
|
+
};
|
|
133
|
+
ws.onmessage = (e) => {
|
|
134
|
+
try {
|
|
135
|
+
const msg = JSON.parse(typeof e.data === 'string' ? e.data : '');
|
|
136
|
+
handleEvent(msg);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
// Non-JSON or malformed frame — ignore, log for debugging.
|
|
139
|
+
console.warn('[acsWs] parse failed', err);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
ws.onerror = (e: any) => {
|
|
143
|
+
console.warn('[acsWs] error', e?.message || e);
|
|
144
|
+
};
|
|
145
|
+
ws.onclose = () => {
|
|
146
|
+
_ws = null;
|
|
147
|
+
if (!_wantOpen) return;
|
|
148
|
+
const delay = backoffMs(_retryAttempt++);
|
|
149
|
+
console.log(`[acsWs] closed; reconnecting in ${Math.round(delay)}ms (attempt ${_retryAttempt})`);
|
|
150
|
+
if (_retryTimer) clearTimeout(_retryTimer);
|
|
151
|
+
_retryTimer = setTimeout(openSocket, delay);
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Call from the host's AppState listener when the app returns to the
|
|
157
|
+
* foreground — if the socket dropped while backgrounded, reconnect
|
|
158
|
+
* immediately rather than waiting out the backoff timer.
|
|
159
|
+
*/
|
|
160
|
+
export function notifyAcsWsForeground() {
|
|
161
|
+
if (_wantOpen && !_ws) {
|
|
162
|
+
if (_retryTimer) { clearTimeout(_retryTimer); _retryTimer = null; }
|
|
163
|
+
_retryAttempt = 0;
|
|
164
|
+
openSocket();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Start the ACS WS subscription. Idempotent — calling twice with the same
|
|
170
|
+
* token/tenant is a no-op. Call once the host says the user is signed in
|
|
171
|
+
* (JWT available).
|
|
172
|
+
*/
|
|
173
|
+
export function connectAcsWs(token: string, tenant: string) {
|
|
174
|
+
if (_ws && _wantOpen && _token === token && _tenant === tenant) return;
|
|
175
|
+
// Token or tenant changed → tear down + reopen.
|
|
176
|
+
if (_ws) {
|
|
177
|
+
try { _ws.close(); } catch {}
|
|
178
|
+
_ws = null;
|
|
179
|
+
}
|
|
180
|
+
_token = token;
|
|
181
|
+
_tenant = tenant;
|
|
182
|
+
_wantOpen = true;
|
|
183
|
+
openSocket();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Stop the ACS WS subscription. Call on signout / env switch.
|
|
188
|
+
*/
|
|
189
|
+
export function disconnectAcsWs() {
|
|
190
|
+
_wantOpen = false;
|
|
191
|
+
_token = null;
|
|
192
|
+
_tenant = null;
|
|
193
|
+
if (_retryTimer) { clearTimeout(_retryTimer); _retryTimer = null; }
|
|
194
|
+
if (_ws) {
|
|
195
|
+
try { _ws.close(); } catch {}
|
|
196
|
+
_ws = null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { apiFetch } from './client';
|
|
2
|
+
import { getConsumerAuthApiUrl, getToken, getConfig, type AdasSession } from '../config';
|
|
3
|
+
import { useChatStore, closeChatStream } from '../chat/store';
|
|
4
|
+
|
|
5
|
+
export interface TenantMembership {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
role: string | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* List the tenants the CURRENT user belongs to (GET /api/tenants/list).
|
|
13
|
+
*
|
|
14
|
+
* The list is membership-scoped: for consumer JWTs the backend returns ONLY
|
|
15
|
+
* tenants where the user's login identity (google/apple oauth or embed id)
|
|
16
|
+
* has an active actor; builder JWTs get their user-doc memberships. It never
|
|
17
|
+
* leaks the full tenant list. Returns [] when signed out or unknown.
|
|
18
|
+
*/
|
|
19
|
+
export async function listMyTenants(): Promise<TenantMembership[]> {
|
|
20
|
+
const data = await apiFetch<{ ok: boolean; tenants?: TenantMembership[] }>('/api/tenants/list');
|
|
21
|
+
return data.ok && Array.isArray(data.tenants) ? data.tenants : [];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Re-scope the whole session to another tenant the user already belongs to.
|
|
26
|
+
*
|
|
27
|
+
* The backend treats the JWT tenant claim as the sole source of truth, so
|
|
28
|
+
* switching = minting a fresh JWT for the target tenant's actor. The SDK
|
|
29
|
+
* owns the call (POST consumer-auth /platform/consumer/switch-tenant, authed
|
|
30
|
+
* by the current token); the HOST persists the new session via
|
|
31
|
+
* `config.onSession` (the SDK never stores credentials). The chat stream is
|
|
32
|
+
* then torn down and cleared so it reconnects against the new tenant.
|
|
33
|
+
*
|
|
34
|
+
* Requires `consumerAuthUrl` and `onSession` in the initAdas() config.
|
|
35
|
+
* Throws on any non-OK response (e.g. not a member of the target tenant).
|
|
36
|
+
*/
|
|
37
|
+
export async function switchTenant(targetTenantId: string): Promise<AdasSession> {
|
|
38
|
+
const token = getToken();
|
|
39
|
+
if (!token) throw new Error('[adas] switchTenant: not signed in');
|
|
40
|
+
|
|
41
|
+
const res = await fetch(`${getConsumerAuthApiUrl()}/platform/consumer/switch-tenant`, {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
headers: {
|
|
44
|
+
'Content-Type': 'application/json',
|
|
45
|
+
Authorization: `Bearer ${token}`,
|
|
46
|
+
},
|
|
47
|
+
body: JSON.stringify({ target_tenant: targetTenantId }),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const data = await res.json().catch(() => null);
|
|
51
|
+
if (!res.ok || !data?.ok || !data.jwt) {
|
|
52
|
+
throw new Error(data?.error || `switchTenant failed (${res.status})`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const session: AdasSession = {
|
|
56
|
+
jwt: data.jwt,
|
|
57
|
+
tenant: data.tenant,
|
|
58
|
+
actor: data.actor,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Host persists the new token + active tenant BEFORE we reconnect, so the
|
|
62
|
+
// fresh stream/requests pick up the target-tenant credentials.
|
|
63
|
+
const onSession = getConfig().onSession;
|
|
64
|
+
if (!onSession) {
|
|
65
|
+
throw new Error('[adas] switchTenant: config.onSession is required to persist the new session');
|
|
66
|
+
}
|
|
67
|
+
await onSession(session);
|
|
68
|
+
|
|
69
|
+
// Tear down the old-tenant stream and history; the chat screen reloads
|
|
70
|
+
// against the new tenant on its next mount / loadConversation().
|
|
71
|
+
closeChatStream();
|
|
72
|
+
useChatStore.getState().clearChat();
|
|
73
|
+
|
|
74
|
+
return session;
|
|
75
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -41,17 +41,32 @@ export interface AdasUser {
|
|
|
41
41
|
actorId: string;
|
|
42
42
|
name?: string;
|
|
43
43
|
email?: string;
|
|
44
|
+
/** Roles from the host's identity (e.g. JWT claims) — forwarded to plugins
|
|
45
|
+
* as PluginContext.user.roles. */
|
|
46
|
+
roles?: string[];
|
|
44
47
|
}
|
|
45
48
|
|
|
49
|
+
/** A URL that is either static or resolved on each read. Hosts with a
|
|
50
|
+
* runtime env switcher (dev/prod) pass a getter so the SDK always sees the
|
|
51
|
+
* currently-selected backend without re-calling initAdas(). */
|
|
52
|
+
export type UrlSource = string | (() => string);
|
|
53
|
+
|
|
46
54
|
export interface AdasConfig {
|
|
47
|
-
/** Core API base URL, e.g. https://app.ateam-ai.com
|
|
48
|
-
|
|
55
|
+
/** Core API base URL, e.g. https://app.ateam-ai.com. Pass a getter to
|
|
56
|
+
* support a runtime env switch without re-initializing. */
|
|
57
|
+
coreUrl: UrlSource;
|
|
49
58
|
/** Voice service base URL (optional — only voice features need it). */
|
|
50
|
-
voiceUrl?:
|
|
59
|
+
voiceUrl?: UrlSource;
|
|
60
|
+
/** Consumer-auth base URL, e.g. https://consumer-auth.ateam-ai.com. Only
|
|
61
|
+
* needed for `switchTenant()` (re-scoping the session to another tenant
|
|
62
|
+
* the user belongs to). */
|
|
63
|
+
consumerAuthUrl?: UrlSource;
|
|
51
64
|
tokenProvider: TokenProvider;
|
|
52
|
-
/** Tenant slug —
|
|
53
|
-
* tenant from the JWT
|
|
54
|
-
tenant
|
|
65
|
+
/** Tenant slug — used for plugin URL resolution and voice, never sent as a
|
|
66
|
+
* header (the backend derives the tenant from the JWT). Pass a getter when
|
|
67
|
+
* the host resolves the tenant after startup (storage hydration, runtime
|
|
68
|
+
* tenant switch). */
|
|
69
|
+
tenant?: string | (() => string);
|
|
55
70
|
/** Current end user as known to the host. actorId is used as the chat
|
|
56
71
|
* reply context; without it, replies route to 'unknown'. */
|
|
57
72
|
getUser?: () => AdasUser | null;
|
|
@@ -59,6 +74,18 @@ export interface AdasConfig {
|
|
|
59
74
|
* survive app restarts. No-op when omitted (previews live in memory for
|
|
60
75
|
* the session only). @ateam-ai-mobile/ui ships an Expo/RN implementation. */
|
|
61
76
|
attachmentCache?: AttachmentCache;
|
|
77
|
+
/** Called by `switchTenant()` with the freshly-minted session for the
|
|
78
|
+
* target tenant. The host MUST persist it here (store the new JWT so
|
|
79
|
+
* tokenProvider.getToken() returns it, and update the active tenant) —
|
|
80
|
+
* the SDK never stores credentials. Awaited before the chat reconnects. */
|
|
81
|
+
onSession?: (session: AdasSession) => void | Promise<void>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A tenant-scoped session minted by the platform (e.g. via switchTenant). */
|
|
85
|
+
export interface AdasSession {
|
|
86
|
+
jwt: string;
|
|
87
|
+
tenant: { id: string; name: string };
|
|
88
|
+
actor: { id: string; email: string | null; roles: string[] };
|
|
62
89
|
}
|
|
63
90
|
|
|
64
91
|
let _config: AdasConfig | null = null;
|
|
@@ -81,8 +108,12 @@ export function getConfig(): AdasConfig {
|
|
|
81
108
|
return _config;
|
|
82
109
|
}
|
|
83
110
|
|
|
111
|
+
function resolveUrl(src: UrlSource): string {
|
|
112
|
+
return (typeof src === 'function' ? src() : src).replace(/\/$/, '');
|
|
113
|
+
}
|
|
114
|
+
|
|
84
115
|
export function getCoreApiUrl(): string {
|
|
85
|
-
return getConfig().coreUrl
|
|
116
|
+
return resolveUrl(getConfig().coreUrl);
|
|
86
117
|
}
|
|
87
118
|
|
|
88
119
|
export function getCoreWsUrl(): string {
|
|
@@ -92,11 +123,18 @@ export function getCoreWsUrl(): string {
|
|
|
92
123
|
export function getVoiceApiUrl(): string {
|
|
93
124
|
const url = getConfig().voiceUrl;
|
|
94
125
|
if (!url) throw new Error('[adas] voiceUrl not configured');
|
|
95
|
-
return url
|
|
126
|
+
return resolveUrl(url);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function getConsumerAuthApiUrl(): string {
|
|
130
|
+
const url = getConfig().consumerAuthUrl;
|
|
131
|
+
if (!url) throw new Error('[adas] consumerAuthUrl not configured — required for switchTenant()');
|
|
132
|
+
return resolveUrl(url);
|
|
96
133
|
}
|
|
97
134
|
|
|
98
135
|
export function getTenant(): string {
|
|
99
|
-
|
|
136
|
+
const t = getConfig().tenant;
|
|
137
|
+
return (typeof t === 'function' ? t() : t) || '';
|
|
100
138
|
}
|
|
101
139
|
|
|
102
140
|
export function getUser(): AdasUser | null {
|
package/src/index.ts
CHANGED
|
@@ -23,11 +23,12 @@ export {
|
|
|
23
23
|
getCoreApiUrl,
|
|
24
24
|
getCoreWsUrl,
|
|
25
25
|
getVoiceApiUrl,
|
|
26
|
+
getConsumerAuthApiUrl,
|
|
26
27
|
getTenant,
|
|
27
28
|
getUser,
|
|
28
29
|
getToken,
|
|
29
30
|
} from './config';
|
|
30
|
-
export type { AdasConfig, TokenProvider, RefreshOutcome, AdasUser } from './config';
|
|
31
|
+
export type { AdasConfig, TokenProvider, RefreshOutcome, AdasUser, AdasSession } from './config';
|
|
31
32
|
|
|
32
33
|
// API client
|
|
33
34
|
export { apiFetch, apiFetchRaw } from './api/client';
|
|
@@ -41,6 +42,10 @@ export {
|
|
|
41
42
|
unwrapMcpResult,
|
|
42
43
|
} from './api/mcp';
|
|
43
44
|
|
|
45
|
+
// Tenant memberships
|
|
46
|
+
export { listMyTenants, switchTenant } from './api/tenants';
|
|
47
|
+
export type { TenantMembership } from './api/tenants';
|
|
48
|
+
|
|
44
49
|
// Chat
|
|
45
50
|
export {
|
|
46
51
|
useChatStore,
|
|
@@ -70,3 +75,36 @@ export type { BufferedCommand } from './utils/emitter';
|
|
|
70
75
|
|
|
71
76
|
// Theme tokens
|
|
72
77
|
export { colors, ada, spacing, fontSize, borderRadius } from './theme/tokens';
|
|
78
|
+
|
|
79
|
+
// ACS (home widgets + top-actions feed)
|
|
80
|
+
export {
|
|
81
|
+
listSurface,
|
|
82
|
+
listMadeForYou,
|
|
83
|
+
deleteArtifact,
|
|
84
|
+
patchArtifact,
|
|
85
|
+
setOrder,
|
|
86
|
+
recordEvent,
|
|
87
|
+
listHomeActions,
|
|
88
|
+
patchHomeAction,
|
|
89
|
+
setHomeActionOrder,
|
|
90
|
+
} from './acs/api';
|
|
91
|
+
export type {
|
|
92
|
+
AcsArtifact,
|
|
93
|
+
AcsSurface,
|
|
94
|
+
AcsEvent,
|
|
95
|
+
AcsDeleteReason,
|
|
96
|
+
AcsPatchReason,
|
|
97
|
+
DeleteResult,
|
|
98
|
+
PatchResult,
|
|
99
|
+
HomeAction,
|
|
100
|
+
HomeActionLeaf,
|
|
101
|
+
HomeActionReason,
|
|
102
|
+
} from './acs/api';
|
|
103
|
+
export { useWidgetsStore } from './acs/widgetsStore';
|
|
104
|
+
export { useHomeActionsStore } from './acs/homeActionsStore';
|
|
105
|
+
export { connectAcsWs, disconnectAcsWs, notifyAcsWsForeground } from './acs/ws';
|
|
106
|
+
export {
|
|
107
|
+
setAcsReachabilityReporter,
|
|
108
|
+
setAcsCacheStorage,
|
|
109
|
+
} from './acs/providers';
|
|
110
|
+
export type { AcsReachabilityReporter, AcsKvStorage } from './acs/providers';
|