@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,120 @@
|
|
|
1
|
+
import { OxyServices } from '../../OxyServices';
|
|
2
|
+
|
|
3
|
+
function makeJwt(payload: Record<string, unknown>): string {
|
|
4
|
+
const encode = (value: Record<string, unknown>): string =>
|
|
5
|
+
Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
6
|
+
return `${encode({ alg: 'none', typ: 'JWT' })}.${encode({ exp: Math.floor(Date.now() / 1000) + 3600, ...payload })}.sig`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function jsonResponse(data: unknown): Response {
|
|
10
|
+
return new Response(JSON.stringify({ data }), {
|
|
11
|
+
status: 200,
|
|
12
|
+
headers: { 'content-type': 'application/json' },
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('native agency authority', () => {
|
|
17
|
+
let originalFetch: typeof globalThis.fetch;
|
|
18
|
+
let fetchMock: jest.Mock<Promise<Response>, [RequestInfo | URL, RequestInit?]>;
|
|
19
|
+
let oxy: OxyServices;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
originalFetch = globalThis.fetch;
|
|
23
|
+
fetchMock = jest.fn();
|
|
24
|
+
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
25
|
+
oxy = new OxyServices({ baseURL: 'http://test.invalid' });
|
|
26
|
+
oxy.httpService.setTokens(makeJwt({ userId: 'owner' }));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
globalThis.fetch = originalFetch;
|
|
31
|
+
jest.clearAllMocks();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('loads account-scoped grants and audit events', async () => {
|
|
35
|
+
fetchMock
|
|
36
|
+
.mockResolvedValueOnce(jsonResponse({ grants: [] }))
|
|
37
|
+
.mockResolvedValueOnce(jsonResponse({ events: [] }));
|
|
38
|
+
|
|
39
|
+
await expect(oxy.listDelegationGrants('owner/account')).resolves.toEqual([]);
|
|
40
|
+
await expect(oxy.listCapabilityAuditEvents('owner/account')).resolves.toEqual([]);
|
|
41
|
+
|
|
42
|
+
expect(String(fetchMock.mock.calls[0]?.[0])).toBe(
|
|
43
|
+
'http://test.invalid/capabilities/grants?ownerAccountId=owner%2Faccount',
|
|
44
|
+
);
|
|
45
|
+
expect(String(fetchMock.mock.calls[1]?.[0])).toBe(
|
|
46
|
+
'http://test.invalid/capabilities/audit?accountId=owner%2Faccount',
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('invalidates the exact grant list after revocation', async () => {
|
|
51
|
+
const clear = jest.spyOn(oxy, 'clearCacheEntry');
|
|
52
|
+
fetchMock.mockResolvedValueOnce(jsonResponse(undefined));
|
|
53
|
+
|
|
54
|
+
await oxy.revokeDelegationGrant('grant/id', 'owner/account');
|
|
55
|
+
|
|
56
|
+
expect(String(fetchMock.mock.calls[0]?.[0])).toBe(
|
|
57
|
+
'http://test.invalid/capabilities/grants/grant%2Fid',
|
|
58
|
+
);
|
|
59
|
+
expect(fetchMock.mock.calls[0]?.[1]?.method).toBe('DELETE');
|
|
60
|
+
expect(clear).toHaveBeenCalledWith(
|
|
61
|
+
'GET:/capabilities/grants?ownerAccountId=owner%2Faccount',
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('updates only a grant authority envelope through its stable id', async () => {
|
|
66
|
+
const grant = { id: 'grant/id', catalog: null };
|
|
67
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ grant }));
|
|
68
|
+
|
|
69
|
+
await expect(oxy.updateDelegationGrant('grant/id', 'owner/account', {
|
|
70
|
+
capabilityPackages: ['read'],
|
|
71
|
+
capabilities: ['email.read'],
|
|
72
|
+
toolOverrides: [{ tool: 'sendEmail', decision: 'deny' }],
|
|
73
|
+
limits: [],
|
|
74
|
+
maximumAutonomy: 'read_only',
|
|
75
|
+
canRedelegate: false,
|
|
76
|
+
expiresAt: null,
|
|
77
|
+
})).resolves.toEqual(grant);
|
|
78
|
+
|
|
79
|
+
const [url, init] = fetchMock.mock.calls[0] ?? [];
|
|
80
|
+
expect(String(url)).toBe('http://test.invalid/capabilities/grants/grant%2Fid');
|
|
81
|
+
expect(init?.method).toBe('PUT');
|
|
82
|
+
expect(JSON.parse(String(init?.body))).toEqual({
|
|
83
|
+
capabilityPackages: ['read'],
|
|
84
|
+
capabilities: ['email.read'],
|
|
85
|
+
toolOverrides: [{ tool: 'sendEmail', decision: 'deny' }],
|
|
86
|
+
limits: [],
|
|
87
|
+
maximumAutonomy: 'read_only',
|
|
88
|
+
canRedelegate: false,
|
|
89
|
+
expiresAt: null,
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('writes the most restrictive account policy without leaking it across accounts', async () => {
|
|
94
|
+
const policy = {
|
|
95
|
+
id: 'policy-1',
|
|
96
|
+
accountId: 'account-a',
|
|
97
|
+
appSlug: 'mention',
|
|
98
|
+
maximumAutonomy: 'draft' as const,
|
|
99
|
+
deniedCapabilities: ['social.publish'],
|
|
100
|
+
createdAt: '2026-09-03T00:00:00.000Z',
|
|
101
|
+
updatedAt: '2026-09-03T00:00:00.000Z',
|
|
102
|
+
};
|
|
103
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ policy }));
|
|
104
|
+
|
|
105
|
+
await expect(oxy.putAccountCapabilityPolicy('mention', {
|
|
106
|
+
accountId: 'account-a',
|
|
107
|
+
maximumAutonomy: 'draft',
|
|
108
|
+
deniedCapabilities: ['social.publish'],
|
|
109
|
+
})).resolves.toEqual(policy);
|
|
110
|
+
|
|
111
|
+
const [url, init] = fetchMock.mock.calls[0] ?? [];
|
|
112
|
+
expect(String(url)).toBe('http://test.invalid/capabilities/account-policies/mention');
|
|
113
|
+
expect(init?.method).toBe('PUT');
|
|
114
|
+
expect(init?.body).toBe(JSON.stringify({
|
|
115
|
+
accountId: 'account-a',
|
|
116
|
+
maximumAutonomy: 'draft',
|
|
117
|
+
deniedCapabilities: ['social.publish'],
|
|
118
|
+
}));
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -120,4 +120,27 @@ describe('connected apps (OAuth grants)', () => {
|
|
|
120
120
|
expect(clearSpy).toHaveBeenCalledWith('GET:/auth/grants');
|
|
121
121
|
clearSpy.mockRestore();
|
|
122
122
|
});
|
|
123
|
+
|
|
124
|
+
it('lists and revokes resource-bound external MCP clients', async () => {
|
|
125
|
+
const grant = {
|
|
126
|
+
id: 'mcp-grant',
|
|
127
|
+
appSlug: 'noted',
|
|
128
|
+
resource: 'https://mcp.noted.oxy.so',
|
|
129
|
+
scopes: ['notes.read'],
|
|
130
|
+
clientId: 'client-public-id',
|
|
131
|
+
clientName: 'Research client',
|
|
132
|
+
createdAt: '2026-09-03T00:00:00.000Z',
|
|
133
|
+
lastUsedAt: '2026-09-03T01:00:00.000Z',
|
|
134
|
+
};
|
|
135
|
+
fetchMock
|
|
136
|
+
.mockResolvedValueOnce(jsonResponse({ grants: [grant] }))
|
|
137
|
+
.mockResolvedValueOnce(jsonResponse(undefined));
|
|
138
|
+
|
|
139
|
+
await expect(oxy.listConnectedMcpClients()).resolves.toEqual([grant]);
|
|
140
|
+
await expect(oxy.revokeConnectedMcpClient('mcp/grant')).resolves.toBeUndefined();
|
|
141
|
+
|
|
142
|
+
expect(String(fetchMock.mock.calls[0]?.[0])).toBe('http://test.invalid/auth/mcp/oauth/grants');
|
|
143
|
+
expect(String(fetchMock.mock.calls[1]?.[0])).toBe('http://test.invalid/auth/mcp/oauth/grants/mcp%2Fgrant');
|
|
144
|
+
expect(fetchMock.mock.calls[1]?.[1]?.method).toBe('DELETE');
|
|
145
|
+
});
|
|
123
146
|
});
|
package/src/mixins/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { OxyServicesReputationMixin } from './OxyServices.reputation';
|
|
|
17
17
|
import { OxyServicesAssetsMixin } from './OxyServices.assets';
|
|
18
18
|
import { OxyServicesAccountsMixin } from './OxyServices.accounts';
|
|
19
19
|
import { OxyServicesConnectedAppsMixin } from './OxyServices.connectedApps';
|
|
20
|
+
import { OxyServicesAgencyMixin } from './OxyServices.agency';
|
|
20
21
|
import { OxyServicesStoreMixin } from './OxyServices.store';
|
|
21
22
|
import { OxyServicesLocationMixin } from './OxyServices.location';
|
|
22
23
|
import { OxyServicesAnalyticsMixin } from './OxyServices.analytics';
|
|
@@ -31,7 +32,6 @@ import { OxyServicesAppDataMixin } from './OxyServices.appData';
|
|
|
31
32
|
import { OxyServicesCivicMixin } from './OxyServices.civic';
|
|
32
33
|
import { OxyServicesChainsMixin } from './OxyServices.chains';
|
|
33
34
|
import { OxyServicesNodesMixin } from './OxyServices.nodes';
|
|
34
|
-
import { OxyServicesLinksMixin } from './OxyServices.links';
|
|
35
35
|
import { OxyServicesFollowGraphMixin } from './OxyServices.followGraph';
|
|
36
36
|
import { OxyServicesInferenceMixin } from './OxyServices.inference';
|
|
37
37
|
import { OxyServicesDeviceBootMixin } from './OxyServices.deviceBoot';
|
|
@@ -58,6 +58,7 @@ type AllMixinInstances =
|
|
|
58
58
|
& InstanceType<ReturnType<typeof OxyServicesAssetsMixin<typeof OxyServicesBase>>>
|
|
59
59
|
& InstanceType<ReturnType<typeof OxyServicesAccountsMixin<typeof OxyServicesBase>>>
|
|
60
60
|
& InstanceType<ReturnType<typeof OxyServicesConnectedAppsMixin<typeof OxyServicesBase>>>
|
|
61
|
+
& InstanceType<ReturnType<typeof OxyServicesAgencyMixin<typeof OxyServicesBase>>>
|
|
61
62
|
& InstanceType<ReturnType<typeof OxyServicesStoreMixin<typeof OxyServicesBase>>>
|
|
62
63
|
& InstanceType<ReturnType<typeof OxyServicesLocationMixin<typeof OxyServicesBase>>>
|
|
63
64
|
& InstanceType<ReturnType<typeof OxyServicesAnalyticsMixin<typeof OxyServicesBase>>>
|
|
@@ -71,7 +72,6 @@ type AllMixinInstances =
|
|
|
71
72
|
& InstanceType<ReturnType<typeof OxyServicesCivicMixin<typeof OxyServicesBase>>>
|
|
72
73
|
& InstanceType<ReturnType<typeof OxyServicesChainsMixin<typeof OxyServicesBase>>>
|
|
73
74
|
& InstanceType<ReturnType<typeof OxyServicesNodesMixin<typeof OxyServicesBase>>>
|
|
74
|
-
& InstanceType<ReturnType<typeof OxyServicesLinksMixin<typeof OxyServicesBase>>>
|
|
75
75
|
& InstanceType<ReturnType<typeof OxyServicesFollowGraphMixin<typeof OxyServicesBase>>>
|
|
76
76
|
& InstanceType<ReturnType<typeof OxyServicesInferenceMixin<typeof OxyServicesBase>>>
|
|
77
77
|
& InstanceType<ReturnType<typeof OxyServicesDeviceBootMixin<typeof OxyServicesBase>>>
|
|
@@ -129,6 +129,8 @@ const MIXIN_PIPELINE: MixinFunction[] = [
|
|
|
129
129
|
// OAuth-consent surface (public app identity + connected-app grants). Kept
|
|
130
130
|
// separate from account ownership.
|
|
131
131
|
OxyServicesConnectedAppsMixin,
|
|
132
|
+
// Oxy-owned delegated authority for Alia and digital-agent accounts.
|
|
133
|
+
OxyServicesAgencyMixin,
|
|
132
134
|
// The app store: the public storefront, the reviews on it, and the listing a
|
|
133
135
|
// publisher edits. A module OVER the platform — turn it off and OAuth still
|
|
134
136
|
// works — so it is its own surface rather than more of `accounts`.
|
|
@@ -151,9 +153,6 @@ const MIXIN_PIPELINE: MixinFunction[] = [
|
|
|
151
153
|
// User nodes / decentralization (Fase 5): register/read/revoke/manage the
|
|
152
154
|
// caller's personal data node + ingest hint.
|
|
153
155
|
OxyServicesNodesMixin,
|
|
154
|
-
// Link previews / unfurls: SDK-owned link-metadata resolution via oxy-api,
|
|
155
|
-
// so apps stop scraping link metadata locally.
|
|
156
|
-
OxyServicesLinksMixin,
|
|
157
156
|
// The user-owned follow graph (#809). One relationship per user and target,
|
|
158
157
|
// shared across applications, with per-application context on top.
|
|
159
158
|
OxyServicesFollowGraphMixin,
|
package/src/models/interfaces.ts
CHANGED
|
@@ -596,6 +596,33 @@ export interface AssetUpdateVisibilityResponse {
|
|
|
596
596
|
* deleted ids are omitted from the response (never error the whole batch),
|
|
597
597
|
* so the result may be shorter than the requested id list.
|
|
598
598
|
*/
|
|
599
|
+
/**
|
|
600
|
+
* A short-lived direct download URL for a file the calling application's own
|
|
601
|
+
* user attached to it, from `POST /assets/service/linked-url`.
|
|
602
|
+
*
|
|
603
|
+
* `url` is BEARER AUTHORITY over the bytes for `expiresIn` seconds: whoever
|
|
604
|
+
* holds the string is the reader, and nothing about the calling service's
|
|
605
|
+
* entitlement is re-evaluated when it is used. Hand it only to a caller the
|
|
606
|
+
* service has itself authorized, and never log it.
|
|
607
|
+
*
|
|
608
|
+
* `expiresIn` bounds when a transfer may START, not how long one may take. A
|
|
609
|
+
* large file downloads fine on a slow link; a RESUMED range request after the
|
|
610
|
+
* deadline does not, and needs a fresh URL.
|
|
611
|
+
*
|
|
612
|
+
* Unlike {@link ServiceAssetMetadata}, the absence of an id from the response is
|
|
613
|
+
* an AUTHORIZATION answer as often as an existence one — see
|
|
614
|
+
* `getServiceLinkedDownloadUrls`.
|
|
615
|
+
*/
|
|
616
|
+
export interface ServiceLinkedDownloadUrl {
|
|
617
|
+
id: string;
|
|
618
|
+
url: string;
|
|
619
|
+
/** Seconds from issue until `url` stops being accepted. */
|
|
620
|
+
expiresIn: number;
|
|
621
|
+
mime: string;
|
|
622
|
+
size: number;
|
|
623
|
+
sha256: string;
|
|
624
|
+
}
|
|
625
|
+
|
|
599
626
|
export interface ServiceAssetMetadata {
|
|
600
627
|
id: string;
|
|
601
628
|
sha256: string;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createEcosystemTraffic } from '../traffic';
|
|
2
|
+
|
|
3
|
+
jest.mock('../../OxyServices', () => ({ OxyServices: jest.fn(() => ({})) }));
|
|
4
|
+
|
|
5
|
+
it('publishes independently of viewers and waits for registration before removing a stopped instance', async () => {
|
|
6
|
+
const originalFetch = globalThis.fetch;
|
|
7
|
+
const bodies: Array<Record<string, unknown> | unknown[]> = [];
|
|
8
|
+
let releaseRegistration!: () => void;
|
|
9
|
+
const registration = new Promise<void>(resolve => { releaseRegistration = resolve; });
|
|
10
|
+
let registered!: () => void;
|
|
11
|
+
const registrationStarted = new Promise<void>(resolve => { registered = resolve; });
|
|
12
|
+
globalThis.fetch = jest.fn(async (_input, init) => {
|
|
13
|
+
const body = JSON.parse(String(init?.body));
|
|
14
|
+
bodies.push(body);
|
|
15
|
+
if (!Array.isArray(body) && !body.removed) {
|
|
16
|
+
registered();
|
|
17
|
+
await registration;
|
|
18
|
+
}
|
|
19
|
+
return new Response(null, { status: 204 });
|
|
20
|
+
}) as unknown as typeof fetch;
|
|
21
|
+
const traffic = createEcosystemTraffic({ service: 'mention', region: 'us-west-2', credential: async () => 'test-service-token', ready: () => true });
|
|
22
|
+
try {
|
|
23
|
+
await registrationStarted;
|
|
24
|
+
traffic.record({ scope: 'internal', direction: 'outbound', activityType: 'communication', sourceRegion: 'us-west-2', targetRegion: 'us-west-2', targetService: 'oxy-api' });
|
|
25
|
+
const stopped = traffic.stop();
|
|
26
|
+
expect(bodies.some(body => !Array.isArray(body) && body.removed === true)).toBe(false);
|
|
27
|
+
releaseRegistration();
|
|
28
|
+
await stopped;
|
|
29
|
+
expect(bodies[bodies.length - 1]).toMatchObject({ removed: true, service: 'mention', status: 'online' });
|
|
30
|
+
expect(bodies.find(Array.isArray)).toEqual([expect.objectContaining({ service: 'mention', scope: 'internal', direction: 'outbound', requests: 1 })]);
|
|
31
|
+
} finally {
|
|
32
|
+
releaseRegistration();
|
|
33
|
+
await traffic.stop();
|
|
34
|
+
globalThis.fetch = originalFetch;
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('rejects a partial activity credential instead of mixing it with service credentials', () => {
|
|
39
|
+
const previous = { ...process.env };
|
|
40
|
+
try {
|
|
41
|
+
process.env.OXY_ACTIVITY_API_KEY = 'activity-key';
|
|
42
|
+
delete process.env.OXY_ACTIVITY_API_SECRET;
|
|
43
|
+
process.env.OXY_SERVICE_API_KEY = 'service-key';
|
|
44
|
+
process.env.OXY_SERVICE_API_SECRET = 'service-secret';
|
|
45
|
+
expect(() => createEcosystemTraffic({ service: 'homiio', region: 'us-west-2' })).toThrow('complete OXY_ACTIVITY');
|
|
46
|
+
} finally {
|
|
47
|
+
process.env = previous;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { once } from 'node:events';
|
|
3
|
+
import { observeNodeHttp, withoutNodeHttpObservation } from '../trafficNodeHttp';
|
|
4
|
+
import type { TrafficFlow } from '@oxy.so/telemetry/collector';
|
|
5
|
+
|
|
6
|
+
it('observes real HTTP clients, preserves behavior and restores the HTTP facade', async () => {
|
|
7
|
+
const events: TrafficFlow[] = [];
|
|
8
|
+
const original = http.request;
|
|
9
|
+
const server = http.createServer((_req, res) => { res.setHeader('X-Oxy-Region', 'eu-west-1'); res.end('ok'); });
|
|
10
|
+
server.listen(0, '127.0.0.1');
|
|
11
|
+
await once(server, 'listening');
|
|
12
|
+
const port = (server.address() as { port: number }).port;
|
|
13
|
+
const stop = observeNodeHttp({ record: flow => events.push(flow) }, 'website', 'us-west-2', () => ({ service: 'oxy-api', region: 'unknown' }));
|
|
14
|
+
const request = (path: string) => new Promise<void>((resolve, reject) => {
|
|
15
|
+
const outgoing = http.get(`http://127.0.0.1:${port}${path}`, response => { response.resume(); response.on('end', resolve); });
|
|
16
|
+
// The observer must not swallow an unhandled request error.
|
|
17
|
+
expect(outgoing.listenerCount('error')).toBe(0);
|
|
18
|
+
outgoing.once('error', reject);
|
|
19
|
+
});
|
|
20
|
+
try {
|
|
21
|
+
// Bun leaves Host implicit: its ClientRequest.host is the available authority.
|
|
22
|
+
const getHeader = http.ClientRequest.prototype.getHeader;
|
|
23
|
+
http.ClientRequest.prototype.getHeader = function (name: string) { return name === 'host' ? undefined : getHeader.call(this, name); };
|
|
24
|
+
try { await request('/files/private-id'); } finally { http.ClientRequest.prototype.getHeader = getHeader; }
|
|
25
|
+
expect(events).toEqual([
|
|
26
|
+
expect.objectContaining({ scope: 'internal', direction: 'outbound', activityType: 'media', sourceRegion: 'us-west-2', targetRegion: 'eu-west-1' }),
|
|
27
|
+
expect.objectContaining({ scope: 'internal', direction: 'inbound', sourceRegion: 'eu-west-1', targetRegion: 'us-west-2' }),
|
|
28
|
+
]);
|
|
29
|
+
expect(JSON.stringify(events)).not.toContain('private-id');
|
|
30
|
+
await withoutNodeHttpObservation(() => request('/files/no-double-count'));
|
|
31
|
+
await request('/internal/activity');
|
|
32
|
+
expect(events).toHaveLength(2);
|
|
33
|
+
stop();
|
|
34
|
+
expect(http.request).toBe(original);
|
|
35
|
+
await request('/files/after-stop');
|
|
36
|
+
expect(events).toHaveLength(2);
|
|
37
|
+
} finally { stop(); await new Promise<void>(resolve => server.close(() => resolve())); }
|
|
38
|
+
});
|
package/src/server/index.ts
CHANGED
|
@@ -134,3 +134,8 @@ export type { OxyIdentityCacheEvictor } from '../utils/identityCacheSweep';
|
|
|
134
134
|
// lets `@oxy.so/api` derive `auth.<apex>` without duplicating PSL logic.
|
|
135
135
|
export { registrableApex } from '../utils/registrableApex';
|
|
136
136
|
export { isOfficialWebOrigin } from '../utils/officialOrigins';
|
|
137
|
+
|
|
138
|
+
export { createEcosystemTraffic } from './traffic';
|
|
139
|
+
export type { EcosystemTrafficOptions } from './traffic';
|
|
140
|
+
|
|
141
|
+
export { observeNodeHttp, withoutNodeHttpObservation } from './trafficNodeHttp';
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { observeNodeHttp, withoutNodeHttpObservation } from './trafficNodeHttp';
|
|
2
|
+
import { observeTrafficSocket, observeTrafficWebSocket, type TrafficWebSocket, type TrafficSocket } from '@oxy.so/telemetry/socket';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import type { RequestHandler } from 'express';
|
|
5
|
+
import { OxyServices } from '../OxyServices';
|
|
6
|
+
import { createTrafficCollector, instrumentTrafficFetch, trafficMiddleware, type TrafficFlow, type ServiceEndpoint, resolveOxyServiceEndpoint, infrastructureLocation } from '@oxy.so/telemetry/collector';
|
|
7
|
+
|
|
8
|
+
export interface EcosystemTrafficOptions {
|
|
9
|
+
service: string;
|
|
10
|
+
region?: string;
|
|
11
|
+
baseURL?: string;
|
|
12
|
+
credential?: () => Promise<string>;
|
|
13
|
+
resolveEndpoint?: (url: URL) => ServiceEndpoint | undefined;
|
|
14
|
+
onError?: () => void;
|
|
15
|
+
ready?: () => boolean;
|
|
16
|
+
location?: { label: string; coordinates: [number, number] };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Install once per service process, before registering HTTP routes. */
|
|
20
|
+
export function createEcosystemTraffic(options: EcosystemTrafficOptions): {
|
|
21
|
+
middleware: RequestHandler;
|
|
22
|
+
record(flow: Omit<TrafficFlow, 'service' | 'region'>): void;
|
|
23
|
+
wrapFetch(fetcher: typeof fetch): typeof fetch;
|
|
24
|
+
installFetch(): void;
|
|
25
|
+
observeHttp: ReturnType<typeof trafficMiddleware>;
|
|
26
|
+
observeSocket(socket: TrafficSocket, options?: Parameters<typeof observeTrafficSocket>[4]): () => void;
|
|
27
|
+
observeWebSocket(socket: TrafficWebSocket, options?: Parameters<typeof observeTrafficWebSocket>[4]): () => void;
|
|
28
|
+
stop(): Promise<void>;
|
|
29
|
+
} {
|
|
30
|
+
const region = options.region ?? process.env.AWS_REGION ?? 'unknown';
|
|
31
|
+
const baseURL = options.baseURL ?? process.env.OXY_API_URL ?? 'https://api.oxy.so';
|
|
32
|
+
const activityKey = process.env.OXY_ACTIVITY_API_KEY;
|
|
33
|
+
const activitySecret = process.env.OXY_ACTIVITY_API_SECRET;
|
|
34
|
+
if (!options.credential && Boolean(activityKey) !== Boolean(activitySecret)) {
|
|
35
|
+
throw new Error('Ecosystem activity requires a complete OXY_ACTIVITY_API_KEY and OXY_ACTIVITY_API_SECRET pair');
|
|
36
|
+
}
|
|
37
|
+
const apiKey = activityKey || process.env.OXY_SERVICE_API_KEY;
|
|
38
|
+
const apiSecret = activitySecret || process.env.OXY_SERVICE_API_SECRET;
|
|
39
|
+
if (!options.credential && (!apiKey || !apiSecret)) throw new Error('Ecosystem activity requires OXY_SERVICE_API_KEY and OXY_SERVICE_API_SECRET');
|
|
40
|
+
const oxy = new OxyServices({ baseURL });
|
|
41
|
+
if (apiKey && apiSecret) oxy.configureServiceAuth(apiKey, apiSecret);
|
|
42
|
+
const credential = options.credential ?? (() => oxy.getServiceToken());
|
|
43
|
+
const fetcher = globalThis.fetch.bind(globalThis);
|
|
44
|
+
const instanceId = randomUUID();
|
|
45
|
+
const location = options.location ?? infrastructureLocation(region);
|
|
46
|
+
if (!location) throw new Error('Ecosystem activity requires a known infrastructure region or explicit location');
|
|
47
|
+
const post = async (path: string, data: unknown) => {
|
|
48
|
+
const token = await credential();
|
|
49
|
+
const response = await fetcher(new URL(path, baseURL), {
|
|
50
|
+
method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
51
|
+
body: JSON.stringify(data), signal: AbortSignal.timeout(4_000),
|
|
52
|
+
});
|
|
53
|
+
if (!response.ok) throw new Error('Activity collection failed');
|
|
54
|
+
};
|
|
55
|
+
const collector = createTrafficCollector(events => post('/internal/activity', events));
|
|
56
|
+
let stopped = false;
|
|
57
|
+
let heartbeatWork = Promise.resolve();
|
|
58
|
+
let heartbeatPending = false;
|
|
59
|
+
const reportError = options.onError ?? (() => console.warn('Ecosystem activity publisher unavailable'));
|
|
60
|
+
const heartbeat = (removed = false) => {
|
|
61
|
+
if (!removed && (stopped || heartbeatPending)) return heartbeatWork;
|
|
62
|
+
heartbeatPending = true;
|
|
63
|
+
heartbeatWork = heartbeatWork.then(() => post('/internal/activity/infrastructure', { instanceId, service: options.service, region, ...location, status: options.ready?.() ? 'online' : 'unknown', removed })).catch(reportError).finally(() => { heartbeatPending = false; });
|
|
64
|
+
return heartbeatWork;
|
|
65
|
+
};
|
|
66
|
+
const flush = () => collector.flush().catch(reportError);
|
|
67
|
+
const cleanups = new Set<() => void>();
|
|
68
|
+
const sockets = new WeakMap<object, () => void>();
|
|
69
|
+
let fetchInstalled = false;
|
|
70
|
+
const wrapFetch = (fetcher: typeof fetch): typeof fetch => {
|
|
71
|
+
const wrapped = instrumentTrafficFetch(fetcher, collector, options.service, region, options.resolveEndpoint ?? resolveOxyServiceEndpoint);
|
|
72
|
+
return Object.assign((...args: Parameters<typeof fetch>) => withoutNodeHttpObservation(() => wrapped(...args)), fetcher) as typeof fetch;
|
|
73
|
+
};
|
|
74
|
+
const observeHttp = trafficMiddleware(collector, options.service, region);
|
|
75
|
+
const timer = setInterval(() => { void flush(); }, 2_000);
|
|
76
|
+
timer.unref?.();
|
|
77
|
+
void heartbeat();
|
|
78
|
+
const heartbeatTimer = setInterval(() => { void heartbeat(); }, 10_000);
|
|
79
|
+
heartbeatTimer.unref?.();
|
|
80
|
+
return {
|
|
81
|
+
record: flow => collector.record({ ...flow, service: options.service, region }),
|
|
82
|
+
middleware: observeHttp as RequestHandler,
|
|
83
|
+
observeHttp,
|
|
84
|
+
wrapFetch,
|
|
85
|
+
installFetch() {
|
|
86
|
+
if (fetchInstalled || stopped) return;
|
|
87
|
+
fetchInstalled = true;
|
|
88
|
+
const original = globalThis.fetch;
|
|
89
|
+
const wrapped = wrapFetch(original);
|
|
90
|
+
globalThis.fetch = wrapped;
|
|
91
|
+
cleanups.add(() => { if (globalThis.fetch === wrapped) globalThis.fetch = original; });
|
|
92
|
+
cleanups.add(observeNodeHttp(collector, options.service, region, options.resolveEndpoint ?? resolveOxyServiceEndpoint));
|
|
93
|
+
},
|
|
94
|
+
observeWebSocket(socket, socketOptions) {
|
|
95
|
+
const existing = sockets.get(socket);
|
|
96
|
+
if (existing) return existing;
|
|
97
|
+
const unobserve = observeTrafficWebSocket(socket, collector, options.service, region, socketOptions);
|
|
98
|
+
const cleanup = () => { unobserve(); socket.off('close', cleanup); cleanups.delete(cleanup); sockets.delete(socket); };
|
|
99
|
+
sockets.set(socket, cleanup);
|
|
100
|
+
socket.once('close', cleanup);
|
|
101
|
+
cleanups.add(cleanup);
|
|
102
|
+
return cleanup;
|
|
103
|
+
},
|
|
104
|
+
observeSocket(socket, socketOptions) {
|
|
105
|
+
const existing = sockets.get(socket);
|
|
106
|
+
if (existing) return existing;
|
|
107
|
+
const unobserve = observeTrafficSocket(socket, collector, options.service, region, socketOptions);
|
|
108
|
+
const cleanup = () => { unobserve(); socket.off('disconnect', cleanup); cleanups.delete(cleanup); sockets.delete(socket); };
|
|
109
|
+
sockets.set(socket, cleanup);
|
|
110
|
+
socket.once('disconnect', cleanup);
|
|
111
|
+
cleanups.add(cleanup);
|
|
112
|
+
return cleanup;
|
|
113
|
+
},
|
|
114
|
+
async stop() { if (stopped) return; stopped = true; cleanups.forEach(cleanup => cleanup()); cleanups.clear(); clearInterval(timer); clearInterval(heartbeatTimer); await flush(); await heartbeat(true); },
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import https from 'node:https';
|
|
3
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
4
|
+
import { syncBuiltinESMExports } from 'node:module';
|
|
5
|
+
import { normalizeInfrastructureRegion, trafficType, type TrafficFlow, type ServiceEndpoint } from '@oxy.so/telemetry/collector';
|
|
6
|
+
|
|
7
|
+
const suppressed = new AsyncLocalStorage<boolean>();
|
|
8
|
+
export function withoutNodeHttpObservation<T>(callback: () => T): T { return suppressed.run(true, callback); }
|
|
9
|
+
|
|
10
|
+
/** Node and Bun share the HTTP facade; Bun does not emit Node's HTTP diagnostic channels. */
|
|
11
|
+
export function observeNodeHttp(
|
|
12
|
+
collector: { record(flow: TrafficFlow): void }, service: string, region: string,
|
|
13
|
+
resolveEndpoint: (url: URL) => ServiceEndpoint | undefined,
|
|
14
|
+
): () => void {
|
|
15
|
+
const seen = new WeakSet<http.ClientRequest>();
|
|
16
|
+
const restoreRequests = new Set<() => void>();
|
|
17
|
+
const observe = (request: http.ClientRequest) => {
|
|
18
|
+
if (seen.has(request) || suppressed.getStore()) return request;
|
|
19
|
+
seen.add(request);
|
|
20
|
+
const host = request.getHeader('host') ?? request.host;
|
|
21
|
+
if (typeof host !== 'string') return request;
|
|
22
|
+
const url = new URL(request.path || '/', `${request.protocol || 'http:'}//${host}`);
|
|
23
|
+
if (/^\/(?:internal\/activity|auth\/service-token|platform-infrastructure)(?:\/|$)/.test(url.pathname)) return request;
|
|
24
|
+
const peer = resolveEndpoint(url);
|
|
25
|
+
if (peer && !request.headersSent) request.setHeader('X-Oxy-Source-Region', region);
|
|
26
|
+
const flow: TrafficFlow = { service, region, activityType: trafficType(url.pathname), scope: peer ? 'internal' : 'external', direction: 'outbound', sourceRegion: region, sourceService: service, targetService: peer?.service };
|
|
27
|
+
const emit = request.emit;
|
|
28
|
+
let recorded = false;
|
|
29
|
+
const restore = () => { if (request.emit === observedEmit) request.emit = emit; restoreRequests.delete(restore); };
|
|
30
|
+
// Intercept notifications without adding an 'error' listener: observation
|
|
31
|
+
// must not turn an unhandled application error into a swallowed error.
|
|
32
|
+
const observedEmit: typeof request.emit = function (this: http.ClientRequest, event: string | symbol, ...args: unknown[]) {
|
|
33
|
+
try {
|
|
34
|
+
if (!recorded && event === 'response') {
|
|
35
|
+
recorded = true;
|
|
36
|
+
const response = args[0] as http.IncomingMessage;
|
|
37
|
+
const targetRegion = peer ? normalizeInfrastructureRegion(response.headers['x-oxy-region']) : undefined;
|
|
38
|
+
collector.record({ ...flow, targetRegion });
|
|
39
|
+
collector.record({ ...flow, direction: 'inbound', sourceRegion: targetRegion, targetRegion: region, sourceService: peer?.service, targetService: service });
|
|
40
|
+
} else if (!recorded && (event === 'error' || event === 'close')) {
|
|
41
|
+
recorded = true;
|
|
42
|
+
collector.record(flow);
|
|
43
|
+
}
|
|
44
|
+
} catch { /* A metrics failure cannot change a request's behavior. */ }
|
|
45
|
+
if (event === 'close') restore();
|
|
46
|
+
return emit.call(this, event, ...args);
|
|
47
|
+
};
|
|
48
|
+
request.emit = observedEmit;
|
|
49
|
+
restoreRequests.add(restore);
|
|
50
|
+
return request;
|
|
51
|
+
};
|
|
52
|
+
const safelyObserve = (request: http.ClientRequest) => {
|
|
53
|
+
try { return observe(request); } catch { return request; }
|
|
54
|
+
};
|
|
55
|
+
const patches = [http, https].map(module => {
|
|
56
|
+
const request = module.request;
|
|
57
|
+
const get = module.get;
|
|
58
|
+
const wrappedRequest = function (...args: Parameters<typeof request>) { return safelyObserve(request.apply(module, args)); } as typeof request;
|
|
59
|
+
const wrappedGet = function (...args: Parameters<typeof get>) { return safelyObserve(get.apply(module, args)); } as typeof get;
|
|
60
|
+
module.request = wrappedRequest;
|
|
61
|
+
module.get = wrappedGet;
|
|
62
|
+
return () => {
|
|
63
|
+
if (module.request === wrappedRequest) module.request = request;
|
|
64
|
+
if (module.get === wrappedGet) module.get = get;
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
syncBuiltinESMExports();
|
|
68
|
+
return () => { patches.forEach(restore => restore()); restoreRequests.forEach(restore => restore()); syncBuiltinESMExports(); };
|
|
69
|
+
}
|
|
@@ -90,7 +90,7 @@ const LEADING_HORIZONTAL_WHITESPACE = /\n +/g;
|
|
|
90
90
|
const EXCESS_BLANK_LINES = /\n{3,}/g;
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
|
-
* Normalize a SINGLE-LINE text value
|
|
93
|
+
* Normalize a SINGLE-LINE text value such as a page title or `siteName`,
|
|
94
94
|
* display names, image alt text, handles, profile field labels.
|
|
95
95
|
*
|
|
96
96
|
* A line break in such a value is never meaningful — it is an artifact of the
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Link Preview (unfurl) Mixin
|
|
3
|
-
*
|
|
4
|
-
* Resolves link previews ("unfurls") through the Oxy API so every Oxy app
|
|
5
|
-
* stops scraping link metadata locally. The API owns resolution and re-hosts
|
|
6
|
-
* the preview `image`/`favicon` on Oxy media (`cloud.oxy.so/<fileId>`), so
|
|
7
|
-
* consumers render the returned URLs directly with no per-app proxy.
|
|
8
|
-
*
|
|
9
|
-
* Wire shapes (`LinkPreview`, `LinkPreviewBatchResponse`) are the single source
|
|
10
|
-
* of truth in `@oxy.so/contracts`; this mixin imports them rather than
|
|
11
|
-
* redefining them so producer (oxy-api) and consumers cannot drift.
|
|
12
|
-
*
|
|
13
|
-
* Caching note: these GET/POST reads are NOT cached at the SDK layer (`cache:
|
|
14
|
-
* false`). A preview can be returned `'pending'` first and `'resolved'` on a
|
|
15
|
-
* later read, so an SDK GET cache would pin the stale `'pending'` snapshot.
|
|
16
|
-
* App-side caching (React Query / stores) owns this responsibility.
|
|
17
|
-
*/
|
|
18
|
-
import type { LinkPreview } from '@oxy.so/contracts';
|
|
19
|
-
import type { OxyServicesBase } from '../OxyServices.base';
|
|
20
|
-
import { buildUrl } from '../utils/apiUtils';
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Maximum number of URLs sent per `POST /links/previews` request. Matches the
|
|
24
|
-
* server-side batch cap (`linkPreviewBatchRequestSchema`'s `.max(50)`); larger
|
|
25
|
-
* inputs are split into multiple chunked calls and the result maps merged,
|
|
26
|
-
* mirroring how `getUsersByIds` chunks at 100.
|
|
27
|
-
*/
|
|
28
|
-
const LINK_PREVIEWS_CHUNK_SIZE = 50;
|
|
29
|
-
|
|
30
|
-
export function OxyServicesLinksMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
31
|
-
return class extends Base {
|
|
32
|
-
constructor(...args: any[]) {
|
|
33
|
-
super(...(args as [any]));
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Resolve a single link preview via `GET /links/preview?url=<encoded>&wait=0|1`.
|
|
38
|
-
*
|
|
39
|
-
* @param url - The URL to unfurl. Sent percent-encoded in the query string.
|
|
40
|
-
* @param opts.wait - When `true`, asks the server to resolve synchronously
|
|
41
|
-
* (`wait=1`) instead of returning a `'pending'` placeholder for a
|
|
42
|
-
* first-seen URL. Defaults to `false` (`wait=0`).
|
|
43
|
-
*
|
|
44
|
-
* Not cached at the SDK layer: a `'pending'` result can become `'resolved'`
|
|
45
|
-
* on a later read, so caching here would serve the stale placeholder.
|
|
46
|
-
*/
|
|
47
|
-
async getLinkPreview(url: string, opts?: { wait?: boolean }): Promise<LinkPreview> {
|
|
48
|
-
try {
|
|
49
|
-
const path = buildUrl('/links/preview', { url, wait: opts?.wait ? 1 : 0 });
|
|
50
|
-
return await this.makeRequest<LinkPreview>('GET', path, undefined, { cache: false });
|
|
51
|
-
} catch (error) {
|
|
52
|
-
throw this.handleError(error);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Resolve multiple link previews via `POST /links/previews` (body `{ urls }`).
|
|
58
|
-
*
|
|
59
|
-
* Inputs are de-duplicated and split into chunks of {@link LINK_PREVIEWS_CHUNK_SIZE}
|
|
60
|
-
* (the server-side cap). Chunks run concurrently and their result maps are
|
|
61
|
-
* merged into a single result keyed by the REQUESTED url (the exact string
|
|
62
|
-
* passed in `urls`) — matching the batch contract — so a caller can always
|
|
63
|
-
* look its own input back up.
|
|
64
|
-
*
|
|
65
|
-
* `makeRequest` unwraps the API's top-level `{ data }` envelope (same as
|
|
66
|
-
* `getUsersByIds`'s `makeServiceRequest<User[]>`), so each chunk response is
|
|
67
|
-
* already the `Record<url, LinkPreview>` map — merge it directly.
|
|
68
|
-
*
|
|
69
|
-
* An empty / whitespace-only input resolves immediately with `{}` and
|
|
70
|
-
* performs no network call. A failure in any chunk surfaces (via
|
|
71
|
-
* `handleError`) rather than being swallowed.
|
|
72
|
-
*/
|
|
73
|
-
async getLinkPreviews(urls: string[]): Promise<Record<string, LinkPreview>> {
|
|
74
|
-
const uniqueUrls = Array.from(
|
|
75
|
-
new Set(urls.filter((u): u is string => typeof u === 'string' && u.trim().length > 0)),
|
|
76
|
-
);
|
|
77
|
-
if (uniqueUrls.length === 0) {
|
|
78
|
-
return {};
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const chunks: string[][] = [];
|
|
82
|
-
for (let i = 0; i < uniqueUrls.length; i += LINK_PREVIEWS_CHUNK_SIZE) {
|
|
83
|
-
chunks.push(uniqueUrls.slice(i, i + LINK_PREVIEWS_CHUNK_SIZE));
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
try {
|
|
87
|
-
const responses = await Promise.all(
|
|
88
|
-
chunks.map((chunk) =>
|
|
89
|
-
this.makeRequest<Record<string, LinkPreview>>(
|
|
90
|
-
'POST',
|
|
91
|
-
'/links/previews',
|
|
92
|
-
{ urls: chunk },
|
|
93
|
-
{ cache: false },
|
|
94
|
-
),
|
|
95
|
-
),
|
|
96
|
-
);
|
|
97
|
-
|
|
98
|
-
return responses.reduce<Record<string, LinkPreview>>(
|
|
99
|
-
(merged, response) => Object.assign(merged, response ?? {}),
|
|
100
|
-
{},
|
|
101
|
-
);
|
|
102
|
-
} catch (error) {
|
|
103
|
-
throw this.handleError(error);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
};
|
|
107
|
-
}
|