@oxy.so/core 1.0.1 → 1.1.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/OxyServices.errors.js +28 -1
- package/dist/cjs/i18n/locales/en-US.json +14 -1
- package/dist/cjs/i18n/locales/es-ES.json +14 -1
- package/dist/cjs/i18n/locales/locales/en-US.json +14 -1
- package/dist/cjs/i18n/locales/locales/es-ES.json +14 -1
- package/dist/cjs/mixins/OxyServices.agency.js +113 -0
- package/dist/cjs/mixins/OxyServices.assets.js +90 -0
- package/dist/cjs/mixins/OxyServices.connectedApps.js +20 -3
- package/dist/cjs/mixins/OxyServices.utility.js +1 -21
- package/dist/cjs/mixins/index.js +3 -4
- package/dist/cjs/server/index.js +6 -1
- package/dist/cjs/server/traffic.js +107 -0
- package/dist/cjs/server/trafficNodeHttp.js +86 -0
- package/dist/cjs/utils/textNormalization.js +1 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/OxyServices.errors.js +26 -0
- package/dist/esm/i18n/locales/en-US.json +14 -1
- package/dist/esm/i18n/locales/es-ES.json +14 -1
- package/dist/esm/i18n/locales/locales/en-US.json +14 -1
- package/dist/esm/i18n/locales/locales/es-ES.json +14 -1
- package/dist/esm/mixins/OxyServices.agency.js +110 -0
- package/dist/esm/mixins/OxyServices.assets.js +91 -1
- package/dist/esm/mixins/OxyServices.connectedApps.js +20 -3
- package/dist/esm/mixins/OxyServices.utility.js +1 -21
- package/dist/esm/mixins/index.js +3 -4
- package/dist/esm/server/index.js +2 -0
- package/dist/esm/server/traffic.js +104 -0
- package/dist/esm/server/trafficNodeHttp.js +79 -0
- package/dist/esm/utils/textNormalization.js +1 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/OxyServices.errors.d.ts +24 -0
- package/dist/types/index.d.ts +2 -1
- package/dist/types/mixins/OxyServices.agency.d.ts +135 -0
- package/dist/types/mixins/OxyServices.assets.d.ts +43 -1
- package/dist/types/mixins/OxyServices.connectedApps.d.ts +15 -0
- package/dist/types/mixins/OxyServices.links.d.ts +2 -2
- package/dist/types/mixins/OxyServices.utility.d.ts +0 -9
- package/dist/types/mixins/index.d.ts +2 -2
- package/dist/types/models/interfaces.d.ts +26 -0
- package/dist/types/server/index.d.ts +3 -0
- package/dist/types/server/traffic.d.ts +27 -0
- package/dist/types/server/trafficNodeHttp.d.ts +6 -0
- package/dist/types/utils/textNormalization.d.ts +1 -1
- package/package.json +4 -4
- package/src/OxyServices.errors.ts +36 -0
- package/src/i18n/locales/en-US.json +14 -1
- package/src/i18n/locales/es-ES.json +14 -1
- package/src/index.ts +15 -0
- package/src/mixins/OxyServices.agency.ts +277 -0
- package/src/mixins/OxyServices.assets.ts +105 -2
- package/src/mixins/OxyServices.connectedApps.ts +42 -4
- package/src/mixins/OxyServices.utility.ts +1 -26
- package/src/mixins/__tests__/OxyServices.serviceLinkedDownloadUrl.test.ts +139 -0
- package/src/mixins/__tests__/agency.test.ts +120 -0
- package/src/mixins/__tests__/connectedApps.test.ts +23 -0
- package/src/mixins/index.ts +4 -5
- package/src/models/interfaces.ts +27 -0
- package/src/server/__tests__/traffic.test.ts +49 -0
- package/src/server/__tests__/trafficNodeHttp.test.ts +38 -0
- package/src/server/index.ts +5 -0
- package/src/server/traffic.ts +116 -0
- package/src/server/trafficNodeHttp.ts +69 -0
- package/src/utils/textNormalization.ts +1 -1
- package/src/mixins/OxyServices.links.ts +0 -107
- package/src/mixins/__tests__/OxyServices.links.test.ts +0 -154
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native Alia and digital-agent authority.
|
|
3
|
+
*
|
|
4
|
+
* These methods manage Oxy-owned delegation grants, account autonomy policy,
|
|
5
|
+
* execution authorizations and their audit trail. They deliberately use the
|
|
6
|
+
* signed-in account session: capability tickets and app service credentials
|
|
7
|
+
* stay on the server and are never exposed to Settings clients.
|
|
8
|
+
*/
|
|
9
|
+
import type {
|
|
10
|
+
AppCapabilityCatalog,
|
|
11
|
+
AuditEvent,
|
|
12
|
+
AutonomyLevel,
|
|
13
|
+
CapabilityCatalogBinding,
|
|
14
|
+
CapabilityPackage,
|
|
15
|
+
DelegationGrant,
|
|
16
|
+
GrantLimit,
|
|
17
|
+
ResourceRef,
|
|
18
|
+
ToolGrantOverride,
|
|
19
|
+
} from '@oxy.so/contracts';
|
|
20
|
+
import type { OxyServicesBase } from '../OxyServices.base';
|
|
21
|
+
import { CACHE_TIMES } from './mixinHelpers';
|
|
22
|
+
|
|
23
|
+
export type DelegationCatalogBinding = CapabilityCatalogBinding;
|
|
24
|
+
export type DelegationGrantView = DelegationGrant;
|
|
25
|
+
|
|
26
|
+
export interface CreateDelegationGrantInput {
|
|
27
|
+
ownerAccountId: string;
|
|
28
|
+
actorAccountId: string;
|
|
29
|
+
resource: ResourceRef;
|
|
30
|
+
capabilityPackages: CapabilityPackage[];
|
|
31
|
+
capabilities: string[];
|
|
32
|
+
toolOverrides?: ToolGrantOverride[];
|
|
33
|
+
limits?: GrantLimit[];
|
|
34
|
+
maximumAutonomy: AutonomyLevel;
|
|
35
|
+
canRedelegate?: boolean;
|
|
36
|
+
expiresAt?: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type UpdateDelegationGrantInput = Pick<
|
|
40
|
+
CreateDelegationGrantInput,
|
|
41
|
+
| 'capabilityPackages'
|
|
42
|
+
| 'capabilities'
|
|
43
|
+
| 'toolOverrides'
|
|
44
|
+
| 'limits'
|
|
45
|
+
| 'maximumAutonomy'
|
|
46
|
+
| 'canRedelegate'
|
|
47
|
+
| 'expiresAt'
|
|
48
|
+
>;
|
|
49
|
+
|
|
50
|
+
export interface AvailableCapabilityCatalog {
|
|
51
|
+
id: string;
|
|
52
|
+
appId: string;
|
|
53
|
+
version: string;
|
|
54
|
+
digest: string;
|
|
55
|
+
audience: string;
|
|
56
|
+
catalog: AppCapabilityCatalog;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface AccountCapabilityPolicy {
|
|
60
|
+
id: string;
|
|
61
|
+
accountId: string;
|
|
62
|
+
appSlug: string;
|
|
63
|
+
maximumAutonomy: AutonomyLevel;
|
|
64
|
+
deniedCapabilities: string[];
|
|
65
|
+
createdAt: string;
|
|
66
|
+
updatedAt: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface PutAccountCapabilityPolicyInput {
|
|
70
|
+
accountId: string;
|
|
71
|
+
maximumAutonomy: AutonomyLevel;
|
|
72
|
+
deniedCapabilities: string[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface CapabilityExecutionAuthorization {
|
|
76
|
+
id: string;
|
|
77
|
+
kind: 'direct_request' | 'automation';
|
|
78
|
+
requesterAccountId: string;
|
|
79
|
+
ownerAccountId: string;
|
|
80
|
+
coordinatorApplicationId: string;
|
|
81
|
+
coordinatorCredentialId: string;
|
|
82
|
+
actorType: 'alia' | 'agent';
|
|
83
|
+
actorAccountId: string | null;
|
|
84
|
+
resourceApp: string;
|
|
85
|
+
effectiveAccountId: string;
|
|
86
|
+
resourceType: string;
|
|
87
|
+
resourceKey: string;
|
|
88
|
+
tool: string;
|
|
89
|
+
runId: string | null;
|
|
90
|
+
stepId: string | null;
|
|
91
|
+
automationId: string | null;
|
|
92
|
+
maximumAutonomy: AutonomyLevel;
|
|
93
|
+
limits: GrantLimit[];
|
|
94
|
+
expiresAt: string;
|
|
95
|
+
revokedAt: string | null;
|
|
96
|
+
createdAt: string;
|
|
97
|
+
updatedAt: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function query(path: string, key: string, value: string): string {
|
|
101
|
+
return `${path}?${key}=${encodeURIComponent(value)}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function OxyServicesAgencyMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
105
|
+
return class extends Base {
|
|
106
|
+
async listAvailableCapabilityCatalogs(accountId: string): Promise<AvailableCapabilityCatalog[]> {
|
|
107
|
+
try {
|
|
108
|
+
const response = await this.makeRequest<{ catalogs: AvailableCapabilityCatalog[] }>(
|
|
109
|
+
'GET',
|
|
110
|
+
query('/capabilities/catalogs/available', 'accountId', accountId),
|
|
111
|
+
undefined,
|
|
112
|
+
{ cache: true, cacheTTL: CACHE_TIMES.MEDIUM },
|
|
113
|
+
);
|
|
114
|
+
return response.catalogs;
|
|
115
|
+
} catch (error) {
|
|
116
|
+
throw this.handleError(error);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async listDelegationGrants(ownerAccountId: string): Promise<DelegationGrantView[]> {
|
|
121
|
+
try {
|
|
122
|
+
const response = await this.makeRequest<{ grants: DelegationGrantView[] }>(
|
|
123
|
+
'GET',
|
|
124
|
+
query('/capabilities/grants', 'ownerAccountId', ownerAccountId),
|
|
125
|
+
undefined,
|
|
126
|
+
{ cache: true, cacheTTL: CACHE_TIMES.SHORT },
|
|
127
|
+
);
|
|
128
|
+
return response.grants;
|
|
129
|
+
} catch (error) {
|
|
130
|
+
throw this.handleError(error);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async createDelegationGrant(input: CreateDelegationGrantInput): Promise<DelegationGrantView> {
|
|
135
|
+
try {
|
|
136
|
+
const response = await this.makeRequest<{ grant: DelegationGrantView }>(
|
|
137
|
+
'POST',
|
|
138
|
+
'/capabilities/grants',
|
|
139
|
+
input,
|
|
140
|
+
{ cache: false },
|
|
141
|
+
);
|
|
142
|
+
this.clearCacheEntry(`GET:/capabilities/grants?ownerAccountId=${encodeURIComponent(input.ownerAccountId)}`);
|
|
143
|
+
return response.grant;
|
|
144
|
+
} catch (error) {
|
|
145
|
+
throw this.handleError(error);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async revokeDelegationGrant(grantId: string, ownerAccountId: string): Promise<void> {
|
|
150
|
+
try {
|
|
151
|
+
await this.makeRequest<void>(
|
|
152
|
+
'DELETE',
|
|
153
|
+
`/capabilities/grants/${encodeURIComponent(grantId)}`,
|
|
154
|
+
undefined,
|
|
155
|
+
{ cache: false },
|
|
156
|
+
);
|
|
157
|
+
this.clearCacheEntry(`GET:/capabilities/grants?ownerAccountId=${encodeURIComponent(ownerAccountId)}`);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
throw this.handleError(error);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async updateDelegationGrant(
|
|
164
|
+
grantId: string,
|
|
165
|
+
ownerAccountId: string,
|
|
166
|
+
input: UpdateDelegationGrantInput,
|
|
167
|
+
): Promise<DelegationGrantView> {
|
|
168
|
+
try {
|
|
169
|
+
const response = await this.makeRequest<{ grant: DelegationGrantView }>(
|
|
170
|
+
'PUT',
|
|
171
|
+
`/capabilities/grants/${encodeURIComponent(grantId)}`,
|
|
172
|
+
input,
|
|
173
|
+
{ cache: false },
|
|
174
|
+
);
|
|
175
|
+
this.clearCacheEntry(`GET:/capabilities/grants?ownerAccountId=${encodeURIComponent(ownerAccountId)}`);
|
|
176
|
+
return response.grant;
|
|
177
|
+
} catch (error) {
|
|
178
|
+
throw this.handleError(error);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async listAccountCapabilityPolicies(accountId: string): Promise<AccountCapabilityPolicy[]> {
|
|
183
|
+
try {
|
|
184
|
+
const response = await this.makeRequest<{ policies: AccountCapabilityPolicy[] }>(
|
|
185
|
+
'GET',
|
|
186
|
+
query('/capabilities/account-policies', 'accountId', accountId),
|
|
187
|
+
undefined,
|
|
188
|
+
{ cache: true, cacheTTL: CACHE_TIMES.SHORT },
|
|
189
|
+
);
|
|
190
|
+
return response.policies;
|
|
191
|
+
} catch (error) {
|
|
192
|
+
throw this.handleError(error);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async putAccountCapabilityPolicy(
|
|
197
|
+
appId: string,
|
|
198
|
+
input: PutAccountCapabilityPolicyInput,
|
|
199
|
+
): Promise<AccountCapabilityPolicy> {
|
|
200
|
+
try {
|
|
201
|
+
const response = await this.makeRequest<{ policy: AccountCapabilityPolicy }>(
|
|
202
|
+
'PUT',
|
|
203
|
+
`/capabilities/account-policies/${encodeURIComponent(appId)}`,
|
|
204
|
+
input,
|
|
205
|
+
{ cache: false },
|
|
206
|
+
);
|
|
207
|
+
this.clearCacheEntry(`GET:/capabilities/account-policies?accountId=${encodeURIComponent(input.accountId)}`);
|
|
208
|
+
return response.policy;
|
|
209
|
+
} catch (error) {
|
|
210
|
+
throw this.handleError(error);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async deleteAccountCapabilityPolicy(appId: string, accountId: string): Promise<void> {
|
|
215
|
+
try {
|
|
216
|
+
await this.makeRequest<void>(
|
|
217
|
+
'DELETE',
|
|
218
|
+
`/capabilities/account-policies/${encodeURIComponent(appId)}?accountId=${encodeURIComponent(accountId)}`,
|
|
219
|
+
undefined,
|
|
220
|
+
{ cache: false },
|
|
221
|
+
);
|
|
222
|
+
this.clearCacheEntry(`GET:/capabilities/account-policies?accountId=${encodeURIComponent(accountId)}`);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
throw this.handleError(error);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async listCapabilityExecutionAuthorizations(
|
|
229
|
+
ownerAccountId: string,
|
|
230
|
+
): Promise<CapabilityExecutionAuthorization[]> {
|
|
231
|
+
try {
|
|
232
|
+
const response = await this.makeRequest<{ authorizations: CapabilityExecutionAuthorization[] }>(
|
|
233
|
+
'GET',
|
|
234
|
+
query('/capabilities/execution-authorizations', 'ownerAccountId', ownerAccountId),
|
|
235
|
+
undefined,
|
|
236
|
+
{ cache: true, cacheTTL: CACHE_TIMES.SHORT },
|
|
237
|
+
);
|
|
238
|
+
return response.authorizations;
|
|
239
|
+
} catch (error) {
|
|
240
|
+
throw this.handleError(error);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async revokeCapabilityExecutionAuthorization(
|
|
245
|
+
authorizationId: string,
|
|
246
|
+
ownerAccountId: string,
|
|
247
|
+
): Promise<void> {
|
|
248
|
+
try {
|
|
249
|
+
await this.makeRequest<void>(
|
|
250
|
+
'DELETE',
|
|
251
|
+
`/capabilities/execution-authorizations/${encodeURIComponent(authorizationId)}`,
|
|
252
|
+
undefined,
|
|
253
|
+
{ cache: false },
|
|
254
|
+
);
|
|
255
|
+
this.clearCacheEntry(
|
|
256
|
+
`GET:/capabilities/execution-authorizations?ownerAccountId=${encodeURIComponent(ownerAccountId)}`,
|
|
257
|
+
);
|
|
258
|
+
} catch (error) {
|
|
259
|
+
throw this.handleError(error);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async listCapabilityAuditEvents(accountId: string): Promise<AuditEvent[]> {
|
|
264
|
+
try {
|
|
265
|
+
const response = await this.makeRequest<{ events: AuditEvent[] }>(
|
|
266
|
+
'GET',
|
|
267
|
+
query('/capabilities/audit', 'accountId', accountId),
|
|
268
|
+
undefined,
|
|
269
|
+
{ cache: true, cacheTTL: CACHE_TIMES.SHORT },
|
|
270
|
+
);
|
|
271
|
+
return response.events;
|
|
272
|
+
} catch (error) {
|
|
273
|
+
throw this.handleError(error);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type { AccountStorageUsageResponse, AssetUploadInput, AssetUrlResponse, AssetVariant, BatchFileAccessResponse, RNFileDescriptor, ServiceAssetMetadata, ServiceAssetMetadataBySha } from '../models/interfaces';
|
|
1
|
+
import type { AccountStorageUsageResponse, AssetUploadInput, AssetUrlResponse, AssetVariant, BatchFileAccessResponse, RNFileDescriptor, ServiceAssetMetadata, ServiceAssetMetadataBySha, ServiceLinkedDownloadUrl } from '../models/interfaces';
|
|
2
2
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
3
3
|
import { isReactNative } from '@oxy.so/protocol';
|
|
4
4
|
import { logger } from '../logger';
|
|
5
|
-
import { AssetUrlResolutionError, ServiceAssetMetadataError } from '../OxyServices.errors';
|
|
5
|
+
import { AssetUrlResolutionError, ServiceAssetMetadataError, ServiceLinkedDownloadUrlError } from '../OxyServices.errors';
|
|
6
6
|
import { extractErrorStatus } from '../utils/errorUtils';
|
|
7
7
|
import { redactUrlQuery } from '../utils/redactUrl';
|
|
8
8
|
|
|
@@ -22,6 +22,14 @@ const SERVICE_ASSET_METADATA_CHUNK_SIZE = 100;
|
|
|
22
22
|
*/
|
|
23
23
|
const SERVICE_ASSET_METADATA_BY_SHA_CHUNK_SIZE = 100;
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Maximum number of ids sent per `POST /assets/service/linked-url` request.
|
|
27
|
+
* Matches that route's own cap, which is a QUARTER of the metadata routes' —
|
|
28
|
+
* see `MAX_ASSETS_LINKED_URL` in the API for why minting download credentials is
|
|
29
|
+
* budgeted more tightly than projecting rows.
|
|
30
|
+
*/
|
|
31
|
+
const SERVICE_LINKED_DOWNLOAD_URL_CHUNK_SIZE = 25;
|
|
32
|
+
|
|
25
33
|
/**
|
|
26
34
|
* Lowercase hex SHA-256 digest matcher (exactly 64 hex chars). The reverse
|
|
27
35
|
* lookup drops non-conforming hashes client-side so a single malformed value
|
|
@@ -426,6 +434,101 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
|
|
|
426
434
|
return settled.flat();
|
|
427
435
|
}
|
|
428
436
|
|
|
437
|
+
/**
|
|
438
|
+
* Mint short-lived direct download URLs for files the calling application's
|
|
439
|
+
* own users attached to it, via `POST /assets/service/linked-url`.
|
|
440
|
+
*
|
|
441
|
+
* This is the ONLY path in this SDK by which a service token reaches file
|
|
442
|
+
* BYTES. {@link getServiceAssetMetadataByIds} is metadata-only, and every
|
|
443
|
+
* other download helper here — {@link getFileDownloadUrl},
|
|
444
|
+
* {@link getFileDownloadUrlAsync}, {@link getBatchFileAccess} — resolves a URL
|
|
445
|
+
* for the CURRENT USER through `makeRequest`, which is a different question: a
|
|
446
|
+
* relying service holds no user session, and the buyer whose entitlement it is
|
|
447
|
+
* enforcing is not a viewer Oxy knows anything about.
|
|
448
|
+
*
|
|
449
|
+
* **Service-token auth (required), plus the `files:linked:read` scope**, which
|
|
450
|
+
* is NOT implied by `files:read`. The calling client must be service-configured
|
|
451
|
+
* (`configureServiceAuth(apiKey, apiSecret)`); a user-session request is
|
|
452
|
+
* rejected by the route's service-auth guard.
|
|
453
|
+
*
|
|
454
|
+
* **What the server will and will not give you.** A URL comes back only for a
|
|
455
|
+
* file whose OWN OWNER linked it to this application (`file_links.created_by =
|
|
456
|
+
* files.owner_user_id`). Every other id is omitted: unknown, deleted,
|
|
457
|
+
* system-owned, linked by somebody else, or not linked here at all. So the
|
|
458
|
+
* result is routinely shorter than the input and must be mapped by `id`.
|
|
459
|
+
*
|
|
460
|
+
* ABSENCE IS A REFUSAL, NOT A FACT ABOUT THE FILE, and the caller is the only
|
|
461
|
+
* party that can tell the difference. The route deliberately cannot — it
|
|
462
|
+
* answers identically for "no such file" and "not yours" so it leaks nothing —
|
|
463
|
+
* which means a service must decide what to tell its user from its OWN
|
|
464
|
+
* entitlement record, never from this response. Do not surface "file not
|
|
465
|
+
* found" on the strength of an omission.
|
|
466
|
+
*
|
|
467
|
+
* FAILURE IS NOT ABSENCE EITHER, and here it cannot be opted out of: a failed
|
|
468
|
+
* chunk throws {@link ServiceLinkedDownloadUrlError} carrying its ids, with no
|
|
469
|
+
* `{ partial: true }` mode. Swallowing a 429 would tell a user who paid for a
|
|
470
|
+
* file that it is not there. An empty input resolves to `[]` with no network
|
|
471
|
+
* call.
|
|
472
|
+
*
|
|
473
|
+
* Never cached at the SDK layer, and that is a stronger statement than the
|
|
474
|
+
* sibling methods' "low hit rate": each entry is a live credential for the
|
|
475
|
+
* bytes, so caching one keeps it usable after the service has decided the
|
|
476
|
+
* caller's entitlement ended.
|
|
477
|
+
*/
|
|
478
|
+
async getServiceLinkedDownloadUrls(ids: string[]): Promise<ServiceLinkedDownloadUrl[]> {
|
|
479
|
+
const uniqueIds = Array.from(
|
|
480
|
+
new Set(ids.filter((id): id is string => typeof id === 'string' && id.trim().length > 0)),
|
|
481
|
+
);
|
|
482
|
+
if (uniqueIds.length === 0) {
|
|
483
|
+
return [];
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const chunks: string[][] = [];
|
|
487
|
+
for (let i = 0; i < uniqueIds.length; i += SERVICE_LINKED_DOWNLOAD_URL_CHUNK_SIZE) {
|
|
488
|
+
chunks.push(uniqueIds.slice(i, i + SERVICE_LINKED_DOWNLOAD_URL_CHUNK_SIZE));
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Chunks stay independent so one failure never cancels work already in
|
|
492
|
+
// flight; the failures are collected and re-raised together below, exactly
|
|
493
|
+
// as in the metadata sibling.
|
|
494
|
+
const unresolvedIds: string[] = [];
|
|
495
|
+
const statuses: number[] = [];
|
|
496
|
+
let firstError: unknown;
|
|
497
|
+
|
|
498
|
+
const settled = await Promise.all(
|
|
499
|
+
chunks.map(async (chunk): Promise<ServiceLinkedDownloadUrl[]> => {
|
|
500
|
+
try {
|
|
501
|
+
const entries = await this.makeServiceRequest<ServiceLinkedDownloadUrl[]>(
|
|
502
|
+
'POST',
|
|
503
|
+
'/assets/service/linked-url',
|
|
504
|
+
{ ids: chunk },
|
|
505
|
+
);
|
|
506
|
+
return Array.isArray(entries) ? entries : [];
|
|
507
|
+
} catch (error: unknown) {
|
|
508
|
+
const status = extractErrorStatus(error);
|
|
509
|
+
// `chunkSize` and `status`, never the ids and never a `url`: a minted
|
|
510
|
+
// URL in a log line is a credential in a log line.
|
|
511
|
+
logger.warn('getServiceLinkedDownloadUrls: chunk failed', {
|
|
512
|
+
method: 'getServiceLinkedDownloadUrls',
|
|
513
|
+
chunkSize: chunk.length,
|
|
514
|
+
status,
|
|
515
|
+
error: error instanceof Error ? error.message : String(error),
|
|
516
|
+
});
|
|
517
|
+
unresolvedIds.push(...chunk);
|
|
518
|
+
if (typeof status === 'number') statuses.push(status);
|
|
519
|
+
firstError ??= error;
|
|
520
|
+
return [];
|
|
521
|
+
}
|
|
522
|
+
}),
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
if (unresolvedIds.length > 0) {
|
|
526
|
+
throw new ServiceLinkedDownloadUrlError(unresolvedIds, statuses, firstError);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return settled.flat();
|
|
530
|
+
}
|
|
531
|
+
|
|
429
532
|
/**
|
|
430
533
|
* Reverse content-address lookup: resolve many content `sha256` digests to
|
|
431
534
|
* the servable Oxy asset holding each, in one round-trip per chunk via
|
|
@@ -83,12 +83,20 @@ export interface ConnectedApp {
|
|
|
83
83
|
lastUsedAt: string;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/** An external MCP client authorized for one exact app resource and account. */
|
|
87
|
+
export interface ConnectedMcpClient {
|
|
88
|
+
id: string;
|
|
89
|
+
appSlug: string;
|
|
90
|
+
resource: string;
|
|
91
|
+
scopes: string[];
|
|
92
|
+
clientId: string;
|
|
93
|
+
clientName: string;
|
|
94
|
+
createdAt: string;
|
|
95
|
+
lastUsedAt: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
86
98
|
export function OxyServicesConnectedAppsMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
87
99
|
return class extends Base {
|
|
88
|
-
constructor(...args: any[]) {
|
|
89
|
-
super(...(args as [any]));
|
|
90
|
-
}
|
|
91
|
-
|
|
92
100
|
/**
|
|
93
101
|
* Resolve an OAuth client identifier to the owning application's PUBLIC
|
|
94
102
|
* identity. No authentication required — the API returns only sanitized,
|
|
@@ -170,5 +178,35 @@ export function OxyServicesConnectedAppsMixin<T extends typeof OxyServicesBase>(
|
|
|
170
178
|
throw this.handleError(error);
|
|
171
179
|
}
|
|
172
180
|
}
|
|
181
|
+
|
|
182
|
+
/** List resource-bound external MCP connections for the active Oxy account. */
|
|
183
|
+
async listConnectedMcpClients(): Promise<ConnectedMcpClient[]> {
|
|
184
|
+
try {
|
|
185
|
+
const response = await this.makeRequest<{ grants: ConnectedMcpClient[] }>(
|
|
186
|
+
'GET',
|
|
187
|
+
'/auth/mcp/oauth/grants',
|
|
188
|
+
undefined,
|
|
189
|
+
{ cache: true, cacheTTL: CACHE_TIMES.SHORT },
|
|
190
|
+
);
|
|
191
|
+
return response.grants;
|
|
192
|
+
} catch (error) {
|
|
193
|
+
throw this.handleError(error);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Revoke one external MCP connection and every token in its family. */
|
|
198
|
+
async revokeConnectedMcpClient(grantId: string): Promise<void> {
|
|
199
|
+
try {
|
|
200
|
+
await this.makeRequest<void>(
|
|
201
|
+
'DELETE',
|
|
202
|
+
`/auth/mcp/oauth/grants/${encodeURIComponent(grantId)}`,
|
|
203
|
+
undefined,
|
|
204
|
+
{ cache: false },
|
|
205
|
+
);
|
|
206
|
+
this.clearCacheEntry('GET:/auth/mcp/oauth/grants');
|
|
207
|
+
} catch (error) {
|
|
208
|
+
throw this.handleError(error);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
173
211
|
};
|
|
174
212
|
}
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Utility Methods Mixin
|
|
3
3
|
*
|
|
4
|
-
* Provides utility methods including
|
|
5
|
-
* and Express.js authentication middleware
|
|
4
|
+
* Provides utility methods including Express.js authentication middleware.
|
|
6
5
|
*/
|
|
7
6
|
import { jwtDecode } from 'jwt-decode';
|
|
8
7
|
import type { JsonWebKey } from 'node:crypto';
|
|
9
|
-
import type { LinkPreview } from '@oxy.so/contracts';
|
|
10
8
|
import type { ApiError, User } from '../models/interfaces';
|
|
11
9
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
12
10
|
import { loadNodeCrypto } from '@oxy.so/protocol';
|
|
@@ -278,29 +276,6 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
278
276
|
}
|
|
279
277
|
}
|
|
280
278
|
|
|
281
|
-
/**
|
|
282
|
-
* Fetch link metadata
|
|
283
|
-
*/
|
|
284
|
-
async fetchLinkMetadata(url: string): Promise<{
|
|
285
|
-
url: string;
|
|
286
|
-
title: string;
|
|
287
|
-
description: string;
|
|
288
|
-
image?: string;
|
|
289
|
-
}> {
|
|
290
|
-
try {
|
|
291
|
-
const path = buildUrl('/links/preview', { url, wait: 1 });
|
|
292
|
-
const preview = await this.makeRequest<LinkPreview>('GET', path, undefined, { cache: false });
|
|
293
|
-
return {
|
|
294
|
-
url: preview.url,
|
|
295
|
-
title: preview.title?.trim() || preview.url.replace(/^https?:\/\//, '').replace(/\/$/, ''),
|
|
296
|
-
description: preview.description?.trim() || 'Link',
|
|
297
|
-
image: preview.image,
|
|
298
|
-
};
|
|
299
|
-
} catch (error) {
|
|
300
|
-
throw this.handleError(error);
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
279
|
/**
|
|
305
280
|
* Express.js authentication middleware
|
|
306
281
|
*
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `getServiceLinkedDownloadUrls` mixin tests.
|
|
3
|
+
*
|
|
4
|
+
* Stubs `makeServiceRequest` (the service-token transport the route's
|
|
5
|
+
* `serviceAuthMiddleware` + `files:linked:read` scope require) so these run with
|
|
6
|
+
* no network and no `getServiceToken()` round-trip.
|
|
7
|
+
*
|
|
8
|
+
* The contract worth guarding here is not the transport — the metadata sibling
|
|
9
|
+
* already pins that shape — it is the TWO absences this method has to keep
|
|
10
|
+
* distinguishable, because conflating them misinforms a person who paid money:
|
|
11
|
+
*
|
|
12
|
+
* - The server OMITS an id it will not mint for, with no way to tell "no such
|
|
13
|
+
* file" from "not yours". That is a legitimate short result, and the caller
|
|
14
|
+
* has to map by `id`.
|
|
15
|
+
* - A FAILED chunk is not that. It throws, with no `{ partial: true }` escape
|
|
16
|
+
* hatch, because the sibling's best-effort mode would make a 429
|
|
17
|
+
* indistinguishable from the refusal above.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { ServiceLinkedDownloadUrl } from '../../models/interfaces';
|
|
21
|
+
import { OxyServices } from '../../OxyServices';
|
|
22
|
+
import { ServiceLinkedDownloadUrlError } from '../../OxyServices.errors';
|
|
23
|
+
|
|
24
|
+
const sampleEntry: ServiceLinkedDownloadUrl = {
|
|
25
|
+
id: 'asset-1',
|
|
26
|
+
url: 'https://s3.example/private/asset-1.stl?X-Amz-Signature=deadbeef',
|
|
27
|
+
expiresIn: 300,
|
|
28
|
+
mime: 'model/stl',
|
|
29
|
+
size: 4096,
|
|
30
|
+
sha256: 'a'.repeat(64),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
describe('OxyServices.assets — getServiceLinkedDownloadUrls', () => {
|
|
34
|
+
let oxy: OxyServices;
|
|
35
|
+
let makeServiceRequestSpy: jest.SpyInstance;
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
oxy = new OxyServices({ baseURL: 'http://test.invalid' });
|
|
39
|
+
makeServiceRequestSpy = jest.spyOn(oxy, 'makeServiceRequest');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
jest.restoreAllMocks();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('returns [] and performs no network call for empty / whitespace input', async () => {
|
|
47
|
+
await expect(oxy.getServiceLinkedDownloadUrls([])).resolves.toEqual([]);
|
|
48
|
+
await expect(oxy.getServiceLinkedDownloadUrls(['', ' '])).resolves.toEqual([]);
|
|
49
|
+
expect(makeServiceRequestSpy).not.toHaveBeenCalled();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('POSTs de-duplicated ids to /assets/service/linked-url as { ids }', async () => {
|
|
53
|
+
makeServiceRequestSpy.mockResolvedValueOnce([sampleEntry]);
|
|
54
|
+
|
|
55
|
+
await oxy.getServiceLinkedDownloadUrls(['asset-1', 'asset-1', ' ']);
|
|
56
|
+
|
|
57
|
+
expect(makeServiceRequestSpy).toHaveBeenCalledTimes(1);
|
|
58
|
+
expect(makeServiceRequestSpy).toHaveBeenCalledWith(
|
|
59
|
+
'POST',
|
|
60
|
+
'/assets/service/linked-url',
|
|
61
|
+
{ ids: ['asset-1'] },
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('returns entries untouched, so expiresIn and url reach the caller as sent', async () => {
|
|
66
|
+
makeServiceRequestSpy.mockResolvedValueOnce([sampleEntry]);
|
|
67
|
+
|
|
68
|
+
const [entry] = await oxy.getServiceLinkedDownloadUrls(['asset-1']);
|
|
69
|
+
|
|
70
|
+
expect(entry).toEqual(sampleEntry);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('chunks at 25, a QUARTER of the metadata route\'s 100', async () => {
|
|
74
|
+
// The cap is the route's, and the two routes deliberately differ: this one
|
|
75
|
+
// mints a credential per id. A client chunking at 100 would 400 every call.
|
|
76
|
+
const ids = Array.from({ length: 26 }, (_, index) => `asset-${index}`);
|
|
77
|
+
makeServiceRequestSpy.mockResolvedValue([]);
|
|
78
|
+
|
|
79
|
+
await oxy.getServiceLinkedDownloadUrls(ids);
|
|
80
|
+
|
|
81
|
+
expect(makeServiceRequestSpy).toHaveBeenCalledTimes(2);
|
|
82
|
+
const sent = makeServiceRequestSpy.mock.calls.map(
|
|
83
|
+
(call) => (call[2] as { ids: string[] }).ids.length,
|
|
84
|
+
);
|
|
85
|
+
expect(sent.sort((a, b) => b - a)).toEqual([25, 1]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('accepts a SHORT result without complaint — an omitted id is a refusal', async () => {
|
|
89
|
+
// The server gives no URL for a file the owner did not link here, and says so
|
|
90
|
+
// by saying nothing. A method that treated a short result as an error would
|
|
91
|
+
// make the normal case throw.
|
|
92
|
+
makeServiceRequestSpy.mockResolvedValueOnce([sampleEntry]);
|
|
93
|
+
|
|
94
|
+
const entries = await oxy.getServiceLinkedDownloadUrls(['asset-1', 'asset-refused']);
|
|
95
|
+
|
|
96
|
+
expect(entries.map((entry) => entry.id)).toEqual(['asset-1']);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('THROWS on a failed chunk, naming its ids', async () => {
|
|
100
|
+
const failure = Object.assign(new Error('Too Many Requests'), {
|
|
101
|
+
response: { status: 429 },
|
|
102
|
+
});
|
|
103
|
+
makeServiceRequestSpy.mockRejectedValueOnce(failure);
|
|
104
|
+
|
|
105
|
+
await expect(oxy.getServiceLinkedDownloadUrls(['asset-1'])).rejects.toThrow(
|
|
106
|
+
ServiceLinkedDownloadUrlError,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
makeServiceRequestSpy.mockRejectedValueOnce(failure);
|
|
110
|
+
const error = await oxy
|
|
111
|
+
.getServiceLinkedDownloadUrls(['asset-1'])
|
|
112
|
+
.catch((caught: unknown) => caught as ServiceLinkedDownloadUrlError);
|
|
113
|
+
|
|
114
|
+
expect(error.unresolvedIds).toEqual(['asset-1']);
|
|
115
|
+
expect(error.statuses).toEqual([429]);
|
|
116
|
+
expect(error.code).toBe('SERVICE_LINKED_DOWNLOAD_URL_UNRESOLVED');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('throws even when OTHER chunks succeeded — a partial answer is not an answer', async () => {
|
|
120
|
+
// The inverse of the sibling's `{ partial: true }`. Returning the 25 that
|
|
121
|
+
// worked and dropping the one that did not is exactly how a buyer is told a
|
|
122
|
+
// file they own is missing, so there is no option to ask for it.
|
|
123
|
+
const ids = Array.from({ length: 26 }, (_, index) => `asset-${index}`);
|
|
124
|
+
makeServiceRequestSpy
|
|
125
|
+
.mockResolvedValueOnce([sampleEntry])
|
|
126
|
+
.mockRejectedValueOnce(new Error('boom'));
|
|
127
|
+
|
|
128
|
+
await expect(oxy.getServiceLinkedDownloadUrls(ids)).rejects.toThrow(
|
|
129
|
+
ServiceLinkedDownloadUrlError,
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('has no partial option at all', async () => {
|
|
134
|
+
// Asserted on the ARITY rather than by passing an option and watching it be
|
|
135
|
+
// ignored: a future signature that quietly accepted `{ partial: true }` would
|
|
136
|
+
// restore the behaviour this method exists to refuse.
|
|
137
|
+
expect(oxy.getServiceLinkedDownloadUrls.length).toBe(1);
|
|
138
|
+
});
|
|
139
|
+
});
|