@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/src/gate.ts ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * SDK-owned app-gate cascade (R9).
3
+ *
4
+ * The host's root layout used to inline the "which screen do we show right now"
5
+ * decision — a linear cascade (loading → onboarding → auth → tenant →
6
+ * capabilities → app) with a pre-auth PRIORITY (a web→app handoff beats an
7
+ * invite, which beats the default login). That decision is generic navigation
8
+ * orchestration: every host makes the same one. R9 moves the DECISION here as a
9
+ * pure function; the host keeps the presentation (its branded phase screens) and
10
+ * the Expo Router `<Stack>` that owns route registration.
11
+ *
12
+ * No react-native import — a pure function of the flags the host already tracks
13
+ * (auth comes from the SDK session; onboarding/capabilities/invite/handoff are
14
+ * host state), so it is trivially unit-testable and host-agnostic.
15
+ */
16
+ export type AdasPhase =
17
+ | 'loading'
18
+ | 'onboarding'
19
+ | 'handoff'
20
+ | 'invite'
21
+ | 'login'
22
+ | 'tenant'
23
+ | 'capabilities'
24
+ | 'app';
25
+
26
+ export interface AdasGateFlags {
27
+ /** Host config/service-URL hydration finished. */
28
+ configHydrated: boolean;
29
+ /** The SDK session is still restoring (status === 'loading'). */
30
+ authLoading: boolean;
31
+ /** First-launch onboarding: true = done, false = show it, undefined = still checking. */
32
+ onboarded: boolean | undefined;
33
+ /** The SDK session is authenticated. */
34
+ isAuthenticated: boolean;
35
+ /** Host capability init still running. */
36
+ capabilitiesLoading: boolean;
37
+ /** A pending web→app handoff (one-time code → session) — the strongest pre-auth signal. */
38
+ handoffPending: boolean;
39
+ /** A pending invite (tenant join → OAuth). */
40
+ invitePending: boolean;
41
+ /** Whether the host requires an explicit tenant pick before entering the app
42
+ * (multi-tenant). A single-tenant host passes false and skips the phase. */
43
+ requireTenantPick: boolean;
44
+ /** The user has confirmed/selected a tenant (auto-true in single-tenant). */
45
+ tenantConfirmed: boolean;
46
+ /** Capability onboarding done. */
47
+ hasCompletedSetup: boolean;
48
+ }
49
+
50
+ /**
51
+ * Resolve the single screen phase to render, in the exact order the host used:
52
+ * a still-hydrating app shows `loading`; then first-launch `onboarding`; then,
53
+ * if signed out, the pre-auth priority handoff > invite > login; then (only when
54
+ * the host requires it) the tenant pick; then capability setup; then the app.
55
+ */
56
+ export function resolveAdasPhase(f: AdasGateFlags): AdasPhase {
57
+ if (!f.configHydrated || f.authLoading || f.capabilitiesLoading || f.onboarded === undefined) {
58
+ return 'loading';
59
+ }
60
+ if (!f.onboarded) return 'onboarding';
61
+ if (!f.isAuthenticated) {
62
+ return f.handoffPending ? 'handoff' : f.invitePending ? 'invite' : 'login';
63
+ }
64
+ if (f.requireTenantPick && !f.tenantConfirmed) return 'tenant';
65
+ if (!f.hasCompletedSetup) return 'capabilities';
66
+ return 'app';
67
+ }
package/src/index.ts CHANGED
@@ -46,11 +46,77 @@ export {
46
46
  // Tenant memberships
47
47
  export { getSession, isDevHost } from './session';
48
48
  export type { Session, SessionEnv } from './session';
49
+
50
+ // SDK-owned session state machine (R6) — JWT lifecycle, refresh table, restore,
51
+ // logout orchestration. The host provides secure storage + a logout teardown hook.
52
+ export { createAuthSession, userFromJwt } from './authSession';
53
+ export type {
54
+ AuthSession,
55
+ AuthSessionState,
56
+ AuthSessionDeps,
57
+ SessionPersistence,
58
+ SessionUser,
59
+ SessionStatus,
60
+ } from './authSession';
61
+
62
+ // Push registration lifecycle + app-foreground coordinator (R7) — the host
63
+ // supplies the native token acquisition + the backend calls; the SDK owns the
64
+ // register/re-register/unregister lifecycle and the foreground reaction order.
65
+ export { createPushRegistration } from './pushRegistration';
66
+ export type { PushRegistration, PushBackend } from './pushRegistration';
67
+ export { onAppForeground, notifyAppForeground } from './lifecycle';
68
+
69
+ // App-gate cascade (R9) — the pure "which phase to render" decision the host's
70
+ // root layout consumes; the host keeps the branded phase screens + the router.
71
+ export { resolveAdasPhase } from './gate';
72
+ export type { AdasPhase, AdasGateFlags } from './gate';
73
+
74
+ // Consumer-auth flow (R9) — config fetch + provider/handoff/invite/open-join
75
+ // exchanges + channel OTP. The host acquires the native id_token; this trades it
76
+ // for a session JWT. Reads only the SDK config getters.
77
+ export {
78
+ fetchConsumerConfig,
79
+ signInWithGoogle,
80
+ signInWithApple,
81
+ exchangeHandoff,
82
+ previewInvite,
83
+ joinWithGoogle,
84
+ joinWithApple,
85
+ openJoinWithGoogle,
86
+ openJoinWithApple,
87
+ linkChannelStart,
88
+ linkChannelVerify,
89
+ } from './consumerAuth';
90
+ export type {
91
+ ConsumerProviderConfig,
92
+ ConsumerCapabilityConfig,
93
+ ConsumerSignInResult,
94
+ OtpStartResult,
95
+ HandoffExchangeResult,
96
+ InvitePreview,
97
+ } from './consumerAuth';
98
+
99
+ // Deep-link code parsing (R9) — generic invite/handoff extraction; the URL
100
+ // scheme stays host-side.
101
+ export { parseDeepLinkParam, parseInviteCode, parseHandoffCode } from './deepLinks';
102
+
103
+ // Workspace subscription state (server-resolved; GET /api/session)
104
+ export {
105
+ useWorkspaceStore,
106
+ capabilityEnabled,
107
+ useCapability,
108
+ } from './workspace';
109
+ export type {
110
+ WorkspaceSnapshot,
111
+ WorkspaceState,
112
+ WorkspaceCapabilities,
113
+ } from './workspace';
49
114
  export { listMyTenants, switchTenant } from './api/tenants';
50
115
  export type { TenantMembership } from './api/tenants';
51
116
 
52
117
  // Chat
53
118
  export {
119
+ setClientDeclarationProvider,
54
120
  useChatStore,
55
121
  closeChatStream,
56
122
  blocksToText,
@@ -0,0 +1,43 @@
1
+ /**
2
+ * SDK-owned app-lifecycle coordinator (R7).
3
+ *
4
+ * The OS foreground/background EVENT is a native concern — the host owns the one
5
+ * `AppState` listener (core cannot import react-native). But the REACTIONS to it
6
+ * were scattered across three separate app listeners (ACS WS reconnect, a
7
+ * battery refresh, an ACS GET safety-net), each firing independently on
8
+ * `active`. R7 consolidates the reactions here: the host pokes `notifyAppForeground()`
9
+ * from its single listener, and the SDK runs them in a deliberate order —
10
+ * reconnect the ACS socket FIRST (so a resumed session is live before the GET
11
+ * safety-net runs), then every registered host callback.
12
+ *
13
+ * No react-native import — the same seam works for any host.
14
+ */
15
+ import { notifyAcsWsForeground } from './acs/ws';
16
+
17
+ const foregroundCbs = new Set<() => void>();
18
+
19
+ /**
20
+ * Register a reaction to run when the app returns to the foreground (e.g. the
21
+ * host's battery refresh, or an ACS GET safety-net). Returns an unsubscribe.
22
+ */
23
+ export function onAppForeground(cb: () => void): () => void {
24
+ foregroundCbs.add(cb);
25
+ return () => { foregroundCbs.delete(cb); };
26
+ }
27
+
28
+ /**
29
+ * The host calls this from its single AppState listener when the app becomes
30
+ * `active`. Order is owned here: force the ACS WS to reconnect first, then run
31
+ * the registered host reactions. Each step is guarded so one failure can't stop
32
+ * the rest.
33
+ */
34
+ export function notifyAppForeground(): void {
35
+ try { notifyAcsWsForeground(); } catch (e: any) {
36
+ console.warn('[adas/lifecycle] acs foreground reconnect failed:', e?.message || e);
37
+ }
38
+ for (const cb of foregroundCbs) {
39
+ try { cb(); } catch (e: any) {
40
+ console.warn('[adas/lifecycle] foreground callback failed:', e?.message || e);
41
+ }
42
+ }
43
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * SDK-owned push registration lifecycle (R7).
3
+ *
4
+ * R6 left the sign-out push unregister as a temporary APP hook (authStore's
5
+ * teardown dynamically imported getCurrentPushToken + unregisterDevice). R7
6
+ * finishes it with an SDK-owned service: the token→backend LIFECYCLE lives here
7
+ * (register when a token is acquired, re-register when it refreshes, unregister
8
+ * on sign-out, remember the current token), and the host supplies only the two
9
+ * genuinely native/host pieces via an injected backend:
10
+ * - registerDevice / unregisterDevice (the Core notif-api calls)
11
+ * The host's native layer (FCM/APNs acquisition, permission prompts, token
12
+ * refresh listeners) feeds tokens in via setToken(); it does not own the
13
+ * backend lifecycle any more.
14
+ *
15
+ * No Expo/RN import here — the same seam works for any host.
16
+ */
17
+ export interface PushBackend {
18
+ /** Register a device push token with the backend (needs a live JWT). */
19
+ registerDevice(token: string): Promise<void>;
20
+ /** Unregister a device push token from the backend. */
21
+ unregisterDevice(token: string): Promise<void>;
22
+ }
23
+
24
+ export interface PushRegistration {
25
+ /**
26
+ * Adopt a freshly-acquired native push token and register it with the backend.
27
+ * Idempotent — re-registering the SAME token is a no-op; a NEW token (refresh)
28
+ * registers the new one. Pass null to forget without a backend call.
29
+ * Best-effort: a backend failure is logged, never thrown (push is not
30
+ * allowed to break sign-in).
31
+ */
32
+ setToken(token: string | null): Promise<void>;
33
+ /** The current registered token, or null. */
34
+ getToken(): string | null;
35
+ /**
36
+ * Sign-out: unregister the current token from the backend and forget it.
37
+ * Fire-and-forget on the network call so a flaky connection can't BLOCK
38
+ * sign-out (the ordering contract: this runs while the JWT still exists, then
39
+ * the session clears the JWT). Returns once the token is forgotten locally.
40
+ */
41
+ unregister(): Promise<void>;
42
+ }
43
+
44
+ export function createPushRegistration(backend: PushBackend): PushRegistration {
45
+ let token: string | null = null;
46
+
47
+ return {
48
+ getToken: () => token,
49
+
50
+ async setToken(next) {
51
+ if (!next) { token = null; return; }
52
+ if (next === token) return; // already registered this token
53
+ token = next;
54
+ try {
55
+ await backend.registerDevice(next);
56
+ } catch (e: any) {
57
+ console.warn('[adas/push] registerDevice failed:', e?.message || e);
58
+ }
59
+ },
60
+
61
+ async unregister() {
62
+ const t = token;
63
+ token = null; // forget locally immediately — a slow network must not block sign-out
64
+ if (t) {
65
+ backend.unregisterDevice(t).catch((e: any) =>
66
+ console.warn('[adas/push] unregisterDevice failed:', e?.message || e),
67
+ );
68
+ }
69
+ },
70
+ };
71
+ }
@@ -0,0 +1,127 @@
1
+ // packages/core/src/workspace.ts
2
+ //
3
+ // Workspace subscription state — resolved ENTIRELY by the server and surfaced,
4
+ // never interpreted by the client. The app holds NO subscription rules (no trial
5
+ // dates, plan names, or close-code meaning); it renders what the server says.
6
+ // That is the whole point of the design: a new plan needs no mobile release.
7
+ //
8
+ // Source: GET /api/session (authenticated, below attachActor). It answers even
9
+ // when the realtime socket is refused (an expired tenant gets WS close 4003), so
10
+ // it — not a WS frame — is the startup carrier for this state. A future ACS WS
11
+ // delta push will update it in place; that path is not built yet, so no
12
+ // push-apply API is exposed until the delta contract exists (avoids freezing an
13
+ // unused contract early).
14
+
15
+ import { create } from 'zustand';
16
+ import { apiFetch } from './api/client';
17
+
18
+ export type WorkspaceState = 'active' | 'expired' | 'suspended' | 'unknown';
19
+
20
+ /** Server-granted capabilities. Open-ended on purpose: a plan can introduce a
21
+ * new capability name and old app versions simply won't grant it. */
22
+ export interface WorkspaceCapabilities {
23
+ chat?: boolean;
24
+ live_updates?: boolean;
25
+ ui_commands?: boolean;
26
+ [capability: string]: boolean | undefined;
27
+ }
28
+
29
+ export interface WorkspaceSnapshot {
30
+ state: WorkspaceState;
31
+ reason: string | null;
32
+ trialEndsAt: string | null;
33
+ expiresInDays: number | null;
34
+ capabilities: WorkspaceCapabilities;
35
+ /** Server-authored copy. Rendered VERBATIM — the client never composes it. */
36
+ message: string | null;
37
+ }
38
+
39
+ /** GET /api/session response envelope (only `workspace` is read here). */
40
+ interface SessionResponse {
41
+ ok?: boolean;
42
+ actor?: unknown;
43
+ tenant?: string;
44
+ workspace?: Partial<WorkspaceSnapshot> | null;
45
+ }
46
+
47
+ function normalize(w: Partial<WorkspaceSnapshot> | null | undefined): WorkspaceSnapshot | null {
48
+ if (!w || typeof w !== 'object') return null;
49
+ const state: WorkspaceState =
50
+ w.state === 'active' || w.state === 'expired' || w.state === 'suspended' ? w.state : 'unknown';
51
+ return {
52
+ state,
53
+ reason: typeof w.reason === 'string' ? w.reason : null,
54
+ trialEndsAt: typeof w.trialEndsAt === 'string' ? w.trialEndsAt : null,
55
+ expiresInDays: typeof w.expiresInDays === 'number' ? w.expiresInDays : null,
56
+ capabilities:
57
+ w.capabilities && typeof w.capabilities === 'object' ? (w.capabilities as WorkspaceCapabilities) : {},
58
+ message: typeof w.message === 'string' && w.message.trim() ? w.message : null,
59
+ };
60
+ }
61
+
62
+ // Monotonic identity generation. Bumped whenever the workspace IDENTITY changes
63
+ // — clear() on tenant switch / signout. A refresh() captures the generation at
64
+ // dispatch and applies its result only if the generation has not moved on, so a
65
+ // slow /api/session response scoped to tenant A can never land on (and mislabel)
66
+ // tenant B after a switch. Without this, a late A response — success OR the
67
+ // snapshot-preserving failure path — could leak A's banner/capabilities into B.
68
+ let _gen = 0;
69
+
70
+ interface WorkspaceStore {
71
+ /** null = not loaded / endpoint absent — treat the app as fully capable. */
72
+ snapshot: WorkspaceSnapshot | null;
73
+ loaded: boolean;
74
+ /** Fetch GET /api/session and store the workspace snapshot (identity-guarded). */
75
+ refresh: () => Promise<void>;
76
+ /** Reset AND invalidate any in-flight refresh. Call on signout / tenant switch
77
+ * (the host must clear before refreshing a new identity). */
78
+ clear: () => void;
79
+ }
80
+
81
+ export const useWorkspaceStore = create<WorkspaceStore>((set) => ({
82
+ snapshot: null,
83
+ loaded: false,
84
+ refresh: async () => {
85
+ const gen = _gen; // this refresh belongs to the CURRENT identity
86
+ try {
87
+ const res = await apiFetch<SessionResponse>('/api/session');
88
+ if (gen !== _gen) return; // superseded by a tenant switch / signout — drop the stale response
89
+ set({ snapshot: normalize(res?.workspace), loaded: true });
90
+ } catch {
91
+ // Superseded → do not touch the new identity's state.
92
+ if (gen !== _gen) return;
93
+ // Same identity, transient failure (or pre-promotion 404): DO NOT grey
94
+ // anything out. Keep the last-known snapshot (null on first load), so the
95
+ // app stays fully capable and the banner only ever shows from a real
96
+ // snapshot, never from the absence of one.
97
+ set({ loaded: true });
98
+ }
99
+ },
100
+ clear: () => {
101
+ _gen++; // invalidate any in-flight refresh from the previous identity
102
+ set({ snapshot: null, loaded: false });
103
+ },
104
+ }));
105
+
106
+ /**
107
+ * Is a capability granted? The SERVER decides; the client never infers it from
108
+ * a local rule. Two deliberate, asymmetric defaults:
109
+ * - No snapshot loaded (endpoint absent, or not fetched yet) → GRANTED, so the
110
+ * app behaves exactly as it did before this layer existed.
111
+ * - Snapshot loaded but this capability missing/unknown → NOT granted, so a new
112
+ * plan can restrict an old app version and an unrecognised name never leaks
113
+ * access.
114
+ */
115
+ export function capabilityEnabled(name: string): boolean {
116
+ const { snapshot } = useWorkspaceStore.getState();
117
+ if (!snapshot) return true;
118
+ return snapshot.capabilities?.[name] === true;
119
+ }
120
+
121
+ /** Reactive form of capabilityEnabled for components (re-renders on change). */
122
+ export function useCapability(name: string): boolean {
123
+ return useWorkspaceStore((s) => {
124
+ if (!s.snapshot) return true;
125
+ return s.snapshot.capabilities?.[name] === true;
126
+ });
127
+ }