@oxyhq/core 9.2.0 → 9.2.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.
Files changed (40) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/boot/sessionColdBoot.js +13 -0
  3. package/dist/cjs/index.js +6 -4
  4. package/dist/cjs/mixins/OxyServices.accounts.js +3 -0
  5. package/dist/cjs/mixins/OxyServices.utility.js +9 -5
  6. package/dist/cjs/session/SessionClient.js +45 -0
  7. package/dist/cjs/session/accountDialogController.js +31 -0
  8. package/dist/cjs/session/authStateStore.js +196 -16
  9. package/dist/esm/.tsbuildinfo +1 -1
  10. package/dist/esm/boot/sessionColdBoot.js +13 -0
  11. package/dist/esm/index.js +1 -0
  12. package/dist/esm/mixins/OxyServices.accounts.js +1 -0
  13. package/dist/esm/mixins/OxyServices.utility.js +9 -5
  14. package/dist/esm/session/SessionClient.js +45 -0
  15. package/dist/esm/session/accountDialogController.js +31 -0
  16. package/dist/esm/session/authStateStore.js +195 -15
  17. package/dist/types/.tsbuildinfo +1 -1
  18. package/dist/types/index.d.ts +2 -1
  19. package/dist/types/mixins/OxyServices.accounts.d.ts +8 -0
  20. package/dist/types/mixins/OxyServices.auth.d.ts +1 -0
  21. package/dist/types/models/interfaces.d.ts +3 -1
  22. package/dist/types/models/session.d.ts +6 -0
  23. package/dist/types/session/SessionClient.d.ts +11 -0
  24. package/dist/types/session/accountDialogController.d.ts +17 -0
  25. package/dist/types/session/authStateStore.d.ts +33 -8
  26. package/package.json +2 -2
  27. package/src/boot/__tests__/sessionColdBoot.test.ts +20 -0
  28. package/src/boot/sessionColdBoot.ts +13 -0
  29. package/src/index.ts +3 -0
  30. package/src/mixins/OxyServices.accounts.ts +9 -0
  31. package/src/mixins/OxyServices.auth.ts +2 -0
  32. package/src/mixins/OxyServices.utility.ts +10 -9
  33. package/src/models/interfaces.ts +3 -1
  34. package/src/models/session.ts +6 -0
  35. package/src/session/SessionClient.ts +44 -0
  36. package/src/session/__tests__/SessionClient.serverEvents.test.ts +71 -0
  37. package/src/session/__tests__/accountDialogController.test.ts +93 -1
  38. package/src/session/__tests__/authStateStore.test.ts +170 -0
  39. package/src/session/accountDialogController.ts +54 -1
  40. package/src/session/authStateStore.ts +219 -15
@@ -34,7 +34,8 @@ export type { CanonicalUserHandleInput, UserHandleInput } from './utils/userHand
34
34
  export { normalizeProfileLinks } from './utils/profileLinks';
35
35
  export type { ProfileLink, ProfileLinkMetadata } from './utils/profileLinks';
36
36
  export type { PublicApplication, ConnectedApp, } from './mixins/OxyServices.connectedApps';
37
- export type { AccountKind, AccountRelationship, AccountRole, AccountMemberStatus, AccountMemberSource, AccountMember, AccountNode, AccountCredentialType, AccountCredentialEnvironment, AccountCredentialStatus, AccountCredential, AccountCredentialWithSecret, RotateAccountCredentialResult, ListAccountsOptions, CreateAccountInput, UpdateAccountInput, InviteAccountMemberInput, UpdateAccountMemberInput, TransferAccountOwnershipInput, CreateAccountCredentialInput, AccountSuccessResult, SwitchAccountResult, Application, ApplicationType, ApplicationStatus, ApplicationCredential, ApplicationCredentialType, ApplicationCredentialStatus, ApplicationEnvironment, CreateApplicationInput, UpdateApplicationInput, CreateApplicationCredentialInput, ApplicationCredentialWithSecret, RotateApplicationCredentialResult, ApplicationUsagePeriod, ApplicationUsageSummary, ApplicationUsageByDay, ApplicationUsageByEndpoint, ApplicationUsageStats, } from './mixins/OxyServices.accounts';
37
+ export type { AccountKind, OrganizationCategory, AccountRelationship, AccountRole, AccountMemberStatus, AccountMemberSource, AccountMember, AccountNode, AccountCredentialType, AccountCredentialEnvironment, AccountCredentialStatus, AccountCredential, AccountCredentialWithSecret, RotateAccountCredentialResult, ListAccountsOptions, CreateAccountInput, UpdateAccountInput, InviteAccountMemberInput, UpdateAccountMemberInput, TransferAccountOwnershipInput, CreateAccountCredentialInput, AccountSuccessResult, SwitchAccountResult, Application, ApplicationType, ApplicationStatus, ApplicationCredential, ApplicationCredentialType, ApplicationCredentialStatus, ApplicationEnvironment, CreateApplicationInput, UpdateApplicationInput, CreateApplicationCredentialInput, ApplicationCredentialWithSecret, RotateApplicationCredentialResult, ApplicationUsagePeriod, ApplicationUsageSummary, ApplicationUsageByDay, ApplicationUsageByEndpoint, ApplicationUsageStats, } from './mixins/OxyServices.accounts';
38
+ export { ORGANIZATION_CATEGORIES } from './mixins/OxyServices.accounts';
38
39
  export type { ReputationCategory, TrustTier, ReputationTransactionStatus, ReputationTargetEntityType, ReputationDisputeStatus, ReputationInfluenceContext, ReputationTransaction, ReputationBalanceBreakdown, ReputationInfluence, ReputationReliability, ReputationBalance, ReputationDispute, ReputationRule, ReputationLeaderboardEntry, ReputationInfluenceResult, ReverseReputationTransactionResult, AwardReputationInput, CreateReputationDisputeInput, ResolveReputationDisputeInput, UpsertReputationRuleInput, ReverseReputationTransactionInput, } from './mixins/OxyServices.reputation';
39
40
  export { buildUserDid } from './mixins/OxyServices.identity';
40
41
  export type { IdentityRecordType, UnlinkableAuthMethodType, LinkAuthMethodResult, PublishRecordResult, VerifyRecordResult, VerifyDomainResult, RemoveDomainResult, } from './mixins/OxyServices.identity';
@@ -33,6 +33,7 @@
33
33
  * registers the switched session into the operator's device-set directly).
34
34
  */
35
35
  import type { User } from '../models/interfaces';
36
+ import type { OrganizationCategory } from '@oxyhq/contracts';
36
37
  import type { SessionLoginResponse } from '../models/session';
37
38
  import type { OxyServicesBase } from '../OxyServices.base';
38
39
  /**
@@ -42,6 +43,9 @@ import type { OxyServicesBase } from '../OxyServices.base';
42
43
  * and have no direct login.
43
44
  */
44
45
  export type AccountKind = 'personal' | 'organization' | 'project' | 'bot';
46
+ /** Real-estate / team taxonomy for `kind: 'organization'` accounts. */
47
+ export type { OrganizationCategory } from '@oxyhq/contracts';
48
+ export { ORGANIZATION_CATEGORIES } from '@oxyhq/contracts';
45
49
  /**
46
50
  * The calling user's relationship to an account node, as resolved by the API:
47
51
  * - `self` — the caller's own personal (root) account.
@@ -143,6 +147,8 @@ export interface CreateAccountInput {
143
147
  };
144
148
  bio?: string;
145
149
  avatar?: string;
150
+ /** Meaningful only when `kind` is `organization`. */
151
+ organizationCategory?: OrganizationCategory;
146
152
  }
147
153
  /** Input accepted by `updateAccount`. Tree placement changes go through `/move`. */
148
154
  export interface UpdateAccountInput {
@@ -153,6 +159,8 @@ export interface UpdateAccountInput {
153
159
  };
154
160
  bio?: string | null;
155
161
  avatar?: string | null;
162
+ /** Clears the category when `null`; only valid on `kind: 'organization'`. */
163
+ organizationCategory?: OrganizationCategory | null;
156
164
  }
157
165
  /** Input accepted by `inviteAccountMember`. The owner role cannot be invited. */
158
166
  export interface InviteAccountMemberInput {
@@ -319,6 +319,7 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
319
319
  deviceId: string;
320
320
  expiresAt: string;
321
321
  user: User;
322
+ deviceSecret?: string;
322
323
  }>;
323
324
  /**
324
325
  * MECHANISM A — same-device shared-keychain SSO.
@@ -1,4 +1,4 @@
1
- import type { UserNameResponse } from '@oxyhq/contracts';
1
+ import type { OrganizationCategory, UserNameResponse } from '@oxyhq/contracts';
2
2
  export interface OxyConfig {
3
3
  baseURL: string;
4
4
  cloudURL?: string;
@@ -129,6 +129,8 @@ export interface User {
129
129
  };
130
130
  isManagedAccount?: boolean;
131
131
  managedBy?: string;
132
+ /** Real-estate taxonomy when this user is a `kind: 'organization'` account. */
133
+ organizationCategory?: OrganizationCategory;
132
134
  notificationPreferences?: NotificationPreferences;
133
135
  userPreferences?: UserPreferences;
134
136
  [key: string]: unknown;
@@ -31,4 +31,10 @@ export interface SessionLoginResponse {
31
31
  user: MinimalUserData;
32
32
  /** JWT access token for API authentication */
33
33
  accessToken?: string;
34
+ /**
35
+ * Rotating zero-cookie device credential minted on sign-in / claim. Persisted
36
+ * first-party alongside `deviceId` so cold boot can re-mint via
37
+ * `POST /session/device/token`.
38
+ */
39
+ deviceSecret?: string;
34
40
  }
@@ -56,9 +56,20 @@ export declare class SessionClient {
56
56
  private started;
57
57
  /** Same-origin cross-tab state-propagation channel; null on platforms without BroadcastChannel. */
58
58
  private channel;
59
+ /** App-facing subscriptions to named server-pushed socket events. */
60
+ private readonly serverEvents;
61
+ /** Event names already bound on the CURRENT socket instance. */
62
+ private readonly boundServerEvents;
59
63
  constructor(host: SessionClientHost, options?: SessionClientOptions);
60
64
  getState(): DeviceSessionState | null;
61
65
  subscribe(listener: StateListener): () => void;
66
+ /**
67
+ * Subscribe to a named server-pushed Socket.IO event (e.g. `civic:attested`).
68
+ * Listeners survive reconnects and socket re-creation; the returned function
69
+ * unsubscribes. Payloads are delivered as-is — callers validate shape.
70
+ */
71
+ onServerEvent(event: string, listener: (payload: unknown) => void): () => void;
72
+ private bindServerEvent;
62
73
  protected notify(): void;
63
74
  /** Validate + last-writer-wins by revision. Returns true if applied. */
64
75
  protected applyState(raw: unknown): boolean;
@@ -111,6 +111,16 @@ export interface AccountDialogControllerOptions {
111
111
  * `Linking.openURL`). Headless core never touches `window`/`Linking` itself.
112
112
  */
113
113
  openUrl?: (url: string) => void;
114
+ /**
115
+ * Optional "can this app open this URL scheme?" probe, symmetric to
116
+ * {@link openUrl}. When provided, `showQr` uses it to detect an installed
117
+ * Commons (`oxycommons://`) and, if present, deep-links straight into its
118
+ * approve screen via {@link openUrl} — while KEEPING the QR/polling active as
119
+ * the fallback. Injected by the provider (native: `Linking.canOpenURL`; web:
120
+ * absent/false). Headless core never touches `Linking` itself; when absent
121
+ * `showQr` behaves exactly as before (render QR only).
122
+ */
123
+ canOpenApp?: (url: string) => Promise<boolean>;
114
124
  }
115
125
  type SnapshotListener = (snapshot: AccountDialogSnapshot) => void;
116
126
  export declare class AccountDialogController {
@@ -124,6 +134,7 @@ export declare class AccountDialogController {
124
134
  private readonly authRedirectUri;
125
135
  private readonly pollIntervalMs;
126
136
  private readonly openUrl?;
137
+ private readonly canOpenApp?;
127
138
  private readonly listeners;
128
139
  private view;
129
140
  private graph;
@@ -232,6 +243,12 @@ export declare class AccountDialogController {
232
243
  * session committed. Requires `clientId`.
233
244
  */
234
245
  showQr(): Promise<void>;
246
+ /**
247
+ * When a `canOpenApp` probe is injected and reports Commons installed, open the
248
+ * approve deep link via the injected `openUrl`. Best-effort and non-blocking: a
249
+ * probe/open failure is logged and swallowed — the QR/polling fallback remains.
250
+ */
251
+ private maybeOpenCommons;
235
252
  /** Tear down the active sign-in device flow (timers + token) and reset to idle. */
236
253
  cancelSignIn(): void;
237
254
  /**
@@ -77,11 +77,32 @@ export interface NativeKeyValueStorage {
77
77
  removeItem(key: string): Promise<void>;
78
78
  }
79
79
  /**
80
- * Versioned storage key. The `.v1` suffix lets a future shape change ship a
81
- * `.v2` key without reading a stale/incompatible `.v1` blob. Distinct from the
82
- * `oxy_shared_*` keychain keys in `KeyManager`, so it never collides.
80
+ * Versioned DURABLE storage key. Holds ONLY the small, re-mint-critical fields
81
+ * (`sessionId`, `userId`, `deviceId`, `deviceSecret`) never the large JWT
82
+ * `accessToken`. Keeping this blob small (<2KB) matters on Android
83
+ * `expo-secure-store`, whose backing store can silently fail to persist an
84
+ * oversize value; bundling the token here previously took the mint credential
85
+ * down with it on every write, losing the session on cold restart.
86
+ *
87
+ * The `.v1` suffix lets a future shape change ship a `.v2` key without reading a
88
+ * stale/incompatible `.v1` blob. Distinct from the `oxy_shared_*` keychain keys
89
+ * in `KeyManager`, so it never collides.
90
+ *
91
+ * BACK-COMPAT: pre-split builds wrote the WHOLE state (including `accessToken` /
92
+ * `expiresAt`) into this single key. `load()` still reads those token fields
93
+ * from here when the warm key ({@link AUTH_STATE_TOKEN_STORAGE_KEY}) is absent,
94
+ * so upgrading users are not signed out; the next `save()` splits them apart.
83
95
  */
84
96
  export declare const AUTH_STATE_STORAGE_KEY = "oxy.auth.v1";
97
+ /**
98
+ * Versioned BEST-EFFORT warm-token storage key. Holds the short-lived
99
+ * `{ accessToken, expiresAt }` pair only. Its write is genuinely non-fatal — a
100
+ * failure (quota / oversize keychain value) is swallowed because the session is
101
+ * fully re-mintable from the durable `deviceSecret`. Kept separate from
102
+ * {@link AUTH_STATE_STORAGE_KEY} so a failed token write can NEVER abort or
103
+ * corrupt the durable credential write.
104
+ */
105
+ export declare const AUTH_STATE_TOKEN_STORAGE_KEY = "oxy.auth.token.v1";
85
106
  /**
86
107
  * A process-lifetime, in-memory {@link AuthStateStore}. Used directly for
87
108
  * tests/SSR and as the degraded fallback of the web store when `localStorage`
@@ -90,8 +111,9 @@ export declare const AUTH_STATE_STORAGE_KEY = "oxy.auth.v1";
90
111
  */
91
112
  export declare function createMemoryAuthStateStore(): AuthStateStore;
92
113
  /**
93
- * A `localStorage`-backed {@link AuthStateStore} under the versioned
94
- * {@link AUTH_STATE_STORAGE_KEY}.
114
+ * A `localStorage`-backed {@link AuthStateStore} split across the durable
115
+ * {@link AUTH_STATE_STORAGE_KEY} (mint credential) and the best-effort
116
+ * {@link AUTH_STATE_TOKEN_STORAGE_KEY} (warm access token).
95
117
  *
96
118
  * Resilience:
97
119
  * - If `localStorage` is unreachable (sandboxed-iframe `SecurityError`, SSR),
@@ -107,8 +129,11 @@ export declare function createWebAuthStateStore(): AuthStateStore;
107
129
  * A native {@link AuthStateStore} over an injected async key/value store.
108
130
  *
109
131
  * `@oxyhq/core` never imports `expo-secure-store`; `@oxyhq/services` constructs
110
- * the SecureStore-backed adapter and passes it here. Every operation is wrapped
111
- * so a storage exception degrades gracefully (read `null`, write → swallowed)
112
- * exactly like the web store.
132
+ * the SecureStore-backed adapter and passes it here. Persistence is split across
133
+ * the durable {@link AUTH_STATE_STORAGE_KEY} (mint credential) and the
134
+ * best-effort {@link AUTH_STATE_TOKEN_STORAGE_KEY} (warm access token) — the
135
+ * durable write is read-back-verified and its failure surfaced (not swallowed),
136
+ * while the warm-token write and all reads degrade gracefully exactly like the
137
+ * web store.
113
138
  */
114
139
  export declare function createNativeAuthStateStore(storage: NativeKeyValueStorage): AuthStateStore;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "9.2.0",
3
+ "version": "9.2.2",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -94,7 +94,7 @@
94
94
  }
95
95
  },
96
96
  "dependencies": {
97
- "@oxyhq/contracts": "^0.13.1",
97
+ "@oxyhq/contracts": "^0.13.2",
98
98
  "@oxyhq/protocol": "^0.1.3",
99
99
  "bip39": "^3.1.0",
100
100
  "buffer": "^6.0.3",
@@ -258,6 +258,26 @@ describe('runSessionColdBoot — shared-key-signin (native)', () => {
258
258
  );
259
259
  });
260
260
 
261
+ it('persists deviceSecret from shared-key verifyChallenge for the next mint lane', async () => {
262
+ const store = createMemoryAuthStateStore();
263
+ const signInWithSharedIdentity = jest.fn(async () => ({
264
+ ...sharedSession,
265
+ deviceSecret: 'shared-mint-secret',
266
+ }));
267
+ const { oxy } = makeOxy({ signInWithSharedIdentity });
268
+
269
+ await runSessionColdBoot({ oxy, store, platform: NATIVE });
270
+
271
+ const persisted = await store.load();
272
+ expect(persisted).toMatchObject({
273
+ sessionId: 'sess-shared',
274
+ userId: 'user-shared',
275
+ deviceId: 'dev-1',
276
+ deviceSecret: 'shared-mint-secret',
277
+ accessToken: 'access-shared',
278
+ });
279
+ });
280
+
261
281
  it('does NOT run the shared-key lane on web', async () => {
262
282
  const store = createMemoryAuthStateStore();
263
283
  const signInWithSharedIdentity = jest.fn(async () => sharedSession);
@@ -156,6 +156,19 @@ export async function runSessionColdBoot(
156
156
  if (!session?.accessToken) {
157
157
  return { kind: 'skip' };
158
158
  }
159
+ // `verifyChallenge` mints a rotating deviceSecret; persist it so the next
160
+ // boot can use the faster device-secret-mint lane (sockets + tab-focus
161
+ // re-mint depend on the credential being in the store).
162
+ if (session.deviceId && session.deviceSecret) {
163
+ await store.save({
164
+ sessionId: session.sessionId,
165
+ userId: session.user.id,
166
+ deviceId: session.deviceId,
167
+ deviceSecret: session.deviceSecret,
168
+ accessToken: session.accessToken,
169
+ expiresAt: session.expiresAt,
170
+ });
171
+ }
159
172
  return {
160
173
  kind: 'session',
161
174
  session: {
package/src/index.ts CHANGED
@@ -86,6 +86,7 @@ export type {
86
86
  // ---------------------------------------------------------------------------
87
87
  export type {
88
88
  AccountKind,
89
+ OrganizationCategory,
89
90
  AccountRelationship,
90
91
  AccountRole,
91
92
  AccountMemberStatus,
@@ -127,6 +128,8 @@ export type {
127
128
  ApplicationUsageStats,
128
129
  } from './mixins/OxyServices.accounts';
129
130
 
131
+ export { ORGANIZATION_CATEGORIES } from './mixins/OxyServices.accounts';
132
+
130
133
  // ---------------------------------------------------------------------------
131
134
  // Reputation (Oxy Trust: ledger, balances, disputes, rules, influence)
132
135
  // ---------------------------------------------------------------------------
@@ -33,6 +33,7 @@
33
33
  * registers the switched session into the operator's device-set directly).
34
34
  */
35
35
  import type { User } from '../models/interfaces';
36
+ import type { OrganizationCategory } from '@oxyhq/contracts';
36
37
  import type { SessionLoginResponse } from '../models/session';
37
38
  import type { OxyServicesBase } from '../OxyServices.base';
38
39
  import { normalizeUserIdentity } from '../utils/userIdentity';
@@ -50,6 +51,10 @@ import { CACHE_TIMES } from './mixinHelpers';
50
51
  */
51
52
  export type AccountKind = 'personal' | 'organization' | 'project' | 'bot';
52
53
 
54
+ /** Real-estate / team taxonomy for `kind: 'organization'` accounts. */
55
+ export type { OrganizationCategory } from '@oxyhq/contracts';
56
+ export { ORGANIZATION_CATEGORIES } from '@oxyhq/contracts';
57
+
53
58
  /**
54
59
  * The calling user's relationship to an account node, as resolved by the API:
55
60
  * - `self` — the caller's own personal (root) account.
@@ -155,6 +160,8 @@ export interface CreateAccountInput {
155
160
  name?: { first?: string; last?: string };
156
161
  bio?: string;
157
162
  avatar?: string;
163
+ /** Meaningful only when `kind` is `organization`. */
164
+ organizationCategory?: OrganizationCategory;
158
165
  }
159
166
 
160
167
  /** Input accepted by `updateAccount`. Tree placement changes go through `/move`. */
@@ -163,6 +170,8 @@ export interface UpdateAccountInput {
163
170
  name?: { first?: string; last?: string };
164
171
  bio?: string | null;
165
172
  avatar?: string | null;
173
+ /** Clears the category when `null`; only valid on `kind: 'organization'`. */
174
+ organizationCategory?: OrganizationCategory | null;
166
175
  }
167
176
 
168
177
  /** Input accepted by `inviteAccountMember`. The owner role cannot be invited. */
@@ -664,6 +664,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
664
664
  deviceId: string;
665
665
  expiresAt: string;
666
666
  user: User;
667
+ deviceSecret?: string;
667
668
  }> {
668
669
  try {
669
670
  const res = await this.makeRequest<{
@@ -672,6 +673,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
672
673
  deviceId: string;
673
674
  expiresAt: string;
674
675
  user: User;
676
+ deviceSecret?: string;
675
677
  }>(
676
678
  'POST',
677
679
  '/auth/session/claim',
@@ -5,9 +5,11 @@
5
5
  * and Express.js authentication middleware
6
6
  */
7
7
  import { jwtDecode } from 'jwt-decode';
8
+ import type { LinkPreview } from '@oxyhq/contracts';
8
9
  import type { ApiError, User } from '../models/interfaces';
9
10
  import type { OxyServicesBase } from '../OxyServices.base';
10
11
  import { loadNodeCrypto } from '@oxyhq/protocol';
12
+ import { buildUrl } from '../utils/apiUtils';
11
13
  import { logger } from '../utils/loggerUtils';
12
14
  import { CACHE_TIMES } from './mixinHelpers';
13
15
 
@@ -217,15 +219,14 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
217
219
  image?: string;
218
220
  }> {
219
221
  try {
220
- return await this.makeRequest<{
221
- url: string;
222
- title: string;
223
- description: string;
224
- image?: string;
225
- }>('GET', '/link-metadata', { url }, {
226
- cache: true,
227
- cacheTTL: CACHE_TIMES.EXTRA_LONG,
228
- });
222
+ const path = buildUrl('/links/preview', { url, wait: 1 });
223
+ const preview = await this.makeRequest<LinkPreview>('GET', path, undefined, { cache: false });
224
+ return {
225
+ url: preview.url,
226
+ title: preview.title?.trim() || preview.url.replace(/^https?:\/\//, '').replace(/\/$/, ''),
227
+ description: preview.description?.trim() || 'Link',
228
+ image: preview.image,
229
+ };
229
230
  } catch (error) {
230
231
  throw this.handleError(error);
231
232
  }
@@ -1,4 +1,4 @@
1
- import type { UserNameResponse } from '@oxyhq/contracts';
1
+ import type { OrganizationCategory, UserNameResponse } from '@oxyhq/contracts';
2
2
 
3
3
  export interface OxyConfig {
4
4
  baseURL: string;
@@ -142,6 +142,8 @@ export interface User {
142
142
  // Managed account fields
143
143
  isManagedAccount?: boolean;
144
144
  managedBy?: string;
145
+ /** Real-estate taxonomy when this user is a `kind: 'organization'` account. */
146
+ organizationCategory?: OrganizationCategory;
145
147
  // User-controlled notification preferences. All channels default to on; users
146
148
  // opt out per-channel. Updated via `PUT /users/me`.
147
149
  notificationPreferences?: NotificationPreferences;
@@ -35,4 +35,10 @@ export interface SessionLoginResponse {
35
35
  user: MinimalUserData;
36
36
  /** JWT access token for API authentication */
37
37
  accessToken?: string;
38
+ /**
39
+ * Rotating zero-cookie device credential minted on sign-in / claim. Persisted
40
+ * first-party alongside `deviceId` so cold boot can re-mint via
41
+ * `POST /session/device/token`.
42
+ */
43
+ deviceSecret?: string;
38
44
  }
@@ -82,6 +82,10 @@ export class SessionClient {
82
82
  private started = false;
83
83
  /** Same-origin cross-tab state-propagation channel; null on platforms without BroadcastChannel. */
84
84
  private channel: SessionBroadcastChannel | null = null;
85
+ /** App-facing subscriptions to named server-pushed socket events. */
86
+ private readonly serverEvents = new Map<string, Set<(payload: unknown) => void>>();
87
+ /** Event names already bound on the CURRENT socket instance. */
88
+ private readonly boundServerEvents = new Set<string>();
85
89
 
86
90
  constructor(
87
91
  protected readonly host: SessionClientHost,
@@ -99,6 +103,40 @@ export class SessionClient {
99
103
  };
100
104
  }
101
105
 
106
+ /**
107
+ * Subscribe to a named server-pushed Socket.IO event (e.g. `civic:attested`).
108
+ * Listeners survive reconnects and socket re-creation; the returned function
109
+ * unsubscribes. Payloads are delivered as-is — callers validate shape.
110
+ */
111
+ onServerEvent(event: string, listener: (payload: unknown) => void): () => void {
112
+ let listeners = this.serverEvents.get(event);
113
+ if (!listeners) {
114
+ listeners = new Set();
115
+ this.serverEvents.set(event, listeners);
116
+ }
117
+ listeners.add(listener);
118
+ this.bindServerEvent(event);
119
+ return () => {
120
+ listeners.delete(listener);
121
+ };
122
+ }
123
+
124
+ private bindServerEvent(event: string): void {
125
+ if (!this.socket || this.boundServerEvents.has(event)) return;
126
+ this.boundServerEvents.add(event);
127
+ this.socket.on(event, (payload: unknown) => {
128
+ const listeners = this.serverEvents.get(event);
129
+ if (!listeners) return;
130
+ for (const listener of [...listeners]) {
131
+ try {
132
+ listener(payload);
133
+ } catch (error) {
134
+ logger.warn('[SessionClient] server-event listener threw', { component: 'SessionClient' }, error);
135
+ }
136
+ }
137
+ });
138
+ }
139
+
102
140
  protected notify(): void {
103
141
  for (const listener of this.listeners) {
104
142
  try {
@@ -287,6 +325,7 @@ export class SessionClient {
287
325
  if (this.socket) {
288
326
  this.socket.disconnect();
289
327
  this.socket = null;
328
+ this.boundServerEvents.clear();
290
329
  }
291
330
  }
292
331
 
@@ -341,6 +380,11 @@ export class SessionClient {
341
380
  }
342
381
  });
343
382
  this.socket = socket;
383
+ // (Re)bind app-facing server-event subscriptions on the fresh socket.
384
+ this.boundServerEvents.clear();
385
+ for (const event of this.serverEvents.keys()) {
386
+ this.bindServerEvent(event);
387
+ }
344
388
  }
345
389
 
346
390
  /**
@@ -0,0 +1,71 @@
1
+ import type { DeviceSessionState } from '@oxyhq/contracts';
2
+ import type { MinimalSocket, SocketIOFactory } from '../socketLoader';
3
+ import { SessionClient, type SessionClientHost } from '../SessionClient';
4
+
5
+ type Handler = (...args: unknown[]) => void;
6
+ class FakeSocket implements MinimalSocket {
7
+ connected = false;
8
+ handlers = new Map<string, Handler[]>();
9
+ on(event: string, cb: Handler) { const l = this.handlers.get(event) ?? []; l.push(cb); this.handlers.set(event, l); }
10
+ off(event: string, cb?: Handler) { if (!cb) { this.handlers.delete(event); return; } this.handlers.set(event, (this.handlers.get(event) ?? []).filter((h) => h !== cb)); }
11
+ connect() { this.connected = true; }
12
+ disconnect() { this.connected = false; }
13
+ emitServer(event: string, payload: unknown) { for (const h of this.handlers.get(event) ?? []) h(payload); }
14
+ }
15
+
16
+ const STATE = (rev: number): DeviceSessionState => ({ deviceId: 'd1', accounts: [{ accountId: 'a1', sessionId: 's1', authuser: 0 }], activeAccountId: 'a1', revision: rev, updatedAt: 1720000000000 });
17
+ const SYNC = (rev: number) => ({ state: STATE(rev), activeToken: { accessToken: `jwt-${rev}`, expiresAt: 'x' } });
18
+
19
+ function makeHost(over: Partial<SessionClientHost> = {}): SessionClientHost {
20
+ return {
21
+ makeRequest: jest.fn().mockResolvedValue(SYNC(1)),
22
+ getBaseURL: () => 'http://test.invalid',
23
+ getAccessToken: () => 'tok',
24
+ getDeviceCredential: () => null,
25
+ onTokensChanged: () => () => undefined,
26
+ setTokens: jest.fn(),
27
+ getCurrentAccountId: () => 'a1',
28
+ ...over,
29
+ };
30
+ }
31
+
32
+ describe('SessionClient.onServerEvent', () => {
33
+ it('delivers a server event to a listener registered BEFORE the socket exists', async () => {
34
+ let created: FakeSocket | null = null;
35
+ const factory: SocketIOFactory = jest.fn(() => { created = new FakeSocket(); created.connected = true; return created; });
36
+ const client = new SessionClient(makeHost(), { socketFactory: factory });
37
+ const seen: unknown[] = [];
38
+ client.onServerEvent('civic:attested', (p) => seen.push(p));
39
+ await client.start();
40
+ created?.emitServer('civic:attested', { byUserId: 'u2' });
41
+ expect(seen).toEqual([{ byUserId: 'u2' }]);
42
+ client.stop();
43
+ });
44
+
45
+ it('delivers to a listener registered AFTER the socket exists, and unsubscribe stops delivery', async () => {
46
+ let created: FakeSocket | null = null;
47
+ const factory: SocketIOFactory = jest.fn(() => { created = new FakeSocket(); created.connected = true; return created; });
48
+ const client = new SessionClient(makeHost(), { socketFactory: factory });
49
+ await client.start();
50
+ const seen: unknown[] = [];
51
+ const unsub = client.onServerEvent('civic:attested', (p) => seen.push(p));
52
+ created?.emitServer('civic:attested', 1);
53
+ unsub();
54
+ created?.emitServer('civic:attested', 2);
55
+ expect(seen).toEqual([1]);
56
+ client.stop();
57
+ });
58
+
59
+ it('one listener throwing does not break the others', async () => {
60
+ let created: FakeSocket | null = null;
61
+ const factory: SocketIOFactory = jest.fn(() => { created = new FakeSocket(); created.connected = true; return created; });
62
+ const client = new SessionClient(makeHost(), { socketFactory: factory });
63
+ await client.start();
64
+ const seen: unknown[] = [];
65
+ client.onServerEvent('civic:attested', () => { throw new Error('boom'); });
66
+ client.onServerEvent('civic:attested', (p) => seen.push(p));
67
+ created?.emitServer('civic:attested', 'ok');
68
+ expect(seen).toEqual(['ok']);
69
+ client.stop();
70
+ });
71
+ });