feat(server): TREK_S3_* env wiring — schema, s3 namespace, all-or-nothing boot rule

This commit is contained in:
jubnl
2026-08-19 20:27:50 +02:00
parent 8ae73a2b5c
commit bc4d70986f
9 changed files with 170 additions and 3 deletions
+3 -1
View File
@@ -69,7 +69,9 @@ MCP_MAX_SESSION_PER_USER, MCP_SSE_KEEPALIVE, TREK_PLUGIN_RPC_*/LOG_*/MAX_RSS_MB,
TREK_PLUGIN_REGISTRY_URL, TREK_WIKI_DIR*, TREK_PLACE_PHOTO_DIR,
TREK_UPLOADS_DIR, BACKUP_*,
TRANSIT_API_URL, LOG_LEVEL*, ALLOW_INTERNAL_NETWORK*, DEFAULT_LANGUAGE,
TREK_DB_FILE, TREK_DB_JOURNAL_MODE, TREK_DB_SYNCHRONOUS, ENCRYPTION_KEY.
TREK_DB_FILE, TREK_DB_JOURNAL_MODE, TREK_DB_SYNCHRONOUS, ENCRYPTION_KEY,
TREK_S3_* (endpoint/bucket/credentials/region/keyPrefix/retries/timeoutMs —
read once by the storage registry at driver init, not on the request path).
(* frozen today because the consuming module captures it at import; tests that
override these set them at file top, before the SUT import.)
+27
View File
@@ -18,6 +18,7 @@ import { SUPPORTED_LANGUAGE_CODES } from '@trek/shared';
import {
csvList,
csvListFiltered,
nonNegativeIntOr,
numberOr,
parseBool,
parseDurationMs,
@@ -268,6 +269,31 @@ export function deriveNet(raw: RawEnv) {
};
}
/**
* S3 backend (spec: docs/superpowers/specs/2026-08-17-s3-storage-driver-design.md).
* New namespace — no legacy call site, so semantics are chosen clean: trim
* everything, blank = unset. `configured` is the registry's gate; partial sets
* are refused at boot by s3Preconditions (env.ts), never silently degraded.
*/
export function deriveS3(raw: RawEnv) {
const endpoint = (raw.TREK_S3_ENDPOINT ?? '').trim();
const bucket = (raw.TREK_S3_BUCKET ?? '').trim();
const accessKeyId = (raw.TREK_S3_ACCESS_KEY_ID ?? '').trim();
const secretAccessKey = (raw.TREK_S3_SECRET_ACCESS_KEY ?? '').trim();
return {
configured: Boolean(endpoint && bucket && accessKeyId && secretAccessKey),
endpoint,
bucket,
accessKeyId,
secretAccessKey,
/** Arbitrary values accepted — non-AWS endpoints ignore it (R2 wants 'auto'). */
region: (raw.TREK_S3_REGION ?? '').trim() || 'us-east-1',
keyPrefix: (raw.TREK_S3_KEY_PREFIX ?? '').trim(),
retries: nonNegativeIntOr(raw.TREK_S3_RETRIES, 1),
timeoutMs: positiveNumberOr(raw.TREK_S3_TIMEOUT_MS, 30000),
};
}
export function deriveAll(raw: RawEnv) {
return {
app: deriveApp(raw),
@@ -286,6 +312,7 @@ export function deriveAll(raw: RawEnv) {
backup: deriveBackup(raw),
db: deriveDb(raw),
paths: derivePaths(raw),
s3: deriveS3(raw),
net: deriveNet(raw),
};
}
+10
View File
@@ -128,6 +128,16 @@ export const envSchema = z.object({
BACKUP_UPLOAD_LIMIT_MB: positiveNumber,
BACKUP_MAX_DECOMPRESSED_MB: positiveNumber,
// S3 storage backend (all-or-nothing set — cross-field rule in env.ts s3Preconditions)
TREK_S3_ENDPOINT: url,
TREK_S3_BUCKET: anyString,
TREK_S3_ACCESS_KEY_ID: anyString,
TREK_S3_SECRET_ACCESS_KEY: anyString,
TREK_S3_REGION: anyString,
TREK_S3_KEY_PREFIX: anyString,
TREK_S3_RETRIES: integer(0, Number.MAX_SAFE_INTEGER, 'must be a non-negative integer (0 disables retries)'),
TREK_S3_TIMEOUT_MS: positiveNumber,
// Admin / demo
ADMIN_EMAIL: anyString,
ADMIN_PASSWORD: anyString,
+45 -1
View File
@@ -1,5 +1,10 @@
import { deriveAll, type AppEnv, type RawEnv } from './derive';
import { envSchema } from './env.schema';
// storage-keys is the single source of truth for key/prefix shape — deliberately
// imported here rather than re-implementing the rule for TREK_S3_KEY_PREFIX. It
// is a plain module with no imports back into app-config, so this does not
// create a cycle.
import { isValidPrefix } from '../nest/storage/storage-keys';
export type { AppEnv, RawEnv };
@@ -30,7 +35,7 @@ export function validateEnvAtBoot(raw: RawEnv = process.env as RawEnv): void {
const key = String(issue.path[0]);
return ` - ${key}=${JSON.stringify(raw[key])}: ${issue.message}`;
});
lines.push(...managedPreconditions(raw));
lines.push(...managedPreconditions(raw), ...s3Preconditions(raw));
if (lines.length === 0) return;
console.error(`Invalid environment configuration:\n${lines.join('\n')}`);
throw new Error(
@@ -68,3 +73,42 @@ function managedPreconditions(raw: RawEnv): string[] {
return problems;
}
const S3_VARS = [
'TREK_S3_ENDPOINT',
'TREK_S3_BUCKET',
'TREK_S3_ACCESS_KEY_ID',
'TREK_S3_SECRET_ACCESS_KEY',
'TREK_S3_REGION',
'TREK_S3_KEY_PREFIX',
'TREK_S3_RETRIES',
'TREK_S3_TIMEOUT_MS',
] as const;
const S3_REQUIRED = S3_VARS.slice(0, 4);
/**
* All-or-nothing: any TREK_S3_* set means the operator wants off-box storage;
* booting with a partial set would silently run without it (fail closed).
* Prefix shape delegates to storage-keys — the single source of key rules.
*/
function s3Preconditions(raw: RawEnv): string[] {
const present = (key: string): boolean => Boolean(raw[key]?.trim());
if (!S3_VARS.some(present)) return [];
const problems: string[] = [];
for (const key of S3_REQUIRED) {
if (!present(key)) {
problems.push(
` - ${key} is unset: required when any TREK_S3_* variable is set, because a ` +
'half-configured s3 backend would boot without the off-box storage the operator asked for.',
);
}
}
const prefix = raw.TREK_S3_KEY_PREFIX?.trim().replace(/^\/+|\/+$/g, '');
if (prefix && !isValidPrefix(`${prefix}/`)) {
problems.push(
` - TREK_S3_KEY_PREFIX=${JSON.stringify(raw.TREK_S3_KEY_PREFIX)}: must be plain key segments ` +
'(no dot-segments, backslashes, or control characters).',
);
}
return problems;
}
+1
View File
@@ -20,6 +20,7 @@ export {
deriveDb,
derivePaths,
deriveNet,
deriveS3,
} from './derive';
export * from './parsers';
export { envSchema } from './env.schema';
+7
View File
@@ -46,6 +46,13 @@ export function positiveIntOr(raw: string | undefined, fallback: number): number
return Number.isFinite(n) && n > 0 ? n : fallback;
}
/** Integer ≥ 0 (zero is meaningful — e.g. retries disabled), else the fallback. */
export function nonNegativeIntOr(raw: string | undefined, fallback: number): number {
if (raw === undefined || raw.trim() === '') return fallback;
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) && n >= 0 ? n : fallback;
}
/** Comma-split + trim, null when unset — the websocket ALLOWED_ORIGINS variant (no empty-entry filter). */
export function csvList(raw: string | undefined): string[] | null {
return raw ? raw.split(',').map((o) => o.trim()) : null;