@oxyhq/core 3.10.1 → 3.12.0
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/AuthManager.js +9 -2
- package/dist/cjs/HttpService.js +27 -9
- package/dist/cjs/OxyServices.base.js +3 -2
- package/dist/cjs/crypto/canonicalJson.js +107 -0
- package/dist/cjs/crypto/keyManager.js +67 -8
- package/dist/cjs/crypto/signatureService.js +189 -0
- package/dist/cjs/index.js +30 -4
- package/dist/cjs/mixins/OxyServices.assets.js +16 -1
- package/dist/cjs/mixins/OxyServices.auth.js +190 -1
- package/dist/cjs/mixins/OxyServices.civic.js +611 -0
- package/dist/cjs/mixins/OxyServices.identity.js +291 -0
- package/dist/cjs/mixins/OxyServices.sso.js +28 -1
- package/dist/cjs/mixins/OxyServices.user.js +1 -0
- package/dist/cjs/mixins/index.js +6 -0
- package/dist/cjs/server/cors.js +20 -21
- package/dist/cjs/server/rateLimit.js +32 -8
- package/dist/cjs/utils/profileLinks.js +52 -0
- package/dist/cjs/utils/ssoReturn.js +1 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/AuthManager.js +9 -2
- package/dist/esm/HttpService.js +27 -9
- package/dist/esm/OxyServices.base.js +3 -2
- package/dist/esm/crypto/canonicalJson.js +104 -0
- package/dist/esm/crypto/keyManager.js +67 -8
- package/dist/esm/crypto/signatureService.js +187 -0
- package/dist/esm/index.js +19 -1
- package/dist/esm/mixins/OxyServices.assets.js +16 -1
- package/dist/esm/mixins/OxyServices.auth.js +190 -1
- package/dist/esm/mixins/OxyServices.civic.js +605 -0
- package/dist/esm/mixins/OxyServices.identity.js +287 -0
- package/dist/esm/mixins/OxyServices.sso.js +28 -1
- package/dist/esm/mixins/OxyServices.user.js +1 -0
- package/dist/esm/mixins/index.js +6 -0
- package/dist/esm/server/cors.js +20 -21
- package/dist/esm/server/rateLimit.js +32 -8
- package/dist/esm/utils/profileLinks.js +49 -0
- package/dist/esm/utils/ssoReturn.js +1 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +3 -0
- package/dist/types/OxyServices.d.ts +2 -2
- package/dist/types/crypto/canonicalJson.d.ts +44 -0
- package/dist/types/crypto/keyManager.d.ts +7 -0
- package/dist/types/crypto/signatureService.d.ts +112 -0
- package/dist/types/index.d.ts +10 -2
- package/dist/types/mixins/OxyServices.auth.d.ts +136 -0
- package/dist/types/mixins/OxyServices.civic.d.ts +512 -0
- package/dist/types/mixins/OxyServices.identity.d.ts +249 -0
- package/dist/types/mixins/OxyServices.sso.d.ts +4 -1
- package/dist/types/mixins/index.d.ts +3 -1
- package/dist/types/models/interfaces.d.ts +3 -0
- package/dist/types/server/cors.d.ts +5 -5
- package/dist/types/utils/profileLinks.d.ts +36 -0
- package/dist/types/utils/ssoReturn.d.ts +1 -1
- package/package.json +2 -2
- package/src/AuthManager.ts +8 -2
- package/src/HttpService.ts +36 -8
- package/src/OxyServices.base.ts +3 -2
- package/src/OxyServices.ts +1 -1
- package/src/__tests__/authManager.security.test.ts +31 -0
- package/src/__tests__/httpServiceCsrf.test.ts +75 -0
- package/src/crypto/__tests__/canonicalJson.test.ts +116 -0
- package/src/crypto/__tests__/keyManager.atomicity.test.ts +41 -2
- package/src/crypto/__tests__/signChallengeShared.test.ts +64 -0
- package/src/crypto/__tests__/signedRecord.test.ts +345 -0
- package/src/crypto/canonicalJson.ts +120 -0
- package/src/crypto/keyManager.ts +62 -12
- package/src/crypto/signatureService.ts +225 -0
- package/src/index.ts +55 -2
- package/src/mixins/OxyServices.assets.ts +16 -1
- package/src/mixins/OxyServices.auth.ts +309 -1
- package/src/mixins/OxyServices.civic.ts +956 -0
- package/src/mixins/OxyServices.identity.ts +445 -0
- package/src/mixins/OxyServices.sso.ts +30 -1
- package/src/mixins/OxyServices.user.ts +1 -0
- package/src/mixins/__tests__/OxyServices.civic.test.ts +1097 -0
- package/src/mixins/__tests__/OxyServices.identity.test.ts +364 -0
- package/src/mixins/__tests__/assetCredentials.test.ts +47 -0
- package/src/mixins/__tests__/commonsSignIn.test.ts +277 -0
- package/src/mixins/__tests__/serviceAuth.test.ts +19 -0
- package/src/mixins/__tests__/sso.test.ts +31 -0
- package/src/mixins/index.ts +8 -0
- package/src/models/interfaces.ts +3 -0
- package/src/server/__tests__/cors.test.ts +5 -1
- package/src/server/__tests__/rateLimit.test.ts +116 -0
- package/src/server/cors.ts +25 -20
- package/src/server/rateLimit.ts +39 -8
- package/src/utils/__tests__/consumeSsoReturn.test.ts +1 -1
- package/src/utils/__tests__/profileLinks.test.ts +126 -0
- package/src/utils/__tests__/ssoReturn.test.ts +1 -1
- package/src/utils/profileLinks.ts +74 -0
- package/src/utils/ssoReturn.ts +2 -2
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identity Methods Mixin (self-sovereign identity layer)
|
|
3
|
+
*
|
|
4
|
+
* Provides typed access to Oxy's AtProto/Bluesky-flavoured identity &
|
|
5
|
+
* portability layer:
|
|
6
|
+
* - DID resolution (`did:web:oxy.so:u:<userId>`, derived on demand by the API).
|
|
7
|
+
* - The auth-method ↔ DID verification-method mapping and its reversibility
|
|
8
|
+
* (link/unlink an identity key, link a password) via the existing
|
|
9
|
+
* `/auth/link` surface.
|
|
10
|
+
* - Signed records: clients sign an envelope with their own cryptographic key
|
|
11
|
+
* (`SignatureService.signRecord` + the shared `canonicalize`) and publish it;
|
|
12
|
+
* anyone can fetch and verify it.
|
|
13
|
+
* - The signed data-export ("credible exit") bundle.
|
|
14
|
+
* - Verified-domain badges (prove ownership of `nate.com`).
|
|
15
|
+
*
|
|
16
|
+
* Wire shapes come from `@oxyhq/contracts` (`DidDocument`,
|
|
17
|
+
* `SignedRecordEnvelope`, `AuthMethodsResponse`, `VerifiedDomain`,
|
|
18
|
+
* `DomainVerificationInstructions`, `ExportBundle`) — the single source of truth
|
|
19
|
+
* the API validates its output against, so producer and consumer cannot drift.
|
|
20
|
+
*
|
|
21
|
+
* Identity signing is NATIVE-ONLY: the private key lives in native secure
|
|
22
|
+
* storage, so `linkIdentityKey`, `signRecord`, and `publishRecord` require an
|
|
23
|
+
* on-device identity and throw on web (where `KeyManager.getPublicKey()` is
|
|
24
|
+
* always `null`).
|
|
25
|
+
*/
|
|
26
|
+
import type { AuthMethodsResponse, DidDocument, DomainVerificationInstructions, ExportBundle, SignedRecordEnvelope, VerifiedDomain } from '@oxyhq/contracts';
|
|
27
|
+
import type { OxyServicesBase } from '../OxyServices.base';
|
|
28
|
+
/** Record categories a client may sign and publish. */
|
|
29
|
+
export type IdentityRecordType = SignedRecordEnvelope['type'];
|
|
30
|
+
/** Auth-method types that can be unlinked via {@link OxyServicesIdentityMixin}. */
|
|
31
|
+
export type UnlinkableAuthMethodType = 'identity' | 'password' | 'google' | 'apple' | 'github';
|
|
32
|
+
/**
|
|
33
|
+
* Result of a link/unlink auth-method mutation (`POST /auth/link`,
|
|
34
|
+
* `DELETE /auth/link/:type`).
|
|
35
|
+
*/
|
|
36
|
+
export interface LinkAuthMethodResult {
|
|
37
|
+
success: boolean;
|
|
38
|
+
message: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Result of publishing a signed record (`POST /identity/records`). Echoes the
|
|
42
|
+
* stored envelope plus the server's verification verdict.
|
|
43
|
+
*/
|
|
44
|
+
export interface PublishRecordResult {
|
|
45
|
+
envelope: SignedRecordEnvelope;
|
|
46
|
+
verified: boolean;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Result of verifying a stored record (`GET /identity/records/:userId/:type/verify`).
|
|
50
|
+
* `verified` is the server's verdict; `reason` is present when it is `false`.
|
|
51
|
+
*/
|
|
52
|
+
export interface VerifyRecordResult {
|
|
53
|
+
verified: boolean;
|
|
54
|
+
reason?: string;
|
|
55
|
+
}
|
|
56
|
+
/** Result of a successful domain verification (`POST /identity/domains/:domain/verify`). */
|
|
57
|
+
export interface VerifyDomainResult {
|
|
58
|
+
verified: boolean;
|
|
59
|
+
domain: VerifiedDomain;
|
|
60
|
+
}
|
|
61
|
+
/** Result of removing a verified domain (`DELETE /identity/domains/:domain`). */
|
|
62
|
+
export interface RemoveDomainResult {
|
|
63
|
+
success: boolean;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Derive a user's Oxy DID from their stable account id.
|
|
67
|
+
* `did:web:oxy.so:u:<userId>`.
|
|
68
|
+
*/
|
|
69
|
+
export declare function buildUserDid(userId: string): string;
|
|
70
|
+
export declare function OxyServicesIdentityMixin<T extends typeof OxyServicesBase>(Base: T): {
|
|
71
|
+
new (...args: any[]): {
|
|
72
|
+
/**
|
|
73
|
+
* Resolve the W3C DID document for any user. The API derives it on demand
|
|
74
|
+
* from the account's `authMethods` + `publicKey` — there is no stored
|
|
75
|
+
* document. Public (no auth required); short-TTL cached.
|
|
76
|
+
*
|
|
77
|
+
* @param userId - The account's Mongo `_id`. URL-encoded into the path.
|
|
78
|
+
*/
|
|
79
|
+
resolveDid(userId: string): Promise<DidDocument>;
|
|
80
|
+
/**
|
|
81
|
+
* The current user's DID (`did:web:oxy.so:u:<userId>`), derived locally from
|
|
82
|
+
* the access token's user id. Throws if no user is authenticated.
|
|
83
|
+
*/
|
|
84
|
+
getMyDid(): string;
|
|
85
|
+
/** Resolve the current user's DID document. Requires an authenticated session. */
|
|
86
|
+
getMyDidDocument(): Promise<DidDocument>;
|
|
87
|
+
/**
|
|
88
|
+
* List the current user's linked authentication methods plus their DID.
|
|
89
|
+
* Each `identity` method carries a `verificationMethodId` linking it to its
|
|
90
|
+
* DID verification-method fragment.
|
|
91
|
+
*/
|
|
92
|
+
listAuthMethods(): Promise<AuthMethodsResponse>;
|
|
93
|
+
/**
|
|
94
|
+
* Link the on-device cryptographic identity to the current account,
|
|
95
|
+
* upgrading it from custodial to self-sovereign. Signs a proof of private
|
|
96
|
+
* key ownership and posts it to `POST /auth/link`.
|
|
97
|
+
*
|
|
98
|
+
* NATIVE-ONLY: requires a stored identity (throws if `KeyManager` has no key
|
|
99
|
+
* or no user is authenticated). The signed payload is
|
|
100
|
+
* `JSON.stringify({ action: 'link_identity', userId, timestamp })` — the
|
|
101
|
+
* exact bytes the server reconstructs and verifies.
|
|
102
|
+
*/
|
|
103
|
+
linkIdentityKey(): Promise<LinkAuthMethodResult>;
|
|
104
|
+
/**
|
|
105
|
+
* Link password authentication to the current account. Adds a `password`
|
|
106
|
+
* auth method (does not remove existing methods).
|
|
107
|
+
*
|
|
108
|
+
* @param email - The email to associate with password auth.
|
|
109
|
+
* @param password - The new password (server enforces strength rules).
|
|
110
|
+
*/
|
|
111
|
+
linkPassword(email: string, password: string): Promise<LinkAuthMethodResult>;
|
|
112
|
+
/**
|
|
113
|
+
* Unlink an authentication method from the current account. The server
|
|
114
|
+
* refuses to remove the last remaining method (the account would become
|
|
115
|
+
* inaccessible). Unlinking `identity` downgrades the account to custodial.
|
|
116
|
+
*
|
|
117
|
+
* @param type - The auth-method type to remove.
|
|
118
|
+
*/
|
|
119
|
+
unlinkAuthMethod(type: UnlinkableAuthMethodType): Promise<LinkAuthMethodResult>;
|
|
120
|
+
/**
|
|
121
|
+
* Sign a record with the on-device identity key, WITHOUT publishing it.
|
|
122
|
+
* The subject is the current user's DID. NATIVE-ONLY (requires a stored
|
|
123
|
+
* key). Use {@link publishRecord} to sign and store in one step.
|
|
124
|
+
*
|
|
125
|
+
* @param type - The record category.
|
|
126
|
+
* @param record - The arbitrary record payload to attest to.
|
|
127
|
+
*/
|
|
128
|
+
signRecord(type: IdentityRecordType, record: Record<string, unknown>): Promise<SignedRecordEnvelope>;
|
|
129
|
+
/**
|
|
130
|
+
* Sign a record and publish it to the append-only record store
|
|
131
|
+
* (`POST /identity/records`). NATIVE-ONLY (requires a stored key).
|
|
132
|
+
*
|
|
133
|
+
* @param type - The record category.
|
|
134
|
+
* @param record - The arbitrary record payload to attest to.
|
|
135
|
+
*/
|
|
136
|
+
publishRecord(type: IdentityRecordType, record: Record<string, unknown>): Promise<PublishRecordResult>;
|
|
137
|
+
/**
|
|
138
|
+
* Fetch a user's most recent signed record of a given type. Public (no auth
|
|
139
|
+
* required); short-TTL cached.
|
|
140
|
+
*
|
|
141
|
+
* @param userId - The subject account's Mongo `_id`.
|
|
142
|
+
* @param type - The record category to fetch.
|
|
143
|
+
*/
|
|
144
|
+
getRecord(userId: string, type: IdentityRecordType): Promise<SignedRecordEnvelope>;
|
|
145
|
+
/**
|
|
146
|
+
* Ask the server to verify a user's stored record: it recomputes the
|
|
147
|
+
* canonical signing input, checks the signature, and asserts the signing key
|
|
148
|
+
* is a current verification method on the subject's DID.
|
|
149
|
+
*
|
|
150
|
+
* @param userId - The subject account's Mongo `_id`.
|
|
151
|
+
* @param type - The record category to verify.
|
|
152
|
+
*/
|
|
153
|
+
verifyRecord(userId: string, type: IdentityRecordType): Promise<VerifyRecordResult>;
|
|
154
|
+
/**
|
|
155
|
+
* Download the current user's signed, open-format data-export bundle
|
|
156
|
+
* (`GET /users/me/export`) — the "credible exit" snapshot. Always carries an
|
|
157
|
+
* Oxy provenance `attestation`; carries an optional client `proof` when the
|
|
158
|
+
* account holds its own key.
|
|
159
|
+
*/
|
|
160
|
+
exportMyData(): Promise<ExportBundle>;
|
|
161
|
+
/**
|
|
162
|
+
* Start verifying ownership of a domain. Returns the instructions: publish
|
|
163
|
+
* EITHER the DNS-TXT record OR the `/.well-known/oxy-domain` file, then call
|
|
164
|
+
* {@link verifyDomain}.
|
|
165
|
+
*
|
|
166
|
+
* @param domain - The domain to claim (e.g. `nate.com`).
|
|
167
|
+
*/
|
|
168
|
+
requestDomainVerification(domain: string): Promise<DomainVerificationInstructions>;
|
|
169
|
+
/**
|
|
170
|
+
* Complete domain verification: the server checks the DNS-TXT record or
|
|
171
|
+
* well-known file and, on success, attaches the domain to the account
|
|
172
|
+
* (surfaced in the DID's `alsoKnownAs` and the user's `verifiedDomains`).
|
|
173
|
+
*
|
|
174
|
+
* @param domain - The domain previously requested via
|
|
175
|
+
* {@link requestDomainVerification}.
|
|
176
|
+
*/
|
|
177
|
+
verifyDomain(domain: string): Promise<VerifyDomainResult>;
|
|
178
|
+
/** List the current user's verified domains. */
|
|
179
|
+
listDomains(): Promise<VerifiedDomain[]>;
|
|
180
|
+
/**
|
|
181
|
+
* Remove a verified domain from the current account.
|
|
182
|
+
* @param domain - The verified domain to remove.
|
|
183
|
+
*/
|
|
184
|
+
removeDomain(domain: string): Promise<RemoveDomainResult>;
|
|
185
|
+
/**
|
|
186
|
+
* Bust the cached reads that an identity mutation invalidates: the current
|
|
187
|
+
* user (`/users/me*`), the linked auth-methods list, the verified-domains
|
|
188
|
+
* list, and the user's derived DID document (which embeds auth methods +
|
|
189
|
+
* verified domains, so it goes stale on link/unlink/domain changes).
|
|
190
|
+
*
|
|
191
|
+
* Internal helper (leading underscore); not part of the supported public
|
|
192
|
+
* surface. Public rather than `private` because mixins compose into an
|
|
193
|
+
* exported anonymous class, where TypeScript cannot represent a private
|
|
194
|
+
* member in the emitted declaration file (TS4094).
|
|
195
|
+
*/
|
|
196
|
+
_invalidateIdentityCaches(userId: string | null): void;
|
|
197
|
+
httpService: import("../HttpService").HttpService;
|
|
198
|
+
cloudURL: string;
|
|
199
|
+
config: import("../OxyServices.base").OxyConfig;
|
|
200
|
+
__resetTokensForTests(): void;
|
|
201
|
+
makeRequest<T_1>(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", url: string, data?: any, options?: import("../HttpService").RequestOptions): Promise<T_1>;
|
|
202
|
+
getBaseURL(): string;
|
|
203
|
+
getSessionBaseUrl(): string;
|
|
204
|
+
getClient(): import("../HttpService").HttpService;
|
|
205
|
+
createLinkedClient(config: import("../OxyServices.base").OxyConfig): import("..").LinkedHttpClient;
|
|
206
|
+
getMetrics(): {
|
|
207
|
+
totalRequests: number;
|
|
208
|
+
successfulRequests: number;
|
|
209
|
+
failedRequests: number;
|
|
210
|
+
cacheHits: number;
|
|
211
|
+
cacheMisses: number;
|
|
212
|
+
averageResponseTime: number;
|
|
213
|
+
};
|
|
214
|
+
clearCache(): void;
|
|
215
|
+
clearCacheEntry(key: string): void;
|
|
216
|
+
clearCacheByPrefix(prefix: string): number;
|
|
217
|
+
getCacheStats(): {
|
|
218
|
+
size: number;
|
|
219
|
+
hits: number;
|
|
220
|
+
misses: number;
|
|
221
|
+
hitRate: number;
|
|
222
|
+
};
|
|
223
|
+
getCloudURL(): string;
|
|
224
|
+
setTokens(accessToken: string): void;
|
|
225
|
+
clearTokens(): void;
|
|
226
|
+
onTokensChanged(listener: (accessToken: string | null) => void): () => void;
|
|
227
|
+
_cachedUserId: string | null | undefined;
|
|
228
|
+
_cachedAccessToken: string | null;
|
|
229
|
+
getCurrentUserId(): string | null;
|
|
230
|
+
hasValidToken(): boolean;
|
|
231
|
+
getAccessToken(): string | null;
|
|
232
|
+
setActingAs(userId: string | null): void;
|
|
233
|
+
getActingAs(): string | null;
|
|
234
|
+
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
235
|
+
withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
|
|
236
|
+
maxRetries?: number;
|
|
237
|
+
retryDelay?: number;
|
|
238
|
+
authTimeoutMs?: number;
|
|
239
|
+
}): Promise<T_1>;
|
|
240
|
+
validate(): Promise<boolean>;
|
|
241
|
+
handleError(error: unknown): Error;
|
|
242
|
+
healthCheck(): Promise<{
|
|
243
|
+
status: string;
|
|
244
|
+
users?: number;
|
|
245
|
+
timestamp?: string;
|
|
246
|
+
[key: string]: any;
|
|
247
|
+
}>;
|
|
248
|
+
};
|
|
249
|
+
} & T;
|
|
@@ -53,9 +53,12 @@ export declare function OxyServicesSsoMixin<T extends typeof OxyServicesBase>(Ba
|
|
|
53
53
|
* @param code - The opaque single-use code delivered in the SSO return
|
|
54
54
|
* fragment (see {@link parseSsoReturnFragment}). The central store burns
|
|
55
55
|
* it atomically on exchange.
|
|
56
|
+
* @param state - The state value returned alongside the code. In browsers,
|
|
57
|
+
* when an SSO bounce state is still stored for the current origin, this
|
|
58
|
+
* must match before any token-committing exchange is attempted.
|
|
56
59
|
* @returns The resolved {@link SessionLoginResponse}.
|
|
57
60
|
*/
|
|
58
|
-
exchangeSsoCode(code: string): Promise<SessionLoginResponse>;
|
|
61
|
+
exchangeSsoCode(code: string, state?: string): Promise<SessionLoginResponse>;
|
|
59
62
|
httpService: import("../HttpService").HttpService;
|
|
60
63
|
cloudURL: string;
|
|
61
64
|
config: import("../OxyServices.base").OxyConfig;
|
|
@@ -11,6 +11,7 @@ import { OxyServicesSilentAuthMixin } from './OxyServices.silent';
|
|
|
11
11
|
import { OxyServicesRedirectAuthMixin } from './OxyServices.redirect';
|
|
12
12
|
import { OxyServicesSsoMixin } from './OxyServices.sso';
|
|
13
13
|
import { OxyServicesUserMixin } from './OxyServices.user';
|
|
14
|
+
import { OxyServicesIdentityMixin } from './OxyServices.identity';
|
|
14
15
|
import { OxyServicesPrivacyMixin } from './OxyServices.privacy';
|
|
15
16
|
import { OxyServicesLanguageMixin } from './OxyServices.language';
|
|
16
17
|
import { OxyServicesPaymentMixin } from './OxyServices.payment';
|
|
@@ -28,6 +29,7 @@ import { OxyServicesTopicsMixin } from './OxyServices.topics';
|
|
|
28
29
|
import { OxyServicesManagedAccountsMixin } from './OxyServices.managedAccounts';
|
|
29
30
|
import { OxyServicesContactsMixin } from './OxyServices.contacts';
|
|
30
31
|
import { OxyServicesAppDataMixin } from './OxyServices.appData';
|
|
32
|
+
import { OxyServicesCivicMixin } from './OxyServices.civic';
|
|
31
33
|
/**
|
|
32
34
|
* Instance shape of every mixin in the pipeline, intersected. The runtime
|
|
33
35
|
* `composeOxyServices()` produces a class whose instances expose all of
|
|
@@ -37,7 +39,7 @@ import { OxyServicesAppDataMixin } from './OxyServices.appData';
|
|
|
37
39
|
* If you add a new mixin to `MIXIN_PIPELINE`, add it here too so its methods
|
|
38
40
|
* are visible without a cast.
|
|
39
41
|
*/
|
|
40
|
-
type AllMixinInstances = InstanceType<ReturnType<typeof OxyServicesAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFedCMMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSilentAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesRedirectAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSsoMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUserMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLanguageMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPaymentMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesReputationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAssetsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesApplicationsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesWorkspacesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLocationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAnalyticsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDevicesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSecurityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFeaturesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesTopicsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesManagedAccountsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesContactsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAppDataMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
|
|
42
|
+
type AllMixinInstances = InstanceType<ReturnType<typeof OxyServicesAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFedCMMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSilentAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesRedirectAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSsoMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUserMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesIdentityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLanguageMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPaymentMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesReputationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAssetsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesApplicationsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesWorkspacesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLocationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAnalyticsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDevicesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSecurityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFeaturesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesTopicsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesManagedAccountsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesContactsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAppDataMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesCivicMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
|
|
41
43
|
/**
|
|
42
44
|
* Constructor type for the fully composed mixin pipeline. Each mixin returns
|
|
43
45
|
* a new constructor that augments its input; reducing across the pipeline
|
|
@@ -11,10 +11,9 @@
|
|
|
11
11
|
*
|
|
12
12
|
* `createOxyCors` returns a self-contained Express middleware (no `cors`
|
|
13
13
|
* package dependency) that:
|
|
14
|
-
* - allows the Oxy apex origin family
|
|
15
|
-
*
|
|
16
|
-
* `console.oxy.so`, `inbox.oxy.so`,
|
|
17
|
-
* central-origin constants already in core, NOT a fresh hardcoded list,
|
|
14
|
+
* - allows the Oxy apex origin family over HTTPS only: the apex plus
|
|
15
|
+
* one-label subdomains such as `auth.oxy.so`, `api.oxy.so`,
|
|
16
|
+
* `accounts.oxy.so`, `console.oxy.so`, and `inbox.oxy.so`,
|
|
18
17
|
* - allows the caller's explicit `appOrigins`,
|
|
19
18
|
* - DENIES everything else (no reflection, never a wildcard with credentials),
|
|
20
19
|
* - echoes back the EXACT matched origin (so credentialed requests work) and
|
|
@@ -28,7 +27,8 @@ export interface OxyCorsOptions {
|
|
|
28
27
|
/**
|
|
29
28
|
* Explicit additional allowed origins (exact-origin match, e.g.
|
|
30
29
|
* `https://app.example.com`, `http://localhost:3000`). These are allowed IN
|
|
31
|
-
* ADDITION TO the Oxy apex origin family. Each is normalized
|
|
30
|
+
* ADDITION TO the built-in HTTPS Oxy apex origin family. Each is normalized
|
|
31
|
+
* via `new URL().origin`.
|
|
32
32
|
*/
|
|
33
33
|
appOrigins?: string[];
|
|
34
34
|
/**
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalized profile link shape for display.
|
|
3
|
+
*
|
|
4
|
+
* `id` is a stable key for list rendering (the source entry's id when present,
|
|
5
|
+
* otherwise the source index as a string). `url` is always a non-empty string.
|
|
6
|
+
*/
|
|
7
|
+
export interface ProfileLink {
|
|
8
|
+
id: string;
|
|
9
|
+
title?: string;
|
|
10
|
+
url: string;
|
|
11
|
+
}
|
|
12
|
+
/** Source shape of a single `User.linksMetadata` entry. */
|
|
13
|
+
export interface ProfileLinkMetadata {
|
|
14
|
+
url: string;
|
|
15
|
+
title?: string;
|
|
16
|
+
description?: string;
|
|
17
|
+
image?: string;
|
|
18
|
+
id?: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Normalizes a user's profile links into a clean display shape.
|
|
22
|
+
*
|
|
23
|
+
* Pure, no side effects, no I/O.
|
|
24
|
+
*
|
|
25
|
+
* - Prefers `linksMetadata` when it is a non-empty array: maps each entry to
|
|
26
|
+
* `{ id, title, url }`, using `entry.id` when present and falling back to the
|
|
27
|
+
* entry index. Entries without a non-empty string `url` are dropped.
|
|
28
|
+
* - Otherwise falls back to the legacy `links` string array: maps each string to
|
|
29
|
+
* `{ id: <index>, url }` (no title). Empty/non-string values are dropped.
|
|
30
|
+
* - Returns `[]` when both are absent or empty (including when `linksMetadata`
|
|
31
|
+
* is present but every entry is dropped — it does NOT fall back to `links`).
|
|
32
|
+
*
|
|
33
|
+
* URLs are trimmed and blanks are filtered out. This does NOT add a scheme such
|
|
34
|
+
* as `https://`; prefixing is a display concern left to the caller.
|
|
35
|
+
*/
|
|
36
|
+
export declare function normalizeProfileLinks(linksMetadata?: ProfileLinkMetadata[], links?: string[]): ProfileLink[];
|
|
@@ -142,5 +142,5 @@ export interface ConsumeSsoReturnDeps {
|
|
|
142
142
|
* @returns The exchanged session on success, otherwise `null`.
|
|
143
143
|
*/
|
|
144
144
|
export declare function consumeSsoReturn(oxy: {
|
|
145
|
-
exchangeSsoCode: (code: string) => Promise<SessionLoginResponse>;
|
|
145
|
+
exchangeSsoCode: (code: string, state?: string) => Promise<SessionLoginResponse>;
|
|
146
146
|
}, deps?: ConsumeSsoReturnDeps): Promise<SessionLoginResponse | null>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxyhq/core",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.12.0",
|
|
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",
|
|
@@ -98,7 +98,7 @@
|
|
|
98
98
|
}
|
|
99
99
|
},
|
|
100
100
|
"dependencies": {
|
|
101
|
-
"@oxyhq/contracts": "
|
|
101
|
+
"@oxyhq/contracts": "0.4.0",
|
|
102
102
|
"bip39": "^3.1.0",
|
|
103
103
|
"buffer": "^6.0.3",
|
|
104
104
|
"elliptic": "^6.6.1",
|
package/src/AuthManager.ts
CHANGED
|
@@ -445,8 +445,14 @@ export class AuthManager {
|
|
|
445
445
|
* Get default storage based on environment.
|
|
446
446
|
*/
|
|
447
447
|
private getDefaultStorage(): StorageAdapter {
|
|
448
|
-
|
|
449
|
-
|
|
448
|
+
try {
|
|
449
|
+
if (typeof window !== 'undefined' && window.localStorage) {
|
|
450
|
+
return new LocalStorageAdapter();
|
|
451
|
+
}
|
|
452
|
+
} catch {
|
|
453
|
+
// Accessing window.localStorage can throw in opaque-origin/sandboxed
|
|
454
|
+
// browser contexts or when storage is disabled. Fall back to memory so
|
|
455
|
+
// AuthManager construction remains safe during provider render.
|
|
450
456
|
}
|
|
451
457
|
return new MemoryStorage();
|
|
452
458
|
}
|
package/src/HttpService.ts
CHANGED
|
@@ -68,6 +68,7 @@ export interface RequestOptions {
|
|
|
68
68
|
timeout?: number;
|
|
69
69
|
signal?: AbortSignal;
|
|
70
70
|
headers?: Record<string, string>;
|
|
71
|
+
responseType?: 'blob';
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
interface RequestConfig extends RequestOptions {
|
|
@@ -496,13 +497,21 @@ export class HttpService {
|
|
|
496
497
|
const useXhrForUpload = isFormData && isReactNative() && typeof XMLHttpRequest !== 'undefined';
|
|
497
498
|
|
|
498
499
|
const response = useXhrForUpload
|
|
499
|
-
? await this.uploadViaXHR(
|
|
500
|
+
? await this.uploadViaXHR(
|
|
501
|
+
fullUrl,
|
|
502
|
+
method,
|
|
503
|
+
headers,
|
|
504
|
+
bodyValue as FormData,
|
|
505
|
+
controller.signal,
|
|
506
|
+
timeout,
|
|
507
|
+
this.shouldSendCredentials(fullUrl),
|
|
508
|
+
)
|
|
500
509
|
: await fetch(fullUrl, {
|
|
501
510
|
method,
|
|
502
511
|
headers,
|
|
503
512
|
body: bodyValue as BodyInit | null | undefined,
|
|
504
513
|
signal: controller.signal,
|
|
505
|
-
credentials:
|
|
514
|
+
credentials: this.getCredentialsMode(fullUrl),
|
|
506
515
|
});
|
|
507
516
|
|
|
508
517
|
if (timeoutId) clearTimeout(timeoutId);
|
|
@@ -530,7 +539,7 @@ export class HttpService {
|
|
|
530
539
|
const errBody = await clonedResponse.json() as { code?: string } | null;
|
|
531
540
|
if (errBody?.code === 'CSRF_TOKEN_INVALID' || errBody?.code === 'CSRF_TOKEN_MISSING') {
|
|
532
541
|
this.tokenStore.clearCsrfToken();
|
|
533
|
-
return this.request<T>({ ...config, _isCsrfRetry: true, retry: false });
|
|
542
|
+
return this.request<T>({ ...config, _isCsrfRetry: true, retry: false, deduplicate: false });
|
|
534
543
|
}
|
|
535
544
|
} catch {
|
|
536
545
|
// Failed to parse error body — not a CSRF error
|
|
@@ -568,7 +577,9 @@ export class HttpService {
|
|
|
568
577
|
const contentType = response.headers.get('content-type');
|
|
569
578
|
let responseData: unknown;
|
|
570
579
|
|
|
571
|
-
if (
|
|
580
|
+
if (config.responseType === 'blob') {
|
|
581
|
+
responseData = await response.blob();
|
|
582
|
+
} else if (contentType && contentType.includes('application/json')) {
|
|
572
583
|
// Use response.json() directly for better performance
|
|
573
584
|
try {
|
|
574
585
|
responseData = await response.json();
|
|
@@ -694,13 +705,15 @@ export class HttpService {
|
|
|
694
705
|
body: FormData,
|
|
695
706
|
abortSignal: AbortSignal,
|
|
696
707
|
timeout: number,
|
|
708
|
+
withCredentials: boolean,
|
|
697
709
|
): Promise<Response> {
|
|
698
710
|
return new Promise<Response>((resolve, reject) => {
|
|
699
711
|
const xhr = new XMLHttpRequest();
|
|
700
712
|
xhr.open(method, url, true);
|
|
701
|
-
//
|
|
702
|
-
//
|
|
703
|
-
|
|
713
|
+
// Only send ambient cookies to the configured API origin. Absolute
|
|
714
|
+
// caller-supplied URLs can target arbitrary origins, so they must not
|
|
715
|
+
// receive credential-bearing requests by default.
|
|
716
|
+
xhr.withCredentials = withCredentials;
|
|
704
717
|
|
|
705
718
|
// Forward headers but skip Content-Type — XHR sets the multipart
|
|
706
719
|
// boundary automatically and overriding it breaks the upload.
|
|
@@ -874,6 +887,18 @@ export class HttpService {
|
|
|
874
887
|
return queryString ? `${base}${base.includes('?') ? '&' : '?'}${queryString}` : base;
|
|
875
888
|
}
|
|
876
889
|
|
|
890
|
+
private getCredentialsMode(url: string): RequestCredentials {
|
|
891
|
+
return this.shouldSendCredentials(url) ? 'include' : 'omit';
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
private shouldSendCredentials(url: string): boolean {
|
|
895
|
+
try {
|
|
896
|
+
return new URL(url).origin === new URL(this.baseURL).origin;
|
|
897
|
+
} catch {
|
|
898
|
+
return false;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
877
902
|
/**
|
|
878
903
|
* Fetch CSRF token from server (with deduplication)
|
|
879
904
|
* Required for state-changing requests (POST, PUT, PATCH, DELETE)
|
|
@@ -915,8 +940,11 @@ export class HttpService {
|
|
|
915
940
|
|
|
916
941
|
if (response.ok) {
|
|
917
942
|
const data = await response.json() as { csrfToken?: string };
|
|
918
|
-
this.logger.debug('CSRF response data:', data);
|
|
919
943
|
const token = data.csrfToken || null;
|
|
944
|
+
this.logger.debug('CSRF response data:', {
|
|
945
|
+
hasCsrfToken: typeof token === 'string' && token.length > 0,
|
|
946
|
+
csrfTokenLength: token?.length,
|
|
947
|
+
});
|
|
920
948
|
this.tokenStore.setCsrfToken(token);
|
|
921
949
|
this.logger.debug('CSRF token fetched');
|
|
922
950
|
return token;
|
package/src/OxyServices.base.ts
CHANGED
|
@@ -287,8 +287,9 @@ export class OxyServicesBase {
|
|
|
287
287
|
|
|
288
288
|
try {
|
|
289
289
|
const decoded = jwtDecode<JwtPayload>(accessToken);
|
|
290
|
-
|
|
291
|
-
|
|
290
|
+
const userId = decoded.userId || decoded.id || null;
|
|
291
|
+
this._cachedUserId = userId;
|
|
292
|
+
return userId;
|
|
292
293
|
} catch {
|
|
293
294
|
this._cachedUserId = null;
|
|
294
295
|
return null;
|
package/src/OxyServices.ts
CHANGED
|
@@ -153,7 +153,7 @@ export interface OxyServices extends InstanceType<ReturnType<typeof composeOxySe
|
|
|
153
153
|
signUpWithRedirect(options?: RedirectAuthOptions): void;
|
|
154
154
|
|
|
155
155
|
// Central cross-domain SSO (opaque single-use code exchange)
|
|
156
|
-
exchangeSsoCode(code: string): Promise<SessionLoginResponse>;
|
|
156
|
+
exchangeSsoCode(code: string, state?: string): Promise<SessionLoginResponse>;
|
|
157
157
|
generateSsoState(): string;
|
|
158
158
|
|
|
159
159
|
// Express.js middleware
|
|
@@ -164,6 +164,37 @@ describe('AuthManager.switchAuthuser — concurrency lock', () => {
|
|
|
164
164
|
});
|
|
165
165
|
});
|
|
166
166
|
|
|
167
|
+
describe('AuthManager default storage selection', () => {
|
|
168
|
+
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
|
169
|
+
|
|
170
|
+
afterEach(() => {
|
|
171
|
+
if (originalWindow) {
|
|
172
|
+
Object.defineProperty(globalThis, 'window', originalWindow);
|
|
173
|
+
} else {
|
|
174
|
+
Reflect.deleteProperty(globalThis, 'window');
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('falls back to memory storage when localStorage access throws', () => {
|
|
179
|
+
const blockedWindow = {};
|
|
180
|
+
Object.defineProperty(blockedWindow, 'localStorage', {
|
|
181
|
+
configurable: true,
|
|
182
|
+
get() {
|
|
183
|
+
throw new DOMException('Blocked localStorage', 'SecurityError');
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
Object.defineProperty(globalThis, 'window', {
|
|
187
|
+
configurable: true,
|
|
188
|
+
value: blockedWindow,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
expect(() => new AuthManager(makeMockServices() as unknown as OxyServices, {
|
|
192
|
+
autoRefresh: false,
|
|
193
|
+
crossTabSync: false,
|
|
194
|
+
})).not.toThrow();
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
167
198
|
describe('AuthManager.switchAuthuser — hydration of unknown slots', () => {
|
|
168
199
|
it('hydrates a slot with no prior user metadata via getCurrentUser()', async () => {
|
|
169
200
|
const services = makeMockServices();
|
|
@@ -157,4 +157,79 @@ describe('HttpService CSRF behavior', () => {
|
|
|
157
157
|
expect(headers.Authorization).toBeUndefined();
|
|
158
158
|
expect(headers['X-CSRF-Token']).toBe('csrf_1');
|
|
159
159
|
});
|
|
160
|
+
|
|
161
|
+
it('includes credentials for configured API origin requests', async () => {
|
|
162
|
+
const calls: FetchCall[] = [];
|
|
163
|
+
globalThis.fetch = async (input, init) => {
|
|
164
|
+
calls.push({ url: String(input), init });
|
|
165
|
+
return jsonResponse({ ok: true });
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const http = new HttpService({ baseURL: 'https://api.oxy.so', enableRetry: false });
|
|
169
|
+
|
|
170
|
+
await http.get('/users/me');
|
|
171
|
+
|
|
172
|
+
expect(calls).toHaveLength(1);
|
|
173
|
+
expect(calls[0].url).toBe('https://api.oxy.so/users/me');
|
|
174
|
+
expect(calls[0].init?.credentials).toBe('include');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('omits credentials for caller-supplied absolute URLs outside the configured API origin', async () => {
|
|
178
|
+
const calls: FetchCall[] = [];
|
|
179
|
+
globalThis.fetch = async (input, init) => {
|
|
180
|
+
calls.push({ url: String(input), init });
|
|
181
|
+
return jsonResponse({ ok: true });
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const http = new HttpService({ baseURL: 'https://api.oxy.so', enableRetry: false });
|
|
185
|
+
|
|
186
|
+
await http.get('https://attacker.oxy.so/collect');
|
|
187
|
+
|
|
188
|
+
expect(calls).toHaveLength(1);
|
|
189
|
+
expect(calls[0].url).toBe('https://attacker.oxy.so/collect');
|
|
190
|
+
expect(calls[0].init?.credentials).toBe('omit');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('bypasses request deduplication for the internal CSRF retry', async () => {
|
|
194
|
+
const calls: FetchCall[] = [];
|
|
195
|
+
let csrfFetches = 0;
|
|
196
|
+
let postFetches = 0;
|
|
197
|
+
|
|
198
|
+
globalThis.fetch = async (input, init) => {
|
|
199
|
+
const url = String(input);
|
|
200
|
+
calls.push({ url, init });
|
|
201
|
+
|
|
202
|
+
if (url.endsWith('/csrf-token')) {
|
|
203
|
+
csrfFetches += 1;
|
|
204
|
+
return new Response(JSON.stringify({ csrfToken: `csrf_${csrfFetches}` }), {
|
|
205
|
+
status: 200,
|
|
206
|
+
headers: { 'content-type': 'application/json' },
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
postFetches += 1;
|
|
211
|
+
if (postFetches === 1) {
|
|
212
|
+
return new Response(JSON.stringify({ code: 'CSRF_TOKEN_INVALID' }), {
|
|
213
|
+
status: 403,
|
|
214
|
+
statusText: 'Forbidden',
|
|
215
|
+
headers: { 'content-type': 'application/json' },
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return jsonResponse({ ok: true });
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
|
|
223
|
+
|
|
224
|
+
await expect(http.post('/posts', { text: 'hello' })).resolves.toEqual({ ok: true });
|
|
225
|
+
expect(calls.map((call) => call.url)).toEqual([
|
|
226
|
+
'https://api.mention.earth/csrf-token',
|
|
227
|
+
'https://api.mention.earth/posts',
|
|
228
|
+
'https://api.mention.earth/csrf-token',
|
|
229
|
+
'https://api.mention.earth/posts',
|
|
230
|
+
]);
|
|
231
|
+
|
|
232
|
+
expect(readHeaders(calls[1].init)['X-CSRF-Token']).toBe('csrf_1');
|
|
233
|
+
expect(readHeaders(calls[3].init)['X-CSRF-Token']).toBe('csrf_2');
|
|
234
|
+
});
|
|
160
235
|
});
|