@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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/boot/sessionColdBoot.js +13 -0
- package/dist/cjs/index.js +6 -4
- package/dist/cjs/mixins/OxyServices.accounts.js +3 -0
- package/dist/cjs/mixins/OxyServices.utility.js +9 -5
- package/dist/cjs/session/SessionClient.js +45 -0
- package/dist/cjs/session/accountDialogController.js +31 -0
- package/dist/cjs/session/authStateStore.js +196 -16
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/sessionColdBoot.js +13 -0
- package/dist/esm/index.js +1 -0
- package/dist/esm/mixins/OxyServices.accounts.js +1 -0
- package/dist/esm/mixins/OxyServices.utility.js +9 -5
- package/dist/esm/session/SessionClient.js +45 -0
- package/dist/esm/session/accountDialogController.js +31 -0
- package/dist/esm/session/authStateStore.js +195 -15
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +2 -1
- package/dist/types/mixins/OxyServices.accounts.d.ts +8 -0
- package/dist/types/mixins/OxyServices.auth.d.ts +1 -0
- package/dist/types/models/interfaces.d.ts +3 -1
- package/dist/types/models/session.d.ts +6 -0
- package/dist/types/session/SessionClient.d.ts +11 -0
- package/dist/types/session/accountDialogController.d.ts +17 -0
- package/dist/types/session/authStateStore.d.ts +33 -8
- package/package.json +2 -2
- package/src/boot/__tests__/sessionColdBoot.test.ts +20 -0
- package/src/boot/sessionColdBoot.ts +13 -0
- package/src/index.ts +3 -0
- package/src/mixins/OxyServices.accounts.ts +9 -0
- package/src/mixins/OxyServices.auth.ts +2 -0
- package/src/mixins/OxyServices.utility.ts +10 -9
- package/src/models/interfaces.ts +3 -1
- package/src/models/session.ts +6 -0
- package/src/session/SessionClient.ts +44 -0
- package/src/session/__tests__/SessionClient.serverEvents.test.ts +71 -0
- package/src/session/__tests__/accountDialogController.test.ts +93 -1
- package/src/session/__tests__/authStateStore.test.ts +170 -0
- package/src/session/accountDialogController.ts +54 -1
- package/src/session/authStateStore.ts +219 -15
package/dist/types/index.d.ts
CHANGED
|
@@ -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 {
|
|
@@ -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.
|
|
81
|
-
*
|
|
82
|
-
* `
|
|
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}
|
|
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.
|
|
111
|
-
*
|
|
112
|
-
*
|
|
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.
|
|
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.
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
}
|
package/src/models/interfaces.ts
CHANGED
|
@@ -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;
|
package/src/models/session.ts
CHANGED
|
@@ -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
|
+
});
|