@spinekit/media 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,97 @@
1
+ //#region src/providers/media-provider.config.ts
2
+ /** Required credential fields per provider — the completeness check is data, not code. */
3
+ const REQUIRED = {
4
+ s3: [
5
+ "bucket",
6
+ "region",
7
+ "accessKeyId",
8
+ "secretAccessKey"
9
+ ],
10
+ gcs: ["bucket"],
11
+ "cloudflare-images": ["accountId", "apiToken"],
12
+ cloudinary: [
13
+ "cloudName",
14
+ "apiKey",
15
+ "apiSecret"
16
+ ],
17
+ imagekit: [
18
+ "publicKey",
19
+ "privateKey",
20
+ "urlEndpoint"
21
+ ],
22
+ imgbb: ["apiKey"],
23
+ local: ["basePath"],
24
+ memory: []
25
+ };
26
+ /** Field names never echoed in an error message. */
27
+ const SECRET_FIELDS = /* @__PURE__ */ new Set([
28
+ "secretAccessKey",
29
+ "apiToken",
30
+ "apiSecret",
31
+ "privateKey",
32
+ "apiKey",
33
+ "credentials"
34
+ ]);
35
+ /** Which required fields are missing. Exported so a host can pre-flight its own config. */
36
+ function missingProviderFields(config) {
37
+ const required = REQUIRED[config.kind] ?? [];
38
+ const bag = config;
39
+ return required.filter((f) => {
40
+ const v = bag[f];
41
+ return v === void 0 || v === null || v === "";
42
+ });
43
+ }
44
+ async function resolveMediaProvider(config, options = {}) {
45
+ if (!config || missingProviderFields(config).length > 0) {
46
+ if (options.fallback) return options.fallback();
47
+ const missing = config ? missingProviderFields(config) : ["<no provider configured>"];
48
+ throw new Error(`[spine-media] storage provider ${config ? `"${config.kind}" ` : ""}is not fully configured. Missing: ${missing.filter((m) => !SECRET_FIELDS.has(m) || true).join(", ")}. Supply the missing values, choose a different \`kind\`, or pass an explicit \`fallback\` if this deployment genuinely intends a non-persistent driver.`);
49
+ }
50
+ switch (config.kind) {
51
+ case "s3": {
52
+ const { S3Provider } = await import("@classytic/media-kit/providers/s3");
53
+ return new S3Provider({
54
+ bucket: config.bucket,
55
+ region: config.region,
56
+ credentials: {
57
+ accessKeyId: config.accessKeyId,
58
+ secretAccessKey: config.secretAccessKey
59
+ },
60
+ ...config.publicUrl ? { publicUrl: config.publicUrl } : {},
61
+ ...config.acl ? { acl: config.acl } : {}
62
+ });
63
+ }
64
+ case "gcs": {
65
+ const { GCSProvider } = await import("@classytic/media-kit/providers/gcs");
66
+ return new GCSProvider({ ...config });
67
+ }
68
+ case "cloudflare-images": {
69
+ const { CloudflareImagesProvider } = await import("@classytic/media-kit/providers/cloudflare-images");
70
+ return new CloudflareImagesProvider({ ...config });
71
+ }
72
+ case "cloudinary": {
73
+ const { CloudinaryProvider } = await import("@classytic/media-kit/providers/cloudinary");
74
+ return new CloudinaryProvider({ ...config });
75
+ }
76
+ case "imagekit": {
77
+ const { ImageKitProvider } = await import("@classytic/media-kit/providers/imagekit");
78
+ return new ImageKitProvider({ ...config });
79
+ }
80
+ case "imgbb": {
81
+ const { ImgbbProvider } = await import("@classytic/media-kit/providers/imgbb");
82
+ return new ImgbbProvider({ ...config });
83
+ }
84
+ case "local": {
85
+ const { LocalProvider } = await import("@classytic/media-kit/providers/local");
86
+ return new LocalProvider({
87
+ basePath: config.basePath,
88
+ baseUrl: config.publicUrl ?? ""
89
+ });
90
+ }
91
+ case "memory":
92
+ if (!options.fallback) throw new Error("[spine-media] kind 'memory' persists nothing and must not be selected implicitly. Pass a `fallback` that returns your in-memory driver, so the choice is visible at the composition site.");
93
+ return options.fallback();
94
+ }
95
+ }
96
+ //#endregion
97
+ export { missingProviderFields, resolveMediaProvider };
@@ -0,0 +1,5 @@
1
+ import { StorageDriver } from "@classytic/media-kit";
2
+ //#region src/providers/memory.provider.d.ts
3
+ declare function createMemoryDriver(): Promise<StorageDriver>;
4
+ //#endregion
5
+ export { createMemoryDriver };
@@ -0,0 +1,68 @@
1
+ import { Readable } from "node:stream";
2
+ //#region src/providers/memory.provider.ts
3
+ /**
4
+ * In-memory storage driver — tests only.
5
+ *
6
+ * Conforms to media-kit v3's `StorageDriver` interface so the engine can
7
+ * fall back to a heap-backed store when no S3 bucket is configured.
8
+ */
9
+ async function createMemoryDriver() {
10
+ const store = /* @__PURE__ */ new Map();
11
+ async function toBuffer(input) {
12
+ if (Buffer.isBuffer(input)) return input;
13
+ const chunks = [];
14
+ for await (const chunk of input) if (Buffer.isBuffer(chunk)) chunks.push(chunk);
15
+ else if (chunk instanceof Uint8Array) chunks.push(Buffer.from(chunk));
16
+ else if (typeof chunk === "string") chunks.push(Buffer.from(chunk));
17
+ return Buffer.concat(chunks);
18
+ }
19
+ return {
20
+ name: "memory",
21
+ async write(key, data, contentType) {
22
+ const buffer = await toBuffer(data);
23
+ store.set(key, {
24
+ buffer,
25
+ contentType,
26
+ lastModified: /* @__PURE__ */ new Date()
27
+ });
28
+ return {
29
+ key,
30
+ url: `memory://${key}`,
31
+ size: buffer.length
32
+ };
33
+ },
34
+ async read(key) {
35
+ const f = store.get(key);
36
+ if (!f) throw new Error(`Not found: ${key}`);
37
+ return Readable.from(f.buffer);
38
+ },
39
+ async delete(key) {
40
+ return store.delete(key);
41
+ },
42
+ async exists(key) {
43
+ return store.has(key);
44
+ },
45
+ async stat(key) {
46
+ const f = store.get(key);
47
+ if (!f) throw new Error(`Not found: ${key}`);
48
+ return {
49
+ size: f.buffer.length,
50
+ contentType: f.contentType,
51
+ lastModified: f.lastModified
52
+ };
53
+ },
54
+ getPublicUrl(key) {
55
+ return `memory://${key}`;
56
+ },
57
+ async getSignedUploadUrl(key, _contentType, expiresIn) {
58
+ return {
59
+ uploadUrl: `memory://${key}?upload=1`,
60
+ key,
61
+ publicUrl: `memory://${key}`,
62
+ expiresIn: expiresIn ?? 3600
63
+ };
64
+ }
65
+ };
66
+ }
67
+ //#endregion
68
+ export { createMemoryDriver };
@@ -0,0 +1,47 @@
1
+ import { PermissionCheck } from "@classytic/arc/permissions";
2
+ import { MediaRepository } from "@classytic/media-kit";
3
+ import { RouteDefinition } from "@classytic/arc/types";
4
+ import { FastifyRequest } from "fastify";
5
+ //#region src/resources/media/media.buffered-upload.d.ts
6
+ interface BufferedMediaUploadEngine {
7
+ repositories: {
8
+ media: Pick<MediaRepository, 'upload'>;
9
+ };
10
+ }
11
+ interface BufferedMediaUploadFolderInput {
12
+ /** Untrusted text field supplied by the multipart request, when present. */
13
+ requestedFolder?: string;
14
+ request: FastifyRequest;
15
+ }
16
+ interface BufferedMediaUploadRouteOptions {
17
+ /** Arc permission gate for this opt-in route. */
18
+ permission: PermissionCheck;
19
+ /** Per-file buffer ceiling. Required because buffering is process memory. */
20
+ maxFileSize: number;
21
+ /** Whole-request buffer ceiling. Required so concurrency has a known bound. */
22
+ maxTotalBytes: number;
23
+ /** Explicit allow-list; exact MIME values and subtype wildcards are supported. */
24
+ allowedMimeTypes: readonly string[];
25
+ /**
26
+ * Host-owned folder policy. The helper never trusts `body.folder` directly.
27
+ * Omit it to let MediaKit apply its configured default folder.
28
+ */
29
+ resolveFolder?: (input: BufferedMediaUploadFolderInput) => string | undefined;
30
+ /** Authorize the resolved folder after multipart parsing and before bytes are persisted. */
31
+ authorizeFolder?: (input: BufferedMediaUploadFolderInput & {
32
+ folder?: string;
33
+ }) => void | Promise<void>;
34
+ /** Resource-relative route path. */
35
+ path?: string;
36
+ /** Multipart file field. */
37
+ fileField?: string;
38
+ /** OpenAPI summary override. */
39
+ summary?: string;
40
+ }
41
+ /**
42
+ * Build one authenticated, single-file buffered upload route for a Media module
43
+ * seam. The returned route is inert until a host adds it to `seams.routes`.
44
+ */
45
+ declare function createBufferedMediaUploadRoute(engine: BufferedMediaUploadEngine, options: BufferedMediaUploadRouteOptions): RouteDefinition;
46
+ //#endregion
47
+ export { BufferedMediaUploadEngine, BufferedMediaUploadFolderInput, BufferedMediaUploadRouteOptions, createBufferedMediaUploadRoute };
@@ -0,0 +1,76 @@
1
+ import { scopeFirstCtx } from "@classytic/arc/scope";
2
+ import { UnauthorizedError } from "@classytic/arc/utils";
3
+ import { multipartBody } from "@classytic/arc/middleware";
4
+ //#region src/resources/media/media.buffered-upload.ts
5
+ /**
6
+ * Optional application-server ingress for deliberately small media uploads.
7
+ *
8
+ * Large assets belong on the module's direct-to-storage routes. This helper is
9
+ * only the bounded buffering mechanism: every deployment must state its byte,
10
+ * MIME, permission, and folder-selection policy when it opts in.
11
+ */
12
+ function requirePositiveInteger(value, name) {
13
+ if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`createBufferedMediaUploadRoute: ${name} must be a positive integer`);
14
+ }
15
+ function validateOptions(options) {
16
+ requirePositiveInteger(options.maxFileSize, "maxFileSize");
17
+ requirePositiveInteger(options.maxTotalBytes, "maxTotalBytes");
18
+ if (options.allowedMimeTypes.length === 0) throw new Error("createBufferedMediaUploadRoute: allowedMimeTypes must not be empty");
19
+ if (options.allowedMimeTypes.some((value) => value.trim() === "" || ["*", "*/*"].includes(value.trim()))) throw new Error("createBufferedMediaUploadRoute: allowedMimeTypes must be an explicit allow-list");
20
+ if (options.fileField !== void 0 && options.fileField.trim() === "") throw new Error("createBufferedMediaUploadRoute: fileField must not be empty");
21
+ if (options.path !== void 0 && !options.path.startsWith("/")) throw new Error("createBufferedMediaUploadRoute: path must start with /");
22
+ }
23
+ /**
24
+ * Build one authenticated, single-file buffered upload route for a Media module
25
+ * seam. The returned route is inert until a host adds it to `seams.routes`.
26
+ */
27
+ function createBufferedMediaUploadRoute(engine, options) {
28
+ validateOptions(options);
29
+ const fileField = options.fileField ?? "file";
30
+ return {
31
+ method: "POST",
32
+ path: options.path ?? "/upload",
33
+ summary: options.summary ?? "Upload one small media asset through the application server",
34
+ permissions: options.permission,
35
+ preHandler: [multipartBody({
36
+ requiredFields: [fileField],
37
+ maxFiles: 1,
38
+ maxFileSize: options.maxFileSize,
39
+ maxTotalBytes: options.maxTotalBytes,
40
+ allowedMimeTypes: [...options.allowedMimeTypes]
41
+ })],
42
+ rawHandler: async (request, reply) => {
43
+ const { actorId: userId, organizationId } = scopeFirstCtx(request, { orgHeader: false });
44
+ if (!userId) throw new UnauthorizedError();
45
+ const body = request.body;
46
+ const file = body._files?.[fileField];
47
+ if (!file) throw Object.assign(/* @__PURE__ */ new Error(`Missing required file field: ${fileField}`), {
48
+ statusCode: 400,
49
+ code: "media.file_required"
50
+ });
51
+ const folder = options.resolveFolder?.({
52
+ ...typeof body.folder === "string" ? { requestedFolder: body.folder } : {},
53
+ request
54
+ });
55
+ await options.authorizeFolder?.({
56
+ ...typeof body.folder === "string" ? { requestedFolder: body.folder } : {},
57
+ ...folder !== void 0 ? { folder } : {},
58
+ request
59
+ });
60
+ const asset = await engine.repositories.media.upload({
61
+ buffer: file.buffer,
62
+ filename: file.filename,
63
+ mimeType: file.mimetype,
64
+ ...folder !== void 0 ? { folder } : {},
65
+ ...typeof body.alt === "string" ? { alt: body.alt } : {},
66
+ ...typeof body.title === "string" ? { title: body.title } : {}
67
+ }, {
68
+ userId,
69
+ ...organizationId !== void 0 ? { organizationId } : {}
70
+ });
71
+ return reply.code(201).send(asset);
72
+ }
73
+ };
74
+ }
75
+ //#endregion
76
+ export { createBufferedMediaUploadRoute };
@@ -0,0 +1,28 @@
1
+ import { BufferedMediaUploadEngine, BufferedMediaUploadFolderInput, BufferedMediaUploadRouteOptions, createBufferedMediaUploadRoute } from "./media.buffered-upload.mjs";
2
+ import { MediaRepository } from "@classytic/media-kit";
3
+ import { RouteDefinition } from "@classytic/arc/types";
4
+ //#region src/resources/media/media.routes.d.ts
5
+ /**
6
+ * The repository verbs these routes require.
7
+ *
8
+ * `MediaRepository` declares folder/tag operations OPTIONAL — media-kit can be configured
9
+ * without them. be-prod's looser tsconfig let the routes call them unguarded; under this
10
+ * package's `exactOptionalPropertyTypes` every call was an error, which is the honest
11
+ * reading: mounting these routes against an engine that lacks the verbs would throw at
12
+ * request time.
13
+ *
14
+ * Stating the requirement in the TYPE means a caller finds out at composition, not from a
15
+ * 500 in production.
16
+ */
17
+ type MediaRoutesRepository = MediaRepository & Required<Pick<MediaRepository, 'addTags' | 'deleteFolder' | 'getBreadcrumb' | 'getFolderStats' | 'getFolderTree' | 'getSubfolders' | 'hardDeleteMany' | 'move' | 'removeTags' | 'renameFolder'>>;
18
+ type PermissionGate = import('@classytic/arc/permissions').PermissionCheck;
19
+ /**
20
+ * Library-management routes merged into the arc-media `/media` resource.
21
+ *
22
+ * @param repo the media-kit repository the module bootstrapped (single source)
23
+ * @param manage the gate guarding every mutation (folders/tags/bulk/move/meta);
24
+ * reads reuse it too — the library is a platform-admin surface.
25
+ */
26
+ declare function buildMediaExtensionRoutes(repo: MediaRoutesRepository, manage: PermissionGate): RouteDefinition[];
27
+ //#endregion
28
+ export { type BufferedMediaUploadEngine, type BufferedMediaUploadFolderInput, type BufferedMediaUploadRouteOptions, MediaRoutesRepository, buildMediaExtensionRoutes, createBufferedMediaUploadRoute };
@@ -0,0 +1,203 @@
1
+ import { BASE_FOLDERS } from "../../config/media.defaults.mjs";
2
+ import { mediaSchemas } from "./media.schema.mjs";
3
+ import { createBufferedMediaUploadRoute } from "./media.buffered-upload.mjs";
4
+ import { NotFoundError } from "@classytic/arc/utils";
5
+ //#region src/resources/media/media.routes.ts
6
+ /** Actor context — the actor is ALWAYS the authenticated user, never the body. */
7
+ function ctxOf(req) {
8
+ const user = req.user;
9
+ const userId = user?.id ?? user?._id;
10
+ return userId !== void 0 ? { userId } : {};
11
+ }
12
+ /**
13
+ * Library-management routes merged into the arc-media `/media` resource.
14
+ *
15
+ * @param repo the media-kit repository the module bootstrapped (single source)
16
+ * @param manage the gate guarding every mutation (folders/tags/bulk/move/meta);
17
+ * reads reuse it too — the library is a platform-admin surface.
18
+ */
19
+ function buildMediaExtensionRoutes(repo, manage) {
20
+ const r = repo;
21
+ return [
22
+ {
23
+ method: "GET",
24
+ path: "/folders",
25
+ permissions: manage,
26
+ summary: "Get allowed base folders",
27
+ rawHandler: async (_req, reply) => reply.send(BASE_FOLDERS)
28
+ },
29
+ {
30
+ method: "GET",
31
+ path: "/folders/tree",
32
+ permissions: manage,
33
+ summary: "Get folder tree",
34
+ rawHandler: async (req, reply) => reply.send(await r.getFolderTree(ctxOf(req)))
35
+ },
36
+ {
37
+ method: "GET",
38
+ path: "/folders/:folder/stats",
39
+ permissions: manage,
40
+ summary: "Get folder stats",
41
+ schema: mediaSchemas.folderParam,
42
+ rawHandler: async (req, reply) => {
43
+ const { folder } = req.params;
44
+ return reply.send(await r.getFolderStats(folder, ctxOf(req)));
45
+ }
46
+ },
47
+ {
48
+ method: "GET",
49
+ path: "/folders/:folder/breadcrumb",
50
+ permissions: manage,
51
+ summary: "Get folder breadcrumb",
52
+ schema: mediaSchemas.folderParam,
53
+ rawHandler: async (req, reply) => {
54
+ const { folder } = req.params;
55
+ return reply.send(r.getBreadcrumb(folder));
56
+ }
57
+ },
58
+ {
59
+ method: "GET",
60
+ path: "/folders/:folder/subfolders",
61
+ permissions: manage,
62
+ summary: "Get subfolders",
63
+ schema: mediaSchemas.folderParam,
64
+ rawHandler: async (req, reply) => {
65
+ const { folder } = req.params;
66
+ return reply.send(await r.getSubfolders(folder, ctxOf(req)));
67
+ }
68
+ },
69
+ {
70
+ method: "PATCH",
71
+ path: "/folders/:folder",
72
+ permissions: manage,
73
+ summary: "Rename folder",
74
+ schema: mediaSchemas.renameFolder,
75
+ handler: async (req) => {
76
+ const { folder } = req.params;
77
+ const { newName } = req.body ?? {};
78
+ return { data: await r.renameFolder(folder, newName, ctxOf(req)) };
79
+ }
80
+ },
81
+ {
82
+ method: "DELETE",
83
+ path: "/folders/:folder",
84
+ permissions: manage,
85
+ summary: "Delete folder (and its media)",
86
+ schema: mediaSchemas.folderParam,
87
+ handler: async (req) => {
88
+ const { folder } = req.params;
89
+ const results = await r.deleteFolder(folder, ctxOf(req));
90
+ const failed = results.failed ?? [];
91
+ const success = results.success ?? [];
92
+ if (success.length === 0 && failed.length === 0) throw new NotFoundError("No files in folder");
93
+ await req.server?.audit?.custom("media", folder, "folder_delete", {
94
+ deleted: success.length,
95
+ failed: failed.length
96
+ });
97
+ return {
98
+ status: failed.length ? 207 : 200,
99
+ data: {
100
+ success: failed.length === 0,
101
+ data: {
102
+ success,
103
+ failed
104
+ },
105
+ message: `Deleted ${success.length} files`
106
+ }
107
+ };
108
+ }
109
+ },
110
+ {
111
+ method: "POST",
112
+ path: "/bulk-delete",
113
+ permissions: manage,
114
+ summary: "Delete multiple files (storage + doc)",
115
+ schema: mediaSchemas.bulkDelete,
116
+ handler: async (req) => {
117
+ const { ids } = req.body ?? {};
118
+ const results = await r.hardDeleteMany(ids, ctxOf(req));
119
+ const failed = results.failed ?? [];
120
+ const success = results.success ?? [];
121
+ await req.server?.audit?.custom("media", ids.join(","), "bulk_delete", {
122
+ requested: ids.length,
123
+ deleted: success.length,
124
+ failed: failed.length
125
+ });
126
+ return {
127
+ status: failed.length ? 207 : 200,
128
+ data: {
129
+ success,
130
+ failed,
131
+ message: `Deleted ${success.length} of ${ids.length} files`
132
+ }
133
+ };
134
+ }
135
+ },
136
+ {
137
+ method: "POST",
138
+ path: "/move",
139
+ permissions: manage,
140
+ summary: "Move files to folder",
141
+ schema: mediaSchemas.move,
142
+ handler: async (req) => {
143
+ const { ids, targetFolder } = req.body ?? {};
144
+ const result = await r.move(ids, targetFolder, ctxOf(req));
145
+ return { data: {
146
+ ...result,
147
+ message: `Moved ${result.modifiedCount ?? 0} files`
148
+ } };
149
+ }
150
+ },
151
+ {
152
+ method: "PATCH",
153
+ path: "/:id/meta",
154
+ permissions: manage,
155
+ summary: "Update media metadata (alt / title)",
156
+ schema: mediaSchemas.updateMeta,
157
+ handler: async (req) => {
158
+ const { id } = req.params;
159
+ const { alt, title } = req.body ?? {};
160
+ const patch = {};
161
+ if (alt !== void 0) patch.alt = alt;
162
+ if (title !== void 0) patch.title = title;
163
+ const before = await r.getById(id, {
164
+ ...ctxOf(req),
165
+ throwOnNotFound: false
166
+ });
167
+ const updated = await r.update(id, patch, ctxOf(req));
168
+ if (!updated) throw new NotFoundError("Media");
169
+ await req.server?.audit?.update("media", id, {
170
+ alt: before?.alt,
171
+ title: before?.title
172
+ }, patch, ctxOf(req));
173
+ return { data: updated };
174
+ }
175
+ },
176
+ {
177
+ method: "POST",
178
+ path: "/:id/tags",
179
+ permissions: manage,
180
+ summary: "Add tags",
181
+ schema: mediaSchemas.addTags,
182
+ handler: async (req) => {
183
+ const { id } = req.params;
184
+ const { tags } = req.body ?? {};
185
+ return { data: await r.addTags(id, tags, ctxOf(req)) };
186
+ }
187
+ },
188
+ {
189
+ method: "DELETE",
190
+ path: "/:id/tags",
191
+ permissions: manage,
192
+ summary: "Remove tags",
193
+ schema: mediaSchemas.removeTags,
194
+ handler: async (req) => {
195
+ const { id } = req.params;
196
+ const { tags } = req.body ?? {};
197
+ return { data: await r.removeTags(id, tags, ctxOf(req)) };
198
+ }
199
+ }
200
+ ];
201
+ }
202
+ //#endregion
203
+ export { buildMediaExtensionRoutes, createBufferedMediaUploadRoute };
@@ -0,0 +1,62 @@
1
+ import { z } from "zod";
2
+ //#region src/resources/media/media.schema.d.ts
3
+ declare const mediaSchemas: {
4
+ /** GET /folders/:folder/* — bare folder param. */
5
+ folderParam: {
6
+ params: z.ZodObject<{
7
+ folder: z.ZodString;
8
+ }, z.core.$strip>;
9
+ };
10
+ /** PATCH /folders/:folder — rename. */
11
+ renameFolder: {
12
+ params: z.ZodObject<{
13
+ folder: z.ZodString;
14
+ }, z.core.$strip>;
15
+ body: z.ZodObject<{
16
+ newName: z.ZodString;
17
+ }, z.core.$strict>;
18
+ };
19
+ /** POST /bulk-delete. */
20
+ bulkDelete: {
21
+ body: z.ZodObject<{
22
+ ids: z.ZodArray<z.ZodString>;
23
+ }, z.core.$strict>;
24
+ };
25
+ /** POST /move. */
26
+ move: {
27
+ body: z.ZodObject<{
28
+ ids: z.ZodArray<z.ZodString>;
29
+ targetFolder: z.ZodString;
30
+ }, z.core.$strict>;
31
+ };
32
+ /** PATCH /:id/meta — metadata only (never storage bytes). */
33
+ updateMeta: {
34
+ params: z.ZodObject<{
35
+ id: z.ZodString;
36
+ }, z.core.$strip>;
37
+ body: z.ZodObject<{
38
+ alt: z.ZodOptional<z.ZodString>;
39
+ title: z.ZodOptional<z.ZodString>;
40
+ }, z.core.$strict>;
41
+ };
42
+ /** POST /:id/tags. */
43
+ addTags: {
44
+ params: z.ZodObject<{
45
+ id: z.ZodString;
46
+ }, z.core.$strip>;
47
+ body: z.ZodObject<{
48
+ tags: z.ZodArray<z.ZodString>;
49
+ }, z.core.$strict>;
50
+ };
51
+ /** DELETE /:id/tags. */
52
+ removeTags: {
53
+ params: z.ZodObject<{
54
+ id: z.ZodString;
55
+ }, z.core.$strip>;
56
+ body: z.ZodObject<{
57
+ tags: z.ZodArray<z.ZodString>;
58
+ }, z.core.$strict>;
59
+ };
60
+ };
61
+ //#endregion
62
+ export { mediaSchemas };
@@ -0,0 +1,54 @@
1
+ import { BASE_FOLDERS } from "../../config/media.defaults.mjs";
2
+ import { z } from "zod";
3
+ //#region src/resources/media/media.schema.ts
4
+ /**
5
+ * Media extension-route schemas (Zod v4 — Arc convention).
6
+ *
7
+ * Arc auto-converts these to JSON Schema via `z.toJSONSchema()` for Fastify
8
+ * validation + OpenAPI. These cover ONLY the library-management routes be-prod
9
+ * adds on top of arc-media ([media.routes.ts](./media.routes.ts): folders /
10
+ * tags / bulk-delete / move / metadata). The media resource's list/get/delete
11
+ * schemas + the two-phase upload contract are owned by @spinekit/media.
12
+ */
13
+ /** Folder path: must start with an allowed base folder, then `/segment` parts. */
14
+ const folderPathPattern = new RegExp(`^(${BASE_FOLDERS.join("|")})(/[a-zA-Z0-9_-]+)*$`);
15
+ const folderPath = z.string().regex(folderPathPattern, "Invalid folder path");
16
+ const folderParam = z.object({ folder: z.string().min(1) });
17
+ const idParam = z.object({ id: z.string().min(1) });
18
+ const tagList = z.array(z.string().min(1).max(50)).min(1).max(20);
19
+ const mediaSchemas = {
20
+ /** GET /folders/:folder/* — bare folder param. */
21
+ folderParam: { params: folderParam },
22
+ /** PATCH /folders/:folder — rename. */
23
+ renameFolder: {
24
+ params: folderParam,
25
+ body: z.object({ newName: z.string().min(1).regex(/^[a-zA-Z0-9_-]+$/, "Invalid folder name") }).strict()
26
+ },
27
+ /** POST /bulk-delete. */
28
+ bulkDelete: { body: z.object({ ids: z.array(z.string().min(1)).min(1).max(100).describe("Media IDs to delete") }).strict() },
29
+ /** POST /move. */
30
+ move: { body: z.object({
31
+ ids: z.array(z.string().min(1)).min(1).max(100),
32
+ targetFolder: folderPath.describe("Target folder path (e.g. products/featured)")
33
+ }).strict() },
34
+ /** PATCH /:id/meta — metadata only (never storage bytes). */
35
+ updateMeta: {
36
+ params: idParam,
37
+ body: z.object({
38
+ alt: z.string().max(255).optional(),
39
+ title: z.string().max(255).optional()
40
+ }).strict()
41
+ },
42
+ /** POST /:id/tags. */
43
+ addTags: {
44
+ params: idParam,
45
+ body: z.object({ tags: tagList }).strict()
46
+ },
47
+ /** DELETE /:id/tags. */
48
+ removeTags: {
49
+ params: idParam,
50
+ body: z.object({ tags: tagList }).strict()
51
+ }
52
+ };
53
+ //#endregion
54
+ export { mediaSchemas };