@oxyhq/core 20.1.0 → 21.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +47 -8
- package/dist/cjs/boot/sessionColdBoot.js +107 -8
- package/dist/cjs/i18n/locales/en-US.json +26 -4
- package/dist/cjs/i18n/locales/es-ES.json +26 -4
- package/dist/cjs/i18n/locales/locales/en-US.json +26 -4
- package/dist/cjs/i18n/locales/locales/es-ES.json +26 -4
- package/dist/cjs/index.js +57 -16
- package/dist/cjs/inference/OxyInferenceClient.js +330 -0
- package/dist/cjs/mixins/OxyServices.accounts.js +5 -72
- package/dist/cjs/mixins/OxyServices.auth.js +27 -3
- package/dist/cjs/mixins/OxyServices.inference.js +59 -0
- package/dist/cjs/mixins/OxyServices.utility.js +18 -6
- package/dist/cjs/mixins/index.js +6 -0
- package/dist/cjs/server/auth.js +76 -0
- package/dist/cjs/server/index.js +5 -1
- package/dist/cjs/session/SessionClient.js +361 -1
- package/dist/cjs/session/accountDialogController.js +121 -147
- package/dist/cjs/session/accountSwitchTargets.js +75 -0
- package/dist/cjs/session/deviceDirectory.js +143 -0
- package/dist/cjs/session/deviceSwitcherRows.js +76 -0
- package/dist/cjs/session/projectSessionState.js +8 -1
- package/dist/cjs/session/sharedDeviceCredential.js +247 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +47 -8
- package/dist/esm/boot/sessionColdBoot.js +107 -8
- package/dist/esm/i18n/locales/en-US.json +26 -4
- package/dist/esm/i18n/locales/es-ES.json +26 -4
- package/dist/esm/i18n/locales/locales/en-US.json +26 -4
- package/dist/esm/i18n/locales/locales/es-ES.json +26 -4
- package/dist/esm/index.js +36 -10
- package/dist/esm/inference/OxyInferenceClient.js +325 -0
- package/dist/esm/mixins/OxyServices.accounts.js +5 -72
- package/dist/esm/mixins/OxyServices.auth.js +27 -3
- package/dist/esm/mixins/OxyServices.inference.js +56 -0
- package/dist/esm/mixins/OxyServices.utility.js +18 -6
- package/dist/esm/mixins/index.js +6 -0
- package/dist/esm/server/auth.js +72 -0
- package/dist/esm/server/index.js +1 -1
- package/dist/esm/session/SessionClient.js +362 -2
- package/dist/esm/session/accountDialogController.js +121 -147
- package/dist/esm/session/accountSwitchTargets.js +71 -0
- package/dist/esm/session/deviceDirectory.js +135 -0
- package/dist/esm/session/deviceSwitcherRows.js +72 -0
- package/dist/esm/session/projectSessionState.js +8 -2
- package/dist/esm/session/sharedDeviceCredential.js +239 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +39 -1
- package/dist/types/boot/sessionColdBoot.d.ts +24 -4
- package/dist/types/index.d.ts +11 -4
- package/dist/types/inference/OxyInferenceClient.d.ts +324 -0
- package/dist/types/mixins/OxyServices.accounts.d.ts +73 -95
- package/dist/types/mixins/OxyServices.auth.d.ts +75 -3
- package/dist/types/mixins/OxyServices.inference.d.ts +95 -0
- package/dist/types/mixins/OxyServices.utility.d.ts +44 -13
- package/dist/types/mixins/index.d.ts +2 -1
- package/dist/types/models/session.d.ts +11 -0
- package/dist/types/server/auth.d.ts +80 -0
- package/dist/types/server/index.d.ts +2 -2
- package/dist/types/session/SessionClient.d.ts +202 -1
- package/dist/types/session/accountDialogController.d.ts +76 -64
- package/dist/types/session/accountSwitchTargets.d.ts +64 -0
- package/dist/types/session/deviceDirectory.d.ts +182 -0
- package/dist/types/session/deviceSwitcherRows.d.ts +92 -0
- package/dist/types/session/projectSessionState.d.ts +29 -0
- package/dist/types/session/sharedDeviceCredential.d.ts +202 -0
- package/package.json +3 -3
- package/src/HttpService.ts +50 -10
- package/src/__tests__/httpServiceUnwrapEnvelope.test.ts +115 -0
- package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +325 -0
- package/src/boot/sessionColdBoot.ts +133 -9
- package/src/i18n/locales/en-US.json +26 -4
- package/src/i18n/locales/es-ES.json +26 -4
- package/src/index.ts +94 -25
- package/src/inference/OxyInferenceClient.ts +590 -0
- package/src/inference/__tests__/OxyInferenceClient.test.ts +383 -0
- package/src/mixins/OxyServices.accounts.ts +75 -176
- package/src/mixins/OxyServices.auth.ts +67 -5
- package/src/mixins/OxyServices.inference.ts +57 -0
- package/src/mixins/OxyServices.utility.ts +58 -14
- package/src/mixins/__tests__/accounts.test.ts +57 -102
- package/src/mixins/__tests__/inferenceFactory.test.ts +58 -0
- package/src/mixins/__tests__/preSessionSkipAuth.test.ts +54 -1
- package/src/mixins/__tests__/serviceAuth.test.ts +2 -0
- package/src/mixins/index.ts +8 -0
- package/src/models/session.ts +11 -0
- package/src/server/__tests__/serviceTokenAttribution.test.ts +396 -0
- package/src/server/auth.ts +118 -0
- package/src/server/index.ts +6 -0
- package/src/session/SessionClient.ts +386 -1
- package/src/session/__tests__/SessionClient.directory.test.ts +688 -0
- package/src/session/__tests__/accountDialogController.test.ts +411 -278
- package/src/session/__tests__/accountDialogShape.test.ts +118 -0
- package/src/session/__tests__/accountSwitchTargets.test.ts +132 -0
- package/src/session/__tests__/deviceDirectory.test.ts +422 -0
- package/src/session/__tests__/deviceSwitcherRows.test.ts +223 -0
- package/src/session/__tests__/projectSessionState.test.ts +17 -0
- package/src/session/__tests__/sharedDeviceCredential.test.ts +300 -0
- package/src/session/accountDialogController.ts +141 -179
- package/src/session/accountSwitchTargets.ts +87 -0
- package/src/session/deviceDirectory.ts +269 -0
- package/src/session/deviceSwitcherRows.ts +145 -0
- package/src/session/projectSessionState.ts +9 -3
- package/src/session/sharedDeviceCredential.ts +349 -0
- package/dist/cjs/session/accountProjection.js +0 -213
- package/dist/esm/session/accountProjection.js +0 -207
- package/dist/types/session/accountProjection.d.ts +0 -198
- package/src/session/__tests__/accountProjection.test.ts +0 -447
- package/src/session/accountProjection.ts +0 -354
|
@@ -288,72 +288,6 @@ export interface TransferAccountOwnershipInput {
|
|
|
288
288
|
userId: string;
|
|
289
289
|
}
|
|
290
290
|
|
|
291
|
-
// ---------------------------------------------------------------------------
|
|
292
|
-
// Bot (account) service-credential types
|
|
293
|
-
// ---------------------------------------------------------------------------
|
|
294
|
-
|
|
295
|
-
/** Credential kind. Account (bot) credentials are always `service` tokens. */
|
|
296
|
-
export type AccountCredentialType = 'service';
|
|
297
|
-
|
|
298
|
-
/** Deployment environment a bot credential is scoped to. */
|
|
299
|
-
export type AccountCredentialEnvironment = 'development' | 'staging' | 'production';
|
|
300
|
-
|
|
301
|
-
/** Bot credential lifecycle status. */
|
|
302
|
-
export type AccountCredentialStatus = 'active' | 'deprecated' | 'revoked';
|
|
303
|
-
|
|
304
|
-
/** Input accepted by `createAccountCredential`. Credential `type` is always `service`. */
|
|
305
|
-
export interface CreateAccountCredentialInput {
|
|
306
|
-
name: string;
|
|
307
|
-
environment: AccountCredentialEnvironment;
|
|
308
|
-
scopes?: string[];
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
/**
|
|
312
|
-
* Client-facing AccountCredential shape (a bot account's service token). The raw
|
|
313
|
-
* secret is NEVER part of this shape — it is returned exactly once, separately,
|
|
314
|
-
* at creation/rotation.
|
|
315
|
-
*/
|
|
316
|
-
export interface AccountCredential {
|
|
317
|
-
_id: string;
|
|
318
|
-
/** The bot account this credential authenticates as (account `_id`). */
|
|
319
|
-
accountId: string;
|
|
320
|
-
name: string;
|
|
321
|
-
publicKey: string;
|
|
322
|
-
type: AccountCredentialType;
|
|
323
|
-
environment: AccountCredentialEnvironment;
|
|
324
|
-
scopes: string[];
|
|
325
|
-
status: AccountCredentialStatus;
|
|
326
|
-
lastUsedAt?: string;
|
|
327
|
-
expiresAt?: string;
|
|
328
|
-
/**
|
|
329
|
-
* Audit link to the credential this one was rotated FROM. Populated on
|
|
330
|
-
* credentials created via rotation; absent on original credentials.
|
|
331
|
-
*/
|
|
332
|
-
rotatedFromCredentialId?: string;
|
|
333
|
-
createdByUserId: string;
|
|
334
|
-
createdAt: string;
|
|
335
|
-
updatedAt: string;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
/** Result of creating a bot credential — `secret` is returned ONCE. */
|
|
339
|
-
export interface AccountCredentialWithSecret {
|
|
340
|
-
credential: AccountCredential;
|
|
341
|
-
secret: string;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
/**
|
|
345
|
-
* Result of rotating a bot credential. Extends the create result with audit
|
|
346
|
-
* fields: the new plaintext `secret` is returned ONCE, plus `rotatedFrom` (the
|
|
347
|
-
* previous credential's `credentialId`) and `graceExpiresAt` (ISO string marking
|
|
348
|
-
* when the old credential stops being honoured during the rotation grace window).
|
|
349
|
-
*/
|
|
350
|
-
export interface RotateAccountCredentialResult extends AccountCredentialWithSecret {
|
|
351
|
-
/** The previous credential's `credentialId` that this rotation supersedes. */
|
|
352
|
-
rotatedFrom: string;
|
|
353
|
-
/** ISO timestamp at which the rotated-from credential's grace window ends. */
|
|
354
|
-
graceExpiresAt: string;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
291
|
// ---------------------------------------------------------------------------
|
|
358
292
|
// Application (owned by an account) types
|
|
359
293
|
// ---------------------------------------------------------------------------
|
|
@@ -367,8 +301,18 @@ export type ApplicationType = 'first_party' | 'third_party' | 'internal' | 'syst
|
|
|
367
301
|
/** Lifecycle status of an application. */
|
|
368
302
|
export type ApplicationStatus = 'active' | 'suspended' | 'deleted' | 'pending_review';
|
|
369
303
|
|
|
370
|
-
/**
|
|
371
|
-
|
|
304
|
+
/**
|
|
305
|
+
* Credential kind.
|
|
306
|
+
*
|
|
307
|
+
* The first three are OAuth clients: the `oxy_dk_…` `publicKey` is the
|
|
308
|
+
* `client_id`, and any secret is presented BESIDE it. `service` credentials
|
|
309
|
+
* additionally mint service tokens.
|
|
310
|
+
*
|
|
311
|
+
* `machine` is the OpenAI-SDK-compatible API key (issue #972 §2.3): its
|
|
312
|
+
* credential material is ONE `oxy_sk_…` bearer string returned in `token`
|
|
313
|
+
* exactly once on create/rotate, never in `secret`.
|
|
314
|
+
*/
|
|
315
|
+
export type ApplicationCredentialType = 'public' | 'confidential' | 'service' | 'machine';
|
|
372
316
|
|
|
373
317
|
/** Deployment environment an application credential is scoped to. */
|
|
374
318
|
export type ApplicationEnvironment = 'development' | 'staging' | 'production';
|
|
@@ -428,6 +372,12 @@ export interface ApplicationCredential {
|
|
|
428
372
|
applicationId: string;
|
|
429
373
|
name: string;
|
|
430
374
|
publicKey: string;
|
|
375
|
+
/**
|
|
376
|
+
* `oxy_sk_<id>` — the PUBLIC lookup half of a `machine` credential's bearer
|
|
377
|
+
* token, present only on that type. Safe to render: the secret half is 256
|
|
378
|
+
* bits that were shown exactly once and are never returned again.
|
|
379
|
+
*/
|
|
380
|
+
tokenPrefix?: string;
|
|
431
381
|
type: ApplicationCredentialType;
|
|
432
382
|
environment: ApplicationEnvironment;
|
|
433
383
|
scopes: string[];
|
|
@@ -486,25 +436,70 @@ export interface CreateApplicationCredentialInput {
|
|
|
486
436
|
type: ApplicationCredentialType;
|
|
487
437
|
environment: ApplicationEnvironment;
|
|
488
438
|
scopes?: string[];
|
|
439
|
+
/**
|
|
440
|
+
* Lifetime of a `machine` credential, in seconds — 60 to 730 days. Omit for a
|
|
441
|
+
* key that does not expire on its own.
|
|
442
|
+
*
|
|
443
|
+
* **`machine` only.** On every other credential type `expires_at` means the
|
|
444
|
+
* rotation grace deadline, so a caller setting it at creation would make a
|
|
445
|
+
* brand-new credential indistinguishable from a rotated one. The server
|
|
446
|
+
* REJECTS it for those types rather than ignoring it, so sending it with the
|
|
447
|
+
* wrong `type` is a 400, not a silently dropped field.
|
|
448
|
+
*/
|
|
449
|
+
expiresInSeconds?: number;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Input accepted by `rotateAppCredential`. */
|
|
453
|
+
export interface RotateApplicationCredentialInput {
|
|
454
|
+
/**
|
|
455
|
+
* How long the superseded `machine` token keeps working, in seconds — 1 to 30
|
|
456
|
+
* days. Omitting it revokes the previous token the instant the replacement is
|
|
457
|
+
* minted, which is the safe default for a leaked key.
|
|
458
|
+
*
|
|
459
|
+
* **`machine` only, and opt-in.** `confidential`/`service` credentials always
|
|
460
|
+
* retire on the platform's fixed seven-day grace and the server REJECTS this
|
|
461
|
+
* field for them, so their contract is unchanged.
|
|
462
|
+
*/
|
|
463
|
+
graceSeconds?: number;
|
|
489
464
|
}
|
|
490
465
|
|
|
491
|
-
/**
|
|
466
|
+
/**
|
|
467
|
+
* Result of creating an application credential — credential material is returned
|
|
468
|
+
* ONCE and can never be read back.
|
|
469
|
+
*
|
|
470
|
+
* Exactly one of the two fields carries it, decided by
|
|
471
|
+
* {@link ApplicationCredentialType}: `secret` for a `confidential`/`service`
|
|
472
|
+
* client, `token` for a `machine` API key, and NEITHER for a `public` client
|
|
473
|
+
* (`secret` is `null`). They are separate fields rather than one, so a surface
|
|
474
|
+
* that renders "the secret" cannot silently render an API key's bearer token
|
|
475
|
+
* under the wrong label, or a `null` where a token should be.
|
|
476
|
+
*/
|
|
492
477
|
export interface ApplicationCredentialWithSecret {
|
|
493
478
|
credential: ApplicationCredential;
|
|
494
|
-
secret
|
|
479
|
+
/** The OAuth client secret. `null` for `public` and `machine` credentials. */
|
|
480
|
+
secret: string | null;
|
|
481
|
+
/** The full `oxy_sk_…` bearer token. Present ONLY for a `machine` credential. */
|
|
482
|
+
token?: string;
|
|
495
483
|
}
|
|
496
484
|
|
|
497
485
|
/**
|
|
498
486
|
* Result of rotating an application credential. Extends the create result with
|
|
499
|
-
* audit fields: the new
|
|
500
|
-
* (the previous credential's `credentialId`) and `graceExpiresAt
|
|
501
|
-
* marking when the old credential stops being honoured during the grace window).
|
|
487
|
+
* audit fields: the new credential material is returned ONCE, plus `rotatedFrom`
|
|
488
|
+
* (the previous credential's `credentialId`) and `graceExpiresAt`.
|
|
502
489
|
*/
|
|
503
490
|
export interface RotateApplicationCredentialResult extends ApplicationCredentialWithSecret {
|
|
504
491
|
/** The previous credential's `credentialId` that this rotation supersedes. */
|
|
505
492
|
rotatedFrom: string;
|
|
506
|
-
/**
|
|
507
|
-
|
|
493
|
+
/**
|
|
494
|
+
* ISO timestamp at which the rotated-from credential stops being honoured, or
|
|
495
|
+
* `null` when no grace window was configured and it was revoked outright.
|
|
496
|
+
*
|
|
497
|
+
* Nullable because a `machine` credential's grace is OPT-IN (issue #972 §2.3):
|
|
498
|
+
* rotating an API key without asking for a window kills the old token
|
|
499
|
+
* immediately, and there is then no deadline to report. The OAuth/service
|
|
500
|
+
* types always carry their fixed seven-day deadline.
|
|
501
|
+
*/
|
|
502
|
+
graceExpiresAt: string | null;
|
|
508
503
|
}
|
|
509
504
|
|
|
510
505
|
/** Time window for application usage statistics. */
|
|
@@ -1009,106 +1004,6 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
1009
1004
|
}
|
|
1010
1005
|
}
|
|
1011
1006
|
|
|
1012
|
-
// =========================================================================
|
|
1013
|
-
// Bot (account) service credentials — /accounts/:id/credentials
|
|
1014
|
-
// =========================================================================
|
|
1015
|
-
|
|
1016
|
-
/**
|
|
1017
|
-
* List a bot account's service credentials. The response NEVER includes
|
|
1018
|
-
* secrets.
|
|
1019
|
-
* @param accountId - The account's Mongo `_id`.
|
|
1020
|
-
*/
|
|
1021
|
-
async listAccountCredentials(accountId: string): Promise<AccountCredential[]> {
|
|
1022
|
-
try {
|
|
1023
|
-
const res = await this.makeRequest<{ credentials?: AccountCredential[] }>(
|
|
1024
|
-
'GET',
|
|
1025
|
-
`/accounts/${encodeURIComponent(accountId)}/credentials`,
|
|
1026
|
-
undefined,
|
|
1027
|
-
{ cache: true, cacheTTL: CACHE_TIMES.MEDIUM },
|
|
1028
|
-
);
|
|
1029
|
-
return res.credentials ?? [];
|
|
1030
|
-
} catch (error) {
|
|
1031
|
-
throw this.handleError(error);
|
|
1032
|
-
}
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
/**
|
|
1036
|
-
* Create a service credential for a bot account. The plaintext `secret` is
|
|
1037
|
-
* returned exactly ONCE; the server stores only a hash and will never return
|
|
1038
|
-
* it again.
|
|
1039
|
-
* @param accountId - The account's Mongo `_id`.
|
|
1040
|
-
* @param data - Credential configuration (`type` is always `service`).
|
|
1041
|
-
*/
|
|
1042
|
-
async createAccountCredential(
|
|
1043
|
-
accountId: string,
|
|
1044
|
-
data: CreateAccountCredentialInput,
|
|
1045
|
-
): Promise<AccountCredentialWithSecret> {
|
|
1046
|
-
try {
|
|
1047
|
-
const result = await this.makeRequest<AccountCredentialWithSecret>(
|
|
1048
|
-
'POST',
|
|
1049
|
-
`/accounts/${encodeURIComponent(accountId)}/credentials`,
|
|
1050
|
-
data,
|
|
1051
|
-
{ cache: false },
|
|
1052
|
-
);
|
|
1053
|
-
this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}/credentials`);
|
|
1054
|
-
return result;
|
|
1055
|
-
} catch (error) {
|
|
1056
|
-
throw this.handleError(error);
|
|
1057
|
-
}
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
/**
|
|
1061
|
-
* Rotate a bot credential's secret. The new plaintext `secret` is returned
|
|
1062
|
-
* exactly ONCE, along with audit fields: `rotatedFrom` (the previous
|
|
1063
|
-
* credentialId) and `graceExpiresAt` (ISO string for the grace window during
|
|
1064
|
-
* which the old credential is still honoured).
|
|
1065
|
-
* @param accountId - The account's Mongo `_id`.
|
|
1066
|
-
* @param credentialId - The credential's Mongo `_id`.
|
|
1067
|
-
*/
|
|
1068
|
-
async rotateAccountCredential(
|
|
1069
|
-
accountId: string,
|
|
1070
|
-
credentialId: string,
|
|
1071
|
-
): Promise<RotateAccountCredentialResult> {
|
|
1072
|
-
try {
|
|
1073
|
-
const result = await this.makeRequest<RotateAccountCredentialResult>(
|
|
1074
|
-
'POST',
|
|
1075
|
-
`/accounts/${encodeURIComponent(accountId)}/credentials/${encodeURIComponent(credentialId)}/rotate`,
|
|
1076
|
-
undefined,
|
|
1077
|
-
{ cache: false },
|
|
1078
|
-
);
|
|
1079
|
-
// Rotation changes credential status/audit fields surfaced by the list.
|
|
1080
|
-
this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}/credentials`);
|
|
1081
|
-
return result;
|
|
1082
|
-
} catch (error) {
|
|
1083
|
-
throw this.handleError(error);
|
|
1084
|
-
}
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
/**
|
|
1088
|
-
* Revoke a bot credential (`status='revoked'`). Revoked credentials can no
|
|
1089
|
-
* longer authenticate.
|
|
1090
|
-
* @param accountId - The account's Mongo `_id`.
|
|
1091
|
-
* @param credentialId - The credential's Mongo `_id`.
|
|
1092
|
-
*/
|
|
1093
|
-
async revokeAccountCredential(
|
|
1094
|
-
accountId: string,
|
|
1095
|
-
credentialId: string,
|
|
1096
|
-
): Promise<AccountSuccessResult> {
|
|
1097
|
-
try {
|
|
1098
|
-
const result = await this.makeRequest<AccountSuccessResult>(
|
|
1099
|
-
'DELETE',
|
|
1100
|
-
`/accounts/${encodeURIComponent(accountId)}/credentials/${encodeURIComponent(credentialId)}`,
|
|
1101
|
-
undefined,
|
|
1102
|
-
{ cache: false },
|
|
1103
|
-
);
|
|
1104
|
-
// Revocation flips the credential's status in the cached list.
|
|
1105
|
-
this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}/credentials`);
|
|
1106
|
-
return result;
|
|
1107
|
-
} catch (error) {
|
|
1108
|
-
throw this.handleError(error);
|
|
1109
|
-
}
|
|
1110
|
-
}
|
|
1111
|
-
|
|
1112
1007
|
// =========================================================================
|
|
1113
1008
|
// Applications owned by an account — /applications
|
|
1114
1009
|
// =========================================================================
|
|
@@ -1273,16 +1168,20 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
1273
1168
|
* which the old credential is still honoured).
|
|
1274
1169
|
* @param applicationId - The application's Mongo `_id`.
|
|
1275
1170
|
* @param credentialId - The credential's Mongo `_id`.
|
|
1171
|
+
* @param options - `graceSeconds` keeps a superseded `machine` token working
|
|
1172
|
+
* for that long. Omitted, the previous token dies the moment the
|
|
1173
|
+
* replacement is minted.
|
|
1276
1174
|
*/
|
|
1277
1175
|
async rotateAppCredential(
|
|
1278
1176
|
applicationId: string,
|
|
1279
1177
|
credentialId: string,
|
|
1178
|
+
options?: RotateApplicationCredentialInput,
|
|
1280
1179
|
): Promise<RotateApplicationCredentialResult> {
|
|
1281
1180
|
try {
|
|
1282
1181
|
const result = await this.makeRequest<RotateApplicationCredentialResult>(
|
|
1283
1182
|
'POST',
|
|
1284
1183
|
`/applications/${encodeURIComponent(applicationId)}/credentials/${encodeURIComponent(credentialId)}/rotate`,
|
|
1285
|
-
|
|
1184
|
+
options,
|
|
1286
1185
|
{ cache: false },
|
|
1287
1186
|
);
|
|
1288
1187
|
// Rotation changes credential status/audit fields surfaced by the list.
|
|
@@ -7,7 +7,6 @@ import type { User } from '../models/interfaces';
|
|
|
7
7
|
import type {
|
|
8
8
|
UserNameResponse,
|
|
9
9
|
LoginResult,
|
|
10
|
-
LoginSessionResult,
|
|
11
10
|
CommonsDenyReason,
|
|
12
11
|
} from '@oxyhq/contracts';
|
|
13
12
|
import { loginResultSchema, safeParseContract } from '@oxyhq/contracts';
|
|
@@ -74,6 +73,42 @@ export interface OAuthUserInfoResponse {
|
|
|
74
73
|
picture?: string;
|
|
75
74
|
}
|
|
76
75
|
|
|
76
|
+
/**
|
|
77
|
+
* The session an OAuth authorization-code exchange yields.
|
|
78
|
+
*
|
|
79
|
+
* Deliberately NOT `LoginSessionResult`. That type mirrors the API's
|
|
80
|
+
* `buildSessionAuthResponse`, which every FIRST-PARTY sign-in lane emits, and it
|
|
81
|
+
* requires `deviceId` because those lanes always join the origin's DeviceSession.
|
|
82
|
+
* `POST /auth/oauth/token` is the RFC 6749 token endpoint and serves third
|
|
83
|
+
* parties, whose grant is deliberately ISOLATED: an untrusted application must be
|
|
84
|
+
* able to receive a session carrying NO DeviceSession credential at all.
|
|
85
|
+
*
|
|
86
|
+
* Both device fields are therefore optional here, and a response omitting them is
|
|
87
|
+
* a well-formed device-less grant rather than a malformed payload. What that
|
|
88
|
+
* costs the session is spelled out on `exchangeOAuthCode` below.
|
|
89
|
+
*/
|
|
90
|
+
export interface OAuthTokenExchangeResult {
|
|
91
|
+
sessionId: string;
|
|
92
|
+
/** ISO-8601 expiry of {@link accessToken}, derived from RFC 6749 `expires_in`. */
|
|
93
|
+
expiresAt: string;
|
|
94
|
+
accessToken?: string;
|
|
95
|
+
/**
|
|
96
|
+
* The DeviceSession this grant joined, when the server issued one. ABSENT for
|
|
97
|
+
* an isolated third-party grant — never assume a string.
|
|
98
|
+
*/
|
|
99
|
+
deviceId?: string;
|
|
100
|
+
/**
|
|
101
|
+
* The zero-cookie mint credential for {@link deviceId}. Present only alongside
|
|
102
|
+
* it; absent for an isolated third-party grant.
|
|
103
|
+
*/
|
|
104
|
+
deviceSecret?: string;
|
|
105
|
+
user: {
|
|
106
|
+
id: string;
|
|
107
|
+
username?: string;
|
|
108
|
+
avatar?: string;
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
77
112
|
// ===========================================================================
|
|
78
113
|
// "Sign in with Oxy" — cross-device QR / app-to-app handoff (Workstream C)
|
|
79
114
|
// ===========================================================================
|
|
@@ -1699,13 +1734,30 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
1699
1734
|
* response this method used before were an Oxy invention no OAuth library
|
|
1700
1735
|
* could interoperate with; the endpoint no longer accepts them. The method's
|
|
1701
1736
|
* OWN signature is unchanged, so callers are unaffected.
|
|
1737
|
+
*
|
|
1738
|
+
* `deviceId` + `deviceSecret` are OPTIONAL and their absence is a valid
|
|
1739
|
+
* outcome, not an error. A third-party grant is meant to be isolated from the
|
|
1740
|
+
* browser's shared DeviceSession, so the token endpoint must be free to return
|
|
1741
|
+
* no device credential at all — the guard that used to require the pair made
|
|
1742
|
+
* that omission unshippable, since it turned every third-party sign-in through
|
|
1743
|
+
* the SDK into a silent `exchange-failed`.
|
|
1744
|
+
*
|
|
1745
|
+
* The cost is real and deliberate: a DEVICE-LESS session cannot use the
|
|
1746
|
+
* zero-cookie mint lane (`POST /session/device/token`), because that lane's
|
|
1747
|
+
* whole proof is possession of a `deviceSecret`. Its lifetime is therefore the
|
|
1748
|
+
* access token itself — nothing persists a restore credential, the cold boot's
|
|
1749
|
+
* `device-secret-mint` step reports `no-secret` and skips, and the refresh
|
|
1750
|
+
* scheduler has nothing to re-mint from. When the token expires the session
|
|
1751
|
+
* ends LOUDLY: the 401 lane clears the tokens and the provider resolves signed
|
|
1752
|
+
* out, so the app can run the OAuth flow again. It never degrades into a
|
|
1753
|
+
* session that looks alive and cannot refresh.
|
|
1702
1754
|
*/
|
|
1703
1755
|
async exchangeOAuthCode(params: {
|
|
1704
1756
|
code: string;
|
|
1705
1757
|
clientId: string;
|
|
1706
1758
|
redirectUri: string;
|
|
1707
1759
|
codeVerifier: string;
|
|
1708
|
-
}): Promise<
|
|
1760
|
+
}): Promise<OAuthTokenExchangeResult> {
|
|
1709
1761
|
try {
|
|
1710
1762
|
const form = new URLSearchParams({
|
|
1711
1763
|
grant_type: 'authorization_code',
|
|
@@ -1730,7 +1782,9 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
1730
1782
|
const deviceId = typeof record.deviceId === 'string' ? record.deviceId : undefined;
|
|
1731
1783
|
const deviceSecret = typeof record.deviceSecret === 'string' ? record.deviceSecret : undefined;
|
|
1732
1784
|
const userRaw = record.user;
|
|
1733
|
-
|
|
1785
|
+
// The device pair is NOT part of this guard — see the note above. What is
|
|
1786
|
+
// still mandatory is what identifies the session at all.
|
|
1787
|
+
if (!sessionId || !userRaw || typeof userRaw !== 'object') {
|
|
1734
1788
|
throw new Error('auth/oauth/token returned an incomplete session payload');
|
|
1735
1789
|
}
|
|
1736
1790
|
const userObj = userRaw as Record<string, unknown>;
|
|
@@ -1744,12 +1798,20 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
1744
1798
|
if (accessToken) {
|
|
1745
1799
|
this.setTokens(accessToken);
|
|
1746
1800
|
}
|
|
1801
|
+
if (!deviceId || !deviceSecret) {
|
|
1802
|
+
logger.debug(
|
|
1803
|
+
'auth/oauth/token returned no device credential — this session lives only as long as its access token',
|
|
1804
|
+
{ component: 'oxy.auth', method: 'exchangeOAuthCode' },
|
|
1805
|
+
);
|
|
1806
|
+
}
|
|
1747
1807
|
return {
|
|
1748
1808
|
sessionId,
|
|
1749
|
-
deviceId,
|
|
1750
1809
|
expiresAt,
|
|
1751
1810
|
accessToken,
|
|
1752
|
-
|
|
1811
|
+
// Omitted rather than set to `undefined` when the server sent no device
|
|
1812
|
+
// credential, so a device-less grant serializes as the absence it is.
|
|
1813
|
+
...(deviceId ? { deviceId } : {}),
|
|
1814
|
+
...(deviceSecret ? { deviceSecret } : {}),
|
|
1753
1815
|
user: {
|
|
1754
1816
|
id: userId,
|
|
1755
1817
|
username: typeof userObj.username === 'string' ? userObj.username : undefined,
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The inference API, reached with whatever bearer this session already holds
|
|
3
|
+
* (issue #972, workstream 15).
|
|
4
|
+
*
|
|
5
|
+
* ```typescript
|
|
6
|
+
* const models = await oxyServices.inference().listModels();
|
|
7
|
+
* ```
|
|
8
|
+
*
|
|
9
|
+
* One method, and it is a FACTORY rather than a set of inference methods on
|
|
10
|
+
* `OxyServices`. The calls themselves live once, in
|
|
11
|
+
* {@link OxyInferenceClient} — which an external developer holding only an
|
|
12
|
+
* `oxy_sk_…` machine key constructs directly, with no Oxy session anywhere in
|
|
13
|
+
* the picture. Declaring the same calls a second time here would give the
|
|
14
|
+
* ecosystem two spellings of one request, and only one of them would stay
|
|
15
|
+
* correct.
|
|
16
|
+
*
|
|
17
|
+
* This is the reasoning `createLinkedClient` is already built on: the plumbing
|
|
18
|
+
* that binds an Oxy bearer to a client belongs in core, once, rather than in
|
|
19
|
+
* each app.
|
|
20
|
+
*
|
|
21
|
+
* The credential is a FUNCTION, not the current token: a session bearer rotates
|
|
22
|
+
* on refresh and on account switch, and a client that captured one at
|
|
23
|
+
* construction would start answering 401 an hour into the process's life.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { OxyInferenceClient } from '../inference/OxyInferenceClient';
|
|
27
|
+
import type { OxyServicesBase } from '../OxyServices.base';
|
|
28
|
+
|
|
29
|
+
export function OxyServicesInferenceMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
30
|
+
return class extends Base {
|
|
31
|
+
/** @internal Memoized so repeated calls return one object identity. */
|
|
32
|
+
_inferenceClient: OxyInferenceClient | null = null;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The inference client for this session.
|
|
36
|
+
*
|
|
37
|
+
* Bound to this instance's base URL and to `getAccessToken()`, so it
|
|
38
|
+
* follows every refresh, sign-in and account switch without being
|
|
39
|
+
* rebuilt.
|
|
40
|
+
*
|
|
41
|
+
* A service-authenticated process wants a different credential and
|
|
42
|
+
* builds {@link OxyInferenceClient} directly:
|
|
43
|
+
* `new OxyInferenceClient({ credential: () => oxy.getServiceToken() })`.
|
|
44
|
+
* The mint is asynchronous and cached, which is exactly what a
|
|
45
|
+
* credential function is for.
|
|
46
|
+
*/
|
|
47
|
+
inference(): OxyInferenceClient {
|
|
48
|
+
if (this._inferenceClient === null) {
|
|
49
|
+
this._inferenceClient = new OxyInferenceClient({
|
|
50
|
+
baseURL: this.getBaseURL(),
|
|
51
|
+
credential: () => this.getAccessToken(),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return this._inferenceClient;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -22,6 +22,7 @@ interface JwtPayload {
|
|
|
22
22
|
type?: string;
|
|
23
23
|
appId?: string;
|
|
24
24
|
credentialId?: string;
|
|
25
|
+
ownerAccountId?: string;
|
|
25
26
|
appName?: string;
|
|
26
27
|
scopes?: string[];
|
|
27
28
|
aud?: string | string[];
|
|
@@ -49,9 +50,21 @@ export interface ServiceActingAsVerification {
|
|
|
49
50
|
|
|
50
51
|
/**
|
|
51
52
|
* Service app metadata attached to requests authenticated with service tokens.
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
53
|
+
*
|
|
54
|
+
* Every field comes from the token's SIGNED payload and is populated only after
|
|
55
|
+
* the signature, `iss`/`aud`/`type` binding and expiry all pass — so a verifier
|
|
56
|
+
* holding this object can name the responsible principals without a lookup of
|
|
57
|
+
* its own. Together with `credentialId` and `ownerAccountId` it is the canonical
|
|
58
|
+
* attribution tuple of ADR 0007 minus the delegated user.
|
|
59
|
+
*
|
|
60
|
+
* `scopes` are the EFFECTIVE scopes: the credential's own scopes intersected
|
|
61
|
+
* with the owning application's grant at mint time (the API's `intersectScopes`
|
|
62
|
+
* is the single authority for that intersection — nothing re-intersects here).
|
|
63
|
+
* Route-level checks narrow further via `requireScope()`.
|
|
64
|
+
*
|
|
65
|
+
* A delegated end user is NOT a field of this type, and must never become one.
|
|
66
|
+
* It lives in `req.serviceActingAs` / `req.userId`, is authorised per request,
|
|
67
|
+
* and is attribution only.
|
|
55
68
|
*/
|
|
56
69
|
export interface ServiceApp {
|
|
57
70
|
appId: string;
|
|
@@ -59,6 +72,12 @@ export interface ServiceApp {
|
|
|
59
72
|
scopes: string[];
|
|
60
73
|
/** The credentialId of the specific service credential that minted this token. */
|
|
61
74
|
credentialId: string;
|
|
75
|
+
/**
|
|
76
|
+
* The Oxy account that owns `appId` and is financially responsible for it.
|
|
77
|
+
* The BILLING principal — never a user id, and never the delegated
|
|
78
|
+
* `X-Oxy-User-Id` (ADR 0007).
|
|
79
|
+
*/
|
|
80
|
+
ownerAccountId: string;
|
|
62
81
|
/** Test/live isolation (F2.0): which `ApplicationCredential.environment` minted this token. */
|
|
63
82
|
environment: OxyServiceEnvironment;
|
|
64
83
|
}
|
|
@@ -122,11 +141,24 @@ interface AuthMiddlewareOptions {
|
|
|
122
141
|
* When provided, service tokens will be cryptographically verified.
|
|
123
142
|
* When omitted, service tokens will be rejected (secure default).
|
|
124
143
|
*
|
|
125
|
-
* **
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
144
|
+
* **The only value that works is `ACCESS_TOKEN_SECRET`, and you should not
|
|
145
|
+
* want to hold it — see issue #987 and ADR 0012.** The Oxy API signs service
|
|
146
|
+
* tokens with `ACCESS_TOKEN_SECRET` (`packages/api/src/routes/auth.ts`),
|
|
147
|
+
* which is also the key that signs every user access token. There is no
|
|
148
|
+
* separate service-token secret: earlier revisions of this comment named a
|
|
149
|
+
* `SERVICE_TOKEN_SECRET` that has never existed in the API, in any workflow or
|
|
150
|
+
* in any task definition, and passing one would fail every verification.
|
|
151
|
+
*
|
|
152
|
+
* The consequence to hold onto: the scheme is symmetric, so a host that can
|
|
153
|
+
* VERIFY a service token can also MINT one — including a user access token.
|
|
154
|
+
* **Local verification is therefore appropriate only inside the Oxy API's own
|
|
155
|
+
* trust boundary, and no service outside it holds this key today.** Do not be
|
|
156
|
+
* the first: if you need to verify Oxy service tokens from another service,
|
|
157
|
+
* follow #987 rather than copying the secret.
|
|
158
|
+
*
|
|
159
|
+
* `docs/adr/0012-service-token-signing-key-model.md` records the decision to
|
|
160
|
+
* retire this option in favour of asymmetric signing against a published
|
|
161
|
+
* JWKS, at which point it is removed rather than deprecated.
|
|
130
162
|
*/
|
|
131
163
|
jwtSecret?: string;
|
|
132
164
|
/**
|
|
@@ -266,7 +298,7 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
266
298
|
* additionally checked for `aud`, `iss`, and `type` claims to prevent
|
|
267
299
|
* cross-token-type confusion attacks.
|
|
268
300
|
* - The backend's own `authMiddleware` uses `jwt.verify()` because it has
|
|
269
|
-
* direct access to `
|
|
301
|
+
* direct access to `ACCESS_TOKEN_SECRET`.
|
|
270
302
|
*
|
|
271
303
|
* **Why session-less user tokens are refused rather than trusted:**
|
|
272
304
|
* every user access token the Oxy API issues carries a `sessionId` (see
|
|
@@ -298,7 +330,7 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
298
330
|
* const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
299
331
|
*
|
|
300
332
|
* // Protect all routes under /protected
|
|
301
|
-
* app.use('/protected', oxy.auth({ jwtSecret: process.env.
|
|
333
|
+
* app.use('/protected', oxy.auth({ jwtSecret: process.env.ACCESS_TOKEN_SECRET }));
|
|
302
334
|
*
|
|
303
335
|
* // Access user in route handler
|
|
304
336
|
* app.get('/protected/me', (req, res) => {
|
|
@@ -312,7 +344,7 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
312
344
|
* app.use('/public', oxy.auth({ optional: true }));
|
|
313
345
|
*
|
|
314
346
|
* // Require a specific scope on a service-token-protected route
|
|
315
|
-
* app.use('/internal/files', oxy.serviceAuth({ jwtSecret: process.env.
|
|
347
|
+
* app.use('/internal/files', oxy.serviceAuth({ jwtSecret: process.env.ACCESS_TOKEN_SECRET }), oxy.requireScope('files:write'));
|
|
316
348
|
* ```
|
|
317
349
|
*
|
|
318
350
|
* @param options Optional configuration
|
|
@@ -490,14 +522,20 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
490
522
|
return res.status(401).json(error);
|
|
491
523
|
}
|
|
492
524
|
|
|
493
|
-
// Validate required service token fields
|
|
525
|
+
// Validate required service token fields. All of them are
|
|
526
|
+
// required, `ownerAccountId` included: an optional billing
|
|
527
|
+
// principal is one fallback away from being resolved from the
|
|
528
|
+
// delegated user, which is the exact confusion ADR 0007 forbids.
|
|
494
529
|
const appId = decoded.appId;
|
|
495
530
|
const credentialId = decoded.credentialId;
|
|
531
|
+
const ownerAccountId = decoded.ownerAccountId;
|
|
496
532
|
const environment = decoded.environment;
|
|
497
533
|
if (
|
|
498
534
|
!appId ||
|
|
499
535
|
typeof credentialId !== 'string' ||
|
|
500
536
|
credentialId.length === 0 ||
|
|
537
|
+
typeof ownerAccountId !== 'string' ||
|
|
538
|
+
ownerAccountId.length === 0 ||
|
|
501
539
|
!isOxyServiceEnvironment(environment)
|
|
502
540
|
) {
|
|
503
541
|
if (optional) {
|
|
@@ -538,6 +576,11 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
538
576
|
return res.status(403).json(error);
|
|
539
577
|
}
|
|
540
578
|
|
|
579
|
+
// ATTRIBUTION ONLY. `req.userId` answers "on whose behalf", never
|
|
580
|
+
// "who pays": the billing principal stays `req.serviceApp
|
|
581
|
+
// .ownerAccountId`, which this branch does not touch. Read it
|
|
582
|
+
// through `getOxyBillingPrincipal` (`@oxyhq/core/server`), whose
|
|
583
|
+
// return type a user id cannot satisfy (ADR 0007).
|
|
541
584
|
req.userId = oxyUserId;
|
|
542
585
|
req.user = { id: oxyUserId } as User;
|
|
543
586
|
req.serviceActingAs = { userId: oxyUserId, scopes: grant.scopes };
|
|
@@ -552,6 +595,7 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
552
595
|
appId,
|
|
553
596
|
appName: decoded.appName || 'unknown',
|
|
554
597
|
credentialId,
|
|
598
|
+
ownerAccountId,
|
|
555
599
|
scopes: Array.isArray(decoded.scopes) ? decoded.scopes : [],
|
|
556
600
|
environment,
|
|
557
601
|
};
|
|
@@ -896,7 +940,7 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
896
940
|
* @example
|
|
897
941
|
* ```typescript
|
|
898
942
|
* // Protect internal endpoints
|
|
899
|
-
* app.use('/internal', oxy.serviceAuth({ jwtSecret: process.env.
|
|
943
|
+
* app.use('/internal', oxy.serviceAuth({ jwtSecret: process.env.ACCESS_TOKEN_SECRET }));
|
|
900
944
|
*
|
|
901
945
|
* app.post('/internal/trigger', (req, res) => {
|
|
902
946
|
* console.log('Service app:', req.serviceApp);
|
|
@@ -936,7 +980,7 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
936
980
|
* ```typescript
|
|
937
981
|
* app.use(
|
|
938
982
|
* '/internal/files',
|
|
939
|
-
* oxy.serviceAuth({ jwtSecret: process.env.
|
|
983
|
+
* oxy.serviceAuth({ jwtSecret: process.env.ACCESS_TOKEN_SECRET }),
|
|
940
984
|
* oxy.requireScope('files:write'),
|
|
941
985
|
* );
|
|
942
986
|
* ```
|