@aotter/mantle 0.2.1 → 0.2.2-alpha.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.
Files changed (70) hide show
  1. package/dist/auth/routes.d.ts +3 -1
  2. package/dist/auth/routes.js +15 -2
  3. package/dist/build/compiler.d.ts +1 -0
  4. package/dist/build/compiler.js +1 -1
  5. package/dist/build/spec/kernel/diagnostic.d.ts +1 -1
  6. package/dist/bun/assets.d.ts +3 -1
  7. package/dist/bun/assets.js +4 -1
  8. package/dist/domain/describe/chunk-GMUORMOX.js +1 -0
  9. package/dist/domain/describe/index.js +1 -1
  10. package/dist/domain/describe/public.js +1 -1
  11. package/dist/domain/generated-tools.js +24 -9
  12. package/dist/infrastructure/bundle.js +30 -1
  13. package/dist/infrastructure/inputs.js +3 -2
  14. package/dist/infrastructure/wrangler.js +4 -3
  15. package/dist/kernel/authorization.d.ts +1 -0
  16. package/dist/kernel/authorization.js +12 -5
  17. package/dist/kernel/mcp-call.d.ts +3 -3
  18. package/dist/kernel/mcp-call.js +4 -4
  19. package/dist/kernel/mcp-handler.js +4 -3
  20. package/dist/kernel/mcp.d.ts +12 -10
  21. package/dist/kernel/mcp.js +1 -1
  22. package/dist/kernel/media-fetch.js +109 -0
  23. package/dist/kernel/media.d.ts +15 -2
  24. package/dist/kernel/media.js +138 -16
  25. package/dist/kernel/r2-media.d.ts +3 -2
  26. package/dist/kernel/r2-media.js +12 -8
  27. package/dist/kernel/result-meta.d.ts +10 -0
  28. package/dist/kernel/result-meta.js +1 -0
  29. package/dist/kernel/site-types.d.ts +54 -6
  30. package/dist/kernel/site.d.ts +1 -1
  31. package/dist/usecase/generate.js +2 -2
  32. package/dist/usecase/mcp-media.js +17 -5
  33. package/dist/usecase/mcp.js +6 -5
  34. package/dist/usecase/site.js +4 -2
  35. package/docs/examples/README.md +5 -1
  36. package/docs/examples/app-shell/README.md +5 -1
  37. package/docs/examples/app-shell/package.json +9 -9
  38. package/docs/examples/app-shell/src/jwks.ts +22 -0
  39. package/docs/examples/app-shell/src/service.ts +12 -1
  40. package/docs/examples/app-shell/wrangler.jsonc +1 -1
  41. package/docs/examples/club-events/README.md +10 -3
  42. package/docs/examples/club-events/manifests/club-events.yaml +42 -11
  43. package/docs/examples/club-events/smoke.mjs +31 -1
  44. package/docs/examples/club-events/src/handlers.ts +9 -8
  45. package/docs/examples/frontend-mcp-app/README.md +23 -0
  46. package/docs/examples/frontend-mcp-app/app.tsx +50 -0
  47. package/docs/examples/frontend-mcp-app/manifest.yaml +30 -0
  48. package/docs/examples/purchasing/README.md +9 -0
  49. package/docs/examples/purchasing/manifests/purchasing.yaml +18 -16
  50. package/docs/examples/purchasing/smoke.mjs +5 -0
  51. package/docs/examples/purchasing/wrangler.jsonc +1 -1
  52. package/docs/examples/recipes/wrangler.jsonc +1 -1
  53. package/docs/handbook/cloudflare/authentication.md +51 -0
  54. package/docs/handbook/cloudflare/deploy-and-operate.md +5 -0
  55. package/docs/handbook/cloudflare/media-r2.md +5 -2
  56. package/docs/handbook/cloudflare/service-entry.md +28 -7
  57. package/docs/handbook/examples/hub.md +1 -1
  58. package/docs/handbook/guides/mcp-apps.md +94 -18
  59. package/docs/handbook/guides/native-admin.md +29 -0
  60. package/docs/handbook/guides/performance.md +5 -1
  61. package/docs/handbook/guides/revisions.md +1 -1
  62. package/docs/handbook/guides/sql-patterns.md +33 -0
  63. package/docs/handbook/reference/authorization.md +1 -1
  64. package/docs/handbook/reference/describe.md +5 -0
  65. package/docs/handbook/reference/schema.md +19 -0
  66. package/docs/handbook/reference/site-config.md +2 -0
  67. package/docs/handbook/reference/surface.md +66 -10
  68. package/docs/handbook/releases/index.md +24 -1
  69. package/package.json +10 -10
  70. package/dist/domain/describe/chunk-CW7X5Q5T.js +0 -1
@@ -2,7 +2,7 @@
2
2
  import { ContractError, type Row } from "./contracts.js";
3
3
  import type { Bound } from "./binds.js";
4
4
  import type { Dialect } from "./dialect/common.js";
5
- import type { MediaAsset, MediaLibrary, MediaStorage, MediaPurposePolicy } from "./site-types.js";
5
+ import type { MediaAsset, MediaLibrary, MediaStorageOption, MediaPurposePolicy } from "./site-types.js";
6
6
  export interface MediaDatabase {
7
7
  read(sql: string, binds: unknown[]): Row[] | PromiseLike<Row[]>;
8
8
  write(steps: readonly Bound[]): Promise<Row[][]>; /** Positive native evidence that the write rolled back; absence is uncertain. */
@@ -13,9 +13,10 @@ export interface MediaDatabase {
13
13
  * outside this module raising these codes uses it: core `statusOf` maps only the core codes.
14
14
  */
15
15
  export declare const mediaError: (code: string, message: string, path?: string, options?: ErrorOptions) => ContractError;
16
- export declare function mediaLibrary(driver: MediaDatabase, d: Pick<Dialect, "param" | "escapeLike" | "likeEscape" | "like">, storage: MediaStorage, policies: readonly MediaPurposePolicy[], now?: () => number): MediaLibrary;
16
+ export declare function mediaLibrary(driver: MediaDatabase, d: Pick<Dialect, "param" | "escapeLike" | "likeEscape" | "like">, option: MediaStorageOption, policies: readonly MediaPurposePolicy[], now?: () => number): MediaLibrary;
17
17
  /** The 0.2 Admin library item's convenience fields, without changing the storage DTO. */
18
18
  export declare function mediaItem(asset: MediaAsset): {
19
+ visibility?: "private" | undefined;
19
20
  id: string;
20
21
  variants: readonly import("./site-types.js").MediaVariant[];
21
22
  primaryUrl: string | null;
@@ -25,3 +26,15 @@ export declare function mediaItem(asset: MediaAsset): {
25
26
  caption: string | null;
26
27
  createdAt: number;
27
28
  };
29
+ /** Where `upload_media` puts the upload targets for an MCP App: the result `_meta`, which the host gives the App and not the model. */
30
+ export declare const UPLOAD_META = "net.aotter.mantle/upload";
31
+ /**
32
+ * `upload_media`, one mode per call: `{ uploadId }` commits; `variants` returns the targets (in the result `_meta` when the
33
+ * client renders MCP Apps, else in the data, for an agent that PUTs itself); `sourceUrl` or `file` fetches one public image
34
+ * and commits it; a bare purpose returns the policy. `policies` are the site's expanded purposes.
35
+ */
36
+ export declare function uploadMedia(library: MediaLibrary, policies: readonly MediaPurposePolicy[], input: Record<string, unknown>, call: {
37
+ readonly apps: boolean;
38
+ }): Promise<unknown>;
39
+ /** `find_media`: `ids` reads those assets (ids not found are listed in `missing`), else the newest-first list. */
40
+ export declare function findMedia(library: MediaLibrary, input: Record<string, unknown>): Promise<unknown>;
@@ -1,8 +1,10 @@
1
1
  import { ContractError } from "./contracts.js";
2
+ import { withResultMeta } from "./result-meta.js";
2
3
  const P = "media";
3
4
  const STATUS = new Map(Object.entries({
4
5
  MEDIA_PURPOSE_REJECTED: 400, MEDIA_SVG_REJECTED: 400, MEDIA_MIME_REJECTED: 400, MEDIA_VARIANTS_INCOMPLETE: 400, MEDIA_VARIANT_SIZE_EXCEEDED: 400, MEDIA_VARIANTS_SUSPICIOUS_SIZE: 400,
5
- MEDIA_ASSET_NOT_FOUND: 404, MEDIA_OBJECT_NOT_FOUND: 409, MEDIA_UPLOAD_EXPIRED: 410, SITE_NOT_CONFIGURED: 501, MEDIA_NOT_CONFIGURED: 501, PARTIAL_FAILURE: 503,
6
+ MEDIA_SOURCE_REJECTED: 400, MEDIA_ASSET_NOT_FOUND: 404, MEDIA_NOT_OWNED: 404, MEDIA_OBJECT_NOT_FOUND: 409, MEDIA_UPLOAD_MISMATCH: 409, MEDIA_UPLOAD_EXPIRED: 410,
7
+ SITE_NOT_CONFIGURED: 501, MEDIA_NOT_CONFIGURED: 501, MEDIA_SOURCE_FAILED: 502, MEDIA_STORAGE_FAILED: 502, PARTIAL_FAILURE: 503,
6
8
  }));
7
9
  const extra = (code) => ({ status: STATUS.get(code), ...(code === "PARTIAL_FAILURE" ? { fields: { failure: { outcome: "partial", retry: "safe", resource: "media" } } } : {}) });
8
10
  export const mediaError = (code, message, path = P, options) => new ContractError(code, message, path, { ...options, ...extra(code) });
@@ -15,6 +17,9 @@ const ALLOWED = new Set(["image/png", "image/jpeg", "image/webp", "image/avif",
15
17
  const MODERN = new Set(["image/avif", "image/webp"]);
16
18
  const ROLES = new Set(["primary", "alternate", "fallback"]);
17
19
  const TTL_MS = 15 * 60 * 1000;
20
+ const MAX_READ_SECONDS = 7 * 24 * 3600;
21
+ const TAG_KEY = "media_id_tag";
22
+ const TAGGED = /^([0-9a-f]{12})_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
18
23
  const obj = (v) => (v !== null && typeof v === "object" && !Array.isArray(v) ? v : {});
19
24
  const MAX_TEXT = 1000;
20
25
  const MAX_FILENAME = 255;
@@ -48,9 +53,11 @@ function checkVariants(policy, variants) {
48
53
  throw fail("MEDIA_VARIANTS_SUSPICIOUS_SIZE", `${heavy.mimeType} (${heavy.byteSize}B) is larger than ${fallback.mimeType} (${fallback.byteSize}B): optimize it before uploading`);
49
54
  }
50
55
  const COLUMNS = "id, created_at, alt, caption, variants, metadata";
56
+ const hidden = (variants) => variants.length > 0 && variants.every((v) => v.publicUrl === undefined);
51
57
  function assetOf(r) {
58
+ const variants = JSON.parse(String(r.variants));
52
59
  return {
53
- id: String(r.id), variants: JSON.parse(String(r.variants)), createdAt: Number(r.created_at),
60
+ id: String(r.id), variants, createdAt: Number(r.created_at), ...(hidden(variants) ? { visibility: "private" } : {}),
54
61
  ...(r.alt === null ? {} : { alt: String(r.alt) }), ...(r.caption === null ? {} : { caption: String(r.caption) }),
55
62
  ...(r.metadata === null ? {} : { metadata: JSON.parse(String(r.metadata)) }),
56
63
  };
@@ -59,16 +66,39 @@ const save = (a, p) => ({
59
66
  sql: `INSERT INTO media_assets (id, created_at, owner_id, alt, caption, variants, metadata) VALUES (${p(1)}, ${p(2)}, NULL, ${p(3)}, ${p(4)}, ${p(5)}, ${p(6)}) ON CONFLICT(id) DO NOTHING`,
60
67
  binds: [a.id, a.createdAt, a.alt ?? null, a.caption ?? null, JSON.stringify(a.variants), a.metadata ? JSON.stringify(a.metadata) : null],
61
68
  });
62
- export function mediaLibrary(driver, d, storage, policies, now = Date.now) {
69
+ export function mediaLibrary(driver, d, option, policies, now = Date.now) {
70
+ const split = "public" in option;
71
+ const pub = split ? option.public : option, priv = split ? option.private : undefined;
72
+ const closed = policies.filter((policy) => policy.visibility === "private").map((policy) => `'${policy.name}'`);
73
+ if (closed.length && !priv)
74
+ throw fail("MEDIA_NOT_CONFIGURED", `media purpose(s) ${closed.join(", ")} are private, but the media option has no private storage; pass { public, private }`);
75
+ const storeOf = (isPrivate) => {
76
+ if (isPrivate && !priv)
77
+ throw fail("MEDIA_NOT_CONFIGURED", "This asset is private, but the media option has no private storage; pass { public, private }");
78
+ return isPrivate ? priv : pub;
79
+ };
63
80
  const p = d.param;
64
81
  const one = async (sql, binds) => (await driver.read(sql, binds))[0];
65
82
  const byId = (id) => one(`SELECT ${COLUMNS} FROM media_assets WHERE id = ${p(1)}`, [id]);
66
83
  const unpend = (id) => ({ sql: `DELETE FROM pending_media_uploads WHERE id = ${p(1)}`, binds: [id] });
67
- const removeAll = (variants) => Promise.allSettled(variants.map((v) => storage.deleteObject({ storageKey: v.storageKey })));
84
+ const removeAll = (variants, storage) => Promise.allSettled(variants.map((v) => storage.deleteObject({ storageKey: v.storageKey })));
85
+ const ownTag = async () => (await one(`SELECT value FROM site_config WHERE key = ${p(1)}`, [TAG_KEY]))?.value;
86
+ const tag = async () => {
87
+ const found = await ownTag();
88
+ if (found)
89
+ return found;
90
+ await driver.write([{ sql: `INSERT INTO site_config (key, value) VALUES (${p(1)}, ${p(2)}) ON CONFLICT(key) DO NOTHING`, binds: [TAG_KEY, crypto.randomUUID().slice(0, 13).replace("-", "")] }]);
91
+ return (await ownTag());
92
+ };
93
+ const missing = async (id, what, code, message) => {
94
+ const foreign = TAGGED.exec(id)?.[1];
95
+ return foreign && foreign !== await ownTag() ? fail("MEDIA_NOT_OWNED", `not ${what} of this server; use the server that created it`) : fail(code, message);
96
+ };
97
+ const notFound = (id) => missing(id, "a media asset", "MEDIA_ASSET_NOT_FOUND", `no media asset '${id}'`);
68
98
  const get = async (id) => {
69
99
  const r = await byId(id);
70
100
  if (!r)
71
- throw fail("MEDIA_ASSET_NOT_FOUND", `no media asset '${id}'`);
101
+ throw await notFound(id);
72
102
  return assetOf(r);
73
103
  };
74
104
  return {
@@ -88,7 +118,8 @@ export function mediaLibrary(driver, d, storage, policies, now = Date.now) {
88
118
  checkVariants(policy, asked);
89
119
  const at = now();
90
120
  const expiresAt = at + TTL_MS;
91
- const uploadGroupId = crypto.randomUUID();
121
+ const uploadGroupId = `${await tag()}_${crypto.randomUUID()}`;
122
+ const isPrivate = policy.visibility === "private", storage = storeOf(isPrivate);
92
123
  const specs = asked.map(({ mimeType, byteSize, role }) => ({ mimeType, byteSize, role, maxBytes: policy.maxBytes[mimeType] }));
93
124
  let capabilities;
94
125
  try {
@@ -98,7 +129,7 @@ export function mediaLibrary(driver, d, storage, policies, now = Date.now) {
98
129
  throw own(error);
99
130
  }
100
131
  const record = {
101
- purpose, filename, ...described, expiresAt, createdAt: at,
132
+ purpose, ...(isPrivate ? { private: true } : {}), filename, ...described, expiresAt, createdAt: at,
102
133
  variants: capabilities.map((c, i) => ({ mimeType: c.mimeType, role: c.role, storageKey: c.storageKey, expectedSize: specs[i].byteSize, maxBytes: specs[i].maxBytes })),
103
134
  };
104
135
  await driver.write([
@@ -114,14 +145,18 @@ export function mediaLibrary(driver, d, storage, policies, now = Date.now) {
114
145
  return assetOf(committed);
115
146
  const row = await one(`SELECT record FROM pending_media_uploads WHERE id = ${p(1)}`, [uploadGroupId]);
116
147
  const record = row ? JSON.parse(String(row.record)) : null;
117
- const discard = () => removeAll(record?.variants ?? []);
148
+ const storage = storeOf(record?.private);
149
+ const discard = () => removeAll(record?.variants ?? [], storage);
118
150
  if (!record || record.expiresAt <= now()) {
119
151
  if (record) {
120
152
  await discard();
121
153
  await driver.write([unpend(uploadGroupId)]);
122
154
  }
123
- throw fail("MEDIA_UPLOAD_EXPIRED", `upload group '${uploadGroupId}' is unknown or expired; create a new upload`);
155
+ throw await missing(uploadGroupId, "an upload", "MEDIA_UPLOAD_EXPIRED", `upload group '${uploadGroupId}' is unknown or expired; create a new upload`);
124
156
  }
157
+ const named = obj(request).purpose;
158
+ if (named !== undefined && named !== record.purpose)
159
+ throw fail("MEDIA_UPLOAD_MISMATCH", `upload '${uploadGroupId}' was created for purpose '${record.purpose}', not '${String(named)}'`);
125
160
  let asset;
126
161
  try {
127
162
  asset = await storage.commitUpload({
@@ -143,9 +178,16 @@ export function mediaLibrary(driver, d, storage, policies, now = Date.now) {
143
178
  }
144
179
  if (asset.id !== uploadGroupId)
145
180
  throw new ContractError("OUTCOME_UNKNOWN", "Storage returned a different asset id; reconcile this upload group.", P);
181
+ if (asset.variants.some((v) => record.private ? v.publicUrl !== undefined : typeof v.publicUrl !== "string")) {
182
+ const failed = rejected(await removeAll(asset.variants, storage));
183
+ await discard();
184
+ if (failed.length)
185
+ throw fail("PARTIAL_FAILURE", "Some copied variants could not be removed. Retry the same upload group.", { cause: new AggregateError(failed.map(r => r.reason)) });
186
+ throw fail("MEDIA_NOT_CONFIGURED", record.private ? "The private media storage returned a publicUrl: its objects would be public." : "The public media storage returned a variant without a publicUrl.");
187
+ }
146
188
  await driver.write([save(asset, p), unpend(uploadGroupId)]).catch(async (error) => {
147
189
  if (driver.definitive?.(error) === true) {
148
- const failed = rejected(await removeAll(asset.variants));
190
+ const failed = rejected(await removeAll(asset.variants, storage));
149
191
  if (failed.length)
150
192
  throw fail("PARTIAL_FAILURE", "The write rolled back, but some attempt copies could not be removed. Reconcile storage before retrying.", { cause: new AggregateError([error, ...failed.map(r => r.reason)]) });
151
193
  throw error;
@@ -154,7 +196,7 @@ export function mediaLibrary(driver, d, storage, policies, now = Date.now) {
154
196
  });
155
197
  const durable = await get(uploadGroupId);
156
198
  const selected = new Set(durable.variants.map(v => v.storageKey));
157
- if (rejected(await removeAll(asset.variants.filter(v => !selected.has(v.storageKey)))).length)
199
+ if (rejected(await removeAll(asset.variants.filter(v => !selected.has(v.storageKey)), storage)).length)
158
200
  throw new ContractError("OUTCOME_UNKNOWN", "The asset is committed, but unselected upload copies need storage reconciliation.", P);
159
201
  await discard();
160
202
  return durable;
@@ -167,8 +209,8 @@ export function mediaLibrary(driver, d, storage, policies, now = Date.now) {
167
209
  if (!Number.isInteger(n) || n < 1 || n > 500 || !Number.isSafeInteger(offset) || offset < 0 || (cursor !== undefined && String(offset) !== cursor))
168
210
  throw bad("limit 1..500 and a cursor this list returned");
169
211
  const term = search ? d.escapeLike(search) : undefined;
170
- const where = term ? `WHERE id ${d.like(true)} ${p(3)}${d.likeEscape} OR alt ${d.like(true)} ${p(3)}${d.likeEscape} OR caption ${d.like(true)} ${p(3)}${d.likeEscape}` : "";
171
- const page = await driver.read(`SELECT ${COLUMNS} FROM media_assets ${where} ORDER BY created_at DESC, id DESC LIMIT ${p(1)} OFFSET ${p(2)}`, [n + 1, offset, ...(term ? [`%${term}%`] : [])]);
212
+ const where = term ? `WHERE id = ${p(4)} OR alt ${d.like(true)} ${p(3)}${d.likeEscape} OR caption ${d.like(true)} ${p(3)}${d.likeEscape}` : "";
213
+ const page = await driver.read(`SELECT ${COLUMNS} FROM media_assets ${where} ORDER BY created_at DESC, id DESC LIMIT ${p(1)} OFFSET ${p(2)}`, [n + 1, offset, ...(term ? [`%${term}%`, search] : [])]);
172
214
  const rows = page.slice(0, n).map(assetOf);
173
215
  return { rows, ...(page.length > n ? { nextCursor: String(offset + n) } : {}) };
174
216
  },
@@ -179,12 +221,31 @@ export function mediaLibrary(driver, d, storage, policies, now = Date.now) {
179
221
  return get(id);
180
222
  const [r] = (await driver.write([{ sql: `UPDATE media_assets SET ${patch.map(([k], i) => `${k} = ${p(i + 2)}`).join(", ")} WHERE id = ${p(1)} RETURNING ${COLUMNS}`, binds: [id, ...patch.map(([, v]) => v)] }]))[0];
181
223
  if (!r)
182
- throw fail("MEDIA_ASSET_NOT_FOUND", `no media asset '${id}'`);
224
+ throw await notFound(id);
183
225
  return assetOf(r);
184
226
  },
227
+ async signRead(id, expiresIn = 300) {
228
+ if (!Number.isSafeInteger(expiresIn) || expiresIn < 1 || expiresIn > MAX_READ_SECONDS)
229
+ throw bad(`expiresIn: whole seconds, 1..${MAX_READ_SECONDS}`);
230
+ const asset = await get(id);
231
+ if (!asset.visibility)
232
+ return { id, variants: asset.variants.map(({ mimeType, role, publicUrl }) => ({ mimeType, role, url: publicUrl })) };
233
+ const storage = storeOf(true);
234
+ if (!storage.signRead)
235
+ throw fail("MEDIA_NOT_CONFIGURED", "The private media storage cannot sign reads: it has no signRead.");
236
+ const expiresAt = now() + expiresIn * 1000;
237
+ let variants;
238
+ try {
239
+ variants = await Promise.all(asset.variants.map(async ({ mimeType, role, storageKey }) => ({ mimeType, role, url: await storage.signRead({ storageKey, expiresIn }) })));
240
+ }
241
+ catch (error) {
242
+ throw own(error);
243
+ }
244
+ return { id, variants, expiresAt };
245
+ },
185
246
  async delete(id) {
186
247
  const asset = await get(id);
187
- const failed = rejected(await removeAll(asset.variants));
248
+ const failed = rejected(await removeAll(asset.variants, storeOf(asset.visibility)));
188
249
  if (failed.length)
189
250
  throw fail("PARTIAL_FAILURE", "Some objects could not be removed. Retry deleting the same asset.", { cause: new AggregateError(failed.map(r => r.reason)) });
190
251
  await driver.write([{ sql: `DELETE FROM media_assets WHERE id = ${p(1)}`, binds: [id] }]);
@@ -194,5 +255,66 @@ export function mediaLibrary(driver, d, storage, policies, now = Date.now) {
194
255
  }
195
256
  export function mediaItem(asset) {
196
257
  const primary = asset.variants.find(v => v.role === "primary") ?? asset.variants[0];
197
- return { id: asset.id, variants: asset.variants, primaryUrl: primary?.publicUrl ?? null, mime: primary?.mimeType ?? null, byteSize: primary?.byteSize ?? null, alt: asset.alt ?? null, caption: asset.caption ?? null, createdAt: asset.createdAt };
258
+ return { id: asset.id, variants: asset.variants, primaryUrl: primary?.publicUrl ?? null, mime: primary?.mimeType ?? null, byteSize: primary?.byteSize ?? null, alt: asset.alt ?? null, caption: asset.caption ?? null, createdAt: asset.createdAt, ...(asset.visibility ? { visibility: asset.visibility } : {}) };
259
+ }
260
+ export const UPLOAD_META = "net.aotter.mantle/upload";
261
+ export async function uploadMedia(library, policies, input, call) {
262
+ const { uploadId, sourceUrl, file, variants, purpose, filename, ...described } = input;
263
+ const present = (keys) => keys.filter(key => input[key] !== undefined);
264
+ if (uploadId !== undefined) {
265
+ if (present(["variants", "filename", "sourceUrl", "file"]).length)
266
+ throw bad("{ uploadId, purpose?, alt?, caption? }: filename, variants, sourceUrl and file belong to the first call");
267
+ return library.commitUpload(String(uploadId), { ...described, ...(purpose !== undefined ? { purpose } : {}) });
268
+ }
269
+ if (sourceUrl !== undefined || file !== undefined) {
270
+ if (present(["sourceUrl", "file", "variants"]).length > 1)
271
+ throw bad("one of variants, sourceUrl or file");
272
+ const policy = policies.find(p => p.name === purpose);
273
+ if (!policy)
274
+ throw fail("MEDIA_PURPOSE_REJECTED", `purpose '${String(purpose)}' is not declared`);
275
+ if (policy.required.length !== 1)
276
+ throw fail("MEDIA_VARIANTS_INCOMPLETE", `purpose '${policy.name}' takes ${policy.required.length} variants, and a fetched file is one: prepare the variants locally and send them as variants`);
277
+ const accepts = policy.required[0].split(",");
278
+ const url = sourceUrl ?? obj(file).download_url;
279
+ if (typeof url !== "string")
280
+ throw bad("sourceUrl: string, or file: { download_url, file_id }");
281
+ const fetched = await (await import("./media-fetch.js")).fetchImage(url, Math.max(...accepts.map(mime => policy.maxBytes[mime] ?? 0)));
282
+ if (!accepts.includes(fetched.mimeType))
283
+ throw fail("MEDIA_MIME_REJECTED", `the source is ${fetched.mimeType}; purpose '${policy.name}' takes ${accepts.join(" or ")}`);
284
+ const created = await library.createUpload({ ...described, purpose, filename: filename ?? fetched.filename, variants: [{ mimeType: fetched.mimeType, byteSize: fetched.bytes.byteLength, role: "primary" }] });
285
+ for (const target of created.capabilities) {
286
+ let status = 0;
287
+ try {
288
+ const response = await fetch(target.uploadUrl, { method: target.method, headers: target.requiredHeaders ?? {}, body: fetched.bytes });
289
+ status = response.status;
290
+ await response.body?.cancel();
291
+ }
292
+ catch { }
293
+ if (status < 200 || status > 299)
294
+ throw fail("MEDIA_STORAGE_FAILED", `storage did not accept the upload${status ? ` (${status})` : ""}; call upload_media again`);
295
+ }
296
+ return library.commitUpload(created.uploadGroupId);
297
+ }
298
+ if (variants !== undefined) {
299
+ const created = await library.createUpload(input);
300
+ const targets = created.capabilities.map(({ role, mimeType, method, uploadUrl, requiredHeaders }) => ({ role, mimeType, method, url: uploadUrl, headers: requiredHeaders ?? {} }));
301
+ const data = { uploadId: created.uploadGroupId, expiresAt: created.expiresAt };
302
+ return call.apps ? withResultMeta(data, { [UPLOAD_META]: { targets } }) : { ...data, targets };
303
+ }
304
+ if (present(["filename", "alt", "caption"]).length)
305
+ throw bad("variants, sourceUrl or file with filename, alt and caption");
306
+ const listed = policies.filter(p => purpose === undefined || p.name === purpose);
307
+ if (purpose !== undefined && !listed.length)
308
+ throw fail("MEDIA_PURPOSE_REJECTED", `purpose '${String(purpose)}' is not declared`);
309
+ return { purposes: listed, ...(call.apps ? { card: "The upload card is open: the person picks the file there, and the card reports the committed asset id." } : {}) };
310
+ }
311
+ export async function findMedia(library, input) {
312
+ const { ids, ...query } = input;
313
+ if (ids === undefined)
314
+ return library.list(query);
315
+ if (Object.values(query).some(value => value !== undefined) || !Array.isArray(ids) || ids.some(id => typeof id !== "string"))
316
+ throw bad("ids: string[] alone, or limit, cursor and search");
317
+ const found = await Promise.all([...new Set(ids)].map(id => library.get(id).catch(error => { if (error instanceof ContractError && (error.code === "MEDIA_ASSET_NOT_FOUND" || error.code === "MEDIA_NOT_OWNED"))
318
+ return id; throw error; })));
319
+ return { rows: found.filter(row => typeof row !== "string"), missing: found.filter(row => typeof row === "string") };
198
320
  }
@@ -22,7 +22,7 @@ export interface R2Bucket {
22
22
  /** `aws4fetch`'s `AwsClient` has this shape. */
23
23
  export interface S3Signer {
24
24
  sign(url: string, init: {
25
- method: "PUT";
25
+ method: "PUT" | "GET";
26
26
  aws: {
27
27
  signQuery: true;
28
28
  service: "s3";
@@ -36,7 +36,8 @@ export interface R2MediaStorageOptions {
36
36
  readonly signer: S3Signer;
37
37
  /** This bucket's S3 endpoint, e.g. `https://<bucket>.<account>.r2.cloudflarestorage.com`. */
38
38
  readonly endpoint: string;
39
- readonly publicBase: string;
39
+ /** The bucket's public URL, or `null` for a private bucket: its variants then get no `publicUrl`, and reads go through `signRead`. */
40
+ readonly publicBase: string | null;
40
41
  }
41
42
  export declare function r2MediaStorage({ bucket, signer, endpoint, publicBase }: R2MediaStorageOptions): MediaStorage;
42
43
  export {};
@@ -6,20 +6,23 @@ const fail = (code, message, options) => mediaError(code, message, "media/r2", o
6
6
  const ext = (mimeType) => EXT[mimeType] ?? "bin";
7
7
  const cancel = (o) => o?.body.cancel().catch(() => undefined);
8
8
  export function r2MediaStorage({ bucket, signer, endpoint, publicBase }) {
9
- if (!publicBase)
10
- throw fail("MEDIA_NOT_CONFIGURED", "R2 media storage needs publicBase, the bucket's public URL");
11
- const base = publicBase.replace(/\/+$/, "");
9
+ if (!publicBase && publicBase !== null)
10
+ throw fail("MEDIA_NOT_CONFIGURED", "R2 media storage needs publicBase, the bucket's public URL, or null for a private bucket");
11
+ const base = publicBase?.replace(/\/+$/, "");
12
12
  const s3 = endpoint.replace(/\/+$/, "");
13
+ const signed = async (method, storageKey, seconds) => {
14
+ const target = new URL(`${s3}/${storageKey}`);
15
+ target.searchParams.set("X-Amz-Expires", String(seconds));
16
+ return (await signer.sign(target.href, { method, aws: { signQuery: true, service: "s3" } })).url;
17
+ };
13
18
  return {
19
+ uploadOrigin: new URL(s3).origin,
14
20
  async createUpload({ uploadGroupId, purpose, variants, now, expiresAt }) {
15
21
  const ttl = Math.max(60, Math.floor((expiresAt - now) / 1000));
16
22
  return {
17
23
  capabilities: await Promise.all(variants.map(async (v) => {
18
24
  const storageKey = `${UPLOADS}${purpose}/${uploadGroupId}/${v.role}.${ext(v.mimeType)}`;
19
- const target = new URL(`${s3}/${storageKey}`);
20
- target.searchParams.set("X-Amz-Expires", String(ttl));
21
- const { url } = await signer.sign(target.href, { method: "PUT", aws: { signQuery: true, service: "s3" } });
22
- return { mimeType: v.mimeType, role: v.role, method: "PUT", uploadUrl: url, storageKey, requiredHeaders: { "Content-Type": v.mimeType } };
25
+ return { mimeType: v.mimeType, role: v.role, method: "PUT", uploadUrl: await signed("PUT", storageKey, ttl), storageKey, requiredHeaders: { "Content-Type": v.mimeType } };
23
26
  })),
24
27
  };
25
28
  },
@@ -45,7 +48,7 @@ export function r2MediaStorage({ bucket, signer, endpoint, publicBase }) {
45
48
  await cancel(o);
46
49
  throw fail("MEDIA_OBJECT_NOT_FOUND", `variant ${spec.role} of '${uploadGroupId}' changed during commit`);
47
50
  }
48
- out.push({ mimeType: spec.mimeType, publicUrl: `${base}/${key}`, storageKey: key, byteSize: o.size, role: spec.role });
51
+ out.push({ mimeType: spec.mimeType, ...(base === undefined ? {} : { publicUrl: `${base}/${key}` }), storageKey: key, byteSize: o.size, role: spec.role });
49
52
  try {
50
53
  await bucket.put(key, o.body, {
51
54
  httpMetadata: { contentType: spec.mimeType },
@@ -67,5 +70,6 @@ export function r2MediaStorage({ bucket, signer, endpoint, publicBase }) {
67
70
  return { id: uploadGroupId, variants: out, createdAt: now, metadata: { filename }, ...(alt !== undefined ? { alt } : {}), ...(caption !== undefined ? { caption } : {}) };
68
71
  },
69
72
  deleteObject: ({ storageKey }) => bucket.delete(storageKey),
73
+ signRead: ({ storageKey, expiresIn }) => signed("GET", storageKey, expiresIn),
70
74
  };
71
75
  }
@@ -0,0 +1,10 @@
1
+ /** What `withResultMeta` returns: `data` is the tool result as usual, `_meta` goes only to the `tools/call` result `_meta`. */
2
+ export interface ResultWithMeta<T> {
3
+ readonly data: T;
4
+ readonly _meta: Readonly<Record<string, unknown>>;
5
+ }
6
+ /**
7
+ * Returned from a tool's `run`: MCP sends `data` exactly as an unwrapped return, and `meta` only in the result `_meta`
8
+ * (beside `net.aotter.mantle/tool`, which it cannot replace). `callTool` returns the wrapper itself; drop `_meta` before forwarding to a model.
9
+ */
10
+ export declare const withResultMeta: <T>(data: T, meta: Readonly<Record<string, unknown>>) => ResultWithMeta<T>;
@@ -0,0 +1 @@
1
+ export const withResultMeta = (data, meta) => ({ [Symbol.for("net.aotter.mantle/result")]: true, data, _meta: meta });
@@ -9,6 +9,13 @@ export interface MediaPurposePolicy {
9
9
  readonly name: string;
10
10
  readonly required: readonly string[];
11
11
  readonly maxBytes: Readonly<Record<string, number>>;
12
+ /**
13
+ * `"public"` (the default when omitted) stores this purpose's assets in the public storage, each variant with a
14
+ * permanent `publicUrl`. `"private"` stores them in the `private` storage of the `media` option and gives them no
15
+ * URL on any surface; read one through `MediaLibrary.signRead` or `MediaStorage.signRead`. A purpose's visibility
16
+ * does not change: moving it means moving its objects.
17
+ */
18
+ readonly visibility?: "public" | "private";
12
19
  }
13
20
  export interface SiteDefaults {
14
21
  readonly brand?: string;
@@ -42,13 +49,17 @@ export interface MantleSite {
42
49
  /** Stored operator values over generated defaults; deployment fields are code constants. */
43
50
  read(): Promise<SiteConfig>;
44
51
  updateSettings(values: SiteSettings): Promise<SiteConfig>;
45
- /** The media library over this site's tables, its objects in `storage`. */
46
- media(storage: MediaStorage): MediaLibrary;
52
+ /**
53
+ * The media library over this site's tables, its objects in `storage`. A private purpose with no private storage is
54
+ * `MEDIA_NOT_CONFIGURED` here.
55
+ */
56
+ media(storage: MediaStorageOption): MediaLibrary;
47
57
  }
48
58
  export type MediaVariantRole = "primary" | "alternate" | "fallback";
49
59
  export interface MediaVariant {
50
60
  readonly mimeType: string;
51
- readonly publicUrl: string;
61
+ /** Absent on a private asset. */
62
+ readonly publicUrl?: string;
52
63
  readonly storageKey: string;
53
64
  readonly byteSize: number;
54
65
  readonly role: MediaVariantRole;
@@ -61,9 +72,21 @@ export interface MediaAsset {
61
72
  readonly caption?: string;
62
73
  readonly createdAt: number;
63
74
  readonly metadata?: Readonly<Record<string, string>>;
75
+ /** `"private"` when the asset's purpose is private: its variants have no `publicUrl`. Absent means public. */
76
+ readonly visibility?: "private";
64
77
  }
65
- /** Object storage for media, public bucket only. The bytes go to `uploadUrl` directly, never through the Worker. */
78
+ /**
79
+ * The `media` option: one `MediaStorage`, which holds every purpose's objects and serves them publicly, or a public
80
+ * and a private storage. Private purposes go to `private`.
81
+ */
82
+ export type MediaStorageOption = MediaStorage | {
83
+ readonly public: MediaStorage;
84
+ readonly private: MediaStorage;
85
+ };
86
+ /** Object storage for media. The bytes go to `uploadUrl` directly, never through the Worker. */
66
87
  export interface MediaStorage {
88
+ /** The origin every `uploadUrl` PUTs to, such as the bucket's S3 endpoint: what an MCP App that uploads declares in its CSP `connectDomains`. */
89
+ readonly uploadOrigin?: string;
67
90
  /** One short-lived PUT capability per variant, keyed under `uploadGroupId`. */
68
91
  createUpload(args: {
69
92
  readonly uploadGroupId: string;
@@ -109,6 +132,17 @@ export interface MediaStorage {
109
132
  deleteObject(args: {
110
133
  readonly storageKey: string;
111
134
  }): Promise<void>;
135
+ /**
136
+ * A short-lived read URL for one committed object, valid for `expiresIn` seconds. Optional; a private storage
137
+ * implements it so its assets can be read at all. Mantle calls it only from `MediaLibrary.signRead`: no list, get or
138
+ * commit signs anything. An application that lets other callers read a private asset calls it itself, after its own
139
+ * check (for example in a `ref` handler), with a `storageKey` from the asset's variants. A signed URL is a bearer
140
+ * capability: an MCP tool returns it only in the result `_meta` (`withResultMeta`), never in model-visible content.
141
+ */
142
+ signRead?(args: {
143
+ readonly storageKey: string;
144
+ readonly expiresIn: number;
145
+ }): Promise<string>;
112
146
  }
113
147
  /** The media use cases. Each checks its own input, so any surface can hand it a request body. */
114
148
  export interface MediaLibrary {
@@ -124,9 +158,9 @@ export interface MediaLibrary {
124
158
  }[];
125
159
  readonly expiresAt: number;
126
160
  }>;
127
- /** An unknown or expired group is `MEDIA_UPLOAD_EXPIRED`. */
161
+ /** An unknown or expired group is `MEDIA_UPLOAD_EXPIRED`; one another server created is `MEDIA_NOT_OWNED`, as is its asset id in the calls below. */
128
162
  commitUpload(uploadGroupId: string, request?: unknown): Promise<MediaAsset>;
129
- /** Newest first; `search` matches id, alt and caption. */
163
+ /** Newest first; `search` matches the exact id, or text in alt or caption. */
130
164
  list(query: {
131
165
  readonly limit?: number;
132
166
  readonly cursor?: string;
@@ -138,6 +172,20 @@ export interface MediaLibrary {
138
172
  get(id: string): Promise<MediaAsset>;
139
173
  /** `{ alt?, caption? }`: an omitted field is unchanged, an empty string clears it. */
140
174
  update(id: string, request: unknown): Promise<MediaAsset>;
175
+ /**
176
+ * Read URLs for each variant: a public asset's `publicUrl`, or a private asset's `MediaStorage.signRead` URL valid
177
+ * for `expiresIn` seconds (default 300), with `expiresAt` in milliseconds. A private storage without `signRead` is
178
+ * `MEDIA_NOT_CONFIGURED`.
179
+ */
180
+ signRead(id: string, expiresIn?: number): Promise<{
181
+ readonly id: string;
182
+ readonly variants: readonly {
183
+ readonly mimeType: string;
184
+ readonly role: MediaVariantRole;
185
+ readonly url: string;
186
+ }[];
187
+ readonly expiresAt?: number;
188
+ }>;
141
189
  /** The objects first, then the row, so a partial failure can be retried with the same id. */
142
190
  delete(id: string): Promise<{
143
191
  readonly deleted: true;
@@ -3,7 +3,7 @@ import { type Caller } from "./contracts.js";
3
3
  import { type MediaDatabase } from "./media.js";
4
4
  import type { Dialect } from "./dialect/common.js";
5
5
  import type { MantleSite, SiteDefaults, SiteConfig, MediaPurposePolicy } from "./site-types.js";
6
- export type { MantleSite, SiteDefaults, SiteConfig, MediaStorage, MediaAsset, MediaLibrary, MediaPurposePolicy } from "./site-types.js";
6
+ export type { MantleSite, SiteDefaults, SiteConfig, MediaStorage, MediaStorageOption, MediaAsset, MediaLibrary, MediaPurposePolicy } from "./site-types.js";
7
7
  export declare function createSite(db: MediaDatabase, d: Pick<Dialect, "param" | "escapeLike" | "likeEscape" | "like">, caller: Caller, defaults: SiteDefaults, policies: readonly MediaPurposePolicy[]): MantleSite;
8
8
  /** 0.2 /admin/api/site projection; the app supplies its selected capabilities. */
9
9
  export declare function siteInfo(config: SiteConfig, options: {
@@ -15,7 +15,7 @@ import { refuseDeclarations } from "./refusals.js";
15
15
  import { emitHandlers } from "./handlers.js";
16
16
  import { emitAdminDescriptors } from "./adminDescriptors.js";
17
17
  import { emitAdminClient } from "./adminClient.js";
18
- import { emitSite } from "./site.js";
18
+ import { emitSite, mediaPolicies } from "./site.js";
19
19
  import { emitTranslations } from "./translations.js";
20
20
  import { emitAdminBindings } from "./adminBindings.js";
21
21
  import { emitRest } from "./rest.js";
@@ -144,7 +144,7 @@ export function generate(input) {
144
144
  emitTtl(plan, isSqlite, declarations, procedures, entries, refuse);
145
145
  emitPublishing(plan, isSqlite, declarations, procedures, entries, values, refuse, input.locks);
146
146
  const stores = emitStores(plan.schemas, isSqlite, newId, refuse, hasHandlers, input.locks);
147
- const surfaces = { ...emitPaging(plan, lowered, target, refuse, paging), ...emitPaging(plan, input.wireLowered ?? lowered, target, refuse, paging, true), ...emitValidation(plan, refuse), ...emitRest(plan, lowered, target, input.description), ...emitWeb(plan, lowered, target), ...emitMcp(plan, lowered, target, input.description, refuse, !!site), ...emitAdminDescriptors({ plan, admin }, diagnostics), ...emitAdminBindings(input, refuse), ...(admin ? emitAdminClient(plan, typeOf) : {}), ...emitSite(target, site, refuse, Object.keys(plan.schemas)), ...emitTranslations(plan, target) };
147
+ const surfaces = { ...emitPaging(plan, lowered, target, refuse, paging), ...emitPaging(plan, input.wireLowered ?? lowered, target, refuse, paging, true), ...emitValidation(plan, refuse), ...emitRest(plan, lowered, target, input.description), ...emitWeb(plan, lowered, target), ...emitMcp(plan, lowered, target, input.description, refuse, site ? mediaPolicies(site) : undefined), ...emitAdminDescriptors({ plan, admin }, diagnostics), ...emitAdminBindings(input, refuse), ...(admin ? emitAdminClient(plan, typeOf) : {}), ...emitSite(target, site, refuse, Object.keys(plan.schemas)), ...emitTranslations(plan, target) };
148
148
  if (diagnostics.length)
149
149
  return { ok: false, diagnostics };
150
150
  const storeProcedures = hasHandlers ? "storeProcedures" : "procedures";
@@ -1,15 +1,27 @@
1
1
  import { emitValidation } from "./validation.js";
2
2
  import { mediaDefinitions } from "../domain/generated-tools.js";
3
3
  export { mediaDefinitions };
4
+ const library = "site(db,caller).media(storage())";
5
+ const invoke = {
6
+ upload: `(db,caller,input:Record<string,unknown>,_handlers:unknown,call:ToolCall) => uploadMedia(${library},policies,input,call)`,
7
+ find: `(db,caller,input:Record<string,unknown>) => findMedia(${library},input)`,
8
+ policy: 'async (db,caller) => ({purposes:(await site(db,caller).read()).media.purposes})',
9
+ create: `(db,caller,input:Record<string,unknown>) => ${library}.createUpload(input)`,
10
+ commit: `(db,caller,input:Record<string,unknown>) => ${library}.commitUpload(input.uploadGroupId as string,input)`,
11
+ list: `(db,caller,input:Record<string,unknown>) => ${library}.list(input)`,
12
+ get: `(db,caller,input:Record<string,unknown>) => ${library}.get(input.id as string)`,
13
+ update: `(db,caller,input:Record<string,unknown>) => ${library}.update(input.id as string,input)`,
14
+ delete: `(db,caller,input:Record<string,unknown>) => ${library}.delete(input.id as string)`,
15
+ };
4
16
  export function emitMcpMedia(definitions, refuse) {
5
17
  const inputs = Object.fromEntries(definitions.map(tool => [tool.name, { input: tool.inputSchema }]));
6
18
  const checks = Object.fromEntries(Object.entries(emitValidation({ views: {}, procedures: inputs }, refuse)).map(([path, code]) => [path.replace("validation/procedures/", "validation/media/"), code]));
7
- const invoke = { policy: 'async (db,caller) => ({purposes:(await site(db,caller).read()).media.purposes})', create: '(db,caller,input:Record<string,unknown>) => site(db,caller).media(storage()).createUpload(input)', commit: '(db,caller,input:Record<string,unknown>) => site(db,caller).media(storage()).commitUpload(input.uploadGroupId as string,input)', list: '(db,caller,input:Record<string,unknown>) => site(db,caller).media(storage()).list(input)', get: '(db,caller,input:Record<string,unknown>) => site(db,caller).media(storage()).get(input.id as string)', update: '(db,caller,input:Record<string,unknown>) => site(db,caller).media(storage()).update(input.id as string,input)', delete: '(db,caller,input:Record<string,unknown>) => site(db,caller).media(storage()).delete(input.id as string)' };
8
- return { ...checks, "mcp-media.ts": `import { site } from "./site.js";
9
- import type { MediaStorage } from "@aotter/mantle/site";
10
- import type { Tool } from "@aotter/mantle/mcp";
19
+ return { ...checks, "mcp-media.ts": `import { policies, site } from "./site.js";
20
+ import type { MediaStorageOption } from "@aotter/mantle/site";
21
+ import { findMedia, uploadMedia } from "@aotter/mantle/media";
22
+ import type { Tool, ToolCall } from "@aotter/mantle/mcp";
11
23
  ${definitions.map((tool, i) => `import { validate as v${i} } from "./validation/media/${tool.name}.js";`).join("\n")}
12
- export function mediaTools<Handlers = never>(storage: () => MediaStorage): Tool<Parameters<typeof site>[0], Handlers>[] { return [
24
+ export function mediaTools<Handlers = never>(storage: () => MediaStorageOption): Tool<Parameters<typeof site>[0], Handlers>[] { return [
13
25
  ${definitions.map(({ action, ...tool }, i) => `{...${JSON.stringify({ ...tool, ...(tool.annotations.readOnlyHint ? { readOnly: true } : {}) })},surface:"admin",closed:true,validate:v${i},run:${invoke[action]}}`).join(",\n")}
14
26
  ]; }
15
27
  ` };
@@ -6,13 +6,14 @@ import { authLines, conversions } from "../domain/input.js";
6
6
  import { publishingAnnotations, publishingInput } from "./publishing.js";
7
7
  import { versioned } from "./tool-version.js";
8
8
  import { boundaryPage, outputConversions } from "../domain/columns.js";
9
- export function emitMcp(plan, lowered, target, description, refuse, media = false) {
9
+ export function emitMcp(plan, lowered, target, description, refuse, policies) {
10
+ const media = !!policies;
10
11
  if (!Object.values(plan.views).some(view => view.surface !== "internal") && !Object.values(plan.triggers).some(trigger => trigger.source.kind === "mcp"))
11
12
  return {};
12
13
  const reads = [];
13
14
  const imports = plan.access ? ['import { userBinds } from "@aotter/mantle/user-fields";'] : [], tools = [];
14
- const mediaTools = media ? mediaDefinitions().map(({ action, ...tool }) => ({ action, ...versioned({ ...tool, title: toolTitle(tool.name) }) })) : [];
15
- const files = media ? emitMcpMedia(mediaTools, refuse) : {};
15
+ const mediaTools = policies ? mediaDefinitions(policies).map(({ action, ...tool }) => { const { meta, ...listed } = tool; return { action, ...versioned({ ...listed, title: toolTitle(tool.name) }), ...(meta ? { meta } : {}) }; }) : [];
16
+ const files = policies ? emitMcpMedia(mediaTools, refuse) : {};
16
17
  let hasRef = false, hasWire = false;
17
18
  const names = new Set(mediaTools.map(tool => "admin:" + tool.name));
18
19
  const guard = (requires, path, binds = [], trustedOnly = false) => {
@@ -81,10 +82,10 @@ export function emitMcp(plan, lowered, target, description, refuse, media = fals
81
82
  if (count > 1 && schema)
82
83
  shared.set(schema, `schema${shared.size}`);
83
84
  return { ...files, "mcp.ts": `${[`import { mcp as serve, type Options as McpOptions, type Tool } from "@aotter/mantle/mcp";`, databaseImport(target),
84
- ...(media ? ['import type { MediaStorage } from "@aotter/mantle/site";'] : []),
85
+ ...(media ? ['import type { MediaStorageOption } from "@aotter/mantle/site";'] : []),
85
86
  'import type { Handlers } from "./generated.js";', ...(hasRef ? ['import { store } from "./generated.js";'] : []), ...imports,
86
87
  ...[...shared].map(([schema, name]) => `const ${name} = ${schema};`)].join("\n")}
87
- export type Options<Env = unknown> = McpOptions<Env, Database> & { handlers${refs ? "" : "?"}: Handlers }${media ? " & { media?: (env: Env) => MediaStorage }" : ""};
88
+ export type Options<Env = unknown> = McpOptions<Env, Database> & { handlers${refs ? "" : "?"}: Handlers }${media ? " & { media?: (env: Env) => MediaStorageOption }" : ""};
88
89
  /** The generated tools, exported so an app can compose them with its own tools and MCP Apps. */
89
90
  export const tools: readonly Tool<Database, Handlers>[] = [
90
91
  ${tools.map(({ fields, schema }) => ` { ${fields.map(field => field === "inputSchema" ? `inputSchema: ${shared.get(schema) ?? schema}` : field).join(", ")} },`).join("\n")}
@@ -1,5 +1,6 @@
1
1
  import { assertSiteDefaultsCanonical, canonicalizeLocaleList, expandPolicyRequired } from "../build/compiler.js";
2
2
  import { driverFor, isSqlite, q } from "../domain/entry.js";
3
+ export const mediaPolicies = (defaults) => defaults?.media?.purposes?.map(p => ({ ...p, required: expandPolicyRequired(p.required).map(slot => slot.join(",")) })) ?? [];
3
4
  export function emitSite(target, defaults, refuse, schemaNames) {
4
5
  if (!defaults)
5
6
  return {};
@@ -17,7 +18,7 @@ export function emitSite(target, defaults, refuse, schemaNames) {
17
18
  refuse("site/media/purposes", "Media purpose names must be unique.");
18
19
  defaults = { ...defaults, ...(defaults.locales ? { locales: canonicalizeLocaleList(defaults.locales).valid } : {}) };
19
20
  const pg = !isSqlite(target), driver = driverFor(target);
20
- const policies = defaults.media?.purposes?.map(p => ({ ...p, required: expandPolicyRequired(p.required).map(slot => slot.join(",")) })) ?? [];
21
+ const policies = mediaPolicies(defaults);
21
22
  const seed = ["brand", "title", "description"].filter(key => defaults[key]).map(key => `INSERT INTO site_config (key,value) VALUES ('${key}','${defaults[key].replaceAll("'", "''")}') ON CONFLICT(key) DO NOTHING;`).join("\n");
22
23
  const root = "@aotter/mantle";
23
24
  return { "site.ts": `import { createSite } from "${root}/site";
@@ -26,7 +27,8 @@ import type { ${pg ? "Postgres" : "Handle"} as Database } from "${root}/${pg ? "
26
27
  import * as DRIVER from "${root}/driver/${driver}";
27
28
  import { param, escapeLike, likeEscape, like } from "${root}/dialect/${pg ? "postgres" : "sqlite"}";
28
29
  export const siteDefaults = ${q(defaults)} as const;
29
- const policies = ${q(policies)} as const;
30
+ /** The site's media purposes, expanded; the library and upload_media check against them. */
31
+ export const policies = ${q(policies)} as const;
30
32
  export function site(db: Database, caller: Caller) { return createSite({read:(sql,values)=>DRIVER.read(db,sql,values),write:steps=>DRIVER.procedure(db,steps,"site"),definitive:DRIVER.definitive},{param,escapeLike,likeEscape,like},caller,siteDefaults,policies); }
31
33
  `, "site-seed.sql": seed + "\n", "site-schema.sql": `CREATE TABLE site_config (key TEXT PRIMARY KEY, value TEXT NOT NULL);
32
34
  CREATE TABLE media_assets (id TEXT PRIMARY KEY, created_at ${pg ? "BIGINT" : "INTEGER"} NOT NULL, owner_id TEXT, alt TEXT, caption TEXT, variants TEXT NOT NULL, metadata TEXT);