@velora-cms/server 0.11.0 → 0.12.0

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.
Files changed (75) hide show
  1. package/dist/admin/sync-admin-bundle.d.ts +9 -0
  2. package/dist/admin/sync-admin-bundle.js +120 -0
  3. package/dist/admin-bundle/.velora-version +1 -0
  4. package/dist/admin-bundle/assets/{_virtual_mf-localSharedImportMap___mfe_internal__velora_admin__mf_owner__1-DOKdWYyj.js → _virtual_mf-localSharedImportMap___mfe_internal__velora_admin__mf_owner__1-DlIgIuFP.js} +1 -1
  5. package/dist/admin-bundle/assets/{_virtual_mf___mfe_internal__velora_admin__mf_owner__1__loadShare___mf_0_velora_mf_2_cms_mf_1_plugin_mf_2_sdk__loadShare__.js-C1-ZAxwO.js → _virtual_mf___mfe_internal__velora_admin__mf_owner__1__loadShare___mf_0_velora_mf_2_cms_mf_1_plugin_mf_2_sdk__loadShare__.js-Bm9EahCG.js} +2 -2
  6. package/dist/admin-bundle/assets/{content-trash.schema-DfXHkC-w.js → content-trash.schema-tvJ4Lbos.js} +5 -5
  7. package/dist/admin-bundle/assets/{hostInit--C4xK6nj.js → hostInit-B-6-34XY.js} +1 -1
  8. package/dist/admin-bundle/assets/{index-DhMY-0sC.js → index-CHCGuSbb.js} +1 -1
  9. package/dist/admin-bundle/assets/{index-BIAF0ShP.js → index-DYO7-IK8.js} +33 -33
  10. package/dist/admin-bundle/assets/{mf-entry-bootstrap-0-aef015cd.js → mf-entry-bootstrap-0-7b0ce2fe.js} +2 -2
  11. package/dist/admin-bundle/assets/{remoteEntry-CNyN68bc.js → remoteEntry-J2yJXsGB.js} +2 -2
  12. package/dist/admin-bundle/index.html +4 -4
  13. package/dist/app.d.ts +16 -1
  14. package/dist/app.js +63 -4
  15. package/dist/auth/api-keys.d.ts +1 -0
  16. package/dist/auth/api-keys.js +5 -1
  17. package/dist/auth/route-rate-limits.d.ts +4 -0
  18. package/dist/auth/route-rate-limits.js +13 -0
  19. package/dist/bin-sync-admin.d.ts +2 -0
  20. package/dist/bin-sync-admin.js +39 -0
  21. package/dist/bundled-templates/com.velora.blog/package.tar.gz +0 -0
  22. package/dist/config/public-url.d.ts +3 -0
  23. package/dist/config/public-url.js +45 -0
  24. package/dist/db/core-migrations/steps.js +95 -16
  25. package/dist/docs-bundle/pagefind/fragment/en_42f5356.pf_fragment +0 -0
  26. package/dist/docs-bundle/pagefind/fragment/en_67dccf8.pf_fragment +0 -0
  27. package/dist/docs-bundle/pagefind/fragment/en_80941b8.pf_fragment +0 -0
  28. package/dist/docs-bundle/pagefind/fragment/en_a64440b.pf_fragment +0 -0
  29. package/dist/docs-bundle/pagefind/fragment/en_eb168aa.pf_fragment +0 -0
  30. package/dist/docs-bundle/pagefind/index/en_8450ec7.pf_index +0 -0
  31. package/dist/docs-bundle/pagefind/index/en_c285f7b.pf_index +0 -0
  32. package/dist/docs-bundle/pagefind/index/en_e8cada7.pf_index +0 -0
  33. package/dist/docs-bundle/pagefind/pagefind-entry.json +1 -1
  34. package/dist/docs-bundle/pagefind/pagefind.en_57d2f8922f.pf_meta +0 -0
  35. package/dist/docs-bundle/v1/using-velora/getting-started/index.html +5 -3
  36. package/dist/docs-bundle/v1/using-velora/headless-api/index.html +10 -4
  37. package/dist/docs-bundle/v1/using-velora/index.html +6 -4
  38. package/dist/docs-bundle/v1/using-velora/installation/index.html +73 -15
  39. package/dist/docs-bundle/v1/using-velora/media/index.html +118 -34
  40. package/dist/graphql/schema.d.ts +1 -0
  41. package/dist/graphql/schema.js +5 -2
  42. package/dist/lib.d.ts +1 -0
  43. package/dist/lib.js +45 -11
  44. package/dist/media/media-service.d.ts +14 -15
  45. package/dist/media/media-service.js +121 -59
  46. package/dist/media/media-tokens.d.ts +6 -0
  47. package/dist/media/media-tokens.js +88 -0
  48. package/dist/media/storage/local.d.ts +16 -0
  49. package/dist/media/storage/local.js +99 -0
  50. package/dist/media/storage/s3.d.ts +25 -0
  51. package/dist/media/storage/s3.js +134 -0
  52. package/dist/media/storage/types.d.ts +21 -0
  53. package/dist/media/storage/types.js +44 -0
  54. package/dist/routes/admin-static.d.ts +2 -0
  55. package/dist/routes/admin-static.js +42 -4
  56. package/dist/routes/media-public.d.ts +6 -0
  57. package/dist/routes/media-public.js +265 -0
  58. package/dist/routes/media.d.ts +2 -1
  59. package/dist/routes/media.js +95 -5
  60. package/dist/routes/plugin-storage.js +5 -1
  61. package/dist/routes/public-api.js +5 -3
  62. package/dist/routes/published-item.d.ts +2 -1
  63. package/dist/routes/published-item.js +28 -5
  64. package/dist/scripts/copy-admin-bundle.js +9 -1
  65. package/package.json +7 -6
  66. package/prisma/schema.prisma +16 -3
  67. package/dist/docs-bundle/pagefind/fragment/en_1d9eb24.pf_fragment +0 -0
  68. package/dist/docs-bundle/pagefind/fragment/en_3099406.pf_fragment +0 -0
  69. package/dist/docs-bundle/pagefind/fragment/en_9aa652e.pf_fragment +0 -0
  70. package/dist/docs-bundle/pagefind/fragment/en_d855550.pf_fragment +0 -0
  71. package/dist/docs-bundle/pagefind/fragment/en_e6d88d8.pf_fragment +0 -0
  72. package/dist/docs-bundle/pagefind/index/en_5feeed7.pf_index +0 -0
  73. package/dist/docs-bundle/pagefind/index/en_c6981f9.pf_index +0 -0
  74. package/dist/docs-bundle/pagefind/index/en_ce4f6bf.pf_index +0 -0
  75. package/dist/docs-bundle/pagefind/pagefind.en_1e94abd852.pf_meta +0 -0
@@ -1,7 +1,41 @@
1
1
  import { z } from "zod";
2
- import { ApiErrorSchema, MediaDeleteResponseSchema, MediaFileResponseSchema, MediaListQuerySchema, MediaListResponseSchema, } from "@velora-cms/api-schemas";
2
+ import { ApiErrorSchema, MediaDeleteResponseSchema, MediaFileResponseSchema, MediaLinkResponseSchema, MediaListQuerySchema, MediaListResponseSchema, UpdateMediaVisibilityRequestSchema, } from "@velora-cms/api-schemas";
3
3
  import { requireAuth } from "../auth/require-auth.js";
4
+ import { sanitizeForKey } from "../media/media-service.js";
5
+ import { mintMediaToken } from "../media/media-tokens.js";
4
6
  const MediaParamsSchema = z.object({ id: z.string().uuid() });
7
+ const PATH_SEPARATOR_RE = /[\\/]/;
8
+ // LOW-11: multipart file parts can't be described by the Zod `schema`
9
+ // option (Fastify/Zod validate JSON bodies, not multipart streams), so
10
+ // this runs by hand right after `request.file()` resolves, before the
11
+ // filename is ever handed to MediaService.upload (which turns it
12
+ // straight into a storage key via sanitizeForKey). Defense in depth
13
+ // alongside HIGH-2's driver-level assertValidStorageKey: this rejects
14
+ // with a friendly, rule-naming 400 instead of a generic 500 from
15
+ // whatever the driver does with a hostile key.
16
+ //
17
+ // In practice @fastify/multipart's busboy already runs its own
18
+ // basename()-style stripping on `filename` before this ever sees it
19
+ // (path components dropped, a bare "." or ".." collapsed to "" and
20
+ // caught by the pre-existing `!file.filename` check) — see
21
+ // media.test.ts for direct unit coverage of every branch below, since
22
+ // an HTTP round-trip can no longer exercise the path-separator branch
23
+ // with the multipart plugin's default (non-preservePath) config. This
24
+ // still guards the cases busboy does NOT touch (e.g. a filename that
25
+ // sanitizes to nothing without being "."/".." verbatim) and stays
26
+ // correct if that config or an internal caller ever changes.
27
+ export function invalidUploadFilenameReason(filename) {
28
+ if (filename === "." || filename === "..") {
29
+ return 'Filename must not be "." or ".."';
30
+ }
31
+ if (PATH_SEPARATOR_RE.test(filename)) {
32
+ return "Filename must not contain path separators (\"/\" or \"\\\")";
33
+ }
34
+ if (sanitizeForKey(filename).length === 0) {
35
+ return "Filename must not be empty after sanitization";
36
+ }
37
+ return null;
38
+ }
5
39
  export const mediaRoutes = async (app, opts) => {
6
40
  const { mediaService } = opts;
7
41
  async function toResponse(media) {
@@ -25,6 +59,10 @@ export const mediaRoutes = async (app, opts) => {
25
59
  if (!file || !file.filename) {
26
60
  return reply.code(400).send({ message: "A multipart file field is required" });
27
61
  }
62
+ const filenameError = invalidUploadFilenameReason(file.filename);
63
+ if (filenameError) {
64
+ return reply.code(400).send({ message: filenameError });
65
+ }
28
66
  const data = await file.toBuffer();
29
67
  const media = await mediaService.upload({
30
68
  filename: file.filename,
@@ -35,10 +73,10 @@ export const mediaRoutes = async (app, opts) => {
35
73
  return reply.code(201).send(await toResponse(media));
36
74
  });
37
75
  // Media library listing — admin-authed (SEC-6, S175 audit). This lists
38
- // EVERY uploaded asset regardless of publish state, each with a working
39
- // presigned downloadUrl, so it must not be anonymously enumerable;
40
- // published/headless media has its own API-key-gated /api/v1/media
41
- // surface. The admin UI reaches this through authFetch.
76
+ // EVERY uploaded asset regardless of publish state, so it must not be
77
+ // anonymously enumerable; published/headless media has its own
78
+ // API-key-gated /api/v1/media surface. The admin UI reaches this
79
+ // through authFetch.
42
80
  app.get("/api/media", {
43
81
  onRequest: requireAuth,
44
82
  schema: {
@@ -72,6 +110,58 @@ export const mediaRoutes = async (app, opts) => {
72
110
  }
73
111
  return toResponse(media);
74
112
  });
113
+ // Flips the visibility flag (S186c §2) — admin-authed, same as every
114
+ // other /api/media route (this is the backoffice library surface, not
115
+ // the API-key-gated /api/v1/media headless read path). No file move, no
116
+ // key/URL change; only the row changes.
117
+ app.patch("/api/media/:id", {
118
+ onRequest: requireAuth,
119
+ schema: {
120
+ params: MediaParamsSchema,
121
+ body: UpdateMediaVisibilityRequestSchema,
122
+ response: {
123
+ 200: MediaFileResponseSchema,
124
+ 400: ApiErrorSchema,
125
+ 401: ApiErrorSchema,
126
+ 404: ApiErrorSchema,
127
+ },
128
+ },
129
+ }, async (request, reply) => {
130
+ const media = await mediaService.getById(request.params.id);
131
+ if (!media) {
132
+ return reply.code(404).send({ message: `No media file found for id ${request.params.id}` });
133
+ }
134
+ const updated = await mediaService.setVisibility(request.params.id, request.body.visibility);
135
+ return toResponse(updated);
136
+ });
137
+ // Mints a signed, short-lived link to a media item (S186c §3) — the
138
+ // "Copy temporary link" button behind a private item in the admin.
139
+ // Same requireAuth idiom as every other /api/media route (any
140
+ // signed-in admin user, not a role gate) — mandate 4 of the S186c
141
+ // Task 3 review round: this is the file's own admin-session auth, kept
142
+ // consistent across the whole /api/media surface rather than inventing
143
+ // a stricter check just for this route. Works for a public item too
144
+ // (harmless — the plain stable path already serves it with no token
145
+ // needed); the admin UI simply only surfaces the button for private
146
+ // ones.
147
+ app.post("/api/media/:id/link", {
148
+ onRequest: requireAuth,
149
+ schema: {
150
+ params: MediaParamsSchema,
151
+ response: {
152
+ 200: MediaLinkResponseSchema,
153
+ 401: ApiErrorSchema,
154
+ 404: ApiErrorSchema,
155
+ },
156
+ },
157
+ }, async (request, reply) => {
158
+ const media = await mediaService.getById(request.params.id);
159
+ if (!media) {
160
+ return reply.code(404).send({ message: `No media file found for id ${request.params.id}` });
161
+ }
162
+ const { token, expiresAt } = mintMediaToken(media.id);
163
+ return { url: `${media.url}?token=${encodeURIComponent(token)}`, expiresAt };
164
+ });
75
165
  app.delete("/api/media/:id", {
76
166
  onRequest: requireAuth,
77
167
  schema: {
@@ -22,7 +22,11 @@ import { PluginNotActiveError, PluginNotInstalledError } from "../plugins/plugin
22
22
  // - This was NOT the CMS's only unauthenticated read of internal content
23
23
  // data — GET /api/media and GET /api/media/:id
24
24
  // (apps/server/src/routes/media.ts:65-66, 84-85) are unauthenticated
25
- // and return media items each carrying a fresh presigned downloadUrl,
25
+ // and return media items each carrying a downloadUrl (M3 fix wave: no
26
+ // longer presigned/time-limited as of S186c — a stable app path,
27
+ // `/media/<id>/<filename>`, that GET /media/:id/:filename streams
28
+ // server-side; see media-service.ts's getDownloadUrl and
29
+ // apps/server/prisma/schema.prisma's storageKey field comment),
26
30
  // and GET /api/document-types (apps/server/src/routes/document-types.ts:146-147),
27
31
  // GET /api/templates (apps/server/src/routes/templates.ts:40-41),
28
32
  // GET /api/themes (apps/server/src/routes/themes.ts:15-16), and
@@ -11,7 +11,7 @@ import { isPublicDocsEnabled } from "../config/public-docs.js";
11
11
  import { maxQueryComplexity, maxQueryDepth } from "../config/graphql-limits.js";
12
12
  import { createFieldCountRule } from "../graphql/field-count-rule.js";
13
13
  import { buildGraphqlSchema } from "../graphql/schema.js";
14
- import { computeExtensions, isNotFoundError, preparePublishedItem, resolveItemNode, resolveSubtreeFilter, toPublicDocumentType, toPublicMediaFile, toSummaries, } from "./published-item.js";
14
+ import { computeExtensions, isNotFoundError, preparePublishedItem, resolveItemNode, resolvePublicBaseUrl, resolveSubtreeFilter, toPublicDocumentType, toPublicMediaFile, toSummaries, } from "./published-item.js";
15
15
  const PublicContentParamsSchema = z.object({ id: z.string().uuid() });
16
16
  // mercurius' own /api/v1/graphql routes carry plain JSON-Schema objects for
17
17
  // their request/response envelope (not Zod). This app's validatorCompiler/
@@ -400,7 +400,8 @@ export const publicApiRoutes = async (app, opts) => {
400
400
  }, async (request) => {
401
401
  const { cursor, limit, search } = request.query;
402
402
  const files = await mediaService.list({ cursor, limit, search });
403
- const items = await Promise.all(files.map((file) => toPublicMediaFile(mediaService, file)));
403
+ const baseUrl = resolvePublicBaseUrl(request);
404
+ const items = await Promise.all(files.map((file) => toPublicMediaFile(mediaService, file, baseUrl)));
404
405
  const nextCursor = files.length === limit ? (files[files.length - 1]?.id ?? null) : null;
405
406
  return { items, nextCursor };
406
407
  });
@@ -415,7 +416,7 @@ export const publicApiRoutes = async (app, opts) => {
415
416
  if (!media) {
416
417
  return reply.code(404).send({ message: `No media file found for id ${request.params.id}` });
417
418
  }
418
- return toPublicMediaFile(mediaService, media);
419
+ return toPublicMediaFile(mediaService, media, resolvePublicBaseUrl(request));
419
420
  });
420
421
  // Public document type introspection + template listing (Session 105).
421
422
  // Both are read-only projections over existing backoffice adapter calls
@@ -539,6 +540,7 @@ export const publicApiRoutes = async (app, opts) => {
539
540
  contentService,
540
541
  mediaService,
541
542
  getContentNode: (id) => adapter.getContentNode(id),
543
+ mediaBaseUrl: resolvePublicBaseUrl(request),
542
544
  }),
543
545
  });
544
546
  });
@@ -18,7 +18,8 @@ export declare function resolveSubtreeFilter(adapter: DatabaseAdapter, underId:
18
18
  } | null>;
19
19
  export declare function toSummaries(nodes: ContentNode[]): PublicContentNodeSummary[];
20
20
  export declare function toPublicDocumentType(doc: DocumentTypeDefinition): PublicDocumentType;
21
- export declare function toPublicMediaFile(mediaService: MediaService, media: MediaFile): Promise<PublicMediaFile>;
21
+ export { resolvePublicBaseUrl } from "../config/public-url.js";
22
+ export declare function toPublicMediaFile(mediaService: MediaService, media: MediaFile, baseUrl: string): Promise<PublicMediaFile>;
22
23
  export type PublishedItemDeps = {
23
24
  adapter: DatabaseAdapter;
24
25
  listSerializerContributions: () => Array<{
@@ -117,16 +117,39 @@ export function toPublicDocumentType(doc) {
117
117
  })),
118
118
  };
119
119
  }
120
- // The S105 public media projection, with a FRESH presigned download URL
121
- // (never cached/stored same convention as the REST media routes).
122
- export async function toPublicMediaFile(mediaService, media) {
123
- const downloadUrl = await mediaService.getDownloadUrl(media.id);
120
+ // S186c Task 3, mandate 1: the PUBLIC surface (REST /api/v1/media* and
121
+ // GraphQL) is consumed cross-origin by design a headless frontend's own
122
+ // server fetches `downloadUrl` directly (e.g. the blog starter's
123
+ // og-image route does `fetch(media.downloadUrl)`, and its post template
124
+ // renders `<img src={media.downloadUrl}>` on the FRONTEND's origin, not
125
+ // the CMS's). A path-relative URL there resolves against the wrong
126
+ // origin and 404s. The backoffice /api/media surface stays
127
+ // path-relative on purpose (routes/media.ts's toResponse) — it's always
128
+ // same-origin with the admin SPA that renders it.
129
+ //
130
+ // S186c Task 3 fix wave, L1: the actual base-resolution logic (and its
131
+ // full doc comment) moved to config/public-url.ts, mirroring
132
+ // config/preview.ts's previewBaseUrl idiom — re-exported here unchanged
133
+ // so every existing call site (public-api.ts, graphql/schema.ts) keeps
134
+ // importing it from this module.
135
+ export { resolvePublicBaseUrl } from "../config/public-url.js";
136
+ // The S105 public media projection. downloadUrl is the stable, computed
137
+ // app path (S186c §1/§3, `/media/<id>/<filename>`) made ABSOLUTE (see
138
+ // resolvePublicBaseUrl above) — NOT a time-limited presigned URL; it's
139
+ // recomputed on every call from id+filename rather than cached/stored,
140
+ // same convention as the REST media routes, but there is nothing here to
141
+ // expire. For a PRIVATE item the underlying path additionally carries a
142
+ // freshly minted `?token=` (MediaService.getDownloadUrl, S186c §3) —
143
+ // still recomputed fresh, still made absolute the same way.
144
+ export async function toPublicMediaFile(mediaService, media, baseUrl) {
145
+ const relativeUrl = await mediaService.getDownloadUrl(media.id);
124
146
  return {
125
147
  id: media.id,
126
148
  filename: media.filename,
127
149
  mimeType: media.mimeType,
128
150
  size: media.size,
129
- downloadUrl,
151
+ downloadUrl: `${baseUrl}${relativeUrl}`,
152
+ visibility: media.visibility,
130
153
  uploadedAt: media.uploadedAt,
131
154
  };
132
155
  }
@@ -1,4 +1,4 @@
1
- import { cpSync, existsSync, rmSync } from "node:fs";
1
+ import { cpSync, existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  // Build-time only. The devDependency on @velora-cms/admin-ui exists purely
@@ -21,4 +21,12 @@ if (!existsSync(source)) {
21
21
  }
22
22
  rmSync(target, { recursive: true, force: true });
23
23
  cpSync(source, target, { recursive: true });
24
+ // S186b: stamp the bundle with this build's own server version, so a
25
+ // scaffold that copies dist/admin-bundle out (or a syncAdminBundle() run
26
+ // later) inherits a version marker for boot-time drift detection
27
+ // (admin-static.ts) without re-deriving it. Resolved relative to this
28
+ // script, not cwd — same reasoning as `source`/`target` above.
29
+ const packageJson = JSON.parse(readFileSync(join(serverDist, "..", "package.json"), "utf8"));
30
+ writeFileSync(join(target, ".velora-version"), packageJson.version);
24
31
  console.log(`[copy-admin-bundle] copied admin bundle -> ${target}`);
32
+ console.log(`[copy-admin-bundle] stamped version ${packageJson.version}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velora-cms/server",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "engines": {
5
5
  "node": ">=22.12"
6
6
  },
@@ -9,7 +9,8 @@
9
9
  "license": "Apache-2.0",
10
10
  "type": "module",
11
11
  "bin": {
12
- "velora-server": "./dist/bin.js"
12
+ "velora-server": "./dist/bin.js",
13
+ "velora-sync-admin": "./dist/bin-sync-admin.js"
13
14
  },
14
15
  "main": "./dist/lib.js",
15
16
  "exports": {
@@ -58,9 +59,9 @@
58
59
  "mercurius": "16.10.0",
59
60
  "prisma": "6.19.3",
60
61
  "zod": "4.4.3",
62
+ "@velora-cms/api-schemas": "0.12.0",
63
+ "@velora-cms/db-adapter": "0.11.2",
61
64
  "@velora-cms/content-service": "0.11.0",
62
- "@velora-cms/api-schemas": "0.11.0",
63
- "@velora-cms/db-adapter": "0.11.0",
64
65
  "@velora-cms/design-tokens": "0.11.0",
65
66
  "@velora-cms/plugin-package": "0.11.0",
66
67
  "@velora-cms/plugin-sdk": "0.11.0",
@@ -75,10 +76,10 @@
75
76
  "form-data": "4.0.6",
76
77
  "tar": "^7.5.22",
77
78
  "tsx": "4.22.4",
78
- "@velora-cms/docs": "0.1.0",
79
- "create-velora-plugin": "0.11.0",
80
79
  "@velora-cms/admin-ui": "0.1.0",
80
+ "@velora-cms/docs": "0.1.0",
81
81
  "@velora-cms/google-maps": "0.1.0",
82
+ "create-velora-plugin": "0.11.0",
82
83
  "velora-plugin": "0.11.0",
83
84
  "@velora-cms/reading-time": "0.1.0"
84
85
  },
@@ -60,14 +60,27 @@ model MediaFile {
60
60
  filename String
61
61
  mimeType String @map("mime_type")
62
62
  size Int
63
- // Object key inside the S3 bucket what upload/download/delete address.
64
- // `url` is the canonical object URL persisted for API responses; actual
65
- // downloads go through presigned URLs generated from the key.
63
+ // Object key inside the storage backend (S3/MinIO or the local driver)
64
+ // what upload/download/delete address. `url` is the canonical stable app
65
+ // path persisted for API responses (`/media/<id>/<filename>`, S186c
66
+ // §1/§3). M3 fix wave: downloads do NOT go through presigned URLs
67
+ // anymore (that mechanism expired in 15 minutes even for public files,
68
+ // which S186c replaced) — GET /media/:id/:filename (media-public.ts)
69
+ // looks up this key server-side and streams the bytes itself, same as
70
+ // the backoffice /api/media route.
66
71
  storageKey String @unique @map("storage_key")
67
72
  url String
68
73
  uploadedBy String @map("uploaded_by")
69
74
  uploader User @relation(fields: [uploadedBy], references: [id])
70
75
  uploadedAt DateTime @default(now()) @map("uploaded_at")
76
+ // 'public' | 'private' (S186c §2) — a flag, never a location: flipping it
77
+ // updates this row only, no file move, no key/URL change in either
78
+ // storage driver. Existing rows default/backfill to 'public', matching
79
+ // their pre-S186c behavior (everything was effectively link-accessible).
80
+ // Ships as core-migration step 0003_media_visibility (probe-guarded,
81
+ // additive) for pre-existing databases; new/db-push databases get it
82
+ // straight from this column.
83
+ visibility String @default("public")
71
84
 
72
85
  @@index([uploadedBy])
73
86
  @@map("media_files")