Skip to content

Commit d362e68

Browse files
improvement(setup): reuse shared helpers, parallelize probes, drop dead code
- PKCE verifier/state/pairing code now use generateSecureToken, generateRandomHex and generateShortId instead of hand-rolled randomBytes; the pairing loop's modulo was unbiased only because 256 % 32 == 0 - new sha256Base64Url in @sim/security/hash so both sides of the PKCE exchange derive the challenge from one implementation - isUsableSecret moved beside SECRET_KEYS so setup and doctor apply the same rule; doctor previously passed a key setup would replace - isTruthy narrowed to true/1, matching the app it claims to mirror — it accepted yes/on, so a flag could read on in doctor and off in the app - checkLive runs its five probes concurrently (~17s serial worst case) - detection overlaps the banner animation instead of queueing behind it - glyph.fail/glyph.warn at 13 sites that bypassed the constant; removed unused prompter exports, a dead ENV_PATHS re-export, and an unused export keyword
1 parent 65e6ef1 commit d362e68

16 files changed

Lines changed: 161 additions & 110 deletions

File tree

apps/sim/lib/cli-auth/code-store.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { createHash } from 'node:crypto'
21
import { safeCompare } from '@sim/security/compare'
3-
import { sha256Hex } from '@sim/security/hash'
2+
import { sha256Base64Url, sha256Hex } from '@sim/security/hash'
43
import { generateShortId } from '@sim/utils/id'
54
import { getRedisClient } from '@/lib/core/config/redis'
65

@@ -40,9 +39,9 @@ function codeKey(code: string): string {
4039
return `cli:auth:code:${sha256Hex(code)}`
4140
}
4241

43-
/** Base64url rather than hex: RFC 7636 defines the PKCE challenge that way. */
42+
/** Shares its implementation with the CLI so the two sides cannot drift apart. */
4443
function challengeFor(verifier: string): string {
45-
return createHash('sha256').update(verifier).digest('base64url')
44+
return sha256Base64Url(verifier)
4645
}
4746

4847
/** Returns the plaintext code, which is never stored. */

packages/security/src/hash.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,18 @@ export function sha256Hex(input: string | Uint8Array): string {
1515
}
1616
return hash.digest('hex')
1717
}
18+
19+
/**
20+
* SHA-256 digest, base64url-encoded. The encoding PKCE specifies for a code
21+
* challenge (RFC 7636), so both sides of a challenge/verifier exchange can
22+
* derive it from one implementation instead of two that must stay in step.
23+
*/
24+
export function sha256Base64Url(input: string | Uint8Array): string {
25+
const hash = createHash('sha256')
26+
if (typeof input === 'string') {
27+
hash.update(input, 'utf8')
28+
} else {
29+
hash.update(input)
30+
}
31+
return hash.digest('base64url')
32+
}

scripts/setup/checks.ts

Lines changed: 67 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import {
55
generateSecret,
66
isPlaceholder,
77
isTruthy,
8+
isUsableSecret,
89
readEnvFile,
910
SECRET_KEYS,
1011
SHARED_KEYS,
12+
secretRequirement,
1113
writeEnvValues,
1214
} from './env-files.ts'
1315
import { httpHealth, pgProbe, redisPing } from './probes.ts'
@@ -153,11 +155,11 @@ function checkSchema(ctx: CheckContext): Finding[] {
153155
})
154156
continue
155157
}
156-
if (MIN_32_KEYS.has(key) && value.length < 32) {
158+
if (MIN_32_KEYS.has(key) && !isUsableSecret(key, value)) {
157159
findings.push({
158160
group: 'schema',
159161
status: 'fail',
160-
message: `${rel(file)}: ${key} is shorter than 32 chars — the realtime server rejects it`,
162+
message: `${rel(file)}: ${key} ${secretRequirement(key)}`,
161163
fix: 'generate a new one with `openssl rand -hex 32` (rotating it invalidates existing sessions/encrypted data)',
162164
})
163165
continue
@@ -436,9 +438,8 @@ function checkCoherence(ctx: CheckContext): Finding[] {
436438
return findings
437439
}
438440

439-
async function checkLive(ctx: CheckContext): Promise<Finding[]> {
441+
async function checkDatabase(sim: EnvFile): Promise<Finding[]> {
440442
const findings: Finding[] = []
441-
const sim = ctx.env.sim
442443
const dsn = sim.vars.get('DATABASE_URL')
443444
const dsnPassword = (() => {
444445
try {
@@ -497,53 +498,73 @@ async function checkLive(ctx: CheckContext): Promise<Finding[]> {
497498
})
498499
}
499500

501+
return findings
502+
}
503+
504+
async function checkRedis(sim: EnvFile): Promise<Finding[]> {
500505
const redisUrl = sim.vars.get('REDIS_URL')
501-
if (redisUrl) {
502-
const ping = await redisPing(redisUrl)
503-
findings.push(
504-
ping.ok
505-
? { group: 'live', status: 'pass', message: 'redis reachable' }
506-
: {
507-
group: 'live',
508-
status: 'fail',
509-
message: `redis unreachable: ${ping.error}`,
510-
fix: 'fix REDIS_URL or remove it (optional for single-replica)',
511-
}
512-
)
513-
}
506+
if (!redisUrl) return []
507+
const ping = await redisPing(redisUrl)
508+
return [
509+
ping.ok
510+
? { group: 'live', status: 'pass', message: 'redis reachable' }
511+
: {
512+
group: 'live',
513+
status: 'fail',
514+
message: `redis unreachable: ${ping.error}`,
515+
fix: 'fix REDIS_URL or remove it (optional for single-replica)',
516+
},
517+
]
518+
}
514519

515-
for (const [label, port, url] of [
516-
['app', 3000, 'http://localhost:3000/api/health'],
517-
['realtime', 3002, 'http://localhost:3002/health'],
518-
] as const) {
519-
if (!(await portOpen(port))) {
520-
findings.push({ group: 'live', status: 'skip', message: `${label}: not running on :${port}` })
521-
} else if (await httpHealth(url)) {
522-
findings.push({ group: 'live', status: 'pass', message: `${label} healthy on :${port}` })
523-
} else {
524-
findings.push({
525-
group: 'live',
526-
status: 'fail',
527-
message: `${label}: something is on :${port} but ${url} is not answering`,
528-
fix: 'check the dev server logs',
529-
})
530-
}
520+
async function checkService(label: string, port: number, url: string): Promise<Finding[]> {
521+
if (!(await portOpen(port))) {
522+
return [{ group: 'live', status: 'skip', message: `${label}: not running on :${port}` }]
523+
}
524+
if (await httpHealth(url)) {
525+
return [{ group: 'live', status: 'pass', message: `${label} healthy on :${port}` }]
531526
}
527+
return [
528+
{
529+
group: 'live',
530+
status: 'fail',
531+
message: `${label}: something is on :${port} but ${url} is not answering`,
532+
fix: 'check the dev server logs',
533+
},
534+
]
535+
}
532536

537+
async function checkOllama(sim: EnvFile): Promise<Finding[]> {
533538
const ollamaUrl = sim.vars.get('OLLAMA_URL')
534-
if (ollamaUrl) {
535-
findings.push(
536-
(await httpHealth(`${ollamaUrl.replace(/\/$/, '')}/api/tags`))
537-
? { group: 'live', status: 'pass', message: 'ollama reachable' }
538-
: {
539-
group: 'live',
540-
status: 'warn',
541-
message: 'OLLAMA_URL is set but Ollama is not answering',
542-
fix: 'start Ollama or remove OLLAMA_URL',
543-
}
544-
)
545-
}
546-
return findings
539+
if (!ollamaUrl) return []
540+
return [
541+
(await httpHealth(`${ollamaUrl.replace(/\/$/, '')}/api/tags`))
542+
? { group: 'live', status: 'pass', message: 'ollama reachable' }
543+
: {
544+
group: 'live',
545+
status: 'warn',
546+
message: 'OLLAMA_URL is set but Ollama is not answering',
547+
fix: 'start Ollama or remove OLLAMA_URL',
548+
},
549+
]
550+
}
551+
552+
/**
553+
* The five probes are independent, so they run concurrently — serially this is
554+
* the sum of every timeout (~17s worst case) on a command whose whole job is to
555+
* tell you what's broken. Results are concatenated in a fixed order so the
556+
* report stays deterministic regardless of which probe settles first.
557+
*/
558+
async function checkLive(ctx: CheckContext): Promise<Finding[]> {
559+
const sim = ctx.env.sim
560+
const [database, redis, app, realtime, ollama] = await Promise.all([
561+
checkDatabase(sim),
562+
checkRedis(sim),
563+
checkService('app', 3000, 'http://localhost:3000/api/health'),
564+
checkService('realtime', 3002, 'http://localhost:3002/health'),
565+
checkOllama(sim),
566+
])
567+
return [...database, ...redis, ...app, ...realtime, ...ollama]
547568
}
548569

549570
export async function runChecks(ctx: CheckContext, groups?: CheckGroup[]): Promise<Finding[]> {

scripts/setup/cli-auth.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { spawnSync } from 'node:child_process'
2-
import { createHash, randomBytes } from 'node:crypto'
32
import http from 'node:http'
3+
import { sha256Base64Url } from '@sim/security/hash'
4+
import { generateSecureToken } from '@sim/security/tokens'
5+
import { generateShortId } from '@sim/utils/id'
6+
import { generateRandomHex } from '@sim/utils/random'
47
import * as p from './prompter.ts'
58
import { link, theme } from './theme.ts'
69

@@ -18,8 +21,8 @@ function openBrowser(url: string): void {
1821
* through the browser — so a code intercepted in transit cannot be redeemed.
1922
*/
2023
function createPkcePair(): { verifier: string; challenge: string } {
21-
const verifier = randomBytes(32).toString('base64url')
22-
return { verifier, challenge: createHash('sha256').update(verifier).digest('base64url') }
24+
const verifier = generateSecureToken(32)
25+
return { verifier, challenge: sha256Base64Url(verifier) }
2326
}
2427

2528
/** No O/0 or I/1 — this exists to be compared by eye against a browser tab. */
@@ -35,9 +38,8 @@ const PAIRING_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
3538
* a code you don't recognise, the approval isn't yours.
3639
*/
3740
function createPairingCode(): string {
38-
const bytes = randomBytes(8)
39-
const chars = Array.from(bytes, (byte) => PAIRING_ALPHABET[byte % PAIRING_ALPHABET.length])
40-
return `${chars.slice(0, 4).join('')}-${chars.slice(4).join('')}`
41+
const chars = generateShortId(8, PAIRING_ALPHABET)
42+
return `${chars.slice(0, 4)}-${chars.slice(4)}`
4143
}
4244

4345
export interface CodeListener {
@@ -56,7 +58,7 @@ export interface CodeListener {
5658
* mismatch.
5759
*/
5860
export function startCodeListener(origin: string): Promise<CodeListener> {
59-
const state = randomBytes(16).toString('hex')
61+
const state = generateRandomHex(32)
6062
const { verifier, challenge } = createPkcePair()
6163
const pairingCode = createPairingCode()
6264

scripts/setup/db.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { generateSecret } from './env-files.ts'
55
import { SetupError } from './errors.ts'
66
import { pgProbe, waitFor } from './probes.ts'
77
import * as p from './prompter.ts'
8-
import { theme } from './theme.ts'
8+
import { glyph, theme } from './theme.ts'
99

1010
const DEFAULT_DSN = 'postgresql://postgres:postgres@localhost:5432/simstudio'
1111

@@ -21,12 +21,10 @@ async function probeWithSpinner(dsn: string, label: string): Promise<boolean> {
2121
spin.start(label)
2222
const probe = await pgProbe(dsn)
2323
if (probe.ok && probe.pgvectorAvailable === false) {
24-
spin.stop(`${theme.warn('!')} connected, but pgvector is missing on that Postgres`)
24+
spin.stop(`${glyph.warn} connected, but pgvector is missing on that Postgres`)
2525
return false
2626
}
27-
spin.stop(
28-
probe.ok ? 'database reachable (pgvector available)' : `${theme.warn('!')} ${probe.error}`
29-
)
27+
spin.stop(probe.ok ? 'database reachable (pgvector available)' : `${glyph.warn} ${probe.error}`)
3028
return probe.ok
3129
}
3230

@@ -84,7 +82,7 @@ async function startManagedContainer(detection: Detection): Promise<string> {
8482
spin.start(`Starting ${DB_CONTAINER} container on :${hostPort}…`)
8583
const healthy = await waitFor(async () => (await pgProbe(dsn)).ok, 45_000, 1500)
8684
if (!healthy) {
87-
spin.stop(`${theme.error('✗')} container did not become healthy`)
85+
spin.stop(`${glyph.fail} container did not become healthy`)
8886
const logs = spawnSync('docker', ['logs', '--tail', '20', DB_CONTAINER], { encoding: 'utf8' })
8987
throw new SetupError(
9088
`the Postgres container failed to start. Last logs:\n${logs.stdout}${logs.stderr}`,
@@ -113,9 +111,7 @@ async function restartManagedContainer(existingDsn: string | undefined): Promise
113111
const spin = p.spinner()
114112
spin.start(`Starting existing ${DB_CONTAINER} container…`)
115113
const healthy = await waitFor(async () => (await pgProbe(existingDsn)).ok, 30_000, 1500)
116-
spin.stop(
117-
healthy ? `${DB_CONTAINER} running` : `${theme.error('✗')} ${DB_CONTAINER} did not come up`
118-
)
114+
spin.stop(healthy ? `${DB_CONTAINER} running` : `${glyph.fail} ${DB_CONTAINER} did not come up`)
119115
if (!healthy) throw new Error(`${DB_CONTAINER} started but is not answering on ${existingDsn}`)
120116
return existingDsn
121117
}

scripts/setup/detect.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { spawnSync } from 'node:child_process'
22
import net from 'node:net'
33
import os from 'node:os'
4-
import { ENV_PATHS, ROOT, readEnvFile } from './env-files.ts'
4+
import { ROOT, readEnvFile } from './env-files.ts'
55

66
export const MANAGED_LABEL = 'managed-by=sim-setup'
77
export const DB_CONTAINER = 'sim-postgres'
@@ -147,5 +147,3 @@ export async function runDetection(): Promise<Detection> {
147147
specs: detectSpecs(dockerRunning),
148148
}
149149
}
150-
151-
export { ENV_PATHS }

scripts/setup/docker.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { spawnSync } from 'node:child_process'
22
import { SetupError } from './errors.ts'
33
import { waitFor } from './probes.ts'
44
import * as p from './prompter.ts'
5-
import { theme } from './theme.ts'
5+
import { glyph, theme } from './theme.ts'
66

77
const INSTALL_HINTS = [
88
'install Docker Desktop: https://docker.com/products/docker-desktop',
@@ -56,7 +56,7 @@ export async function ensureDocker(required: boolean): Promise<boolean> {
5656
const spin = p.spinner()
5757
spin.start('Waiting for the Docker daemon…')
5858
const up = await waitFor(async () => daemonUp(), 90_000, 2000)
59-
spin.stop(up ? 'Docker is running' : `${theme.error('✗')} daemon did not come up`)
59+
spin.stop(up ? 'Docker is running' : `${glyph.fail} daemon did not come up`)
6060
if (!up) {
6161
throw new SetupError('Docker Desktop did not start within 90s.', [
6262
'first-ever launch needs a GUI license acceptance — open Docker Desktop manually once, then re-run',

scripts/setup/env-files.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { randomBytes } from 'node:crypto'
21
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
32
import path from 'node:path'
43
import { fileURLToPath } from 'node:url'
4+
import { generateRandomHex } from '@sim/utils/random'
55

66
export const ROOT = path.resolve(fileURLToPath(new URL('.', import.meta.url)), '../..')
77

@@ -130,14 +130,46 @@ export function archiveEnvFile(target: EnvTarget): string | null {
130130
}
131131

132132
export function generateSecret(): string {
133-
return randomBytes(32).toString('hex')
133+
return generateRandomHex(64)
134+
}
135+
136+
/**
137+
* `ENCRYPTION_KEY` and `API_ENCRYPTION_KEY` are read as raw AES-256 material,
138+
* so the app requires exactly 64 hex characters and throws on anything else
139+
* (`lib/core/security/encryption.ts`, `lib/api-key/crypto.ts`). A merely-long
140+
* passphrase passes a length check and then fails every encryption path at
141+
* runtime, so those two are validated on format rather than length.
142+
*
143+
* Lives here so setup (which replaces an unusable secret) and doctor (which
144+
* reports one) apply the same rule — they disagreed while it was duplicated.
145+
*/
146+
const HEX_KEY_PATTERN = /^[0-9a-f]{64}$/i
147+
const HEX_SECRET_KEYS = new Set<string>(['ENCRYPTION_KEY', 'API_ENCRYPTION_KEY'])
148+
149+
export function isUsableSecret(key: string, value: string): boolean {
150+
if (isPlaceholder(value)) return false
151+
return HEX_SECRET_KEYS.has(key) ? HEX_KEY_PATTERN.test(value) : value.length >= 32
152+
}
153+
154+
/** Human-readable reason a secret is unusable, for doctor's finding message. */
155+
export function secretRequirement(key: string): string {
156+
return HEX_SECRET_KEYS.has(key)
157+
? 'must be exactly 64 hex characters (32-byte AES key)'
158+
: 'must be at least 32 characters'
134159
}
135160

136161
export function isPlaceholder(value: string): boolean {
137162
return PLACEHOLDER_VALUES.has(value) || value.startsWith('your_')
138163
}
139164

140-
/** Mirrors the app's isTruthy env coercion (apps/sim/lib/core/config/env.ts). */
165+
/**
166+
* Mirrors the app's `isTruthy` (apps/sim/lib/core/config/env.ts:633) exactly —
167+
* `true` or `1` only. The app's separate `envBoolean` additionally accepts
168+
* `yes`/`on`, but feature flags read through `isTruthy`, so accepting the wider
169+
* set here made the wizard and doctor report a flag as on that the app treats
170+
* as off.
171+
*/
141172
export function isTruthy(value: string | undefined): boolean {
142-
return value !== undefined && ['true', '1', 'yes', 'on'].includes(value.toLowerCase())
173+
if (value === undefined) return false
174+
return value.toLowerCase() === 'true' || value === '1'
143175
}

scripts/setup/modes/compose.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
promptStorage,
1616
promptUnlocks,
1717
} from '../steps.ts'
18-
import { theme } from '../theme.ts'
18+
import { glyph, theme } from '../theme.ts'
1919

2020
interface BusyPort {
2121
port: number
@@ -158,7 +158,7 @@ export async function runComposeMode(detection: Detection, quick: boolean): Prom
158158
const realtimeHealthy =
159159
appHealthy && (await waitFor(() => httpHealth('http://localhost:3002/health'), 60_000, 2000))
160160
if (!appHealthy || !realtimeHealthy) {
161-
spin.stop(`${theme.error('✗')} services did not become healthy`)
161+
spin.stop(`${glyph.fail} services did not become healthy`)
162162
throw new SetupError(
163163
`${!appHealthy ? 'the app (:3000)' : 'realtime (:3002)'} never answered its health check.`,
164164
[

0 commit comments

Comments
 (0)