@aotter/mantle 0.2.0-alpha.9 → 0.2.1-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/README.md +15 -5
- package/dist/auth/adapter.js +1 -28
- package/dist/auth/admin.js +2 -5
- package/dist/auth/caller.js +0 -27
- package/dist/auth/consent.js +100 -0
- package/dist/auth/hardening.js +0 -7
- package/dist/auth/identity.js +0 -11
- package/dist/auth/oauth.js +0 -32
- package/dist/auth/resource-seeds.js +0 -2
- package/dist/auth/route-http.js +33 -0
- package/dist/auth/routes.js +5 -151
- package/dist/build/compiler.d.ts +19 -609
- package/dist/build/compiler.js +6 -6896
- package/dist/build/spec/domain/model/ManifestGrammar.d.ts +39 -1
- package/dist/build/spec/domain/model/RuntimePlan.d.ts +16 -3
- package/dist/build/spec/domain/model/SqlIr.d.ts +32 -0
- package/dist/build/spec/domain/model/UserFields.d.ts +1 -0
- package/dist/build/spec/kernel/diagnostic.d.ts +1 -1
- package/dist/bun/assets.js +0 -2
- package/dist/domain/adminOrder.js +0 -2
- package/dist/domain/columns.js +7 -20
- package/dist/domain/describe/chunk-P6PP6VVV.js +1 -0
- package/dist/domain/describe/index.d.ts +1 -0
- package/dist/domain/describe/index.js +1 -115
- package/dist/domain/describe/match.d.ts +19 -0
- package/dist/domain/describe/target-hash.js +1 -12
- package/dist/domain/entry.js +0 -14
- package/dist/domain/generated-tools.js +0 -1
- package/dist/domain/input.js +0 -2
- package/dist/domain/paging.js +0 -17
- package/dist/domain/recipes.js +10 -7
- package/dist/domain/userReferences.js +0 -1
- package/dist/domain/wireScalars.js +0 -6
- package/dist/infrastructure/adminEntries.js +27 -8
- package/dist/infrastructure/adminPaging.js +55 -57
- package/dist/infrastructure/artifacts.js +0 -4
- package/dist/infrastructure/authSchema.js +0 -9
- package/dist/infrastructure/bundle.js +45 -38
- package/dist/infrastructure/cli.js +26 -12
- package/dist/infrastructure/generate.js +26 -19
- package/dist/infrastructure/generateAuth.js +1 -9
- package/dist/infrastructure/help.js +55 -0
- package/dist/infrastructure/init.js +50 -0
- package/dist/infrastructure/inputs.js +0 -9
- package/dist/infrastructure/mounts.js +22 -0
- package/dist/infrastructure/wrangler.js +0 -6
- package/dist/kernel/authorization.js +0 -1
- package/dist/kernel/binds.d.ts +34 -7
- package/dist/kernel/binds.js +5 -11
- package/dist/kernel/caller-identity.d.ts +0 -3
- package/dist/kernel/caller-identity.js +0 -15
- package/dist/kernel/caller-mode.d.ts +4 -0
- package/dist/kernel/caller-mode.js +14 -0
- package/dist/kernel/contracts.d.ts +13 -10
- package/dist/kernel/contracts.js +14 -41
- package/dist/kernel/cron.js +0 -4
- package/dist/kernel/dialect/common.js +0 -3
- package/dist/kernel/dialect/postgres.js +0 -2
- package/dist/kernel/dialect/sqlite.js +1 -6
- package/dist/kernel/drain.js +2 -8
- package/dist/kernel/driver/bun-sqlite.js +3 -7
- package/dist/kernel/driver/d1.d.ts +1 -1
- package/dist/kernel/driver/d1.js +6 -4
- package/dist/kernel/driver/postgres.d.ts +2 -1
- package/dist/kernel/driver/postgres.js +12 -22
- package/dist/kernel/driver/transaction-context.js +0 -1
- package/dist/kernel/email.js +0 -4
- package/dist/kernel/host-access.js +2 -3
- package/dist/kernel/host-caller.d.ts +1 -1
- package/dist/kernel/host-caller.js +3 -6
- package/dist/kernel/json-body.js +0 -2
- package/dist/kernel/mcp-apps.js +0 -5
- package/dist/kernel/mcp-call.js +0 -1
- package/dist/kernel/mcp-handler.js +4 -27
- package/dist/kernel/mcp.js +0 -5
- package/dist/kernel/media.js +0 -18
- package/dist/kernel/paging.js +1 -9
- package/dist/kernel/publishing.js +3 -4
- package/dist/kernel/r2-media.js +0 -7
- package/dist/kernel/rest-dispatch.js +0 -1
- package/dist/kernel/rest-router.js +5 -8
- package/dist/kernel/rest.d.ts +2 -1
- package/dist/kernel/rest.js +1 -3
- package/dist/kernel/route-pattern.d.ts +26 -0
- package/dist/kernel/route-pattern.js +34 -0
- package/dist/kernel/scalars.js +1 -9
- package/dist/kernel/search.js +0 -1
- package/dist/kernel/site.js +0 -2
- package/dist/kernel/store.d.ts +26 -2
- package/dist/kernel/store.js +69 -49
- package/dist/kernel/surface-failure.d.ts +10 -0
- package/dist/kernel/surface-failure.js +29 -0
- package/dist/kernel/surface.js +0 -3
- package/dist/kernel/sweep.js +0 -1
- package/dist/kernel/translations.js +0 -1
- package/dist/kernel/user-caller.js +2 -8
- package/dist/kernel/user-fields.d.ts +3 -2
- package/dist/kernel/user-fields.js +9 -9
- package/dist/kernel/validation.js +0 -8
- package/dist/kernel/web-gateway.js +0 -2
- package/dist/kernel/web-head.js +0 -1
- package/dist/kernel/web-index.js +0 -1
- package/dist/kernel/web-response.js +0 -1
- package/dist/kernel/web-serve.js +97 -0
- package/dist/kernel/web.d.ts +1 -1
- package/dist/kernel/web.js +10 -91
- package/dist/kernel/where.js +0 -6
- package/dist/usecase/adminBindings.js +32 -23
- package/dist/usecase/adminClient.js +4 -22
- package/dist/usecase/adminDescriptors.js +2 -9
- package/dist/usecase/generate.js +14 -17
- package/dist/usecase/handlers.js +1 -4
- package/dist/usecase/mcp-media.js +0 -1
- package/dist/usecase/mcp.js +0 -4
- package/dist/usecase/paging.js +1 -3
- package/dist/usecase/publishing.js +20 -11
- package/dist/usecase/rest.js +5 -6
- package/dist/usecase/site.js +0 -1
- package/dist/usecase/stores.js +12 -17
- package/dist/usecase/tool-version.js +0 -3
- package/dist/usecase/translations.js +0 -2
- package/dist/usecase/validation.js +0 -20
- package/docs/examples/README.md +13 -5
- package/docs/examples/app-shell/README.md +91 -12
- package/docs/examples/app-shell/accept.mjs +91 -0
- package/docs/examples/app-shell/gitignore +7 -0
- package/docs/examples/app-shell/package.json +10 -9
- package/docs/examples/app-shell/smoke-kit.mjs +29 -15
- package/docs/examples/app-shell/src/routes.ts +6 -3
- package/docs/examples/app-shell/src/service.ts +2 -1
- package/docs/examples/club-events/README.md +7 -2
- package/docs/examples/club-events/manifests/club-events.yaml +17 -0
- package/docs/examples/club-events/smoke.mjs +7 -1
- package/docs/examples/generated-web/seed.sql +5 -4
- package/docs/examples/purchasing/README.md +12 -3
- package/docs/examples/purchasing/manifests/purchasing.yaml +24 -4
- package/docs/examples/purchasing/smoke.mjs +14 -0
- package/docs/examples/recipes/README.md +5 -3
- package/docs/handbook/cloudflare/media-r2.md +6 -3
- package/docs/handbook/cloudflare/service-entry.md +5 -0
- package/docs/handbook/concepts/lifecycle-and-locales.md +21 -1
- package/docs/handbook/examples/hub.md +1 -1
- package/docs/handbook/guides/admin-ui.md +1 -1
- package/docs/handbook/guides/agent-setup.md +20 -10
- package/docs/handbook/guides/agent-workflow-acceptance.md +225 -105
- package/docs/handbook/guides/local-testing.md +85 -0
- package/docs/handbook/guides/native-admin.md +242 -16
- package/docs/handbook/guides/native-execution.md +10 -8
- package/docs/handbook/guides/public-pages.md +44 -4
- package/docs/handbook/guides/revisions.md +584 -0
- package/docs/handbook/guides/size-and-startup.md +140 -0
- package/docs/handbook/guides/sql-patterns.md +312 -0
- package/docs/handbook/guides/typed-queries.md +146 -3
- package/docs/handbook/navigation.json +20 -0
- package/docs/handbook/reference/admin-descriptors.md +4 -1
- package/docs/handbook/reference/authorization.md +3 -1
- package/docs/handbook/reference/describe.md +42 -0
- package/docs/handbook/reference/diagnostics.md +16 -2
- package/docs/handbook/reference/procedure.md +6 -6
- package/docs/handbook/reference/schema.md +79 -9
- package/docs/handbook/reference/sql.md +65 -0
- package/docs/handbook/reference/surface.md +8 -4
- package/docs/handbook/reference/trigger.md +12 -0
- package/docs/handbook/reference/view.md +11 -3
- package/docs/handbook/releases/index.md +71 -7
- package/docs/handbook/start/agents.md +49 -0
- package/docs/handbook/start/project-and-cli.md +10 -0
- package/docs/handbook/start/quickstart-worker.md +1 -1
- package/docs/skills/develop/SKILL.md +19 -6
- package/docs/skills/integrate/SKILL.md +3 -1
- package/docs/skills/performance/SKILL.md +169 -0
- package/docs/skills/update/SKILL.md +10 -4
- package/docs/upgrade-0.1-to-0.2.md +151 -39
- package/package.json +31 -11
- package/skills/README.md +6 -2
- package/skills/mantle/SKILL.md +53 -120
- package/dist/domain/describe/chunk-ZVXBGP7Y.js +0 -424
- package/skills/mantle/references/local-testing.md +0 -20
- package/skills/mantle/references/web.md +0 -41
- package/skills/mantle/references/website.md +0 -51
package/dist/kernel/email.js
CHANGED
|
@@ -24,10 +24,6 @@ export function adminInvitationEmail(role, signInUrl) {
|
|
|
24
24
|
signInUrl,
|
|
25
25
|
]);
|
|
26
26
|
}
|
|
27
|
-
/**
|
|
28
|
-
* Local development only: logs recipients and sign-in secrets, sends nothing.
|
|
29
|
-
* Select at the application's environment boundary; never use in production.
|
|
30
|
-
*/
|
|
31
27
|
export class ConsoleEmailSender {
|
|
32
28
|
async send(args) {
|
|
33
29
|
const { to, subject, text, locale, category } = args;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
/** Request-only gateway decisions and observation; no database or compiler dependencies. */
|
|
2
1
|
import { forwarded, ContractError, wire } from "./contracts.js";
|
|
3
|
-
import { isHostCaller
|
|
2
|
+
import { isHostCaller } from "./caller-identity.js";
|
|
3
|
+
import { checkFresh } from "./caller-mode.js";
|
|
4
4
|
const logged = new WeakSet();
|
|
5
5
|
export function accessRequest(ctx, options) {
|
|
6
6
|
const present = forwarded(ctx);
|
|
@@ -43,7 +43,6 @@ export function accessRequest(ctx, options) {
|
|
|
43
43
|
logged.add(listener);
|
|
44
44
|
console.error("[mantle onAccess] listener failed");
|
|
45
45
|
} };
|
|
46
|
-
// Schedule observation after the decision without awaiting its promise.
|
|
47
46
|
queueMicrotask(() => { try {
|
|
48
47
|
void Promise.resolve(listener(finished)).catch(log);
|
|
49
48
|
}
|
|
@@ -36,4 +36,4 @@ export declare function gatewayCaller(ctx: RequestContext | undefined, gateway:
|
|
|
36
36
|
/** Host errors are machine-readable and never challenge for application credentials. */
|
|
37
37
|
export declare function hostFailure(error: unknown, caller?: Caller, where?: string, challenge?: string): Response;
|
|
38
38
|
export { isHostCaller } from "./caller-identity.js";
|
|
39
|
-
export { checkFresh } from "./caller-
|
|
39
|
+
export { checkFresh } from "./caller-mode.js";
|
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
/** Request-only host contract validation. */
|
|
2
1
|
export { draining } from "./drain.js";
|
|
3
|
-
import { forwarded, ContractError
|
|
2
|
+
import { forwarded, ContractError } from "./contracts.js";
|
|
3
|
+
import { surfaceError, surfaceFailure } from "./surface-failure.js";
|
|
4
4
|
import { userCaller } from "./user-caller.js";
|
|
5
5
|
import { identities, isHostCaller } from "./caller-identity.js";
|
|
6
6
|
const hostContract = (message, field) => { throw new ContractError("HOST_CONTRACT", message, "", { status: 412, fields: field ? { field } : undefined }); };
|
|
7
|
-
/** Validate the forwarding contract and reuse the ordinary user validation and brand. */
|
|
8
7
|
export function hostCaller(props, options) {
|
|
9
8
|
const record = (v) => !!v && typeof v === "object" && !Array.isArray(v) && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null);
|
|
10
9
|
const text = (v) => typeof v === "string" && v.length > 0 && !v.includes("\0") && v.isWellFormed();
|
|
@@ -35,7 +34,6 @@ export function hostCaller(props, options) {
|
|
|
35
34
|
identities.set(caller, Object.freeze({ ...identities.get(base), admin: caller.admin, host: true }));
|
|
36
35
|
return caller;
|
|
37
36
|
}
|
|
38
|
-
/** Resolve only forwarded props, or trip on an entry that did not opt in. */
|
|
39
37
|
export function gatewayCaller(ctx, gateway, fields) {
|
|
40
38
|
const present = forwarded(ctx);
|
|
41
39
|
if (!gateway)
|
|
@@ -44,7 +42,6 @@ export function gatewayCaller(ctx, gateway, fields) {
|
|
|
44
42
|
return { kind: 'anonymous' };
|
|
45
43
|
return hostCaller(ctx.props.mantle, { tenant: gateway.tenant, fields });
|
|
46
44
|
}
|
|
47
|
-
/** Host errors are machine-readable and never challenge for application credentials. */
|
|
48
45
|
export function hostFailure(error, caller, where, challenge) {
|
|
49
46
|
if ((!caller || !isHostCaller(caller)) && !(error instanceof ContractError && error.code === 'HOST_CONTRACT'))
|
|
50
47
|
return surfaceFailure(error, challenge, where);
|
|
@@ -53,4 +50,4 @@ export function hostFailure(error, caller, where, challenge) {
|
|
|
53
50
|
return Response.json({ error: { code, ...(typeof result.error.field === 'string' ? { field: result.error.field } : {}), ...(result.error.path ? { target: result.error.path } : {}) } }, { status: result.status === 401 ? 403 : result.status, headers: { 'cache-control': 'private, no-store' } });
|
|
54
51
|
}
|
|
55
52
|
export { isHostCaller } from "./caller-identity.js";
|
|
56
|
-
export { checkFresh } from "./caller-
|
|
53
|
+
export { checkFresh } from "./caller-mode.js";
|
package/dist/kernel/json-body.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
/** Request-only JSON reader: bound bytes before copying or parsing, independent of UTF-8 chunk boundaries. */
|
|
2
|
-
/** Bound raw bytes before decoding a request body. */
|
|
3
1
|
export async function bytes(request, limit, fail) {
|
|
4
2
|
if (!Number.isSafeInteger(limit) || limit < 1)
|
|
5
3
|
fail("Body limit must be a positive safe integer.");
|
package/dist/kernel/mcp-apps.js
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { ContractError } from "./contracts.js";
|
|
2
|
-
/** MCP Apps (ADR-0029 D7): UI resources served beside the tools that render in them. */
|
|
3
2
|
export const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app";
|
|
4
|
-
/** Flatten contributors; `mcp(...)` reads static resources getters once at construction. */
|
|
5
3
|
export const resourcesOf = (apps) => ({ resources: (Array.isArray(apps) ? apps : [apps]).flatMap(app => app.resources) });
|
|
6
|
-
/** Static contributors are checked at construction; resolved factories are checked per request. A request-bound tool table defers tool-link checks until its first request. */
|
|
7
4
|
export function linkApps(apps, tools, views) {
|
|
8
5
|
const resources = resourcesOf(apps).resources;
|
|
9
6
|
const uris = new Set(), names = new Set(), renders = new Map(), appOnly = new Set();
|
|
@@ -24,7 +21,6 @@ export function linkApps(apps, tools, views) {
|
|
|
24
21
|
refuse(`Tool '${tool}' is claimed by two MCP App resources.`);
|
|
25
22
|
renders.set(tool, resource.uri);
|
|
26
23
|
}
|
|
27
|
-
// an App acts for the user without the model seeing the call, so only a read may be hidden from the model
|
|
28
24
|
for (const tool of resource.appOnly ?? []) {
|
|
29
25
|
if (views && !views.has(tool))
|
|
30
26
|
refuse(`App-only tool '${tool}' must be a View this endpoint serves.`);
|
|
@@ -33,7 +29,6 @@ export function linkApps(apps, tools, views) {
|
|
|
33
29
|
}
|
|
34
30
|
return { resources, renders, appOnly };
|
|
35
31
|
}
|
|
36
|
-
/** One endpoint's Apps bound to its tool table; `previous` is reused while the table's names are unchanged. */
|
|
37
32
|
export function linkSurface(apps, selected, previous) {
|
|
38
33
|
const key = JSON.stringify(selected.map(tool => [tool.name, !!tool.page]).sort(([a], [b]) => a.localeCompare(b)));
|
|
39
34
|
return previous?.key === key ? previous : { key, links: linkApps(apps, new Set(selected.map(tool => tool.name)), new Set(selected.filter(tool => tool.page).map(tool => tool.name))) };
|
package/dist/kernel/mcp-call.js
CHANGED
|
@@ -3,7 +3,6 @@ import { checkPageLimit } from "./paging.js";
|
|
|
3
3
|
export async function callTool(tool, raw, db, caller, handlers) {
|
|
4
4
|
return callToolLazy(tool, raw, () => db, caller, handlers);
|
|
5
5
|
}
|
|
6
|
-
/** Transport input is checked before acquiring an application-owned native handle. */
|
|
7
6
|
export async function callToolLazy(tool, raw, getDb, caller, handlers, dryRun) {
|
|
8
7
|
if (tool.page) {
|
|
9
8
|
const { limit, cursor, ...input } = raw;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
/** Request-only MCP implementation, excluded from Worker startup. */
|
|
2
1
|
import { authorized, authorizeTarget } from "./authorization.js";
|
|
3
|
-
import { forwarded, isAdmin, ContractError,
|
|
2
|
+
import { forwarded, isAdmin, ContractError, wire } from "./contracts.js";
|
|
3
|
+
import { checkOrigin, surfaceCaller, surfaceError } from "./surface-failure.js";
|
|
4
4
|
import { draining } from "./drain.js";
|
|
5
5
|
import { callToolLazy as callTool } from "./mcp-call.js";
|
|
6
6
|
import { RESOURCE_MIME_TYPE, linkSurface, resourcesOf } from "./mcp-apps.js";
|
|
@@ -19,15 +19,9 @@ function observe(listener, event) {
|
|
|
19
19
|
catch { }
|
|
20
20
|
}
|
|
21
21
|
const paging = (schema) => ({ ...schema, properties: { ...schema.properties, limit: { type: "integer", minimum: 1, maximum: 500 }, cursor: { type: "string" } } });
|
|
22
|
-
/**
|
|
23
|
-
* Characters of `JSON.stringify(page)` an MCP View page may hold. A result carries its rows twice (text block and
|
|
24
|
-
* `structuredContent`) and hosts are reported to truncate near 150k characters, so one copy stays at 60k.
|
|
25
|
-
* ponytail: one fixed number; raise it or make it an option if hosts publish their limits.
|
|
26
|
-
*/
|
|
27
22
|
const MCP_PAGE_BUDGET = 60_000;
|
|
28
|
-
|
|
23
|
+
const MCP_INPUT_ELEMENTS = 10_000;
|
|
29
24
|
const union = (scopes, extra) => [...new Set([...scopes, ...extra])].join(" ");
|
|
30
|
-
/** The handler for `/mcp` (public tools) and `/mcp/admin` (tools for `admin` roles); other paths are undefined. */
|
|
31
25
|
export function handler(tools, options, apps = {}, reads = []) {
|
|
32
26
|
const allowed = options.allowedOrigins ?? [];
|
|
33
27
|
return draining(async (request, env, ctx) => {
|
|
@@ -38,8 +32,6 @@ export function handler(tools, options, apps = {}, reads = []) {
|
|
|
38
32
|
const url = new URL(request.url), path = url.pathname.replace(/\/+$/, ""), surface = path === "/mcp" ? "public" : path === "/mcp/admin" ? "admin" : undefined;
|
|
39
33
|
if (!surface)
|
|
40
34
|
return;
|
|
41
|
-
// MCP transport MUST: a present Origin is a browser, answered only for loopback or `allowedOrigins`. The request's own
|
|
42
|
-
// host is not implied, since a DNS-rebinding page sends Origin == its rebound Host; a missing Origin is a server client.
|
|
43
35
|
if (request.headers.has("origin")) {
|
|
44
36
|
const sdk = await import("@modelcontextprotocol/server");
|
|
45
37
|
const foreign = sdk.originValidationResponse(request, [...sdk.localhostAllowedOrigins(), ...allowed]);
|
|
@@ -51,7 +43,6 @@ export function handler(tools, options, apps = {}, reads = []) {
|
|
|
51
43
|
}
|
|
52
44
|
}
|
|
53
45
|
const select = async () => (typeof tools === "function" ? await tools(request, env) : tools).filter(tool => tool.surface === surface);
|
|
54
|
-
// Dynamic admin tables stay behind the admin gate; cache classification uses generated metadata only.
|
|
55
46
|
let selected = surface === "public" ? await select() : [];
|
|
56
47
|
let parsedBody, tooLarge = false;
|
|
57
48
|
const cookieOnly = request.headers.has("cookie") && !request.headers.has("authorization");
|
|
@@ -120,7 +111,6 @@ export function handler(tools, options, apps = {}, reads = []) {
|
|
|
120
111
|
}
|
|
121
112
|
if (surface === "admin")
|
|
122
113
|
selected = await select();
|
|
123
|
-
// Before per-caller filtering, so every caller of a deploy sees one catalogue version.
|
|
124
114
|
const version = catalogueVersion(selected);
|
|
125
115
|
const { McpServer, ProtocolError, ProtocolErrorCode, createMcpHandler, fromJsonSchema } = await import("@modelcontextprotocol/server");
|
|
126
116
|
if (request.method === "POST" && !cookieOnly && !options.gateway)
|
|
@@ -176,7 +166,6 @@ export function handler(tools, options, apps = {}, reads = []) {
|
|
|
176
166
|
const sources = (Array.isArray(app) ? app : [app]);
|
|
177
167
|
const dynamic = sources.some(source => typeof source === "function");
|
|
178
168
|
const resolved = resourcesOf(await Promise.all(sources.map(source => typeof source === "function" ? source({ env, request }) : source)));
|
|
179
|
-
// Equal tool names say nothing about request-bound resource identity or ownership.
|
|
180
169
|
const current = linkSurface(resolved, selected, dynamic ? undefined : apps[surface]);
|
|
181
170
|
if (!dynamic)
|
|
182
171
|
apps[surface] = current;
|
|
@@ -193,7 +182,6 @@ export function handler(tools, options, apps = {}, reads = []) {
|
|
|
193
182
|
tool.check?.(caller);
|
|
194
183
|
await authorizeTarget(options.authorize, tool.target ?? { kind: tool.page ? "view" : "procedure", name: tool.name }, caller);
|
|
195
184
|
}
|
|
196
|
-
// an App's HTML is rendered for the same tools the caller's tools/list shows
|
|
197
185
|
if ((Array.isArray(parsedBody) ? parsedBody : [parsedBody]).some(message => message?.method === "tools/list" || message?.method === "resources/read")) {
|
|
198
186
|
const visible = [];
|
|
199
187
|
for (const tool of selected) {
|
|
@@ -218,17 +206,13 @@ export function handler(tools, options, apps = {}, reads = []) {
|
|
|
218
206
|
}
|
|
219
207
|
}
|
|
220
208
|
const serverInfo = { name: "aotter.mantle", ...options.serverInfo, version: options.serverInfo?.version ?? version };
|
|
221
|
-
// Host calls execute in the SDK callback, after transport validation.
|
|
222
209
|
const handler = createMcpHandler(() => {
|
|
223
210
|
const schema = (value) => fromJsonSchema(value, { getValidator() { return (input) => ({ valid: true, data: input, errorMessage: undefined }); } });
|
|
224
|
-
|
|
225
|
-
const server = new McpServer(serverInfo, options.instructions ? { instructions: options.instructions } : {});
|
|
211
|
+
const server = new McpServer(serverInfo, { maxToolInputElements: MCP_INPUT_ELEMENTS, ...(options.instructions ? { instructions: options.instructions } : {}) });
|
|
226
212
|
server.server.registerCapabilities({ tools: { listChanged: false } });
|
|
227
213
|
if (host)
|
|
228
214
|
server.server.onclose = () => access?.finish(isRecord(parsedBody) && parsedBody.method === "initialize" ? undefined : new ContractError("INPUT_VALIDATION_FAILED", "MCP request refused.", "mcp", { status: 400 }));
|
|
229
215
|
const appMeta = links ? registerApps(server, links, parsedBody, new Set(selected.map(tool => tool.name)), { env, request }) : undefined;
|
|
230
|
-
// A host's stale copy names a tool this catalogue lacks: say so, with the catalogue version, instead of the SDK's bare "not found".
|
|
231
|
-
// Wrapped where the SDK stores it, so transport and request validation still answer first.
|
|
232
216
|
const registered = new Set(), set = server.server.setRequestHandler.bind(server.server);
|
|
233
217
|
server.server.setRequestHandler = ((method, handler) => set(method, method === "tools/call" && typeof handler === "function" ? (request, context) => {
|
|
234
218
|
if (!registered.has(request.params.name))
|
|
@@ -240,7 +224,6 @@ export function handler(tools, options, apps = {}, reads = []) {
|
|
|
240
224
|
if (meta === false)
|
|
241
225
|
continue;
|
|
242
226
|
registered.add(tool.name);
|
|
243
|
-
// Not part of structured output: the version, and on failure the current input contract.
|
|
244
227
|
const about = (contract) => { const text = [tool.version && `tool version ${tool.version}`, contract && `input schema ${JSON.stringify(tool.page ? paging(tool.inputSchema) : tool.inputSchema)}`].filter(Boolean).join("; "); return text ? [{ type: "text", text }] : []; };
|
|
245
228
|
server.registerTool(tool.name, { ...(meta ? { _meta: meta } : {}), ...(tool.title ? { title: text(tool.title, locale) } : {}), description: text(tool.description, locale) ?? text(tool.title, locale) ?? tool.name, inputSchema: schema(tool.page ? paging(tool.inputSchema) : tool.inputSchema), ...(tool.outputSchema ? { outputSchema: schema(tool.outputSchema) } : {}), ...(tool.annotations ? { annotations: tool.annotations } : {}) }, async (raw) => {
|
|
246
229
|
const metadata = options.onObservation ? { kind: "invocation", surface, invocationId: crypto.randomUUID(), tool: tool.name, caller: Object.freeze(caller.kind === "user" ? { ...caller, scopes: Object.freeze([...caller.scopes]) } : { ...caller }) } : undefined;
|
|
@@ -256,8 +239,6 @@ export function handler(tools, options, apps = {}, reads = []) {
|
|
|
256
239
|
let db;
|
|
257
240
|
const run = (input) => callTool(tool, input, () => db ??= options.db(env), caller, options.handlers, access?.dryRun ? () => ({ dryRun: true, event: access.finish() }) : undefined);
|
|
258
241
|
let data = await run(raw);
|
|
259
|
-
// A page is cut before the first row that overflows the budget: the same page re-runs with the limit that fits, so the cursor is the View's own.
|
|
260
|
-
// Data can change between reads, so the re-run is measured again. A View without an order is one page and refuses a smaller limit: it stays whole.
|
|
261
242
|
for (let tries = 0; tool.page && isRecord(data) && Array.isArray(data.rows); tries++) {
|
|
262
243
|
const rows = data.rows, total = JSON.stringify(data).length;
|
|
263
244
|
if (total <= MCP_PAGE_BUDGET)
|
|
@@ -299,7 +280,6 @@ export function handler(tools, options, apps = {}, reads = []) {
|
|
|
299
280
|
}
|
|
300
281
|
});
|
|
301
282
|
}
|
|
302
|
-
// An empty catalogue still answers tools/list and refuses calls the same way.
|
|
303
283
|
if (registered.size === 0) {
|
|
304
284
|
server.server.setRequestHandler("tools/list", () => ({ tools: [] }));
|
|
305
285
|
server.server.setRequestHandler("tools/call", () => ({ content: [] }));
|
|
@@ -319,14 +299,12 @@ export function handler(tools, options, apps = {}, reads = []) {
|
|
|
319
299
|
}
|
|
320
300
|
});
|
|
321
301
|
}
|
|
322
|
-
/** A hash of the tools an endpoint serves (`surface:name:version`, sorted), as 32-bit FNV-1a hex: it changes with any tool's definition. */
|
|
323
302
|
function catalogueVersion(tools) {
|
|
324
303
|
const text = JSON.stringify(tools.map(tool => `${tool.surface}:${tool.name}:${tool.version ?? ""}`).sort());
|
|
325
304
|
return [...text].reduce((h, c) => Math.imul(h ^ c.codePointAt(0), 16777619) >>> 0, 2166136261).toString(16).padStart(8, "0");
|
|
326
305
|
}
|
|
327
306
|
const CLIENT_CAPABILITIES_META_KEY = "io.modelcontextprotocol/clientCapabilities";
|
|
328
307
|
const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
329
|
-
/** `unknown`: a stateless request after `initialize` carries no capabilities, so only an explicit refusal hides Apps. */
|
|
330
308
|
function clientUiSupport(message) {
|
|
331
309
|
for (const m of Array.isArray(message) ? message : [message]) {
|
|
332
310
|
if (!isRecord(m) || !isRecord(m.params))
|
|
@@ -344,7 +322,6 @@ function appMeta(r, csp = r.csp) {
|
|
|
344
322
|
const ui = { ...(csp ? { csp } : {}), ...(r.permissions ? { permissions: r.permissions } : {}), ...(r.domain !== undefined ? { domain: r.domain } : {}), ...(r.prefersBorder !== undefined ? { prefersBorder: r.prefersBorder } : {}) };
|
|
345
323
|
return Object.keys(ui).length > 0 ? { ui } : undefined;
|
|
346
324
|
}
|
|
347
|
-
/** Registration uses the official SDK; HTML loaders run only on resources/read. */
|
|
348
325
|
function registerApps(server, links, message, tools, request) {
|
|
349
326
|
const enabled = clientUiSupport(message) !== "unsupported";
|
|
350
327
|
if (enabled)
|
package/dist/kernel/mcp.js
CHANGED
|
@@ -1,22 +1,18 @@
|
|
|
1
1
|
import { linkApps, linkSurface, resourcesOf } from "./mcp-apps.js";
|
|
2
2
|
export const callTool = async (...args) => (await import("./mcp-call.js")).callTool(...args);
|
|
3
3
|
export const callToolLazy = async (...args) => (await import("./mcp-call.js")).callToolLazy(...args);
|
|
4
|
-
/** The selected MCP routes; all request work loads only when an MCP request arrives. */
|
|
5
4
|
export function mcp(tools, options, reads = []) {
|
|
6
5
|
let serve;
|
|
7
|
-
// Check known static App configuration now; factories wait for the request caller gate.
|
|
8
6
|
const linked = {};
|
|
9
7
|
const apps = {};
|
|
10
8
|
for (const surface of ["public", "admin"]) {
|
|
11
9
|
const declared = options.apps?.[surface];
|
|
12
10
|
if (declared) {
|
|
13
|
-
// Read each static contributor's resources getter once, including mixed arrays.
|
|
14
11
|
const sources = (Array.isArray(declared) ? declared : [declared])
|
|
15
12
|
.map(source => typeof source === "function" ? source : resourcesOf(source));
|
|
16
13
|
const fixed = resourcesOf(sources.filter(source => typeof source !== "function"));
|
|
17
14
|
const dynamic = sources.some(source => typeof source === "function");
|
|
18
15
|
apps[surface] = dynamic ? sources : fixed;
|
|
19
|
-
// Known static conflicts still fail before any request or dynamic factory runs.
|
|
20
16
|
if (typeof tools === "function")
|
|
21
17
|
linkApps(fixed);
|
|
22
18
|
else {
|
|
@@ -35,7 +31,6 @@ export function mcp(tools, options, reads = []) {
|
|
|
35
31
|
return serve(request, env, ctx);
|
|
36
32
|
};
|
|
37
33
|
}
|
|
38
|
-
/** `allowedOrigins` as the hostnames the official SDK compares; a bad entry fails here, not on each request. */
|
|
39
34
|
function originHostnames(origins = []) {
|
|
40
35
|
return Object.freeze(origins.map(origin => {
|
|
41
36
|
let url;
|
package/dist/kernel/media.js
CHANGED
|
@@ -1,33 +1,23 @@
|
|
|
1
|
-
/** 0.2 media lifecycle over native target drivers. No bytes pass through this module. */
|
|
2
1
|
import { ContractError } from "./contracts.js";
|
|
3
2
|
const P = "media";
|
|
4
|
-
/** HTTP status of each media code: a custom MediaStorage may throw them without one. */
|
|
5
3
|
const STATUS = new Map(Object.entries({
|
|
6
4
|
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,
|
|
7
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,
|
|
8
6
|
}));
|
|
9
7
|
const extra = (code) => ({ status: STATUS.get(code), ...(code === "PARTIAL_FAILURE" ? { fields: { failure: { outcome: "partial", retry: "safe", resource: "media" } } } : {}) });
|
|
10
|
-
/**
|
|
11
|
-
* A media, site or partial-failure error with the HTTP status (and `failure` payload) its code has on the wire. Code
|
|
12
|
-
* outside this module raising these codes uses it: core `statusOf` maps only the core codes.
|
|
13
|
-
*/
|
|
14
8
|
export const mediaError = (code, message, path = P, options) => new ContractError(code, message, path, { ...options, ...extra(code) });
|
|
15
9
|
const fail = (code, message, options) => mediaError(code, message, P, options);
|
|
16
|
-
/** A storage error keeps the wire status its code has always had. */
|
|
17
10
|
const own = (error) => { if (error instanceof ContractError && !error.status && STATUS.has(error.code))
|
|
18
11
|
Object.assign(error, extra(error.code)); return error; };
|
|
19
12
|
const rejected = (results) => results.filter((r) => r.status === "rejected");
|
|
20
13
|
const bad = (expected) => fail("INPUT_VALIDATION_FAILED", `expected ${expected}`);
|
|
21
|
-
/** SVG is refused: object storage does not sanitize it, and an SVG can carry script. */
|
|
22
14
|
const ALLOWED = new Set(["image/png", "image/jpeg", "image/webp", "image/avif", "image/gif"]);
|
|
23
|
-
/** Modern formats a skipped optimization makes larger than the fallback. */
|
|
24
15
|
const MODERN = new Set(["image/avif", "image/webp"]);
|
|
25
16
|
const ROLES = new Set(["primary", "alternate", "fallback"]);
|
|
26
17
|
const TTL_MS = 15 * 60 * 1000;
|
|
27
18
|
const obj = (v) => (v !== null && typeof v === "object" && !Array.isArray(v) ? v : {});
|
|
28
19
|
const MAX_TEXT = 1000;
|
|
29
20
|
const MAX_FILENAME = 255;
|
|
30
|
-
/** `alt` and `caption`: absent, or a string of at most 1000 characters. */
|
|
31
21
|
function texts(body) {
|
|
32
22
|
const b = obj(body);
|
|
33
23
|
for (const k of ["alt", "caption"])
|
|
@@ -35,7 +25,6 @@ function texts(body) {
|
|
|
35
25
|
throw bad(`{ alt?: string, caption?: string }, each at most ${MAX_TEXT} characters`);
|
|
36
26
|
return { ...(typeof b["alt"] === "string" ? { alt: b["alt"] } : {}), ...(typeof b["caption"] === "string" ? { caption: b["caption"] } : {}) };
|
|
37
27
|
}
|
|
38
|
-
/** Exactly one variant per required slot, nothing outside them, and exactly one primary. */
|
|
39
28
|
function checkVariants(policy, variants) {
|
|
40
29
|
const slots = policy.required.map(slot => slot.split(","));
|
|
41
30
|
const slotOf = new Map(slots.flatMap((mimes, i) => mimes.map((m) => [m, i])));
|
|
@@ -99,7 +88,6 @@ export function mediaLibrary(driver, d, storage, policies, now = Date.now) {
|
|
|
99
88
|
checkVariants(policy, asked);
|
|
100
89
|
const at = now();
|
|
101
90
|
const expiresAt = at + TTL_MS;
|
|
102
|
-
// bearer-equivalent until commit: storage keys under it must not be guessable
|
|
103
91
|
const uploadGroupId = crypto.randomUUID();
|
|
104
92
|
const specs = asked.map(({ mimeType, byteSize, role }) => ({ mimeType, byteSize, role, maxBytes: policy.maxBytes[mimeType] }));
|
|
105
93
|
let capabilities;
|
|
@@ -126,7 +114,6 @@ export function mediaLibrary(driver, d, storage, policies, now = Date.now) {
|
|
|
126
114
|
return assetOf(committed);
|
|
127
115
|
const row = await one(`SELECT record FROM pending_media_uploads WHERE id = ${p(1)}`, [uploadGroupId]);
|
|
128
116
|
const record = row ? JSON.parse(String(row.record)) : null;
|
|
129
|
-
// a rejected group's objects are deleted: an upload URL takes any type and size, so what was PUT is not trusted
|
|
130
117
|
const discard = () => removeAll(record?.variants ?? []);
|
|
131
118
|
if (!record || record.expiresAt <= now()) {
|
|
132
119
|
if (record) {
|
|
@@ -139,7 +126,6 @@ export function mediaLibrary(driver, d, storage, policies, now = Date.now) {
|
|
|
139
126
|
try {
|
|
140
127
|
asset = await storage.commitUpload({
|
|
141
128
|
uploadGroupId, filename: record.filename, now: record.createdAt,
|
|
142
|
-
// the declared size is the cap: a larger object than announced is refused
|
|
143
129
|
variants: record.variants.map((v) => ({ mimeType: v.mimeType, role: v.role, storageKey: v.storageKey, maxBytes: Math.min(v.maxBytes, v.expectedSize) })),
|
|
144
130
|
...((patch.alt ?? record.alt) !== undefined ? { alt: patch.alt ?? record.alt } : {}),
|
|
145
131
|
...((patch.caption ?? record.caption) !== undefined ? { caption: patch.caption ?? record.caption } : {}),
|
|
@@ -158,7 +144,6 @@ export function mediaLibrary(driver, d, storage, policies, now = Date.now) {
|
|
|
158
144
|
if (asset.id !== uploadGroupId)
|
|
159
145
|
throw new ContractError("OUTCOME_UNKNOWN", "Storage returned a different asset id; reconcile this upload group.", P);
|
|
160
146
|
await driver.write([save(asset, p), unpend(uploadGroupId)]).catch(async (error) => {
|
|
161
|
-
// Cleanup requires positive native rollback evidence; an unclassified failure may have committed.
|
|
162
147
|
if (driver.definitive?.(error) === true) {
|
|
163
148
|
const failed = rejected(await removeAll(asset.variants));
|
|
164
149
|
if (failed.length)
|
|
@@ -168,7 +153,6 @@ export function mediaLibrary(driver, d, storage, policies, now = Date.now) {
|
|
|
168
153
|
throw error instanceof ContractError && error.code === "OUTCOME_UNKNOWN" ? error : new ContractError("OUTCOME_UNKNOWN", "The write outcome is unknown; reconcile this upload group before retrying.", P, { cause: error });
|
|
169
154
|
});
|
|
170
155
|
const durable = await get(uploadGroupId);
|
|
171
|
-
// ON CONFLICT may select another concurrent attempt. Only discard this attempt's unselected copies.
|
|
172
156
|
const selected = new Set(durable.variants.map(v => v.storageKey));
|
|
173
157
|
if (rejected(await removeAll(asset.variants.filter(v => !selected.has(v.storageKey)))).length)
|
|
174
158
|
throw new ContractError("OUTCOME_UNKNOWN", "The asset is committed, but unselected upload copies need storage reconciliation.", P);
|
|
@@ -193,7 +177,6 @@ export function mediaLibrary(driver, d, storage, policies, now = Date.now) {
|
|
|
193
177
|
const patch = Object.entries(texts(request));
|
|
194
178
|
if (!patch.length)
|
|
195
179
|
return get(id);
|
|
196
|
-
// only the patched columns, and only a row that still exists: a concurrent delete or edit of the other field stands
|
|
197
180
|
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];
|
|
198
181
|
if (!r)
|
|
199
182
|
throw fail("MEDIA_ASSET_NOT_FOUND", `no media asset '${id}'`);
|
|
@@ -209,7 +192,6 @@ export function mediaLibrary(driver, d, storage, policies, now = Date.now) {
|
|
|
209
192
|
},
|
|
210
193
|
};
|
|
211
194
|
}
|
|
212
|
-
/** The 0.2 Admin library item's convenience fields, without changing the storage DTO. */
|
|
213
195
|
export function mediaItem(asset) {
|
|
214
196
|
const primary = asset.variants.find(v => v.role === "primary") ?? asset.variants[0];
|
|
215
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 };
|
package/dist/kernel/paging.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { modeOf } from "./caller-
|
|
1
|
+
import { modeOf } from "./caller-mode.js";
|
|
2
2
|
import { binds } from "./binds.js";
|
|
3
3
|
import { invalid } from "./surface.js";
|
|
4
|
-
/** Shared keyset wire format; a selected View binds its cursor to its operation. */
|
|
5
4
|
export const encodeCursor = (value, id, operation) => encodeURIComponent(JSON.stringify(operation === undefined ? [value, id] : [value, id, operation]));
|
|
6
5
|
export function decodeCursor(cursor, path = "", operation) {
|
|
7
6
|
try {
|
|
@@ -12,7 +11,6 @@ export function decodeCursor(cursor, path = "", operation) {
|
|
|
12
11
|
catch { }
|
|
13
12
|
invalid("Malformed or foreign cursor.", path);
|
|
14
13
|
}
|
|
15
|
-
/** Selected boundaries may accept a NULL key; their generated kind guard validates it before native I/O. */
|
|
16
14
|
export function decodeNullableCursor(cursor, path, operation) {
|
|
17
15
|
try {
|
|
18
16
|
const c = JSON.parse(decodeURIComponent(cursor));
|
|
@@ -23,7 +21,6 @@ export function decodeNullableCursor(cursor, path, operation) {
|
|
|
23
21
|
invalid("Malformed or foreign cursor.", path);
|
|
24
22
|
}
|
|
25
23
|
export const MAX_PAGE_SIZE = 500;
|
|
26
|
-
/** Transport and native entry bounds share one check; Views retain their declared cap. */
|
|
27
24
|
export function checkPageLimit(limit, path) {
|
|
28
25
|
if (!Number.isSafeInteger(limit) || Number(limit) < 1 || Number(limit) > MAX_PAGE_SIZE)
|
|
29
26
|
invalid("limit is 1 to 500.", path);
|
|
@@ -41,10 +38,6 @@ export function validCursor(kind, sqlite, key) {
|
|
|
41
38
|
default: return typeof key === "string";
|
|
42
39
|
}
|
|
43
40
|
}
|
|
44
|
-
/**
|
|
45
|
-
* A View's paged boundary: `options.limit` defaults to min(50, cap) and is capped at cap and 500. Generated modules
|
|
46
|
-
* call it from their exported function, so importing a View runs no kernel code.
|
|
47
|
-
*/
|
|
48
41
|
export async function page(spec, db, caller, input, options = {}) {
|
|
49
42
|
const { path, cap } = spec;
|
|
50
43
|
spec.auth?.(caller);
|
|
@@ -62,7 +55,6 @@ export async function page(spec, db, caller, input, options = {}) {
|
|
|
62
55
|
throw 0;
|
|
63
56
|
if (spec.keys === 1 && encoded[0] !== encoded[1])
|
|
64
57
|
throw 0;
|
|
65
|
-
// Preserve the existing [id,id,operation] single-key and [value,id,operation] two-key formats.
|
|
66
58
|
keys = spec.keys === 1 ? [encoded[1]] : encoded.slice(0, -1);
|
|
67
59
|
for (const [i, kind] of spec.cursor.entries()) {
|
|
68
60
|
if (!validCursor(kind, spec.sqlite, keys[i]))
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
3
|
-
import { checkFresh } from "./caller-identity.js";
|
|
1
|
+
import { isAdmin, deny, ROW_CONFLICT } from "./contracts.js";
|
|
2
|
+
import { checkFresh } from "./caller-mode.js";
|
|
4
3
|
import { bound } from "./binds.js";
|
|
5
4
|
export async function transition(db, caller, input, path, steps, failure, d, execute, write) {
|
|
6
5
|
checkFresh(caller, false, path);
|
|
@@ -9,7 +8,7 @@ export async function transition(db, caller, input, path, steps, failure, d, exe
|
|
|
9
8
|
return { results: await execute(db, bound(d, steps, caller, input), path).catch(error => {
|
|
10
9
|
if (release && error?.code === "CONFLICT")
|
|
11
10
|
release();
|
|
12
|
-
if (error?.code === "CONFLICT" && error.message ===
|
|
11
|
+
if (error?.code === "CONFLICT" && error.message === ROW_CONFLICT)
|
|
13
12
|
error.message = failure;
|
|
14
13
|
throw error;
|
|
15
14
|
}) };
|
package/dist/kernel/r2-media.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
/** Native R2 binding plus an app-owned SigV4 signer. No SDK dependency. */
|
|
2
1
|
import { ContractError } from "./contracts.js";
|
|
3
2
|
import { mediaError } from "./media.js";
|
|
4
3
|
const EXT = { "image/png": "png", "image/jpeg": "jpg", "image/webp": "webp", "image/avif": "avif", "image/gif": "gif" };
|
|
@@ -16,7 +15,6 @@ export function r2MediaStorage({ bucket, signer, endpoint, publicBase }) {
|
|
|
16
15
|
const ttl = Math.max(60, Math.floor((expiresAt - now) / 1000));
|
|
17
16
|
return {
|
|
18
17
|
capabilities: await Promise.all(variants.map(async (v) => {
|
|
19
|
-
// server-made keys, one directory per asset so its variants list together
|
|
20
18
|
const storageKey = `${UPLOADS}${purpose}/${uploadGroupId}/${v.role}.${ext(v.mimeType)}`;
|
|
21
19
|
const target = new URL(`${s3}/${storageKey}`);
|
|
22
20
|
target.searchParams.set("X-Amz-Expires", String(ttl));
|
|
@@ -26,7 +24,6 @@ export function r2MediaStorage({ bucket, signer, endpoint, publicBase }) {
|
|
|
26
24
|
};
|
|
27
25
|
},
|
|
28
26
|
async commitUpload({ uploadGroupId, filename, variants, alt, caption, now }) {
|
|
29
|
-
// every variant is checked before any is published, so a rejected group publishes nothing
|
|
30
27
|
const heads = await Promise.all(variants.map(spec => bucket.head(spec.storageKey)));
|
|
31
28
|
for (const [i, spec] of variants.entries()) {
|
|
32
29
|
const o = heads[i];
|
|
@@ -38,19 +35,16 @@ export function r2MediaStorage({ bucket, signer, endpoint, publicBase }) {
|
|
|
38
35
|
if (o.size > spec.maxBytes)
|
|
39
36
|
throw fail("MEDIA_VARIANT_SIZE_EXCEEDED", `the stored ${spec.role} is ${o.size} bytes, more than ${spec.maxBytes}`);
|
|
40
37
|
}
|
|
41
|
-
// Each attempt owns its public copies: a concurrent loser cannot overwrite or clean up a committed winner.
|
|
42
38
|
const attempt = crypto.randomUUID();
|
|
43
39
|
const out = [];
|
|
44
40
|
try {
|
|
45
41
|
for (const [i, spec] of variants.entries()) {
|
|
46
42
|
const key = `committed/${uploadGroupId}/${attempt}/${spec.role}.${ext(spec.mimeType)}`;
|
|
47
43
|
const o = await bucket.get(spec.storageKey);
|
|
48
|
-
// an upload URL may PUT again between the check and the copy: the copy must be what was checked
|
|
49
44
|
if (!o || o.size !== heads[i].size || o.httpMetadata?.contentType !== spec.mimeType) {
|
|
50
45
|
await cancel(o);
|
|
51
46
|
throw fail("MEDIA_OBJECT_NOT_FOUND", `variant ${spec.role} of '${uploadGroupId}' changed during commit`);
|
|
52
47
|
}
|
|
53
|
-
// Remember a possibly published key before awaiting put: a failed response may still have stored it.
|
|
54
48
|
out.push({ mimeType: spec.mimeType, publicUrl: `${base}/${key}`, storageKey: key, byteSize: o.size, role: spec.role });
|
|
55
49
|
try {
|
|
56
50
|
await bucket.put(key, o.body, {
|
|
@@ -63,7 +57,6 @@ export function r2MediaStorage({ bucket, signer, endpoint, publicBase }) {
|
|
|
63
57
|
throw error;
|
|
64
58
|
}
|
|
65
59
|
}
|
|
66
|
-
// The use case removes upload keys only after its SQL transaction is durable.
|
|
67
60
|
}
|
|
68
61
|
catch (error) {
|
|
69
62
|
const cleanup = await Promise.allSettled(out.map(v => bucket.delete(v.storageKey)));
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
1
|
+
import { forwarded, ContractError } from "./contracts.js";
|
|
2
|
+
import { checkOrigin, surfaceCaller } from "./surface-failure.js";
|
|
3
3
|
import { decode, invalid } from "./surface.js";
|
|
4
4
|
import { draining } from "./drain.js";
|
|
5
|
+
import { canonicalPath } from "./route-pattern.js";
|
|
5
6
|
const isApi = (path) => path === "/api" || path.startsWith("/api/");
|
|
6
7
|
const fail = message => invalid(message, "rest");
|
|
7
|
-
/** Shared matching/auth/error envelope; selected dispatch is supplied by the surface. */
|
|
8
8
|
export function router(routes, options, send) {
|
|
9
9
|
return draining(async (request, env, ctx) => {
|
|
10
10
|
let caller;
|
|
@@ -15,11 +15,8 @@ export function router(routes, options, send) {
|
|
|
15
15
|
if (isForwarded)
|
|
16
16
|
caller = (await import("./host-caller.js")).gatewayCaller(ctx, options.gateway, options.fields);
|
|
17
17
|
const original = new URL(request.url).pathname;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
path = original.split("/").map(segment => encodeURIComponent(decodeURIComponent(segment))).join("/");
|
|
21
|
-
}
|
|
22
|
-
catch {
|
|
18
|
+
const path = canonicalPath(original);
|
|
19
|
+
if (path === undefined) {
|
|
23
20
|
if (!isApi(original))
|
|
24
21
|
return undefined;
|
|
25
22
|
fail("Malformed request path.");
|
package/dist/kernel/rest.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/** REST request handling for a generated route table: matching, body and parameter decoding, dispatch and errors. */
|
|
2
2
|
import type { Caller, Target, Authorize, Gateway, RequestContext, GatewayOptions } from "./contracts.js";
|
|
3
3
|
import { type Conversions } from "./surface.js";
|
|
4
|
+
/** The routers' path grammar, for Admin's router: its mount, route patterns and the canonical request path. */
|
|
5
|
+
export { ADMIN_API_BASE, canonicalPath, routeSource } from "./route-pattern.js";
|
|
4
6
|
type Paging = {
|
|
5
7
|
limit?: number;
|
|
6
8
|
cursor?: string;
|
|
@@ -55,4 +57,3 @@ export declare function rest<Env, Database, Handlers = never>(routes: readonly R
|
|
|
55
57
|
export declare function nativeRest<Env, Database, Handlers = never>(routes: readonly Route<Database, Handlers, Env>[], options: Options<Env, Database> & {
|
|
56
58
|
handlers?: Handlers;
|
|
57
59
|
}): (request: Request, env: Env, ctx?: RequestContext) => Promise<Response | undefined>;
|
|
58
|
-
export {};
|
package/dist/kernel/rest.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ContractError } from "./contract-error.js";
|
|
2
2
|
import { invalid } from "./surface.js";
|
|
3
|
+
export { ADMIN_API_BASE, canonicalPath, routeSource } from "./route-pattern.js";
|
|
3
4
|
const headers = { "cache-control": "private, no-store" };
|
|
4
5
|
const fail = message => invalid(message, "rest");
|
|
5
6
|
export async function body(request, limit = 1_000_000) {
|
|
@@ -7,7 +8,6 @@ export async function body(request, limit = 1_000_000) {
|
|
|
7
8
|
fail(`Request body exceeds ${limit} bytes.`);
|
|
8
9
|
return (await import("./json-body.js")).body(request, limit, fail);
|
|
9
10
|
}
|
|
10
|
-
/** Shared matching/auth/error envelope; selected dispatch is supplied by the surface. */
|
|
11
11
|
function router(routes, options, send) {
|
|
12
12
|
let handle;
|
|
13
13
|
return async (request, env, ctx) => {
|
|
@@ -15,14 +15,12 @@ function router(routes, options, send) {
|
|
|
15
15
|
return handle(request, env, ctx);
|
|
16
16
|
};
|
|
17
17
|
}
|
|
18
|
-
/** Generated REST routes retain the native View/Procedure dispatch. */
|
|
19
18
|
export function rest(routes, options) {
|
|
20
19
|
return router(routes, options, async (route, request, env, caller, params, access) => {
|
|
21
20
|
const { dispatch } = await import("./rest-dispatch.js");
|
|
22
21
|
return dispatch(route, request, () => options.db(env), caller, params, options, access);
|
|
23
22
|
});
|
|
24
23
|
}
|
|
25
|
-
/** A selected surface's raw/JSON handlers; its branches are absent from ordinary REST bundles. */
|
|
26
24
|
export function nativeRest(routes, options) {
|
|
27
25
|
return router(routes, options, async (route, request, env, caller, params, access) => {
|
|
28
26
|
if (Number(request.headers.get("content-length")) > (route.bodyLimit ?? 1_000_000))
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one path grammar of the generated REST router, the Admin router and describe's `matchTarget`: a route template's
|
|
3
|
+
* pattern, the request path it is tested against and REST precedence. Pure; no database, application or other module.
|
|
4
|
+
*/
|
|
5
|
+
/** Where Admin serves its API. Admin route templates and `admin:` describe ids are relative to it. */
|
|
6
|
+
export declare const ADMIN_API_BASE = "/admin/api";
|
|
7
|
+
/**
|
|
8
|
+
* An http Trigger path as a describe template: `{name}` becomes `:name`. The compiler refuses other braces and
|
|
9
|
+
* `:`-prefixed segments in these paths, so `routePath` inverts it exactly.
|
|
10
|
+
*/
|
|
11
|
+
export declare const pathTemplate: (path: string) => string;
|
|
12
|
+
/** A describe template as the route path the routers compile: `:name` segments become `{name}`. */
|
|
13
|
+
export declare const routePath: (template: string) => string;
|
|
14
|
+
/**
|
|
15
|
+
* A template's anchored pattern source and capture keys. A `{name}` segment captures one segment; with `suffix`,
|
|
16
|
+
* `{name*}` captures the rest of the path. Other segments, `:name` included, match literally in encoded form. One
|
|
17
|
+
* trailing slash is optional.
|
|
18
|
+
*/
|
|
19
|
+
export declare function routeSource(template: string, base?: string, suffix?: boolean): {
|
|
20
|
+
source: string;
|
|
21
|
+
keys: string[];
|
|
22
|
+
};
|
|
23
|
+
/** The path the routers test: each segment decoded and re-encoded, so equivalent escapes match alike. Malformed escapes are undefined. */
|
|
24
|
+
export declare function canonicalPath(pathname: string): string | undefined;
|
|
25
|
+
/** REST precedence over route paths: fewer parameters, then a literal before a parameter from the left, then the path. */
|
|
26
|
+
export declare function routeOrder(a: string, b: string): number;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export const ADMIN_API_BASE = "/admin/api";
|
|
2
|
+
export const pathTemplate = (path) => path.replace(/\{([^}]+)\}/g, ":$1");
|
|
3
|
+
export const routePath = (template) => template.split("/").map(segment => segment.startsWith(":") ? `{${segment.slice(1)}}` : segment).join("/");
|
|
4
|
+
export function routeSource(template, base = "", suffix = false) {
|
|
5
|
+
const keys = [];
|
|
6
|
+
const path = template.split("/").map(segment => {
|
|
7
|
+
const rest = suffix ? /^\{([a-zA-Z]\w*)\*\}$/.exec(segment) : null, one = /^\{([^}]+)\}$/.exec(segment);
|
|
8
|
+
if (rest || one) {
|
|
9
|
+
keys.push((rest ?? one)[1]);
|
|
10
|
+
return rest ? "(.*)" : "([^/]+)";
|
|
11
|
+
}
|
|
12
|
+
return encodeURIComponent(segment).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
13
|
+
}).join("/");
|
|
14
|
+
return { source: `^${base}${path}/?$`, keys };
|
|
15
|
+
}
|
|
16
|
+
export function canonicalPath(pathname) {
|
|
17
|
+
try {
|
|
18
|
+
return pathname.split("/").map(segment => encodeURIComponent(decodeURIComponent(segment))).join("/");
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const isParameter = (segment) => /^\{[^}]+\}$/.test(segment);
|
|
25
|
+
export function routeOrder(a, b) {
|
|
26
|
+
const x = a.split("/"), y = b.split("/");
|
|
27
|
+
const count = (segments) => segments.filter(isParameter).length;
|
|
28
|
+
if (count(x) !== count(y))
|
|
29
|
+
return count(x) - count(y);
|
|
30
|
+
for (let i = 0; i < Math.min(x.length, y.length); i++)
|
|
31
|
+
if (isParameter(x[i]) !== isParameter(y[i]))
|
|
32
|
+
return isParameter(x[i]) ? 1 : -1;
|
|
33
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
34
|
+
}
|