Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 5 additions & 8 deletions client/src/components/models/ModelComparison.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -279,15 +279,12 @@ export default function ModelComparison() {
};

const handleSyncAA = () => {
if (!syncKey.trim()) {
setSyncError('Please enter an Artificial Analysis API key.');
return;
}
setSyncing(true);
setSyncError('');
setSyncStatus('Connecting to Artificial Analysis and syncing models…');
syncArtificialAnalysis({ apiKey: syncKey.trim() }, { silent: true })
syncArtificialAnalysis({ ...(syncKey.trim() ? { apiKey: syncKey.trim() } : {}) }, { silent: true })
.then(res => {
setSyncKey('');
setSyncStatus(`Sync successful! Updated ${res.observations} models (${res.total} total).`);
refreshView();
})
Expand Down Expand Up @@ -651,7 +648,7 @@ export default function ModelComparison() {
</h3>
<p className="text-xs text-port-text-muted leading-relaxed">
Fetch the latest benchmark evaluations, pricing, response times, and reasoning effort measurements from the
Artificial Analysis Free API.
Artificial Analysis Free API. A key entered here is saved privately after authentication succeeds. Leave blank to reuse a configured key, or manage it in Settings → Credentials.
</p>
<div className="space-y-1.5">
<label htmlFor="aa-api-key" className="text-xs font-medium text-port-text-muted">
Expand All @@ -660,7 +657,7 @@ export default function ModelComparison() {
<input
id="aa-api-key"
type="password"
placeholder="aa_..."
placeholder="Leave blank to use your saved key"
aria-label="Artificial Analysis API Key"
className="w-full bg-port-bg text-port-text border border-port-border rounded-lg p-2.5 text-sm font-mono"
value={syncKey}
Expand All @@ -683,7 +680,7 @@ export default function ModelComparison() {
type="button"
className="px-4 py-1.5 text-sm bg-port-accent text-port-on-accent rounded-lg font-medium hover:opacity-90 disabled:opacity-50"
onClick={handleSyncAA}
disabled={syncing || !syncKey.trim()}
disabled={syncing}
>
{syncing ? 'Syncing…' : 'Start Sync'}
</button>
Expand Down
9 changes: 9 additions & 0 deletions client/src/components/models/ModelComparison.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -350,3 +350,12 @@ it('keeps exact Zen IDs in available coverage and plots free prices without inve
expect(screen.queryByTestId('scatter-example-free')).toBeNull();
expect(screen.getAllByText('Unknown').length).toBeGreaterThan(0);
});

it('syncs with the saved server key when the key input is blank', async () => {
api.syncArtificialAnalysis.mockResolvedValue({ success: true, observations: 1, total: 1 });
render(<MemoryRouter><ModelComparison /></MemoryRouter>);
fireEvent.click(await screen.findByRole('button', { name: /Sync from Artificial Analysis/i }));
fireEvent.click(screen.getByRole('button', { name: 'Start Sync' }));
await screen.findByText(/Sync successful!/);
expect(api.syncArtificialAnalysis).toHaveBeenCalledWith({}, { silent: true });
});
33 changes: 31 additions & 2 deletions client/src/components/settings/CredentialsTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { Link } from 'react-router';
import { ExternalLink } from 'lucide-react';
import BrailleSpinner from '../BrailleSpinner';
import { getCredentialInventory } from '../../services/api';
import { getCredentialInventory, saveCredential } from '../../services/api';

const SOURCE_LABEL = {
settings: 'Settings',
Expand All @@ -22,6 +22,19 @@ const TIER_LABEL = {
export function CredentialsTab() {
const [payload, setPayload] = useState(null);
const [error, setError] = useState(null);
const [drafts, setDrafts] = useState({});
const [saving, setSaving] = useState(null);
const [receipt, setReceipt] = useState(null);

const saveKey = (id, value) => {
setSaving(id);
setReceipt({ id, message: 'Saving…' });
saveCredential(id, value, { silent: true }).then(row => {
setPayload(previous => ({ ...previous, credentials: previous.credentials.map(item => item.id === id ? row : item) }));
setDrafts(previous => ({ ...previous, [id]: '' }));
setReceipt({ id, message: value ? 'Key saved privately.' : 'Saved key cleared. External credentials may still apply.' });
}).catch(err => setReceipt({ id, message: err.message })).finally(() => setSaving(null));
};

const load = () => {
setError(null);
Expand Down Expand Up @@ -64,7 +77,7 @@ export function CredentialsTab() {
<p className="text-sm text-gray-400 mt-1">
{payload.headline || 'Most of PortOS works with no key at all.'}
{' '}
This page shows presence and where a value resolved from — never the value itself. Enter or rotate a secret on its existing settings tab.
This page shows presence and where a value resolved from — never the value itself. Save or rotate supported integration keys here. Keys stay on this install in the private data store; blank inputs never reveal saved values.
</p>
</div>

Expand Down Expand Up @@ -113,6 +126,22 @@ export function CredentialsTab() {
</p>
)}

{credential.editable && (
<div className="space-y-2">
<label htmlFor={`credential-${credential.id}`} className="block text-sm">New {credential.label} key</label>
<input id={`credential-${credential.id}`} type="password" autoComplete="new-password"
value={drafts[credential.id] || ''} disabled={saving !== null}
onChange={event => setDrafts(previous => ({ ...previous, [credential.id]: event.target.value }))}
className="w-full bg-port-bg border border-port-border rounded p-2" />
<div className="flex flex-wrap gap-2">
<button type="button" disabled={saving !== null || !drafts[credential.id]?.trim()}
onClick={() => saveKey(credential.id, drafts[credential.id])} className="px-3 py-2 rounded bg-port-border disabled:opacity-50">Save key</button>
<button type="button" disabled={saving !== null || credential.source !== 'settings'}
onClick={() => saveKey(credential.id, '')} className="px-3 py-2 rounded bg-port-border disabled:opacity-50">Clear saved key</button>
</div>
{receipt?.id === credential.id && <p role="status" className="text-sm">{receipt.message}</p>}
</div>
)}
<div className="flex flex-wrap gap-2 pt-1">
{credential.configurePath && (
<Link
Expand Down
19 changes: 18 additions & 1 deletion client/src/components/settings/CredentialsTab.test.jsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { describe, expect, it, beforeEach, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router';

const mock = vi.hoisted(() => ({
getCredentialInventory: vi.fn(),
saveCredential: vi.fn(),
}));

vi.mock('../../services/api', () => mock);
Expand Down Expand Up @@ -88,3 +89,19 @@ describe('CredentialsTab', () => {
expect(screen.queryByText('Not configured')).not.toBeInTheDocument();
});
});

it('saves and clears a write-only key with immediate feedback and no displayed stored value', async () => {
const row = { ...PAYLOAD.credentials[0], editable: true };
mock.getCredentialInventory.mockResolvedValue({ ...PAYLOAD, credentials: [row] });
mock.saveCredential.mockResolvedValue(row);
render(<MemoryRouter><CredentialsTab /></MemoryRouter>);
const input = await screen.findByLabelText('New Hugging Face key');
expect(input.value).toBe('');
fireEvent.change(input, { target: { value: 'hf_example' } });
fireEvent.click(screen.getByRole('button', { name: 'Save key' }));
expect(screen.getByRole('status').textContent).toBe('Saving…');
await waitFor(() => expect(input.value).toBe(''));
expect(mock.saveCredential).toHaveBeenCalledWith('huggingface', 'hf_example', { silent: true });
fireEvent.click(screen.getByRole('button', { name: 'Clear saved key' }));
await waitFor(() => expect(mock.saveCredential).toHaveBeenCalledWith('huggingface', '', { silent: true }));
});
2 changes: 1 addition & 1 deletion client/src/services/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ toasts on throw). **Custom catch ⇒ `silent: true`** — otherwise toasts fire
| `apiSchedules.js` | Automation schedules. |
| `apiQuotaBurn.js` | Quota Burn plan + live status, the job-type catalog its config form renders, and manual runs (`getQuotaBurn`/`getQuotaBurnCatalog`/`saveQuotaBurn`/`runQuotaBurn`), plus `rearmQuotaBurn` to put spent `run once` steps back into the rotation. |
| `apiRapidReader.js` | Rapid Reader's optional author-hosted Accelerando loader and machine-local shelf API. |
| `apiSystem.js` | System info (CPU/memory/ports/alerts/active processing and local hardware capabilities) + D&D-style character sheet getter, plus the usage cost report and explicit historical reconciliation (`getUsage`, `getProviderUsage`, `getUsageBackfillStatus`/`startUsageBackfill`, `updateSubscriptionCosts` for the subscription-vs-API savings comparison, `updateUsageFleetBilling` to exclude an API-billed federated instance from Across Instances totals). Also `getCredentialInventory` (`GET /settings/credentials`) — presence and source of each PortOS credential, never a value. |
| `apiSystem.js` | System info (CPU/memory/ports/alerts/active processing and local hardware capabilities) + D&D-style character sheet getter, plus the usage cost report and explicit historical reconciliation (`getUsage`, `getProviderUsage`, `getUsageBackfillStatus`/`startUsageBackfill`, `updateSubscriptionCosts` for the subscription-vs-API savings comparison, `updateUsageFleetBilling` to exclude an API-billed federated instance from Across Instances totals). Also `saveCredential` (write-only integration key save/clear) and `getCredentialInventory` (`GET /settings/credentials`) — presence and source of each PortOS credential, never a value. |
| `apiAuth.js` | Optional login password — status, login, set/clear password. |
| `apiLoops.js` | Scheduled loops. |

Expand Down
2 changes: 2 additions & 0 deletions client/src/services/apiSystem.js
Original file line number Diff line number Diff line change
Expand Up @@ -476,3 +476,5 @@ export const updateGoalScorecardSettings = (partial) => request('/insights/goal-

export const getEidoverseDestinations = (options) => request('/eidoverse/travel/destinations', options);
export const departEidoverse = (peerId, options) => request('/eidoverse/travel/depart', { method: 'POST', body: JSON.stringify({ peerId }), ...options });

export const saveCredential = (id, value, options) => request(`/settings/credentials/${encodeURIComponent(id)}`, { ...options, method: 'PUT', body: JSON.stringify({ value }) });
26 changes: 26 additions & 0 deletions docs/STORAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,3 +226,29 @@ Apply this checklist to **every new feature that persists data**, and require it
`data/model-comparison.json` is `file-primary`: a bounded, externally researched reference snapshot, directly inspectable/importable as a portable JSON document, with no app-record foreign keys, cross-record queries, search index or accumulated history. It follows the local-assessment reference pattern, rather than representing app-native relational records. It is intentionally machine-local and never federated because configuration and quota interpretation can be install-specific. Schema version 1 is seeded for new installs and migration 351 preserves existing catalogs. Rsync backups include it; no backup exclusion, sync cursor or tombstone is added. Source dates and exact benchmark/configuration identities remain attached to metrics. The server rejects future/malformed versions and merges imports through a serialized last-good-preserving write. See [Models Comparison](MODEL-COMPARISON.md).

Optional SDK environments under `data/venvs/` are machine-local, regenerable runtime files, not application records. Reactor provisions its pinned SDK, private Python and checksum-verified uv manager on the first authorized render (or optionally through `npm run setup:reactor`); no seed, migration, database table, or peer synchronization is needed. Data Manager identifies these environments but does not purge them while render processes may use them.

### Private integration API keys

`data/private/api-keys.json` is machine-local `file-primary` configuration,
not a relational record store or a federation payload. Its directory is owner-only
(0700) and the file is owner-readable/writable (0600) on POSIX systems. It is not
mounted under the HTTP asset routes. Filesystem backups include it: protect backup
access as carefully as the install. This is permission-protected storage, not
application-level encryption.

Settings > Credentials manages Artificial Analysis, Hugging Face, CivitAI, fal.ai,
and reactor.inc keys through a write-only endpoint. Existing integration settings
screens use the same store. Server-side settings readers retain their legacy shape;
public settings and inventory responses never contain these values. Stored keys
win over environment fallbacks; clearing a saved key allows an existing environment
credential (or Hugging Face CLI login) to apply again.

Migration 357 copies existing settings keys before removing their old fields and
preserves keys already in the private store on retry. There is no seed file. The
runtime also reads legacy settings until their next save, so independently updated
installs do not need to re-enter keys. Environment credentials remain supported and
are not copied automatically. Artificial Analysis saves a supplied key after a
successful API fetch and subsequent syncs can omit it. Downgrades to versions before
this store require re-entering keys through the older settings UI or environment.
Other credentials (provider connections, account-specific logins, and auth) retain
their existing dedicated stores and management flows.
1 change: 1 addition & 0 deletions scripts/lib/migrationOwnedPaths.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

/** Paths relative to `data/` (and to `data.reference/`), always posix-spelled. */
export const MIGRATION_OWNED_PATHS = new Set([
'private/api-keys.json', // Derived from legacy integration settings; never seeded.
'eidoverse/portos-world.json', // Per-install state and explicit aliases; never seed over it.
// Migration 339 lifts the durable CoS config out of data/cos/state.json.
// Absent, `loadConfig()` in server/services/cosState.js returns DEFAULT_CONFIG.
Expand Down
16 changes: 16 additions & 0 deletions scripts/migrations/357-private-api-keys.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { join } from 'node:path';
import { atomicWrite, readJSONFileStrict } from '../../server/lib/fileUtils.js';
import { persistPrivateKeys } from '../../server/services/privateKeyStore.js';

export default {
async up({ rootDir }) {
const file = join(rootDir, 'data/settings.json');
const { ok, value } = await readJSONFileStrict(file, null);
if (!ok) throw new Error('Cannot migrate unreadable settings');
if (!value) return { success: true, skipped: 'no settings' };
// Write keys before removing legacy copies. A retry preserves newer keys.
const settings = await persistPrivateKeys(value, join(rootDir, 'data'), { preserveExisting: true });
await atomicWrite(file, settings);
return { success: true };
},
};
19 changes: 19 additions & 0 deletions scripts/migrations/357-private-api-keys.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { it, expect } from 'vitest';
import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import migration from './357-private-api-keys.js';

it('migrates from input even with an existing destination and preserves newer stored keys on retry', async () => {
const rootDir = await mkdtemp(join(tmpdir(), 'keys-migration-'));
try {
await mkdir(join(rootDir, 'data/private'), { recursive: true });
await writeFile(join(rootDir, 'data/private/api-keys.json'), JSON.stringify({ schemaVersion: 1, keys: { civitai: 'example-new' } }));
await writeFile(join(rootDir, 'data/settings.json'), JSON.stringify({ civitai: { apiKey: 'example-old' }, imageGen: { hfToken: 'hf_example', mode: 'local' } }));
await migration.up({ rootDir });
await migration.up({ rootDir });
const store = JSON.parse(await readFile(join(rootDir, 'data/private/api-keys.json'), 'utf8'));
expect(store.keys).toEqual({ civitai: 'example-new', huggingface: 'hf_example' });
expect(JSON.parse(await readFile(join(rootDir, 'data/settings.json'), 'utf8'))).toEqual({ civitai: {}, imageGen: { mode: 'local' } });
} finally { await rm(rootDir, { recursive: true, force: true }); }
});
12 changes: 10 additions & 2 deletions server/lib/apiRouteCatalog.generated.json
Original file line number Diff line number Diff line change
Expand Up @@ -14585,6 +14585,14 @@
"server/routes/settings.js"
]
},
{
"method": "PUT",
"path": "/api/settings/credentials/:id",
"mountPath": "/api/settings",
"sources": [
"server/routes/settings.js"
]
},
{
"method": "GET",
"path": "/api/settings/features",
Expand Down Expand Up @@ -17812,8 +17820,8 @@
],
"stats": {
"mounts": 150,
"operations": 2206,
"declarations": 2214,
"operations": 2207,
"declarations": 2215,
"sourceFiles": 233
}
}
24 changes: 22 additions & 2 deletions server/lib/credentialRegistry.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
//
// Ordered most-value-first. Adding a credential:
// 1. add a descriptor here;
// 2. point `configurePath` at the existing per-integration tab (this page
// never collects a secret);
// 2. point `configurePath` at the integration tab; `privateStore: true` enables
// write-only management here for keys resolved through settings.js;
// 3. tag `feature` when an instance-feature id from instanceFeatureRegistry
// stays dark without it.
// Presence and source only. Never the value, and not a masked prefix either.
Expand All @@ -17,6 +17,7 @@ export const CREDENTIAL_TIERS = Object.freeze(['free', 'metered', 'none']);
export const CREDENTIALS = Object.freeze([
Object.freeze({
id: 'huggingface',
privateStore: true,
label: 'Hugging Face',
unlocks: 'Authenticated model, LoRA, and 3D-asset downloads (FLUX, LTX, Trellis, and gated Hub repos).',
tier: 'free',
Expand All @@ -25,6 +26,24 @@ export const CREDENTIALS = Object.freeze([
settingsPath: 'imageGen.hfToken',
configurePath: '/media/image?settings=1',
}),
Object.freeze({
id: 'artificial-analysis', label: 'Artificial Analysis', privateStore: true,
unlocks: 'Model benchmark and pricing sync.', tier: 'free',
getUrl: 'https://artificialanalysis.ai/', envVars: Object.freeze(['ARTIFICIAL_ANALYSIS_API_KEY']),
settingsPath: 'secrets.artificialAnalysis.apiKey', configurePath: '/settings/credentials',
}),
Object.freeze({
id: 'fal', label: 'fal.ai', privateStore: true,
unlocks: 'Video generation through fal.ai.', tier: 'metered',
getUrl: 'https://fal.ai/dashboard/keys', envVars: Object.freeze(['FAL_KEY']),
settingsPath: 'videoGen.fal.apiKey', configurePath: '/settings/credentials',
}),
Object.freeze({
id: 'reactor', label: 'reactor.inc', privateStore: true,
unlocks: 'Video generation through reactor.inc.', tier: 'metered',
getUrl: 'https://reactor.inc/', envVars: Object.freeze(['REACTOR_API_KEY']),
settingsPath: 'videoGen.reactor.apiKey', configurePath: '/settings/credentials',
}),
Object.freeze({
id: 'github',
label: 'GitHub',
Expand Down Expand Up @@ -77,6 +96,7 @@ export const CREDENTIALS = Object.freeze([
}),
Object.freeze({
id: 'civitai',
privateStore: true,
label: 'CivitAI',
unlocks: 'LoRA and checkpoint downloads from civitai.com.',
tier: 'free',
Expand Down
4 changes: 4 additions & 0 deletions server/lib/validation.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { CREDENTIALS } from './credentialRegistry.js';
import { z } from 'zod';
import { ServerError } from './errorHandler.js';
import { partialWithoutDefaults, emptyToUndefined, emptyToNull, optionalBooleanMap } from './zodCompat.js';
Expand Down Expand Up @@ -1917,4 +1918,7 @@ export const modelComparisonImportSchema = z.object({
});

export const modelComparisonDiscoverySchema = z.object({ providerId: z.string().min(1).max(200) }).strict();
export const privateCredentialParamsSchema = z.object({ id: z.enum(CREDENTIALS.filter(entry => entry.privateStore).map(entry => entry.id)) });
export const privateCredentialInputSchema = z.object({ value: z.string().trim().max(2000) }).strict();

export const modelComparisonSyncSchema = z.object({ apiKey: z.string().min(1).max(200).optional() }).strict();
1 change: 1 addition & 0 deletions server/routes/modelComparison.test.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
vi.mock('../services/settings.js', () => ({ getSettings: vi.fn(async () => ({})), updateSettingsWith: vi.fn() }));
import { beforeEach, afterEach, expect, it, vi } from 'vitest';
import express from 'express';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
Expand Down
Loading