feat(shared): storage backend type registry, wire schemas, category relocation

This commit is contained in:
jubnl
2026-08-20 00:24:18 +02:00
parent b914602b8c
commit 8bfe2d2e52
4 changed files with 390 additions and 12 deletions
+3 -12
View File
@@ -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;
+1
View File
@@ -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';
+143
View File
@@ -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<StorageBackendTypeId, Record<string, unknown>> = {
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<string, unknown>;
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<string, unknown>;
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',
]);
});
});
+243
View File
@@ -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 extends StorageBackendFieldDef> = 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<Fs extends readonly StorageBackendFieldDef[]> = {
[F in Fs[number] as F['key']]: FieldValue<F>;
};
function optionsSchemaFor<const Fs extends readonly StorageBackendFieldDef[]>(
fields: Fs,
): z.ZodType<StorageOptionsOf<Fs>> {
const shape: Record<string, z.ZodTypeAny> = {};
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<StorageOptionsOf<Fs>>;
}
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<typeof storageBackendSchema>;
/**
* 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<typeof storageConfigSchema>;
// ── 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<typeof storageAdminStateSchema>;
export const storageTestRequestSchema = z.strictObject({ backend: storageBackendSchema });
export type StorageTestRequest = z.infer<typeof storageTestRequestSchema>;
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<typeof storageTestResponseSchema>;