@oxy.so/core 1.0.0 → 1.1.1
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/crypto/keyManager.js +5 -3
- package/dist/cjs/i18n/locales/en-US.json +18 -5
- package/dist/cjs/i18n/locales/es-ES.json +18 -5
- package/dist/cjs/i18n/locales/locales/en-US.json +18 -5
- package/dist/cjs/i18n/locales/locales/es-ES.json +18 -5
- 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/package.json +3 -0
- 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/crypto/keyManager.js +5 -3
- package/dist/esm/i18n/locales/en-US.json +18 -5
- package/dist/esm/i18n/locales/es-ES.json +18 -5
- package/dist/esm/i18n/locales/locales/en-US.json +18 -5
- package/dist/esm/i18n/locales/locales/es-ES.json +18 -5
- 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/package.json +3 -0
- 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/crypto/keyManager.d.ts +4 -2
- 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.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 +7 -6
- package/src/OxyServices.errors.ts +36 -0
- package/src/crypto/keyManager.ts +5 -4
- package/src/i18n/locales/en-US.json +18 -5
- package/src/i18n/locales/es-ES.json +18 -5
- 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/dist/cjs/mixins/OxyServices.links.js +0 -72
- package/dist/esm/mixins/OxyServices.links.js +0 -69
- package/dist/types/mixins/OxyServices.links.d.ts +0 -104
- package/src/mixins/OxyServices.links.ts +0 -107
- package/src/mixins/__tests__/OxyServices.links.test.ts +0 -154
|
@@ -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
|
+
});
|
|
@@ -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
|
+
}
|