mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 05:56:53 +00:00
feat(server): TREK_S3_* env wiring — schema, s3 namespace, all-or-nothing boot rule
This commit is contained in:
@@ -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.)
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export {
|
||||
deriveDb,
|
||||
derivePaths,
|
||||
deriveNet,
|
||||
deriveS3,
|
||||
} from './derive';
|
||||
export * from './parsers';
|
||||
export { envSchema } from './env.schema';
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user