Initial Commit
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import { createLocalJWKSet, jwtVerify } from 'jose';
|
||||
import { configuration, requireLiveConfig } from '../src/lib/server/config';
|
||||
import { applicationJwt, TokenCipher } from '../src/lib/server/security';
|
||||
|
||||
let stage = 'required live settings';
|
||||
try {
|
||||
const config = configuration();
|
||||
requireLiveConfig(config);
|
||||
stage = 'encryption-key round trip';
|
||||
const cipher = new TokenCipher(config.encryptionKey);
|
||||
const encrypted = await cipher.seal({ sentinel: true }, 'configuration-check');
|
||||
const decrypted = await cipher.open<{ sentinel: boolean }>(encrypted, 'configuration-check');
|
||||
if (decrypted.sentinel !== true) throw new Error();
|
||||
stage = 'application signing key / public JWKS match';
|
||||
const jwksValue = process.env.APP_AUTH_JWKS ?? '';
|
||||
const prefix = 'data:application/json;base64,';
|
||||
if (!jwksValue.startsWith(prefix)) throw new Error();
|
||||
const jwks = createLocalJWKSet(JSON.parse(Buffer.from(jwksValue.slice(prefix.length), 'base64').toString('utf8')));
|
||||
const jwt = await applicationJwt({ userId: 'configuration-check', sessionId: 'configuration-check', issuer: config.issuer, privateKey: config.privateKey });
|
||||
await jwtVerify(jwt, jwks, { issuer: config.issuer, audience: 'eve-intelligence', algorithms: ['RS256'] });
|
||||
console.log('Live configuration passed: required settings, encryption round trip, and matching application JWT keys. No secret values displayed.');
|
||||
} catch {
|
||||
console.error(`Live configuration failed at: ${stage}. Values and raw errors suppressed.`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export {};
|
||||
// Run only against your intended self-hosted deployment. Values are never printed.
|
||||
const names = ['CONVEX_BRIDGE_SECRET', 'APP_AUTH_ISSUER', 'APP_AUTH_JWKS'] as const;
|
||||
if (!process.env.CONVEX_SELF_HOSTED_URL || !process.env.CONVEX_SELF_HOSTED_ADMIN_KEY) throw new Error('Set self-hosted URL and admin key before configuring Convex.');
|
||||
for (const name of names) {
|
||||
const value = process.env[name];
|
||||
if (!value) throw new Error(`Missing ${name}`);
|
||||
// Pipe values rather than exposing them in process arguments or shell history.
|
||||
const child = Bun.spawn(['bun', 'node_modules/convex/bin/main.js', 'env', 'set', name], { stdin: new Blob([value]), stdout: 'pipe', stderr: 'pipe' });
|
||||
if (await child.exited !== 0) throw new Error(`Could not configure ${name}; check deployment URL/admin key (output suppressed to protect secrets).`);
|
||||
console.log(`Configured ${name}`);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { generateKeyPair, exportPKCS8, exportJWK } from 'jose';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
const { privateKey, publicKey } = await generateKeyPair('RS256', { extractable: true, modulusLength: 2048 });
|
||||
const publicJwk = { ...await exportJWK(publicKey), kid: 'app-v1', alg: 'RS256', use: 'sig' };
|
||||
const values = {
|
||||
TOKEN_ENCRYPTION_KEY: randomBytes(32).toString('base64'),
|
||||
CONVEX_BRIDGE_SECRET: randomBytes(32).toString('base64url'),
|
||||
APP_AUTH_PRIVATE_KEY: Buffer.from(await exportPKCS8(privateKey)).toString('base64'),
|
||||
APP_AUTH_JWKS: `data:application/json;base64,${Buffer.from(JSON.stringify({ keys: [publicJwk] })).toString('base64')}`
|
||||
};
|
||||
await writeFile('.env.keys', Object.entries(values).map(([k, v]) => `${k}='${v}'`).join('\n') + '\n', { flag: 'wx', mode: 0o600 });
|
||||
console.log('Wrote .env.keys (0600). Import its four values into your Infisical project (dev, /), or a private .env for standalone setup. Never commit it. Existing files are not overwritten.');
|
||||
@@ -0,0 +1,174 @@
|
||||
import { constants } from 'node:fs';
|
||||
import { open } from 'node:fs/promises';
|
||||
import { parseEnv } from 'node:util';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export interface VaultProject {
|
||||
workspaceId: string;
|
||||
defaultEnvironment: string;
|
||||
domain: string;
|
||||
secretPath: string;
|
||||
}
|
||||
export const BOOTSTRAP_KEYS = ['INFISICAL_UNIVERSAL_AUTH_CLIENT_ID', 'INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET'] as const;
|
||||
export const APPLICATION_KEYS = [
|
||||
'EVE_CLIENT_ID', 'EVE_CLIENT_SECRET', 'TYPESAFE_API_KEY', 'TOKEN_ENCRYPTION_KEY',
|
||||
'CONVEX_BRIDGE_SECRET', 'APP_AUTH_PRIVATE_KEY', 'APP_AUTH_JWKS',
|
||||
] as const;
|
||||
export const REQUIRED_KEYS = ['EVE_CLIENT_ID', 'TOKEN_ENCRYPTION_KEY', 'CONVEX_BRIDGE_SECRET', 'APP_AUTH_PRIVATE_KEY', 'APP_AUTH_JWKS'] as const;
|
||||
export const ADMIN_KEY = 'CONVEX_SELF_HOSTED_ADMIN_KEY';
|
||||
export type Environment = Record<string, string | undefined>;
|
||||
export type CliRunner = (args: string[], env: Environment) => Promise<string>;
|
||||
|
||||
export function validateProject(input: unknown): VaultProject {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Invalid Infisical project configuration');
|
||||
const project = input as Record<string, unknown>;
|
||||
if (typeof project.workspaceId !== 'string' || !/^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}$/i.test(project.workspaceId)) throw new Error('Invalid Infisical project ID');
|
||||
if (typeof project.defaultEnvironment !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(project.defaultEnvironment)) throw new Error('Invalid Infisical environment');
|
||||
// This repository is deliberately pinned to the operator-authorized vault origin.
|
||||
// A repository config change alone must never send the bootstrap credential elsewhere.
|
||||
if (project.domain !== 'https://secrets.jb.codes') throw new Error('Infisical origin is not the approved instance');
|
||||
if (typeof project.secretPath !== 'string' || !/^\/(?:[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*)?$/.test(project.secretPath)) throw new Error('Invalid Infisical secret path');
|
||||
return project as unknown as VaultProject;
|
||||
}
|
||||
|
||||
export async function readCredentials(path: string): Promise<Record<string, string>> {
|
||||
let handle;
|
||||
try {
|
||||
// Do not follow a symlink or read a world/group-accessible bootstrap secret.
|
||||
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
const stat = await handle.stat();
|
||||
if (!stat.isFile() || (stat.mode & 0o077) !== 0 || stat.uid !== process.getuid?.() || stat.size > 16_384) {
|
||||
throw new Error('Invalid credentials file');
|
||||
}
|
||||
const parsed = parseEnv(await handle.readFile('utf8'));
|
||||
const selected: Record<string, string> = {};
|
||||
for (const name of BOOTSTRAP_KEYS) {
|
||||
if (!parsed[name]?.trim()) throw new Error('Missing bootstrap credential');
|
||||
selected[name] = parsed[name];
|
||||
}
|
||||
return selected;
|
||||
} catch {
|
||||
throw new Error('Cannot read Universal Auth credentials: use a regular, owner-owned 0600 file containing the two documented variables');
|
||||
} finally {
|
||||
await handle?.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function boundedOutput(stream: ReadableStream<Uint8Array>, limit: number): Promise<string> {
|
||||
const reader = stream.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let bytes = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
bytes += value.byteLength;
|
||||
if (bytes > limit) throw new Error('Infisical output exceeded its safety limit');
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally { reader.releaseLock(); }
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
|
||||
async function captureSecretCommand(argv: string[], env: Environment): Promise<string> {
|
||||
const child = Bun.spawn(argv, { env, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' });
|
||||
const deadline = setTimeout(() => child.kill('SIGKILL'), 30_000);
|
||||
deadline.unref();
|
||||
try {
|
||||
const [output, , code] = await Promise.all([
|
||||
boundedOutput(child.stdout, 2 * 1024 * 1024),
|
||||
boundedOutput(child.stderr, 64 * 1024),
|
||||
child.exited,
|
||||
]);
|
||||
if (code !== 0) throw new Error('CLI failed');
|
||||
return output;
|
||||
} catch {
|
||||
// CLI errors can embed request details. Never echo raw stdout/stderr or credentials.
|
||||
throw new Error('Credential command failed; check permissions and connectivity (raw output suppressed)');
|
||||
} finally {
|
||||
clearTimeout(deadline);
|
||||
if (child.exitCode === null) child.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
|
||||
export const runCli: CliRunner = async (args, env) => {
|
||||
const executable = fileURLToPath(new URL('../../node_modules/.bin/infisical', import.meta.url));
|
||||
return captureSecretCommand([executable, ...args], env);
|
||||
};
|
||||
|
||||
export async function localDeploymentAdmin(env: Environment, runner = captureSecretCommand): Promise<string> {
|
||||
if (!['http://127.0.0.1:3210', 'http://localhost:3210'].includes(env.CONVEX_SELF_HOSTED_URL ?? '')) {
|
||||
throw new Error('Local admin derivation is restricted to this host’s Convex deployment');
|
||||
}
|
||||
// The backend derives its admin credential from its persistent random instance
|
||||
// secret. Docker-host access is already administrative; no second secret file
|
||||
// or write-enabled Infisical identity is required. Only explicit --deployment
|
||||
// commands receive this key; Compose never forwards it to the web container.
|
||||
const key = (await runner(['docker', 'compose', 'exec', '-T', 'backend', './generate_admin_key.sh'], cleanEnvironment(env))).trim();
|
||||
if (!key || /\s/.test(key) || key.length > 16_384) throw new Error('Unexpected local admin response; output suppressed');
|
||||
return key;
|
||||
}
|
||||
|
||||
export function parseExport(text: string): Record<string, string> {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
const values: Record<string, string> = Object.create(null);
|
||||
const entries: unknown[] = Array.isArray(parsed)
|
||||
? parsed
|
||||
: parsed && typeof parsed === 'object'
|
||||
? Object.entries(parsed).map(([key, value]) => ({ key, value }))
|
||||
: [];
|
||||
if (!parsed || typeof parsed !== 'object') throw new Error();
|
||||
for (const entry of entries) {
|
||||
if (!entry || typeof entry !== 'object') throw new Error();
|
||||
const row = entry as Record<string, unknown>;
|
||||
const key = row.key ?? row.secretKey;
|
||||
const value = row.value ?? row.secretValue;
|
||||
if (typeof key !== 'string' || !/^[A-Z][A-Z0-9_]*$/.test(key) || typeof value !== 'string' || Object.hasOwn(values, key)) throw new Error();
|
||||
values[key] = value;
|
||||
}
|
||||
return values;
|
||||
} catch { throw new Error('Invalid Infisical export; values suppressed'); }
|
||||
}
|
||||
|
||||
export async function fetchSecrets(project: VaultProject, credentials: Record<string, string>, runner: CliRunner = runCli, base: Environment = process.env): Promise<Record<string, string>> {
|
||||
validateProject(project);
|
||||
for (const name of BOOTSTRAP_KEYS) if (!credentials[name]) throw new Error(`Missing ${name}`);
|
||||
const env = cleanEnvironment(base);
|
||||
const common = ['--domain', project.domain, '--silent', '--telemetry=false'];
|
||||
const bootstrap = Object.fromEntries(BOOTSTRAP_KEYS.map(key => [key, credentials[key]]));
|
||||
const token = (await runner(['login', '--method=universal-auth', '--plain', ...common], { ...env, ...bootstrap })).trim();
|
||||
if (!token || /\s/.test(token) || token.length > 16_384) throw new Error('Unexpected Infisical login response; values suppressed');
|
||||
const output = await runner([
|
||||
'export', '--projectId', project.workspaceId, '--env', project.defaultEnvironment, '--path', project.secretPath,
|
||||
'--format=json', '--expand=false', '--include-imports=false', '--secret-overriding=false', ...common,
|
||||
], { ...env, INFISICAL_TOKEN: token });
|
||||
return parseExport(output);
|
||||
}
|
||||
|
||||
export function cleanEnvironment(base: Environment): Environment {
|
||||
const managed = new Set<string>([ADMIN_KEY, ...APPLICATION_KEYS]);
|
||||
return Object.fromEntries(Object.entries(base).filter(([key]) => !key.startsWith('INFISICAL_') && !managed.has(key)));
|
||||
}
|
||||
|
||||
const PUBLIC_KEYS = new Set([
|
||||
'APP_MODE', 'APP_ORIGIN', 'APP_AUTH_ISSUER', 'EVE_CALLBACK_URL', 'PUBLIC_CONVEX_URL',
|
||||
'CONVEX_URL', 'CONVEX_SELF_HOSTED_URL', 'CONVEX_SITE_ORIGIN', 'REFERENCE_DIR', 'ESI_USER_AGENT',
|
||||
'WEB_BIND_ADDRESS', 'CONVEX_BIND_ADDRESS', 'WEB_PORT', 'CONVEX_PORT', 'CONVEX_SITE_PORT',
|
||||
'CONVEX_IMAGE', 'CONVEX_DASHBOARD_IMAGE',
|
||||
]);
|
||||
export function applicationEnvironment(base: Environment, secrets: Record<string, string>, publicConfig: Environment, deployment = false): Environment {
|
||||
const env = cleanEnvironment(base);
|
||||
// Public file is the configured deployment, not defaults inherited from Bun's .env.local.
|
||||
for (const [key, value] of Object.entries(publicConfig)) {
|
||||
if (!PUBLIC_KEYS.has(key) || typeof value !== 'string') throw new Error('Unexpected public deployment configuration key');
|
||||
env[key] = value;
|
||||
}
|
||||
// Managed app keys must come from the selected vault, never a stale ambient .env.
|
||||
for (const key of APPLICATION_KEYS) {
|
||||
delete env[key];
|
||||
if (secrets[key] !== undefined) env[key] = secrets[key];
|
||||
}
|
||||
if (deployment && secrets[ADMIN_KEY]) env[ADMIN_KEY] = secrets[ADMIN_KEY];
|
||||
return env;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { homedir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { parseEnv } from 'node:util';
|
||||
import { ADMIN_KEY, applicationEnvironment, fetchSecrets, localDeploymentAdmin, readCredentials, REQUIRED_KEYS, validateProject } from './lib/infisical';
|
||||
|
||||
const HELP = `Infisical-backed development deployment (secret values are never printed by this helper)
|
||||
|
||||
bun run secrets:status
|
||||
bun run secrets:run -- <trusted command> [arguments...]
|
||||
bun run secrets:run --deployment -- <trusted deployment command> [arguments...]
|
||||
|
||||
Optional: --credentials-file PATH (default ~/.config/evebot/infisical.env)
|
||||
Project metadata: .infisical.json. Public deployment configuration: config/dev.env.
|
||||
Only allowlisted app secrets are injected. --deployment additionally permits the Convex admin key
|
||||
and, when absent from the vault, derives it from the local persistent backend without printing it.
|
||||
Never use this wrapper with printenv, env, shell tracing, or docker compose config without --quiet.
|
||||
`;
|
||||
|
||||
export function parseArguments(args: string[]) {
|
||||
const [action, ...rest] = args;
|
||||
if (action !== 'status' && action !== 'run') throw new Error('Use status or run');
|
||||
let command: string[] = [];
|
||||
let credentialsFile = join(homedir(), '.config/evebot/infisical.env');
|
||||
let deployment = false;
|
||||
for (let i = 0; i < rest.length; i++) {
|
||||
if (rest[i] === '--') { command = rest.slice(i + 1); break; }
|
||||
if (rest[i] === '--credentials-file' && rest[i + 1] && !rest[i + 1].startsWith('--')) credentialsFile = resolve(rest[++i]);
|
||||
else if (rest[i] === '--deployment') deployment = true;
|
||||
// `bun run` consumes its own -- separator before forwarding script arguments.
|
||||
else if (action === 'run' && !rest[i].startsWith('-')) { command = rest.slice(i); break; }
|
||||
else throw new Error('Unknown or incomplete secrets helper option');
|
||||
}
|
||||
if (action === 'run' && !command.length) throw new Error('Supply a trusted command after --');
|
||||
if (action === 'status' && command.length) throw new Error('status does not execute a command');
|
||||
return { action, command, credentialsFile, deployment };
|
||||
}
|
||||
|
||||
async function main(args: string[]): Promise<void> {
|
||||
if (!args.length || args[0] === '--help') { console.log(HELP); return; }
|
||||
const { action, command, credentialsFile, deployment } = parseArguments(args);
|
||||
const project = validateProject(JSON.parse(await readFile(new URL('../.infisical.json', import.meta.url), 'utf8')));
|
||||
const credentials = await readCredentials(credentialsFile);
|
||||
const secrets = await fetchSecrets(project, credentials);
|
||||
if (action === 'status') {
|
||||
console.log(JSON.stringify({
|
||||
authenticated: true,
|
||||
domain: project.domain, projectId: project.workspaceId,
|
||||
environment: project.defaultEnvironment, secretPath: project.secretPath,
|
||||
secretNames: Object.keys(secrets).sort(),
|
||||
missingRequiredKeys: REQUIRED_KEYS.filter((key) => !secrets[key]?.trim()),
|
||||
values: 'not displayed',
|
||||
}, null, 2));
|
||||
return;
|
||||
}
|
||||
const publicConfig = parseEnv(await readFile(new URL('../config/dev.env', import.meta.url), 'utf8'));
|
||||
const env = applicationEnvironment(process.env, secrets, publicConfig, deployment);
|
||||
if (deployment && !env[ADMIN_KEY]) env[ADMIN_KEY] = await localDeploymentAdmin(env);
|
||||
const child = Bun.spawn(command, { env, stdin: 'inherit', stdout: 'inherit', stderr: 'inherit' });
|
||||
const interrupt = () => child.kill('SIGINT');
|
||||
const terminate = () => child.kill('SIGTERM');
|
||||
process.on('SIGINT', interrupt);
|
||||
process.on('SIGTERM', terminate);
|
||||
try { process.exitCode = await child.exited; }
|
||||
finally {
|
||||
process.off('SIGINT', interrupt);
|
||||
process.off('SIGTERM', terminate);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
try { await main(process.argv.slice(2)); }
|
||||
catch (error) {
|
||||
// Never print stack/source excerpts or raw CLI errors around the secret boundary.
|
||||
console.error(error instanceof Error ? error.message : 'Secret configuration failed');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ConvexHttpClient } from 'convex/browser';
|
||||
import { makeFunctionReference } from 'convex/server';
|
||||
import { ConvexStore } from '../src/lib/server/store';
|
||||
import { applicationJwt, hashSecret, randomSecret } from '../src/lib/server/security';
|
||||
import assert from 'node:assert/strict';
|
||||
import { SignJWT, importPKCS8 } from 'jose';
|
||||
|
||||
if (process.env.SMOKE_ALLOW_WRITE !== '1') throw new Error('This smoke test writes synthetic records. Set SMOKE_ALLOW_WRITE=1 only for a disposable deployment.');
|
||||
const url = process.env.CONVEX_SELF_HOSTED_URL!;
|
||||
const store = new ConvexStore(url, process.env.CONVEX_BRIDGE_SECRET!);
|
||||
const now = Date.now(); const hash = hashSecret(randomSecret());
|
||||
const characterId = 90_000_000 + Math.floor(Math.random() * 1_000_000);
|
||||
const user = await store.completeLogin({ characterId, name: 'Disposable Test Pilot', ownerHash: hashSecret(randomSecret()), scopes: ['esi-assets.read_assets.v1'], encryptedTokens: 'test-only-encrypted-placeholder', tokenExpiresAt: now + 60_000, sessionHash: hash, sessionExpiresAt: now + 60_000 });
|
||||
assert.equal((await store.session(hash))?.userId, user.userId);
|
||||
const generation = await store.beginSync(user.userId, characterId);
|
||||
await store.stageAssets(user.userId, characterId, generation, 0, [{ itemId: '9223372036854775806', characterId, typeId: 34, quantity: 50, locationId: '60003760', locationType: 'station', locationFlag: 'Hangar' }]);
|
||||
await store.finishSync(user.userId, characterId, generation, 1, now, now + 3_600_000);
|
||||
assert.equal((await store.assets(user.userId, characterId))[0]!.quantity, 50);
|
||||
let isolated = false;
|
||||
try { isolated = (await store.assets('other-user', characterId)).length === 0; } catch { isolated = true; }
|
||||
assert(isolated);
|
||||
const failure = await store.beginSync(user.userId, characterId);
|
||||
await store.stageAssets(user.userId, characterId, failure, 0, []);
|
||||
await store.failSync(user.userId, characterId, failure, 'test failure');
|
||||
assert.equal((await store.assets(user.userId, characterId))[0]!.quantity, 50);
|
||||
const client = new ConvexHttpClient(url, { skipConvexDeploymentUrlCheck: true, logger: false });
|
||||
client.setAuth(await applicationJwt({ ...user, issuer: process.env.APP_AUTH_ISSUER!, privateKey: process.env.APP_AUTH_PRIVATE_KEY! }));
|
||||
const rows = await client.query(makeFunctionReference<'query', Record<string, never>, unknown[] | null>('status:characters'), {});
|
||||
assert.equal(rows?.length, 1);
|
||||
assert(!JSON.stringify(rows).includes('encryptedTokens'));
|
||||
const wrongAudience = new ConvexHttpClient(url, { skipConvexDeploymentUrlCheck: true, logger: false });
|
||||
const key = await importPKCS8(Buffer.from(process.env.APP_AUTH_PRIVATE_KEY!, 'base64').toString('utf8'), 'RS256');
|
||||
wrongAudience.setAuth(await new SignJWT({ sid: user.sessionId }).setProtectedHeader({ alg: 'RS256', typ: 'JWT', kid: 'app-v1' }).setIssuer(process.env.APP_AUTH_ISSUER!).setSubject(user.userId).setAudience('not-eve-intelligence').setIssuedAt().setExpirationTime('5m').sign(key));
|
||||
let wrongAudienceDenied = false;
|
||||
try { const rows = await wrongAudience.query(makeFunctionReference<'query', Record<string, never>, unknown[] | null>('status:characters'), {}); wrongAudienceDenied = rows === null; } catch { wrongAudienceDenied = true; }
|
||||
assert(wrongAudienceDenied, 'Convex must reject a correctly signed token for another audience');
|
||||
await store.deleteSession(hash);
|
||||
assert.equal(await store.session(hash), null);
|
||||
let revoked = false;
|
||||
try { const result = await client.query(makeFunctionReference<'query', Record<string, never>, unknown[] | null>('status:characters'), {}); revoked = result === null || result.length === 0; } catch { revoked = true; }
|
||||
assert(revoked);
|
||||
console.log('Real self-hosted Convex smoke passed: login/session, snapshot promotion, failure preservation, tenant isolation, JWT-safe projection and revocation.');
|
||||
@@ -0,0 +1,16 @@
|
||||
import { JevIntentRouter, JEV_MODEL } from '../src/lib/server/assistant/intent';
|
||||
|
||||
if (process.env.LIVE_API_SMOKE !== '1') throw new Error('Set LIVE_API_SMOKE=1 to authorize one real TypeSafe inference request.');
|
||||
if (!process.env.TYPESAFE_API_KEY) throw new Error('TYPESAFE_API_KEY is missing.');
|
||||
|
||||
const started = performance.now();
|
||||
const result = await new JevIntentRouter({ apiKey: process.env.TYPESAFE_API_KEY }).classify(
|
||||
'What is the total estimated reference value of all my assets across all connected characters?',
|
||||
);
|
||||
if (result.status !== 'routed' || result.intent !== 'asset_valuation') {
|
||||
// The router already redacts provider failures. Do not print raw SDK errors.
|
||||
console.error('Live JEV routing smoke did not return the expected valuation intent.', { status: result.status });
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(JSON.stringify({ passed: true, requestedModel: JEV_MODEL, reportedModel: result.model, intent: result.intent, confidence: result.confidence, elapsedMs: Math.round(performance.now() - started) }));
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const base = process.env.SMOKE_WEB_BASE ?? 'http://127.0.0.1:5173';
|
||||
const origin = process.env.APP_ORIGIN ?? 'https://dev.evebot.app';
|
||||
for (const path of ['/', '/assets', '/characters']) {
|
||||
const response = await fetch(`${base}${path}`, { redirect: 'manual' });
|
||||
assert.equal(response.status, 200, `SSR ${path}`);
|
||||
assert(!(await response.text()).includes('FIXTURE DEMO'), 'live app must not be demo mode');
|
||||
}
|
||||
for (const message of ['/assets', '/value']) {
|
||||
const response = await fetch(`${base}/api/chat`, {
|
||||
method: 'POST', headers: { origin, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ message, requestId: crypto.randomUUID() }),
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
const result = await response.json();
|
||||
assert.equal(result.status, 'authentication_required');
|
||||
assert.equal(result.operation?.status, 'authentication_required');
|
||||
}
|
||||
assert.equal((await fetch(`${base}/api/session/token`)).status, 401);
|
||||
assert.equal((await fetch(`${base}/api/sync`, { method: 'POST', headers: { origin: 'https://untrusted.example' } })).status, 403);
|
||||
console.log('Live anonymous web smoke passed: SSR, no demo fallback, asset/chat authentication gates, session-token protection, and CSRF. No EVE login or character data accessed.');
|
||||
@@ -0,0 +1,11 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { EsiClient, EsiMarketEstimates } from '../src/lib/server/esi';
|
||||
|
||||
if (process.env.LIVE_API_SMOKE !== '1') throw new Error('Set LIVE_API_SMOKE=1 to make a real public ESI prices request.');
|
||||
const userAgent = process.env.ESI_USER_AGENT;
|
||||
if (!userAgent) throw new Error('Set an identifying ESI_USER_AGENT.');
|
||||
const snapshot = await new EsiMarketEstimates(new EsiClient({ userAgent })).estimates([34, 587]);
|
||||
assert.equal(snapshot.prices.length, 2);
|
||||
assert(snapshot.expiresAt > Date.now(), 'price snapshot must be fresh');
|
||||
for (const price of snapshot.prices) assert(BigInt(price.unitPrice) > 0n);
|
||||
console.log(JSON.stringify({ publicEsiPrices: 'passed', source: snapshot.source, asOf: snapshot.asOf, expiresAt: snapshot.expiresAt, prices: snapshot.prices, meaning: 'Global reference estimates, not Jita bids or executable quotes' }));
|
||||
@@ -0,0 +1,20 @@
|
||||
import assert from 'node:assert/strict';
|
||||
const base = process.env.SMOKE_WEB_BASE ?? 'http://localhost:15173';
|
||||
const root = await fetch(base); assert.equal(root.status, 200);
|
||||
assert((await root.text()).includes('FIXTURE DEMO'));
|
||||
const characters = await fetch(`${base}/characters`); assert.equal(characters.status, 200);
|
||||
assert((await characters.text()).includes('Fixture Pilot 1'));
|
||||
const assets = await fetch(`${base}/assets`); assert.equal(assets.status, 200);
|
||||
const html = await assets.text(); assert(html.includes('2,086,000.00'));
|
||||
assert(html.includes('Unpriced'));
|
||||
const request = (message: string, origin = base) => fetch(`${base}/api/chat`, { method: 'POST', headers: { origin, 'content-type': 'application/json' }, body: JSON.stringify({ message, requestId: crypto.randomUUID() }) });
|
||||
for (const command of ['/value', '/assets']) {
|
||||
const response = await request(command); assert.equal(response.status, 200);
|
||||
const result = await response.json(); assert.equal(result.status, 'ready');
|
||||
assert.equal(result.operation.report.total, '208600000');
|
||||
assert.equal(result.operation.report.characters.length, 2);
|
||||
assert.equal(result.operation.report.unpricedItems, 2);
|
||||
}
|
||||
assert.equal((await request('/value', 'https://untrusted.example')).status, 403);
|
||||
assert.equal((await request('x'.repeat(20_000))).status, 413);
|
||||
console.log('Production fixture smoke passed: SSR assistant/characters/assets, shared chat/browse totals, exclusions, CSRF and body limits.');
|
||||
Reference in New Issue
Block a user