@oxy.so/core 1.0.1 → 1.1.2

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.
Files changed (73) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/OxyServices.errors.js +28 -1
  3. package/dist/cjs/i18n/locales/en-US.json +14 -1
  4. package/dist/cjs/i18n/locales/es-ES.json +14 -1
  5. package/dist/cjs/i18n/locales/locales/en-US.json +14 -1
  6. package/dist/cjs/i18n/locales/locales/es-ES.json +14 -1
  7. package/dist/cjs/mixins/OxyServices.agency.js +113 -0
  8. package/dist/cjs/mixins/OxyServices.assets.js +90 -0
  9. package/dist/cjs/mixins/OxyServices.connectedApps.js +20 -3
  10. package/dist/cjs/mixins/OxyServices.utility.js +1 -21
  11. package/dist/cjs/mixins/index.js +3 -4
  12. package/dist/cjs/server/index.js +6 -1
  13. package/dist/cjs/server/traffic.js +107 -0
  14. package/dist/cjs/server/trafficNodeHttp.js +86 -0
  15. package/dist/cjs/utils/cache.js +28 -19
  16. package/dist/cjs/utils/textNormalization.js +1 -1
  17. package/dist/esm/.tsbuildinfo +1 -1
  18. package/dist/esm/OxyServices.errors.js +26 -0
  19. package/dist/esm/i18n/locales/en-US.json +14 -1
  20. package/dist/esm/i18n/locales/es-ES.json +14 -1
  21. package/dist/esm/i18n/locales/locales/en-US.json +14 -1
  22. package/dist/esm/i18n/locales/locales/es-ES.json +14 -1
  23. package/dist/esm/mixins/OxyServices.agency.js +110 -0
  24. package/dist/esm/mixins/OxyServices.assets.js +91 -1
  25. package/dist/esm/mixins/OxyServices.connectedApps.js +20 -3
  26. package/dist/esm/mixins/OxyServices.utility.js +1 -21
  27. package/dist/esm/mixins/index.js +3 -4
  28. package/dist/esm/server/index.js +2 -0
  29. package/dist/esm/server/traffic.js +104 -0
  30. package/dist/esm/server/trafficNodeHttp.js +79 -0
  31. package/dist/esm/utils/cache.js +28 -19
  32. package/dist/esm/utils/textNormalization.js +1 -1
  33. package/dist/types/.tsbuildinfo +1 -1
  34. package/dist/types/OxyServices.errors.d.ts +24 -0
  35. package/dist/types/index.d.ts +2 -1
  36. package/dist/types/mixins/OxyServices.agency.d.ts +135 -0
  37. package/dist/types/mixins/OxyServices.assets.d.ts +43 -1
  38. package/dist/types/mixins/OxyServices.connectedApps.d.ts +15 -0
  39. package/dist/types/mixins/OxyServices.utility.d.ts +0 -9
  40. package/dist/types/mixins/index.d.ts +2 -2
  41. package/dist/types/models/interfaces.d.ts +26 -0
  42. package/dist/types/server/index.d.ts +3 -0
  43. package/dist/types/server/traffic.d.ts +27 -0
  44. package/dist/types/server/trafficNodeHttp.d.ts +6 -0
  45. package/dist/types/utils/cache.d.ts +1 -4
  46. package/dist/types/utils/textNormalization.d.ts +1 -1
  47. package/package.json +4 -4
  48. package/src/OxyServices.errors.ts +36 -0
  49. package/src/i18n/locales/en-US.json +14 -1
  50. package/src/i18n/locales/es-ES.json +14 -1
  51. package/src/index.ts +15 -0
  52. package/src/mixins/OxyServices.agency.ts +277 -0
  53. package/src/mixins/OxyServices.assets.ts +105 -2
  54. package/src/mixins/OxyServices.connectedApps.ts +42 -4
  55. package/src/mixins/OxyServices.utility.ts +1 -26
  56. package/src/mixins/__tests__/OxyServices.serviceLinkedDownloadUrl.test.ts +139 -0
  57. package/src/mixins/__tests__/agency.test.ts +120 -0
  58. package/src/mixins/__tests__/connectedApps.test.ts +23 -0
  59. package/src/mixins/index.ts +4 -5
  60. package/src/models/interfaces.ts +27 -0
  61. package/src/server/__tests__/traffic.test.ts +49 -0
  62. package/src/server/__tests__/trafficNodeHttp.test.ts +38 -0
  63. package/src/server/index.ts +5 -0
  64. package/src/server/traffic.ts +116 -0
  65. package/src/server/trafficNodeHttp.ts +69 -0
  66. package/src/utils/__tests__/cacheLifecycle.test.ts +65 -0
  67. package/src/utils/cache.ts +22 -20
  68. package/src/utils/textNormalization.ts +1 -1
  69. package/dist/cjs/mixins/OxyServices.links.js +0 -72
  70. package/dist/esm/mixins/OxyServices.links.js +0 -69
  71. package/dist/types/mixins/OxyServices.links.d.ts +0 -104
  72. package/src/mixins/OxyServices.links.ts +0 -107
  73. 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
  });
@@ -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,
@@ -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
+ });
@@ -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
+ }
@@ -0,0 +1,65 @@
1
+ import { TTLCache, registerCacheForCleanup, unregisterCacheFromCleanup, stopAllCleanupIntervals } from '../cache';
2
+
3
+ afterEach(() => { stopAllCleanupIntervals(); jest.useRealTimers(); });
4
+
5
+ test('empty registration is inert and first data starts one shared cleanup timer', () => {
6
+ jest.useFakeTimers();
7
+ const a = new TTLCache<string>(10);
8
+ const b = new TTLCache<string>(10);
9
+ registerCacheForCleanup(a);
10
+ registerCacheForCleanup(b);
11
+ expect(jest.getTimerCount()).toBe(0);
12
+ a.set('a', 'value');
13
+ b.set('b', 'value');
14
+ expect(jest.getTimerCount()).toBe(1);
15
+ jest.advanceTimersByTime(11);
16
+ expect(a.get('a')).toBeNull();
17
+ expect(b.has('b')).toBe(false);
18
+ jest.advanceTimersByTime(60_000);
19
+ expect(jest.getTimerCount()).toBe(0);
20
+ });
21
+ test('periodic cleanup expires unread entries and clear restarts lazily without losing registration', () => {
22
+ jest.useFakeTimers();
23
+ const a = new TTLCache<string>(10);
24
+ registerCacheForCleanup(a);
25
+ a.set('expired', 'value');
26
+ jest.advanceTimersByTime(60_000);
27
+ expect(a.size()).toBe(0);
28
+ expect(jest.getTimerCount()).toBe(0);
29
+ a.set('again', 'value');
30
+ expect(jest.getTimerCount()).toBe(1);
31
+ a.clear();
32
+ expect(jest.getTimerCount()).toBe(0);
33
+ a.set('after-clear', 'value');
34
+ expect(jest.getTimerCount()).toBe(1);
35
+ });
36
+ test('unregister and stop preserve entries but prevent writes restarting automatic cleanup', () => {
37
+ jest.useFakeTimers();
38
+ const a = new TTLCache<string>(100_000);
39
+ const b = new TTLCache<string>(100_000);
40
+ a.set('a', 'value');
41
+ registerCacheForCleanup(a);
42
+ registerCacheForCleanup(b);
43
+ b.set('b', 'value');
44
+ unregisterCacheFromCleanup(a);
45
+ expect(jest.getTimerCount()).toBe(1);
46
+ b.delete('b');
47
+ expect(jest.getTimerCount()).toBe(0);
48
+ a.set('unregistered', 'value');
49
+ expect(jest.getTimerCount()).toBe(0);
50
+ registerCacheForCleanup(a);
51
+ expect(jest.getTimerCount()).toBe(1);
52
+ stopAllCleanupIntervals();
53
+ a.set('stopped', 'value');
54
+ expect(jest.getTimerCount()).toBe(0);
55
+ expect(a.get('a')).toBe('value');
56
+ });
57
+ test('server barrel imports and client construction start no background timers', () => {
58
+ jest.useFakeTimers();
59
+ jest.isolateModules(() => {
60
+ const server = require('../../server');
61
+ expect(typeof server.createEcosystemTraffic).toBe('function');
62
+ expect(jest.getTimerCount()).toBe(0);
63
+ require('../cache').stopAllCleanupIntervals();
64
+ });
65
+ });
@@ -104,6 +104,7 @@ export class TTLCache<T> {
104
104
  const effectiveTTL = ttl === undefined ? this.defaultTTL : ttl;
105
105
  const expiresAt = now + effectiveTTL;
106
106
  this.cache.set(key, { data, timestamp: now, expiresAt });
107
+ if (!cleanupInterval && activeCaches.has(this)) updateCleanupInterval();
107
108
  }
108
109
 
109
110
  /**
@@ -112,7 +113,9 @@ export class TTLCache<T> {
112
113
  * @returns true if entry was deleted, false if not found
113
114
  */
114
115
  delete(key: string): boolean {
115
- return this.cache.delete(key);
116
+ const deleted = this.cache.delete(key);
117
+ if (activeCaches.has(this)) updateCleanupInterval();
118
+ return deleted;
116
119
  }
117
120
 
118
121
  /**
@@ -122,6 +125,7 @@ export class TTLCache<T> {
122
125
  this.cache.clear();
123
126
  this.hits = 0;
124
127
  this.misses = 0;
128
+ if (activeCaches.has(this)) updateCleanupInterval();
125
129
  }
126
130
 
127
131
  /**
@@ -232,31 +236,29 @@ const activeCaches = new Set<TTLCache<any>>();
232
236
  */
233
237
  export function registerCacheForCleanup(cache: TTLCache<any>): void {
234
238
  activeCaches.add(cache);
239
+ updateCleanupInterval();
240
+ }
235
241
 
236
- // Start cleanup interval if not already running
237
- if (!cleanupInterval) {
238
- cleanupInterval = setInterval(() => {
239
- for (const cache of activeCaches) {
240
- cache.cleanup();
241
- }
242
- }, 60000); // Every minute
243
- // Never block process/worker exit on a housekeeping timer.
244
- cleanupInterval.unref?.();
242
+ /** Empty registered caches need no timer; importing a client must stay inert. */
243
+ function updateCleanupInterval(): void {
244
+ const hasEntries = [...activeCaches].some(cache => cache.size() > 0);
245
+ if (!hasEntries) {
246
+ if (cleanupInterval) clearInterval(cleanupInterval);
247
+ cleanupInterval = null;
248
+ return;
245
249
  }
250
+ if (cleanupInterval) return;
251
+ cleanupInterval = setInterval(() => {
252
+ for (const cache of activeCaches) cache.cleanup();
253
+ updateCleanupInterval();
254
+ }, 60_000);
255
+ cleanupInterval.unref?.();
246
256
  }
247
257
 
248
- /**
249
- * Unregister a cache from automatic cleanup
250
- * @param cache Cache instance to unregister
251
- */
258
+ /** Unregister a cache without changing its entries or TTL behavior. */
252
259
  export function unregisterCacheFromCleanup(cache: TTLCache<any>): void {
253
260
  activeCaches.delete(cache);
254
-
255
- // Stop cleanup interval if no caches are registered
256
- if (activeCaches.size === 0 && cleanupInterval) {
257
- clearInterval(cleanupInterval);
258
- cleanupInterval = null;
259
- }
261
+ updateCleanupInterval();
260
262
  }
261
263
 
262
264
  /**