mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
feat(server): remove TREK_S3_*/TREK_UPLOADS_DIR — tombstone boot rule, built-in override as the relocation path
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
Reference in New Issue
Block a user