@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.
Files changed (74) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/OxyServices.errors.js +28 -1
  3. package/dist/cjs/crypto/keyManager.js +5 -3
  4. package/dist/cjs/i18n/locales/en-US.json +18 -5
  5. package/dist/cjs/i18n/locales/es-ES.json +18 -5
  6. package/dist/cjs/i18n/locales/locales/en-US.json +18 -5
  7. package/dist/cjs/i18n/locales/locales/es-ES.json +18 -5
  8. package/dist/cjs/mixins/OxyServices.agency.js +113 -0
  9. package/dist/cjs/mixins/OxyServices.assets.js +90 -0
  10. package/dist/cjs/mixins/OxyServices.connectedApps.js +20 -3
  11. package/dist/cjs/mixins/OxyServices.utility.js +1 -21
  12. package/dist/cjs/mixins/index.js +3 -4
  13. package/dist/cjs/package.json +3 -0
  14. package/dist/cjs/server/index.js +6 -1
  15. package/dist/cjs/server/traffic.js +107 -0
  16. package/dist/cjs/server/trafficNodeHttp.js +86 -0
  17. package/dist/cjs/utils/textNormalization.js +1 -1
  18. package/dist/esm/.tsbuildinfo +1 -1
  19. package/dist/esm/OxyServices.errors.js +26 -0
  20. package/dist/esm/crypto/keyManager.js +5 -3
  21. package/dist/esm/i18n/locales/en-US.json +18 -5
  22. package/dist/esm/i18n/locales/es-ES.json +18 -5
  23. package/dist/esm/i18n/locales/locales/en-US.json +18 -5
  24. package/dist/esm/i18n/locales/locales/es-ES.json +18 -5
  25. package/dist/esm/mixins/OxyServices.agency.js +110 -0
  26. package/dist/esm/mixins/OxyServices.assets.js +91 -1
  27. package/dist/esm/mixins/OxyServices.connectedApps.js +20 -3
  28. package/dist/esm/mixins/OxyServices.utility.js +1 -21
  29. package/dist/esm/mixins/index.js +3 -4
  30. package/dist/esm/package.json +3 -0
  31. package/dist/esm/server/index.js +2 -0
  32. package/dist/esm/server/traffic.js +104 -0
  33. package/dist/esm/server/trafficNodeHttp.js +79 -0
  34. package/dist/esm/utils/textNormalization.js +1 -1
  35. package/dist/types/.tsbuildinfo +1 -1
  36. package/dist/types/OxyServices.errors.d.ts +24 -0
  37. package/dist/types/crypto/keyManager.d.ts +4 -2
  38. package/dist/types/index.d.ts +2 -1
  39. package/dist/types/mixins/OxyServices.agency.d.ts +135 -0
  40. package/dist/types/mixins/OxyServices.assets.d.ts +43 -1
  41. package/dist/types/mixins/OxyServices.connectedApps.d.ts +15 -0
  42. package/dist/types/mixins/OxyServices.utility.d.ts +0 -9
  43. package/dist/types/mixins/index.d.ts +2 -2
  44. package/dist/types/models/interfaces.d.ts +26 -0
  45. package/dist/types/server/index.d.ts +3 -0
  46. package/dist/types/server/traffic.d.ts +27 -0
  47. package/dist/types/server/trafficNodeHttp.d.ts +6 -0
  48. package/dist/types/utils/textNormalization.d.ts +1 -1
  49. package/package.json +7 -6
  50. package/src/OxyServices.errors.ts +36 -0
  51. package/src/crypto/keyManager.ts +5 -4
  52. package/src/i18n/locales/en-US.json +18 -5
  53. package/src/i18n/locales/es-ES.json +18 -5
  54. package/src/index.ts +15 -0
  55. package/src/mixins/OxyServices.agency.ts +277 -0
  56. package/src/mixins/OxyServices.assets.ts +105 -2
  57. package/src/mixins/OxyServices.connectedApps.ts +42 -4
  58. package/src/mixins/OxyServices.utility.ts +1 -26
  59. package/src/mixins/__tests__/OxyServices.serviceLinkedDownloadUrl.test.ts +139 -0
  60. package/src/mixins/__tests__/agency.test.ts +120 -0
  61. package/src/mixins/__tests__/connectedApps.test.ts +23 -0
  62. package/src/mixins/index.ts +4 -5
  63. package/src/models/interfaces.ts +27 -0
  64. package/src/server/__tests__/traffic.test.ts +49 -0
  65. package/src/server/__tests__/trafficNodeHttp.test.ts +38 -0
  66. package/src/server/index.ts +5 -0
  67. package/src/server/traffic.ts +116 -0
  68. package/src/server/trafficNodeHttp.ts +69 -0
  69. package/src/utils/textNormalization.ts +1 -1
  70. package/dist/cjs/mixins/OxyServices.links.js +0 -72
  71. package/dist/esm/mixins/OxyServices.links.js +0 -69
  72. package/dist/types/mixins/OxyServices.links.d.ts +0 -104
  73. package/src/mixins/OxyServices.links.ts +0 -107
  74. 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
  });
@@ -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
+ }