From 8bfe2d2e5255422518c43b5a17d7961cfb24c811 Mon Sep 17 00:00:00 2001 From: jubnl Date: Thu, 20 Aug 2026 00:24:18 +0200 Subject: [PATCH] feat(shared): storage backend type registry, wire schemas, category relocation --- server/src/nest/storage/storage.types.ts | 15 +- shared/src/index.ts | 1 + shared/src/storage/storage.schema.spec.ts | 143 +++++++++++++ shared/src/storage/storage.schema.ts | 243 ++++++++++++++++++++++ 4 files changed, 390 insertions(+), 12 deletions(-) create mode 100644 shared/src/storage/storage.schema.spec.ts create mode 100644 shared/src/storage/storage.schema.ts diff --git a/server/src/nest/storage/storage.types.ts b/server/src/nest/storage/storage.types.ts index ddbb81524..e9bfc8ea9 100644 --- a/server/src/nest/storage/storage.types.ts +++ b/server/src/nest/storage/storage.types.ts @@ -11,18 +11,9 @@ import type { Readable } from 'node:stream'; * — zero changes to controllers or services. */ -export const STORAGE_CATEGORIES = [ - 'files', - 'journey', - 'covers', - 'avatars', - 'places', - 'photos', - 'photos-google', - 'photos-trek', - 'backups', -] as const; -export type StorageCategory = (typeof STORAGE_CATEGORIES)[number]; +// Relocated to shared (the wire schema needs it; a second copy is forbidden). +// Re-exported so every existing `./storage.types` import keeps working. +export { STORAGE_CATEGORIES, type StorageCategory } from '@trek/shared'; export interface ObjectStat { key: string; diff --git a/shared/src/index.ts b/shared/src/index.ts index f0d7c4c81..ca5413db6 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -41,6 +41,7 @@ export * from './collab/collab.schema'; export * from './file/file.schema'; export * from './journey/journey.schema'; export * from './share/share.schema'; +export * from './storage/storage.schema'; export * from './settings/settings.schema'; export * from './appearance/appearance.schema'; export * from './backup/backup.schema'; diff --git a/shared/src/storage/storage.schema.spec.ts b/shared/src/storage/storage.schema.spec.ts new file mode 100644 index 000000000..2454a057d --- /dev/null +++ b/shared/src/storage/storage.schema.spec.ts @@ -0,0 +1,143 @@ +import { + STORAGE_BACKEND_TYPES, + STORAGE_BACKEND_TYPE_IDS, + STORAGE_CATEGORIES, + storageConfigSchema, + storageSecretFields, + type StorageBackendTypeId, +} from './storage.schema'; + +import { describe, expect, it } from 'vitest'; + +/** + * Per-type samples with EVERY declared field present. The parity suite pins + * that these key sets and the generated schemas cannot drift from the field + * definitions — the registry's whole extensibility story rests on that. + */ +const SAMPLES: Record> = { + local: { root: '/data/uploads' }, + s3: { + endpoint: 'http://127.0.0.1:9000', + bucket: 'trek', + accessKeyId: 'ak', + secretAccessKey: 'sk', + region: 'auto', + keyPrefix: 'trek/prod', + retries: 0, + timeoutMs: 5000, + }, + mirror: { primary: 'backups-local', replicas: ['nas-backups'] }, +}; + +describe('STORAGE_BACKEND_TYPES generation parity', () => { + it.each(STORAGE_BACKEND_TYPE_IDS)('%s: sample keys equal the declared field keys exactly', (type) => { + const declared = STORAGE_BACKEND_TYPES[type].fields.map((f) => f.key).sort(); + expect(Object.keys(SAMPLES[type]).sort()).toEqual(declared); + }); + + it.each(STORAGE_BACKEND_TYPE_IDS)('%s: every declared field round-trips through the generated schema', (type) => { + const parsed = STORAGE_BACKEND_TYPES[type].optionsSchema.parse(SAMPLES[type]) as Record; + for (const field of STORAGE_BACKEND_TYPES[type].fields) { + expect(parsed).toHaveProperty(field.key); + } + }); + + it.each(STORAGE_BACKEND_TYPE_IDS)('%s: unknown option keys are rejected (strict at every level)', (type) => { + const result = STORAGE_BACKEND_TYPES[type].optionsSchema.safeParse({ ...SAMPLES[type], rogue: 'x' }); + expect(result.success).toBe(false); + }); + + it('secret fields are required strings (the encrypt/mask machinery depends on it)', () => { + expect(storageSecretFields('s3')).toEqual(['secretAccessKey']); + expect(storageSecretFields('local')).toEqual([]); + expect(storageSecretFields('mirror')).toEqual([]); + for (const type of STORAGE_BACKEND_TYPE_IDS) { + for (const field of STORAGE_BACKEND_TYPES[type].fields) { + if (field.kind !== 'secret') continue; + expect(field.required).toBe(true); + const parsed = STORAGE_BACKEND_TYPES[type].optionsSchema.parse(SAMPLES[type]) as Record; + expect(typeof parsed[field.key]).toBe('string'); + } + } + }); + + it('every non-required field carries a defaultValue, so parsed options are always total', () => { + for (const type of STORAGE_BACKEND_TYPE_IDS) { + for (const field of STORAGE_BACKEND_TYPES[type].fields) { + if (!field.required) expect(field.defaultValue).toBeDefined(); + } + } + }); + + it('defaults fill omitted optional s3 fields', () => { + const parsed = STORAGE_BACKEND_TYPES.s3.optionsSchema.parse({ + endpoint: 'http://127.0.0.1:9000', + bucket: 'trek', + accessKeyId: 'ak', + secretAccessKey: 'sk', + }); + expect(parsed).toMatchObject({ region: 'us-east-1', keyPrefix: '', retries: 1, timeoutMs: 30000 }); + }); + + it('field refinements layer onto the base kind (endpoint URL, non-negative retries, positive timeout)', () => { + const base = SAMPLES.s3; + expect(STORAGE_BACKEND_TYPES.s3.optionsSchema.safeParse({ ...base, endpoint: 'not a url' }).success).toBe(false); + expect(STORAGE_BACKEND_TYPES.s3.optionsSchema.safeParse({ ...base, retries: -1 }).success).toBe(false); + expect(STORAGE_BACKEND_TYPES.s3.optionsSchema.safeParse({ ...base, timeoutMs: 0 }).success).toBe(false); + }); +}); + +describe('storageConfigSchema wire compatibility', () => { + it('accepts the hand-written S3-slice row shapes byte-for-byte', () => { + // The exact local→NAS mirror example the S3 spec documents as raw SQL. + const doc = { + backends: [ + { name: 'nas-backups', type: 'local', options: { root: '/mnt/nas/trek-backups' } }, + { name: 'backup-mirror', type: 'mirror', options: { primary: 'backups-local', replicas: ['nas-backups'] } }, + ], + categories: { backups: 'backup-mirror' }, + }; + expect(storageConfigSchema.parse(doc)).toEqual(doc); + }); + + it('accepts a settings-declared s3 backend (the v2 acceptance this slice ships)', () => { + const doc = { + backends: [{ name: 'off-box', type: 's3', options: SAMPLES.s3 }], + categories: { backups: 'off-box' }, + }; + expect(storageConfigSchema.safeParse(doc).success).toBe(true); + }); + + it('rejects the reserved readOnly top-level key — loud until a file-override mode is implemented', () => { + const result = storageConfigSchema.safeParse({ backends: [], categories: {}, readOnly: true }); + expect(result.success).toBe(false); + }); + + it('rejects unknown categories, unknown types, empty names, and empty backend refs', () => { + expect(storageConfigSchema.safeParse({ backends: [], categories: { nope: 'uploads-local' } }).success).toBe(false); + expect( + storageConfigSchema.safeParse({ backends: [{ name: 'x', type: 'ftp', options: {} }], categories: {} }).success, + ).toBe(false); + expect( + storageConfigSchema.safeParse({ + backends: [{ name: '', type: 'local', options: { root: '/x' } }], + categories: {}, + }).success, + ).toBe(false); + expect(storageConfigSchema.safeParse({ backends: [], categories: { backups: '' } }).success).toBe(false); + }); + + it('exposes the canonical nine-category list (relocated from the server)', () => { + expect(STORAGE_CATEGORIES).toEqual([ + 'files', + 'journey', + 'covers', + 'avatars', + 'places', + 'photos', + 'photos-google', + 'photos-trek', + 'backups', + ]); + }); +}); diff --git a/shared/src/storage/storage.schema.ts b/shared/src/storage/storage.schema.ts new file mode 100644 index 000000000..602337417 --- /dev/null +++ b/shared/src/storage/storage.schema.ts @@ -0,0 +1,243 @@ +import { z } from 'zod'; + +/** + * Storage admin contract (spec: docs/superpowers/specs/2026-08-19-storage-admin-config-design.md). + * + * STORAGE_BACKEND_TYPES is the single schema-driven registry: each backend + * type declares its fields once, and both the Zod options schema (generated + * below) and the admin form's field metadata render from that one definition. + * Adding a backend type = one entry here + one driver class + one registry + * construction line. The registry is deliberately consumer-agnostic — the + * admin panel is its first renderer and a standalone seed-file generator is + * the planned second; do not specialize it toward either. + * + * Shared Zod validates SHAPE only. Semantics (name references, mirror + * composition, mirror-only-on-backups, no nesting, key rules) stay in the + * server registry's validators — the settled two-layer pattern. + */ + +/** + * Canonical category list — relocated here from server storage.types.ts + * (which re-exports it) because the wire schema needs it and a second copy + * is forbidden. + */ +export const STORAGE_CATEGORIES = [ + 'files', + 'journey', + 'covers', + 'avatars', + 'places', + 'photos', + 'photos-google', + 'photos-trek', + 'backups', +] as const; +export type StorageCategory = (typeof STORAGE_CATEGORIES)[number]; +export const storageCategorySchema = z.enum(STORAGE_CATEGORIES); + +export const STORAGE_BACKEND_TYPE_IDS = ['local', 's3', 'mirror'] as const; +export type StorageBackendTypeId = (typeof STORAGE_BACKEND_TYPE_IDS)[number]; + +export interface StorageBackendFieldDef { + key: string; + kind: 'text' | 'path' | 'secret' | 'number' | 'backend-ref' | 'backend-ref-list'; + /** i18n key under the `storage` domain (locale files land with the client slice). */ + labelKey: string; + helpKey?: string; + required: boolean; + defaultValue?: string | number; + /** Optional per-field refinement piped onto the kind's base Zod type. */ + refine?: { schema: z.ZodTypeAny }; +} + +function fieldSchema(field: StorageBackendFieldDef): z.ZodTypeAny { + let base: z.ZodTypeAny; + switch (field.kind) { + case 'number': + base = z.number(); + break; + case 'backend-ref': + base = z.string().min(1); + break; + case 'backend-ref-list': + base = z.array(z.string().min(1)); + break; + default: // text | path | secret + base = field.required ? z.string().min(1) : z.string(); + } + if (field.defaultValue !== undefined) base = base.default(field.defaultValue); + if (field.refine) base = base.pipe(field.refine.schema); + return base; +} + +type FieldValue = F['kind'] extends 'number' + ? number + : F['kind'] extends 'backend-ref-list' + ? string[] + : string; +/** Parsed options are total: non-required fields must declare a defaultValue (spec pin). */ +export type StorageOptionsOf = { + [F in Fs[number] as F['key']]: FieldValue; +}; + +function optionsSchemaFor( + fields: Fs, +): z.ZodType> { + const shape: Record = {}; + for (const field of fields) shape[field.key] = fieldSchema(field); + // The one sanctioned cast: the runtime schema is generated from the same + // field defs the mapped type reads, and the parity spec pins the two. + return z.strictObject(shape) as unknown as z.ZodType>; +} + +const LOCAL_FIELDS = [ + { + key: 'root', + kind: 'path', + labelKey: 'storage.field.root', + helpKey: 'storage.help.root', + required: true, + }, +] as const satisfies readonly StorageBackendFieldDef[]; + +const S3_FIELDS = [ + { + key: 'endpoint', + kind: 'text', + labelKey: 'storage.field.endpoint', + helpKey: 'storage.help.endpoint', + required: true, + refine: { schema: z.url() }, + }, + { key: 'bucket', kind: 'text', labelKey: 'storage.field.bucket', required: true }, + { key: 'accessKeyId', kind: 'text', labelKey: 'storage.field.accessKeyId', required: true }, + { key: 'secretAccessKey', kind: 'secret', labelKey: 'storage.field.secretAccessKey', required: true }, + { + key: 'region', + kind: 'text', + labelKey: 'storage.field.region', + helpKey: 'storage.help.region', + required: false, + defaultValue: 'us-east-1', + }, + { + key: 'keyPrefix', + kind: 'text', + labelKey: 'storage.field.keyPrefix', + helpKey: 'storage.help.keyPrefix', + required: false, + defaultValue: '', + }, + { + key: 'retries', + kind: 'number', + labelKey: 'storage.field.retries', + required: false, + defaultValue: 1, + refine: { schema: z.number().int().min(0) }, + }, + { + key: 'timeoutMs', + kind: 'number', + labelKey: 'storage.field.timeoutMs', + required: false, + defaultValue: 30000, + refine: { schema: z.number().int().positive() }, + }, +] as const satisfies readonly StorageBackendFieldDef[]; + +const MIRROR_FIELDS = [ + { key: 'primary', kind: 'backend-ref', labelKey: 'storage.field.primary', required: true }, + { key: 'replicas', kind: 'backend-ref-list', labelKey: 'storage.field.replicas', required: true }, +] as const satisfies readonly StorageBackendFieldDef[]; + +export const STORAGE_BACKEND_TYPES = { + local: { fields: LOCAL_FIELDS, optionsSchema: optionsSchemaFor(LOCAL_FIELDS) }, + s3: { fields: S3_FIELDS, optionsSchema: optionsSchemaFor(S3_FIELDS) }, + mirror: { fields: MIRROR_FIELDS, optionsSchema: optionsSchemaFor(MIRROR_FIELDS) }, +} as const; + +export type StorageLocalOptions = z.infer<(typeof STORAGE_BACKEND_TYPES)['local']['optionsSchema']>; +export type StorageS3Options = z.infer<(typeof STORAGE_BACKEND_TYPES)['s3']['optionsSchema']>; +export type StorageMirrorOptions = z.infer<(typeof STORAGE_BACKEND_TYPES)['mirror']['optionsSchema']>; + +/** Field keys the type marks `secret` — the exact set encrypted at rest and masked on GET. */ +export function storageSecretFields(type: StorageBackendTypeId): readonly string[] { + return STORAGE_BACKEND_TYPES[type].fields.filter((f) => f.kind === 'secret').map((f) => f.key); +} + +export const storageBackendSchema = z.discriminatedUnion('type', [ + z.strictObject({ + name: z.string().min(1), + type: z.literal('local'), + options: STORAGE_BACKEND_TYPES.local.optionsSchema, + }), + z.strictObject({ + name: z.string().min(1), + type: z.literal('s3'), + options: STORAGE_BACKEND_TYPES.s3.optionsSchema, + }), + z.strictObject({ + name: z.string().min(1), + type: z.literal('mirror'), + options: STORAGE_BACKEND_TYPES.mirror.optionsSchema, + }), +]); +export type StorageBackend = z.infer; + +/** + * The two app_settings rows as one document — the PUT body and the seed-file + * shape. `.strict()` at every level: unknown keys are rejected, including the + * reserved top-level `readOnly` seed key (loud until a standing file-override + * mode is actually implemented). Byte-compatible with rows hand-written + * during the S3 slice (pinned in the sibling spec file). + */ +export const storageConfigSchema = z.strictObject({ + backends: z.array(storageBackendSchema), + categories: z.partialRecord(storageCategorySchema, z.string().min(1)), +}); +export type StorageConfig = z.infer; + +// ── GET /api/admin/storage state + POST /test wire shapes ───────────────────── + +export const storageBackendSourceSchema = z.enum(['built-in', 'env', 'settings']); +export const storageCategorySourceSchema = z.enum(['default', 'settings']); + +export const storageReplicaFailureSchema = z.object({ + backend: z.string(), + key: z.string(), + op: z.enum(['put', 'delete']), + error: z.string(), + at: z.number(), +}); + +export const storageAdminStateSchema = z.object({ + backends: z.array( + z.object({ + name: z.string(), + type: z.enum(STORAGE_BACKEND_TYPE_IDS), + source: storageBackendSourceSchema, + /** Secret-kind fields carry MASKED_SETTING_VALUE — never a stored value. */ + options: z.record(z.string(), z.union([z.string(), z.number(), z.array(z.string())])), + categories: z.array(storageCategorySchema), + }), + ), + // Exhaustive record: the effective world always maps all nine categories. + categories: z.record( + storageCategorySchema, + z.object({ backend: z.string().min(1), source: storageCategorySourceSchema }), + ), + health: z.object({ replicaFailures: z.array(storageReplicaFailureSchema) }), + encryptionReady: z.boolean(), + seedFilePresent: z.boolean(), +}); +export type StorageAdminState = z.infer; + +export const storageTestRequestSchema = z.strictObject({ backend: storageBackendSchema }); +export type StorageTestRequest = z.infer; + +export const storageTestResponseSchema = z.object({ + ok: z.boolean(), + targets: z.array(z.object({ name: z.string(), ok: z.boolean(), error: z.string().optional() })), +}); +export type StorageTestResponse = z.infer;