@oxy.so/core 1.5.1 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +72 -9
- package/dist/cjs/crypto/identityMove.js +53 -34
- package/dist/cjs/crypto/identityProof.js +28 -0
- package/dist/cjs/crypto/webIdentityCarrier.js +145 -82
- package/dist/cjs/i18n/locales/ar-SA.json +1 -0
- package/dist/cjs/i18n/locales/ca-ES.json +1 -0
- package/dist/cjs/i18n/locales/de-DE.json +1 -0
- package/dist/cjs/i18n/locales/en-US.json +1 -0
- package/dist/cjs/i18n/locales/es-ES.json +1 -0
- package/dist/cjs/i18n/locales/fr-FR.json +1 -0
- package/dist/cjs/i18n/locales/it-IT.json +1 -0
- package/dist/cjs/i18n/locales/ja-JP.json +1 -0
- package/dist/cjs/i18n/locales/ko-KR.json +1 -0
- package/dist/cjs/i18n/locales/locales/ar-SA.json +1 -0
- package/dist/cjs/i18n/locales/locales/ca-ES.json +1 -0
- package/dist/cjs/i18n/locales/locales/de-DE.json +1 -0
- package/dist/cjs/i18n/locales/locales/en-US.json +1 -0
- package/dist/cjs/i18n/locales/locales/es-ES.json +1 -0
- package/dist/cjs/i18n/locales/locales/fr-FR.json +1 -0
- package/dist/cjs/i18n/locales/locales/it-IT.json +1 -0
- package/dist/cjs/i18n/locales/locales/ja-JP.json +1 -0
- package/dist/cjs/i18n/locales/locales/ko-KR.json +1 -0
- package/dist/cjs/i18n/locales/locales/pt-PT.json +1 -0
- package/dist/cjs/i18n/locales/locales/zh-CN.json +1 -0
- package/dist/cjs/i18n/locales/pt-PT.json +1 -0
- package/dist/cjs/i18n/locales/zh-CN.json +1 -0
- package/dist/cjs/index.js +22 -14
- package/dist/cjs/inference/OxyInferenceClient.js +62 -5
- package/dist/cjs/mixins/OxyServices.auth.js +129 -0
- package/dist/cjs/mixins/OxyServices.identity.js +11 -44
- package/dist/cjs/mixins/index.js +0 -2
- package/dist/cjs/server/index.js +10 -1
- package/dist/cjs/server/securityHeaders.js +9 -2
- package/dist/cjs/server/workloadIdentity.js +170 -0
- package/dist/cjs/session/refresh.js +12 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +72 -9
- package/dist/esm/crypto/identityMove.js +51 -33
- package/dist/esm/crypto/identityProof.js +24 -0
- package/dist/esm/crypto/webIdentityCarrier.js +137 -77
- package/dist/esm/i18n/locales/ar-SA.json +1 -0
- package/dist/esm/i18n/locales/ca-ES.json +1 -0
- package/dist/esm/i18n/locales/de-DE.json +1 -0
- package/dist/esm/i18n/locales/en-US.json +1 -0
- package/dist/esm/i18n/locales/es-ES.json +1 -0
- package/dist/esm/i18n/locales/fr-FR.json +1 -0
- package/dist/esm/i18n/locales/it-IT.json +1 -0
- package/dist/esm/i18n/locales/ja-JP.json +1 -0
- package/dist/esm/i18n/locales/ko-KR.json +1 -0
- package/dist/esm/i18n/locales/locales/ar-SA.json +1 -0
- package/dist/esm/i18n/locales/locales/ca-ES.json +1 -0
- package/dist/esm/i18n/locales/locales/de-DE.json +1 -0
- package/dist/esm/i18n/locales/locales/en-US.json +1 -0
- package/dist/esm/i18n/locales/locales/es-ES.json +1 -0
- package/dist/esm/i18n/locales/locales/fr-FR.json +1 -0
- package/dist/esm/i18n/locales/locales/it-IT.json +1 -0
- package/dist/esm/i18n/locales/locales/ja-JP.json +1 -0
- package/dist/esm/i18n/locales/locales/ko-KR.json +1 -0
- package/dist/esm/i18n/locales/locales/pt-PT.json +1 -0
- package/dist/esm/i18n/locales/locales/zh-CN.json +1 -0
- package/dist/esm/i18n/locales/pt-PT.json +1 -0
- package/dist/esm/i18n/locales/zh-CN.json +1 -0
- package/dist/esm/index.js +5 -4
- package/dist/esm/inference/OxyInferenceClient.js +63 -6
- package/dist/esm/mixins/OxyServices.auth.js +96 -0
- package/dist/esm/mixins/OxyServices.identity.js +11 -44
- package/dist/esm/mixins/index.js +0 -2
- package/dist/esm/server/index.js +7 -0
- package/dist/esm/server/securityHeaders.js +9 -2
- package/dist/esm/server/workloadIdentity.js +166 -0
- package/dist/esm/session/refresh.js +12 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +24 -0
- package/dist/types/crypto/identityMove.d.ts +48 -32
- package/dist/types/crypto/identityProof.d.ts +16 -0
- package/dist/types/crypto/webIdentityCarrier.d.ts +74 -47
- package/dist/types/index.d.ts +7 -6
- package/dist/types/inference/OxyInferenceClient.d.ts +21 -1
- package/dist/types/mixins/OxyServices.auth.d.ts +32 -0
- package/dist/types/mixins/OxyServices.identity.d.ts +16 -30
- package/dist/types/mixins/index.d.ts +1 -2
- package/dist/types/server/index.d.ts +8 -0
- package/dist/types/server/securityHeaders.d.ts +8 -0
- package/dist/types/server/workloadIdentity.d.ts +32 -0
- package/package.json +1 -1
- package/src/HttpService.ts +74 -9
- package/src/__tests__/httpServiceRateLimitedRefresh.test.ts +160 -0
- package/src/__tests__/serviceTokenWorkloadFallback.test.ts +85 -0
- package/src/crypto/__tests__/identityMove.test.ts +93 -51
- package/src/crypto/__tests__/identityProof.test.ts +81 -0
- package/src/crypto/__tests__/webIdentityCarrier.test.ts +21 -63
- package/src/crypto/__tests__/webIdentityRecoveryMaterial.test.ts +117 -0
- package/src/crypto/identityMove.ts +77 -43
- package/src/crypto/identityProof.ts +36 -0
- package/src/crypto/webIdentityCarrier.ts +185 -117
- package/src/i18n/locales/ar-SA.json +1 -0
- package/src/i18n/locales/ca-ES.json +1 -0
- package/src/i18n/locales/de-DE.json +1 -0
- package/src/i18n/locales/en-US.json +1 -0
- package/src/i18n/locales/es-ES.json +1 -0
- package/src/i18n/locales/fr-FR.json +1 -0
- package/src/i18n/locales/it-IT.json +1 -0
- package/src/i18n/locales/ja-JP.json +1 -0
- package/src/i18n/locales/ko-KR.json +1 -0
- package/src/i18n/locales/pt-PT.json +1 -0
- package/src/i18n/locales/zh-CN.json +1 -0
- package/src/index.ts +26 -15
- package/src/inference/OxyInferenceClient.ts +84 -7
- package/src/inference/__tests__/OxyInferenceClient.test.ts +48 -0
- package/src/mixins/OxyServices.auth.ts +100 -1
- package/src/mixins/OxyServices.identity.ts +17 -67
- package/src/mixins/__tests__/OxyServices.identity.test.ts +1 -76
- package/src/mixins/index.ts +0 -3
- package/src/server/__tests__/securityHeaders.test.ts +15 -0
- package/src/server/__tests__/workloadIdentity.test.ts +128 -0
- package/src/server/index.ts +9 -0
- package/src/server/securityHeaders.ts +19 -6
- package/src/server/workloadIdentity.ts +207 -0
- package/src/session/__tests__/refresh.test.ts +53 -3
- package/src/session/refresh.ts +12 -0
- package/dist/cjs/mixins/OxyServices.deviceTransfer.js +0 -314
- package/dist/esm/mixins/OxyServices.deviceTransfer.js +0 -311
- package/dist/types/mixins/OxyServices.deviceTransfer.d.ts +0 -149
- package/src/mixins/OxyServices.deviceTransfer.ts +0 -393
- package/src/mixins/__tests__/OxyServices.deviceTransfer.test.ts +0 -271
|
@@ -58,7 +58,9 @@ import type {
|
|
|
58
58
|
InferenceFinishReason,
|
|
59
59
|
InferenceMessage,
|
|
60
60
|
InferenceRequestOutcome,
|
|
61
|
+
InferenceAudioMediaType,
|
|
61
62
|
InferenceStreamEvent,
|
|
63
|
+
InferenceSpeechParameters,
|
|
62
64
|
ModelCatalogueEntry,
|
|
63
65
|
ResponseFormat,
|
|
64
66
|
RoutingPolicyReference,
|
|
@@ -69,7 +71,13 @@ import type {
|
|
|
69
71
|
UsageQuantity,
|
|
70
72
|
UsageSource,
|
|
71
73
|
} from '@oxy.so/contracts';
|
|
72
|
-
import {
|
|
74
|
+
import {
|
|
75
|
+
INFERENCE_ERROR_CODES,
|
|
76
|
+
MAX_INFERENCE_AUDIO_BYTES,
|
|
77
|
+
inferenceAudioMediaTypeSchema,
|
|
78
|
+
inferenceStreamEventSchema,
|
|
79
|
+
modelIdSchema,
|
|
80
|
+
} from '@oxy.so/contracts';
|
|
73
81
|
|
|
74
82
|
/** The base URL of the Oxy API, when a caller names none. */
|
|
75
83
|
export const OXY_INFERENCE_BASE_URL = 'https://api.oxy.so';
|
|
@@ -138,6 +146,23 @@ export interface OxyResponsesRequest {
|
|
|
138
146
|
readonly clientRequestId?: string;
|
|
139
147
|
}
|
|
140
148
|
|
|
149
|
+
/** Speech uses the same credential and delegation lane as responses. */
|
|
150
|
+
export type OxySpeechRequest = (
|
|
151
|
+
| { readonly model: string; readonly routingProfileId?: never }
|
|
152
|
+
| { readonly routingProfileId: string; readonly model?: never }
|
|
153
|
+
) & {
|
|
154
|
+
readonly input: string;
|
|
155
|
+
readonly voice: string;
|
|
156
|
+
readonly response_format?: InferenceSpeechParameters['responseFormat'];
|
|
157
|
+
readonly speed?: number;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export interface OxySpeechResponse {
|
|
161
|
+
readonly audio: Uint8Array;
|
|
162
|
+
readonly mediaType: InferenceAudioMediaType;
|
|
163
|
+
readonly requestId: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
141
166
|
export interface OxyInferenceRequestOptions {
|
|
142
167
|
/**
|
|
143
168
|
* Abort the request. The edge treats a client disconnect as a cancellation:
|
|
@@ -434,6 +459,57 @@ export class OxyInferenceClient {
|
|
|
434
459
|
});
|
|
435
460
|
}
|
|
436
461
|
|
|
462
|
+
/** Generate audio bytes through Oxy's authenticated inference edge. */
|
|
463
|
+
async speech(
|
|
464
|
+
request: OxySpeechRequest,
|
|
465
|
+
options: OxyInferenceRequestOptions = {},
|
|
466
|
+
): Promise<OxySpeechResponse> {
|
|
467
|
+
const response = await this.#fetch(`${this.#baseURL}/v1/audio/speech`, {
|
|
468
|
+
method: 'POST',
|
|
469
|
+
headers: await this.#headers('audio/*', {
|
|
470
|
+
hasBody: true,
|
|
471
|
+
...(options.idempotencyKey === undefined
|
|
472
|
+
? {}
|
|
473
|
+
: { idempotencyKey: options.idempotencyKey }),
|
|
474
|
+
...(options.delegatedUserId === undefined
|
|
475
|
+
? {}
|
|
476
|
+
: { delegatedUserId: options.delegatedUserId }),
|
|
477
|
+
}),
|
|
478
|
+
body: JSON.stringify(request),
|
|
479
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
480
|
+
});
|
|
481
|
+
const requestId = response.headers.get('X-Oxy-Request-Id') ?? '';
|
|
482
|
+
if (!response.ok) {
|
|
483
|
+
throw toInferenceError(await response.json().catch(() => undefined), response.status, requestId);
|
|
484
|
+
}
|
|
485
|
+
const mediaType = inferenceAudioMediaTypeSchema.safeParse(mediaTypeOf(response));
|
|
486
|
+
if (!mediaType.success || !response.body) {
|
|
487
|
+
await cancelUnreadResponse(response);
|
|
488
|
+
throw protocolError('The inference API returned no supported audio body.', requestId);
|
|
489
|
+
}
|
|
490
|
+
const reader = response.body.getReader();
|
|
491
|
+
const chunks: Uint8Array[] = [];
|
|
492
|
+
let size = 0;
|
|
493
|
+
let completed = false;
|
|
494
|
+
try {
|
|
495
|
+
while (true) {
|
|
496
|
+
const next = await reader.read();
|
|
497
|
+
if (next.done) { completed = true; break; }
|
|
498
|
+
size += next.value.byteLength;
|
|
499
|
+
if (size > MAX_INFERENCE_AUDIO_BYTES) throw protocolError('The inference audio exceeds the response limit.', requestId);
|
|
500
|
+
chunks.push(next.value);
|
|
501
|
+
}
|
|
502
|
+
} finally {
|
|
503
|
+
if (!completed) await reader.cancel().catch(() => undefined);
|
|
504
|
+
reader.releaseLock();
|
|
505
|
+
}
|
|
506
|
+
if (size === 0) throw protocolError('The inference API returned empty audio.', requestId);
|
|
507
|
+
const audio = new Uint8Array(size);
|
|
508
|
+
let offset = 0;
|
|
509
|
+
for (const chunk of chunks) { audio.set(chunk, offset); offset += chunk.byteLength; }
|
|
510
|
+
return { audio, mediaType: mediaType.data, requestId };
|
|
511
|
+
}
|
|
512
|
+
|
|
437
513
|
/**
|
|
438
514
|
* Stream one inference request from `POST /v1/responses`.
|
|
439
515
|
*
|
|
@@ -481,11 +557,7 @@ export class OxyInferenceClient {
|
|
|
481
557
|
}
|
|
482
558
|
|
|
483
559
|
const requestId = response.headers.get('X-Oxy-Request-Id') ?? undefined;
|
|
484
|
-
const mediaType = response
|
|
485
|
-
.get('Content-Type')
|
|
486
|
-
?.split(';', 1)[0]
|
|
487
|
-
?.trim()
|
|
488
|
-
.toLowerCase();
|
|
560
|
+
const mediaType = mediaTypeOf(response);
|
|
489
561
|
if (mediaType !== 'text/event-stream') {
|
|
490
562
|
await cancelUnreadResponse(response);
|
|
491
563
|
throw protocolError(
|
|
@@ -607,7 +679,7 @@ export class OxyInferenceClient {
|
|
|
607
679
|
|
|
608
680
|
/** Build the authenticated headers for one request, re-reading its bearer. */
|
|
609
681
|
async #headers(
|
|
610
|
-
accept: 'application/json' | 'text/event-stream',
|
|
682
|
+
accept: 'application/json' | 'text/event-stream' | 'audio/*',
|
|
611
683
|
options: {
|
|
612
684
|
hasBody: boolean;
|
|
613
685
|
idempotencyKey?: string;
|
|
@@ -694,6 +766,11 @@ function protocolError(message: string, requestId?: string): OxyInferenceProtoco
|
|
|
694
766
|
return new OxyInferenceProtocolError(message, requestId);
|
|
695
767
|
}
|
|
696
768
|
|
|
769
|
+
/** The bare, lowercased media type of a response's `Content-Type`, if any. */
|
|
770
|
+
function mediaTypeOf(response: Response): string | undefined {
|
|
771
|
+
return response.headers.get('Content-Type')?.split(';', 1)[0]?.trim().toLowerCase();
|
|
772
|
+
}
|
|
773
|
+
|
|
697
774
|
/** Cancel a nominally successful response whose stream contract is unreadable. */
|
|
698
775
|
async function cancelUnreadResponse(response: Response): Promise<void> {
|
|
699
776
|
await response.body?.cancel().catch(() => undefined);
|
|
@@ -832,3 +832,51 @@ describe('OxyInferenceClient', () => {
|
|
|
832
832
|
expect(calls[0].url).toBe('http://test.invalid/v1/models');
|
|
833
833
|
});
|
|
834
834
|
});
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
describe('speech', () => {
|
|
838
|
+
it('preserves audio bytes, exact profile and delegated user with the service credential', async () => {
|
|
839
|
+
const bytes = new Uint8Array([73, 68, 51, 0, 255, 4]);
|
|
840
|
+
const transport = jest.fn(async () => new Response(bytes, { headers: {
|
|
841
|
+
'Content-Type': 'audio/mpeg', 'X-Oxy-Request-Id': 'req_speech',
|
|
842
|
+
} }));
|
|
843
|
+
const client = new OxyInferenceClient({ credential: async () => 'service-test', fetch: transport });
|
|
844
|
+
const input = { routingProfileId: 'opaque-reviewed-id', input: 'Hola 👋', voice: 'female', speed: 1.15 };
|
|
845
|
+
const result = await client.speech(input, { delegatedUserId: 'user_test', idempotencyKey: 'speech_test' });
|
|
846
|
+
expect(result).toEqual({ audio: bytes, mediaType: 'audio/mpeg', requestId: 'req_speech' });
|
|
847
|
+
const [url, init] = (transport.mock.calls as unknown as Array<[string, RequestInit]>)[0];
|
|
848
|
+
expect(url).toBe('https://api.oxy.so/v1/audio/speech');
|
|
849
|
+
expect(JSON.parse(String(init.body))).toEqual(input);
|
|
850
|
+
const headers = new Headers(init.headers);
|
|
851
|
+
expect(headers.get('Authorization')).toBe('Bearer service-test');
|
|
852
|
+
expect(headers.get('X-Oxy-User-Id')).toBe('user_test');
|
|
853
|
+
expect(headers.get('Idempotency-Key')).toBe('speech_test');
|
|
854
|
+
});
|
|
855
|
+
it('preserves retryable refusal metadata without retrying a paid request', async () => {
|
|
856
|
+
const transport = jest.fn(async () => new Response(JSON.stringify({ schemaVersion: 1,
|
|
857
|
+
code: 'rate_limited', message: 'Capacity unavailable', requestId: 'req_throttled',
|
|
858
|
+
retryable: true, retryAfterMs: 1500 }), { status: 429 }));
|
|
859
|
+
const client = new OxyInferenceClient({ credential: 'test', fetch: transport });
|
|
860
|
+
await expect(client.speech({ model: 'xai/tts', input: 'Hola', voice: 'female' }))
|
|
861
|
+
.rejects.toMatchObject({ code: 'rate_limited', requestId: 'req_throttled', retryAfterMs: 1500 });
|
|
862
|
+
expect(transport).toHaveBeenCalledTimes(1);
|
|
863
|
+
});
|
|
864
|
+
it.each([
|
|
865
|
+
{ body: '', type: 'audio/mpeg' }, { body: '<html>unavailable</html>', type: 'text/html' },
|
|
866
|
+
{ body: '{}', type: 'application/json' },
|
|
867
|
+
])('refuses successful HTTP responses without audio: %p', async ({ body, type }) => {
|
|
868
|
+
const client = new OxyInferenceClient({ credential: 'test', fetch: async () =>
|
|
869
|
+
new Response(body, { headers: { 'Content-Type': type } }) });
|
|
870
|
+
await expect(client.speech({ model: 'xai/tts', input: 'Hola', voice: 'female' }))
|
|
871
|
+
.rejects.toBeInstanceOf(OxyInferenceProtocolError);
|
|
872
|
+
});
|
|
873
|
+
it('bounds streamed binary output and cancels the remaining body', async () => {
|
|
874
|
+
const cancelled = jest.fn();
|
|
875
|
+
const client = new OxyInferenceClient({ credential: 'test', fetch: async () => new Response(
|
|
876
|
+
new ReadableStream({ start(controller) { controller.enqueue(new Uint8Array(20 * 1024 * 1024 + 1)); }, cancel: cancelled }),
|
|
877
|
+
{ headers: { 'Content-Type': 'audio/mpeg' } }) });
|
|
878
|
+
await expect(client.speech({ model: 'xai/tts', input: 'Hola', voice: 'female' }))
|
|
879
|
+
.rejects.toBeInstanceOf(OxyInferenceProtocolError);
|
|
880
|
+
expect(cancelled).toHaveBeenCalledTimes(1);
|
|
881
|
+
});
|
|
882
|
+
});
|
|
@@ -9,7 +9,7 @@ import type {
|
|
|
9
9
|
LoginResult,
|
|
10
10
|
CommonsDenyReason,
|
|
11
11
|
} from '@oxy.so/contracts';
|
|
12
|
-
import { loginResultSchema, safeParseContract } from '@oxy.so/contracts';
|
|
12
|
+
import { loginResultSchema, safeParseContract, type IdentityProof, type WebIdentityEnvelope } from '@oxy.so/contracts';
|
|
13
13
|
import type { SessionLoginResponse } from '../models/session';
|
|
14
14
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
15
15
|
import type { PublicApplication } from './OxyServices.connectedApps';
|
|
@@ -546,6 +546,24 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
546
546
|
const secret = apiSecret || this._serviceApiSecret;
|
|
547
547
|
|
|
548
548
|
if (!key || !secret) {
|
|
549
|
+
/**
|
|
550
|
+
* No credential configured — so prove what this process IS instead
|
|
551
|
+
* (ADR 0026).
|
|
552
|
+
*
|
|
553
|
+
* This is what lets an official service drop its api key and secret
|
|
554
|
+
* WITHOUT a code change: remove the two environment variables, and the
|
|
555
|
+
* next token comes from the workload path. It is deliberately the
|
|
556
|
+
* fallback rather than the preference, so a deployment that still has a
|
|
557
|
+
* credential keeps using it and the migration is one service at a time.
|
|
558
|
+
*
|
|
559
|
+
* A process with no credential AND no attestation still gets the
|
|
560
|
+
* original error: "nothing to authenticate with" is the truth there, and
|
|
561
|
+
* a local checkout must not be told to go looking for a container
|
|
562
|
+
* credentials endpoint that is not there.
|
|
563
|
+
*/
|
|
564
|
+
if (await this._canUseWorkloadIdentity()) {
|
|
565
|
+
return this._getWorkloadServiceToken();
|
|
566
|
+
}
|
|
549
567
|
throw new Error('Service credentials not provided. Call configureServiceAuth() or pass apiKey and apiSecret.');
|
|
550
568
|
}
|
|
551
569
|
|
|
@@ -625,6 +643,81 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
625
643
|
}
|
|
626
644
|
}
|
|
627
645
|
|
|
646
|
+
/**
|
|
647
|
+
* Whether this process can prove what it is (ADR 0026).
|
|
648
|
+
*
|
|
649
|
+
* The check and the module that performs it are loaded LAZILY and only on a
|
|
650
|
+
* Node host. `@oxy.so/core`'s root barrel reaches React Native and Expo, and
|
|
651
|
+
* `server/workloadIdentity` imports `node:crypto` — a static import here
|
|
652
|
+
* would pull it into every mobile bundle for a path a phone can never take.
|
|
653
|
+
*
|
|
654
|
+
* @internal
|
|
655
|
+
*/
|
|
656
|
+
async _canUseWorkloadIdentity(): Promise<boolean> {
|
|
657
|
+
if (typeof process === 'undefined' || !process.versions?.node) return false;
|
|
658
|
+
try {
|
|
659
|
+
const { canAttestWorkloadIdentity } = await import('../server/workloadIdentity');
|
|
660
|
+
return canAttestWorkloadIdentity();
|
|
661
|
+
} catch {
|
|
662
|
+
// A bundler that dropped the server subpath, or a runtime without it.
|
|
663
|
+
// "We cannot attest here" is the correct reading, and the caller falls
|
|
664
|
+
// back to the credential error it would have thrown anyway.
|
|
665
|
+
return false;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* A service token obtained by attestation, cached like a credential's.
|
|
671
|
+
*
|
|
672
|
+
* The cache key is a constant, not a credential hash: a process has exactly
|
|
673
|
+
* one identity, so there is nothing to key on and nothing to keep separate.
|
|
674
|
+
* The secret-comparison guard the credential path applies has nothing to
|
|
675
|
+
* compare here, which is the point — there is no secret.
|
|
676
|
+
*
|
|
677
|
+
* @internal
|
|
678
|
+
*/
|
|
679
|
+
async _getWorkloadServiceToken(): Promise<string> {
|
|
680
|
+
const cacheKey = 'workload-identity';
|
|
681
|
+
const entry = this._serviceTokenCache.get(cacheKey);
|
|
682
|
+
const now = Date.now();
|
|
683
|
+
if (entry?.token && entry.expiresAt > now + 60_000) return entry.token;
|
|
684
|
+
if (entry?.pending) return entry.pending;
|
|
685
|
+
|
|
686
|
+
const { requestWorkloadServiceToken } = await import('../server/workloadIdentity');
|
|
687
|
+
const seeded = entry ?? {
|
|
688
|
+
token: '',
|
|
689
|
+
expiresAt: 0,
|
|
690
|
+
secretBuf: Buffer.alloc(0),
|
|
691
|
+
pending: null,
|
|
692
|
+
apiKey: cacheKey,
|
|
693
|
+
};
|
|
694
|
+
this._serviceTokenCache.set(cacheKey, seeded);
|
|
695
|
+
|
|
696
|
+
const pending = (async () => {
|
|
697
|
+
const granted = await requestWorkloadServiceToken({ baseUrl: this.getBaseURL() });
|
|
698
|
+
const current = this._serviceTokenCache.get(cacheKey);
|
|
699
|
+
if (current) {
|
|
700
|
+
current.token = granted.token;
|
|
701
|
+
current.expiresAt = Date.now() + granted.expiresIn * 1000;
|
|
702
|
+
}
|
|
703
|
+
return granted.token;
|
|
704
|
+
})();
|
|
705
|
+
seeded.pending = pending;
|
|
706
|
+
|
|
707
|
+
try {
|
|
708
|
+
return await pending;
|
|
709
|
+
} catch (error) {
|
|
710
|
+
// Never keep an entry that never held a token: the next caller must try
|
|
711
|
+
// again rather than inherit a failure.
|
|
712
|
+
const failed = this._serviceTokenCache.get(cacheKey);
|
|
713
|
+
if (failed?.pending === pending && !failed.token) this._serviceTokenCache.delete(cacheKey);
|
|
714
|
+
throw error;
|
|
715
|
+
} finally {
|
|
716
|
+
const settled = this._serviceTokenCache.get(cacheKey);
|
|
717
|
+
if (settled?.pending === pending) settled.pending = null;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
628
721
|
/**
|
|
629
722
|
* Perform the actual /auth/service-token request and cache the result.
|
|
630
723
|
* Separated so getServiceToken() can deduplicate concurrent calls.
|
|
@@ -1643,6 +1736,12 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
1643
1736
|
username?: string;
|
|
1644
1737
|
deviceName?: string;
|
|
1645
1738
|
deviceFingerprint?: string;
|
|
1739
|
+
/**
|
|
1740
|
+
* Sign-up only: the root created on the holder before this call, sealed
|
|
1741
|
+
* under the passkey being registered, plus its `enroll_identity` proof
|
|
1742
|
+
* (ADR 0024 D4). The account is created WITH it or not at all.
|
|
1743
|
+
*/
|
|
1744
|
+
identity?: { envelope: WebIdentityEnvelope; proof: IdentityProof };
|
|
1646
1745
|
} = {},
|
|
1647
1746
|
): Promise<{ success: true; message: string } | LoginResult> {
|
|
1648
1747
|
try {
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
* Provides typed access to Oxy's AtProto/Bluesky-flavoured identity &
|
|
5
5
|
* portability layer:
|
|
6
6
|
* - DID resolution (`did:web:oxy.so:u:<userId>`, derived on demand by the API).
|
|
7
|
-
* - The auth-method ↔ DID verification-method mapping
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* - The auth-method ↔ DID verification-method mapping (`GET /auth/methods`) and
|
|
8
|
+
* passkey removal. A root is linked by the holder flows (ADR 0024) and never
|
|
9
|
+
* unlinked; it is replaced by rotation.
|
|
10
10
|
* - Signed records: clients sign an envelope with their own cryptographic key
|
|
11
11
|
* (`SignatureService.signRecord` + the shared `canonicalize`) and publish it;
|
|
12
12
|
* anyone can fetch and verify it.
|
|
@@ -19,12 +19,13 @@
|
|
|
19
19
|
* the API validates its output against, so producer and consumer cannot drift.
|
|
20
20
|
*
|
|
21
21
|
* Identity signing is NATIVE-ONLY: the private key lives in native secure
|
|
22
|
-
* storage, so `
|
|
22
|
+
* storage, so `signRecord` and `publishRecord` require an
|
|
23
23
|
* on-device identity and throw on web (where `KeyManager.getPublicKey()` is
|
|
24
24
|
* always `null`).
|
|
25
25
|
*/
|
|
26
26
|
import type {
|
|
27
27
|
AuthMethodsResponse,
|
|
28
|
+
IdentityRootStatus,
|
|
28
29
|
DidDocument,
|
|
29
30
|
DomainVerificationInstructions,
|
|
30
31
|
ExportBundle,
|
|
@@ -58,13 +59,7 @@ const OXY_IDENTITY_APEX = 'oxy.so';
|
|
|
58
59
|
*/
|
|
59
60
|
export type IdentityRecordType = OxySignedRecordType;
|
|
60
61
|
|
|
61
|
-
/**
|
|
62
|
-
export type UnlinkableAuthMethodType = 'identity' | 'webauthn';
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Result of a link/unlink auth-method mutation (`POST /auth/link`,
|
|
66
|
-
* `DELETE /auth/link/:type`).
|
|
67
|
-
*/
|
|
62
|
+
/** Result of an auth-method mutation (`DELETE /auth/link/webauthn/:id`). */
|
|
68
63
|
export interface LinkAuthMethodResult {
|
|
69
64
|
success: boolean;
|
|
70
65
|
message: string;
|
|
@@ -224,62 +219,15 @@ export function OxyServicesIdentityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
224
219
|
}
|
|
225
220
|
|
|
226
221
|
/**
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
* or no user is authenticated). The signed payload is
|
|
233
|
-
* `JSON.stringify({ action: 'link_identity', userId, timestamp })` — the
|
|
234
|
-
* exact bytes the server reconstructs and verifies.
|
|
222
|
+
* The signed-in account's root readiness (ADR 0024 D5): whether a root is
|
|
223
|
+
* linked, how many passkeys can open the web holder, whether the root has a
|
|
224
|
+
* phrase and whether it is saved and was shown to recover it. Metadata only —
|
|
225
|
+
* nothing here can open the root — so any first-party surface may show a
|
|
226
|
+
* "save your recovery phrase" reminder from it.
|
|
235
227
|
*/
|
|
236
|
-
async
|
|
228
|
+
async getIdentityRootStatus(): Promise<IdentityRootStatus> {
|
|
237
229
|
try {
|
|
238
|
-
|
|
239
|
-
if (!userId) {
|
|
240
|
-
throw new Error('No authenticated user — sign in before linking an identity key.');
|
|
241
|
-
}
|
|
242
|
-
const publicKey = await KeyManager.getPublicKey();
|
|
243
|
-
if (!publicKey) {
|
|
244
|
-
throw new Error('No identity found on this device. Create or import an identity first.');
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
const timestamp = Date.now();
|
|
248
|
-
// The signed message MUST match the server's reconstruction byte-for-byte:
|
|
249
|
-
// JSON.stringify with this exact key order (action, userId, timestamp).
|
|
250
|
-
const message = JSON.stringify({ action: 'link_identity', userId, timestamp });
|
|
251
|
-
const signature = await SignatureService.sign(message);
|
|
252
|
-
|
|
253
|
-
const result = await this.makeRequest<LinkAuthMethodResult>(
|
|
254
|
-
'POST',
|
|
255
|
-
'/auth/link',
|
|
256
|
-
{ type: 'identity', publicKey, signature, timestamp },
|
|
257
|
-
{ cache: false },
|
|
258
|
-
);
|
|
259
|
-
this._invalidateIdentityCaches(userId);
|
|
260
|
-
return result;
|
|
261
|
-
} catch (error) {
|
|
262
|
-
throw this.handleError(error);
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* Unlink an authentication method from the current account. The server
|
|
268
|
-
* refuses to remove the last remaining method (the account would become
|
|
269
|
-
* inaccessible). Unlinking `identity` downgrades the account to custodial.
|
|
270
|
-
*
|
|
271
|
-
* @param type - The auth-method type to remove.
|
|
272
|
-
*/
|
|
273
|
-
async unlinkAuthMethod(type: UnlinkableAuthMethodType): Promise<LinkAuthMethodResult> {
|
|
274
|
-
try {
|
|
275
|
-
const result = await this.makeRequest<LinkAuthMethodResult>(
|
|
276
|
-
'DELETE',
|
|
277
|
-
`/auth/link/${encodeURIComponent(type)}`,
|
|
278
|
-
undefined,
|
|
279
|
-
{ cache: false },
|
|
280
|
-
);
|
|
281
|
-
this._invalidateIdentityCaches(this.getCurrentUserId());
|
|
282
|
-
return result;
|
|
230
|
+
return await this.makeRequest<IdentityRootStatus>('GET', '/identity/root-status', undefined, { cache: false });
|
|
283
231
|
} catch (error) {
|
|
284
232
|
throw this.handleError(error);
|
|
285
233
|
}
|
|
@@ -288,10 +236,12 @@ export function OxyServicesIdentityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
288
236
|
/**
|
|
289
237
|
* Remove ONE passkey (WebAuthn credential) from the current account.
|
|
290
238
|
*
|
|
291
|
-
* Passkeys are per-credential, so
|
|
292
|
-
* removes an auth method by type) this targets a specific credential id.
|
|
239
|
+
* Passkeys are per-credential, so this targets a specific credential id.
|
|
293
240
|
* The server refuses to remove the last remaining auth method (the account
|
|
294
241
|
* would become inaccessible) and deletes the stored `WebauthnCredential`.
|
|
242
|
+
* A passkey that also opens the account's root on the web takes its wrap with
|
|
243
|
+
* it, and the ONLY such passkey is refused with
|
|
244
|
+
* `code: 'IDENTITY_LAST_WEB_HOLDER'` (ADR 0024 D6).
|
|
295
245
|
*
|
|
296
246
|
* @param credentialId - The passkey's public credential id
|
|
297
247
|
* (`AuthMethodEntry.credentialId`).
|
|
@@ -3,9 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Stubs `makeRequest` so the tests run with no network. We assert request shape
|
|
5
5
|
* (method, URL, body, cache options), response unwrapping, the DID derivation,
|
|
6
|
-
* the
|
|
7
|
-
* server's `JSON.stringify({ action, userId, timestamp })` reconstruction
|
|
8
|
-
* byte-for-byte), and the cache sweep on every mutation.
|
|
6
|
+
* and the cache sweep on every mutation.
|
|
9
7
|
*/
|
|
10
8
|
|
|
11
9
|
import type { AuthMethodsResponse, DidDocument, SignedRecordEnvelope, VerifiedDomain } from '@oxy.so/contracts';
|
|
@@ -129,79 +127,6 @@ describe('OxyServices.identity', () => {
|
|
|
129
127
|
});
|
|
130
128
|
});
|
|
131
129
|
|
|
132
|
-
describe('linkIdentityKey', () => {
|
|
133
|
-
it('signs the exact server-expected payload and POSTs /auth/link', async () => {
|
|
134
|
-
jest.spyOn(Date, 'now').mockReturnValue(1700000000000);
|
|
135
|
-
jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue('pub-hex');
|
|
136
|
-
const signSpy = jest.spyOn(SignatureService, 'sign').mockResolvedValue('sig-hex');
|
|
137
|
-
makeRequestSpy.mockResolvedValue({ success: true, message: 'Identity linked successfully' });
|
|
138
|
-
|
|
139
|
-
const result = await oxy.linkIdentityKey();
|
|
140
|
-
|
|
141
|
-
// The signed message MUST match the server's reconstruction byte-for-byte:
|
|
142
|
-
// JSON.stringify({ action, userId, timestamp }) in that key order.
|
|
143
|
-
const expectedMessage = JSON.stringify({
|
|
144
|
-
action: 'link_identity',
|
|
145
|
-
userId: 'user-123',
|
|
146
|
-
timestamp: 1700000000000,
|
|
147
|
-
});
|
|
148
|
-
expect(signSpy).toHaveBeenCalledWith(expectedMessage);
|
|
149
|
-
expect(expectedMessage).toBe(
|
|
150
|
-
'{"action":"link_identity","userId":"user-123","timestamp":1700000000000}',
|
|
151
|
-
);
|
|
152
|
-
|
|
153
|
-
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
154
|
-
'POST',
|
|
155
|
-
'/auth/link',
|
|
156
|
-
{ type: 'identity', publicKey: 'pub-hex', signature: 'sig-hex', timestamp: 1700000000000 },
|
|
157
|
-
expect.objectContaining({ cache: false }),
|
|
158
|
-
);
|
|
159
|
-
expect(result).toEqual({ success: true, message: 'Identity linked successfully' });
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it('sweeps /users/me, auth methods, domains and the DID cache after linking', async () => {
|
|
163
|
-
jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue('pub-hex');
|
|
164
|
-
jest.spyOn(SignatureService, 'sign').mockResolvedValue('sig-hex');
|
|
165
|
-
makeRequestSpy.mockResolvedValue({ success: true, message: 'ok' });
|
|
166
|
-
|
|
167
|
-
await oxy.linkIdentityKey();
|
|
168
|
-
|
|
169
|
-
expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/users/me');
|
|
170
|
-
expect(clearEntrySpy).toHaveBeenCalledWith('GET:/auth/methods');
|
|
171
|
-
expect(clearEntrySpy).toHaveBeenCalledWith('GET:/identity/domains');
|
|
172
|
-
expect(clearEntrySpy).toHaveBeenCalledWith('GET:/u/user-123/did.json');
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
it('throws (no network) when the device has no identity', async () => {
|
|
176
|
-
jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue(null);
|
|
177
|
-
await expect(oxy.linkIdentityKey()).rejects.toThrow(/No identity found/);
|
|
178
|
-
expect(makeRequestSpy).not.toHaveBeenCalled();
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
it('throws when no user is authenticated', async () => {
|
|
182
|
-
jest.spyOn(oxy, 'getCurrentUserId').mockReturnValue(null);
|
|
183
|
-
jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue('pub-hex');
|
|
184
|
-
await expect(oxy.linkIdentityKey()).rejects.toThrow(/No authenticated user/);
|
|
185
|
-
expect(makeRequestSpy).not.toHaveBeenCalled();
|
|
186
|
-
});
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
describe('unlinkAuthMethod', () => {
|
|
190
|
-
it('DELETEs /auth/link/:type and sweeps cache', async () => {
|
|
191
|
-
makeRequestSpy.mockResolvedValue({ success: true, message: 'identity auth unlinked successfully' });
|
|
192
|
-
|
|
193
|
-
await oxy.unlinkAuthMethod('identity');
|
|
194
|
-
|
|
195
|
-
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
196
|
-
'DELETE',
|
|
197
|
-
'/auth/link/identity',
|
|
198
|
-
undefined,
|
|
199
|
-
expect.objectContaining({ cache: false }),
|
|
200
|
-
);
|
|
201
|
-
expect(clearEntrySpy).toHaveBeenCalledWith('GET:/u/user-123/did.json');
|
|
202
|
-
});
|
|
203
|
-
});
|
|
204
|
-
|
|
205
130
|
describe('removePasskey', () => {
|
|
206
131
|
it('DELETEs /auth/link/webauthn/:credentialID and sweeps cache', async () => {
|
|
207
132
|
makeRequestSpy.mockResolvedValue({ success: true, message: 'Passkey unlinked successfully' });
|
package/src/mixins/index.ts
CHANGED
|
@@ -35,7 +35,6 @@ import { OxyServicesNodesMixin } from './OxyServices.nodes';
|
|
|
35
35
|
import { OxyServicesFollowGraphMixin } from './OxyServices.followGraph';
|
|
36
36
|
import { OxyServicesInferenceMixin } from './OxyServices.inference';
|
|
37
37
|
import { OxyServicesDeviceBootMixin } from './OxyServices.deviceBoot';
|
|
38
|
-
import { OxyServicesDeviceTransferMixin } from './OxyServices.deviceTransfer';
|
|
39
38
|
|
|
40
39
|
/**
|
|
41
40
|
* Instance shape of every mixin in the pipeline, intersected. The runtime
|
|
@@ -75,7 +74,6 @@ type AllMixinInstances =
|
|
|
75
74
|
& InstanceType<ReturnType<typeof OxyServicesFollowGraphMixin<typeof OxyServicesBase>>>
|
|
76
75
|
& InstanceType<ReturnType<typeof OxyServicesInferenceMixin<typeof OxyServicesBase>>>
|
|
77
76
|
& InstanceType<ReturnType<typeof OxyServicesDeviceBootMixin<typeof OxyServicesBase>>>
|
|
78
|
-
& InstanceType<ReturnType<typeof OxyServicesDeviceTransferMixin<typeof OxyServicesBase>>>
|
|
79
77
|
& InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
|
|
80
78
|
|
|
81
79
|
/**
|
|
@@ -169,7 +167,6 @@ const MIXIN_PIPELINE: MixinFunction[] = [
|
|
|
169
167
|
|
|
170
168
|
// Device-to-device identity transfer ("add a device"): E2E-encrypted key
|
|
171
169
|
// clone over a short-lived relay (b3 Feature 2).
|
|
172
|
-
OxyServicesDeviceTransferMixin,
|
|
173
170
|
|
|
174
171
|
// Utility (last, can use all above)
|
|
175
172
|
OxyServicesUtilityMixin,
|
|
@@ -198,6 +198,21 @@ function expoExportHtml(body = EXPO_HYDRATE_SCRIPT): string {
|
|
|
198
198
|
].join('');
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
describe('@oxy.so/core/server buildOxyPagesHeaders on a sensitive origin (ADR 0024 D1)', () => {
|
|
202
|
+
it('removes the Cloudflare Insights beacon from script-src and connect-src, and nothing else', () => {
|
|
203
|
+
const csp = (block: string) => block.split('\n').find((line) => line.includes('Content-Security-Policy')) ?? '';
|
|
204
|
+
const normal = csp(buildOxyPagesHeaders({ csp: { connectSrc: ['https://example.test'] } }));
|
|
205
|
+
const sensitive = csp(buildOxyPagesHeaders({ csp: { connectSrc: ['https://example.test'] }, sensitive: true }));
|
|
206
|
+
|
|
207
|
+
expect(normal).toContain('static.cloudflareinsights.com');
|
|
208
|
+
expect(sensitive).not.toContain('cloudflareinsights.com');
|
|
209
|
+
expect(sensitive).toContain("script-src 'self'");
|
|
210
|
+
expect(sensitive).toContain('https://example.test');
|
|
211
|
+
expect(sensitive).toContain('https://api.oxy.so');
|
|
212
|
+
expect(sensitive).toContain("frame-ancestors 'none'");
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
201
216
|
describe('@oxy.so/core/server extractInlineScripts', () => {
|
|
202
217
|
it('returns inline bodies and skips external scripts', () => {
|
|
203
218
|
expect(extractInlineScripts(expoExportHtml())).toEqual([EXPO_HYDRATE_SCRIPT]);
|