@startsimpli/api 0.5.19 → 0.5.21
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/README.md +287 -240
- package/package.json +20 -11
- package/src/constants/endpoints.ts +42 -2
- package/src/index.ts +53 -1
- package/src/lib/__tests__/companies-api.test.ts +53 -0
- package/src/lib/__tests__/domain-claims-api.test.ts +68 -0
- package/src/lib/__tests__/team-invitations-api.test.ts +50 -0
- package/src/lib/__tests__/teams-api.test.ts +74 -0
- package/src/lib/companies-api.ts +48 -0
- package/src/lib/domain-claims-api.ts +63 -0
- package/src/lib/error-handler.ts +40 -5
- package/src/lib/errors.ts +7 -2
- package/src/lib/markets-api.ts +237 -7
- package/src/lib/sanitize-core.ts +32 -0
- package/src/lib/sanitize.native.ts +17 -0
- package/src/lib/sanitize.ts +6 -26
- package/src/lib/team-invitations-api.ts +49 -0
- package/src/lib/teams-api.ts +73 -0
- package/src/lib/users-api.ts +13 -0
- package/src/lib/vault-api.test.ts +62 -0
- package/src/lib/vault-api.ts +155 -0
- package/src/types/api.ts +6 -0
- package/src/types/index.ts +22 -0
- package/src/types/team.ts +175 -0
- package/src/types/user.ts +20 -1
package/package.json
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/api",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.21",
|
|
4
4
|
"description": "Type-safe Django REST API client for StartSimpli apps",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
7
7
|
"exports": {
|
|
8
|
-
".":
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./src/index.ts",
|
|
10
|
+
"react-native": "./src/index.ts",
|
|
11
|
+
"default": "./src/index.ts"
|
|
12
|
+
},
|
|
13
|
+
"./middleware": {
|
|
14
|
+
"types": "./src/middleware/index.ts",
|
|
15
|
+
"default": "./src/middleware/index.ts"
|
|
16
|
+
},
|
|
17
|
+
"./package.json": "./package.json"
|
|
9
18
|
},
|
|
10
19
|
"files": [
|
|
11
20
|
"src",
|
|
@@ -14,14 +23,6 @@
|
|
|
14
23
|
"publishConfig": {
|
|
15
24
|
"access": "public"
|
|
16
25
|
},
|
|
17
|
-
"scripts": {
|
|
18
|
-
"build": "tsup",
|
|
19
|
-
"dev": "tsup --watch",
|
|
20
|
-
"type-check": "tsc --noEmit",
|
|
21
|
-
"test": "vitest run",
|
|
22
|
-
"test:watch": "vitest",
|
|
23
|
-
"clean": "rm -rf dist"
|
|
24
|
-
},
|
|
25
26
|
"dependencies": {
|
|
26
27
|
"isomorphic-dompurify": "^3.10.0",
|
|
27
28
|
"zod": "^4.3.6"
|
|
@@ -51,5 +52,13 @@
|
|
|
51
52
|
"tsup": "^8.5.1",
|
|
52
53
|
"typescript": "^6.0.3",
|
|
53
54
|
"vitest": "^4.1.5"
|
|
55
|
+
},
|
|
56
|
+
"scripts": {
|
|
57
|
+
"build": "tsup",
|
|
58
|
+
"dev": "tsup --watch",
|
|
59
|
+
"type-check": "tsc --noEmit",
|
|
60
|
+
"test": "vitest run",
|
|
61
|
+
"test:watch": "vitest",
|
|
62
|
+
"clean": "rm -rf dist"
|
|
54
63
|
}
|
|
55
|
-
}
|
|
64
|
+
}
|
|
@@ -59,6 +59,9 @@ export const ENDPOINTS = {
|
|
|
59
59
|
CONTACT_ENRICH: (id: string) => `api/v1/contacts/${id}/enrich`,
|
|
60
60
|
CONTACTS_ENRICH_APOLLO: 'api/v1/contacts/enrich-apollo',
|
|
61
61
|
|
|
62
|
+
// Auth
|
|
63
|
+
AUTH_EARLY_REGISTER: 'api/v1/auth/early-register',
|
|
64
|
+
|
|
62
65
|
// Users
|
|
63
66
|
USER_ME: 'api/v1/users/me',
|
|
64
67
|
USER_CHANGE_PASSWORD: 'api/v1/users/me/change-password',
|
|
@@ -66,6 +69,36 @@ export const ENDPOINTS = {
|
|
|
66
69
|
// Companies / Feature flags
|
|
67
70
|
FEATURE_FLAGS: 'api/v1/companies/feature-flags',
|
|
68
71
|
|
|
72
|
+
// Companies (startsim-o7s) — accepts numeric id OR slug
|
|
73
|
+
COMPANIES: 'api/v1/companies',
|
|
74
|
+
COMPANY: (idOrSlug: string) => `api/v1/companies/${idOrSlug}`,
|
|
75
|
+
COMPANY_FEATURE_FLAGS: (idOrSlug: string) => `api/v1/companies/${idOrSlug}/feature-flags`,
|
|
76
|
+
|
|
77
|
+
// Teams (startsim-o7s, startsim-tsm)
|
|
78
|
+
TEAMS: 'api/v1/teams',
|
|
79
|
+
TEAM: (idOrSlug: string) => `api/v1/teams/${idOrSlug}`,
|
|
80
|
+
TEAM_MEMBERS: (idOrSlug: string) => `api/v1/teams/${idOrSlug}/members`,
|
|
81
|
+
TEAM_BULK_INVITE: (idOrSlug: string) => `api/v1/teams/${idOrSlug}/bulk-invite`,
|
|
82
|
+
TEAM_REMOVE_MEMBER: (idOrSlug: string) => `api/v1/teams/${idOrSlug}/remove-member`,
|
|
83
|
+
TEAM_UPDATE_ROLE: (idOrSlug: string) => `api/v1/teams/${idOrSlug}/update-role`,
|
|
84
|
+
TEAM_MEMBERS_MY_TEAMS: 'api/v1/team-members/my-teams',
|
|
85
|
+
|
|
86
|
+
// Team invitations (startsim-tsm)
|
|
87
|
+
TEAM_INVITATIONS: 'api/v1/team-invitations',
|
|
88
|
+
TEAM_INVITATION: (id: string) => `api/v1/team-invitations/${id}`,
|
|
89
|
+
TEAM_INVITATION_ACCEPT: (id: string) => `api/v1/team-invitations/${id}/accept`,
|
|
90
|
+
TEAM_INVITATION_REVOKE: (id: string) => `api/v1/team-invitations/${id}/revoke`,
|
|
91
|
+
|
|
92
|
+
// Email domain claims (startsim-gpu)
|
|
93
|
+
TEAM_DOMAIN_CLAIMS: 'api/v1/team-domain-claims',
|
|
94
|
+
TEAM_DOMAIN_CLAIM: (id: string) => `api/v1/team-domain-claims/${id}`,
|
|
95
|
+
TEAM_DOMAIN_CLAIM_VERIFY_DNS: (id: string) => `api/v1/team-domain-claims/${id}/verify-dns`,
|
|
96
|
+
TEAM_DOMAIN_CLAIM_VERIFY_EMAIL_INITIATE: (id: string) =>
|
|
97
|
+
`api/v1/team-domain-claims/${id}/verify-email-initiate`,
|
|
98
|
+
TEAM_DOMAIN_CLAIM_VERIFY_EMAIL_CODE: (id: string) =>
|
|
99
|
+
`api/v1/team-domain-claims/${id}/verify-email-code`,
|
|
100
|
+
TEAM_DOMAIN_CLAIM_REVOKE: (id: string) => `api/v1/team-domain-claims/${id}/revoke`,
|
|
101
|
+
|
|
69
102
|
// Markets (instruments, prices, analytics, news, health)
|
|
70
103
|
INSTRUMENTS: 'api/v1/markets/instruments',
|
|
71
104
|
INSTRUMENT: (symbol: string) => `api/v1/markets/instruments/${symbol}`,
|
|
@@ -77,11 +110,18 @@ export const ENDPOINTS = {
|
|
|
77
110
|
SECTOR_BREADTH: 'api/v1/markets/sector_breadth',
|
|
78
111
|
EARNINGS_CALENDAR: 'api/v1/markets/calendar',
|
|
79
112
|
TRADING_SNAPSHOTS: 'api/v1/markets/trading/snapshots',
|
|
113
|
+
BARS_BULK: 'api/v1/markets/bars/bulk',
|
|
114
|
+
SOCIAL_POSTS: 'api/v1/markets/social_posts',
|
|
80
115
|
|
|
81
|
-
// Options — schemas
|
|
82
|
-
|
|
116
|
+
// Options — schemas confirmed by claude-mac (agent_bridge req 22ac4889).
|
|
117
|
+
// /options/iv/ summary + /options/skew/ intentionally omitted: iv/history covers
|
|
118
|
+
// rank/RV/spread; skew is client-derived from chain (mac req a2f98ba6).
|
|
119
|
+
OPTIONS_CHAIN: (symbol: string) => `api/v1/markets/instruments/${symbol}/options/chain`,
|
|
83
120
|
OPTIONS_IV_HISTORY: (symbol: string) => `api/v1/markets/instruments/${symbol}/options/iv/history`,
|
|
121
|
+
OPTIONS_ATM_IV_HISTORY: (symbol: string) => `api/v1/markets/instruments/${symbol}/options/atm_iv_history`,
|
|
122
|
+
MACRO_CALENDAR: 'api/v1/markets/macro_calendar',
|
|
84
123
|
OPTIONS_GREEKS: 'api/v1/markets/options/greeks',
|
|
124
|
+
OPTIONS_UNUSUAL_ACTIVITY: 'api/v1/markets/options/unusual_activity',
|
|
85
125
|
VIX_TERM: 'api/v1/markets/vix_term',
|
|
86
126
|
|
|
87
127
|
// Sources ops
|
package/src/index.ts
CHANGED
|
@@ -25,7 +25,7 @@ export type { MessageStats } from './lib/message-stats';
|
|
|
25
25
|
export { MessageTemplatesApi } from './lib/message-templates-api';
|
|
26
26
|
export type { MessageTemplate, MessageTemplateFilters, CreateMessageTemplateInput } from './lib/message-templates-api';
|
|
27
27
|
export { UsersApi } from './lib/users-api';
|
|
28
|
-
export type { UserProfile, UpdateProfileRequest, ChangePasswordRequest, ChangePasswordResponse } from './types/user';
|
|
28
|
+
export type { UserProfile, UpdateProfileRequest, ChangePasswordRequest, ChangePasswordResponse, EarlyRegisterRequest, EarlyRegisterResponse } from './types/user';
|
|
29
29
|
export { FunnelsApi, isFunnelRunConflict, isFunnelValidationError } from './lib/funnels-api';
|
|
30
30
|
export type {
|
|
31
31
|
FunnelPreviewResult,
|
|
@@ -163,6 +163,11 @@ import { EnrichmentApi } from './lib/enrichment-api';
|
|
|
163
163
|
import { TargetListsApi } from './lib/target-lists-api';
|
|
164
164
|
import { MessageTemplatesApi } from './lib/message-templates-api';
|
|
165
165
|
import { MarketsApi } from './lib/markets-api';
|
|
166
|
+
import { VaultApi } from './lib/vault-api';
|
|
167
|
+
import { CompaniesApi } from './lib/companies-api';
|
|
168
|
+
import { TeamsApi } from './lib/teams-api';
|
|
169
|
+
import { TeamInvitationsApi } from './lib/team-invitations-api';
|
|
170
|
+
import { DomainClaimsApi } from './lib/domain-claims-api';
|
|
166
171
|
|
|
167
172
|
import type { ApiClientConfig } from './lib/api-client';
|
|
168
173
|
|
|
@@ -187,9 +192,36 @@ export function createStartSimpliApi(config: ApiClientConfig = {}) {
|
|
|
187
192
|
targetLists: new TargetListsApi(client),
|
|
188
193
|
messageTemplates: new MessageTemplatesApi(client),
|
|
189
194
|
markets: new MarketsApi(client),
|
|
195
|
+
vault: new VaultApi(client),
|
|
196
|
+
companies: new CompaniesApi(client),
|
|
197
|
+
teams: new TeamsApi(client),
|
|
198
|
+
teamInvitations: new TeamInvitationsApi(client),
|
|
199
|
+
domainClaims: new DomainClaimsApi(client),
|
|
190
200
|
};
|
|
191
201
|
}
|
|
192
202
|
|
|
203
|
+
// Team management (startsim-o7s)
|
|
204
|
+
export { CompaniesApi } from './lib/companies-api';
|
|
205
|
+
export { TeamsApi } from './lib/teams-api';
|
|
206
|
+
export type { MyTeamMembership } from './lib/teams-api';
|
|
207
|
+
export { TeamInvitationsApi } from './lib/team-invitations-api';
|
|
208
|
+
export { DomainClaimsApi } from './lib/domain-claims-api';
|
|
209
|
+
|
|
210
|
+
// Vault API
|
|
211
|
+
export { VaultApi } from './lib/vault-api';
|
|
212
|
+
export type {
|
|
213
|
+
VaultEnvironment,
|
|
214
|
+
VaultEnvironmentInput,
|
|
215
|
+
VaultSecret,
|
|
216
|
+
VaultSecretInput,
|
|
217
|
+
VaultSecretReveal,
|
|
218
|
+
VaultAccessKey,
|
|
219
|
+
VaultAccessKeyCreated,
|
|
220
|
+
VaultAccessKeyInput,
|
|
221
|
+
VaultAuditEntry,
|
|
222
|
+
VaultListParams,
|
|
223
|
+
} from './lib/vault-api';
|
|
224
|
+
|
|
193
225
|
// Markets API
|
|
194
226
|
export { MarketsApi } from './lib/markets-api';
|
|
195
227
|
export type {
|
|
@@ -239,9 +271,29 @@ export type {
|
|
|
239
271
|
OptionsIvResponse,
|
|
240
272
|
OptionsIvHistoryParams,
|
|
241
273
|
OptionsSkewPoint,
|
|
274
|
+
OptionsChainRaw,
|
|
242
275
|
OptionsGreeks,
|
|
276
|
+
OptionsUnusualActivityPoint,
|
|
277
|
+
OptionsUnusualActivityParams,
|
|
278
|
+
OptionsUnusualActivityResponse,
|
|
279
|
+
AtmIvHistoryPoint,
|
|
280
|
+
AtmIvHistoryParams,
|
|
281
|
+
AtmIvHistoryResponse,
|
|
282
|
+
MacroCategory,
|
|
283
|
+
MacroCalendarRow,
|
|
284
|
+
MacroCalendarParams,
|
|
285
|
+
MacroCalendarResponse,
|
|
243
286
|
VixTenor,
|
|
244
287
|
VixTermState,
|
|
245
288
|
VixTermPoint,
|
|
246
289
|
VixTermResponse,
|
|
290
|
+
BarsBulkParams,
|
|
291
|
+
BarsBulkResponse,
|
|
292
|
+
SocialPlatform,
|
|
293
|
+
SocialDirection,
|
|
294
|
+
SocialPost,
|
|
295
|
+
SocialPostMatchedEntity,
|
|
296
|
+
SocialPostResolvedContact,
|
|
297
|
+
SocialPostsParams,
|
|
298
|
+
SocialPostsResponse,
|
|
247
299
|
} from './lib/markets-api';
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { CompaniesApi } from '../companies-api';
|
|
3
|
+
|
|
4
|
+
function makeApi() {
|
|
5
|
+
const fetch = { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() };
|
|
6
|
+
const api = new CompaniesApi({ fetch } as never);
|
|
7
|
+
return { api, fetch };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('CompaniesApi', () => {
|
|
11
|
+
let api: CompaniesApi;
|
|
12
|
+
let fetch: { get: ReturnType<typeof vi.fn>; post: ReturnType<typeof vi.fn>; patch: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> };
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
({ api, fetch } = makeApi());
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('list passes params through to the companies endpoint', async () => {
|
|
19
|
+
fetch.get.mockResolvedValue({ results: [], count: 0 });
|
|
20
|
+
await api.list({ search: 'acme', pageSize: 50 });
|
|
21
|
+
expect(fetch.get).toHaveBeenCalledWith('api/v1/companies', {
|
|
22
|
+
params: { search: 'acme', pageSize: 50 },
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('retrieve accepts either id or slug in the path', async () => {
|
|
27
|
+
fetch.get.mockResolvedValue({ id: '1', slug: 'acme' });
|
|
28
|
+
await api.retrieve('acme');
|
|
29
|
+
expect(fetch.get).toHaveBeenCalledWith('api/v1/companies/acme');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('update PATCHes the company with the camelCase payload', async () => {
|
|
33
|
+
fetch.patch.mockResolvedValue({ id: '1', slug: 'acme', name: 'Acme Inc.' });
|
|
34
|
+
await api.update('acme', { name: 'Acme Inc.' });
|
|
35
|
+
expect(fetch.patch).toHaveBeenCalledWith('api/v1/companies/acme', { name: 'Acme Inc.' });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('featureFlags.get unwraps the flags envelope', async () => {
|
|
39
|
+
fetch.get.mockResolvedValue({ flags: { section_inbox: true } });
|
|
40
|
+
const flags = await api.featureFlags.get('acme');
|
|
41
|
+
expect(fetch.get).toHaveBeenCalledWith('api/v1/companies/acme/feature-flags');
|
|
42
|
+
expect(flags).toEqual({ section_inbox: true });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('featureFlags.update PATCHes a wrapped flags payload', async () => {
|
|
46
|
+
fetch.patch.mockResolvedValue({ flags: { section_inbox: false } });
|
|
47
|
+
const flags = await api.featureFlags.update('acme', { section_inbox: false });
|
|
48
|
+
expect(fetch.patch).toHaveBeenCalledWith('api/v1/companies/acme/feature-flags', {
|
|
49
|
+
flags: { section_inbox: false },
|
|
50
|
+
});
|
|
51
|
+
expect(flags).toEqual({ section_inbox: false });
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { DomainClaimsApi } from '../domain-claims-api';
|
|
3
|
+
|
|
4
|
+
function makeApi() {
|
|
5
|
+
const fetch = { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() };
|
|
6
|
+
const api = new DomainClaimsApi({ fetch } as never);
|
|
7
|
+
return { api, fetch };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('DomainClaimsApi', () => {
|
|
11
|
+
let api: DomainClaimsApi;
|
|
12
|
+
let fetch: { get: ReturnType<typeof vi.fn>; post: ReturnType<typeof vi.fn>; patch: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> };
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
({ api, fetch } = makeApi());
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('list hits the team-domain-claims endpoint', async () => {
|
|
19
|
+
fetch.get.mockResolvedValue({ results: [], count: 0 });
|
|
20
|
+
await api.list({ companyId: 'c1', verified: true });
|
|
21
|
+
expect(fetch.get).toHaveBeenCalledWith('api/v1/team-domain-claims', {
|
|
22
|
+
params: { companyId: 'c1', verified: true },
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('create POSTs the claim payload + returns the visible token', async () => {
|
|
27
|
+
fetch.post.mockResolvedValue({
|
|
28
|
+
id: 'd1',
|
|
29
|
+
domain: 'acme.com',
|
|
30
|
+
verificationToken: 'startsim-verify=xyz',
|
|
31
|
+
});
|
|
32
|
+
const r = await api.create({ companyId: 'c1', domain: 'acme.com' });
|
|
33
|
+
expect(fetch.post).toHaveBeenCalledWith('api/v1/team-domain-claims', {
|
|
34
|
+
companyId: 'c1',
|
|
35
|
+
domain: 'acme.com',
|
|
36
|
+
});
|
|
37
|
+
expect(r.verificationToken).toBe('startsim-verify=xyz');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('verifyDns posts the verify-dns action', async () => {
|
|
41
|
+
fetch.post.mockResolvedValue({ id: 'd1', verified: true });
|
|
42
|
+
await api.verifyDns('d1');
|
|
43
|
+
expect(fetch.post).toHaveBeenCalledWith('api/v1/team-domain-claims/d1/verify-dns');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('verifyEmailInitiate posts the initiate action', async () => {
|
|
47
|
+
fetch.post.mockResolvedValue({ detail: 'sent' });
|
|
48
|
+
await api.verifyEmailInitiate('d1');
|
|
49
|
+
expect(fetch.post).toHaveBeenCalledWith(
|
|
50
|
+
'api/v1/team-domain-claims/d1/verify-email-initiate',
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('verifyEmailCode posts the code', async () => {
|
|
55
|
+
fetch.post.mockResolvedValue({ id: 'd1', verified: true });
|
|
56
|
+
await api.verifyEmailCode('d1', '123456');
|
|
57
|
+
expect(fetch.post).toHaveBeenCalledWith(
|
|
58
|
+
'api/v1/team-domain-claims/d1/verify-email-code',
|
|
59
|
+
{ code: '123456' },
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('revoke posts the revoke action', async () => {
|
|
64
|
+
fetch.post.mockResolvedValue(undefined);
|
|
65
|
+
await api.revoke('d1');
|
|
66
|
+
expect(fetch.post).toHaveBeenCalledWith('api/v1/team-domain-claims/d1/revoke');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { TeamInvitationsApi } from '../team-invitations-api';
|
|
3
|
+
|
|
4
|
+
function makeApi() {
|
|
5
|
+
const fetch = { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() };
|
|
6
|
+
const api = new TeamInvitationsApi({ fetch } as never);
|
|
7
|
+
return { api, fetch };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('TeamInvitationsApi', () => {
|
|
11
|
+
let api: TeamInvitationsApi;
|
|
12
|
+
let fetch: { get: ReturnType<typeof vi.fn>; post: ReturnType<typeof vi.fn>; patch: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> };
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
({ api, fetch } = makeApi());
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('list reaches the invitations endpoint', async () => {
|
|
19
|
+
fetch.get.mockResolvedValue({ results: [], count: 0 });
|
|
20
|
+
await api.list({ teamId: 't1' });
|
|
21
|
+
expect(fetch.get).toHaveBeenCalledWith('api/v1/team-invitations', {
|
|
22
|
+
params: { teamId: 't1' },
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('create POSTs the invitation payload', async () => {
|
|
27
|
+
fetch.post.mockResolvedValue({ id: 'i1', token: 'raw-token' });
|
|
28
|
+
const r = await api.create({ email: 'a@x.com', teamId: 't1', role: 'member' });
|
|
29
|
+
expect(fetch.post).toHaveBeenCalledWith('api/v1/team-invitations', {
|
|
30
|
+
email: 'a@x.com',
|
|
31
|
+
teamId: 't1',
|
|
32
|
+
role: 'member',
|
|
33
|
+
});
|
|
34
|
+
expect(r.token).toBe('raw-token');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('revoke POSTs the revoke action', async () => {
|
|
38
|
+
fetch.post.mockResolvedValue(undefined);
|
|
39
|
+
await api.revoke('i1');
|
|
40
|
+
expect(fetch.post).toHaveBeenCalledWith('api/v1/team-invitations/i1/revoke');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('accept POSTs the token in the body', async () => {
|
|
44
|
+
fetch.post.mockResolvedValue({ id: 'i1', acceptedAt: '2025-01-01' });
|
|
45
|
+
await api.accept('i1', 'raw-token');
|
|
46
|
+
expect(fetch.post).toHaveBeenCalledWith('api/v1/team-invitations/i1/accept', {
|
|
47
|
+
token: 'raw-token',
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { TeamsApi } from '../teams-api';
|
|
3
|
+
|
|
4
|
+
function makeApi() {
|
|
5
|
+
const fetch = { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() };
|
|
6
|
+
const api = new TeamsApi({ fetch } as never);
|
|
7
|
+
return { api, fetch };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('TeamsApi', () => {
|
|
11
|
+
let api: TeamsApi;
|
|
12
|
+
let fetch: { get: ReturnType<typeof vi.fn>; post: ReturnType<typeof vi.fn>; patch: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> };
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
({ api, fetch } = makeApi());
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('list hits the teams endpoint with params', async () => {
|
|
19
|
+
fetch.get.mockResolvedValue({ results: [], count: 0 });
|
|
20
|
+
await api.list({ companyId: 'c1' });
|
|
21
|
+
expect(fetch.get).toHaveBeenCalledWith('api/v1/teams', { params: { companyId: 'c1' } });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('retrieve accepts slug', async () => {
|
|
25
|
+
fetch.get.mockResolvedValue({ id: '1', slug: 'eng' });
|
|
26
|
+
await api.retrieve('eng');
|
|
27
|
+
expect(fetch.get).toHaveBeenCalledWith('api/v1/teams/eng');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('members returns the bare array (no pagination wrapper)', async () => {
|
|
31
|
+
fetch.get.mockResolvedValue([{ id: 'm1', userId: 'u1', role: 'owner' }]);
|
|
32
|
+
const members = await api.members('eng');
|
|
33
|
+
expect(fetch.get).toHaveBeenCalledWith('api/v1/teams/eng/members');
|
|
34
|
+
expect(members).toHaveLength(1);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('bulkInvite POSTs the invitations array wrapped in an envelope', async () => {
|
|
38
|
+
fetch.post.mockResolvedValue({ invited: [], skipped: [] });
|
|
39
|
+
await api.bulkInvite('eng', [
|
|
40
|
+
{ email: 'a@x.com', role: 'member' },
|
|
41
|
+
{ email: 'b@x.com', role: 'admin' },
|
|
42
|
+
]);
|
|
43
|
+
expect(fetch.post).toHaveBeenCalledWith('api/v1/teams/eng/bulk-invite', {
|
|
44
|
+
invitations: [
|
|
45
|
+
{ email: 'a@x.com', role: 'member' },
|
|
46
|
+
{ email: 'b@x.com', role: 'admin' },
|
|
47
|
+
],
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('removeMember sends userId in the body, not the URL', async () => {
|
|
52
|
+
fetch.post.mockResolvedValue(undefined);
|
|
53
|
+
await api.removeMember('eng', 'u42');
|
|
54
|
+
expect(fetch.post).toHaveBeenCalledWith('api/v1/teams/eng/remove-member', {
|
|
55
|
+
userId: 'u42',
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('updateRole sends userId + role in the body', async () => {
|
|
60
|
+
fetch.post.mockResolvedValue({ id: 'm1', userId: 'u42', role: 'admin' });
|
|
61
|
+
await api.updateRole('eng', 'u42', 'admin');
|
|
62
|
+
expect(fetch.post).toHaveBeenCalledWith('api/v1/teams/eng/update-role', {
|
|
63
|
+
userId: 'u42',
|
|
64
|
+
role: 'admin',
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('myTeams returns the unpaginated membership array', async () => {
|
|
69
|
+
fetch.get.mockResolvedValue([{ id: 'm1', teamId: 't1', role: 'owner' }]);
|
|
70
|
+
const rows = await api.myTeams();
|
|
71
|
+
expect(fetch.get).toHaveBeenCalledWith('api/v1/team-members/my-teams');
|
|
72
|
+
expect(rows[0].role).toBe('owner');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Companies API wrapper for /api/v1/companies/*.
|
|
3
|
+
*
|
|
4
|
+
* The Django CompanyViewSet accepts numeric id OR slug as the URL identifier.
|
|
5
|
+
* Feature-flags get their own nested endpoint so app shells can fetch flags
|
|
6
|
+
* without pulling the full company object. startsim-o7s.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ApiClient } from './api-client';
|
|
10
|
+
import { ENDPOINTS } from '../constants/endpoints';
|
|
11
|
+
import type {
|
|
12
|
+
Company,
|
|
13
|
+
CompanyListParams,
|
|
14
|
+
UpdateCompanyInput,
|
|
15
|
+
} from '../types/team';
|
|
16
|
+
import type { FeatureFlags, FeatureFlagsResponse } from './feature-flags';
|
|
17
|
+
import type { PaginatedResponse } from '../types';
|
|
18
|
+
|
|
19
|
+
export class CompaniesApi {
|
|
20
|
+
constructor(private client: ApiClient) {}
|
|
21
|
+
|
|
22
|
+
/** List companies the current user belongs to. */
|
|
23
|
+
list(params?: CompanyListParams): Promise<PaginatedResponse<Company>> {
|
|
24
|
+
return this.client.fetch.get<PaginatedResponse<Company>>(ENDPOINTS.COMPANIES, { params });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Retrieve a single company by id or slug. */
|
|
28
|
+
retrieve(idOrSlug: string): Promise<Company> {
|
|
29
|
+
return this.client.fetch.get<Company>(ENDPOINTS.COMPANY(idOrSlug));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** PATCH update — name, slug, settings. */
|
|
33
|
+
update(idOrSlug: string, patch: UpdateCompanyInput): Promise<Company> {
|
|
34
|
+
return this.client.fetch.patch<Company>(ENDPOINTS.COMPANY(idOrSlug), patch);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Per-company feature-flag accessors (admin-gated on PATCH). */
|
|
38
|
+
readonly featureFlags = {
|
|
39
|
+
get: (idOrSlug: string): Promise<FeatureFlags> =>
|
|
40
|
+
this.client.fetch
|
|
41
|
+
.get<FeatureFlagsResponse>(ENDPOINTS.COMPANY_FEATURE_FLAGS(idOrSlug))
|
|
42
|
+
.then((r) => r.flags),
|
|
43
|
+
update: (idOrSlug: string, patch: Partial<FeatureFlags>): Promise<FeatureFlags> =>
|
|
44
|
+
this.client.fetch
|
|
45
|
+
.patch<FeatureFlagsResponse>(ENDPOINTS.COMPANY_FEATURE_FLAGS(idOrSlug), { flags: patch })
|
|
46
|
+
.then((r) => r.flags),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EmailDomainClaim API wrapper for /api/v1/team-domain-claims/*.
|
|
3
|
+
*
|
|
4
|
+
* Two verification methods are supported by the backend (startsim-gpu):
|
|
5
|
+
* - DNS TXT: caller adds a TXT record, then calls verify-dns.
|
|
6
|
+
* - Email attestation: backend sends a 6-digit code to a postmaster@<domain>
|
|
7
|
+
* mailbox; caller submits the code via verify-email-code.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ApiClient } from './api-client';
|
|
11
|
+
import { ENDPOINTS } from '../constants/endpoints';
|
|
12
|
+
import type {
|
|
13
|
+
EmailDomainClaim,
|
|
14
|
+
DomainClaimListParams,
|
|
15
|
+
CreateDomainClaimInput,
|
|
16
|
+
DomainVerifyEmailInitiateResponse,
|
|
17
|
+
} from '../types/team';
|
|
18
|
+
import type { PaginatedResponse } from '../types';
|
|
19
|
+
|
|
20
|
+
export class DomainClaimsApi {
|
|
21
|
+
constructor(private client: ApiClient) {}
|
|
22
|
+
|
|
23
|
+
list(params?: DomainClaimListParams): Promise<PaginatedResponse<EmailDomainClaim>> {
|
|
24
|
+
return this.client.fetch.get<PaginatedResponse<EmailDomainClaim>>(
|
|
25
|
+
ENDPOINTS.TEAM_DOMAIN_CLAIMS,
|
|
26
|
+
{ params },
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Create a new claim. The response includes the verification_token (visible
|
|
32
|
+
* only to the creator); subsequent reads return null.
|
|
33
|
+
*/
|
|
34
|
+
create(input: CreateDomainClaimInput): Promise<EmailDomainClaim> {
|
|
35
|
+
return this.client.fetch.post<EmailDomainClaim>(ENDPOINTS.TEAM_DOMAIN_CLAIMS, input);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Trigger DNS TXT lookup. Backend marks verified=true on a clean match. */
|
|
39
|
+
verifyDns(id: string): Promise<EmailDomainClaim> {
|
|
40
|
+
return this.client.fetch.post<EmailDomainClaim>(
|
|
41
|
+
ENDPOINTS.TEAM_DOMAIN_CLAIM_VERIFY_DNS(id),
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Kick off email-attestation flow — backend emails postmaster@<domain>. */
|
|
46
|
+
verifyEmailInitiate(id: string): Promise<DomainVerifyEmailInitiateResponse> {
|
|
47
|
+
return this.client.fetch.post<DomainVerifyEmailInitiateResponse>(
|
|
48
|
+
ENDPOINTS.TEAM_DOMAIN_CLAIM_VERIFY_EMAIL_INITIATE(id),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Submit the postmaster mailbox's 6-digit code to complete attestation. */
|
|
53
|
+
verifyEmailCode(id: string, code: string): Promise<EmailDomainClaim> {
|
|
54
|
+
return this.client.fetch.post<EmailDomainClaim>(
|
|
55
|
+
ENDPOINTS.TEAM_DOMAIN_CLAIM_VERIFY_EMAIL_CODE(id),
|
|
56
|
+
{ code },
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
revoke(id: string): Promise<void> {
|
|
61
|
+
return this.client.fetch.post<void>(ENDPOINTS.TEAM_DOMAIN_CLAIM_REVOKE(id));
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/lib/error-handler.ts
CHANGED
|
@@ -9,23 +9,34 @@ export class ApiException extends Error {
|
|
|
9
9
|
public statusText?: string;
|
|
10
10
|
public errors?: Record<string, string[]>;
|
|
11
11
|
public detail?: string;
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
/** Machine-readable code from the standardized error response (e.g.
|
|
13
|
+
* 'limit_reached', 'no_company'). Set by parseErrorResponse when the
|
|
14
|
+
* backend emits a structured error shape via the core exception handler. */
|
|
15
|
+
public code?: string;
|
|
16
|
+
/** Extra context the backend stapled onto the standardized response —
|
|
17
|
+
* e.g. limit_reached carries { feature_key, limit, current }. */
|
|
18
|
+
public details?: Record<string, unknown>;
|
|
19
|
+
|
|
20
|
+
constructor(message: string, options?: Partial<DRFApiError> & { code?: string; details?: Record<string, unknown> }) {
|
|
14
21
|
super(message);
|
|
15
22
|
this.name = 'ApiException';
|
|
16
23
|
this.status = options?.status;
|
|
17
24
|
this.statusText = options?.statusText;
|
|
18
25
|
this.errors = options?.errors;
|
|
19
26
|
this.detail = options?.detail;
|
|
27
|
+
this.code = options?.code;
|
|
28
|
+
this.details = options?.details;
|
|
20
29
|
}
|
|
21
30
|
|
|
22
|
-
toJSON(): DRFApiError {
|
|
31
|
+
toJSON(): DRFApiError & { code?: string; details?: Record<string, unknown> } {
|
|
23
32
|
return {
|
|
24
33
|
message: this.message,
|
|
25
34
|
detail: this.detail,
|
|
26
35
|
status: this.status,
|
|
27
36
|
statusText: this.statusText,
|
|
28
37
|
errors: this.errors,
|
|
38
|
+
code: this.code,
|
|
39
|
+
details: this.details,
|
|
29
40
|
};
|
|
30
41
|
}
|
|
31
42
|
}
|
|
@@ -41,7 +52,31 @@ export async function parseErrorResponse(response: Response): Promise<DRFApiErro
|
|
|
41
52
|
try {
|
|
42
53
|
const data = await response.json();
|
|
43
54
|
|
|
44
|
-
//
|
|
55
|
+
// 1) Standardized response shape from apps.core.exceptions:
|
|
56
|
+
// { error, code, statusCode, fieldErrors?, ...details } — the
|
|
57
|
+
// 'error' field carries the human message; 'code' is the machine
|
|
58
|
+
// code (e.g. 'limit_reached'); any extra keys are structured
|
|
59
|
+
// context the view stapled on (feature_key, limit, current, …).
|
|
60
|
+
// Snake_case keys keep through camelCase transform too (already
|
|
61
|
+
// converted by snakeToCamel before we get here when the client
|
|
62
|
+
// applies it; both shapes handled here for safety).
|
|
63
|
+
if (typeof data === 'object' && data !== null && typeof (data.error ?? data.message) === 'string' && data.code) {
|
|
64
|
+
const reservedKeys = new Set(['error', 'code', 'statusCode', 'status_code', 'fieldErrors', 'field_errors', 'timestamp', 'requestId', 'request_id', 'detail']);
|
|
65
|
+
const extras: Record<string, unknown> = {};
|
|
66
|
+
for (const [k, v] of Object.entries(data as Record<string, unknown>)) {
|
|
67
|
+
if (!reservedKeys.has(k)) extras[k] = v;
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
message: (data.error as string) ?? (data.message as string),
|
|
71
|
+
detail: (data.detail as string | undefined) ?? (data.error as string),
|
|
72
|
+
code: data.code as string,
|
|
73
|
+
details: Object.keys(extras).length > 0 ? extras : undefined,
|
|
74
|
+
status: response.status,
|
|
75
|
+
statusText: response.statusText,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 2) Plain Django REST Framework error: { detail: '...' }
|
|
45
80
|
if (data.detail) {
|
|
46
81
|
return {
|
|
47
82
|
detail: data.detail,
|
|
@@ -51,7 +86,7 @@ export async function parseErrorResponse(response: Response): Promise<DRFApiErro
|
|
|
51
86
|
};
|
|
52
87
|
}
|
|
53
88
|
|
|
54
|
-
//
|
|
89
|
+
// 3) Field-level validation errors: { email: ['...'], password: ['...'] }
|
|
55
90
|
if (typeof data === 'object') {
|
|
56
91
|
return {
|
|
57
92
|
errors: data,
|
package/src/lib/errors.ts
CHANGED
|
@@ -70,8 +70,13 @@ export class AppError extends Error {
|
|
|
70
70
|
this.details = details;
|
|
71
71
|
this.isOperational = isOperational;
|
|
72
72
|
|
|
73
|
-
// Maintains proper stack trace
|
|
74
|
-
|
|
73
|
+
// Maintains proper stack trace where supported (V8/Hermes). Guarded and
|
|
74
|
+
// typed locally so the package stays portable to engines/tsconfigs without
|
|
75
|
+
// the Node-only Error.captureStackTrace global (e.g. React Native).
|
|
76
|
+
const captureStackTrace = (
|
|
77
|
+
Error as { captureStackTrace?: (target: object, ctor?: unknown) => void }
|
|
78
|
+
).captureStackTrace;
|
|
79
|
+
captureStackTrace?.(this, this.constructor);
|
|
75
80
|
}
|
|
76
81
|
|
|
77
82
|
/**
|