@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.
- package/dist/auth/routes.d.ts +3 -1
- package/dist/auth/routes.js +15 -2
- package/dist/build/compiler.d.ts +1 -0
- package/dist/build/compiler.js +1 -1
- package/dist/build/spec/kernel/diagnostic.d.ts +1 -1
- package/dist/bun/assets.d.ts +3 -1
- package/dist/bun/assets.js +4 -1
- package/dist/domain/describe/chunk-GMUORMOX.js +1 -0
- package/dist/domain/describe/index.js +1 -1
- package/dist/domain/describe/public.js +1 -1
- package/dist/domain/generated-tools.js +24 -9
- package/dist/infrastructure/bundle.js +30 -1
- package/dist/infrastructure/inputs.js +3 -2
- package/dist/infrastructure/wrangler.js +4 -3
- package/dist/kernel/authorization.d.ts +1 -0
- package/dist/kernel/authorization.js +12 -5
- package/dist/kernel/mcp-call.d.ts +3 -3
- package/dist/kernel/mcp-call.js +4 -4
- package/dist/kernel/mcp-handler.js +4 -3
- package/dist/kernel/mcp.d.ts +12 -10
- package/dist/kernel/mcp.js +1 -1
- package/dist/kernel/media-fetch.js +109 -0
- package/dist/kernel/media.d.ts +15 -2
- package/dist/kernel/media.js +138 -16
- package/dist/kernel/r2-media.d.ts +3 -2
- package/dist/kernel/r2-media.js +12 -8
- package/dist/kernel/result-meta.d.ts +10 -0
- package/dist/kernel/result-meta.js +1 -0
- package/dist/kernel/site-types.d.ts +54 -6
- package/dist/kernel/site.d.ts +1 -1
- package/dist/usecase/generate.js +2 -2
- package/dist/usecase/mcp-media.js +17 -5
- package/dist/usecase/mcp.js +6 -5
- package/dist/usecase/site.js +4 -2
- package/docs/examples/README.md +5 -1
- package/docs/examples/app-shell/README.md +5 -1
- package/docs/examples/app-shell/package.json +9 -9
- package/docs/examples/app-shell/src/jwks.ts +22 -0
- package/docs/examples/app-shell/src/service.ts +12 -1
- package/docs/examples/app-shell/wrangler.jsonc +1 -1
- package/docs/examples/club-events/README.md +10 -3
- package/docs/examples/club-events/manifests/club-events.yaml +42 -11
- package/docs/examples/club-events/smoke.mjs +31 -1
- package/docs/examples/club-events/src/handlers.ts +9 -8
- package/docs/examples/frontend-mcp-app/README.md +23 -0
- package/docs/examples/frontend-mcp-app/app.tsx +50 -0
- package/docs/examples/frontend-mcp-app/manifest.yaml +30 -0
- package/docs/examples/purchasing/README.md +9 -0
- package/docs/examples/purchasing/manifests/purchasing.yaml +18 -16
- package/docs/examples/purchasing/smoke.mjs +5 -0
- package/docs/examples/purchasing/wrangler.jsonc +1 -1
- package/docs/examples/recipes/wrangler.jsonc +1 -1
- package/docs/handbook/cloudflare/authentication.md +51 -0
- package/docs/handbook/cloudflare/deploy-and-operate.md +5 -0
- package/docs/handbook/cloudflare/media-r2.md +5 -2
- package/docs/handbook/cloudflare/service-entry.md +28 -7
- package/docs/handbook/examples/hub.md +1 -1
- package/docs/handbook/guides/mcp-apps.md +94 -18
- package/docs/handbook/guides/native-admin.md +29 -0
- package/docs/handbook/guides/performance.md +5 -1
- package/docs/handbook/guides/revisions.md +1 -1
- package/docs/handbook/guides/sql-patterns.md +33 -0
- package/docs/handbook/reference/authorization.md +1 -1
- package/docs/handbook/reference/describe.md +5 -0
- package/docs/handbook/reference/schema.md +19 -0
- package/docs/handbook/reference/site-config.md +2 -0
- package/docs/handbook/reference/surface.md +66 -10
- package/docs/handbook/releases/index.md +24 -1
- package/package.json +10 -10
- package/dist/domain/describe/chunk-CW7X5Q5T.js +0 -1
package/dist/kernel/media.d.ts
CHANGED
|
@@ -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,
|
|
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">,
|
|
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>;
|
package/dist/kernel/media.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 {};
|
package/dist/kernel/r2-media.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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}
|
|
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
|
-
/**
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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;
|
package/dist/kernel/site.d.ts
CHANGED
|
@@ -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: {
|
package/dist/usecase/generate.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
import
|
|
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: () =>
|
|
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
|
` };
|
package/dist/usecase/mcp.js
CHANGED
|
@@ -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,
|
|
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 =
|
|
15
|
-
const files =
|
|
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 {
|
|
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) =>
|
|
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")}
|
package/dist/usecase/site.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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);
|