@tangle-network/agent-app 0.46.53 → 0.46.55
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/README.md +4 -4
- package/dist/billing/crypto-probe.d.ts +3 -0
- package/dist/billing/identity-bound.d.ts +183 -0
- package/dist/billing/index.d.ts +1 -0
- package/dist/billing/index.js +3 -1
- package/dist/chat-routes/index.js +1 -1
- package/dist/{chunk-T374KO42.js → chunk-LKZBMDYM.js} +4 -3
- package/dist/chunk-LKZBMDYM.js.map +1 -0
- package/dist/chunk-TS2ZIB53.js +701 -0
- package/dist/chunk-TS2ZIB53.js.map +1 -0
- package/dist/preset-cloudflare/index.js +1 -1
- package/dist/sandbox/index.d.ts +2 -2
- package/dist/sandbox/index.js +1 -1
- package/dist/turn-stream/adapters.d.ts +10 -10
- package/dist/turn-stream/core.d.ts +15 -12
- package/dist/turn-stream/do.d.ts +2 -2
- package/dist/turn-stream/index.d.ts +5 -4
- package/dist/turn-stream/index.js.map +1 -1
- package/package.json +5 -5
- package/dist/chunk-GXE6KHTS.js +0 -168
- package/dist/chunk-GXE6KHTS.js.map +0 -1
- package/dist/chunk-T374KO42.js.map +0 -1
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
// src/billing/crypto-probe.ts
|
|
2
|
+
async function assertWorkspaceKeyCryptoUsable(crypto) {
|
|
3
|
+
const probe = "agent-app:key-manager:crypto-probe";
|
|
4
|
+
let roundTripped;
|
|
5
|
+
try {
|
|
6
|
+
roundTripped = await crypto.decrypt(await crypto.encrypt(probe));
|
|
7
|
+
} catch (error) {
|
|
8
|
+
throw new Error(
|
|
9
|
+
"Key encryption is misconfigured: the crypto seam threw before minting. Validate FIELD_ENCRYPTION_KEY (64-char hex) at startup. No platform key was minted.",
|
|
10
|
+
{ cause: error }
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
if (roundTripped !== probe) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
"Key encryption is misconfigured: encrypt/decrypt round-trip did not preserve the plaintext. No platform key was minted."
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// src/billing/identity-bound.ts
|
|
21
|
+
var DEFAULT_KEY_LIFETIME_MS = 55 * 6e4;
|
|
22
|
+
var DEFAULT_LEASE_MS = 6e4;
|
|
23
|
+
var DEFAULT_LEASE_WAIT_MS = 15e3;
|
|
24
|
+
var DEFAULT_LEASE_POLL_MS = 50;
|
|
25
|
+
var DEFAULT_LEASE_RENEW_INTERVAL_MS = Math.floor(DEFAULT_LEASE_MS / 3);
|
|
26
|
+
var DEFAULT_REVOCATION_RETRY_BASE_MS = 2e3;
|
|
27
|
+
var DEFAULT_REVOCATION_RETRY_MAX_MS = 6e4;
|
|
28
|
+
var PENDING_REVOCATION_BATCH_SIZE = 20;
|
|
29
|
+
function sleep(ms) {
|
|
30
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
31
|
+
}
|
|
32
|
+
function operationId() {
|
|
33
|
+
return globalThis.crypto.randomUUID();
|
|
34
|
+
}
|
|
35
|
+
function requirePositive(value, label) {
|
|
36
|
+
if (!Number.isFinite(value) || value <= 0) throw new Error(`${label} must be a positive finite number`);
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
function requireNonNegative(value, label) {
|
|
40
|
+
if (!Number.isFinite(value) || value < 0) throw new Error(`${label} must be a non-negative finite number`);
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
function normalizeIdentity(identity) {
|
|
44
|
+
const workspaceId = identity.workspaceId.trim();
|
|
45
|
+
const ownerUserId = identity.ownerUserId.trim();
|
|
46
|
+
const platformUserId = identity.platformUserId.trim();
|
|
47
|
+
const sourceKeyFingerprint = identity.sourceKeyFingerprint.trim();
|
|
48
|
+
const sourceKeyId2 = identity.sourceKeyId?.trim() || null;
|
|
49
|
+
if (!workspaceId || !ownerUserId || !platformUserId || !sourceKeyFingerprint) {
|
|
50
|
+
throw new Error("workspace child key identity is incomplete");
|
|
51
|
+
}
|
|
52
|
+
return { workspaceId, ownerUserId, platformUserId, sourceKeyId: sourceKeyId2, sourceKeyFingerprint };
|
|
53
|
+
}
|
|
54
|
+
function scopeFor(identity, product) {
|
|
55
|
+
return { workspaceId: identity.workspaceId, ownerUserId: identity.ownerUserId, product };
|
|
56
|
+
}
|
|
57
|
+
function scopeKey(scope) {
|
|
58
|
+
return JSON.stringify([scope.product, scope.workspaceId, scope.ownerUserId]);
|
|
59
|
+
}
|
|
60
|
+
function safeNamePart(value) {
|
|
61
|
+
const normalized = value.replace(/[^A-Za-z0-9._-]/g, "-").replace(/-+/g, "-");
|
|
62
|
+
return normalized.slice(0, 80) || "unknown";
|
|
63
|
+
}
|
|
64
|
+
function defaultKeyName(identity, product, operationId2) {
|
|
65
|
+
return `agent-app:${safeNamePart(product)}:${safeNamePart(identity.workspaceId)}:${safeNamePart(identity.platformUserId)}:${operationId2}`;
|
|
66
|
+
}
|
|
67
|
+
function sourceKeyId(identity) {
|
|
68
|
+
return identity.sourceKeyId?.trim() || null;
|
|
69
|
+
}
|
|
70
|
+
function sameIdentity(row, identity, product) {
|
|
71
|
+
return row.workspaceId === identity.workspaceId && row.ownerUserId === identity.ownerUserId && row.product === product && row.platformUserId === identity.platformUserId && row.sourceKeyId === sourceKeyId(identity) && row.sourceKeyFingerprint === identity.sourceKeyFingerprint;
|
|
72
|
+
}
|
|
73
|
+
function sameRowIdentity(a, b) {
|
|
74
|
+
return a.workspaceId === b.workspaceId && a.ownerUserId === b.ownerUserId && a.product === b.product && a.platformUserId === b.platformUserId && a.sourceKeyId === b.sourceKeyId && a.sourceKeyFingerprint === b.sourceKeyFingerprint;
|
|
75
|
+
}
|
|
76
|
+
function errorMessage(error, fallback) {
|
|
77
|
+
return (error instanceof Error ? error.message : fallback).replace(/Bearer\s+[^\s,;]+/gi, "Bearer [redacted]").replace(/((?:authorization|api[-_ ]?key|token|secret|password)\s*[:=]\s*)(?!Bearer\b)[^\s,;]+/gi, "$1[redacted]").slice(0, 240);
|
|
78
|
+
}
|
|
79
|
+
function isProvisioningId(value) {
|
|
80
|
+
return value.startsWith("provisioning:");
|
|
81
|
+
}
|
|
82
|
+
function usageFromRemote(row, remote, now) {
|
|
83
|
+
const budgetUsd = remote.budgetUsd ?? row.budgetUsd;
|
|
84
|
+
const budgetSpent = remote.budgetSpent ?? 0;
|
|
85
|
+
if (!Number.isFinite(budgetUsd) || budgetUsd < 0) throw new Error("workspace child key budget is malformed");
|
|
86
|
+
if (!Number.isFinite(budgetSpent) || budgetSpent < 0) throw new Error("workspace child key usage is malformed");
|
|
87
|
+
const expiresAt = remote.expiresAt ?? row.expiresAt.toISOString();
|
|
88
|
+
const expiryMs = Date.parse(expiresAt);
|
|
89
|
+
if (!Number.isFinite(expiryMs)) throw new Error("workspace child key expiry is malformed");
|
|
90
|
+
const budgetRemaining = Math.max(0, budgetUsd - budgetSpent);
|
|
91
|
+
return {
|
|
92
|
+
keyId: row.keyId,
|
|
93
|
+
budgetUsd,
|
|
94
|
+
budgetSpent,
|
|
95
|
+
budgetRemaining,
|
|
96
|
+
expiresAt,
|
|
97
|
+
exhausted: budgetRemaining <= 0 || expiryMs <= now.getTime()
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function defaultRemoteMissing(error) {
|
|
101
|
+
const value = error;
|
|
102
|
+
return value?.status === 404 || value?.code === "not_found";
|
|
103
|
+
}
|
|
104
|
+
function createIdentityBoundWorkspaceKeyManager(options) {
|
|
105
|
+
const product = options.product.trim();
|
|
106
|
+
if (!product) throw new Error("workspace child key product is required");
|
|
107
|
+
const defaultBudgetUsd = requirePositive(options.defaultBudgetUsd, "defaultBudgetUsd");
|
|
108
|
+
const keyLifetimeMs = requirePositive(options.keyLifetimeMs ?? DEFAULT_KEY_LIFETIME_MS, "keyLifetimeMs");
|
|
109
|
+
const leaseMs = requirePositive(options.leaseMs ?? DEFAULT_LEASE_MS, "leaseMs");
|
|
110
|
+
const leaseWaitMs = requireNonNegative(options.leaseWaitMs ?? DEFAULT_LEASE_WAIT_MS, "leaseWaitMs");
|
|
111
|
+
const leasePollMs = requirePositive(options.leasePollMs ?? DEFAULT_LEASE_POLL_MS, "leasePollMs");
|
|
112
|
+
const leaseRenewIntervalMs = requirePositive(
|
|
113
|
+
options.leaseRenewIntervalMs ?? Math.min(DEFAULT_LEASE_RENEW_INTERVAL_MS, Math.floor(leaseMs / 3)),
|
|
114
|
+
"leaseRenewIntervalMs"
|
|
115
|
+
);
|
|
116
|
+
if (leaseRenewIntervalMs >= leaseMs) throw new Error("leaseRenewIntervalMs must be shorter than leaseMs");
|
|
117
|
+
const revocationRetryBaseMs = requirePositive(
|
|
118
|
+
options.revocationRetryBaseMs ?? DEFAULT_REVOCATION_RETRY_BASE_MS,
|
|
119
|
+
"revocationRetryBaseMs"
|
|
120
|
+
);
|
|
121
|
+
const revocationRetryMaxMs = requirePositive(
|
|
122
|
+
options.revocationRetryMaxMs ?? DEFAULT_REVOCATION_RETRY_MAX_MS,
|
|
123
|
+
"revocationRetryMaxMs"
|
|
124
|
+
);
|
|
125
|
+
if (revocationRetryMaxMs < revocationRetryBaseMs) throw new Error("revocationRetryMaxMs must cover the base delay");
|
|
126
|
+
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
127
|
+
const isRemoteMissing = options.isRemoteMissing ?? defaultRemoteMissing;
|
|
128
|
+
const localLocks = /* @__PURE__ */ new Map();
|
|
129
|
+
async function withLocalLock(scope, work) {
|
|
130
|
+
const previous = localLocks.get(scope) ?? Promise.resolve();
|
|
131
|
+
let release;
|
|
132
|
+
const current = new Promise((resolve) => {
|
|
133
|
+
release = resolve;
|
|
134
|
+
});
|
|
135
|
+
localLocks.set(scope, current);
|
|
136
|
+
await previous;
|
|
137
|
+
try {
|
|
138
|
+
return await work();
|
|
139
|
+
} finally {
|
|
140
|
+
release();
|
|
141
|
+
if (localLocks.get(scope) === current) localLocks.delete(scope);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async function withDurableLease(scope, work, onLeaseLost) {
|
|
145
|
+
const leaseOperationId = operationId();
|
|
146
|
+
const startedAt = Date.now();
|
|
147
|
+
let acquired = false;
|
|
148
|
+
while (!acquired && Date.now() - startedAt <= leaseWaitMs) {
|
|
149
|
+
acquired = await options.store.acquireLease(scope, leaseOperationId, now(), leaseMs);
|
|
150
|
+
if (!acquired) await sleep(leasePollMs);
|
|
151
|
+
}
|
|
152
|
+
if (!acquired) throw new Error("workspace child-key issuance is busy; retry the request");
|
|
153
|
+
let stopped = false;
|
|
154
|
+
let leaseLost = null;
|
|
155
|
+
let leaseTimer;
|
|
156
|
+
let renewalPromise;
|
|
157
|
+
const scheduleRenewal = () => {
|
|
158
|
+
if (stopped || leaseLost) return;
|
|
159
|
+
leaseTimer = setTimeout(() => {
|
|
160
|
+
leaseTimer = void 0;
|
|
161
|
+
const renewal = (async () => {
|
|
162
|
+
try {
|
|
163
|
+
if (!await options.store.renewLease(scope, leaseOperationId, now(), leaseMs)) {
|
|
164
|
+
leaseLost = new Error("workspace child-key lease was lost during reconciliation");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
scheduleRenewal();
|
|
168
|
+
} catch (error) {
|
|
169
|
+
leaseLost = error instanceof Error ? error : new Error("workspace child-key lease renewal failed");
|
|
170
|
+
}
|
|
171
|
+
})();
|
|
172
|
+
renewalPromise = renewal;
|
|
173
|
+
void renewal.finally(() => {
|
|
174
|
+
if (renewalPromise === renewal) renewalPromise = void 0;
|
|
175
|
+
}).catch(() => void 0);
|
|
176
|
+
}, leaseRenewIntervalMs);
|
|
177
|
+
};
|
|
178
|
+
scheduleRenewal();
|
|
179
|
+
let workError;
|
|
180
|
+
let leaseLossCleanupError;
|
|
181
|
+
try {
|
|
182
|
+
const result = await work();
|
|
183
|
+
if (leaseLost) throw leaseLost;
|
|
184
|
+
return result;
|
|
185
|
+
} catch (error) {
|
|
186
|
+
workError = error;
|
|
187
|
+
throw error;
|
|
188
|
+
} finally {
|
|
189
|
+
stopped = true;
|
|
190
|
+
if (leaseTimer) clearTimeout(leaseTimer);
|
|
191
|
+
if (renewalPromise) await renewalPromise;
|
|
192
|
+
if (leaseLost && onLeaseLost) {
|
|
193
|
+
try {
|
|
194
|
+
await onLeaseLost();
|
|
195
|
+
} catch (error) {
|
|
196
|
+
leaseLossCleanupError = error;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
await options.store.releaseLease(scope, leaseOperationId);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (workError === void 0) throw error;
|
|
203
|
+
}
|
|
204
|
+
if (workError === void 0 && leaseLossCleanupError) {
|
|
205
|
+
throw new Error("workspace child-key lease was lost and cleanup failed", { cause: leaseLossCleanupError });
|
|
206
|
+
}
|
|
207
|
+
if (workError === void 0 && leaseLost) throw leaseLost;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function retryDelay(attempts) {
|
|
211
|
+
return Math.min(revocationRetryMaxMs, revocationRetryBaseMs * 2 ** Math.min(Math.max(attempts - 1, 0), 5));
|
|
212
|
+
}
|
|
213
|
+
async function markPending(id, error, incrementAttempts) {
|
|
214
|
+
const delay = incrementAttempts ? retryDelay(1) : 0;
|
|
215
|
+
await options.store.markRevocationPending({
|
|
216
|
+
id,
|
|
217
|
+
error: errorMessage(error, "remote key cleanup failed"),
|
|
218
|
+
nextAttemptAt: new Date(now().getTime() + delay),
|
|
219
|
+
incrementAttempts
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
async function revokePending(row, identity) {
|
|
223
|
+
if (!row.keyId || isProvisioningId(row.keyId)) return cleanupProvisioning(row, identity);
|
|
224
|
+
const active = await options.store.getActive({
|
|
225
|
+
workspaceId: row.workspaceId,
|
|
226
|
+
ownerUserId: row.ownerUserId,
|
|
227
|
+
product: row.product
|
|
228
|
+
});
|
|
229
|
+
if (active && active.id !== row.id && active.keyId === row.keyId) {
|
|
230
|
+
await options.store.markRevoked(row.id, now());
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
await options.provisioner.revokeKey(row.keyId);
|
|
235
|
+
await options.store.markRevoked(row.id, now());
|
|
236
|
+
return true;
|
|
237
|
+
} catch (error) {
|
|
238
|
+
if (isRemoteMissing(error)) {
|
|
239
|
+
await options.store.markRevoked(row.id, now());
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
await options.store.markRevocationPending({
|
|
243
|
+
id: row.id,
|
|
244
|
+
error: errorMessage(error, "remote key revocation failed"),
|
|
245
|
+
nextAttemptAt: new Date(now().getTime() + retryDelay(row.revocationAttempts + 1)),
|
|
246
|
+
incrementAttempts: true
|
|
247
|
+
});
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
async function cleanupProvisioning(row, identity) {
|
|
252
|
+
let candidates;
|
|
253
|
+
if (row.keyId && !isProvisioningId(row.keyId)) {
|
|
254
|
+
candidates = [{ id: row.keyId }];
|
|
255
|
+
} else {
|
|
256
|
+
const name = row.name?.trim() || options.legacyNameForRecord?.(row)?.trim();
|
|
257
|
+
if (!name) {
|
|
258
|
+
await options.store.markRevocationPending({
|
|
259
|
+
id: row.id,
|
|
260
|
+
error: "provisioning row has no recoverable remote name; migrate the row or configure legacyNameForRecord",
|
|
261
|
+
nextAttemptAt: now(),
|
|
262
|
+
incrementAttempts: false
|
|
263
|
+
});
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
candidates = await options.provisioner.findCreatedKeys({
|
|
267
|
+
name,
|
|
268
|
+
product: row.product,
|
|
269
|
+
sourceKeyId: row.sourceKeyId
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
const scope = { workspaceId: row.workspaceId, ownerUserId: row.ownerUserId, product: row.product };
|
|
273
|
+
const active = await options.store.getActive(scope);
|
|
274
|
+
const activeMatches = active && (identity ? sameIdentity(active, identity, product) : sameRowIdentity(active, row));
|
|
275
|
+
const activeKeyId = activeMatches ? active.keyId : null;
|
|
276
|
+
const toRevoke = candidates.map((candidate) => candidate.id.trim()).filter((candidateId) => candidateId && candidateId !== activeKeyId);
|
|
277
|
+
for (const keyId of toRevoke) {
|
|
278
|
+
try {
|
|
279
|
+
await options.provisioner.revokeKey(keyId);
|
|
280
|
+
} catch (error) {
|
|
281
|
+
if (isRemoteMissing(error)) continue;
|
|
282
|
+
await options.store.markRevocationPending({
|
|
283
|
+
id: row.id,
|
|
284
|
+
error: errorMessage(error, "remote provisioning cleanup failed"),
|
|
285
|
+
nextAttemptAt: new Date(now().getTime() + retryDelay(row.revocationAttempts + 1)),
|
|
286
|
+
incrementAttempts: true
|
|
287
|
+
});
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (toRevoke.length > 0 || candidates.length === 0 || activeKeyId !== null) {
|
|
292
|
+
await options.store.markRevoked(row.id, now());
|
|
293
|
+
} else {
|
|
294
|
+
await options.store.markOrphaned(row.id, "no unowned remote child matched the durable provisioning record");
|
|
295
|
+
}
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
async function reconcileProvisioning(identity) {
|
|
299
|
+
const scope = scopeFor(identity, product);
|
|
300
|
+
const rows = await options.store.listProvisioning(scope);
|
|
301
|
+
for (const row of rows) {
|
|
302
|
+
if (!await cleanupProvisioning(row, identity)) {
|
|
303
|
+
throw new Error("a previous workspace child-key cleanup is pending; issuance is blocked until it completes");
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
async function retryPendingRevocationsUnlocked(scopeFilter, activeIdentity) {
|
|
308
|
+
const rows = await options.store.listPendingRevocations({
|
|
309
|
+
product,
|
|
310
|
+
workspaceId: scopeFilter?.workspaceId,
|
|
311
|
+
ownerUserId: scopeFilter?.ownerUserId,
|
|
312
|
+
now: now(),
|
|
313
|
+
limit: PENDING_REVOCATION_BATCH_SIZE
|
|
314
|
+
});
|
|
315
|
+
let completed = 0;
|
|
316
|
+
let pending = 0;
|
|
317
|
+
for (const row of rows) {
|
|
318
|
+
if (row.product !== product) continue;
|
|
319
|
+
if (scopeFilter && (row.workspaceId !== scopeFilter.workspaceId || row.ownerUserId !== scopeFilter.ownerUserId)) continue;
|
|
320
|
+
if (await revokePending(row, activeIdentity)) completed += 1;
|
|
321
|
+
else pending += 1;
|
|
322
|
+
}
|
|
323
|
+
return { completed, pending };
|
|
324
|
+
}
|
|
325
|
+
async function assertNoPendingRevocations(identity) {
|
|
326
|
+
const rows = await options.store.listPendingRevocations({
|
|
327
|
+
product,
|
|
328
|
+
workspaceId: identity.workspaceId,
|
|
329
|
+
ownerUserId: identity.ownerUserId,
|
|
330
|
+
now: now(),
|
|
331
|
+
limit: PENDING_REVOCATION_BATCH_SIZE,
|
|
332
|
+
includeFuture: true
|
|
333
|
+
});
|
|
334
|
+
if (rows.some((row) => row.product === product && row.workspaceId === identity.workspaceId && row.ownerUserId === identity.ownerUserId)) {
|
|
335
|
+
throw new Error("a previous workspace child-key cleanup is pending; issuance is blocked until it completes");
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
async function retryPendingRevocations(scopeInput) {
|
|
339
|
+
if (scopeInput) {
|
|
340
|
+
const workspaceId = scopeInput.workspaceId.trim();
|
|
341
|
+
const ownerUserId = scopeInput.ownerUserId.trim();
|
|
342
|
+
if (!workspaceId || !ownerUserId) throw new Error("workspace child key retry scope is incomplete");
|
|
343
|
+
const scope = scopeKey({ workspaceId, ownerUserId, product });
|
|
344
|
+
return withLocalLock(scope, () => withDurableLease(scope, async () => (await retryPendingRevocationsUnlocked({ workspaceId, ownerUserId })).completed));
|
|
345
|
+
}
|
|
346
|
+
const rows = await options.store.listPendingRevocations({ product, now: now(), limit: PENDING_REVOCATION_BATCH_SIZE });
|
|
347
|
+
let completed = 0;
|
|
348
|
+
const scopes = /* @__PURE__ */ new Set();
|
|
349
|
+
for (const row of rows) {
|
|
350
|
+
if (row.product !== product) continue;
|
|
351
|
+
const scope = scopeKey({ workspaceId: row.workspaceId, ownerUserId: row.ownerUserId, product });
|
|
352
|
+
if (scopes.has(scope)) continue;
|
|
353
|
+
scopes.add(scope);
|
|
354
|
+
completed += await withLocalLock(scope, () => withDurableLease(scope, async () => (await retryPendingRevocationsUnlocked({
|
|
355
|
+
workspaceId: row.workspaceId,
|
|
356
|
+
ownerUserId: row.ownerUserId
|
|
357
|
+
})).completed));
|
|
358
|
+
}
|
|
359
|
+
return completed;
|
|
360
|
+
}
|
|
361
|
+
async function readActive(identity) {
|
|
362
|
+
const row = await options.store.getActive(scopeFor(identity, product));
|
|
363
|
+
if (!row) return null;
|
|
364
|
+
if (!sameIdentity(row, identity, product)) {
|
|
365
|
+
await options.store.markRevocationPending({
|
|
366
|
+
id: row.id,
|
|
367
|
+
error: "active child key identity does not match the authenticated owner and source",
|
|
368
|
+
nextAttemptAt: now(),
|
|
369
|
+
incrementAttempts: false
|
|
370
|
+
});
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
let remote;
|
|
374
|
+
try {
|
|
375
|
+
remote = await options.provisioner.getKey(row.keyId);
|
|
376
|
+
} catch (error) {
|
|
377
|
+
if (isRemoteMissing(error)) {
|
|
378
|
+
await options.store.markRevoked(row.id, now());
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
throw error;
|
|
382
|
+
}
|
|
383
|
+
const usage = usageFromRemote(row, remote, now());
|
|
384
|
+
if (usage.exhausted) {
|
|
385
|
+
await options.store.markRevocationPending({
|
|
386
|
+
id: row.id,
|
|
387
|
+
error: "active child key is expired or exhausted",
|
|
388
|
+
nextAttemptAt: now(),
|
|
389
|
+
incrementAttempts: false
|
|
390
|
+
});
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
return { row, usage };
|
|
394
|
+
}
|
|
395
|
+
async function mint(identity, budgetUsd, onRemoteKey) {
|
|
396
|
+
await assertWorkspaceKeyCryptoUsable(options.crypto);
|
|
397
|
+
const mintOperationId = operationId();
|
|
398
|
+
const createdAt = now();
|
|
399
|
+
const expiresAt = new Date(createdAt.getTime() + keyLifetimeMs);
|
|
400
|
+
const name = (options.nameForIdentity?.(identity, mintOperationId) ?? defaultKeyName(identity, product, mintOperationId)).trim();
|
|
401
|
+
if (!name) throw new Error("workspace child key remote name is required");
|
|
402
|
+
const rowId = operationId();
|
|
403
|
+
const provisioningRow = {
|
|
404
|
+
id: rowId,
|
|
405
|
+
workspaceId: identity.workspaceId,
|
|
406
|
+
ownerUserId: identity.ownerUserId,
|
|
407
|
+
product,
|
|
408
|
+
platformUserId: identity.platformUserId,
|
|
409
|
+
sourceKeyId: sourceKeyId(identity),
|
|
410
|
+
sourceKeyFingerprint: identity.sourceKeyFingerprint,
|
|
411
|
+
name,
|
|
412
|
+
keyId: `provisioning:${mintOperationId}`,
|
|
413
|
+
keyEncrypted: "",
|
|
414
|
+
budgetUsd,
|
|
415
|
+
expiresAt,
|
|
416
|
+
status: "provisioning",
|
|
417
|
+
revocationAttempts: 0,
|
|
418
|
+
nextRevocationAt: null,
|
|
419
|
+
lastRevocationError: null,
|
|
420
|
+
createdAt
|
|
421
|
+
};
|
|
422
|
+
await options.store.insertProvisioning(provisioningRow);
|
|
423
|
+
let created;
|
|
424
|
+
try {
|
|
425
|
+
created = await options.provisioner.createKey({
|
|
426
|
+
name,
|
|
427
|
+
product,
|
|
428
|
+
budgetUsd,
|
|
429
|
+
expiresAt: expiresAt.toISOString()
|
|
430
|
+
});
|
|
431
|
+
} catch (error) {
|
|
432
|
+
try {
|
|
433
|
+
await markPending(rowId, error, false);
|
|
434
|
+
} catch {
|
|
435
|
+
}
|
|
436
|
+
try {
|
|
437
|
+
await cleanupProvisioning({ ...provisioningRow, status: "revocation_pending" }, identity);
|
|
438
|
+
} catch (cleanupError) {
|
|
439
|
+
throw new Error("remote create failed and its cleanup could not be completed", { cause: cleanupError });
|
|
440
|
+
}
|
|
441
|
+
throw error;
|
|
442
|
+
}
|
|
443
|
+
const remoteId = created.id?.trim() ?? "";
|
|
444
|
+
const secret = created.key?.trim() ?? "";
|
|
445
|
+
if (!remoteId || !secret) {
|
|
446
|
+
if (remoteId) {
|
|
447
|
+
try {
|
|
448
|
+
await options.store.markProvisioningRemote({ id: rowId, keyId: remoteId });
|
|
449
|
+
} catch {
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
try {
|
|
453
|
+
await markPending(rowId, new Error("remote create returned no usable child key"), false);
|
|
454
|
+
} catch {
|
|
455
|
+
}
|
|
456
|
+
const pendingRow = { ...provisioningRow, keyId: remoteId || provisioningRow.keyId, status: "revocation_pending" };
|
|
457
|
+
await revokePending(pendingRow, identity);
|
|
458
|
+
throw new Error("remote create returned no usable child key");
|
|
459
|
+
}
|
|
460
|
+
onRemoteKey?.(provisioningRow, remoteId);
|
|
461
|
+
try {
|
|
462
|
+
await options.store.markProvisioningRemote({ id: rowId, keyId: remoteId });
|
|
463
|
+
const keyEncrypted = await options.crypto.encrypt(secret);
|
|
464
|
+
const remoteExpiresAt = created.expiresAt?.trim();
|
|
465
|
+
const activeExpiresAt = remoteExpiresAt ? new Date(remoteExpiresAt) : expiresAt;
|
|
466
|
+
if (!Number.isFinite(activeExpiresAt.getTime())) throw new Error("remote child key expiry is malformed");
|
|
467
|
+
const activeBudgetUsd = created.budgetUsd ?? budgetUsd;
|
|
468
|
+
if (!Number.isFinite(activeBudgetUsd) || activeBudgetUsd < 0) throw new Error("remote child key budget is malformed");
|
|
469
|
+
await options.store.markActive({
|
|
470
|
+
id: rowId,
|
|
471
|
+
keyId: remoteId,
|
|
472
|
+
keyEncrypted,
|
|
473
|
+
expiresAt: activeExpiresAt,
|
|
474
|
+
budgetUsd: activeBudgetUsd
|
|
475
|
+
});
|
|
476
|
+
} catch (error) {
|
|
477
|
+
try {
|
|
478
|
+
await markPending(rowId, error, false);
|
|
479
|
+
} catch {
|
|
480
|
+
}
|
|
481
|
+
await revokePending({ ...provisioningRow, keyId: remoteId, status: "revocation_pending" }, identity);
|
|
482
|
+
throw error;
|
|
483
|
+
}
|
|
484
|
+
const row = await options.store.getActive(scopeFor(identity, product));
|
|
485
|
+
if (!row || row.keyId !== remoteId || !sameIdentity(row, identity, product)) {
|
|
486
|
+
const error = new Error("workspace child key was not persisted as active");
|
|
487
|
+
try {
|
|
488
|
+
await options.store.markRevocationPending({
|
|
489
|
+
id: rowId,
|
|
490
|
+
error: error.message,
|
|
491
|
+
nextAttemptAt: now(),
|
|
492
|
+
incrementAttempts: false
|
|
493
|
+
});
|
|
494
|
+
} catch {
|
|
495
|
+
}
|
|
496
|
+
await revokePending({ ...provisioningRow, keyId: remoteId, status: "revocation_pending" }, identity);
|
|
497
|
+
throw error;
|
|
498
|
+
}
|
|
499
|
+
const usage = usageFromRemote(row, {
|
|
500
|
+
budgetUsd: created.budgetUsd,
|
|
501
|
+
budgetSpent: 0,
|
|
502
|
+
expiresAt: created.expiresAt ?? expiresAt.toISOString()
|
|
503
|
+
}, now());
|
|
504
|
+
return { key: secret, usage, refreshed: true };
|
|
505
|
+
}
|
|
506
|
+
async function ensureKey(identityInput, keyOptions) {
|
|
507
|
+
const identity = normalizeIdentity(identityInput);
|
|
508
|
+
const budgetUsd = keyOptions?.budgetUsd ?? defaultBudgetUsd;
|
|
509
|
+
requirePositive(budgetUsd, "budgetUsd");
|
|
510
|
+
const scope = scopeKey(scopeFor(identity, product));
|
|
511
|
+
let mintedRemote = null;
|
|
512
|
+
return withLocalLock(scope, () => withDurableLease(scope, async () => {
|
|
513
|
+
const initialCleanup = await retryPendingRevocationsUnlocked(identity, identity);
|
|
514
|
+
if (initialCleanup.pending > 0) throw new Error("a previous workspace child-key cleanup is pending; issuance is blocked until it completes");
|
|
515
|
+
await reconcileProvisioning(identity);
|
|
516
|
+
const active = await readActive(identity);
|
|
517
|
+
if (active) {
|
|
518
|
+
const key = await options.crypto.decrypt(active.row.keyEncrypted);
|
|
519
|
+
if (!key.trim()) throw new Error("workspace child key secret is empty");
|
|
520
|
+
return { key, usage: active.usage, refreshed: false };
|
|
521
|
+
}
|
|
522
|
+
const finalCleanup = await retryPendingRevocationsUnlocked(identity, identity);
|
|
523
|
+
if (finalCleanup.pending > 0) throw new Error("a previous workspace child-key cleanup is pending; issuance is blocked until it completes");
|
|
524
|
+
await assertNoPendingRevocations(identity);
|
|
525
|
+
return mint(identity, budgetUsd, (row, remoteId) => {
|
|
526
|
+
mintedRemote = { row, remoteId };
|
|
527
|
+
});
|
|
528
|
+
}, async () => {
|
|
529
|
+
const remote = mintedRemote;
|
|
530
|
+
if (!remote) return;
|
|
531
|
+
const cleaned = await revokePending({
|
|
532
|
+
...remote.row,
|
|
533
|
+
keyId: remote.remoteId,
|
|
534
|
+
status: "revocation_pending"
|
|
535
|
+
}, identity);
|
|
536
|
+
if (!cleaned) throw new Error("lease-loss child-key cleanup is pending");
|
|
537
|
+
}));
|
|
538
|
+
}
|
|
539
|
+
async function getUsage(identityInput) {
|
|
540
|
+
const identity = normalizeIdentity(identityInput);
|
|
541
|
+
const scope = scopeKey(scopeFor(identity, product));
|
|
542
|
+
return withLocalLock(scope, () => withDurableLease(scope, async () => {
|
|
543
|
+
const active = await readActive(identity);
|
|
544
|
+
return active?.usage ?? null;
|
|
545
|
+
}));
|
|
546
|
+
}
|
|
547
|
+
return { ensureKey, getUsage, retryPendingRevocations };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// src/billing/index.ts
|
|
551
|
+
function createTcloudKeyProvisioner(client) {
|
|
552
|
+
return {
|
|
553
|
+
createKey: async (input) => {
|
|
554
|
+
const created = await client.createKey(input);
|
|
555
|
+
return { id: created.id, key: created.key };
|
|
556
|
+
},
|
|
557
|
+
revokeKey: (keyId) => client.revokeKey(keyId),
|
|
558
|
+
getKey: async (keyId) => {
|
|
559
|
+
const info = await client.getKey(keyId);
|
|
560
|
+
return {
|
|
561
|
+
budgetUsd: info.budgetUsd ?? void 0,
|
|
562
|
+
budgetSpent: info.budgetSpent ?? void 0,
|
|
563
|
+
expiresAt: info.expiresAt ?? null
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
function nextPeriodEnd(now) {
|
|
569
|
+
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1, 0, 0, 0, 0));
|
|
570
|
+
}
|
|
571
|
+
function createPlatformBalanceManager(opts) {
|
|
572
|
+
const { client, planLimits, freePlan, productSlug } = opts;
|
|
573
|
+
const getState = async (userId) => {
|
|
574
|
+
const identity = await client.resolveIdentity(userId);
|
|
575
|
+
if (!identity || !identity.apiKey) {
|
|
576
|
+
const limits2 = planLimits[freePlan];
|
|
577
|
+
return {
|
|
578
|
+
platformUserId: identity?.platformUserId ?? null,
|
|
579
|
+
plan: freePlan,
|
|
580
|
+
monthlyBalanceUsd: limits2.monthlyBalanceUsd,
|
|
581
|
+
remainingBalanceUsd: 0,
|
|
582
|
+
lifetimeSpentUsd: 0,
|
|
583
|
+
concurrency: limits2.concurrency,
|
|
584
|
+
overageAllowed: limits2.overageAllowed
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
const [plan, balance] = await Promise.all([client.getPlan(identity.apiKey), client.getBalance(identity.apiKey)]);
|
|
588
|
+
const limits = planLimits[plan];
|
|
589
|
+
return {
|
|
590
|
+
platformUserId: identity.platformUserId,
|
|
591
|
+
plan,
|
|
592
|
+
monthlyBalanceUsd: limits.monthlyBalanceUsd,
|
|
593
|
+
remainingBalanceUsd: balance.balance,
|
|
594
|
+
lifetimeSpentUsd: balance.lifetimeSpent,
|
|
595
|
+
concurrency: limits.concurrency,
|
|
596
|
+
overageAllowed: limits.overageAllowed
|
|
597
|
+
};
|
|
598
|
+
};
|
|
599
|
+
const canStartBillableTurn = async (userId) => {
|
|
600
|
+
const state = await getState(userId);
|
|
601
|
+
if (!state.platformUserId) return { allowed: false, state };
|
|
602
|
+
const allowed = state.overageAllowed || state.remainingBalanceUsd > 0;
|
|
603
|
+
return { allowed, state };
|
|
604
|
+
};
|
|
605
|
+
const deduct = async (userId, params) => {
|
|
606
|
+
const identity = await client.resolveIdentity(userId);
|
|
607
|
+
if (!identity) throw new Error("Shared billing requires a platform-linked user");
|
|
608
|
+
await client.deduct({
|
|
609
|
+
platformUserId: identity.platformUserId,
|
|
610
|
+
amountUsd: params.amountUsd,
|
|
611
|
+
type: params.type,
|
|
612
|
+
description: params.description,
|
|
613
|
+
referenceId: params.referenceId
|
|
614
|
+
});
|
|
615
|
+
};
|
|
616
|
+
const getProductUsage = async (userId) => {
|
|
617
|
+
const identity = await client.resolveIdentity(userId);
|
|
618
|
+
if (!identity?.apiKey) return { spentUsd: 0, transactionCount: 0 };
|
|
619
|
+
const rows = await client.getUsageByProduct(identity.apiKey);
|
|
620
|
+
const product = rows.find((row) => row.product === productSlug);
|
|
621
|
+
return { spentUsd: product?.totalSpent ?? 0, transactionCount: product?.count ?? 0 };
|
|
622
|
+
};
|
|
623
|
+
return { getState, canStartBillableTurn, deduct, getProductUsage };
|
|
624
|
+
}
|
|
625
|
+
function createWorkspaceKeyManager(opts) {
|
|
626
|
+
const clock = opts.now ?? (() => /* @__PURE__ */ new Date());
|
|
627
|
+
const product = opts.product ?? "router";
|
|
628
|
+
const getUsage = async (workspaceId) => {
|
|
629
|
+
const active = await opts.store.getActive(workspaceId);
|
|
630
|
+
if (!active) return null;
|
|
631
|
+
const info = await opts.provisioner.getKey(active.keyId);
|
|
632
|
+
const budgetUsd = info.budgetUsd ?? active.budgetUsd;
|
|
633
|
+
const budgetSpent = info.budgetSpent ?? 0;
|
|
634
|
+
const budgetRemaining = Math.max(0, budgetUsd - budgetSpent);
|
|
635
|
+
return {
|
|
636
|
+
keyId: active.keyId,
|
|
637
|
+
budgetUsd,
|
|
638
|
+
budgetSpent,
|
|
639
|
+
budgetRemaining,
|
|
640
|
+
expiresAt: info.expiresAt ?? (active.expiresAt ? active.expiresAt.toISOString() : null),
|
|
641
|
+
exhausted: budgetRemaining <= 0
|
|
642
|
+
};
|
|
643
|
+
};
|
|
644
|
+
const rotateKey = async (workspaceId, ropts) => {
|
|
645
|
+
const now = clock();
|
|
646
|
+
const allowance = ropts?.budgetUsd ?? opts.defaultBudgetUsd;
|
|
647
|
+
let budgetUsd = allowance;
|
|
648
|
+
if (ropts?.rollover) {
|
|
649
|
+
const prior = await getUsage(workspaceId).catch(() => null);
|
|
650
|
+
budgetUsd = allowance + (prior?.budgetRemaining ?? 0);
|
|
651
|
+
if (ropts.rolloverCapUsd != null) budgetUsd = Math.min(budgetUsd, ropts.rolloverCapUsd);
|
|
652
|
+
}
|
|
653
|
+
await assertWorkspaceKeyCryptoUsable(opts.crypto);
|
|
654
|
+
const expiresAt = nextPeriodEnd(now);
|
|
655
|
+
const created = await opts.provisioner.createKey({ name: `ws:${workspaceId}`, product, budgetUsd, expiresAt: expiresAt.toISOString() });
|
|
656
|
+
if (!created.key || !created.id) throw new Error("tcloud createKey returned no key");
|
|
657
|
+
const mintedKeyId = created.id;
|
|
658
|
+
let keyEncrypted;
|
|
659
|
+
let priors;
|
|
660
|
+
try {
|
|
661
|
+
keyEncrypted = await opts.crypto.encrypt(created.key);
|
|
662
|
+
priors = await opts.store.listActive(workspaceId);
|
|
663
|
+
await opts.store.insert({ workspaceId, keyId: mintedKeyId, keyEncrypted, budgetUsd, expiresAt });
|
|
664
|
+
} catch (err) {
|
|
665
|
+
try {
|
|
666
|
+
await opts.provisioner.revokeKey(mintedKeyId);
|
|
667
|
+
} catch (revokeErr) {
|
|
668
|
+
console.error(
|
|
669
|
+
`[workspace-key-manager] FAILED to revoke orphaned child key ${mintedKeyId} for workspace ${workspaceId} after a post-mint failure \u2014 it now leaks parent-key budget. Revoke error:`,
|
|
670
|
+
revokeErr
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
throw err;
|
|
674
|
+
}
|
|
675
|
+
for (const p of priors) {
|
|
676
|
+
await opts.store.markRevoked(p.id, now);
|
|
677
|
+
try {
|
|
678
|
+
await opts.provisioner.revokeKey(p.keyId);
|
|
679
|
+
} catch {
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
return created.key;
|
|
683
|
+
};
|
|
684
|
+
const ensureKey = async (workspaceId, eopts) => {
|
|
685
|
+
const now = clock();
|
|
686
|
+
const active = await opts.store.getActive(workspaceId);
|
|
687
|
+
if (active && (!active.expiresAt || active.expiresAt.getTime() > now.getTime())) {
|
|
688
|
+
return opts.crypto.decrypt(active.keyEncrypted);
|
|
689
|
+
}
|
|
690
|
+
return rotateKey(workspaceId, { budgetUsd: eopts?.budgetUsd });
|
|
691
|
+
};
|
|
692
|
+
return { ensureKey, rotateKey, getUsage };
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
export {
|
|
696
|
+
createIdentityBoundWorkspaceKeyManager,
|
|
697
|
+
createTcloudKeyProvisioner,
|
|
698
|
+
createPlatformBalanceManager,
|
|
699
|
+
createWorkspaceKeyManager
|
|
700
|
+
};
|
|
701
|
+
//# sourceMappingURL=chunk-TS2ZIB53.js.map
|