@oxy.so/core 1.6.0 → 1.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/core",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -118,7 +118,7 @@
118
118
  "@oxy.so/telemetry": "^1.1.1",
119
119
  "@noble/ciphers": "^1.3.0",
120
120
  "@noble/hashes": "^1.8.0",
121
- "@oxy.so/contracts": "^1.2.0",
121
+ "@oxy.so/contracts": "^1.3.0",
122
122
  "@oxy.so/protocol": "^1.0.1",
123
123
  "@scure/bip39": "^1.6.0",
124
124
  "buffer": "^6.0.3",
@@ -1,6 +1,61 @@
1
1
  import { createEcosystemTraffic } from '../traffic';
2
2
 
3
- jest.mock('../../OxyServices', () => ({ OxyServices: jest.fn(() => ({})) }));
3
+ /**
4
+ * Every client the publisher builds, so a test can ask what it was handed.
5
+ *
6
+ * Named `mock*` because ts-jest hoists `jest.mock` above the imports and only
7
+ * lets a factory close over a binding with that prefix.
8
+ */
9
+ const mockClients: Array<{ configureServiceAuth: jest.Mock; getServiceToken: jest.Mock }> = [];
10
+
11
+ jest.mock('../../OxyServices', () => ({
12
+ OxyServices: jest.fn(() => {
13
+ const client = {
14
+ configureServiceAuth: jest.fn(),
15
+ getServiceToken: jest.fn(async () => 'token-from-workload'),
16
+ };
17
+ mockClients.push(client);
18
+ return client;
19
+ }),
20
+ }));
21
+
22
+ function lastClient() {
23
+ const client = mockClients[mockClients.length - 1];
24
+ if (!client) throw new Error('No OxyServices was built.');
25
+ return client;
26
+ }
27
+
28
+ /** Accepts every publish and remembers the headers, so nothing reaches a network. */
29
+ function recordingFetch(sent: RequestInit[]) {
30
+ return jest.fn(async (_input, init) => {
31
+ sent.push(init as RequestInit);
32
+ return new Response(null, { status: 204 });
33
+ }) as unknown as typeof fetch;
34
+ }
35
+
36
+ function authorizations(sent: RequestInit[]): string[] {
37
+ return sent.map(init => (init.headers as Record<string, string>).Authorization);
38
+ }
39
+
40
+ /**
41
+ * A process with nothing configured and nothing to attest: a laptop.
42
+ *
43
+ * Emptied by assignment rather than `delete`, which Biome refuses on a property
44
+ * access — and `= undefined` would be worse than either, because Node stores the
45
+ * STRING "undefined" and every check here would read it as a configured value.
46
+ */
47
+ function clearCredentialEnvironment() {
48
+ process.env.OXY_ACTIVITY_API_KEY = '';
49
+ process.env.OXY_ACTIVITY_API_SECRET = '';
50
+ process.env.OXY_SERVICE_API_KEY = '';
51
+ process.env.OXY_SERVICE_API_SECRET = '';
52
+ process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI = '';
53
+ process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI = '';
54
+ }
55
+
56
+ beforeEach(() => {
57
+ mockClients.length = 0;
58
+ });
4
59
 
5
60
  it('publishes independently of viewers and waits for registration before removing a stopped instance', async () => {
6
61
  const originalFetch = globalThis.fetch;
@@ -47,3 +102,79 @@ it('rejects a partial activity credential instead of mixing it with service cred
47
102
  process.env = previous;
48
103
  }
49
104
  });
105
+
106
+ /**
107
+ * Who may publish ecosystem activity, and who is still refused.
108
+ *
109
+ * ADR 0026 made "no api key, no secret" a legitimate shape for a first-party
110
+ * service: it attests its ECS task role instead. This constructor used to throw
111
+ * on exactly that shape, so the migration it asks for — delete two variables
112
+ * from a task definition — killed the process at boot, the circuit breaker
113
+ * rolled the deploy back, and the operator was told the service was stable.
114
+ */
115
+ describe('ecosystem activity credentials', () => {
116
+ const previous = { ...process.env };
117
+ const originalFetch = globalThis.fetch;
118
+
119
+ afterEach(() => {
120
+ process.env = { ...previous };
121
+ globalThis.fetch = originalFetch;
122
+ });
123
+
124
+ it('uses a service key pair when the process holds one', async () => {
125
+ clearCredentialEnvironment();
126
+ process.env.OXY_SERVICE_API_KEY = 'service-key';
127
+ process.env.OXY_SERVICE_API_SECRET = 'service-secret';
128
+ const sent: RequestInit[] = [];
129
+ globalThis.fetch = recordingFetch(sent);
130
+
131
+ const traffic = createEcosystemTraffic({ service: 'mention', region: 'us-west-2' });
132
+ await traffic.stop();
133
+
134
+ expect(lastClient().configureServiceAuth).toHaveBeenCalledWith('service-key', 'service-secret');
135
+ expect(lastClient().getServiceToken).toHaveBeenCalled();
136
+ });
137
+
138
+ it('lets an explicit credential win over the environment', async () => {
139
+ clearCredentialEnvironment();
140
+ process.env.OXY_SERVICE_API_KEY = 'service-key';
141
+ process.env.OXY_SERVICE_API_SECRET = 'service-secret';
142
+ const sent: RequestInit[] = [];
143
+ globalThis.fetch = recordingFetch(sent);
144
+
145
+ const traffic = createEcosystemTraffic({ service: 'mention', region: 'us-west-2', credential: async () => 'explicit-token' });
146
+ await traffic.stop();
147
+
148
+ expect(authorizations(sent)).not.toContain('Bearer token-from-workload');
149
+ expect(authorizations(sent)).toContain('Bearer explicit-token');
150
+ expect(lastClient().getServiceToken).not.toHaveBeenCalled();
151
+ });
152
+
153
+ it('refuses a process with no credential and nothing to attest', () => {
154
+ clearCredentialEnvironment();
155
+
156
+ // A laptop or a CI box: the container credentials endpoint is not going to
157
+ // appear later, so saying so at boot beats failing on the first heartbeat.
158
+ expect(() => createEcosystemTraffic({ service: 'homiio', region: 'us-west-2' })).toThrow(
159
+ /needs one of: an OXY_ACTIVITY_API_KEY and OXY_ACTIVITY_API_SECRET pair, an OXY_SERVICE_API_KEY and OXY_SERVICE_API_SECRET pair, or a workload identity/
160
+ );
161
+ });
162
+
163
+ it('publishes with no key pair at all when the process can attest its workload identity', async () => {
164
+ clearCredentialEnvironment();
165
+ // The variable ECS sets on every task, and nothing else does. Stubbed, so
166
+ // `canAttestWorkloadIdentity()` answers yes without a credentials endpoint.
167
+ process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI = '/v2/credentials/abc';
168
+ const sent: RequestInit[] = [];
169
+ globalThis.fetch = recordingFetch(sent);
170
+
171
+ const traffic = createEcosystemTraffic({ service: 'mention', region: 'us-west-2' });
172
+ await traffic.stop();
173
+
174
+ // Unconfigured on purpose: that is what sends `getServiceToken()` down the
175
+ // workload path instead of handing it half a credential.
176
+ expect(lastClient().configureServiceAuth).not.toHaveBeenCalled();
177
+ expect(lastClient().getServiceToken).toHaveBeenCalled();
178
+ expect(authorizations(sent)).toContain('Bearer token-from-workload');
179
+ });
180
+ });
@@ -3,6 +3,12 @@ import { observeTrafficSocket, observeTrafficWebSocket, type TrafficWebSocket, t
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import type { RequestHandler } from 'express';
5
5
  import { OxyServices } from '../OxyServices';
6
+ // Static, unlike the lazy import in `mixins/OxyServices.auth.ts`: that module is
7
+ // reachable from the root barrel, which ships to React Native, so pulling
8
+ // `node:crypto` in eagerly there would cost every mobile bundle. This file is
9
+ // server-only and the server barrel already exports `./workloadIdentity`, so
10
+ // there is nothing here for a phone to load.
11
+ import { canAttestWorkloadIdentity } from './workloadIdentity';
6
12
  import { createTrafficCollector, instrumentTrafficFetch, trafficMiddleware, type TrafficFlow, type ServiceEndpoint, resolveOxyServiceEndpoint, infrastructureLocation } from '@oxy.so/telemetry/collector';
7
13
 
8
14
  export interface EcosystemTrafficOptions {
@@ -36,8 +42,32 @@ export function createEcosystemTraffic(options: EcosystemTrafficOptions): {
36
42
  }
37
43
  const apiKey = activityKey || process.env.OXY_SERVICE_API_KEY;
38
44
  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');
45
+ /**
46
+ * Having no key pair is a legitimate configuration now — but only where this
47
+ * process can prove what it IS (ADR 0026).
48
+ *
49
+ * This used to throw on a missing pair, full stop. Every service in the fleet
50
+ * boots with `OXY_ECOSYSTEM_ACTIVITY_ENABLED=true`, so deleting
51
+ * `OXY_SERVICE_API_KEY` and `OXY_SERVICE_API_SECRET` from a task definition —
52
+ * which is the entire migration ADR 0026 asks for — killed the process at
53
+ * boot. The ECS circuit breaker then rolled the deploy back and reported the
54
+ * service stable, so the operator saw a rollback with nothing naming its
55
+ * cause. That is what one line cost, multiplied by fourteen services holding
56
+ * a secret they no longer need.
57
+ *
58
+ * What is still refused: nothing to authenticate with AND nothing to attest.
59
+ * That is a laptop or a CI box, where the container credentials endpoint is
60
+ * not going to turn up later, and the honest answer is to say so at boot
61
+ * rather than to fail on the first heartbeat.
62
+ */
63
+ if (!options.credential && (!apiKey || !apiSecret) && !canAttestWorkloadIdentity()) {
64
+ throw new Error(
65
+ 'Ecosystem activity needs one of: an OXY_ACTIVITY_API_KEY and OXY_ACTIVITY_API_SECRET pair, an OXY_SERVICE_API_KEY and OXY_SERVICE_API_SECRET pair, or a workload identity this process can attest (ADR 0026).',
66
+ );
67
+ }
40
68
  const oxy = new OxyServices({ baseURL });
69
+ // Left unconfigured when there is no pair on purpose: `getServiceToken()`
70
+ // attests instead of being handed half a credential to fail on.
41
71
  if (apiKey && apiSecret) oxy.configureServiceAuth(apiKey, apiSecret);
42
72
  const credential = options.credential ?? (() => oxy.getServiceToken());
43
73
  const fetcher = globalThis.fetch.bind(globalThis);