@oxy.so/core 4.1.0 → 4.3.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/HttpService.js +142 -33
- package/dist/cjs/api/accounts.js +49 -10
- package/dist/cjs/api/apps.js +2 -2
- package/dist/cjs/api/auth.js +24 -3
- package/dist/cjs/api/billing.js +35 -0
- package/dist/cjs/boot/sessionColdBoot.js +28 -57
- package/dist/cjs/inference/OxyInferenceClient.js +34 -15
- package/dist/cjs/server/OxyServer.js +107 -51
- package/dist/cjs/server/agentAccount.js +19 -0
- package/dist/cjs/server/auth.js +19 -0
- package/dist/cjs/server/capabilityTicket.js +3 -41
- package/dist/cjs/server/index.js +10 -2
- package/dist/cjs/server/liveCapabilityTicket.js +48 -0
- package/dist/cjs/server/middleware.js +60 -18
- package/dist/cjs/server/namespaces.js +57 -0
- package/dist/cjs/server/serviceTokenLifetime.js +13 -0
- package/dist/cjs/session/SessionClient.js +90 -12
- package/dist/cjs/session/authStateStore.js +130 -39
- package/dist/cjs/session/refresh.js +152 -30
- package/dist/cjs/session/sharedDeviceCredential.js +17 -1
- package/dist/cjs/utils/officialOrigins.js +7 -18
- package/dist/esm/HttpService.js +143 -34
- package/dist/esm/api/accounts.js +38 -0
- package/dist/esm/api/apps.js +2 -2
- package/dist/esm/api/auth.js +23 -3
- package/dist/esm/api/billing.js +35 -0
- package/dist/esm/boot/sessionColdBoot.js +29 -58
- package/dist/esm/inference/OxyInferenceClient.js +32 -13
- package/dist/esm/server/OxyServer.js +107 -51
- package/dist/esm/server/agentAccount.js +16 -0
- package/dist/esm/server/auth.js +18 -0
- package/dist/esm/server/capabilityTicket.js +1 -39
- package/dist/esm/server/index.js +4 -1
- package/dist/esm/server/liveCapabilityTicket.js +45 -0
- package/dist/esm/server/middleware.js +60 -18
- package/dist/esm/server/namespaces.js +57 -0
- package/dist/esm/server/serviceTokenLifetime.js +9 -0
- package/dist/esm/session/SessionClient.js +90 -12
- package/dist/esm/session/authStateStore.js +128 -38
- package/dist/esm/session/refresh.js +151 -30
- package/dist/esm/session/sharedDeviceCredential.js +17 -1
- package/dist/esm/utils/officialOrigins.js +2 -13
- package/dist/types/HttpService.d.ts +26 -12
- package/dist/types/api/accounts.d.ts +38 -0
- package/dist/types/api/agency.d.ts +3 -1
- package/dist/types/api/apps.d.ts +3 -1
- package/dist/types/api/auth.d.ts +12 -1
- package/dist/types/api/billing.d.ts +12 -0
- package/dist/types/api/session.d.ts +3 -1
- package/dist/types/boot/sessionColdBoot.d.ts +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/inference/OxyInferenceClient.d.ts +18 -0
- package/dist/types/inference/index.d.ts +1 -1
- package/dist/types/server/OxyServer.d.ts +16 -9
- package/dist/types/server/agentAccount.d.ts +14 -0
- package/dist/types/server/auth.d.ts +18 -0
- package/dist/types/server/capabilityTicket.d.ts +2 -4
- package/dist/types/server/index.d.ts +7 -1
- package/dist/types/server/liveCapabilityTicket.d.ts +15 -0
- package/dist/types/server/middleware.d.ts +15 -6
- package/dist/types/server/namespaces.d.ts +78 -2
- package/dist/types/server/serviceTokenLifetime.d.ts +7 -0
- package/dist/types/session/SessionClient.d.ts +8 -0
- package/dist/types/session/authStateStore.d.ts +15 -0
- package/dist/types/session/index.d.ts +1 -1
- package/dist/types/session/refresh.d.ts +32 -7
- package/dist/types/session/sharedDeviceCredential.d.ts +3 -1
- package/dist/types/utils/officialOrigins.d.ts +1 -5
- package/package.json +6 -6
- package/src/HttpService.ts +138 -50
- package/src/api/accounts.ts +22 -0
- package/src/api/agency.ts +3 -1
- package/src/api/apps.ts +2 -2
- package/src/api/auth.ts +31 -2
- package/src/api/billing.ts +71 -9
- package/src/api/session.ts +3 -1
- package/src/boot/sessionColdBoot.ts +27 -72
- package/src/index.ts +1 -1
- package/src/inference/OxyInferenceClient.ts +35 -5
- package/src/inference/index.ts +1 -0
- package/src/server/OxyServer.ts +114 -53
- package/src/server/agentAccount.ts +28 -0
- package/src/server/auth.ts +25 -0
- package/src/server/capabilityTicket.ts +2 -42
- package/src/server/index.ts +13 -0
- package/src/server/liveCapabilityTicket.ts +47 -0
- package/src/server/middleware.ts +72 -25
- package/src/server/namespaces.ts +99 -2
- package/src/server/serviceTokenLifetime.ts +13 -0
- package/src/session/SessionClient.ts +85 -12
- package/src/session/authStateStore.ts +130 -46
- package/src/session/index.ts +1 -0
- package/src/session/refresh.ts +144 -28
- package/src/session/sharedDeviceCredential.ts +14 -2
- package/src/utils/officialOrigins.ts +2 -13
package/src/api/billing.ts
CHANGED
|
@@ -9,6 +9,16 @@
|
|
|
9
9
|
* `payment.controller`, `wallet.routes` in `packages/api`); `Date` columns
|
|
10
10
|
* arrive as ISO-8601 strings.
|
|
11
11
|
*/
|
|
12
|
+
import { type SubjectProductAccess,
|
|
13
|
+
type SubjectProductAccessQuery,
|
|
14
|
+
subjectProductAccessQuerySchema, subjectProductAccessSchema, } from '@oxy.so/contracts';
|
|
15
|
+
import {
|
|
16
|
+
type ProductSubscriptionSummary, type SubscriptionCreditGrant, type ProductSubscriptionCancellationResult,
|
|
17
|
+
productSubscriptionCancellationResultSchema,
|
|
18
|
+
cancelProductSubscriptionSchema,
|
|
19
|
+
productSubscriptionsResponseSchema,
|
|
20
|
+
subscriptionCreditGrantsResponseSchema,
|
|
21
|
+
} from '@oxy.so/contracts';
|
|
12
22
|
import type { OxyContext } from '../client/context';
|
|
13
23
|
|
|
14
24
|
const SUBSCRIPTION_TTL = 2 * 60 * 1000;
|
|
@@ -105,13 +115,65 @@ export interface WalletTransactionsPage {
|
|
|
105
115
|
export class BillingApi {
|
|
106
116
|
constructor(protected readonly ctx: OxyContext) {}
|
|
107
117
|
|
|
108
|
-
/**
|
|
109
|
-
async
|
|
118
|
+
/** Product rights only; requires a production application-bound user session. Never cached. */
|
|
119
|
+
async productAccess(query: SubjectProductAccessQuery): Promise<SubjectProductAccess> {
|
|
120
|
+
const parsed = subjectProductAccessQuerySchema.parse(query);
|
|
121
|
+
const response = await this.ctx.request<unknown>('GET',
|
|
122
|
+
`/v1/products/${encodeURIComponent(parsed.productId)}/access/${encodeURIComponent(parsed.subjectAccountId)}`,
|
|
123
|
+
undefined, { cache: false });
|
|
124
|
+
return subjectProductAccessSchema.parse(response);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Named commercial sources for the current subject; no inferred plan collapse. */
|
|
128
|
+
async productSubscriptions(): Promise<ProductSubscriptionSummary[]> {
|
|
129
|
+
return productSubscriptionsResponseSchema.parse(
|
|
130
|
+
await this.ctx.request<unknown>(
|
|
131
|
+
"GET",
|
|
132
|
+
"/billing/product-subscriptions",
|
|
133
|
+
undefined,
|
|
134
|
+
{ cache: false },
|
|
135
|
+
),
|
|
136
|
+
).subscriptions;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Per-grant credit provenance; no interpretation of the historical mixed balance. */
|
|
140
|
+
async creditGrants(): Promise<SubscriptionCreditGrant[]> {
|
|
141
|
+
return subscriptionCreditGrantsResponseSchema.parse(
|
|
142
|
+
await this.ctx.request<unknown>(
|
|
143
|
+
"GET",
|
|
144
|
+
"/billing/credit-grants",
|
|
145
|
+
undefined,
|
|
146
|
+
{ cache: false },
|
|
147
|
+
),
|
|
148
|
+
).grants;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Payer-only named-source cancellation. Resolves on provider acceptance, including pending local reconciliation. Use cancelProductSubscriptionWithStatus for detailed completion state. */
|
|
152
|
+
async cancelProductSubscription(sourceId: string, expectedSubjectAccountId?: string): Promise<void> {
|
|
153
|
+
await this.ctx.request(
|
|
154
|
+
"POST",
|
|
155
|
+
"/billing/product-subscriptions/cancel",
|
|
156
|
+
cancelProductSubscriptionSchema.parse({ sourceId, ...(expectedSubjectAccountId ? { expectedSubjectAccountId } : {}) }),
|
|
157
|
+
{ cache: false },
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Provider acceptance versus completed local cancellation, for user-facing flows. */
|
|
162
|
+
async cancelProductSubscriptionWithStatus(sourceId: string, expectedSubjectAccountId?: string): Promise<ProductSubscriptionCancellationResult> {
|
|
163
|
+
return productSubscriptionCancellationResultSchema.parse(await this.ctx.request<unknown>(
|
|
164
|
+
'POST', '/billing/product-subscriptions/cancel',
|
|
165
|
+
cancelProductSubscriptionSchema.parse({ sourceId, ...(expectedSubjectAccountId ? { expectedSubjectAccountId } : {}) }),
|
|
166
|
+
{ cache: false },
|
|
167
|
+
));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The signed-in user's payment history. Never cached. */
|
|
171
|
+
async payments(): Promise<Payment[]> {
|
|
110
172
|
return this.ctx.request<Payment[]>('GET', '/payments/user', undefined, { cache: false });
|
|
111
173
|
}
|
|
112
174
|
|
|
113
|
-
|
|
114
|
-
|
|
175
|
+
/** A user's subscription (default: the signed-in user's). */
|
|
176
|
+
async subscription(userId?: string): Promise<Subscription> {
|
|
115
177
|
const id = this.resolveUserId(userId);
|
|
116
178
|
return this.ctx.request<Subscription>('GET', `/subscription/${id}`, undefined, {
|
|
117
179
|
cache: true,
|
|
@@ -119,14 +181,14 @@ export class BillingApi {
|
|
|
119
181
|
});
|
|
120
182
|
}
|
|
121
183
|
|
|
122
|
-
|
|
123
|
-
|
|
184
|
+
/** A user's wallet (default: the signed-in user's). Cached briefly: the balance moves. */
|
|
185
|
+
async wallet(userId?: string): Promise<Wallet> {
|
|
124
186
|
const id = this.resolveUserId(userId);
|
|
125
187
|
return this.ctx.request<Wallet>('GET', `/wallet/${id}`, undefined, { cache: true, cacheTTL: WALLET_TTL });
|
|
126
188
|
}
|
|
127
189
|
|
|
128
|
-
|
|
129
|
-
|
|
190
|
+
/** A page of a user's wallet transactions (default: the signed-in user's). Never cached. */
|
|
191
|
+
async walletTransactions(
|
|
130
192
|
options: { userId?: string; limit?: number; offset?: number } = {},
|
|
131
193
|
): Promise<WalletTransactionsPage> {
|
|
132
194
|
const id = this.resolveUserId(options.userId);
|
|
@@ -136,7 +198,7 @@ export class BillingApi {
|
|
|
136
198
|
return this.ctx.request<WalletTransactionsPage>('GET', `/wallet/transactions/${id}`, params, { cache: false });
|
|
137
199
|
}
|
|
138
200
|
|
|
139
|
-
|
|
201
|
+
private resolveUserId(userId?: string): string {
|
|
140
202
|
const id = userId || this.ctx.oxy.session.userId;
|
|
141
203
|
if (!id) throw new Error('User not authenticated');
|
|
142
204
|
return id;
|
package/src/api/session.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* token belongs to: validating it, listing the sessions beside it, signing out.
|
|
7
7
|
*/
|
|
8
8
|
import { jwtDecode } from 'jwt-decode';
|
|
9
|
-
import type { DeviceProof } from '@oxy.so/contracts';
|
|
9
|
+
import type { AccountActorChain, DeviceProof } from '@oxy.so/contracts';
|
|
10
10
|
import type { OxyContext } from '../client/context';
|
|
11
11
|
import type { ClientSession } from '../models/session';
|
|
12
12
|
import type { User } from '../models/interfaces';
|
|
@@ -26,6 +26,8 @@ export interface SessionValidation {
|
|
|
26
26
|
user: User;
|
|
27
27
|
sessionId?: string;
|
|
28
28
|
source?: string;
|
|
29
|
+
/** Who acted, and as whom, read off the session row (issue #1520). */
|
|
30
|
+
actor?: AccountActorChain;
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
interface AccessTokenClaims {
|
|
@@ -44,17 +44,14 @@ import { runColdBoot, type ColdBootOutcome, type ColdBootStep } from '../utils/c
|
|
|
44
44
|
import { isNative as detectNative } from '../utils/platform';
|
|
45
45
|
import { logger } from '../logger';
|
|
46
46
|
import { computeIdentityTag } from '../utils/cacheKey';
|
|
47
|
-
import { TOKEN_REFRESH_LEAD_MS, refreshDeviceSecretArm } from '../session/refresh';
|
|
47
|
+
import { TOKEN_REFRESH_LEAD_MS, refreshDeviceSecretArm, refreshSharedDeviceArm } from '../session/refresh';
|
|
48
48
|
import {
|
|
49
49
|
establishIdentitySession,
|
|
50
50
|
resolveIdentityPin,
|
|
51
51
|
type IdentityBinding,
|
|
52
52
|
} from '../session/identitySession';
|
|
53
53
|
import type { IdentityPin } from '../session/identityPin';
|
|
54
|
-
import {
|
|
55
|
-
decideSharedDeviceJoin,
|
|
56
|
-
type SharedDeviceCredentialStore,
|
|
57
|
-
} from '../session/sharedDeviceCredential';
|
|
54
|
+
import type { SharedDeviceCredentialStore } from '../session/sharedDeviceCredential';
|
|
58
55
|
import type { OxyServices } from '../OxyServices';
|
|
59
56
|
import type { AuthStateStore, PersistedAuthState } from '../session/authStateStore';
|
|
60
57
|
import type { DeviceSessionState } from '@oxy.so/contracts';
|
|
@@ -178,6 +175,8 @@ export async function runSessionColdBoot(
|
|
|
178
175
|
// Boot-local (not module-level) so it cannot leak across boots or break under
|
|
179
176
|
// bundler re-evaluation.
|
|
180
177
|
let signedOutReason: SignedOutReason = 'no_session';
|
|
178
|
+
let rejectedLocalHolder = false;
|
|
179
|
+
let accountRecoverySuperseded = false;
|
|
181
180
|
|
|
182
181
|
// Boot-local memo for the identity pin. Resolved lazily INSIDE a step so the
|
|
183
182
|
// (local, but storage-backed) read is covered by `overallDeadlineMs`, and
|
|
@@ -212,6 +211,7 @@ export async function runSessionColdBoot(
|
|
|
212
211
|
steps.push({
|
|
213
212
|
id: 'warm-token-plant',
|
|
214
213
|
run: async () => {
|
|
214
|
+
if (identityBinding === null && isNative && await store.isAutomaticIdentitySignInSuppressed?.()) return { kind: 'skip' };
|
|
215
215
|
const persisted = await store.load();
|
|
216
216
|
if (!persisted?.accessToken || !persisted.sessionId || !persisted.userId || !persisted.expiresAt) {
|
|
217
217
|
return { kind: 'skip' };
|
|
@@ -287,6 +287,11 @@ export async function runSessionColdBoot(
|
|
|
287
287
|
},
|
|
288
288
|
};
|
|
289
289
|
case 'invalid-secret': {
|
|
290
|
+
if (identityBinding === null && store.setAutomaticIdentitySignInSuppressed && await store.isAutomaticIdentitySignInSuppressed?.()) {
|
|
291
|
+
rejectedLocalHolder = true;
|
|
292
|
+
signedOutReason = 'no_session';
|
|
293
|
+
return {kind: 'skip'};
|
|
294
|
+
}
|
|
290
295
|
// Stale/diverged secret — drop it so the mint lane stops firing. On
|
|
291
296
|
// native the Commons-proof step below can still recover; on web this ends
|
|
292
297
|
// signed out. Setting it undefined drops the key on the store's JSON
|
|
@@ -298,6 +303,7 @@ export async function runSessionColdBoot(
|
|
|
298
303
|
return { kind: 'skip' };
|
|
299
304
|
}
|
|
300
305
|
case 'no-session':
|
|
306
|
+
rejectedLocalHolder = identityBinding === null;
|
|
301
307
|
// Device known, no live session — authoritative signed-out. Keep the
|
|
302
308
|
// secret (the device may sign in again).
|
|
303
309
|
signedOutReason = 'no_session';
|
|
@@ -329,7 +335,9 @@ export async function runSessionColdBoot(
|
|
|
329
335
|
);
|
|
330
336
|
return { kind: 'skip' };
|
|
331
337
|
case 'session-ended':
|
|
332
|
-
//
|
|
338
|
+
// The prior holder/epoch was superseded. This boot must not turn a
|
|
339
|
+
// late rejection into a fresh Commons sign-in; a new boot can reconcile.
|
|
340
|
+
if (identityBinding === null) accountRecoverySuperseded = true;
|
|
333
341
|
return { kind: 'skip' };
|
|
334
342
|
case 'no-secret':
|
|
335
343
|
return { kind: 'skip' };
|
|
@@ -394,73 +402,16 @@ export async function runSessionColdBoot(
|
|
|
394
402
|
// The adoption itself is local, but it is only worth committing alongside
|
|
395
403
|
// a mint that proves the credential — so the whole lane is online-gated
|
|
396
404
|
// like every other network step.
|
|
397
|
-
enabled: () => isNative && !isOffline(),
|
|
405
|
+
enabled: () => isNative && !isOffline() && !accountRecoverySuperseded,
|
|
398
406
|
run: async () => {
|
|
399
|
-
const
|
|
400
|
-
const decision = decideSharedDeviceJoin(before, await sharedSlot.read());
|
|
401
|
-
if (decision.action === 'skip') {
|
|
402
|
-
logger.debug(
|
|
403
|
-
`shared device credential not adopted (${decision.reason})`,
|
|
404
|
-
{ component: 'sessionColdBoot', method: 'shared-device-adopt' },
|
|
405
|
-
);
|
|
406
|
-
return { kind: 'skip' };
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
// Restore the store to exactly what it held before this lane touched it.
|
|
410
|
-
// A credential we adopted and could not prove must not be left behind for
|
|
411
|
-
// the next boot's mint lane to keep retrying.
|
|
412
|
-
const revert = async (): Promise<void> => {
|
|
413
|
-
if (before) {
|
|
414
|
-
await store.save(before);
|
|
415
|
-
} else {
|
|
416
|
-
await store.clear();
|
|
417
|
-
}
|
|
418
|
-
};
|
|
419
|
-
|
|
420
|
-
const adopted: PersistedAuthState = {
|
|
421
|
-
// The mint fills both in from the device's live state; carrying the
|
|
422
|
-
// previous session's ids into a different device session would be a
|
|
423
|
-
// lie for however long the mint takes.
|
|
424
|
-
sessionId: '',
|
|
425
|
-
userId: '',
|
|
426
|
-
deviceId: decision.credential.deviceId,
|
|
427
|
-
deviceSecret: decision.credential.deviceSecret,
|
|
428
|
-
};
|
|
429
|
-
if (!(await store.save(adopted))) {
|
|
430
|
-
logger.error(
|
|
431
|
-
'adopted the shared device credential but it could not be durably persisted — reverting',
|
|
432
|
-
undefined,
|
|
433
|
-
{ component: 'sessionColdBoot', method: 'shared-device-adopt' },
|
|
434
|
-
);
|
|
435
|
-
await revert();
|
|
436
|
-
return { kind: 'skip' };
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
const result = await refreshDeviceSecretArm({ oxy, store, pin: null });
|
|
407
|
+
const result = await refreshSharedDeviceArm({oxy, store, shared: sharedSlot, rejectedLocalHolder});
|
|
440
408
|
if (result.status === 'ok') {
|
|
441
|
-
return {
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
sessionId: result.sessionId,
|
|
445
|
-
userId: result.userId,
|
|
446
|
-
accessToken: result.token,
|
|
447
|
-
},
|
|
448
|
-
};
|
|
409
|
+
return {kind: 'session', session: {
|
|
410
|
+
sessionId: result.sessionId, userId: result.userId, accessToken: result.token, state: result.state,
|
|
411
|
+
}};
|
|
449
412
|
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
// The one place we hold POSITIVE proof that the exact bytes in the
|
|
453
|
-
// shared slot are dead — the server rejected them by name. Clearing it
|
|
454
|
-
// signs nobody out (a credential the server does not recognise cannot
|
|
455
|
-
// be minting for anyone) and it is what stops a dead credential from
|
|
456
|
-
// blocking every future install: a stale slot owned by a different
|
|
457
|
-
// `deviceId` is otherwise never overwritten, by design.
|
|
458
|
-
await sharedSlot.clear();
|
|
459
|
-
} else if (result.status === 'no-session') {
|
|
460
|
-
signedOutReason = 'no_session';
|
|
461
|
-
}
|
|
462
|
-
await revert();
|
|
463
|
-
return { kind: 'skip' };
|
|
413
|
+
if (result.status === 'no-session') signedOutReason = 'no_session';
|
|
414
|
+
return {kind: 'skip'};
|
|
464
415
|
},
|
|
465
416
|
});
|
|
466
417
|
}
|
|
@@ -488,8 +439,10 @@ export async function runSessionColdBoot(
|
|
|
488
439
|
id: 'commons-proof-signin',
|
|
489
440
|
enabled: () => isNative && !isOffline(),
|
|
490
441
|
run: async () => {
|
|
491
|
-
|
|
492
|
-
|
|
442
|
+
if (accountRecoverySuperseded || await store.isAutomaticIdentitySignInSuppressed?.()) return { kind: 'skip' };
|
|
443
|
+
const epoch = oxy.http.getSessionEpoch();
|
|
444
|
+
const session = await oxy.auth.signInWithCommonsIdentity({ plantTokens: false, requestOptions: { retry: false } });
|
|
445
|
+
if (!session?.accessToken || oxy.http.getSessionEpoch() !== epoch || await store.isAutomaticIdentitySignInSuppressed?.()) {
|
|
493
446
|
return { kind: 'skip' };
|
|
494
447
|
}
|
|
495
448
|
// `verifyChallenge` issues a deviceSecret; persist it so the next
|
|
@@ -505,6 +458,8 @@ export async function runSessionColdBoot(
|
|
|
505
458
|
expiresAt: session.expiresAt,
|
|
506
459
|
});
|
|
507
460
|
}
|
|
461
|
+
if (oxy.http.getSessionEpoch() !== epoch || await store.isAutomaticIdentitySignInSuppressed?.()) return { kind: 'skip' };
|
|
462
|
+
oxy.session.setAccessToken(session.accessToken);
|
|
508
463
|
return {
|
|
509
464
|
kind: 'session',
|
|
510
465
|
session: {
|
package/src/index.ts
CHANGED
|
@@ -40,7 +40,7 @@ export {
|
|
|
40
40
|
ServiceAssetMetadataError,
|
|
41
41
|
} from './OxyServices.errors';
|
|
42
42
|
export type { HttpMethod } from './client/context';
|
|
43
|
-
export type { RequestOptions, AuthenticatedResponseRequest, AuthRefreshReason, AuthRefreshHandler } from './HttpService';
|
|
43
|
+
export type { RequestOptions, ResponseRequest, ResponseTransport, AuthenticatedResponseRequest, AuthRefreshReason, AuthRefreshHandler } from './HttpService';
|
|
44
44
|
|
|
45
45
|
// The namespaces — their classes (for typing) and their input/output types.
|
|
46
46
|
export type { SessionApi, DeviceCredentialProvider, SessionValidation } from './api/session';
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { MeteredGeneration } from '@oxy.so/contracts';
|
|
1
2
|
/**
|
|
2
3
|
* The Oxy inference client — one surface, two credential lanes (issue #972,
|
|
3
4
|
* workstream 15).
|
|
@@ -263,6 +264,9 @@ export interface OxyGenerationReceipt {
|
|
|
263
264
|
readonly settledAt: string;
|
|
264
265
|
}
|
|
265
266
|
|
|
267
|
+
/** The endpoint returns either a customer receipt or internal technical usage. */
|
|
268
|
+
export type OxyGenerationRecord = OxyGenerationReceipt | MeteredGeneration;
|
|
269
|
+
|
|
266
270
|
/**
|
|
267
271
|
* Anything the inference API refused.
|
|
268
272
|
*
|
|
@@ -688,12 +692,38 @@ export class OxyInferenceClient {
|
|
|
688
692
|
*/
|
|
689
693
|
async getGeneration(
|
|
690
694
|
id: string,
|
|
691
|
-
options: { signal?: AbortSignal } = {},
|
|
695
|
+
options: { signal?: AbortSignal; delegatedUserId?: string } = {},
|
|
692
696
|
): Promise<OxyGenerationReceipt> {
|
|
693
|
-
const
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
+
const record = await this.getGenerationRecord(id, options);
|
|
698
|
+
if (record.schemaVersion !== 1) {
|
|
699
|
+
throw new OxyInferenceProtocolError('Internal usage has no financial receipt. Use getGenerationRecord().', record.requestId);
|
|
700
|
+
}
|
|
701
|
+
return record;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/** Read a financial receipt (v1) or an internal technical record (v2). */
|
|
705
|
+
async getGenerationRecord(
|
|
706
|
+
id: string,
|
|
707
|
+
options: { signal?: AbortSignal; delegatedUserId?: string } = {},
|
|
708
|
+
): Promise<OxyGenerationRecord> {
|
|
709
|
+
const body = await this.#request<{ data: OxyGenerationRecord }>(
|
|
710
|
+
'GET', `/v1/generations/${encodeURIComponent(id)}`, options,
|
|
711
|
+
);
|
|
712
|
+
return body.data;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Recover the existing record using the original credential and key. This
|
|
717
|
+
* performs only GET; 404 is unknown/pending, never proof of zero cost and
|
|
718
|
+
* never an instruction to repeat decide(). Lost answers are not retained.
|
|
719
|
+
*/
|
|
720
|
+
async getGenerationRecordByIdempotencyKey(
|
|
721
|
+
key: string,
|
|
722
|
+
options: { signal?: AbortSignal; delegatedUserId?: string } = {},
|
|
723
|
+
): Promise<OxyGenerationRecord> {
|
|
724
|
+
if (key.length === 0 || key.length > 128) throw new Error('Invalid original idempotency key.');
|
|
725
|
+
const body = await this.#request<{ data: OxyGenerationRecord }>(
|
|
726
|
+
'GET', '/v1/generations/by-idempotency-key', { ...options, idempotencyKey: key },
|
|
697
727
|
);
|
|
698
728
|
return body.data;
|
|
699
729
|
}
|
package/src/inference/index.ts
CHANGED
package/src/server/OxyServer.ts
CHANGED
|
@@ -26,6 +26,17 @@
|
|
|
26
26
|
* Node only.
|
|
27
27
|
*/
|
|
28
28
|
import { jwtDecode } from 'jwt-decode';
|
|
29
|
+
import { hasBoundedServiceTokenLifetime } from './serviceTokenLifetime';
|
|
30
|
+
|
|
31
|
+
/** Unverified cache metadata only: an old lifetime forces a new signed mint. */
|
|
32
|
+
function reusableServiceToken(token: string): boolean {
|
|
33
|
+
try {
|
|
34
|
+
const claims = jwtDecode<{ iat?: number; exp?: number }>(token);
|
|
35
|
+
return hasBoundedServiceTokenLifetime(claims) && typeof claims.exp === 'number'
|
|
36
|
+
&& claims.exp > Math.floor(Date.now() / 1000);
|
|
37
|
+
} catch { return false; }
|
|
38
|
+
}
|
|
39
|
+
|
|
29
40
|
import { loadNodeCrypto } from '@oxy.so/protocol';
|
|
30
41
|
import { OxyServices, type OxyConfig } from '../OxyServices';
|
|
31
42
|
import type { HttpMethod, ServiceLane } from '../client/context';
|
|
@@ -87,6 +98,21 @@ export interface OxyServerConfig extends OxyConfig {
|
|
|
87
98
|
export const ANONYMOUS_SERVICE_TOKEN_RETRY_MS = 30_000;
|
|
88
99
|
|
|
89
100
|
/** `POST /auth/service-token`. */
|
|
101
|
+
export interface ActingAsVerificationOptions {
|
|
102
|
+
/** Only explicit read-only callers may reuse authority; effects always pass false. */
|
|
103
|
+
cache?: boolean;
|
|
104
|
+
/** Taken from verified service claims by middleware, never from a user header. */
|
|
105
|
+
credentialId?: string;
|
|
106
|
+
ownerAccountId?: string;
|
|
107
|
+
environment?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface ActingAsGeneration {
|
|
111
|
+
epoch: bigint;
|
|
112
|
+
issued: number;
|
|
113
|
+
completed: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
90
116
|
export interface ServiceTokenResponse {
|
|
91
117
|
token: string;
|
|
92
118
|
expiresIn: number;
|
|
@@ -127,10 +153,10 @@ const TOKEN_REUSE_MARGIN_MS = 60_000;
|
|
|
127
153
|
|
|
128
154
|
/** Bound on remembered `appId:userId` delegation answers. */
|
|
129
155
|
const ACTING_AS_CACHE_MAX = 1000;
|
|
130
|
-
/**
|
|
131
|
-
const ACTING_AS_GRANT_TTL_MS =
|
|
132
|
-
/**
|
|
133
|
-
const ACTING_AS_DENIAL_TTL_MS =
|
|
156
|
+
/** Explicit read reuse only; effects always perform a fresh lookup. */
|
|
157
|
+
const ACTING_AS_GRANT_TTL_MS = 60_000;
|
|
158
|
+
/** Explicit authoritative read denials last ten seconds; failures are never cached. */
|
|
159
|
+
const ACTING_AS_DENIAL_TTL_MS = 10_000;
|
|
134
160
|
|
|
135
161
|
export class OxyServer extends OxyServices {
|
|
136
162
|
private readonly serviceTokens = new Map<string, ServiceTokenCacheEntry>();
|
|
@@ -138,6 +164,7 @@ export class OxyServer extends OxyServices {
|
|
|
138
164
|
private serviceApiSecret: string | null = null;
|
|
139
165
|
private readonly actingAs = new Map<string, { result: ServiceActingAsVerification | null; expiresAt: number }>();
|
|
140
166
|
private readonly actingAsPending = new Map<string, Promise<ServiceActingAsVerification | null>>();
|
|
167
|
+
private readonly actingAsGenerations = new Map<string, ActingAsGeneration>();
|
|
141
168
|
/** Public keys only; never private material. */
|
|
142
169
|
private readonly jwksCache: ServiceTokenJwksCache = { keys: new Map(), expiresAt: 0, lastAttemptAt: 0 };
|
|
143
170
|
/**
|
|
@@ -180,7 +207,7 @@ export class OxyServer extends OxyServices {
|
|
|
180
207
|
},
|
|
181
208
|
jwksCache: this.jwksCache,
|
|
182
209
|
validateSession: (sessionId, options) => this.session.validate(sessionId, options),
|
|
183
|
-
verifyActingAs: (appId, userId) => this.verifyActingAs(appId, userId),
|
|
210
|
+
verifyActingAs: (appId, userId, options) => this.verifyActingAs(appId, userId, options),
|
|
184
211
|
});
|
|
185
212
|
}
|
|
186
213
|
|
|
@@ -277,7 +304,7 @@ export class OxyServer extends OxyServices {
|
|
|
277
304
|
});
|
|
278
305
|
throw new ServiceCredentialMismatchError();
|
|
279
306
|
}
|
|
280
|
-
if (entry.token && entry.expiresAt > now + TOKEN_REUSE_MARGIN_MS) {
|
|
307
|
+
if (entry.token && reusableServiceToken(entry.token) && entry.expiresAt > now + TOKEN_REUSE_MARGIN_MS) {
|
|
281
308
|
return entry.token;
|
|
282
309
|
}
|
|
283
310
|
if (entry.pending) {
|
|
@@ -344,32 +371,45 @@ export class OxyServer extends OxyServices {
|
|
|
344
371
|
// ── Delegation and account events ────────────────────────────────────────
|
|
345
372
|
|
|
346
373
|
/**
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
* revocation window; a denial or failed lookup for 1 minute), bounded to the
|
|
353
|
-
* 1000 most recent pairs, and concurrent lookups of one pair share a single
|
|
354
|
-
* request.
|
|
374
|
+
* Resolve authority live by default. Explicit read-only reuse lasts at most
|
|
375
|
+
* 60s positive / 10s negative, anchored to request start; errors never cache.
|
|
376
|
+
* Effects do not share an older in-flight read. Durable epoch plus local
|
|
377
|
+
* completion ordering prevents ABA and late positive replies overwriting a
|
|
378
|
+
* newer refusal even when app/credential revocation leaves epoch unchanged.
|
|
355
379
|
*/
|
|
356
|
-
async verifyActingAs(
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
this.actingAs.
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
}
|
|
380
|
+
async verifyActingAs(
|
|
381
|
+
appId: string, userId: string, options: ActingAsVerificationOptions = {},
|
|
382
|
+
): Promise<ServiceActingAsVerification | null> {
|
|
383
|
+
const pair = JSON.stringify([appId, userId]);
|
|
384
|
+
const cacheKey = JSON.stringify([appId, userId, options.credentialId, options.ownerAccountId, options.environment]);
|
|
385
|
+
if (options.cache === true) {
|
|
386
|
+
const cached = this.actingAs.get(cacheKey);
|
|
387
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
388
|
+
this.actingAs.delete(cacheKey);
|
|
389
|
+
this.actingAs.set(cacheKey, cached);
|
|
390
|
+
return cached.result;
|
|
391
|
+
}
|
|
392
|
+
const pending = this.actingAsPending.get(cacheKey);
|
|
393
|
+
if (pending) return pending;
|
|
394
|
+
}
|
|
395
|
+
let generation = this.actingAsGenerations.get(pair);
|
|
396
|
+
if (!generation) {
|
|
397
|
+
generation = { epoch: 0n, issued: 0, completed: 0 };
|
|
398
|
+
this.actingAsGenerations.set(pair, generation);
|
|
399
|
+
if (this.actingAsGenerations.size > ACTING_AS_CACHE_MAX) {
|
|
400
|
+
const oldest = this.actingAsGenerations.keys().next().value;
|
|
401
|
+
if (oldest !== undefined) this.actingAsGenerations.delete(oldest);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
const serial = ++generation.issued;
|
|
405
|
+
const startedAt = Date.now();
|
|
406
|
+
const lookup = this.lookupActingAs(appId, userId, options, pair, cacheKey, generation, serial, startedAt);
|
|
407
|
+
if (options.cache !== true) return lookup;
|
|
371
408
|
this.actingAsPending.set(cacheKey, lookup);
|
|
372
|
-
return lookup;
|
|
409
|
+
try { return await lookup; }
|
|
410
|
+
finally {
|
|
411
|
+
if (this.actingAsPending.get(cacheKey) === lookup) this.actingAsPending.delete(cacheKey);
|
|
412
|
+
}
|
|
373
413
|
}
|
|
374
414
|
|
|
375
415
|
/**
|
|
@@ -457,7 +497,7 @@ export class OxyServer extends OxyServices {
|
|
|
457
497
|
/** A token obtained by attestation, cached like a credential's (one identity per process). */
|
|
458
498
|
private async workloadServiceToken(): Promise<string> {
|
|
459
499
|
const entry = this.serviceTokens.get(WORKLOAD_CACHE_KEY);
|
|
460
|
-
if (entry?.token && entry.expiresAt > Date.now() + TOKEN_REUSE_MARGIN_MS) return entry.token;
|
|
500
|
+
if (entry?.token && reusableServiceToken(entry.token) && entry.expiresAt > Date.now() + TOKEN_REUSE_MARGIN_MS) return entry.token;
|
|
461
501
|
if (entry?.pending) return entry.pending;
|
|
462
502
|
|
|
463
503
|
const seeded = entry ?? { token: '', expiresAt: 0, secretBuf: Buffer.alloc(0), pending: null, apiKey: WORKLOAD_CACHE_KEY };
|
|
@@ -486,41 +526,62 @@ export class OxyServer extends OxyServices {
|
|
|
486
526
|
}
|
|
487
527
|
}
|
|
488
528
|
|
|
489
|
-
private async lookupActingAs(
|
|
529
|
+
private async lookupActingAs(
|
|
530
|
+
appId: string, userId: string, options: ActingAsVerificationOptions,
|
|
531
|
+
pair: string, cacheKey: string, generation: ActingAsGeneration, serial: number, startedAt: number,
|
|
532
|
+
): Promise<ServiceActingAsVerification | null> {
|
|
490
533
|
try {
|
|
491
|
-
// The verify endpoint admits only a platform-trusted caller, so this
|
|
492
|
-
// carries the VERIFIER's own service token. No retry and a short timeout:
|
|
493
|
-
// it runs inside request-handling middleware, and a retry loop would
|
|
494
|
-
// multiply the latency of every delegated request. A verifier with no
|
|
495
|
-
// credential throws here and is refused below — a host that cannot prove
|
|
496
|
-
// who it is has no business learning who delegated to whom.
|
|
497
534
|
const serviceToken = await this.serviceToken();
|
|
498
535
|
const result = await this.request<ServiceActingAsVerification>(
|
|
499
|
-
'GET',
|
|
500
|
-
|
|
501
|
-
|
|
536
|
+
'GET', '/internal/service-acting-as/verify',
|
|
537
|
+
{ appId, userId, ...(options.credentialId ? { credentialId: options.credentialId,
|
|
538
|
+
ownerAccountId: options.ownerAccountId, environment: options.environment } : {}) },
|
|
502
539
|
{ cache: false, retry: false, timeout: 5000, headers: { Authorization: `Bearer ${serviceToken}` } },
|
|
503
540
|
);
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
541
|
+
// A deadline bounds how long an already old response can authorize. It
|
|
542
|
+
// is not by itself a measurement of end-to-end revocation latency.
|
|
543
|
+
if (this.actingAsGenerations.get(pair) !== generation || serial < generation.completed) return null;
|
|
544
|
+
generation.completed = serial;
|
|
545
|
+
this.clearActingAsPair(appId, userId);
|
|
546
|
+
if (Date.now() - startedAt >= 5000) return null;
|
|
547
|
+
const rawEpoch = result?.epoch;
|
|
548
|
+
if (typeof rawEpoch !== 'string' || !/^(?:0|[1-9][0-9]{0,18})$/.test(rawEpoch)) return null;
|
|
549
|
+
const epoch = BigInt(rawEpoch);
|
|
550
|
+
if (epoch > 9223372036854775807n) return null;
|
|
551
|
+
if (typeof result.authorized !== 'boolean' || !Array.isArray(result.scopes)
|
|
552
|
+
|| !result.scopes.every((scope) => typeof scope === 'string' && scope.length > 0 && scope.trim() === scope)
|
|
553
|
+
|| new Set(result.scopes).size !== result.scopes.length
|
|
554
|
+
|| (!result.authorized && result.scopes.length !== 0)) return null;
|
|
555
|
+
const older = epoch < generation.epoch;
|
|
556
|
+
if (epoch > generation.epoch) generation.epoch = epoch;
|
|
557
|
+
if (result.authorized && older) return null;
|
|
558
|
+
const verified: ServiceActingAsVerification | null = result.authorized === true
|
|
559
|
+
? { authorized: true, scopes: result.scopes, epoch: rawEpoch } : null;
|
|
560
|
+
if (options.cache === true) this.rememberActingAs(cacheKey, verified,
|
|
561
|
+
startedAt + (verified ? ACTING_AS_GRANT_TTL_MS : ACTING_AS_DENIAL_TTL_MS));
|
|
508
562
|
return verified;
|
|
509
563
|
} catch (error) {
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
564
|
+
if (this.actingAsGenerations.get(pair) === generation && serial >= generation.completed) {
|
|
565
|
+
generation.completed = serial;
|
|
566
|
+
this.clearActingAsPair(appId, userId);
|
|
567
|
+
}
|
|
568
|
+
logger.warn('[oxy.auth] verifyActingAs lookup failed — not cached', {
|
|
569
|
+
component: 'auth', method: 'verifyActingAs', appId, userId,
|
|
515
570
|
}, error);
|
|
516
|
-
this.rememberActingAs(`${appId}:${userId}`, null, ACTING_AS_DENIAL_TTL_MS);
|
|
517
571
|
return null;
|
|
518
572
|
}
|
|
519
573
|
}
|
|
520
574
|
|
|
521
|
-
private
|
|
575
|
+
private clearActingAsPair(appId: string, userId: string): void {
|
|
576
|
+
for (const key of this.actingAs.keys()) {
|
|
577
|
+
const [cachedApp, cachedUser] = JSON.parse(key) as [string, string];
|
|
578
|
+
if (cachedApp === appId && cachedUser === userId) this.actingAs.delete(key);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
private rememberActingAs(cacheKey: string, result: ServiceActingAsVerification | null, expiresAt: number): void {
|
|
522
583
|
this.actingAs.delete(cacheKey);
|
|
523
|
-
this.actingAs.set(cacheKey, { result, expiresAt
|
|
584
|
+
this.actingAs.set(cacheKey, { result, expiresAt });
|
|
524
585
|
if (this.actingAs.size > ACTING_AS_CACHE_MAX) {
|
|
525
586
|
const oldest = this.actingAs.keys().next().value;
|
|
526
587
|
if (oldest !== undefined) this.actingAs.delete(oldest);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Autonomous entry helper. The signer owns its private key (for example in a KMS). */
|
|
2
|
+
import { buildAgentProofMessage, type LoginResult } from '@oxy.so/contracts';
|
|
3
|
+
import { normalizeSecp256k1PublicKey } from '@oxy.so/protocol/secp256k1';
|
|
4
|
+
import type { OxyServices } from '../OxyServices';
|
|
5
|
+
|
|
6
|
+
export interface AgentAccountSigner {
|
|
7
|
+
/** Compressed or uncompressed SEC1 secp256k1 public key; no private-key field exists. */
|
|
8
|
+
publicKey: string;
|
|
9
|
+
signMessage(message: string): Promise<string>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Explicit sign-in; no timer, automatic escalation or service credential lane. */
|
|
13
|
+
export async function signInAgentAccount(options: {
|
|
14
|
+
client: Pick<OxyServices, 'auth'>;
|
|
15
|
+
accountId: string;
|
|
16
|
+
signer: AgentAccountSigner;
|
|
17
|
+
}): Promise<LoginResult> {
|
|
18
|
+
const { client, accountId, signer } = options;
|
|
19
|
+
const publicKey = normalizeSecp256k1PublicKey(signer.publicKey.trim());
|
|
20
|
+
const claims = await client.auth.agent.requestChallenge(publicKey);
|
|
21
|
+
const timestamp = Date.now();
|
|
22
|
+
if (claims.action !== 'agent_signin' || claims.accountId !== accountId || claims.actorId !== accountId
|
|
23
|
+
|| normalizeSecp256k1PublicKey(claims.publicKey) !== publicKey || !claims.authMethodId || claims.expiresAt <= timestamp) {
|
|
24
|
+
throw new Error('Agent challenge does not match this account and signer');
|
|
25
|
+
}
|
|
26
|
+
const signature = await signer.signMessage(buildAgentProofMessage(claims, timestamp));
|
|
27
|
+
return client.auth.agent.verify(publicKey, { challenge: claims.challenge, timestamp, signature });
|
|
28
|
+
}
|