feat(server): remove TREK_S3_*/TREK_UPLOADS_DIR — tombstone boot rule, built-in override as the relocation path

This commit is contained in:
jubnl
2026-08-20 00:41:03 +02:00
parent 5758471e4e
commit a6fb82833f
9 changed files with 150 additions and 298 deletions
+8 -5
View File
@@ -66,15 +66,18 @@ fallthrough and the strip-ALL-slashes quirk (see `parsers.ts`
DI in Nest, module-top `readEnv()` consts elsewhere):
PORT, HOST, TRUST_PROXY, SESSION_DURATION(_REMEMBER), MCP_SESSION_TTL,
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_*,
TREK_PLUGIN_REGISTRY_URL, TREK_WIKI_DIR*, TREK_PLACE_PHOTO_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_S3_* (endpoint/bucket/credentials/region/keyPrefix/retries/timeoutMs —
read once by the storage registry at driver init, not on the request path).
TREK_DB_FILE, TREK_DB_JOURNAL_MODE, TREK_DB_SYNCHRONOUS, ENCRYPTION_KEY.
(* frozen today because the consuming module captures it at import; tests that
override these set them at file top, before the SUT import.)
**Tombstoned** (boot-aborting since the admin-config slice — configure storage
in the admin UI / `data/storage-config.json` instead): `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`,
`TREK_UPLOADS_DIR` (see `storageTombstonePreconditions` in `env.ts`).
**Runtime-toggled** (read live on every access via `readEnv()` /
`RuntimeEnvService`; tests mutate these mid-lifetime):
TREK_MANAGED, PLACES_API_BASE, PLACES_API_KEY, MAPBOX_ACCESS_TOKEN, DEMO_MODE, NODE_ENV, APP_VERSION, APP_URL, TREK_API_DOCS_ENABLED,
-29
View File
@@ -18,7 +18,6 @@ import { SUPPORTED_LANGUAGE_CODES } from '@trek/shared';
import {
csvList,
csvListFiltered,
nonNegativeIntOr,
numberOr,
parseBool,
parseDurationMs,
@@ -258,8 +257,6 @@ export function derivePaths(raw: RawEnv) {
return {
wikiDir: raw.TREK_WIKI_DIR,
placePhotoDir: raw.TREK_PLACE_PHOTO_DIR,
/** Uploads root override; unset keeps the computed `<server>/uploads` default (storage registry). */
uploadsDir: raw.TREK_UPLOADS_DIR,
};
}
@@ -269,31 +266,6 @@ 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),
@@ -312,7 +284,6 @@ export function deriveAll(raw: RawEnv) {
backup: deriveBackup(raw),
db: deriveDb(raw),
paths: derivePaths(raw),
s3: deriveS3(raw),
net: deriveNet(raw),
};
}
-11
View File
@@ -124,20 +124,9 @@ export const envSchema = z.object({
TREK_DB_SYNCHRONOUS: anyString,
TREK_WIKI_DIR: anyString,
TREK_PLACE_PHOTO_DIR: anyString,
TREK_UPLOADS_DIR: anyString,
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,
+15 -40
View File
@@ -1,10 +1,5 @@
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 };
@@ -35,7 +30,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), ...s3Preconditions(raw));
lines.push(...managedPreconditions(raw), ...storageTombstonePreconditions(raw));
if (lines.length === 0) return;
console.error(`Invalid environment configuration:\n${lines.join('\n')}`);
throw new Error(
@@ -74,7 +69,15 @@ function managedPreconditions(raw: RawEnv): string[] {
return problems;
}
const S3_VARS = [
/**
* Storage configuration moved wholesale to the admin UI / data/storage-config.json
* (spec: docs/superpowers/specs/2026-08-19-storage-admin-config-design.md).
* These variables are tombstoned rather than ignored: an upgrading operator
* with them set would otherwise silently run local-only — the exact
* half-configured trap the old all-or-nothing rule existed to prevent.
* TREK_PLACE_PHOTO_DIR intentionally survives (documented, prod-supported).
*/
const REMOVED_STORAGE_VARS = [
'TREK_S3_ENDPOINT',
'TREK_S3_BUCKET',
'TREK_S3_ACCESS_KEY_ID',
@@ -83,39 +86,11 @@ const S3_VARS = [
'TREK_S3_KEY_PREFIX',
'TREK_S3_RETRIES',
'TREK_S3_TIMEOUT_MS',
'TREK_UPLOADS_DIR',
] as const;
// Explicit, not `S3_VARS.slice(0, 4)` — the required set must not silently
// shift if S3_VARS is ever reordered.
const S3_REQUIRED: (typeof S3_VARS)[number][] = [
'TREK_S3_ENDPOINT',
'TREK_S3_BUCKET',
'TREK_S3_ACCESS_KEY_ID',
'TREK_S3_SECRET_ACCESS_KEY',
];
/**
* 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: (typeof S3_VARS)[number]): 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;
function storageTombstonePreconditions(raw: RawEnv): string[] {
return REMOVED_STORAGE_VARS.filter((key) => Boolean(raw[key]?.trim())).map(
(key) => ` - ${key} was removed — configure storage in the admin UI or data/storage-config.json.`,
);
}
-1
View File
@@ -20,7 +20,6 @@ export {
deriveDb,
derivePaths,
deriveNet,
deriveS3,
} from './derive';
export * from './parsers';
export { envSchema } from './env.schema';
@@ -177,34 +177,17 @@ export class StorageRegistryService implements OnModuleInit {
private build(settings: { backends: unknown; categories: unknown }, boot: boolean): RegistryState {
// 1. Env is read fresh on every load (never snapshotted — RuntimeEnvService rule).
const paths = this.env.env().paths;
const uploadsRoot = paths.uploadsDir || DEFAULT_UPLOADS_ROOT;
const placePhotoDir = paths.placePhotoDir;
const placePhotoDir = this.env.env().paths.placePhotoDir;
// 2. Built-in defaults; settings entries with the same name/category override.
// uploads-local's root is the computed default since TREK_UPLOADS_DIR was
// removed — relocation is a settings override row with the built-in's name.
const backends = new Map<string, BackendConfig>();
backends.set('uploads-local', { name: 'uploads-local', type: 'local', options: { root: uploadsRoot } });
backends.set('uploads-local', { name: 'uploads-local', type: 'local', options: { root: DEFAULT_UPLOADS_ROOT } });
backends.set('backups-local', { name: 'backups-local', type: 'local', options: { root: DEFAULT_BACKUPS_ROOT } });
if (placePhotoDir) {
backends.set('place-photos-local', { name: 'place-photos-local', type: 'local', options: { root: placePhotoDir } });
}
const s3 = this.env.env().s3;
if (s3.configured) {
backends.set('s3-main', {
name: 's3-main',
type: 's3',
options: {
endpoint: s3.endpoint,
region: s3.region,
bucket: s3.bucket,
keyPrefix: s3.keyPrefix,
accessKeyId: s3.accessKeyId,
secretAccessKey: s3.secretAccessKey,
retries: s3.retries,
timeoutMs: s3.timeoutMs,
},
});
}
for (const config of parseBackendList(settings.backends)) backends.set(config.name, config);
const categoryBackends = new Map<StorageCategory, string>();