openvibers/openvibe.ai Commits

C2 first slice: expiring keys, idempotency and routing previews (#382)

OP OpenVibers committed 4 days ago · 3fdbe94742 · 17 files, +874 −14

* Reapply C2 on main with expiry and owner-scoped replay guards

* Cover request claims in account data and retain main's speed tests

Changes

ddiff --git a/docs/c2/status.md b/docs/c2/status.md
nnew file mode 100644
iindex 0000000..14e7f34
---- /dev/null
++++ b/docs/c2/status.md
@@@ -0,0 +1,16 @@
++# C2 implementation status
++
++This is an independently reviewable first slice, not a completed parity release. It builds on current main, including the merged AI #379 platform and its bounded holds.
++
++Implemented:
++
++- Optional expires_at in the private key provisioning API and metadata. Expiry is checked at resolution and again at a spend reservation; an already admitted request may finish.
++- Idempotency-Key forwarding for OpenAI Chat Completions and Anthropic Messages. The existing run store enforces ownership and rejects different inputs under one key. Replayed responses carry x-openvibe-replayed: 1. Opaque requests claim (owner, Idempotency-Key) in the database before reserving; replays reuse the original ceiling and stored run without another hold or usage row. A concurrent request still being admitted gets 409 idempotency.in_progress and can retry. Claims with a stored run survive restarts; an interrupted admission without a stored run remains refused rather than risking another hold.
++- Authenticated, no-inference POST /v1/routing/preview: validates OpenRouter-shaped provider constraints, lists excluded offers, fails closed on unknown price/privacy/evidence, and never returns secrets or hidden/restricted/BYOK providers. It is explicitly not an execution policy.
++- Authenticated POST /v1/presets/import/preview: validates an explicit exported JSON subset without fetching an account, storing a preset or executing a request. Success and error responses never echo submitted values; the importer returns only a validation summary. Model-level hidden/restricted/BYOK metadata is filtered as well as provider-level access.
++
++Outstanding: compatible legacy Completions/Embeddings/Images endpoints, full routing execution and model variants/fallbacks, persistent presets, generation lookup, web grounding/citations, live Report ratings, per-provider BYOK policy, tasks/choices and agent workflow step streams. The existing engine's features do not establish parity for those public endpoints.
++
++Contracts and SDK release changes are outside this first slice. Task-choice OpenAPI work remains in its separate job; this change only edits Exchange key schema fields.
++
++Migration 0054 is expand-only, after the applied 0052 platform and 0053 Link credential migration; neither applied migration is changed. No production data, provider execution, accounts or money were involved. Full-suite and focused results are recorded in the PR.
ddiff --git a/migrations/0054_exchange_key_expiry.sql b/migrations/0054_exchange_key_expiry.sql
nnew file mode 100644
iindex 0000000..48a5c40
---- /dev/null
++++ b/migrations/0054_exchange_key_expiry.sql
@@@ -0,0 +1,11 @@
++-- phase: expand
++-- Optional expiry; existing keys retain their current lifetime.
++ALTER TABLE exchange_api_keys ADD COLUMN IF NOT EXISTS expires_at text;
++
++-- Claim a logical request before taking its hold. Store only a fingerprint and the
++-- admitted ceiling; the durable runs table remains the authority for the result.
++CREATE TABLE IF NOT EXISTS exchange_key_requests (
++ owner text NOT NULL, idempotency_key text NOT NULL, request_hash text NOT NULL,
++ max_cost_usd double precision,
++ PRIMARY KEY(owner, idempotency_key)
++);
ddiff --git a/server/account-data.js b/server/account-data.js
iindex fdd5785..674b190 100644
---- a/server/account-data.js
++++ b/server/account-data.js
@@@ -26,7 +26,8 @@ const PERSON_RUN = `on_behalf_of IS NOT NULL AND on_behalf_of::jsonb ->> 'type'
const RUN_COLUMNS = 'id, workflow_key, workflow_version, template_key, template_version, route_key, status, model_key, provider_key, input, output, citations_count, error_code, error_detail, tokens_in, tokens_out, cost_usd, created_at, started_at, finished_at';
const TABLES = [
-- { table: 'exchange_api_keys', subject: 'owner', file: 'exchange-keys.json', columns: ['id','name','prefix','monthly_cap_usd','allowed_models','created_at','revoked_at'], erase: { keep: 'extraErase deletes key usage and cap rows before deleting keys' } },
++ { table: 'exchange_api_keys', subject: 'owner', file: 'exchange-keys.json', columns: ['id','name','prefix','monthly_cap_usd','allowed_models','created_at','revoked_at','expires_at'], erase: { keep: 'extraErase deletes key usage and cap rows before deleting keys' } },
++ { table: 'exchange_key_requests', subject: 'owner', file: 'exchange-key-requests.json', columns: ['idempotency_key','request_hash','max_cost_usd'], erase: 'delete' },
{ table: 'exchange_supply_settings', subject: 'actor', file: null, erase: { keep: 'owner pricing decisions are the operator audit trail' } },
{ table: 'console_sessions', subject: 'subject', file: 'console-sessions.json', columns: ['username', 'created_at', 'expires_at', 'revoked_at'], order: 'created_at' },
{ table: 'person_sessions', subject: 'subject', file: 'person-sessions.json', columns: ['username', 'created_at', 'expires_at', 'revoked_at'], order: 'created_at' },
ddiff --git a/server/api/messages.js b/server/api/messages.js
iindex f5546d9..d124682 100644
---- a/server/api/messages.js
++++ b/server/api/messages.js
@@@ -212,7 +212,7 @@ function messagesRouter({ runs, auth, config, registry, presets = null, receipts
}
}
}
-- let body = { workflow: 'ai.messages', input };
++ let body = { workflow: 'ai.messages', input, idempotency_key: req.get('idempotency-key') || undefined };
// The preset's rules ride the run into the router.
if (preset) body = { ...body, preset: { name: preset.name, scope: preset.scope, policy: preset.policy } };
if (preset && preset.policy.adult && b.policy == null) body = { ...body, policy: 'uncensored' };
@@@ -235,6 +235,7 @@ function messagesRouter({ runs, auth, config, registry, presets = null, receipts
const created = await runs.create(body, req.principal, { trace: req.ov && req.ov.traceId, requestId: req.ov && req.ov.requestId, abuse: req.abuse,
...(live ? { onDelta: live.event } : {}),
...(live && config.modelCatalog && config.modelCatalog.enabled ? { onRoute: (info) => live.route(info) } : {}) });
++ if (created.created === false) res.setHeader('x-openvibe-replayed', '1');
if (live) live.open(created.run);
run = await runs.explained(await runs.wait(created, Math.max(config.runs.maxWaitMs, 120000)));
} catch (err) {
ddiff --git a/server/api/openai.js b/server/api/openai.js
iindex 5de2d86..a303bfe 100644
---- a/server/api/openai.js
++++ b/server/api/openai.js
@@@ -201,7 +201,7 @@ function openaiRouter({ runs, auth, config, registry, presets = null, receipts =
if (maxTokens != null) input.max_tokens = Math.max(1, Math.min(Number(maxTokens) || 1, 16000));
// AI Exchange F: the caller's liability cap for the Balance hold (an OpenVibe extension, like x_openvibe).
if (b.max_cost_usd != null) input.max_cost_usd = b.max_cost_usd;
-- let body = { workflow: 'ai.complete', input };
++ let body = { workflow: 'ai.complete', input, idempotency_key: req.get('idempotency-key') || undefined };
// The preset's rules ride the run into the router.
if (preset) body = { ...body, preset: { name: preset.name, scope: preset.scope, policy: preset.policy } };
if (preset && preset.policy.adult && b.policy == null) body = { ...body, policy: 'uncensored' };
@@@ -238,6 +238,7 @@ function openaiRouter({ runs, auth, config, registry, presets = null, receipts =
const created = await runs.create(body, req.principal, { trace: req.ov && req.ov.traceId, requestId: req.ov && req.ov.requestId, abuse: req.abuse,
...(live ? { onDelta: live.delta } : {}),
...(live && config.modelCatalog && config.modelCatalog.enabled ? { onRoute: (info) => live.route(info) } : {}) });
++ if (created.created === false) res.setHeader('x-openvibe-replayed', '1');
if (live) live.open(created.run);
run = await runs.explained(await runs.wait(created, Math.max(config.runs.maxWaitMs, 120000)));
} catch (err) {
ddiff --git a/server/api/routing-preview.js b/server/api/routing-preview.js
nnew file mode 100644
iindex 0000000..34623ac
---- /dev/null
++++ b/server/api/routing-preview.js
@@@ -0,0 +1,107 @@
++'use strict';
++const express = require('express');
++const { CAPS } = require('../auth');
++const { policyFor, select } = require('../exchange/compat-routing');
++const { canonicalOf } = require('../exchange/catalog');
++const { sendError } = require('../util');
++function routingPreview({ auth, registry, log }) {
++ const r = express.Router();
++ r.post(
++ '/v1/routing/preview',
++ auth.appOrCap(CAPS.appRun, CAPS.runCreate),
++ async (req, res) => {
++ try {
++ const { model, variant, policy } = policyFor(req.body || {});
++ if (variant === 'online')
++ return res
++ .status(400)
++ .json({
++ error: {
++ code: 'input.invalid',
++ message:
++ 'Grounding is not part of a routing preview',
++ },
++ });
++ const providers = new Map(
++ (await registry.listProviders())
++ .filter(
++ (p) =>
++ p.status === 'active' &&
++ p.kind !== 'stub' &&
++ p.metadata?.hidden !== true &&
++ p.metadata?.synthetic !== true &&
++ !['CUSTOMER_BYOK', 'RESTRICTED'].includes(
++ p.provider_access,
++ ),
++ )
++ .map((p) => [p.key, p]),
++ );
++ const rows = (await registry.listModels()).filter(
++ (m) =>
++ m.status === 'active' && providers.has(m.provider_key),
++ ).filter(
++ (m) => m.metadata?.hidden !== true && m.metadata?.synthetic !== true &&
++ m.metadata?.restricted !== true && m.metadata?.byok !== true &&
++ !['CUSTOMER_BYOK', 'RESTRICTED'].includes(m.provider_access || m.metadata?.provider_access),
++ );
++ const candidates = rows
++ .filter(
++ (m) =>
++ model === 'openvibe/auto' ||
++ canonicalOf(m, providers.get(m.provider_key)) ===
++ model ||
++ m.metadata?.id === model,
++ )
++ .map((m) => ({
++ provider: m.provider_key,
++ model: m.model_key,
++ row: m,
++ }));
++ const answer = await select(candidates, policy, async (c) => {
++ const p = providers.get(c.provider),
++ m = c.row;
++ const privacy = p.metadata?.data_policy || {};
++ return {
++ priceIn: m.cost?.in_per_mtok,
++ priceOut: m.cost?.out_per_mtok,
++ training: privacy.training,
++ retention: privacy.retention,
++ };
++ });
++ // No prompt, credential, balance hold or upstream request is needed for this diagnostic.
++ return res
++ .set('Cache-Control', 'no-store')
++ .json({
++ executable: false,
++ notice: 'Catalogue-only preview; live availability, account gates and budgets are checked at execution. Quality and speed are excluded when no measured evidence is supplied.',
++ candidates: answer.allowed.map((c) => ({
++ provider: c.provider,
++ model: c.model,
++ })),
++ excluded: answer.excluded,
++ });
++ } catch (err) {
++ return sendError(res, err, req.ov, log);
++ }
++ },
++ );
++ r.post(
++ '/v1/presets/import/preview',
++ auth.appOrCap(CAPS.appRun, CAPS.runCreate),
++ (req, res) => {
++ try {
++ return res
++ .set('Cache-Control', 'no-store')
++ .json(
++ require('../exchange/import-preset').importPreset(
++ req.body,
++ ),
++ );
++ } catch (err) {
++ return sendError(res, err, req.ov, log);
++ }
++ },
++ );
++ return r;
++}
++module.exports = { routingPreview };
ddiff --git a/server/app.js b/server/app.js
iindex 3bfa9eb..24a7ebc 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -217,6 +217,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
app.use(runsRouter({ runs, registry, auth, config, log }));
app.use(actionsRouter({ runs, registry, auth, log }));
// The OpenAI-compatible endpoint (POST /v1/chat/completions, GET /v1/models): runs of ai.complete.
++ app.use(require('./api/routing-preview').routingPreview({ auth, registry, log }));
app.use(require('./api/openai').openaiRouter({ runs, auth, config, registry, presets, receipts, log }));
// The Anthropic-compatible endpoint (POST /v1/messages, GET /v1/models): runs of ai.messages (Codes C3).
app.use(require('./api/messages').messagesRouter({ runs, auth, config, registry, presets, receipts, log }));
ddiff --git a/server/exchange/compat-routing.js b/server/exchange/compat-routing.js
nnew file mode 100644
iindex 0000000..4c0d895
---- /dev/null
++++ b/server/exchange/compat-routing.js
@@@ -0,0 +1,158 @@
++'use strict';
++const { AiError } = require('../util');
++const sorts = ['price', 'throughput', 'latency', 'quality', 'value'];
++const fields = [
++ 'order',
++ 'only',
++ 'ignore',
++ 'allow_fallbacks',
++ 'sort',
++ 'max_price',
++ 'data_collection',
++ 'zdr',
++];
++const bad = (message) => {
++ throw new AiError(400, 'input.invalid', message);
++};
++function normalize(provider = {}) {
++ if (!provider || typeof provider !== 'object' || Array.isArray(provider))
++ bad('provider must be an object');
++ for (const key of Object.keys(provider))
++ if (!fields.includes(key))
++ bad('Unsupported provider parameter');
++ const out = {};
++ for (const key of ['order', 'only', 'ignore'])
++ if (provider[key] !== undefined) {
++ if (
++ !Array.isArray(provider[key]) ||
++ provider[key].length > 64 ||
++ provider[key].some(
++ (x) =>
++ typeof x !== 'string' ||
++ !/^[a-zA-Z0-9._-]{1,100}$/.test(x),
++ )
++ )
++ bad(`provider.${key} must be a list of provider ids`);
++ out[key] = [...new Set(provider[key])];
++ }
++ for (const key of ['allow_fallbacks', 'zdr'])
++ if (provider[key] !== undefined) {
++ if (typeof provider[key] !== 'boolean')
++ bad(`provider.${key} must be boolean`);
++ out[key] = provider[key];
++ }
++ if (provider.sort !== undefined) {
++ if (!sorts.includes(provider.sort)) bad('Unknown provider.sort');
++ out.sort = provider.sort;
++ }
++ if (provider.data_collection !== undefined) {
++ if (!['allow', 'deny'].includes(provider.data_collection))
++ bad('Unknown data_collection policy');
++ out.data_collection = provider.data_collection;
++ }
++ if (provider.max_price !== undefined) {
++ const p = provider.max_price;
++ if (
++ !p ||
++ typeof p !== 'object' ||
++ Array.isArray(p) ||
++ Object.keys(p).some((k) => !['prompt', 'completion'].includes(k))
++ )
++ bad(
++ 'max_price accepts prompt and completion in USD per million tokens',
++ );
++ out.max_price = {};
++ for (const [k, v] of Object.entries(p)) {
++ if (typeof v !== 'number' || !Number.isFinite(v) || v < 0)
++ bad('Prices must be finite nonnegative numbers');
++ out.max_price[k] = v;
++ }
++ }
++ return out;
++}
++function modelName(value) {
++ if (typeof value !== 'string' || !value || value.length > 256)
++ bad('model must be a model id');
++ const m = /:(free|floor|nitro|online)$/.exec(value);
++ return {
++ model: m ? value.slice(0, -m[0].length) : value,
++ variant: m?.[1] || null,
++ };
++}
++function policyFor(body) {
++ const { model, variant } = modelName(body.model || 'openvibe/auto');
++ const policy = normalize(body.provider);
++ if (variant === 'free') policy.max_price = { prompt: 0, completion: 0 };
++ if (variant === 'floor') policy.sort = 'price';
++ if (variant === 'nitro') policy.sort = 'throughput';
++ return { model, variant, policy };
++}
++const finite = (x) => typeof x === 'number' && Number.isFinite(x);
++async function select(list, policy, facts) {
++ const ranked = [],
++ excluded = [];
++ for (const [index, candidate] of list.entries()) {
++ const f = await facts(candidate),
++ id = candidate.provider;
++ let reason = null;
++ if (policy.only && !policy.only.includes(id)) reason = 'provider.only';
++ if (policy.ignore?.includes(id)) reason = 'provider.ignore';
++ if (policy.data_collection === 'deny' && f.training !== false)
++ reason = 'provider.data_collection';
++ if (policy.zdr && f.retention !== 'none') reason = 'provider.zdr';
++ for (const [key, fact] of [
++ ['prompt', 'priceIn'],
++ ['completion', 'priceOut'],
++ ])
++ if (
++ policy.max_price?.[key] !== undefined &&
++ (!finite(f[fact]) || f[fact] > policy.max_price[key])
++ )
++ reason = 'provider.max_price';
++ const cost =
++ finite(f.priceIn) && finite(f.priceOut)
++ ? f.priceIn + f.priceOut
++ : null;
++ let score = 0;
++ if (policy.sort === 'price') score = cost ?? Infinity;
++ if (policy.sort === 'latency') {
++ if (!finite(f.latency)) reason = 'provider.latency_unavailable';
++ score = finite(f.latency) ? f.latency : Infinity;
++ }
++ if (policy.sort === 'throughput') {
++ if (!finite(f.throughput))
++ reason = 'provider.throughput_unavailable';
++ score = finite(f.throughput) ? -f.throughput : Infinity;
++ }
++ if (policy.sort === 'quality' || policy.sort === 'value') {
++ if (!finite(f.quality)) reason = 'provider.rating_unavailable';
++ score =
++ policy.sort === 'quality'
++ ? -f.quality
++ : cost === null
++ ? Infinity
++ : -(f.quality / Math.max(cost, 0.000001));
++ }
++ if (reason)
++ excluded.push({ provider: id, model: candidate.model, reason });
++ else
++ ranked.push({
++ candidate,
++ score,
++ index,
++ order: policy.order?.includes(id)
++ ? policy.order.indexOf(id)
++ : Infinity,
++ });
++ }
++ ranked.sort(
++ (a, b) => a.order - b.order || a.score - b.score || a.index - b.index,
++ );
++ const allowed = ranked.map((x) => x.candidate);
++ return {
++ allowed:
++ policy.allow_fallbacks === false ? allowed.slice(0, 1) : allowed,
++ excluded,
++ };
++}
++module.exports = { normalize, policyFor, modelName, select };
ddiff --git a/server/exchange/import-preset.js b/server/exchange/import-preset.js
nnew file mode 100644
iindex 0000000..05b50b9
---- /dev/null
++++ b/server/exchange/import-preset.js
@@@ -0,0 +1,104 @@
++'use strict';
++const { normalize, modelName } = require('./compat-routing');
++const { AiError } = require('../util');
++const accepted = [
++ 'name',
++ 'model',
++ 'models',
++ 'system',
++ 'provider',
++ 'temperature',
++ 'top_p',
++ 'max_tokens',
++ 'seed',
++ 'response_format',
++];
++// Import preview is intentionally pure: no keys, URLs, provider calls or writes.
++function importPreset(source) {
++ const bad = (detail) => {
++ throw new AiError(400, 'input.invalid', detail);
++ };
++ if (!source || typeof source !== 'object' || Array.isArray(source))
++ bad('A preset must be a JSON object');
++ const unsupported = Object.keys(source).filter(
++ (k) => !accepted.includes(k),
++ );
++ if (unsupported.length)
++ bad('Preset contains unsupported fields; remove them before importing');
++ if (
++ typeof source.name !== 'string' ||
++ !/^[a-z0-9][a-z0-9_-]{0,63}$/.test(source.name)
++ )
++ bad('Preset name must be a lowercase slug');
++ const out = { name: source.name };
++ if (source.model !== undefined) {
++ modelName(source.model);
++ if (source.model.startsWith('@preset/'))
++ bad('Nested presets are not supported');
++ out.model = source.model;
++ }
++ if (source.models !== undefined) {
++ if (
++ !Array.isArray(source.models) ||
++ source.models.length < 1 ||
++ source.models.length > 8
++ )
++ bad('models must contain one to eight model ids');
++ source.models.forEach((x) => {
++ modelName(x);
++ if (x.startsWith('@preset/'))
++ bad('Nested presets are not supported');
++ });
++ out.models = [...new Set(source.models)];
++ }
++ if (!out.model && !out.models)
++ bad('A preset must name a model or fallbacks');
++ if (source.provider !== undefined)
++ out.provider = normalize(source.provider);
++ if (source.system !== undefined) {
++ if (typeof source.system !== 'string' || source.system.length > 32000)
++ bad('system must be at most 32000 characters');
++ out.system = source.system;
++ }
++ for (const [k, max] of [
++ ['temperature', 2],
++ ['top_p', 1],
++ ['max_tokens', 64000],
++ ])
++ if (source[k] !== undefined) {
++ if (
++ !Number.isFinite(source[k]) ||
++ source[k] < 0 ||
++ source[k] > max ||
++ (k === 'max_tokens' &&
++ (!Number.isInteger(source[k]) || source[k] < 1))
++ )
++ bad('Invalid sampling parameter');
++ out[k] = source[k];
++ }
++ if (source.seed !== undefined) {
++ if (!Number.isSafeInteger(source.seed))
++ bad('seed must be a safe integer');
++ out.seed = source.seed;
++ }
++ if (source.response_format !== undefined) {
++ if (
++ !source.response_format ||
++ typeof source.response_format !== 'object' ||
++ Array.isArray(source.response_format)
++ )
++ bad('response_format must be an object');
++ out.response_format = JSON.parse(
++ JSON.stringify(source.response_format),
++ );
++ }
++ return {
++ valid: true,
++ fields: Object.keys(out),
++ model_count: out.models ? out.models.length : 1,
++ warnings: [
++ 'Preview only: this does not save a preset or make a provider request.',
++ ],
++ };
++}
++module.exports = { importPreset };
ddiff --git a/server/openapi.js b/server/openapi.js
iindex 1f8a6e4..c063dad 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -475,6 +475,7 @@ const exchangeKey = {
id: str({ pattern: '^xkey_' }), name: str({ maxLength: 80 }), prefix: str({ description: 'The first ten characters of the secret (ovx_…), to tell keys apart' }),
monthly_cap_usd: { type: 'number', minimum: 0 }, allowed_models: arrayOf(str()), created_at: str({ format: 'date-time' }),
revoked_at: nullable('string', { format: 'date-time' }),
++ expires_at: nullable('string', { format: 'date-time' }),
},
};
route('get', '/exchange/keys', {
@@@ -487,6 +488,7 @@ route('post', '/exchange/keys', {
description: 'Only a person. The secret (ovx_…) is in this answer only; it is stored as a digest. A run with the key is refused once the month\'s cap is spent, and for a model not in allowed_models.',
requestBody: body({ type: 'object', required: ['name', 'monthly_cap_usd', 'allowed_models'], properties: {
name: str({ minLength: 1, maxLength: 80 }), monthly_cap_usd: { type: 'number', minimum: 0 }, allowed_models: arrayOf(str({ maxLength: 200 })),
++ expires_at: nullable('string', { format: 'date-time', description: 'Optional future expiry; checked at authentication and reservation, before a hold' }),
} }),
responses: { 201: json('The key and its secret, shown once', { type: 'object', required: ['key', 'secret'], properties: { key: exchangeKey, secret: str({ pattern: '^ovx_' }) } }), 422: problem, ...errors([401, 403]) },
});
ddiff --git a/server/platform/idempotency.js b/server/platform/idempotency.js
nnew file mode 100644
iindex 0000000..f7035aa
---- /dev/null
++++ b/server/platform/idempotency.js
@@@ -0,0 +1,43 @@
++'use strict';
++const { AiError, sha256 } = require('../util');
++
++// A database uniqueness constraint, not a process-local lock: concurrent callers and
++// restarts cannot take two key holds for the same owner's logical request.
++async function claimRequest(db, owner, idempotencyKey, body) {
++ if (!/^[A-Za-z0-9._:-]{8,128}$/.test(idempotencyKey))
++ throw new AiError(422, 'input.invalid', 'Idempotency-Key must be 8-128 of [A-Za-z0-9._:-]');
++ const fingerprint = sha256(body);
++ const claimed = await db.prepare(
++ 'INSERT INTO exchange_key_requests(owner,idempotency_key,request_hash) VALUES(?,?,?) ON CONFLICT DO NOTHING RETURNING owner',
++ ).get(owner, idempotencyKey, fingerprint);
++ const existingRun = () => db.prepare(
++ "SELECT id FROM runs WHERE requester_type = 'user' AND requester_id = ? AND idempotency_key = ?",
++ ).get(owner, idempotencyKey);
++ if (!claimed) {
++ const previous = await db.prepare(
++ 'SELECT request_hash,max_cost_usd FROM exchange_key_requests WHERE owner = ? AND idempotency_key = ?',
++ ).get(owner, idempotencyKey);
++ if (!previous || previous.request_hash !== fingerprint)
++ throw new AiError(409, 'idempotency.conflict', 'Idempotency-Key was used with different input');
++ const run = await existingRun();
++ if (!run)
++ throw new AiError(409, 'idempotency.in_progress', 'The original request is still being admitted; retry shortly');
++ // runs.create verifies the input again and returns this owner's stored run.
++ // Retain the original ceiling even if the key's remaining budget changed.
++ return { replay: true, maxCost: previous.max_cost_usd === null ? null : Number(previous.max_cost_usd) };
++ }
++ // The same person may first have used their person token rather than an opaque
++ // key. Their existing run is still a replay, with its original request ceiling.
++ if (await existingRun()) return { replay: true, maxCost: null };
++ return {
++ replay: false,
++ admitted: (amount) => db.prepare(
++ 'UPDATE exchange_key_requests SET max_cost_usd = ? WHERE owner = ? AND idempotency_key = ?',
++ ).run(amount, owner, idempotencyKey),
++ abandon: () => db.prepare(
++ `DELETE FROM exchange_key_requests WHERE owner = ? AND idempotency_key = ?
++ AND NOT EXISTS (SELECT 1 FROM runs WHERE requester_type = 'user' AND requester_id = ? AND idempotency_key = ?)`,
++ ).run(owner, idempotencyKey, owner, idempotencyKey),
++ };
++}
++module.exports = { claimRequest };
ddiff --git a/server/platform/keys.js b/server/platform/keys.js
iindex c67e993..73f4ce3 100644
---- a/server/platform/keys.js
++++ b/server/platform/keys.js
@@@ -14,6 +14,7 @@ function createKeys({ db, clock = { now: () => Date.now() } }) {
allowed_models: JSON.parse(r.allowed_models),
created_at: r.created_at,
revoked_at: r.revoked_at,
++ expires_at: r.expires_at || null,
});
async function list(owner) {
return (
@@@ -42,11 +43,13 @@ function createKeys({ db, clock = { now: () => Date.now() } }) {
'ai.invalid',
'A name, nonnegative monthly cap and allowed model list are required',
);
++ const expires = b.expires_at == null ? null : Date.parse(b.expires_at);
++ if (expires !== null && (!Number.isFinite(expires) || expires <= clock.now())) throw new AiError(422, 'ai.invalid', 'expires_at must be a future timestamp');
const secret = 'ovx_' + randomBytes(32).toString('base64url');
const id = 'xkey_' + randomUUID();
await db
.prepare(
-- 'INSERT INTO exchange_api_keys(id,owner,name,digest,prefix,monthly_cap_usd,allowed_models,created_at) VALUES(?,?,?,?,?,?,?,?)',
++ 'INSERT INTO exchange_api_keys(id,owner,name,digest,prefix,monthly_cap_usd,allowed_models,created_at,expires_at) VALUES(?,?,?,?,?,?,?,?,?)',
)
.run(
id,
@@@ -57,6 +60,7 @@ function createKeys({ db, clock = { now: () => Date.now() } }) {
b.monthly_cap_usd,
JSON.stringify(b.allowed_models),
iso(),
++ expires === null ? null : new Date(expires).toISOString(),
);
return { key: (await list(owner)).find((k) => k.id === id), secret };
}
@@@ -73,9 +77,9 @@ function createKeys({ db, clock = { now: () => Date.now() } }) {
if (!/^ovx_[A-Za-z0-9_-]{43}$/.test(secret)) return null;
return db
.prepare(
-- 'SELECT * FROM exchange_api_keys WHERE digest = ? AND revoked_at IS NULL',
++ 'SELECT * FROM exchange_api_keys WHERE digest = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)',
)
-- .get(hash(secret));
++ .get(hash(secret), iso());
}
/**
* A hold for one call against the key's monthly cap: the call's own ceiling (`limit`, its max_cost_usd) or
@@@ -84,6 +88,11 @@ function createKeys({ db, clock = { now: () => Date.now() } }) {
* hung up) and gives the rest of the hold back. Search calls (`free`) take no hold and skip the model list.
*/
async function reserve(key, model, free = false, limit = null) {
++ // Re-read the credential: a previously resolved key may have expired or been revoked.
++ const active = await db.prepare(
++ 'SELECT id FROM exchange_api_keys WHERE id = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)',
++ ).get(key.id, iso());
++ if (!active) throw new AiError(401, 'token.invalid', 'Invalid, expired or revoked Exchange key');
const allowed = JSON.parse(key.allowed_models);
if (!free && allowed.length && !allowed.includes(model))
throw new AiError(403, 'exchange.model_denied', 'This model is not allowed by this key');
@@@ -108,10 +117,11 @@ function createKeys({ db, clock = { now: () => Date.now() } }) {
)
UPDATE exchange_key_months x SET reserved = x.reserved + m.take FROM m
WHERE x.key_id = m.key_id AND x.month = m.month
-- AND EXISTS (SELECT 1 FROM exchange_api_keys k WHERE k.id = x.key_id AND k.revoked_at IS NULL)
++ AND EXISTS (SELECT 1 FROM exchange_api_keys k WHERE k.id = x.key_id AND k.revoked_at IS NULL
++ AND (k.expires_at IS NULL OR k.expires_at::timestamptz > GREATEST(?::timestamptz, clock_timestamp())))
RETURNING m.take AS take`,
)
-- .get(want, key.monthly_cap_usd, key.id, month);
++ .get(want, key.monthly_cap_usd, key.id, month, iso());
if (!row)
throw new AiError(429, 'exchange.key_cap', "This key's monthly cap is spent");
amount = Number(row.take);
ddiff --git a/server/platform/router.js b/server/platform/router.js
iindex 502e395..c1efa09 100644
---- a/server/platform/router.js
++++ b/server/platform/router.js
@@@ -3,6 +3,7 @@ const express = require('express');
const { CAPS } = require('../auth');
const { AiError, sendError } = require('../util');
const { createKeys } = require('./keys');
++const { claimRequest } = require('./idempotency');
const { createSearch } = require('../search');
function platform({ db, auth, clock, log, searchFetch }) {
const keys = createKeys({ db, clock });
@@@ -92,6 +93,7 @@ function platform({ db, auth, clock, log, searchFetch }) {
'',
);
if (!token.startsWith('ovx_')) return next();
++ let claim = null;
try {
const isSearch =
req.method === 'POST' &&
@@@ -115,9 +117,14 @@ function platform({ db, auth, clock, log, searchFetch }) {
throw new AiError(
401,
'token.invalid',
-- 'Invalid or revoked Exchange key',
++ 'Invalid, expired or revoked Exchange key',
);
-- const hold = models
++ const allowed = JSON.parse(key.allowed_models);
++ if (inference && allowed.length && !allowed.includes(req.body?.model))
++ throw new AiError(403, 'exchange.model_denied', 'This model is not allowed by this key');
++ if (inference && req.get('idempotency-key'))
++ claim = await claimRequest(db, key.owner, req.get('idempotency-key'), req.body);
++ const hold = models || claim?.replay
? null
: await keys.reserve(
key,
@@@ -127,13 +134,17 @@ function platform({ db, auth, clock, log, searchFetch }) {
isSearch,
Number.isFinite(req.body?.max_cost_usd) ? req.body.max_cost_usd : null,
);
-- if (inference) {
-- req.body.max_cost_usd = Math.min(
++ if (inference && !(claim?.replay && claim.maxCost === null)) {
++ req.body.max_cost_usd = claim?.replay ? claim.maxCost : Math.min(
hold.amount,
Number.isFinite(req.body.max_cost_usd)
? req.body.max_cost_usd
: hold.amount,
);
++ if (claim && !claim.replay) {
++ try { await claim.admitted(req.body.max_cost_usd); }
++ catch (err) { await hold.settle({ cost_usd: 0 }); throw err; }
++ }
}
req.exchangePrincipal = {
kind: 'user',
@@@ -172,14 +183,16 @@ function platform({ db, auth, clock, log, searchFetch }) {
}
const usage = result?.usage || {};
const cost =
-- isSearch && res.statusCode < 400
++ (isSearch || res.getHeader('x-openvibe-replayed') === '1') && res.statusCode < 400
? 0
: result?.x_openvibe?.cost_usd;
++ // An early disconnect can precede run insertion. Keep its claim
++ // until a response finishes so a retry cannot take another hold.
hold.settle({
cost_usd: cost,
tokens_in: usage.prompt_tokens || 0,
tokens_out: usage.completion_tokens || 0,
-- }).catch(() =>
++ }).then(() => res.writableFinished ? claim?.abandon?.() : undefined).catch(() =>
log.error('Exchange usage settlement failed'),
);
};
@@@ -188,6 +201,7 @@ function platform({ db, auth, clock, log, searchFetch }) {
}
next();
} catch (e) {
++ if (claim && !claim.replay) await claim.abandon().catch(() => log.error('Exchange request cleanup failed'));
sendError(res, e, req.ov, log);
}
}
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex f78c98d..a6bdb16 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -51,6 +51,7 @@ t.test('an export carries the person\'s sessions and credential hints, and a del
const h = await boot({ env: { AI_EVENTS_SECRET: SECRET, AI_EVENTS_SUBSCRIBE: '0', OV_NETWORK_INTERNAL_URL: stub.url, OV_OAUTH_CLIENT_SECRET: 'ai-secret' } });
try {
for (const [subject, name] of [[A, 'ada'], [B, 'bo']]) {
++ await h.db.exec('INSERT INTO exchange_key_requests(owner,idempotency_key,request_hash,max_cost_usd) VALUES ($1,$2,$3,1)', [subject, `request-${name}`, `hash-${name}`]);
await h.db.exec("INSERT INTO console_sessions (id_hash, subject, username, staff, csrf, created_at, expires_at) VALUES ($1, $2, $3, '{}', 'c', '2026-10-01', '2027-10-01')", [`h_${name}`, subject, name]);
await h.db.exec("INSERT INTO person_sessions (id_hash, subject, username, created_at, expires_at) VALUES ($1, $2, $3, '2026-10-01', '2027-10-01')", [`p_${name}`, subject, name]);
await h.db.exec("INSERT INTO subject_credentials (owner, subject, provider, key_enc, key_hint, models, created_at, updated_at) VALUES ('proj_1', $1, 'openai', 'v1:secret', $2, '{}', '2026-10-01', '2026-10-02')", [subject, `hint-${name}`]);
@@@ -82,10 +83,13 @@ t.test('an export carries the person\'s sessions and credential hints, and a del
assert.ok(!JSON.stringify(part.body).includes('v1:cookie-secret'), 'the encrypted session cookie is never exported');
assert.ok(!JSON.stringify(part.body).includes('v1:secret'), 'the encrypted key is never exported');
assert.ok(!JSON.stringify(part.body).includes(B), 'nobody else\'s rows');
++ assert.deepStrictEqual(part.body.files.find((f) => f.name === 'exchange-key-requests.json').content, [{ idempotency_key: 'request-ada', request_hash: 'hash-ada', max_cost_usd: 1 }]);
const d = await deliver(ev('evt_01JZ0000000000000000000D01', 'network.account.deleted', { deletion_id: DEL, subject: A, aliases: [] }));
assert.deepStrictEqual([d.status, d.json.outcome], [200, 'erased']);
assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM console_sessions WHERE subject = $1', [A])), 0);
++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM exchange_key_requests WHERE owner = $1', [A])), 0, 'request claims are erased with their owner');
++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM exchange_key_requests WHERE owner = $1', [B])), 1, 'another owner\'s claims remain');
assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM abuse_devices WHERE subject = $1', [A])), 0, 'the device row goes');
assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM abuse_daily_usage WHERE subject = $1', [`user:${A}`])), 0, 'the daily counter goes');
assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM abuse_devices WHERE subject = $1', [B])), 1, "nobody else's device row");
ddiff --git a/test/compat-routing.test.js b/test/compat-routing.test.js
nnew file mode 100644
iindex 0000000..764f874
---- /dev/null
++++ b/test/compat-routing.test.js
@@@ -0,0 +1,107 @@
++'use strict';
++const assert = require('node:assert/strict');
++const {
++ normalize,
++ policyFor,
++ select,
++} = require('../server/exchange/compat-routing');
++(async () => {
++ assert.throws(() => normalize({ unrecognized: true }), /Unsupported/);
++ assert.throws(() => normalize({ allow_fallbacks: 'false' }), /boolean/);
++ assert.throws(
++ () => normalize({ max_price: { prompt: -1 } }),
++ /nonnegative/,
++ );
++ assert.deepEqual(
++ policyFor({ model: 'maker/model:free' }).policy.max_price,
++ { prompt: 0, completion: 0 },
++ );
++ const list = [
++ { provider: 'a', model: 'm' },
++ { provider: 'b', model: 'm' },
++ { provider: 'c', model: 'm' },
++ ];
++ const facts = async (c) =>
++ ({
++ a: { priceIn: null, priceOut: null },
++ b: {
++ priceIn: 1,
++ priceOut: 2,
++ training: false,
++ retention: 'none',
++ quality: 80,
++ },
++ c: {
++ priceIn: 2,
++ priceOut: 4,
++ training: true,
++ retention: 'standard',
++ quality: 90,
++ },
++ })[c.provider];
++ assert.deepEqual(
++ (
++ await select(
++ list,
++ normalize({
++ max_price: { prompt: 1 },
++ data_collection: 'deny',
++ zdr: true,
++ }),
++ facts,
++ )
++ ).allowed,
++ [list[1]],
++ );
++ assert.deepEqual(
++ (
++ await select(
++ list,
++ normalize({
++ only: ['b', 'c'],
++ order: ['c'],
++ allow_fallbacks: false,
++ }),
++ facts,
++ )
++ ).allowed,
++ [list[2]],
++ );
++ assert.deepEqual(
++ (await select(list, normalize({ sort: 'value' }), facts)).allowed,
++ [list[1], list[2]],
++ );
++ assert.equal(
++ (await select(list, normalize({ sort: 'quality' }), async () => ({})))
++ .allowed.length,
++ 0,
++ );
++ console.log(
++ 'Strict routing fields, missing prices, privacy, provider order and evidence requirements passed',
++ );
++})().catch((e) => {
++ console.error(e);
++ process.exitCode = 1;
++});
++const { importPreset } = require('../server/exchange/import-preset');
++assert.equal(
++ importPreset({
++ name: 'writer',
++ model: 'maker/model',
++ provider: { only: ['a'] },
++ }).valid,
++ true,
++);
++assert.throws(
++ () =>
++ importPreset({ name: 'writer', model: 'm', api_key: 'not-a-real-key' }),
++ /unsupported/,
++);
++assert.throws(
++ () => importPreset({ name: 'writer', model: '@preset/recursive' }),
++ /Nested/,
++);
++const sensitive = 'private-content-do-not-reflect';
++const summary = importPreset({ name: sensitive, model: sensitive, system: sensitive, provider: { only: [sensitive] }, response_format: { [sensitive]: sensitive } });
++assert.equal(JSON.stringify(summary).includes(sensitive), false, 'validated values are never returned');
++assert.throws(() => normalize({ [sensitive]: sensitive }), (e) => !e.message.includes(sensitive));
ddiff --git a/test/exchange-c2-http.test.js b/test/exchange-c2-http.test.js
nnew file mode 100644
iindex 0000000..9ee73fd
---- /dev/null
++++ b/test/exchange-c2-http.test.js
@@@ -0,0 +1,203 @@
++'use strict';
++const assert = require('node:assert/strict');
++const { boot, token, ALL } = require('./helpers');
++(async () => {
++ const h = await boot();
++ try {
++ const auth = 'Bearer ' + token('live', ALL);
++ const call = async (path, body, extra = {}) =>
++ fetch(h.base + path, {
++ method: 'POST',
++ headers: {
++ authorization: auth,
++ 'content-type': 'application/json',
++ ...extra,
++ },
++ body: JSON.stringify(body),
++ });
++ for (const path of ['/v1/routing/preview', '/v1/presets/import/preview']) assert.equal(
++ (
++ await fetch(h.base + path, {
++ method: 'POST',
++ headers: { 'content-type': 'application/json' },
++ body: '{}',
++ })
++ ).status,
++ 401,
++ );
++ const listProviders = h.registry.listProviders;
++ const listModels = h.registry.listModels;
++ const realFetch = global.fetch;
++ const createRun = h.runs.create;
++ let upstreamCalls = 0;
++ let modelCalls = 0;
++ global.fetch = (url, ...args) => {
++ if (String(url).startsWith(h.base + '/')) return realFetch(url, ...args);
++ upstreamCalls++;
++ throw new Error('Preview attempted an account or provider fetch');
++ };
++ h.runs.create = () => { modelCalls++; throw new Error('Preview attempted inference'); };
++ h.registry.listProviders = async () => [
++ { key: 'public-fixture', kind: 'openai', status: 'active', provider_access: 'OWN_DEPLOYMENT', metadata: {} },
++ { key: 'hidden-fixture', kind: 'openai', status: 'active', metadata: { hidden: true } },
++ { key: 'restricted-fixture', kind: 'openai', status: 'active', provider_access: 'RESTRICTED' },
++ { key: 'byok-fixture', kind: 'openai', status: 'active', provider_access: 'CUSTOMER_BYOK' },
++ ];
++ h.registry.listModels = async () => [
++ ...['public-fixture', 'hidden-fixture', 'restricted-fixture', 'byok-fixture'].map((provider_key) => ({ provider_key, model_key: provider_key + '-model', status: 'active', metadata: {}, cost: {} })),
++ ...[{ hidden: true }, { provider_access: 'RESTRICTED' }, { provider_access: 'CUSTOMER_BYOK' }].map((metadata, i) => ({ provider_key: 'public-fixture', model_key: 'private-model-' + i, status: 'active', metadata, cost: {} })),
++ ];
++ try {
++ const preview = await call('/v1/routing/preview', {
++ model: 'openvibe/auto',
++ provider: {},
++ });
++ assert.equal(preview.status, 200);
++ const visible = await preview.json();
++ assert.equal(visible.executable, false);
++ assert.deepEqual(visible.candidates, [{ provider: 'public-fixture', model: 'public-fixture-model' }]);
++ assert.deepEqual(visible.excluded, []);
++ const sensitive = 'submitted-private-marker';
++ for (const [path, input] of [
++ ['/v1/routing/preview', { model: sensitive, provider: { only: [sensitive] } }],
++ ['/v1/routing/preview', { provider: { [sensitive]: sensitive } }],
++ ['/v1/presets/import/preview', { name: sensitive, model: sensitive, system: sensitive, provider: { only: [sensitive] }, response_format: { [sensitive]: sensitive } }],
++ ['/v1/presets/import/preview', { name: 'test', model: sensitive, [sensitive]: sensitive }],
++ ]) {
++ const response = await call(path, input);
++ assert.ok([200, 400].includes(response.status));
++ assert.equal((await response.text()).includes(sensitive), false);
++ }
++ assert.equal(
++ (await call('/v1/routing/preview', { provider: { unknown: 'x' } }))
++ .status,
++ 400,
++ );
++ const imported = await call('/v1/presets/import/preview', {
++ name: 'writer',
++ model: 'openvibe/auto',
++ });
++ assert.equal(imported.status, 200);
++ assert.equal((await imported.json()).valid, true);
++ assert.equal(upstreamCalls, 0, 'previews never fetch an account or provider');
++ assert.equal(modelCalls, 0, 'previews never create a run');
++ assert.equal((await h.db.prepare('SELECT id FROM runs').all()).length, 0);
++ assert.equal((await h.db.prepare('SELECT id FROM exchange_key_usage').all()).length, 0);
++ } finally {
++ global.fetch = realFetch;
++ h.runs.create = createRun;
++ h.registry.listProviders = listProviders;
++ h.registry.listModels = listModels;
++ }
++ const body = {
++ model: 'openvibe',
++ messages: [{ role: 'user', content: 'Hello' }],
++ };
++ const first = await call('/v1/chat/completions', body, {
++ 'idempotency-key': 'c2-repeat-request-001',
++ });
++ assert.equal(first.status, 200);
++ const a = await first.json();
++ const again = await call('/v1/chat/completions', body, {
++ 'idempotency-key': 'c2-repeat-request-001',
++ });
++ assert.equal(again.status, 200);
++ assert.equal(again.headers.get('x-openvibe-replayed'), '1');
++ assert.equal((await again.json()).id, a.id);
++ const changed = await call(
++ '/v1/chat/completions',
++ { ...body, messages: [{ role: 'user', content: 'Different' }] },
++ { 'idempotency-key': 'c2-repeat-request-001' },
++ );
++ assert.equal(changed.status, 409);
++ const messagesBody = { model: 'openvibe', max_tokens: 20, messages: body.messages };
++ const messagesFirst = await call('/v1/messages', messagesBody, { 'idempotency-key': 'messages-repeat-001' });
++ assert.equal(messagesFirst.status, 200);
++ const messagesResult = await messagesFirst.json();
++ const messagesAgain = await call('/v1/messages', messagesBody, { 'idempotency-key': 'messages-repeat-001' });
++ assert.equal(messagesAgain.status, 200);
++ assert.equal(messagesAgain.headers.get('x-openvibe-replayed'), '1');
++ assert.deepEqual(await messagesAgain.json(), messagesResult);
++ const { createKeys } = require('../server/platform/keys');
++ const keys = createKeys({ db: h.db });
++ const key = await keys.create('usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', {
++ name: 'replay',
++ monthly_cap_usd: 1,
++ allowed_models: [],
++ });
++ const keyHeaders = {
++ authorization: 'Bearer ' + key.secret,
++ 'idempotency-key': 'key-repeat-request-001',
++ };
++ const prepare = h.db.prepare.bind(h.db);
++ let holdStatements = 0;
++ h.db.prepare = (sql) => {
++ if (sql.includes('SET reserved = x.reserved + m.take')) holdStatements++;
++ return prepare(sql);
++ };
++ const keyFirst = await call('/v1/chat/completions', body, keyHeaders);
++ assert.equal(keyFirst.status, 200);
++ const keyResult = await keyFirst.json();
++ await new Promise((r) => setTimeout(r, 100));
++ const before = await h.db.prepare('SELECT * FROM exchange_key_usage WHERE key_id = ?').all(key.key.id);
++ assert.equal(before.length, 1);
++ assert.equal(before[0].status, 'settled');
++ // A replay must succeed even when no additional hold would fit.
++ await h.db.prepare('UPDATE exchange_key_months SET spent = 1 WHERE key_id = ?').run(key.key.id);
++ const keyAgain = await call('/v1/chat/completions', body, keyHeaders);
++ assert.equal(keyAgain.status, 200);
++ assert.equal(keyAgain.headers.get('x-openvibe-replayed'), '1');
++ assert.deepEqual(await keyAgain.json(), keyResult);
++ assert.equal(holdStatements, 1, 'a replay never takes a second hold');
++ assert.deepEqual(await h.db.prepare('SELECT * FROM exchange_key_usage WHERE key_id = ?').all(key.key.id), before);
++ assert.deepEqual(await h.db.prepare('SELECT spent,reserved FROM exchange_key_months WHERE key_id = ?').get(key.key.id), { spent: 1, reserved: 0 });
++ const conflict = await call('/v1/chat/completions', { ...body, messages: [{ role: 'user', content: 'Changed' }] }, keyHeaders);
++ assert.equal(conflict.status, 409);
++ assert.equal(holdStatements, 1);
++
++ const sameOwner = await keys.create('usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', { name: 'same owner', monthly_cap_usd: 0, allowed_models: [] });
++ const sameReplay = await call('/v1/chat/completions', body, { ...keyHeaders, authorization: 'Bearer ' + sameOwner.secret });
++ assert.equal(sameReplay.status, 200, 'idempotency belongs to the owner across their keys');
++ assert.equal((await sameReplay.json()).id, keyResult.id);
++ assert.equal(holdStatements, 1);
++
++ // Lookup rejects an expired key before the replay shortcut or any new hold.
++ await h.db.prepare('UPDATE exchange_api_keys SET expires_at = ? WHERE id = ?').run('2000-01-01T00:00:00.000Z', sameOwner.key.id);
++ const expired = await call('/v1/chat/completions', body, { ...keyHeaders, authorization: 'Bearer ' + sameOwner.secret });
++ assert.equal(expired.status, 401);
++ assert.equal(holdStatements, 1);
++
++ const other = await keys.create('usr_01JAB2C3D4E5F6G7H8J9K0MNPR', { name: 'other', monthly_cap_usd: 5, allowed_models: [] });
++ const otherHeaders = { ...keyHeaders, authorization: 'Bearer ' + other.secret };
++ const own = await call('/v1/chat/completions', body, otherHeaders);
++ assert.equal(own.status, 200);
++ assert.notEqual((await own.json()).id, keyResult.id, 'one owner cannot replay another owner\'s run');
++ assert.equal(own.headers.get('x-openvibe-replayed'), null);
++
++ const holdCount = holdStatements;
++ const concurrentHeaders = { ...otherHeaders, 'idempotency-key': 'concurrent-request-001' };
++ const concurrent = await Promise.all(Array.from({ length: 4 }, () => call('/v1/chat/completions', body, concurrentHeaders)));
++ const answers = await Promise.all(concurrent.map(async (r) => ({ status: r.status, body: await r.json() })));
++ assert.ok(answers.some((r) => r.status === 200));
++ assert.ok(answers.every((r) => r.status === 200 || r.status === 409));
++ assert.equal(holdStatements - holdCount, 1, 'concurrent duplicates claim one hold');
++ const concurrentReplay = await call('/v1/chat/completions', body, concurrentHeaders);
++ assert.equal(concurrentReplay.status, 200);
++ assert.equal((await concurrentReplay.json()).id, answers.find((r) => r.status === 200).body.id);
++ assert.equal(holdStatements - holdCount, 1);
++ assert.equal((await h.db.prepare("SELECT id FROM runs WHERE requester_type = 'user' AND requester_id = ? AND idempotency_key = ?").all('usr_01JAB2C3D4E5F6G7H8J9K0MNPR', 'concurrent-request-001')).length, 1);
++ const { claimRequest } = require('../server/platform/idempotency');
++ await claimRequest(h.db, 'pending-owner', 'pending-request-001', body);
++ await assert.rejects(claimRequest(h.db, 'pending-owner', 'pending-request-001', body), (e) => e.code === 'idempotency.in_progress');
++ assert.equal(holdStatements - holdCount, 1, 'an interrupted admission stays closed without a second hold');
++ h.db.prepare = prepare;
++ console.log(
++ 'Authenticated preview, import validation and idempotent chat replay passed',
++ );
++ } finally {
++ await h.stop();
++ }
++})().catch((e) => {
++ console.error(e);
++ process.exitCode = 1;
++});
ddiff --git a/test/exchange-key-expiry.test.js b/test/exchange-key-expiry.test.js
nnew file mode 100644
iindex 0000000..29279e0
---- /dev/null
++++ b/test/exchange-key-expiry.test.js
@@@ -0,0 +1,77 @@
++'use strict';
++const assert = require('node:assert/strict');
++const { boot } = require('./helpers');
++const { createKeys } = require('../server/platform/keys');
++(async () => {
++ const h = await boot();
++ try {
++ let now = Date.now();
++ const keys = createKeys({ db: h.db, clock: { now: () => now } });
++ await assert.rejects(
++ keys.create('alice', {
++ name: 'bad',
++ monthly_cap_usd: 1,
++ allowed_models: [],
++ expires_at: 'not-a-date',
++ }),
++ (e) => e.status === 422,
++ );
++ const made = await keys.create('alice', {
++ name: 'expiring',
++ monthly_cap_usd: 1,
++ allowed_models: [],
++ expires_at: new Date(now + 1000).toISOString(),
++ });
++ const resolved = await keys.resolve(made.secret);
++ assert.ok(resolved);
++ assert.ok(made.key.expires_at);
++ now += 2000;
++ assert.equal(await keys.resolve(made.secret), undefined);
++ for (const free of [false, true])
++ await assert.rejects(keys.reserve(resolved, 'model', free), (e) => e.status === 401);
++ assert.equal((await h.db.prepare('SELECT * FROM exchange_key_months WHERE key_id = ?').all(resolved.id)).length, 0);
++ assert.equal((await h.db.prepare('SELECT * FROM exchange_key_usage WHERE key_id = ?').all(resolved.id)).length, 0);
++
++ const boundary = await keys.create('alice', {
++ name: 'boundary', monthly_cap_usd: 2, allowed_models: [],
++ expires_at: new Date(now + 1000).toISOString(),
++ });
++ const stale = await keys.resolve(boundary.secret);
++ const firstHold = await keys.reserve(stale, 'model', false, 0.5);
++ assert.equal(firstHold.amount, 0.5, 'main retains bounded holds');
++ await firstHold.settle({ cost_usd: 0.1 });
++ now += 1000;
++ assert.equal(await keys.resolve(boundary.secret), undefined, 'expiry is inclusive');
++ await assert.rejects(keys.reserve(stale, 'model'), (e) => e.status === 401);
++ const ledger = await h.db.prepare('SELECT spent,reserved FROM exchange_key_months WHERE key_id = ?').get(stale.id);
++ assert.deepEqual(ledger, { spent: 0.1, reserved: 0 });
++
++ // Expiry after the initial check is still refused by the atomic hold statement.
++ const racing = await keys.create('alice', {
++ name: 'race', monthly_cap_usd: 2, allowed_models: [],
++ expires_at: new Date(now + 1000).toISOString(),
++ });
++ const raceKey = await keys.resolve(racing.secret);
++ const raceDb = { prepare(sql) {
++ const statement = h.db.prepare(sql);
++ if (sql.startsWith('INSERT INTO exchange_key_months')) return { async run(...args) {
++ const result = await statement.run(...args);
++ now += 1000;
++ return result;
++ } };
++ return statement;
++ } };
++ await assert.rejects(createKeys({ db: raceDb, clock: { now: () => now } }).reserve(raceKey, 'model'), (e) => e.status === 429);
++ assert.equal((await h.db.prepare('SELECT reserved FROM exchange_key_months WHERE key_id = ?').get(raceKey.id)).reserved, 0);
++ assert.equal((await h.db.prepare('SELECT * FROM exchange_key_usage WHERE key_id = ?').all(raceKey.id)).length, 0);
++ assert.equal((await keys.list('alice')).length, 3);
++ console.log(
++ 'Expired keys fail authentication and stale-key reservations',
++ );
++ } finally {
++ await h.stop();
++ }
++})().catch((e) => {
++ console.error(e);
++ process.exitCode = 1;
++});