mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
feat(shared): storage backend type registry, wire schemas, category relocation
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
Reference in New Issue
Block a user