nuxt-media-library 0.0.44 → 0.0.48

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.
package/README.md CHANGED
@@ -82,6 +82,77 @@ If you use the uploader component in Nuxt, provide config via the Nuxt plugin
82
82
  </template>
83
83
  ```
84
84
 
85
+ ### Authorizing `/manage` with media grants
86
+
87
+ The module's server proxies forward `/api/media-library/manage/*` to the
88
+ media library. Without an `authorize` hook they forward the browser's
89
+ credentials (or your static headers) as before. With one, every `/manage`
90
+ request is checked by your rules and sent upstream with a short-lived
91
+ **media grant** scoped to exactly that request (server:
92
+ `docs/security/media-grants.md`). Your app never re-implements the
93
+ endpoints, and no long-lived credential reaches the media library from the
94
+ browser.
95
+
96
+ ```ts
97
+ // server/api/media-library/[...].ts
98
+ import { createMLCatchAllProxy, specFromNeed } from '@fritill-team/nuxt-media-library/server'
99
+
100
+ const COVER = { kind: 'image', visibility: 'public', allowedMimeTypes: ['image/*'], maxSizeBytes: 5_000_000 }
101
+
102
+ export default createMLCatchAllProxy({
103
+ async authorize(event, need) {
104
+ const user = await requireUser(event) // your session
105
+ const subject = String(user.id)
106
+ if (need.scope === 'upload' && !need.sessionId) { // creating an upload
107
+ return user.canUpload ? { scopes: ['upload'], profile: COVER, subject } : false
108
+ }
109
+ if (need.taskId) return false // or look up the task's asset
110
+ if (need.scope === 'manage:*') return user.isAdmin // tenant-wide list, stream
111
+ return (need.assetIds ?? []).every(id => user.canEdit(id))
112
+ ? { ...specFromNeed(need), subject }
113
+ : false
114
+ },
115
+ })
116
+ ```
117
+
118
+ - `need`: `{ scope, method, path, assetIds?, taskId?, sessionId?, upload? }`
119
+ (`upload` is the create body: MIME type, size, file name).
120
+ - Return `false` (403), `true` (the need as is), or a spec
121
+ `{ scopes, assetIds?, sessionIds?, profile?, subject?, ttlSeconds? }`.
122
+ Creating an upload needs a `profile`: kind, visibility and processing come
123
+ from it, not from the browser. `manage:*` is only accepted for requests
124
+ that need it.
125
+ - Grants are issued with the private runtime config
126
+ `mediaLibrary.serviceToken` (`NUXT_MEDIA_LIBRARY_SERVICE_TOKEN`), or pass
127
+ `issueGrant(spec)` to get them from your backend (e.g. the Python
128
+ client's `create_grant_router`). They are cached per spec until shortly
129
+ before they expire; an upload's parts, finalize and abort reuse the grant
130
+ that created the session.
131
+ - `/delivery` and the processing metadata routes are not affected.
132
+
133
+ ### Showing assets of several services: read grants
134
+
135
+ Every link comes from the media library; each service decides only for its
136
+ own assets by returning a read grant (`mlg_…`, e.g. the Python client's
137
+ `client.grants.read(ids)`) with the data that references them. Hand the
138
+ grants to the module and every preview resolve sends them in one request:
139
+
140
+ ```ts
141
+ const { addMediaGrants } = useMLMediaGrants()
142
+
143
+ const lesson = await $fetch('/api/lessons/42') // course service
144
+ const profile = await $fetch('/api/profile') // profile service
145
+ addMediaGrants(lesson.mediaGrant, profile.mediaGrant) // "mlg_…" or { grant, expiresAt }
146
+ ```
147
+
148
+ - Live grants (up to 10, latest expiry first) go in `X-Media-Grants` on
149
+ `POST /api/media-library/delivery/assets/resolve`; the proxy forwards
150
+ them. Expired ones are dropped.
151
+ - Assets no grant lists come back `forbidden` from the server and are shown
152
+ as broken (`isBroken(id)`), as before; they are resolved again whenever
153
+ grants are added.
154
+ - Grants live in the browser only, never in server-side state.
155
+
85
156
  ### Vue installation (non-Nuxt)
86
157
 
87
158
  ```bash
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "media-library",
3
3
  "configKey": "mediaLibrary",
4
- "version": "0.0.44",
4
+ "version": "0.0.48",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -93,7 +93,10 @@ const module$1 = defineNuxtModule({
93
93
  ...existingPrivate,
94
94
  baseURL: existingPrivate.baseURL ?? envBaseURL ?? options.baseURL ?? "",
95
95
  routes: existingPrivate.routes ?? options.routes ?? routes,
96
- headers: resolvedHeaders
96
+ headers: resolvedHeaders,
97
+ // Only read when a proxy has `authorize` and no `issueGrant` (media
98
+ // grants). Declared so NUXT_MEDIA_LIBRARY_SERVICE_TOKEN applies.
99
+ serviceToken: existingPrivate.serviceToken ?? ""
97
100
  };
98
101
  nuxt.options.runtimeConfig.public.mediaLibrary = {
99
102
  ...existingPublic,
@@ -1,6 +1,7 @@
1
1
  <script setup>
2
2
  import { computed, ref, onMounted, watch } from "vue";
3
3
  import { useMLResourcePolicy } from "../../composables/resourcePolicy/useMLResourcePolicy";
4
+ import { mediaGrantHeaders } from "../../composables/useMLMediaGrants";
4
5
  const props = defineProps({
5
6
  assetId: { type: String, required: true },
6
7
  manager: { type: Object, required: true }
@@ -34,7 +35,8 @@ async function resolveVideoUrl() {
34
35
  }
35
36
  try {
36
37
  const result = await $fetch(
37
- `/api/media-library/delivery/assets/${encodeURIComponent(props.assetId)}`
38
+ `/api/media-library/delivery/assets/${encodeURIComponent(props.assetId)}`,
39
+ { headers: mediaGrantHeaders() }
38
40
  );
39
41
  videoUrl.value = result.downloadUrl ?? null;
40
42
  } catch {
@@ -12,14 +12,14 @@ type __VLS_Props = {
12
12
  uploadFailedText?: string;
13
13
  };
14
14
  declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
15
- remove: () => any;
16
15
  error: (message: string) => any;
16
+ remove: () => any;
17
17
  progress: (percent: number) => any;
18
18
  uploaded: (assetIds: string[]) => any;
19
19
  canceled: () => any;
20
20
  }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
21
- onRemove?: (() => any) | undefined;
22
21
  onError?: ((message: string) => any) | undefined;
22
+ onRemove?: (() => any) | undefined;
23
23
  onProgress?: ((percent: number) => any) | undefined;
24
24
  onUploaded?: ((assetIds: string[]) => any) | undefined;
25
25
  onCanceled?: (() => any) | undefined;
@@ -12,14 +12,14 @@ type __VLS_Props = {
12
12
  uploadFailedText?: string;
13
13
  };
14
14
  declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
15
- remove: () => any;
16
15
  error: (message: string) => any;
16
+ remove: () => any;
17
17
  progress: (percent: number) => any;
18
18
  uploaded: (assetIds: string[]) => any;
19
19
  canceled: () => any;
20
20
  }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
21
- onRemove?: (() => any) | undefined;
22
21
  onError?: ((message: string) => any) | undefined;
22
+ onRemove?: (() => any) | undefined;
23
23
  onProgress?: ((percent: number) => any) | undefined;
24
24
  onUploaded?: ((assetIds: string[]) => any) | undefined;
25
25
  onCanceled?: (() => any) | undefined;
@@ -1,10 +1,13 @@
1
1
  import { ref } from "vue";
2
2
  import { $fetch } from "ofetch";
3
+ import { mediaGrantHeaders } from "./useMLMediaGrants.js";
4
+ import { onMediaGrantsAdded } from "../utils/grants/listeners.js";
3
5
  const assets = ref({});
4
6
  const pending = ref(/* @__PURE__ */ new Set());
5
7
  const broken = ref(/* @__PURE__ */ new Set());
6
8
  const forbidden = ref(/* @__PURE__ */ new Set());
7
9
  const loading = ref(false);
10
+ const lockedByGrant = /* @__PURE__ */ new Set();
8
11
  function computeSources(asset) {
9
12
  const sources = [];
10
13
  for (const rendition of asset.renditions ?? []) {
@@ -62,9 +65,13 @@ async function fetchAssets(ids) {
62
65
  try {
63
66
  const result = await $fetch("/api/media-library/delivery/assets/resolve", {
64
67
  method: "POST",
65
- body: { assetIds: targetIds }
68
+ body: { assetIds: targetIds },
69
+ headers: mediaGrantHeaders()
66
70
  });
67
71
  for (const item of result.items ?? []) {
72
+ if (item.status === "forbidden") {
73
+ lockedByGrant.add(item.assetId);
74
+ }
68
75
  if (item.status !== "ok" || !item.asset) {
69
76
  broken.value.add(item.assetId);
70
77
  continue;
@@ -135,6 +142,15 @@ async function flushPending() {
135
142
  pending.value.clear();
136
143
  await fetchAssets(ids);
137
144
  }
145
+ onMediaGrantsAdded(() => {
146
+ if (!lockedByGrant.size) return;
147
+ for (const id of lockedByGrant) {
148
+ broken.value.delete(id);
149
+ pending.value.add(id);
150
+ }
151
+ lockedByGrant.clear();
152
+ if (import.meta.client) scheduleFetch();
153
+ });
138
154
  export const useMLAssetPreview = () => {
139
155
  return {
140
156
  assets,
@@ -0,0 +1,23 @@
1
+ import type { GrantInput, HeldGrant } from '../utils/grants/store.js';
2
+ /**
3
+ * Add read grants your services returned (`"mlg_…"` or `{ grant, expiresAt }`).
4
+ * Assets the server answered "forbidden" for are resolved again, since a
5
+ * new grant may list them. Every delivery resolve sends the live grants in `X-Media-Grants`.
6
+ */
7
+ declare function addMediaGrants(...grants: GrantInput[]): void;
8
+ declare function clearMediaGrants(): void;
9
+ /** Headers to send on a `/delivery` request. */
10
+ declare function mediaGrantHeaders(): Record<string, string>;
11
+ export { addMediaGrants, clearMediaGrants, mediaGrantHeaders };
12
+ export declare const useMLMediaGrants: () => {
13
+ grants: import("vue").Ref<{
14
+ grant: string;
15
+ expiresAt: number;
16
+ }[], HeldGrant[] | {
17
+ grant: string;
18
+ expiresAt: number;
19
+ }[]>;
20
+ addMediaGrants: typeof addMediaGrants;
21
+ clearMediaGrants: typeof clearMediaGrants;
22
+ mediaGrantHeaders: typeof mediaGrantHeaders;
23
+ };
@@ -0,0 +1,22 @@
1
+ import { ref } from "vue";
2
+ import { grantHeaders, mergeGrants } from "../utils/grants/store.js";
3
+ import { notifyMediaGrantsAdded } from "../utils/grants/listeners.js";
4
+ const held = ref([]);
5
+ function addMediaGrants(...grants) {
6
+ if (!import.meta.client) return;
7
+ held.value = mergeGrants(held.value, grants);
8
+ notifyMediaGrantsAdded();
9
+ }
10
+ function clearMediaGrants() {
11
+ held.value = [];
12
+ }
13
+ function mediaGrantHeaders() {
14
+ return import.meta.client ? grantHeaders(held.value) : {};
15
+ }
16
+ export { addMediaGrants, clearMediaGrants, mediaGrantHeaders };
17
+ export const useMLMediaGrants = () => ({
18
+ grants: held,
19
+ addMediaGrants,
20
+ clearMediaGrants,
21
+ mediaGrantHeaders
22
+ });
@@ -0,0 +1,36 @@
1
+ import type { UploadProgress } from '../types/uploader.js';
2
+ import type { ServiceUploadOptions, ServiceUploadResult } from '../utils/uploader/serviceUpload.js';
3
+ export type { ServiceUploadOptions, ServiceUploadResult } from '../utils/uploader/serviceUpload.js';
4
+ /**
5
+ * Upload a file through one of the app's backend services (py_client
6
+ * `create_upload_router`). The service starts the upload and hands out a
7
+ * grant for that session; the file goes straight to the media library.
8
+ *
9
+ * ```ts
10
+ * const { upload, abort, uploading, progress, error } = useMLServiceUpload()
11
+ * const { sessionId, assetId } = await upload(file, {
12
+ * start: '/api/videos/uploads',
13
+ * body: { profile: 'lesson-video', context: { lessonId } },
14
+ * onProgress: p => console.log(p.percent),
15
+ * })
16
+ * await $fetch(`/api/videos/lessons/${lessonId}/video`, { method: 'PUT', body: { sessionId, assetId } })
17
+ * ```
18
+ *
19
+ * One upload at a time per call of the composable; call it once per
20
+ * upload field. The media library must allow the site's origin (CORS).
21
+ */
22
+ export declare function useMLServiceUpload(): {
23
+ upload: (file: File, options: ServiceUploadOptions) => Promise<ServiceUploadResult>;
24
+ abort: () => void;
25
+ uploading: import("vue").Ref<boolean, boolean>;
26
+ progress: import("vue").Ref<{
27
+ totalBytes: number;
28
+ uploadedBytes: number;
29
+ percent: number;
30
+ } | null, UploadProgress | {
31
+ totalBytes: number;
32
+ uploadedBytes: number;
33
+ percent: number;
34
+ } | null>;
35
+ error: import("vue").Ref<unknown, unknown>;
36
+ };
@@ -0,0 +1,32 @@
1
+ import { ref } from "vue";
2
+ import { ChunkUploader } from "../utils/uploader/uploader.js";
3
+ import { createServiceUpload } from "../utils/uploader/serviceUpload.js";
4
+ export function useMLServiceUpload() {
5
+ const uploading = ref(false);
6
+ const progress = ref(null);
7
+ const error = ref(null);
8
+ const runner = createServiceUpload({
9
+ fetch: (url, opts) => $fetch(url, opts),
10
+ createUploader: (cfg) => new ChunkUploader(cfg)
11
+ });
12
+ async function upload(file, options) {
13
+ uploading.value = true;
14
+ progress.value = null;
15
+ error.value = null;
16
+ try {
17
+ return await runner.upload(file, {
18
+ ...options,
19
+ onProgress: (p) => {
20
+ progress.value = p;
21
+ options.onProgress?.(p);
22
+ }
23
+ });
24
+ } catch (err) {
25
+ error.value = err;
26
+ throw err;
27
+ } finally {
28
+ uploading.value = false;
29
+ }
30
+ }
31
+ return { upload, abort: runner.abort, uploading, progress, error };
32
+ }
@@ -1,4 +1,5 @@
1
1
  import type { H3Event } from 'h3';
2
+ import type { MLAuthorize, MLIssueGrant } from './grants.js';
2
3
  export type MLProxyType = 'assets' | 'uploads' | 'delivery' | 'processing' | 'resource-policy';
3
4
  export interface MLProxyOptions {
4
5
  /**
@@ -8,6 +9,21 @@ export interface MLProxyOptions {
8
9
  * from runtimeConfig.
9
10
  */
10
11
  requestHeaders?: (event: H3Event, incoming: Record<string, string>) => Record<string, string> | Promise<Record<string, string>>;
12
+ /**
13
+ * Media grants (server docs/security/media-grants.md). When set, `/manage`
14
+ * requests no longer carry the browser's or the static credentials: for
15
+ * each one the proxy asks `authorize(event, need)` (your rules: the user
16
+ * from your session, the asset ids in `need`) and sends a short-lived
17
+ * grant scoped to exactly that. `false` → 403. Unknown `/manage` routes →
18
+ * 404. `/delivery` is not affected.
19
+ */
20
+ authorize?: MLAuthorize<H3Event>;
21
+ /**
22
+ * How grants are issued. Default: `POST {baseURL}/manage/grants` with the
23
+ * private runtime config `mediaLibrary.serviceToken` (the Nuxt server is
24
+ * the app backend). Pass your own to ask your backend instead.
25
+ */
26
+ issueGrant?: MLIssueGrant;
11
27
  }
12
28
  /**
13
29
  * Create a Nitro event handler that replicates one of the media library's
@@ -1,5 +1,14 @@
1
- import { createError, defineEventHandler, getRequestHeaders, getRequestURL, proxyRequest, readRawBody } from "h3";
1
+ import { createError, defineEventHandler, getRequestHeaders, getRequestURL, getRequestWebStream, proxyRequest, readBody, sendProxy, setResponseStatus } from "h3";
2
2
  import { useRuntimeConfig } from "#imports";
3
+ import {
4
+ cachedIssuer,
5
+ grantFor,
6
+ grantNeed,
7
+ MLGrantIssueError,
8
+ serviceTokenIssuer,
9
+ SessionGrants,
10
+ stripProfileFields
11
+ } from "./grants.js";
3
12
  const HOP_BY_HOP = ["connection", "transfer-encoding", "keep-alive", "upgrade", "expect", "host"];
4
13
  function stripHopByHop(headers) {
5
14
  return Object.fromEntries(Object.entries(headers).filter(([k]) => !HOP_BY_HOP.includes(k)));
@@ -33,7 +42,7 @@ function resolveRoute(route, params) {
33
42
  return value === void 0 ? "" : String(value);
34
43
  });
35
44
  }
36
- async function handleAssetsProxy(event, baseURL, staticHeaders, assetsBase, requestHeadersFn) {
45
+ async function handleAssetsProxy(event, baseURL, staticHeaders, assetsBase, options) {
37
46
  const reqUrl = getRequestURL(event);
38
47
  const prefix = "/api/media-library/manage/assets";
39
48
  const rest = reqUrl.pathname.slice(prefix.length);
@@ -51,19 +60,15 @@ async function handleAssetsProxy(event, baseURL, staticHeaders, assetsBase, requ
51
60
  if (isItem && !["GET", "PATCH", "DELETE"].includes(method)) throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" });
52
61
  if (isSubResource && method !== "GET") throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" });
53
62
  const upstreamUrl = buildUpstreamUrl(baseURL, `${assetsBase}${trimmedRest}`, reqUrl.search);
54
- const headers = await buildProxyHeaders(event, staticHeaders, requestHeadersFn);
55
- const result = await proxyRequest(event, upstreamUrl.toString(), { headers, streamRequest: true });
56
- if (event.node.res.statusCode >= 400) {
57
- console.error(`[media-library] assets-proxy upstream responded ${event.node.res.statusCode} for ${method} ${upstreamUrl}`);
58
- }
59
- return result;
63
+ return forward(event, "assets", upstreamUrl, method, baseURL, staticHeaders, options);
60
64
  }
61
- async function handleUploadsProxy(event, baseURL, staticHeaders, routes, requestHeadersFn) {
65
+ async function handleUploadsProxy(event, baseURL, staticHeaders, routes, options) {
62
66
  const reqUrl = getRequestURL(event);
63
67
  const prefix = "/api/media-library/manage/uploads";
64
68
  const rest = reqUrl.pathname.slice(prefix.length);
65
69
  const method = event.node.req.method || "GET";
66
70
  let upstreamPath = "";
71
+ let upstreamMethod = method;
67
72
  if (rest === "/sessions" || rest === "/sessions/") {
68
73
  if (method !== "POST") throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" });
69
74
  upstreamPath = routes.createSession;
@@ -79,7 +84,8 @@ async function handleUploadsProxy(event, baseURL, staticHeaders, routes, request
79
84
  if (method !== "POST") throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" });
80
85
  upstreamPath = resolveRoute(routes.finalize, { id: finalizeMatch[1] });
81
86
  } else if (abortMatch && routes.abort) {
82
- if (method !== "DELETE") throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" });
87
+ if (method !== "POST" && method !== "DELETE") throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" });
88
+ upstreamMethod = "POST";
83
89
  upstreamPath = resolveRoute(routes.abort, { id: abortMatch[1] });
84
90
  } else if (sessionMatch) {
85
91
  if (method !== "GET") throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" });
@@ -88,14 +94,9 @@ async function handleUploadsProxy(event, baseURL, staticHeaders, routes, request
88
94
  }
89
95
  if (!upstreamPath) throw createError({ statusCode: 404, statusMessage: "Unknown upload route" });
90
96
  const upstreamUrl = buildUpstreamUrl(baseURL, upstreamPath, reqUrl.search);
91
- const headers = await buildProxyHeaders(event, staticHeaders, requestHeadersFn);
92
- const result = await proxyRequest(event, upstreamUrl.toString(), { headers, streamRequest: true });
93
- if (event.node.res.statusCode >= 400) {
94
- console.error(`[media-library] uploads-proxy upstream responded ${event.node.res.statusCode} for ${method} ${upstreamUrl}`);
95
- }
96
- return result;
97
+ return forward(event, "uploads", upstreamUrl, upstreamMethod, baseURL, staticHeaders, options);
97
98
  }
98
- async function handleDeliveryProxy(event, baseURL, staticHeaders, requestHeadersFn) {
99
+ async function handleDeliveryProxy(event, baseURL, staticHeaders, options) {
99
100
  const reqUrl = getRequestURL(event);
100
101
  const prefix = "/api/media-library/delivery";
101
102
  const rest = reqUrl.pathname.slice(prefix.length);
@@ -109,14 +110,14 @@ async function handleDeliveryProxy(event, baseURL, staticHeaders, requestHeaders
109
110
  if (isResolve && method !== "POST") throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" });
110
111
  if (isItem && method !== "GET") throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" });
111
112
  const upstreamUrl = buildUpstreamUrl(baseURL, `/delivery${trimmedRest}`, reqUrl.search);
112
- const headers = await buildProxyHeaders(event, staticHeaders, requestHeadersFn);
113
+ const headers = await buildProxyHeaders(event, staticHeaders, options?.requestHeaders);
113
114
  const result = await proxyRequest(event, upstreamUrl.toString(), { headers, streamRequest: true });
114
115
  if (event.node.res.statusCode >= 400) {
115
116
  console.error(`[media-library] delivery-proxy upstream responded ${event.node.res.statusCode} for ${method} ${upstreamUrl}`);
116
117
  }
117
118
  return result;
118
119
  }
119
- async function handleProcessingProxy(event, baseURL, staticHeaders, requestHeadersFn) {
120
+ async function handleProcessingProxy(event, baseURL, staticHeaders, options) {
120
121
  const reqUrl = getRequestURL(event);
121
122
  const prefix = "/api/media-library/manage/processing";
122
123
  const rest = reqUrl.pathname.slice(prefix.length);
@@ -124,15 +125,9 @@ async function handleProcessingProxy(event, baseURL, staticHeaders, requestHeade
124
125
  const allowed = /^\/stream$/.test(rest) && method === "GET" || /^\/assets\/[^/]+\/stream$/.test(rest) && method === "GET" || /^\/assets\/[^/]+\/tasks$/.test(rest) && method === "GET" || /^\/assets\/[^/]+\/process$/.test(rest) && method === "POST" || /^\/tasks\/[^/]+\/retry$/.test(rest) && method === "POST" || /^\/tasks\/[^/]+\/configure$/.test(rest) && method === "POST" || /^\/processors$/.test(rest) && method === "GET";
125
126
  if (!allowed) throw createError({ statusCode: 404, statusMessage: "Not found" });
126
127
  const upstreamUrl = buildUpstreamUrl(baseURL, `/manage/processing${rest}`, reqUrl.search);
127
- const headers = await buildProxyHeaders(event, staticHeaders, requestHeadersFn);
128
- const hasBody = method !== "GET" && method !== "HEAD";
129
- const body = hasBody ? await readRawBody(event, false) : void 0;
130
- return proxyRequest(event, upstreamUrl.toString(), {
131
- headers,
132
- ...body !== void 0 ? { body } : {}
133
- });
128
+ return forward(event, "processing", upstreamUrl, method, baseURL, staticHeaders, options);
134
129
  }
135
- async function handleResourcePolicyProxy(event, baseURL, staticHeaders, requestHeadersFn) {
130
+ async function handleResourcePolicyProxy(event, baseURL, staticHeaders, options) {
136
131
  const reqUrl = getRequestURL(event);
137
132
  const prefix = "/api/media-library/manage/resource-policies";
138
133
  const rest = reqUrl.pathname.slice(prefix.length);
@@ -152,10 +147,113 @@ async function handleResourcePolicyProxy(event, baseURL, staticHeaders, requestH
152
147
  if (isCollection && method !== "GET" && !(isDev && method === "POST")) throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" });
153
148
  if (isItem && method !== "GET" && !(isDev && writeMethods.includes(method))) throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" });
154
149
  const upstreamUrl = buildUpstreamUrl(baseURL, `/manage/resource-policies${trimmedRest}`, reqUrl.search);
155
- const headers = await buildProxyHeaders(event, staticHeaders, requestHeadersFn);
156
- const result = await proxyRequest(event, upstreamUrl.toString(), { headers, streamRequest: true });
150
+ return forward(event, "resource-policy", upstreamUrl, method, baseURL, staticHeaders, options);
151
+ }
152
+ const SESSION_GRANTS = new SessionGrants();
153
+ const issuers = /* @__PURE__ */ new WeakMap();
154
+ let defaultIssuer;
155
+ function grantIssuer(options, baseURL, staticHeaders) {
156
+ if (options.issueGrant) {
157
+ let issue = issuers.get(options.issueGrant);
158
+ if (!issue) issuers.set(options.issueGrant, issue = cachedIssuer(options.issueGrant));
159
+ return issue;
160
+ }
161
+ const mlConfig = useRuntimeConfig().mediaLibrary ?? {};
162
+ const serviceToken = mlConfig.serviceToken;
163
+ if (!serviceToken) {
164
+ throw createError({ statusCode: 500, statusMessage: "mediaLibrary.serviceToken (private runtime config) or issueGrant is required with authorize." });
165
+ }
166
+ const tenant = headerValue(staticHeaders, "x-tenant");
167
+ const key = `${baseURL}|${tenant ?? ""}|${serviceToken}`;
168
+ if (defaultIssuer?.key !== key) {
169
+ defaultIssuer = { key, issue: cachedIssuer(serviceTokenIssuer({ baseURL, serviceToken, tenant })) };
170
+ }
171
+ return defaultIssuer.issue;
172
+ }
173
+ function headerValue(headers, name) {
174
+ const entry = Object.entries(headers).find(([k]) => k.toLowerCase() === name);
175
+ return entry?.[1] || void 0;
176
+ }
177
+ const JSON_BODY_ROUTES = [/\/manage\/assets\/resolve$/, /\/manage\/uploads\/sessions$/];
178
+ async function forward(event, name, upstreamUrl, method, baseURL, staticHeaders, options) {
179
+ if (!options?.authorize) {
180
+ const headers2 = await buildProxyHeaders(event, staticHeaders, options?.requestHeaders);
181
+ return logged(event, name, method, upstreamUrl, proxyRequest(event, upstreamUrl.toString(), { headers: headers2, streamRequest: true, fetchOptions: { method } }));
182
+ }
183
+ const base = upstreamHeaders(await buildProxyHeaders(event, staticHeaders, options.requestHeaders));
184
+ const needsBody = method === "POST" && JSON_BODY_ROUTES.some((re) => re.test(upstreamUrl.pathname));
185
+ let body = needsBody ? await readBody(event) ?? {} : void 0;
186
+ const need = grantNeed(method, upstreamUrl.pathname, body);
187
+ if (need === null) throw createError({ statusCode: 404, statusMessage: "Not found" });
188
+ let headers = base;
189
+ if (need !== "public") {
190
+ let result;
191
+ try {
192
+ result = await grantFor(event, need, options.authorize, grantIssuer(options, baseURL, staticHeaders), SESSION_GRANTS);
193
+ } catch (error) {
194
+ if (error instanceof MLGrantIssueError) {
195
+ console.error(`[media-library] ${error.message}`);
196
+ throw createError({ statusCode: 502, statusMessage: "Media library refused to issue a grant" });
197
+ }
198
+ throw error;
199
+ }
200
+ if ("status" in result) throw createError({ statusCode: result.status, statusMessage: result.message });
201
+ headers = { ...base, Authorization: `Bearer ${result.grant.grant}` };
202
+ if (need.upload && result.spec.profile && body) body = stripProfileFields(body);
203
+ if (need.scope === "upload" && !need.sessionId) {
204
+ const json = JSON.stringify(body ?? {});
205
+ const response = await fetch(upstreamUrl.toString(), {
206
+ method: "POST",
207
+ // The body was rewritten: never reuse the browser's content-length.
208
+ headers: { ...withoutLength(headers), "content-type": "application/json" },
209
+ body: json
210
+ });
211
+ const text = await response.text();
212
+ let payload = text;
213
+ try {
214
+ payload = text ? JSON.parse(text) : null;
215
+ } catch {
216
+ }
217
+ const id = payload?.id;
218
+ if (response.ok && id) SESSION_GRANTS.set(String(id), result.grant);
219
+ setResponseStatus(event, response.status);
220
+ const contentType = response.headers.get("content-type");
221
+ if (contentType) event.node.res.setHeader("content-type", contentType);
222
+ return payload;
223
+ }
224
+ if (need.sessionId && /\/(?:finalize|abort)$/.test(need.path)) {
225
+ const sessionId = need.sessionId;
226
+ event.node.res.once("finish", () => {
227
+ if (event.node.res.statusCode < 400) SESSION_GRANTS.delete(sessionId);
228
+ });
229
+ }
230
+ }
231
+ const fetchOptions = { method, headers };
232
+ if (body !== void 0) {
233
+ const json = JSON.stringify(body);
234
+ fetchOptions.body = json;
235
+ fetchOptions.headers = { ...withoutLength(headers), "content-type": "application/json" };
236
+ } else if (PAYLOAD_METHODS.has((event.node.req.method || "GET").toUpperCase())) {
237
+ fetchOptions.body = getRequestWebStream(event);
238
+ fetchOptions.duplex = "half";
239
+ }
240
+ return logged(event, name, method, upstreamUrl, sendProxy(event, upstreamUrl.toString(), { fetchOptions }));
241
+ }
242
+ const PAYLOAD_METHODS = /* @__PURE__ */ new Set(["PATCH", "POST", "PUT", "DELETE"]);
243
+ const NOT_FORWARDED = ["authorization", "cookie", "accept-encoding", "transfer-encoding", "connection", "keep-alive", "upgrade", "expect", "host", "content-length"];
244
+ function withoutLength(headers) {
245
+ return Object.fromEntries(Object.entries(headers).filter(([k]) => k.toLowerCase() !== "content-length"));
246
+ }
247
+ function upstreamHeaders(headers) {
248
+ const out = Object.fromEntries(Object.entries(headers).filter(([k]) => !NOT_FORWARDED.includes(k.toLowerCase())));
249
+ const length = headerValue(headers, "content-length");
250
+ if (length) out["content-length"] = length;
251
+ return out;
252
+ }
253
+ async function logged(event, name, method, url, request) {
254
+ const result = await request;
157
255
  if (event.node.res.statusCode >= 400) {
158
- console.error(`[media-library] resource-policy-proxy upstream responded ${event.node.res.statusCode} for ${method} ${upstreamUrl}`);
256
+ console.error(`[media-library] ${name}-proxy upstream responded ${event.node.res.statusCode} for ${method} ${url}`);
159
257
  }
160
258
  return result;
161
259
  }
@@ -183,16 +281,16 @@ export function createMLCatchAllProxy(options) {
183
281
  baseURL,
184
282
  staticHeaders,
185
283
  mlConfig.routes?.assets || "/manage/assets",
186
- options?.requestHeaders
284
+ options
187
285
  );
188
286
  case "uploads":
189
- return handleUploadsProxy(event, baseURL, staticHeaders, mlConfig.routes, options?.requestHeaders);
287
+ return handleUploadsProxy(event, baseURL, staticHeaders, mlConfig.routes, options);
190
288
  case "delivery":
191
- return handleDeliveryProxy(event, baseURL, staticHeaders, options?.requestHeaders);
289
+ return handleDeliveryProxy(event, baseURL, staticHeaders, options);
192
290
  case "processing":
193
- return handleProcessingProxy(event, baseURL, staticHeaders, options?.requestHeaders);
291
+ return handleProcessingProxy(event, baseURL, staticHeaders, options);
194
292
  case "resource-policy":
195
- return handleResourcePolicyProxy(event, baseURL, staticHeaders, options?.requestHeaders);
293
+ return handleResourcePolicyProxy(event, baseURL, staticHeaders, options);
196
294
  }
197
295
  });
198
296
  }
@@ -209,7 +307,7 @@ export function createMLProxy(type, options) {
209
307
  baseURL,
210
308
  staticHeaders,
211
309
  mlConfig.routes?.assets || "/manage/assets",
212
- options?.requestHeaders
310
+ options
213
311
  );
214
312
  case "uploads":
215
313
  return handleUploadsProxy(
@@ -217,14 +315,14 @@ export function createMLProxy(type, options) {
217
315
  baseURL,
218
316
  staticHeaders,
219
317
  mlConfig.routes,
220
- options?.requestHeaders
318
+ options
221
319
  );
222
320
  case "delivery":
223
- return handleDeliveryProxy(event, baseURL, staticHeaders, options?.requestHeaders);
321
+ return handleDeliveryProxy(event, baseURL, staticHeaders, options);
224
322
  case "processing":
225
- return handleProcessingProxy(event, baseURL, staticHeaders, options?.requestHeaders);
323
+ return handleProcessingProxy(event, baseURL, staticHeaders, options);
226
324
  case "resource-policy":
227
- return handleResourcePolicyProxy(event, baseURL, staticHeaders, options?.requestHeaders);
325
+ return handleResourcePolicyProxy(event, baseURL, staticHeaders, options);
228
326
  default:
229
327
  throw createError({ statusCode: 500, statusMessage: `Unknown MLProxy type: ${type}` });
230
328
  }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Media grants for the proxy (server docs/security/media-grants.md).
3
+ *
4
+ * With an `authorize` hook, the proxy no longer forwards the browser's
5
+ * credentials to `/manage`. For each request it works out what the call
6
+ * needs (`MLGrantNeed`), asks the app (`authorize`), gets a short-lived grant
7
+ * scoped to exactly that and sends `Authorization: Bearer mlg_…` upstream.
8
+ * The media library enforces the grant, so the app's rules decide and the
9
+ * app never re-implements the endpoints.
10
+ *
11
+ * Pure functions plus `fetch`: no h3 imports, so they are unit-testable.
12
+ */
13
+ export type MLGrantScope = 'upload' | 'read' | 'update' | 'delete' | 'process' | 'renditions' | 'manage:*';
14
+ /** What one proxied `/manage` request needs. */
15
+ export interface MLGrantNeed {
16
+ scope: MLGrantScope;
17
+ /** Upstream method and path (after the proxy's rewrite). */
18
+ method: string;
19
+ path: string;
20
+ /** Assets the request acts on (path id, or `assetIds` in the body). */
21
+ assetIds?: string[];
22
+ /** Processing task the request acts on (its asset is checked upstream). */
23
+ taskId?: string;
24
+ /** Upload session the request continues. */
25
+ sessionId?: string;
26
+ /** Parsed JSON body of `POST /manage/uploads/sessions` (MIME type, size, file name…). */
27
+ upload?: Record<string, unknown>;
28
+ }
29
+ /** Upload profile, as `POST /manage/grants` takes it (camelCase). */
30
+ export interface MLGrantProfile {
31
+ kind: string;
32
+ visibility?: 'private' | 'public' | 'restricted';
33
+ collectionName?: string;
34
+ allowedMimeTypes?: string[];
35
+ maxSizeBytes?: number;
36
+ processingConfig?: Record<string, unknown>;
37
+ }
38
+ /** What to issue. Returned by `authorize` when the default is not enough. */
39
+ export interface MLGrantSpec {
40
+ scopes: MLGrantScope[];
41
+ assetIds?: string[];
42
+ sessionIds?: string[];
43
+ profile?: MLGrantProfile;
44
+ /** The app's user reference; logged by the media library. */
45
+ subject?: string;
46
+ ttlSeconds?: number;
47
+ }
48
+ export interface MLIssuedGrant {
49
+ grant: string;
50
+ /** ISO timestamp. */
51
+ expiresAt: string;
52
+ }
53
+ /**
54
+ * The app's decision. `false`/`null`/`undefined`: 403. `true`: issue exactly
55
+ * the need (scope plus its asset or session; for a task, the app must return
56
+ * a spec with the task's asset ids). An upload create needs a spec with a
57
+ * `profile`: the profile, not the browser, decides kind, visibility and
58
+ * processing.
59
+ */
60
+ export type MLAuthorize<E = unknown> = (event: E, need: MLGrantNeed) => boolean | MLGrantSpec | null | undefined | Promise<boolean | MLGrantSpec | null | undefined>;
61
+ /**
62
+ * The need of an upstream `/manage` request, `'public'` for metadata routes
63
+ * that need no grant, or `null` when the route is unknown (deny).
64
+ */
65
+ export declare function grantNeed(method: string, upstreamPath: string, body?: unknown): MLGrantNeed | 'public' | null;
66
+ /** The spec `authorize: true` stands for. */
67
+ export declare function specFromNeed(need: MLGrantNeed): MLGrantSpec;
68
+ /** The upload-create body to forward under a grant with a profile. */
69
+ export declare function stripProfileFields(body: Record<string, unknown>): Record<string, unknown>;
70
+ export type MLIssueGrant = (spec: MLGrantSpec) => Promise<MLIssuedGrant>;
71
+ /**
72
+ * Issue grants from the Nuxt server itself with the tenant's service token
73
+ * (`POST {baseURL}/manage/grants`). Use when the Nuxt server is the app
74
+ * backend; otherwise pass an `issueGrant` that asks your backend (e.g. the
75
+ * Python client's `create_grant_router`).
76
+ */
77
+ export declare function serviceTokenIssuer(options: {
78
+ baseURL: string;
79
+ serviceToken: string;
80
+ tenant?: string;
81
+ fetch?: typeof fetch;
82
+ }): MLIssueGrant;
83
+ export declare class MLGrantIssueError extends Error {
84
+ status: number;
85
+ detail: string;
86
+ constructor(status: number, detail: string);
87
+ }
88
+ /**
89
+ * Reuse an issued grant for the same spec until shortly before it expires.
90
+ * Specs include `subject`, so users never share grants unless the app
91
+ * returns specs without one.
92
+ */
93
+ export declare function cachedIssuer(issue: MLIssueGrant, options?: {
94
+ marginSeconds?: number;
95
+ maxEntries?: number;
96
+ now?: () => number;
97
+ }): MLIssueGrant;
98
+ /**
99
+ * Remember which grant created an upload session, so its parts, finalize
100
+ * and abort reuse it (the media library binds the session to that grant).
101
+ * `authorize` is still asked for every request.
102
+ * In-memory: with several Nitro instances a miss asks `authorize` again with
103
+ * the session id.
104
+ */
105
+ export declare class SessionGrants {
106
+ private now;
107
+ private max;
108
+ private grants;
109
+ constructor(now?: () => number, max?: number);
110
+ set(sessionId: string, grant: MLIssuedGrant): void;
111
+ get(sessionId: string): MLIssuedGrant | undefined;
112
+ delete(sessionId: string): void;
113
+ }
114
+ /**
115
+ * Resolve the grant for one request. Returns the issued grant, or a status
116
+ * to answer with (403 refused, 404 unknown route).
117
+ */
118
+ export declare function grantFor<E>(event: E, need: MLGrantNeed, authorize: MLAuthorize<E>, issue: MLIssueGrant, sessions?: SessionGrants): Promise<{
119
+ grant: MLIssuedGrant;
120
+ spec: MLGrantSpec;
121
+ } | {
122
+ status: 403 | 422;
123
+ message: string;
124
+ }>;
@@ -0,0 +1,161 @@
1
+ const UUIDish = "[^/]+";
2
+ const ROUTES = [
3
+ ["GET", /^\/manage\/assets$/, "manage:*", null],
4
+ ["POST", /^\/manage\/assets\/resolve$/, "read", "body"],
5
+ ["POST", /^\/manage\/assets\/bulk-visibility$/, "update", "body"],
6
+ ["GET", new RegExp(`^/manage/assets/(${UUIDish})$`), "read", "asset"],
7
+ ["PATCH", new RegExp(`^/manage/assets/(${UUIDish})$`), "update", "asset"],
8
+ ["DELETE", new RegExp(`^/manage/assets/(${UUIDish})$`), "delete", "asset"],
9
+ ["GET", new RegExp(`^/manage/assets/(${UUIDish})/renditions$`), "read", "asset"],
10
+ ["POST", new RegExp(`^/manage/assets/(${UUIDish})/renditions/[^/]+$`), "renditions", "asset"],
11
+ ["POST", new RegExp(`^/manage/processing/assets/(${UUIDish})/process$`), "process", "asset"],
12
+ ["GET", new RegExp(`^/manage/processing/assets/(${UUIDish})/(?:tasks|stream)$`), "read", "asset"],
13
+ ["POST", new RegExp(`^/manage/processing/tasks/(${UUIDish})/(?:configure|retry)$`), "process", "task"],
14
+ ["GET", /^\/manage\/processing\/stream$/, "manage:*", null],
15
+ ["POST", /^\/manage\/uploads\/sessions$/, "upload", null],
16
+ ["GET", new RegExp(`^/manage/uploads/sessions/(${UUIDish})$`), "upload", "session"],
17
+ ["PUT", new RegExp(`^/manage/uploads/sessions/(${UUIDish})/parts/[^/]+$`), "upload", "session"],
18
+ ["POST", new RegExp(`^/manage/uploads/sessions/(${UUIDish})/(?:finalize|abort)$`), "upload", "session"],
19
+ ["GET", /^\/manage\/resource-policies(?:\/[^/]+)?$/, "manage:*", null],
20
+ ["POST", /^\/manage\/resource-policies$/, "manage:*", null],
21
+ ["PUT", /^\/manage\/resource-policies\/[^/]+$/, "manage:*", null],
22
+ ["DELETE", /^\/manage\/resource-policies\/[^/]+$/, "manage:*", null]
23
+ ];
24
+ const PUBLIC = [
25
+ /^\/manage\/processing\/processors$/,
26
+ /^\/manage\/processing-options\//,
27
+ /^\/manage\/resource-policies\/schema$/,
28
+ /^\/manage\/resource-policies\/pipelines\/available-steps$/
29
+ ];
30
+ export function grantNeed(method, upstreamPath, body) {
31
+ const path = upstreamPath.replace(/\/+$/, "").replace(/^.*?(?=\/manage\/)/, "");
32
+ method = method.toUpperCase();
33
+ if (PUBLIC.some((re) => re.test(path))) return "public";
34
+ for (const [m, re, scope, target] of ROUTES) {
35
+ if (m !== method) continue;
36
+ const match = path.match(re);
37
+ if (!match) continue;
38
+ const need = { scope, method, path };
39
+ const id = match[1] ? decodeURIComponent(match[1]) : void 0;
40
+ if (target === "asset") need.assetIds = [id];
41
+ else if (target === "task") need.taskId = id;
42
+ else if (target === "session") need.sessionId = id;
43
+ else if (target === "body") {
44
+ const ids = body?.assetIds;
45
+ need.assetIds = Array.isArray(ids) ? ids.map(String) : [];
46
+ }
47
+ if (scope === "upload" && !target && body && typeof body === "object") {
48
+ need.upload = body;
49
+ }
50
+ return need;
51
+ }
52
+ return null;
53
+ }
54
+ export function specFromNeed(need) {
55
+ const spec = { scopes: [need.scope] };
56
+ if (need.assetIds?.length) spec.assetIds = need.assetIds;
57
+ if (need.sessionId) spec.sessionIds = [need.sessionId];
58
+ return spec;
59
+ }
60
+ const PROFILE_FIELDS = ["kind", "visibility", "processingConfig", "processing_config", "resourceType", "resource_type", "collectionName", "collection_name"];
61
+ export function stripProfileFields(body) {
62
+ return Object.fromEntries(Object.entries(body).filter(([k]) => !PROFILE_FIELDS.includes(k)));
63
+ }
64
+ export function serviceTokenIssuer(options) {
65
+ const doFetch = options.fetch ?? fetch;
66
+ const url = new URL("./manage/grants", options.baseURL.endsWith("/") ? options.baseURL : `${options.baseURL}/`);
67
+ return async (spec) => {
68
+ const headers = {
69
+ "Authorization": `Bearer ${options.serviceToken}`,
70
+ "Content-Type": "application/json"
71
+ };
72
+ if (options.tenant) headers["X-Tenant"] = options.tenant;
73
+ const response = await doFetch(url.toString(), { method: "POST", headers, body: JSON.stringify(spec) });
74
+ if (!response.ok) {
75
+ const detail = await response.text().catch(() => "");
76
+ throw new MLGrantIssueError(response.status, detail);
77
+ }
78
+ const body = await response.json();
79
+ return { grant: body.grant, expiresAt: body.expiresAt };
80
+ };
81
+ }
82
+ export class MLGrantIssueError extends Error {
83
+ constructor(status, detail) {
84
+ super(`media library refused to issue a grant (${status}): ${detail}`);
85
+ this.status = status;
86
+ this.detail = detail;
87
+ }
88
+ }
89
+ export function cachedIssuer(issue, options = {}) {
90
+ const margin = (options.marginSeconds ?? 30) * 1e3;
91
+ const max = options.maxEntries ?? 1e3;
92
+ const now = options.now ?? Date.now;
93
+ const cache = /* @__PURE__ */ new Map();
94
+ return async (spec) => {
95
+ const key = canonicalKey(spec);
96
+ const hit = cache.get(key);
97
+ if (hit && hit.until > now()) return { grant: hit.grant, expiresAt: hit.expiresAt };
98
+ const issued = await issue(spec);
99
+ const until = Date.parse(issued.expiresAt) - margin;
100
+ if (cache.size >= max) cache.delete(cache.keys().next().value);
101
+ if (until > now()) cache.set(key, { ...issued, until });
102
+ return issued;
103
+ };
104
+ }
105
+ function canonicalKey(spec) {
106
+ const sorted = (v) => v ? [...v].sort() : void 0;
107
+ return JSON.stringify({
108
+ s: sorted(spec.scopes),
109
+ a: sorted(spec.assetIds),
110
+ n: sorted(spec.sessionIds),
111
+ p: spec.profile ? stableStringify(spec.profile) : void 0,
112
+ u: spec.subject,
113
+ t: spec.ttlSeconds
114
+ });
115
+ }
116
+ function stableStringify(value) {
117
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
118
+ if (value && typeof value === "object") {
119
+ return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(",")}}`;
120
+ }
121
+ return JSON.stringify(value);
122
+ }
123
+ export class SessionGrants {
124
+ constructor(now = Date.now, max = 5e3) {
125
+ this.now = now;
126
+ this.max = max;
127
+ }
128
+ grants = /* @__PURE__ */ new Map();
129
+ set(sessionId, grant) {
130
+ if (this.grants.size >= this.max) this.grants.delete(this.grants.keys().next().value);
131
+ this.grants.set(sessionId, grant);
132
+ }
133
+ get(sessionId) {
134
+ const grant = this.grants.get(sessionId);
135
+ if (grant && Date.parse(grant.expiresAt) - 5e3 > this.now()) return grant;
136
+ this.grants.delete(sessionId);
137
+ return void 0;
138
+ }
139
+ delete(sessionId) {
140
+ this.grants.delete(sessionId);
141
+ }
142
+ }
143
+ export async function grantFor(event, need, authorize, issue, sessions) {
144
+ const decision = await authorize(event, need);
145
+ if (!decision) return { status: 403, message: "Not allowed" };
146
+ if (need.sessionId && sessions) {
147
+ const known = sessions.get(need.sessionId);
148
+ if (known) return { grant: known, spec: { scopes: ["upload"], sessionIds: [need.sessionId] } };
149
+ }
150
+ const spec = decision === true ? specFromNeed(need) : decision;
151
+ if (spec.scopes.includes("manage:*") && need.scope !== "manage:*") {
152
+ return { status: 403, message: "authorize returned manage:* for a scoped request" };
153
+ }
154
+ if (need.scope === "upload" && !need.sessionId && !spec.profile && !spec.scopes.includes("manage:*")) {
155
+ return { status: 422, message: "authorize must return a profile for upload sessions" };
156
+ }
157
+ if (need.taskId && decision === true) {
158
+ return { status: 422, message: "authorize must return assetIds for processing tasks" };
159
+ }
160
+ return { grant: await issue(spec), spec };
161
+ }
@@ -1,2 +1,4 @@
1
1
  export { createMLProxy, createMLCatchAllProxy } from './create-ml-proxy.js';
2
2
  export type { MLProxyType, MLProxyOptions } from './create-ml-proxy.js';
3
+ export { cachedIssuer, grantNeed, MLGrantIssueError, serviceTokenIssuer, specFromNeed, } from './grants.js';
4
+ export type { MLAuthorize, MLGrantNeed, MLGrantProfile, MLGrantScope, MLGrantSpec, MLIssueGrant, MLIssuedGrant, } from './grants.js';
@@ -1 +1,8 @@
1
1
  export { createMLProxy, createMLCatchAllProxy } from "./create-ml-proxy.js";
2
+ export {
3
+ cachedIssuer,
4
+ grantNeed,
5
+ MLGrantIssueError,
6
+ serviceTokenIssuer,
7
+ specFromNeed
8
+ } from "./grants.js";
@@ -34,6 +34,7 @@ export default defineEventHandler(async (event) => {
34
34
  const rest = path.slice(prefix.length);
35
35
  let upstreamPath = "";
36
36
  const method = event.node.req.method || "GET";
37
+ let upstreamMethod = method;
37
38
  if (rest === "/sessions" || rest === "/sessions/") {
38
39
  if (method !== "POST") {
39
40
  throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" });
@@ -55,9 +56,10 @@ export default defineEventHandler(async (event) => {
55
56
  }
56
57
  upstreamPath = resolveRoute(resolvedRoutes.finalize, { id: finalizeMatch[1] });
57
58
  } else if (abortMatch && resolvedRoutes.abort) {
58
- if (method !== "DELETE") {
59
+ if (method !== "POST" && method !== "DELETE") {
59
60
  throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" });
60
61
  }
62
+ upstreamMethod = "POST";
61
63
  upstreamPath = resolveRoute(resolvedRoutes.abort, { id: abortMatch[1] });
62
64
  } else if (sessionMatch) {
63
65
  if (method !== "GET") {
@@ -86,7 +88,8 @@ export default defineEventHandler(async (event) => {
86
88
  delete proxyHeaders.host;
87
89
  const result = await proxyRequest(event, upstreamUrl.toString(), {
88
90
  headers: proxyHeaders,
89
- streamRequest: true
91
+ streamRequest: true,
92
+ fetchOptions: { method: upstreamMethod }
90
93
  });
91
94
  if (event.node.res.statusCode >= 400) {
92
95
  console.error(`[media-library] uploads-proxy upstream responded ${event.node.res.statusCode} for ${method} ${upstreamUrl.toString()}`);
@@ -164,9 +164,11 @@ export interface DeliveryAssetListResponse {
164
164
  }
165
165
  export interface DeliveryBatchResolveItem {
166
166
  assetId: string;
167
- status: 'ok' | 'not_found';
167
+ status: 'ok' | 'not_found' | 'forbidden' | 'error';
168
168
  asset: DeliveryAsset | null;
169
169
  error: string | null;
170
+ /** Public thumbnail, also sent for forbidden and error items. */
171
+ thumbnailUrl?: string | null;
170
172
  }
171
173
  export interface DeliveryBatchResolveResponse {
172
174
  items: DeliveryBatchResolveItem[];
@@ -0,0 +1,2 @@
1
+ export declare function onMediaGrantsAdded(listener: () => void): () => void;
2
+ export declare function notifyMediaGrantsAdded(): void;
@@ -0,0 +1,8 @@
1
+ const listeners = /* @__PURE__ */ new Set();
2
+ export function onMediaGrantsAdded(listener) {
3
+ listeners.add(listener);
4
+ return () => listeners.delete(listener);
5
+ }
6
+ export function notifyMediaGrantsAdded() {
7
+ for (const listener of listeners) listener();
8
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Media grants the browser holds (server docs/security/media-grants.md).
3
+ *
4
+ * Each service returns a read grant (`mlg_…`) with the data that references
5
+ * its assets. The app adds them here; every delivery resolve sends the live
6
+ * ones in `X-Media-Grants`, so one request resolves assets of several
7
+ * services. Pure (no Vue), so it is unit-testable.
8
+ */
9
+ export declare const GRANTS_HEADER = "X-Media-Grants";
10
+ /** Server limit per request. */
11
+ export declare const MAX_GRANTS = 10;
12
+ export interface HeldGrant {
13
+ grant: string;
14
+ /** Epoch milliseconds. */
15
+ expiresAt: number;
16
+ }
17
+ export type GrantInput = string | {
18
+ grant: string;
19
+ expiresAt?: string | number | null;
20
+ } | null | undefined;
21
+ /** `exp` of an `mlg_<jwt>` grant, in epoch ms, or null when unreadable. */
22
+ export declare function grantExpiry(grant: string): number | null;
23
+ /** `held` plus `inputs`: deduplicated, expired ones dropped, newest kept. */
24
+ export declare function mergeGrants(held: HeldGrant[], inputs: GrantInput[], now?: number, max?: number): HeldGrant[];
25
+ /** Headers for a delivery request: the `MAX_GRANTS` live grants expiring last. */
26
+ export declare function grantHeaders(held: HeldGrant[], now?: number): Record<string, string>;
@@ -0,0 +1,37 @@
1
+ export const GRANTS_HEADER = "X-Media-Grants";
2
+ export const MAX_GRANTS = 10;
3
+ const MARGIN_MS = 5e3;
4
+ export function grantExpiry(grant) {
5
+ const jwt = grant.startsWith("mlg_") ? grant.slice(4) : grant;
6
+ const payload = jwt.split(".")[1];
7
+ if (!payload) return null;
8
+ try {
9
+ const json = JSON.parse(atob(payload.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(payload.length / 4) * 4, "=")));
10
+ return typeof json.exp === "number" ? json.exp * 1e3 : null;
11
+ } catch {
12
+ return null;
13
+ }
14
+ }
15
+ function normalize(input) {
16
+ if (!input) return null;
17
+ const grant = typeof input === "string" ? input : input.grant;
18
+ if (!grant || !grant.startsWith("mlg_")) return null;
19
+ let expiresAt = null;
20
+ if (typeof input !== "string" && input.expiresAt != null) {
21
+ expiresAt = typeof input.expiresAt === "number" ? input.expiresAt : Date.parse(input.expiresAt);
22
+ }
23
+ if (expiresAt == null || Number.isNaN(expiresAt)) expiresAt = grantExpiry(grant);
24
+ if (expiresAt == null) return null;
25
+ return { grant, expiresAt };
26
+ }
27
+ export function mergeGrants(held, inputs, now = Date.now(), max = 50) {
28
+ const byToken = /* @__PURE__ */ new Map();
29
+ for (const g of [...held, ...inputs.map(normalize)]) {
30
+ if (g && g.expiresAt - MARGIN_MS > now) byToken.set(g.grant, g);
31
+ }
32
+ return [...byToken.values()].sort((a, b) => b.expiresAt - a.expiresAt).slice(0, max);
33
+ }
34
+ export function grantHeaders(held, now = Date.now()) {
35
+ const live = held.filter((g) => g.expiresAt - MARGIN_MS > now).slice(0, MAX_GRANTS);
36
+ return live.length ? { [GRANTS_HEADER]: live.map((g) => g.grant).join(", ") } : {};
37
+ }
@@ -0,0 +1,84 @@
1
+ import type { ChunkUploaderConfig, UploadFileOptions, UploadProgress, UploadResult } from '../../types/uploader.js';
2
+ /**
3
+ * Browser uploads started by one of the app's backend services
4
+ * (py_client `create_upload_router`): the service creates the upload
5
+ * session and returns a grant for it; the browser uploads straight to the
6
+ * media library, refreshing the grant through the service, and gets back
7
+ * `{ sessionId, assetId }` to send to the service's attach endpoint.
8
+ */
9
+ /** What the service's start endpoint answers (201). */
10
+ export interface ServiceUploadStart {
11
+ sessionId: string;
12
+ grant: string;
13
+ expiresAt: string;
14
+ uploadBaseUrl: string;
15
+ tenant: string;
16
+ partSizeBytes?: number;
17
+ }
18
+ /** What the service's refresh endpoint answers. */
19
+ export interface ServiceUploadGrant {
20
+ sessionId?: string;
21
+ grant: string;
22
+ expiresAt: string;
23
+ }
24
+ export interface ServiceUploadOptions {
25
+ /** The service's start endpoint, e.g. `/api/videos/uploads`. */
26
+ start: string;
27
+ /**
28
+ * Extra fields for the start request, e.g. `{ profile, context }`.
29
+ * `mimeType`, `sizeBytes` and `filename` are taken from the file
30
+ * (fields given here win).
31
+ */
32
+ body?: Record<string, unknown>;
33
+ /** The refresh endpoint. Default: `${start}/${sessionId}/grant`. */
34
+ refresh?: string | ((sessionId: string) => string);
35
+ /** Headers for the service's start and refresh requests (e.g. CSRF). */
36
+ headers?: Record<string, string>;
37
+ /** Refresh the grant this long before it expires. Default 60 s. */
38
+ refreshMarginMs?: number;
39
+ /** Parallel part uploads. Default 3. */
40
+ concurrency?: number;
41
+ onProgress?: (p: UploadProgress) => void;
42
+ onState?: (s: {
43
+ phase: string;
44
+ message?: string;
45
+ }) => void;
46
+ }
47
+ export interface ServiceUploadResult {
48
+ sessionId: string;
49
+ assetId: string;
50
+ }
51
+ type FetchLike = <T>(url: string, opts: {
52
+ method: string;
53
+ body?: unknown;
54
+ headers?: Record<string, string>;
55
+ }) => Promise<T>;
56
+ interface UploaderLike {
57
+ uploadFile: (file: File, options: UploadFileOptions) => Promise<UploadResult>;
58
+ abort: () => void;
59
+ }
60
+ export interface ServiceUploadDeps {
61
+ fetch: FetchLike;
62
+ createUploader: (cfg: ChunkUploaderConfig) => UploaderLike;
63
+ now?: () => number;
64
+ setTimer?: (fn: () => void, ms: number) => unknown;
65
+ clearTimer?: (handle: unknown) => void;
66
+ }
67
+ /** The media library routes a session grant allows (relative to `uploadBaseUrl`). */
68
+ export declare const serviceUploadRoutes: {
69
+ createSession: string;
70
+ getSession: (id: string) => string;
71
+ uploadPart: (id: string, n: number) => string;
72
+ finalize: (id: string) => string;
73
+ abort: (id: string) => string;
74
+ };
75
+ /**
76
+ * One upload at a time per instance: `upload` runs the flow, `abort` stops
77
+ * it (the parts already sent stay with the session until it expires).
78
+ */
79
+ export declare function createServiceUpload(deps: ServiceUploadDeps): {
80
+ upload: (file: File, options: ServiceUploadOptions) => Promise<ServiceUploadResult>;
81
+ abort: () => void;
82
+ isRunning: () => boolean;
83
+ };
84
+ export {};
@@ -0,0 +1,109 @@
1
+ const SESSIONS = "/manage/uploads/sessions";
2
+ const DEFAULT_PART_SIZE = 8 * 1024 * 1024;
3
+ const MIN_REFRESH_DELAY_MS = 1e3;
4
+ export const serviceUploadRoutes = {
5
+ createSession: SESSIONS,
6
+ getSession: (id) => `${SESSIONS}/${encodeURIComponent(id)}`,
7
+ uploadPart: (id, n) => `${SESSIONS}/${encodeURIComponent(id)}/parts/${n}`,
8
+ finalize: (id) => `${SESSIONS}/${encodeURIComponent(id)}/finalize`,
9
+ abort: (id) => `${SESSIONS}/${encodeURIComponent(id)}/abort`
10
+ };
11
+ export function createServiceUpload(deps) {
12
+ const now = deps.now ?? Date.now;
13
+ const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
14
+ const clearTimer = deps.clearTimer ?? ((h) => clearTimeout(h));
15
+ let current = null;
16
+ const abortError = () => new DOMException("Aborted", "AbortError");
17
+ async function upload(file, options) {
18
+ if (current) throw new Error("An upload is already running; abort it first.");
19
+ const run = { uploader: null, stop: () => {
20
+ }, aborted: false };
21
+ current = run;
22
+ try {
23
+ return await runUpload(file, options, run);
24
+ } finally {
25
+ run.stop();
26
+ current = null;
27
+ }
28
+ }
29
+ async function runUpload(file, options, run) {
30
+ const start = options.start.replace(/\/+$/, "");
31
+ const margin = options.refreshMarginMs ?? 6e4;
32
+ options.onState?.({ phase: "service.start" });
33
+ const started = await deps.fetch(start, {
34
+ method: "POST",
35
+ headers: options.headers,
36
+ body: {
37
+ mimeType: file.type || "application/octet-stream",
38
+ sizeBytes: file.size,
39
+ filename: file.name,
40
+ ...options.body
41
+ }
42
+ });
43
+ if (run.aborted) throw abortError();
44
+ const sessionId = started.sessionId;
45
+ const refreshUrl = typeof options.refresh === "function" ? options.refresh(sessionId) : options.refresh ?? `${start}/${encodeURIComponent(sessionId)}/grant`;
46
+ let grant = started.grant;
47
+ let timer = null;
48
+ let stopped = false;
49
+ let refreshError = null;
50
+ const schedule = (expiresAt) => {
51
+ const expiresMs = Date.parse(expiresAt);
52
+ if (Number.isNaN(expiresMs)) return;
53
+ const delay = Math.max(MIN_REFRESH_DELAY_MS, expiresMs - margin - now());
54
+ timer = setTimer(refresh, delay);
55
+ };
56
+ async function refresh() {
57
+ timer = null;
58
+ if (stopped) return;
59
+ try {
60
+ const next = await deps.fetch(refreshUrl, { method: "POST", headers: options.headers });
61
+ if (stopped) return;
62
+ grant = next.grant;
63
+ refreshError = null;
64
+ schedule(next.expiresAt);
65
+ } catch (err) {
66
+ refreshError = err;
67
+ if (!stopped) timer = setTimer(refresh, Math.max(MIN_REFRESH_DELAY_MS, margin / 4));
68
+ }
69
+ }
70
+ run.stop = () => {
71
+ stopped = true;
72
+ if (timer !== null) clearTimer(timer);
73
+ timer = null;
74
+ };
75
+ const uploader = deps.createUploader({
76
+ baseURL: started.uploadBaseUrl.replace(/\/+$/, ""),
77
+ routes: serviceUploadRoutes,
78
+ partSize: started.partSizeBytes ?? DEFAULT_PART_SIZE,
79
+ concurrency: options.concurrency ?? 3,
80
+ checksum: { algorithm: "SHA-256", perPart: true, final: true },
81
+ headers: () => ({ "Authorization": `Bearer ${grant}`, "X-Tenant": started.tenant })
82
+ });
83
+ run.uploader = uploader;
84
+ schedule(started.expiresAt);
85
+ try {
86
+ const result = await uploader.uploadFile(file, {
87
+ sessionId,
88
+ create: { mime_type: file.type || "application/octet-stream" },
89
+ onProgress: options.onProgress,
90
+ onState: options.onState
91
+ });
92
+ const assetId = result.finalize?.asset_id;
93
+ if (!assetId) throw new Error("The media library did not return an asset id.");
94
+ return { sessionId, assetId: String(assetId) };
95
+ } catch (err) {
96
+ if (refreshError && !(err instanceof DOMException && err.name === "AbortError")) {
97
+ throw new Error("Upload failed after the upload grant could not be refreshed.", { cause: err });
98
+ }
99
+ throw err;
100
+ }
101
+ }
102
+ function abort() {
103
+ if (!current) return;
104
+ current.aborted = true;
105
+ current.stop();
106
+ current.uploader?.abort();
107
+ }
108
+ return { upload, abort, isRunning: () => current !== null };
109
+ }
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "nuxt-media-library",
3
- "version": "0.0.44",
3
+ "version": "0.0.48",
4
4
  "description": "Nuxt module for media library management — chunked uploads, asset previews, and resource policies",
5
- "repository": "fritill-team/nuxt-media-library",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/fritill-team/media_library_module.git"
8
+ },
6
9
  "license": "MIT",
7
10
  "type": "module",
8
11
  "types": "./dist/types.d.mts",