@hollware/server 1.0.0-rc.0 → 1.0.0-rc.2
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/{ceremonies-CI3DkOEL.mjs → ceremonies-zTn1U6BD.mjs} +1 -1
- package/dist/index.d.mts +73 -2
- package/dist/index.mjs +477 -85
- package/dist/{store-DjA9s95l.mjs → store-ApxKV0Sc.mjs} +34 -0
- package/migrations/0017_assets.sql +42 -0
- package/migrations/meta/0017_snapshot.json +2993 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +3 -3
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { A as revisionTags, C as entryEmbeddings, D as publications, E as outbox, F as users, I as ROLE_SCOPES, L as effectiveScopes, M as schedules, N as sites, O as reviewComments, P as tokens, R as isRole, S as entries, T as members, a as dropExpired, f as membershipsOf, g as removePasskey, j as revisions, k as reviews, n as createInvitation, o as endSession, p as passkeySummaries, s as endSessions, u as findSession, w as invitations, x as deliveries } from "./store-
|
|
2
|
-
import { CHUNKER_VERSION, ConfigError, EVENT_ID_HEADER, HollwareError, INDEXNOW_KEY, OPERATIONS, OPERATION_AUDIT_ACTIONS, PREVIEW_QUERY_PARAM, SCOPES, SIGNATURE_HEADER, TIMESTAMP_HEADER, canSignPreviews, canonicalJson, chunksOf, compileSites, definitionsFromConfig, describeBuiltInContracts, describeCatalog, describeResolvedSite, describeSite, generatePersonalToken, generateReadToken, getEntry, getTranslations, hashPersonalToken, isAuditAction, isId, isPersonalToken, isReadToken, isScope, isSiteKey, listEntries, makePreviewToken, newId, processMarkdown, publish, renderHtml, resolveConfig, runContracts, search, signWebhook, siteDefinitionData, sizeLimitViolation, tagsOfChange, tagsOfSiteChange, uniqueSlugViolation, uniqueTranslationViolation, verifyPreviewToken } from "@hollware/core";
|
|
1
|
+
import { A as revisionTags, C as entryEmbeddings, D as publications, E as outbox, F as users, I as ROLE_SCOPES, L as effectiveScopes, M as schedules, N as sites, O as reviewComments, P as tokens, R as isRole, S as entries, T as members, a as dropExpired, f as membershipsOf, g as removePasskey, j as revisions, k as reviews, n as createInvitation, o as endSession, p as passkeySummaries, s as endSessions, u as findSession, w as invitations, x as deliveries } from "./store-ApxKV0Sc.mjs";
|
|
2
|
+
import { CHUNKER_VERSION, ConfigError, EVENT_ID_HEADER, HollwareError, INDEXNOW_KEY, MAX_ASSET_BYTES, OPERATIONS, OPERATION_AUDIT_ACTIONS, PREVIEW_QUERY_PARAM, SCOPES, SIGNATURE_HEADER, TIMESTAMP_HEADER, assetIdOf, canSignPreviews, canonicalJson, checkSvg, chunksOf, compileSites, conversionOf, definitionsFromConfig, describeBuiltInContracts, describeCatalog, describeResolvedSite, describeSite, generatePersonalToken, generateReadToken, getEntry, getTranslations, hashPersonalToken, isAuditAction, isId, isPersonalToken, isReadToken, isScope, isSiteKey, listEntries, makePreviewToken, newId, processMarkdown, publish, renderHtml, resolveAssetReferences, resolveConfig, runContracts, search, signWebhook, siteDefinitionData, sizeLimitViolation, sniffImage, tagsOfChange, tagsOfSiteChange, uniqueSlugViolation, uniqueTranslationViolation, verifyPreviewToken } from "@hollware/core";
|
|
3
3
|
import { Hono } from "hono";
|
|
4
4
|
import { drizzle } from "drizzle-orm/d1";
|
|
5
5
|
import { and, asc, desc, eq, getTableColumns, gt, inArray, isNotNull, isNull, lte, ne, or, sql } from "drizzle-orm";
|
|
@@ -10,7 +10,7 @@ import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, McpServer, accepted
|
|
|
10
10
|
import * as z from "zod";
|
|
11
11
|
import { WorkerEntrypoint } from "cloudflare:workers";
|
|
12
12
|
//#region package.json
|
|
13
|
-
var version = "1.0.0-rc.
|
|
13
|
+
var version = "1.0.0-rc.2";
|
|
14
14
|
//#endregion
|
|
15
15
|
//#region src/auth/responses.ts
|
|
16
16
|
/** The challenge of every 401 and 403 (RFC 6750). */
|
|
@@ -257,6 +257,237 @@ async function checkPersonalToken(db, token, options) {
|
|
|
257
257
|
};
|
|
258
258
|
}
|
|
259
259
|
//#endregion
|
|
260
|
+
//#region src/ops/errors.ts
|
|
261
|
+
/**
|
|
262
|
+
* The error of something that must never happen: a bug of Hollware, not a mistake of the caller.
|
|
263
|
+
* Like the `internal` error of the adapters, its message says nothing about the cause, which goes
|
|
264
|
+
* to the log through `cause`, and its fix names the request id the operator finds the log line by.
|
|
265
|
+
*/
|
|
266
|
+
function internalError$1(context, cause) {
|
|
267
|
+
return new HollwareError("internal", "The operation failed on the server.", `Try again. If it keeps failing, give the site operator this request id: ${context.requestId}.`, { cause: new Error(cause) });
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* The error for an answer of the store that the call it came from can never get: an entry that
|
|
271
|
+
* is created cannot conflict, a publish has no slug to take. It is a bug, not an outcome of the
|
|
272
|
+
* operation, so it is not one of the outcomes of its spec.
|
|
273
|
+
*/
|
|
274
|
+
function unexpectedAnswer(context, call, answer) {
|
|
275
|
+
return internalError$1(context, `The store answered ${answer.outcome} to ${call}, which it never does.`);
|
|
276
|
+
}
|
|
277
|
+
//#endregion
|
|
278
|
+
//#region src/ops/actor.ts
|
|
279
|
+
/**
|
|
280
|
+
* Who a write is made by: the person behind the token of the call, with what its client said
|
|
281
|
+
* about itself. A personal token writes as itself (ADR-019); an OAuth access token writes as the
|
|
282
|
+
* client the person allowed, whose id goes with what the client said (ADR-026). The adapters check
|
|
283
|
+
* the scopes of the operation before any handler runs and never route a write for the RPC
|
|
284
|
+
* binding, so reaching one with it is a bug, answered as `internal` before anything is read or
|
|
285
|
+
* written.
|
|
286
|
+
*/
|
|
287
|
+
function actorOf$1(context) {
|
|
288
|
+
const { principal } = context;
|
|
289
|
+
if (principal.kind === "token") return {
|
|
290
|
+
kind: "token",
|
|
291
|
+
userId: principal.userId,
|
|
292
|
+
tokenId: principal.tokenId,
|
|
293
|
+
meta: context.client
|
|
294
|
+
};
|
|
295
|
+
if (principal.kind === "oauth") return {
|
|
296
|
+
kind: "oauth_client",
|
|
297
|
+
userId: principal.userId,
|
|
298
|
+
tokenId: null,
|
|
299
|
+
meta: {
|
|
300
|
+
...context.client,
|
|
301
|
+
oauthClient: principal.clientId
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
throw internalError$1(context, `A write was routed for a principal of kind ${principal.kind}; only a person writes.`);
|
|
305
|
+
}
|
|
306
|
+
//#endregion
|
|
307
|
+
//#region src/ops/assets.ts
|
|
308
|
+
const NO_BUCKET = {
|
|
309
|
+
message: "This Hollware keeps no images: its Worker has no R2 bucket bound as MEDIA.",
|
|
310
|
+
fix: "Add { \"binding\": \"MEDIA\", \"bucket_name\": \"<name>\" } to r2_buckets in wrangler.jsonc, and run hollware deploy, which creates the bucket."
|
|
311
|
+
};
|
|
312
|
+
/** The keys of an asset's files in R2: the file served, and the file as it was uploaded. */
|
|
313
|
+
const servedKey = (site, id) => `sites/${site}/${id}`;
|
|
314
|
+
const originalKey = (site, id) => `originals/${site}/${id}`;
|
|
315
|
+
/** An asset as the operations answer it. */
|
|
316
|
+
function assetView(row, media) {
|
|
317
|
+
return {
|
|
318
|
+
id: row.id,
|
|
319
|
+
reference: `asset:${row.id}`,
|
|
320
|
+
url: `${media.baseUrl}${row.site}/${row.id}`,
|
|
321
|
+
format: row.format,
|
|
322
|
+
width: row.width,
|
|
323
|
+
height: row.height,
|
|
324
|
+
bytes: row.bytes,
|
|
325
|
+
originalFormat: row.originalFormat,
|
|
326
|
+
originalBytes: row.originalBytes,
|
|
327
|
+
alt: row.alt,
|
|
328
|
+
filename: row.filename,
|
|
329
|
+
createdAt: row.createdAt
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
function bytesOfBase64(data) {
|
|
333
|
+
const clean = data.replace(/\s/g, "");
|
|
334
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(clean) || clean.length % 4 !== 0) return null;
|
|
335
|
+
const binary = atob(clean);
|
|
336
|
+
const bytes = new Uint8Array(binary.length);
|
|
337
|
+
for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
|
|
338
|
+
return bytes;
|
|
339
|
+
}
|
|
340
|
+
async function sha256Of(bytes) {
|
|
341
|
+
return [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
342
|
+
}
|
|
343
|
+
/** The error code of the Images binding, such as 9412 (not an image) or 9422 (no allowance left). */
|
|
344
|
+
function imagesCode(error) {
|
|
345
|
+
const code = typeof error === "object" && error !== null ? Reflect.get(error, "code") : null;
|
|
346
|
+
if (typeof code === "number") return code;
|
|
347
|
+
const match = /\b(9\d{3})\b/.exec(error instanceof Error ? error.message : String(error));
|
|
348
|
+
return match === null ? null : Number(match[1]);
|
|
349
|
+
}
|
|
350
|
+
const refused$1 = (message, fix) => ({
|
|
351
|
+
outcome: "refused",
|
|
352
|
+
problem: {
|
|
353
|
+
message,
|
|
354
|
+
fix
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
const NOT_READABLE = refused$1("Cloudflare Images could not read this file as an image: it may be damaged or cut short.", "Open it in an image editor, save it again, and upload that.");
|
|
358
|
+
const NO_ALLOWANCE = {
|
|
359
|
+
outcome: "unavailable",
|
|
360
|
+
problem: {
|
|
361
|
+
message: "This month's allowance of image conversions is used up (5,000 on Cloudflare's Free plan): nothing was kept.",
|
|
362
|
+
fix: "Upload it next month, set the media format of the site to \"original\" to keep files as they come, or move the account to the Paid plan of Images."
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
const uploadAsset = async (input, context) => {
|
|
366
|
+
const media = context.media ?? null;
|
|
367
|
+
if (media === null) return {
|
|
368
|
+
outcome: "unavailable",
|
|
369
|
+
problem: NO_BUCKET
|
|
370
|
+
};
|
|
371
|
+
const original = bytesOfBase64(input.data);
|
|
372
|
+
if (original === null || original.length === 0) return refused$1("data is not base64.", "Send the file encoded in base64, with its padding.");
|
|
373
|
+
if (original.length > MAX_ASSET_BYTES) return refused$1(`The file is ${original.length} bytes, more than the ${MAX_ASSET_BYTES} an image may be.`, "Make it smaller (fewer pixels, or a format such as WebP) and upload it again.");
|
|
374
|
+
const originalFormat = sniffImage(original);
|
|
375
|
+
if (originalFormat === null) return refused$1("This file is not an image Hollware takes: JPEG, PNG, WebP, AVIF, GIF or SVG, judged by its bytes.", "Export it as one of those formats and upload it again.");
|
|
376
|
+
const sha256 = await sha256Of(original);
|
|
377
|
+
const id = assetIdOf(sha256);
|
|
378
|
+
const site = context.site.key;
|
|
379
|
+
const existing = await media.catalog.byId(site, id);
|
|
380
|
+
if (existing !== null) return {
|
|
381
|
+
outcome: "exists",
|
|
382
|
+
asset: assetView(existing, media)
|
|
383
|
+
};
|
|
384
|
+
let served = original;
|
|
385
|
+
let format = originalFormat;
|
|
386
|
+
let width = null;
|
|
387
|
+
let height = null;
|
|
388
|
+
if (originalFormat === "image/svg+xml") {
|
|
389
|
+
const problem = checkSvg(new TextDecoder().decode(original));
|
|
390
|
+
if (problem !== null) return refused$1(`This SVG is refused: ${problem.reason}.`, problem.fix);
|
|
391
|
+
} else if (media.images !== null) try {
|
|
392
|
+
const info = await media.images.info(original);
|
|
393
|
+
if ("width" in info) {
|
|
394
|
+
width = info.width;
|
|
395
|
+
height = info.height;
|
|
396
|
+
}
|
|
397
|
+
const conversion = conversionOf(originalFormat, width, context.site.media);
|
|
398
|
+
if (conversion !== null) {
|
|
399
|
+
const converted = await media.images.convert(original, conversion);
|
|
400
|
+
served = converted.bytes;
|
|
401
|
+
format = converted.format;
|
|
402
|
+
if (conversion.width !== void 0 && width !== null && height !== null) {
|
|
403
|
+
height = Math.max(1, Math.round(height * conversion.width / width));
|
|
404
|
+
width = conversion.width;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
} catch (error) {
|
|
408
|
+
const code = imagesCode(error);
|
|
409
|
+
if (code === 9422) return NO_ALLOWANCE;
|
|
410
|
+
if (code === 9412) return NOT_READABLE;
|
|
411
|
+
throw error;
|
|
412
|
+
}
|
|
413
|
+
await media.files.put(servedKey(site, id), served, format);
|
|
414
|
+
await media.files.put(originalKey(site, id), original, originalFormat);
|
|
415
|
+
const { row, created } = await media.catalog.insert({
|
|
416
|
+
id,
|
|
417
|
+
site,
|
|
418
|
+
sha256,
|
|
419
|
+
format,
|
|
420
|
+
width,
|
|
421
|
+
height,
|
|
422
|
+
bytes: served.length,
|
|
423
|
+
originalFormat,
|
|
424
|
+
originalBytes: original.length,
|
|
425
|
+
alt: input.alt,
|
|
426
|
+
filename: input.filename,
|
|
427
|
+
createdBy: actorOf$1(context).userId,
|
|
428
|
+
createdAt: context.clock.now().toISOString()
|
|
429
|
+
});
|
|
430
|
+
return {
|
|
431
|
+
outcome: created ? "created" : "exists",
|
|
432
|
+
asset: assetView(row, media)
|
|
433
|
+
};
|
|
434
|
+
};
|
|
435
|
+
/** The cursor of a page: the `createdAt` and id of its last row, in base64url. */
|
|
436
|
+
const cursorOf = (row) => btoa(`${row.createdAt}|${row.id}`).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
437
|
+
function parseCursor(cursor) {
|
|
438
|
+
if (cursor === void 0) return null;
|
|
439
|
+
try {
|
|
440
|
+
const padded = cursor.replace(/-/g, "+").replace(/_/g, "/");
|
|
441
|
+
const [createdAt, id] = atob(padded + "=".repeat((4 - padded.length % 4) % 4)).split("|");
|
|
442
|
+
return createdAt !== void 0 && id !== void 0 ? {
|
|
443
|
+
createdAt,
|
|
444
|
+
id
|
|
445
|
+
} : null;
|
|
446
|
+
} catch {
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
const listAssets = async (input, context) => {
|
|
451
|
+
const media = context.media ?? null;
|
|
452
|
+
if (media === null) return {
|
|
453
|
+
outcome: "ok",
|
|
454
|
+
assets: [],
|
|
455
|
+
nextCursor: null
|
|
456
|
+
};
|
|
457
|
+
const rows = await media.catalog.list(context.site.key, input.limit + 1, parseCursor(input.cursor));
|
|
458
|
+
const page = rows.slice(0, input.limit);
|
|
459
|
+
const last = page.at(-1);
|
|
460
|
+
return {
|
|
461
|
+
outcome: "ok",
|
|
462
|
+
assets: page.map((row) => assetView(row, media)),
|
|
463
|
+
nextCursor: rows.length > input.limit && last !== void 0 ? cursorOf(last) : null
|
|
464
|
+
};
|
|
465
|
+
};
|
|
466
|
+
const getAsset = async (input, context) => {
|
|
467
|
+
const media = context.media ?? null;
|
|
468
|
+
if (media === null) return { outcome: "not_found" };
|
|
469
|
+
const row = await media.catalog.byId(context.site.key, input.id);
|
|
470
|
+
return row === null ? { outcome: "not_found" } : {
|
|
471
|
+
outcome: "ok",
|
|
472
|
+
asset: assetView(row, media)
|
|
473
|
+
};
|
|
474
|
+
};
|
|
475
|
+
/**
|
|
476
|
+
* The URL an `asset:` reference of the site resolves to: where the Worker serves the image, or
|
|
477
|
+
* the reference as it is when the Worker serves no media.
|
|
478
|
+
*/
|
|
479
|
+
function assetUrlOf(context) {
|
|
480
|
+
const base = context.media?.baseUrl;
|
|
481
|
+
return (id) => base === void 0 ? `asset:${id}` : `${base}${context.site.key}/${id}`;
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* A value as a read of the published site sees it (RPC, the read API): every `asset:` reference
|
|
485
|
+
* in it as the image's URL. Over MCP, an agent reads what it wrote.
|
|
486
|
+
*/
|
|
487
|
+
function forReaders(context, value) {
|
|
488
|
+
return context.view === "published" ? resolveAssetReferences(value, assetUrlOf(context)) : value;
|
|
489
|
+
}
|
|
490
|
+
//#endregion
|
|
260
491
|
//#region src/db/d1.ts
|
|
261
492
|
/**
|
|
262
493
|
* Drizzle's D1 driver is typed over a `D1Database`, but at runtime it only calls `prepare` and
|
|
@@ -2074,7 +2305,7 @@ function boundedJson(value) {
|
|
|
2074
2305
|
* The actor of a principal. A person signed in to the admin is a `user`, with the admin as their
|
|
2075
2306
|
* client; any other OAuth client is an `oauth_client`; a personal or read token is a `token`.
|
|
2076
2307
|
*/
|
|
2077
|
-
function actorOf
|
|
2308
|
+
function actorOf(principal, meta, adminClientId) {
|
|
2078
2309
|
if (principal.kind === "oauth") return {
|
|
2079
2310
|
kind: principal.clientId === adminClientId ? "user" : "oauth_client",
|
|
2080
2311
|
userId: principal.userId,
|
|
@@ -2398,7 +2629,7 @@ function createSecretStore(db, now) {
|
|
|
2398
2629
|
* went wrong goes to the log and never to the caller, whose agent could repeat it to a person or
|
|
2399
2630
|
* to another system. The request id is how the operator finds the log line.
|
|
2400
2631
|
*/
|
|
2401
|
-
function internalError
|
|
2632
|
+
function internalError(requestId, cause) {
|
|
2402
2633
|
return new HollwareError("internal", "The operation failed on the server.", `Try again. If it keeps failing, give the site operator this request id: ${requestId}.`, { cause });
|
|
2403
2634
|
}
|
|
2404
2635
|
/** The fix of `timeout`, by effect: a write that ran out of time may still finish. */
|
|
@@ -2458,8 +2689,24 @@ function gatewayError(status, body) {
|
|
|
2458
2689
|
* in its fix, which is how the operator finds the line of the log that has the cause. The cause
|
|
2459
2690
|
* goes to the log and never to the caller, whose agent could repeat it to a person or to another
|
|
2460
2691
|
* system. This holds for every error, a failed D1 query included: nothing turns one into a 401.
|
|
2692
|
+
* - But a database that lacks the migrations of the release is a 503 `not_migrated` that says what
|
|
2693
|
+
* to run: an agent that set the project up can fix it, and the cause still goes to the log only.
|
|
2461
2694
|
* - A path that no route has is a JSON 404.
|
|
2462
2695
|
*/
|
|
2696
|
+
/** What D1 says when a table or a column of this release is not there: a migration is missing. */
|
|
2697
|
+
const MISSING_SCHEMA = /no such (?:table|column)|has no column named/;
|
|
2698
|
+
/**
|
|
2699
|
+
* Whether an error, or one of its causes, is D1 telling that the database lacks the schema of this
|
|
2700
|
+
* release: the one failure whose fix the caller can be told without anything of its cause.
|
|
2701
|
+
*/
|
|
2702
|
+
function isMissingMigration(error) {
|
|
2703
|
+
let current = error;
|
|
2704
|
+
for (let depth = 0; depth < 5 && current instanceof Error; depth++) {
|
|
2705
|
+
if (MISSING_SCHEMA.test(current.message)) return true;
|
|
2706
|
+
current = current.cause;
|
|
2707
|
+
}
|
|
2708
|
+
return false;
|
|
2709
|
+
}
|
|
2463
2710
|
function installErrorHandling(app, log) {
|
|
2464
2711
|
app.onError((c) => {
|
|
2465
2712
|
const requestId = c.get("requestId");
|
|
@@ -2470,6 +2717,11 @@ function installErrorHandling(app, log) {
|
|
|
2470
2717
|
path: c.req.path,
|
|
2471
2718
|
detail: describeError(c.error)
|
|
2472
2719
|
});
|
|
2720
|
+
if (isMissingMigration(c.error)) return gatewayError(503, {
|
|
2721
|
+
code: "not_migrated",
|
|
2722
|
+
message: "The database of this Hollware lacks the migrations of its release.",
|
|
2723
|
+
fix: "Apply them: \"hollware migrate --local\" in local development, \"hollware migrate --remote\" (or \"hollware deploy\") for a deployment. Then try again."
|
|
2724
|
+
});
|
|
2473
2725
|
return gatewayError(500, {
|
|
2474
2726
|
code: "internal",
|
|
2475
2727
|
message: "The request failed on the server.",
|
|
@@ -3511,7 +3763,8 @@ const NAMING_FIELDS = [
|
|
|
3511
3763
|
"id",
|
|
3512
3764
|
"entryId",
|
|
3513
3765
|
"reviewId",
|
|
3514
|
-
"name"
|
|
3766
|
+
"name",
|
|
3767
|
+
"filename"
|
|
3515
3768
|
];
|
|
3516
3769
|
const FIELD_MAX = 200;
|
|
3517
3770
|
function isAudited(name) {
|
|
@@ -3524,7 +3777,7 @@ function idOf(value) {
|
|
|
3524
3777
|
const id = record(value)["id"];
|
|
3525
3778
|
return typeof id === "string" ? id : null;
|
|
3526
3779
|
}
|
|
3527
|
-
/** What the call acted on: the entry, review or
|
|
3780
|
+
/** What the call acted on: the entry, review, comment or asset of its answer, else its site. */
|
|
3528
3781
|
function targetOf(output, site) {
|
|
3529
3782
|
const entry = idOf(output["entry"]);
|
|
3530
3783
|
if (entry !== null) return {
|
|
@@ -3541,6 +3794,11 @@ function targetOf(output, site) {
|
|
|
3541
3794
|
type: "comment",
|
|
3542
3795
|
id: comment
|
|
3543
3796
|
};
|
|
3797
|
+
const asset = idOf(output["asset"]);
|
|
3798
|
+
if (asset !== null) return {
|
|
3799
|
+
type: "asset",
|
|
3800
|
+
id: asset
|
|
3801
|
+
};
|
|
3544
3802
|
return {
|
|
3545
3803
|
type: "site",
|
|
3546
3804
|
id: site
|
|
@@ -3683,6 +3941,116 @@ function semanticOfEnv(env) {
|
|
|
3683
3941
|
return semanticOf(env, typeof model === "string" && model !== "" ? model : DEFAULT_EMBEDDING_MODEL);
|
|
3684
3942
|
}
|
|
3685
3943
|
//#endregion
|
|
3944
|
+
//#region src/media/cloudflare.ts
|
|
3945
|
+
const streamOf = (bytes) => new Blob([bytes]).stream();
|
|
3946
|
+
/** R2 as the store of asset files. */
|
|
3947
|
+
function r2Files(bucket) {
|
|
3948
|
+
return {
|
|
3949
|
+
async put(key, bytes, contentType) {
|
|
3950
|
+
await bucket.put(key, bytes, { httpMetadata: { contentType } });
|
|
3951
|
+
},
|
|
3952
|
+
async get(key) {
|
|
3953
|
+
const object = await bucket.get(key);
|
|
3954
|
+
if (object === null) return null;
|
|
3955
|
+
return {
|
|
3956
|
+
body: object.body,
|
|
3957
|
+
contentType: object.httpMetadata?.contentType ?? "application/octet-stream"
|
|
3958
|
+
};
|
|
3959
|
+
}
|
|
3960
|
+
};
|
|
3961
|
+
}
|
|
3962
|
+
function imagesConverter(images) {
|
|
3963
|
+
return {
|
|
3964
|
+
async info(bytes) {
|
|
3965
|
+
const info = await images.info(streamOf(bytes));
|
|
3966
|
+
if (typeof info !== "object" || info === null) throw new Error("Images answered no info");
|
|
3967
|
+
const format = Reflect.get(info, "format");
|
|
3968
|
+
if (format === "image/svg+xml") return { format };
|
|
3969
|
+
const width = Reflect.get(info, "width");
|
|
3970
|
+
const height = Reflect.get(info, "height");
|
|
3971
|
+
if (typeof format !== "string" || typeof width !== "number" || typeof height !== "number") throw new Error("Images answered an info without format, width and height");
|
|
3972
|
+
return {
|
|
3973
|
+
format,
|
|
3974
|
+
width,
|
|
3975
|
+
height
|
|
3976
|
+
};
|
|
3977
|
+
},
|
|
3978
|
+
async convert(bytes, conversion) {
|
|
3979
|
+
let transformer = images.input(streamOf(bytes));
|
|
3980
|
+
if (conversion.width !== void 0) transformer = transformer.transform({
|
|
3981
|
+
width: conversion.width,
|
|
3982
|
+
fit: "scale-down"
|
|
3983
|
+
});
|
|
3984
|
+
const result = await transformer.output({
|
|
3985
|
+
format: conversion.format,
|
|
3986
|
+
quality: conversion.quality,
|
|
3987
|
+
anim: conversion.anim
|
|
3988
|
+
});
|
|
3989
|
+
return {
|
|
3990
|
+
bytes: new Uint8Array(await new Response(result.image()).arrayBuffer()),
|
|
3991
|
+
format: result.contentType()
|
|
3992
|
+
};
|
|
3993
|
+
}
|
|
3994
|
+
};
|
|
3995
|
+
}
|
|
3996
|
+
const rowOf = (row) => ({
|
|
3997
|
+
id: row.id,
|
|
3998
|
+
site: row.site_id,
|
|
3999
|
+
sha256: row.sha256,
|
|
4000
|
+
format: row.format,
|
|
4001
|
+
width: row.width,
|
|
4002
|
+
height: row.height,
|
|
4003
|
+
bytes: row.bytes,
|
|
4004
|
+
originalFormat: row.original_format,
|
|
4005
|
+
originalBytes: row.original_bytes,
|
|
4006
|
+
alt: row.alt,
|
|
4007
|
+
filename: row.filename,
|
|
4008
|
+
createdBy: row.created_by,
|
|
4009
|
+
createdAt: row.created_at
|
|
4010
|
+
});
|
|
4011
|
+
/** The rows of `assets` in D1, always of one site. */
|
|
4012
|
+
function d1Catalog(db) {
|
|
4013
|
+
const byId = async (site, id) => {
|
|
4014
|
+
const row = await db.prepare("SELECT * FROM assets WHERE site_id = ? AND id = ?").bind(site, id).first();
|
|
4015
|
+
return row === null ? null : rowOf(row);
|
|
4016
|
+
};
|
|
4017
|
+
return {
|
|
4018
|
+
byId,
|
|
4019
|
+
async insert(asset) {
|
|
4020
|
+
const written = await db.prepare(`INSERT INTO assets (id, site_id, sha256, format, width, height, bytes, original_format,
|
|
4021
|
+
original_bytes, alt, filename, created_by, created_at, updated_at)
|
|
4022
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
4023
|
+
ON CONFLICT (site_id, sha256) DO NOTHING
|
|
4024
|
+
RETURNING *`).bind(asset.id, asset.site, asset.sha256, asset.format, asset.width, asset.height, asset.bytes, asset.originalFormat, asset.originalBytes, asset.alt, asset.filename, asset.createdBy, asset.createdAt, asset.createdAt).first();
|
|
4025
|
+
if (written !== null) return {
|
|
4026
|
+
row: rowOf(written),
|
|
4027
|
+
created: true
|
|
4028
|
+
};
|
|
4029
|
+
const existing = await db.prepare("SELECT * FROM assets WHERE site_id = ? AND sha256 = ?").bind(asset.site, asset.sha256).first();
|
|
4030
|
+
if (existing === null) throw new Error(`No asset of ${asset.sha256} after a conflict`);
|
|
4031
|
+
return {
|
|
4032
|
+
row: rowOf(existing),
|
|
4033
|
+
created: false
|
|
4034
|
+
};
|
|
4035
|
+
},
|
|
4036
|
+
async list(site, limit, cursor) {
|
|
4037
|
+
const { results } = await (cursor === null ? db.prepare("SELECT * FROM assets WHERE site_id = ? ORDER BY created_at DESC, id DESC LIMIT ?").bind(site, limit) : db.prepare(`SELECT * FROM assets WHERE site_id = ? AND (created_at, id) < (?, ?)
|
|
4038
|
+
ORDER BY created_at DESC, id DESC LIMIT ?`).bind(site, cursor.createdAt, cursor.id, limit)).all();
|
|
4039
|
+
return results.map(rowOf);
|
|
4040
|
+
}
|
|
4041
|
+
};
|
|
4042
|
+
}
|
|
4043
|
+
/** Media of a Worker: `null` without its R2 bucket, and without conversion when `IMAGES` is not bound. */
|
|
4044
|
+
function mediaOfEnv(env) {
|
|
4045
|
+
if (env.MEDIA === void 0 || env.HOLLWARE_PUBLIC_URL === void 0) return null;
|
|
4046
|
+
return {
|
|
4047
|
+
files: r2Files(env.MEDIA),
|
|
4048
|
+
images: env.IMAGES === void 0 ? null : imagesConverter(env.IMAGES),
|
|
4049
|
+
catalog: d1Catalog(env.DB),
|
|
4050
|
+
baseUrl: `${env.HOLLWARE_PUBLIC_URL.replace(/\/$/, "")}/assets/`
|
|
4051
|
+
};
|
|
4052
|
+
}
|
|
4053
|
+
//#endregion
|
|
3686
4054
|
//#region src/analytics/cloudflare.ts
|
|
3687
4055
|
/** The dataset of Analytics Engine when the Worker names none. */
|
|
3688
4056
|
const DEFAULT_ANALYTICS_DATASET = "hollware_page_views";
|
|
@@ -3842,6 +4210,7 @@ function installOpsApi(app, options) {
|
|
|
3842
4210
|
notify: options.notify(c, requestId, site.key),
|
|
3843
4211
|
semantic: semanticOfEnv(c.env),
|
|
3844
4212
|
analytics: analyticsReaderOf(c.env),
|
|
4213
|
+
media: mediaOfEnv(c.env),
|
|
3845
4214
|
...options.structure === void 0 ? {} : { structure: options.structure(c.env.DB, c.env.HOLLWARE_SECRET) }
|
|
3846
4215
|
};
|
|
3847
4216
|
/** The line of the call, after it answered or failed. */
|
|
@@ -3877,6 +4246,8 @@ function installOpsApi(app, options) {
|
|
|
3877
4246
|
}
|
|
3878
4247
|
//#endregion
|
|
3879
4248
|
//#region src/http/read-api.ts
|
|
4249
|
+
/** The paths of the index of the read API, which answers with no token. */
|
|
4250
|
+
const INDEX_PATHS = /* @__PURE__ */ new Set(["/v1", "/v1/"]);
|
|
3880
4251
|
/** How long a browser keeps the answer of a preflight, in seconds. */
|
|
3881
4252
|
const PREFLIGHT_MAX_AGE = 600;
|
|
3882
4253
|
/** The origin of a request from a page of another origin than Hollware's own, or `null`. */
|
|
@@ -3976,7 +4347,7 @@ function installReadApi(app, options) {
|
|
|
3976
4347
|
message: "The read API only reads.",
|
|
3977
4348
|
fix: "Use GET."
|
|
3978
4349
|
}, 405, { allow: "GET, HEAD" });
|
|
3979
|
-
if (c.req.path === "/v1/openapi.json") return next();
|
|
4350
|
+
if (c.req.path === "/v1/openapi.json" || INDEX_PATHS.has(c.req.path)) return next();
|
|
3980
4351
|
c.res = await options.protect(c, async (principal) => {
|
|
3981
4352
|
if (!principal.scopes.includes("content:read")) return insufficientScope(["content:read"], options.metadataUrl(c));
|
|
3982
4353
|
c.set("site", principal.site);
|
|
@@ -4000,6 +4371,13 @@ function installReadApi(app, options) {
|
|
|
4000
4371
|
return c.res;
|
|
4001
4372
|
});
|
|
4002
4373
|
app.get("/v1/openapi.json", (c) => c.json(openApiDocument()));
|
|
4374
|
+
for (const path of INDEX_PATHS) app.get(path, (c) => {
|
|
4375
|
+
const origin = new URL(c.env.HOLLWARE_PUBLIC_URL).origin;
|
|
4376
|
+
return c.json({
|
|
4377
|
+
openapi: `${origin}/v1/openapi.json`,
|
|
4378
|
+
site: `${origin}/v1/site`
|
|
4379
|
+
});
|
|
4380
|
+
});
|
|
4003
4381
|
app.get("/v1/site", readRoute(async (c) => answer(c, await options.read(c.env).describeSite(c.get("site")))));
|
|
4004
4382
|
app.get("/v1/search", readRoute(async (c) => {
|
|
4005
4383
|
const query = c.req.query();
|
|
@@ -4158,7 +4536,7 @@ async function writeForPerson(db, log, siteOf, userId, line) {
|
|
|
4158
4536
|
* The ceremonies, with SimpleWebAuthn and its parsers, in a chunk of their own: only these four
|
|
4159
4537
|
* routes use them, and a Worker that serves MCP never pays for them at startup (`budgets.json`).
|
|
4160
4538
|
*/
|
|
4161
|
-
const ceremonies = () => import("./ceremonies-
|
|
4539
|
+
const ceremonies = () => import("./ceremonies-zTn1U6BD.mjs");
|
|
4162
4540
|
/**
|
|
4163
4541
|
* The cookie of a signed-in person: only `/authorize` reads it (ADR-026), and the sign-out below
|
|
4164
4542
|
* it, `/authorize/signout`, which the path of the cookie reaches too.
|
|
@@ -4789,7 +5167,7 @@ function createGateway(options) {
|
|
|
4789
5167
|
* The activity log of a request (ADR-037), for an actor known once the client has said what it
|
|
4790
5168
|
* is. A person in the admin is a `user`; any other OAuth client, an `oauth_client`.
|
|
4791
5169
|
*/
|
|
4792
|
-
const auditOf = (c, principal, adminClientId) => (meta) => auditAs(c, actorOf
|
|
5170
|
+
const auditOf = (c, principal, adminClientId) => (meta) => auditAs(c, actorOf(principal, meta, adminClientId));
|
|
4793
5171
|
/**
|
|
4794
5172
|
* A protected route: the resource server of `resource` answers a request without a token and a
|
|
4795
5173
|
* token it refuses (401, logged with the reason), and `proceed` gets the principal of the rest.
|
|
@@ -4859,9 +5237,10 @@ function createGateway(options) {
|
|
|
4859
5237
|
c.set("requestId", crypto.randomUUID());
|
|
4860
5238
|
await next();
|
|
4861
5239
|
const https = c.env.HOLLWARE_PUBLIC_URL?.startsWith("https://") === true;
|
|
4862
|
-
if (!(!c.res.headers.has("x-content-type-options") || https && !c.res.headers.has("strict-transport-security"))) return;
|
|
5240
|
+
if (!(!c.res.headers.has("x-content-type-options") || !c.res.headers.has("x-robots-tag") || https && !c.res.headers.has("strict-transport-security"))) return;
|
|
4863
5241
|
const headers = new Headers(c.res.headers);
|
|
4864
5242
|
if (!headers.has("x-content-type-options")) headers.set("x-content-type-options", "nosniff");
|
|
5243
|
+
if (!headers.has("x-robots-tag")) headers.set("x-robots-tag", "noindex, nofollow");
|
|
4865
5244
|
if (https && !headers.has("strict-transport-security")) headers.set("strict-transport-security", "max-age=31536000");
|
|
4866
5245
|
c.res = new Response(c.res.body, {
|
|
4867
5246
|
status: c.res.status,
|
|
@@ -4908,6 +5287,10 @@ function createGateway(options) {
|
|
|
4908
5287
|
await options.refresh?.(c.env.DB);
|
|
4909
5288
|
await next();
|
|
4910
5289
|
});
|
|
5290
|
+
app.get("/robots.txt", () => new Response("User-agent: *\nDisallow: /\n", { headers: {
|
|
5291
|
+
"content-type": "text/plain; charset=utf-8",
|
|
5292
|
+
"cache-control": "public, max-age=86400"
|
|
5293
|
+
} }));
|
|
4911
5294
|
app.get("/health", () => Response.json({ ok: true }, { headers: { "cache-control": "no-store" } }));
|
|
4912
5295
|
app.all("/mcp", async (c) => protectWrites(c, "mcp", async (principal) => {
|
|
4913
5296
|
const requestId = c.get("requestId");
|
|
@@ -4925,7 +5308,8 @@ function createGateway(options) {
|
|
|
4925
5308
|
notify: notify(c, requestId, site.key),
|
|
4926
5309
|
semantic: semanticOfEnv(c.env),
|
|
4927
5310
|
analytics: analyticsReaderOf(c.env),
|
|
4928
|
-
structure: structureOf(db, c.env.HOLLWARE_SECRET)
|
|
5311
|
+
structure: structureOf(db, c.env.HOLLWARE_SECRET),
|
|
5312
|
+
media: mediaOfEnv(c.env)
|
|
4929
5313
|
},
|
|
4930
5314
|
...principal.kind === "read" ? {} : { audit: {
|
|
4931
5315
|
log: auditOf(c, principal, null),
|
|
@@ -5019,6 +5403,45 @@ function createGateway(options) {
|
|
|
5019
5403
|
structure: structureOf,
|
|
5020
5404
|
audit: async (c, principal) => auditOf(c, principal, (await adminClient(oauth(c.env.HOLLWARE_PUBLIC_URL), c.env)).clientId)
|
|
5021
5405
|
});
|
|
5406
|
+
app.get("/assets/:site/:id", async (c) => {
|
|
5407
|
+
const { site, id } = c.req.param();
|
|
5408
|
+
const media = mediaOfEnv(c.env);
|
|
5409
|
+
const file = media === null || !isSiteKey(site) || !ASSET_ID.test(id) ? null : await media.files.get(servedKey(site, id));
|
|
5410
|
+
if (file === null) return Response.json({
|
|
5411
|
+
code: "not_found",
|
|
5412
|
+
message: "There is no image at this URL.",
|
|
5413
|
+
fix: "Use the url that upload_asset or get_asset gave the image."
|
|
5414
|
+
}, {
|
|
5415
|
+
status: 404,
|
|
5416
|
+
headers: { "cache-control": "no-store" }
|
|
5417
|
+
});
|
|
5418
|
+
const headers = new Headers({
|
|
5419
|
+
"content-type": file.contentType,
|
|
5420
|
+
"cache-control": "public, max-age=31536000, immutable",
|
|
5421
|
+
"cross-origin-resource-policy": "cross-origin"
|
|
5422
|
+
});
|
|
5423
|
+
if (file.contentType === "image/svg+xml") headers.set("content-security-policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox");
|
|
5424
|
+
return new Response(file.body, { headers });
|
|
5425
|
+
});
|
|
5426
|
+
app.get("/v1/assets/:id/original", (c) => protectWrites(c, "v1", async (principal) => {
|
|
5427
|
+
if (missingScopes(principal, ["content:read"]).length > 0) return insufficientScope(["content:read"], oauth(c.env.HOLLWARE_PUBLIC_URL).metadataUrls.v1);
|
|
5428
|
+
const id = c.req.param("id");
|
|
5429
|
+
const media = mediaOfEnv(c.env);
|
|
5430
|
+
const row = media === null || !ASSET_ID.test(id) ? null : await media.catalog.byId(principal.site, id);
|
|
5431
|
+
const file = media === null || row === null ? null : await media.files.get(originalKey(row.site, row.id));
|
|
5432
|
+
if (file === null) return Response.json({
|
|
5433
|
+
code: "not_found",
|
|
5434
|
+
message: `The site has no image ${id}.`,
|
|
5435
|
+
fix: "List the images of the site with list_assets."
|
|
5436
|
+
}, {
|
|
5437
|
+
status: 404,
|
|
5438
|
+
headers: { "cache-control": "no-store" }
|
|
5439
|
+
});
|
|
5440
|
+
return new Response(file.body, { headers: {
|
|
5441
|
+
"content-type": file.contentType,
|
|
5442
|
+
"cache-control": "private, no-store"
|
|
5443
|
+
} });
|
|
5444
|
+
}));
|
|
5022
5445
|
if (options.read !== void 0) installReadApi(app, {
|
|
5023
5446
|
read: options.read,
|
|
5024
5447
|
corsOrigins: (site) => sites.get(site)?.cors.origins ?? [],
|
|
@@ -5033,6 +5456,8 @@ function createGateway(options) {
|
|
|
5033
5456
|
return await app.fetch(request, env, ctx);
|
|
5034
5457
|
};
|
|
5035
5458
|
}
|
|
5459
|
+
/** The id of an asset: `ast_` and 26 hexadecimal characters, from the hash of its file. */
|
|
5460
|
+
const ASSET_ID = /^ast_[0-9a-f]{26}$/;
|
|
5036
5461
|
/** The group of a route that is rate limited, or `null` for one that is not (T-10). */
|
|
5037
5462
|
function limitedRoute(method, pathname) {
|
|
5038
5463
|
if (method !== "POST") return null;
|
|
@@ -5231,6 +5656,9 @@ const OUTPUT_TOO_LARGE_FIX = {
|
|
|
5231
5656
|
get_translations: "The translations do not fit in one answer. Read each one with get_entry instead.",
|
|
5232
5657
|
list_entries: "Ask for less: pass a smaller limit, or narrow the list with locale, tag or status.",
|
|
5233
5658
|
list_reviews: "Ask for less: pass a smaller limit.",
|
|
5659
|
+
list_assets: "Ask for less: pass a smaller limit.",
|
|
5660
|
+
get_asset: "Nothing to ask for less of: the answer is one asset. Try again.",
|
|
5661
|
+
upload_asset: "The operation ran and the image may have been kept: list the images with list_assets before you upload it again.",
|
|
5234
5662
|
publish: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
|
|
5235
5663
|
request_changes: "The operation ran and its change may have been applied: read the review with list_reviews before you repeat it.",
|
|
5236
5664
|
request_review: "The operation ran and its review may have been asked: read it with list_reviews before you repeat it.",
|
|
@@ -5505,7 +5933,7 @@ function createMcpEndpoint(options) {
|
|
|
5505
5933
|
try {
|
|
5506
5934
|
result = spec.name === "publish" ? await performPublish() : answerResult(spec, await perform(input));
|
|
5507
5935
|
} catch (error) {
|
|
5508
|
-
failure = error instanceof HollwareError ? error : internalError
|
|
5936
|
+
failure = error instanceof HollwareError ? error : internalError(call.requestId, error);
|
|
5509
5937
|
isError = true;
|
|
5510
5938
|
result = errorResult(failure);
|
|
5511
5939
|
}
|
|
@@ -5697,7 +6125,8 @@ async function run(deps, env, method, site, schema, build, handler, viewOf = ()
|
|
|
5697
6125
|
clock: deps.clock,
|
|
5698
6126
|
client: null,
|
|
5699
6127
|
requestId,
|
|
5700
|
-
semantic: semanticOfEnv(env)
|
|
6128
|
+
semantic: semanticOfEnv(env),
|
|
6129
|
+
media: mediaOfEnv(env)
|
|
5701
6130
|
};
|
|
5702
6131
|
try {
|
|
5703
6132
|
return await handler(input, context);
|
|
@@ -6322,53 +6751,6 @@ const contentStats = async (input, context) => {
|
|
|
6322
6751
|
};
|
|
6323
6752
|
};
|
|
6324
6753
|
//#endregion
|
|
6325
|
-
//#region src/ops/errors.ts
|
|
6326
|
-
/**
|
|
6327
|
-
* The error of something that must never happen: a bug of Hollware, not a mistake of the caller.
|
|
6328
|
-
* Like the `internal` error of the adapters, its message says nothing about the cause, which goes
|
|
6329
|
-
* to the log through `cause`, and its fix names the request id the operator finds the log line by.
|
|
6330
|
-
*/
|
|
6331
|
-
function internalError(context, cause) {
|
|
6332
|
-
return new HollwareError("internal", "The operation failed on the server.", `Try again. If it keeps failing, give the site operator this request id: ${context.requestId}.`, { cause: new Error(cause) });
|
|
6333
|
-
}
|
|
6334
|
-
/**
|
|
6335
|
-
* The error for an answer of the store that the call it came from can never get: an entry that
|
|
6336
|
-
* is created cannot conflict, a publish has no slug to take. It is a bug, not an outcome of the
|
|
6337
|
-
* operation, so it is not one of the outcomes of its spec.
|
|
6338
|
-
*/
|
|
6339
|
-
function unexpectedAnswer(context, call, answer) {
|
|
6340
|
-
return internalError(context, `The store answered ${answer.outcome} to ${call}, which it never does.`);
|
|
6341
|
-
}
|
|
6342
|
-
//#endregion
|
|
6343
|
-
//#region src/ops/actor.ts
|
|
6344
|
-
/**
|
|
6345
|
-
* Who a write is made by: the person behind the token of the call, with what its client said
|
|
6346
|
-
* about itself. A personal token writes as itself (ADR-019); an OAuth access token writes as the
|
|
6347
|
-
* client the person allowed, whose id goes with what the client said (ADR-026). The adapters check
|
|
6348
|
-
* the scopes of the operation before any handler runs and never route a write for the RPC
|
|
6349
|
-
* binding, so reaching one with it is a bug, answered as `internal` before anything is read or
|
|
6350
|
-
* written.
|
|
6351
|
-
*/
|
|
6352
|
-
function actorOf(context) {
|
|
6353
|
-
const { principal } = context;
|
|
6354
|
-
if (principal.kind === "token") return {
|
|
6355
|
-
kind: "token",
|
|
6356
|
-
userId: principal.userId,
|
|
6357
|
-
tokenId: principal.tokenId,
|
|
6358
|
-
meta: context.client
|
|
6359
|
-
};
|
|
6360
|
-
if (principal.kind === "oauth") return {
|
|
6361
|
-
kind: "oauth_client",
|
|
6362
|
-
userId: principal.userId,
|
|
6363
|
-
tokenId: null,
|
|
6364
|
-
meta: {
|
|
6365
|
-
...context.client,
|
|
6366
|
-
oauthClient: principal.clientId
|
|
6367
|
-
}
|
|
6368
|
-
};
|
|
6369
|
-
throw internalError(context, `A write was routed for a principal of kind ${principal.kind}; only a person writes.`);
|
|
6370
|
-
}
|
|
6371
|
-
//#endregion
|
|
6372
6754
|
//#region src/ops/collections.ts
|
|
6373
6755
|
/**
|
|
6374
6756
|
* Which collection and locale a call names, checked against the config of the site before
|
|
@@ -6498,7 +6880,7 @@ function revisionOf(input) {
|
|
|
6498
6880
|
const createDraft = async (input, context) => {
|
|
6499
6881
|
const collection = requireCollection(context.site, input.collection);
|
|
6500
6882
|
requireLocale(context.site, input.locale);
|
|
6501
|
-
const actor = actorOf(context);
|
|
6883
|
+
const actor = actorOf$1(context);
|
|
6502
6884
|
const processed = await processFor(context.site, collection, input.markdown);
|
|
6503
6885
|
if (!processed.ok) return {
|
|
6504
6886
|
outcome: "refused",
|
|
@@ -6575,13 +6957,13 @@ const getEntry$1 = async (input, context) => {
|
|
|
6575
6957
|
return {
|
|
6576
6958
|
outcome: "ok",
|
|
6577
6959
|
entry: {
|
|
6578
|
-
...entrySummary(found),
|
|
6960
|
+
...forReaders(context, entrySummary(found)),
|
|
6579
6961
|
revision: revision.id,
|
|
6580
6962
|
verdict: verdictView(revision.verdict),
|
|
6581
6963
|
lastPublication: publicationView(publication),
|
|
6582
6964
|
...input.formats.includes("markdown") ? { markdown: revision.markdown } : {},
|
|
6583
|
-
...input.formats.includes("document") ? { document: revision.document } : {},
|
|
6584
|
-
...input.formats.includes("html") ? { html: await renderHtml(revision.document) } : {}
|
|
6965
|
+
...input.formats.includes("document") ? { document: forReaders(context, revision.document) } : {},
|
|
6966
|
+
...input.formats.includes("html") ? { html: await renderHtml(resolveAssetReferences(revision.document, assetUrlOf(context))) } : {}
|
|
6585
6967
|
},
|
|
6586
6968
|
...related === void 0 ? {} : { related }
|
|
6587
6969
|
};
|
|
@@ -6605,7 +6987,7 @@ const getTranslations$1 = async (input, context) => {
|
|
|
6605
6987
|
if (group.length === 0) return { outcome: "not_found" };
|
|
6606
6988
|
return {
|
|
6607
6989
|
outcome: "ok",
|
|
6608
|
-
entries: group.map(entrySummary)
|
|
6990
|
+
entries: group.map((entry) => forReaders(context, entrySummary(entry)))
|
|
6609
6991
|
};
|
|
6610
6992
|
};
|
|
6611
6993
|
//#endregion
|
|
@@ -6626,7 +7008,7 @@ const listEntries$1 = async (input, context) => {
|
|
|
6626
7008
|
});
|
|
6627
7009
|
return {
|
|
6628
7010
|
outcome: "ok",
|
|
6629
|
-
entries: page.entries.map(entrySummary),
|
|
7011
|
+
entries: page.entries.map((entry) => forReaders(context, entrySummary(entry))),
|
|
6630
7012
|
nextCursor: page.nextCursor
|
|
6631
7013
|
};
|
|
6632
7014
|
};
|
|
@@ -6711,7 +7093,7 @@ const search$1 = async (input, context) => {
|
|
|
6711
7093
|
outcome: "ok",
|
|
6712
7094
|
mode,
|
|
6713
7095
|
results: hits.map((hit) => ({
|
|
6714
|
-
entry: entrySummary(hit.entry),
|
|
7096
|
+
entry: forReaders(context, entrySummary(hit.entry)),
|
|
6715
7097
|
snippet: hit.snippet,
|
|
6716
7098
|
score: hit.score
|
|
6717
7099
|
}))
|
|
@@ -6764,11 +7146,11 @@ function siteEventFor(input) {
|
|
|
6764
7146
|
/** How many slugs of refused entries an answer names. */
|
|
6765
7147
|
const EXAMPLES = 3;
|
|
6766
7148
|
function structureOf(context) {
|
|
6767
|
-
if (context.structure === void 0) throw internalError(context, "An operation on the structure ran where the structure is not reachable.");
|
|
7149
|
+
if (context.structure === void 0) throw internalError$1(context, "An operation on the structure ran where the structure is not reachable.");
|
|
6768
7150
|
return context.structure;
|
|
6769
7151
|
}
|
|
6770
7152
|
function actor(context) {
|
|
6771
|
-
const { userId, meta } = actorOf(context);
|
|
7153
|
+
const { userId, meta } = actorOf$1(context);
|
|
6772
7154
|
return {
|
|
6773
7155
|
userId,
|
|
6774
7156
|
meta
|
|
@@ -6819,12 +7201,25 @@ async function impactOf(context, store, current, next) {
|
|
|
6819
7201
|
/** The definition of the site of the call, as stored, with its version. */
|
|
6820
7202
|
async function currentOf(context) {
|
|
6821
7203
|
const stored = await structureOf(context).store.read(context.site.key);
|
|
6822
|
-
if (stored === null) throw internalError(context, `The site ${context.site.key} is served and has no definition in D1.`);
|
|
7204
|
+
if (stored === null) throw internalError$1(context, `The site ${context.site.key} is served and has no definition in D1.`);
|
|
6823
7205
|
return {
|
|
6824
7206
|
version: stored.version,
|
|
6825
7207
|
definition: siteDefinitionData.parse(stored.definition)
|
|
6826
7208
|
};
|
|
6827
7209
|
}
|
|
7210
|
+
/**
|
|
7211
|
+
* A change sent at a version that is not the current one. It says which version is, and that the
|
|
7212
|
+
* change was not applied: whoever changed the structure since (often the caller's own earlier
|
|
7213
|
+
* update_site) made the definition that was read stale.
|
|
7214
|
+
*/
|
|
7215
|
+
function conflict(sent, current) {
|
|
7216
|
+
return {
|
|
7217
|
+
outcome: "conflict",
|
|
7218
|
+
currentVersion: current,
|
|
7219
|
+
message: `The structure is at version ${current}, not ${sent}: it changed since you read it, so nothing was applied.`,
|
|
7220
|
+
fix: `Call get_structure again, apply your change to the definition it returns (version ${current}), and send it with version ${current}. An earlier update_site or set_collection of yours also moves the version.`
|
|
7221
|
+
};
|
|
7222
|
+
}
|
|
6828
7223
|
/** Replaces the definition of the site of the call with `next`, as update_site answers. */
|
|
6829
7224
|
async function change(context, version, next, confirm) {
|
|
6830
7225
|
const structure = structureOf(context);
|
|
@@ -6834,10 +7229,7 @@ async function change(context, version, next, confirm) {
|
|
|
6834
7229
|
problems
|
|
6835
7230
|
};
|
|
6836
7231
|
const current = await currentOf(context);
|
|
6837
|
-
if (current.version !== version) return
|
|
6838
|
-
outcome: "conflict",
|
|
6839
|
-
currentVersion: current.version
|
|
6840
|
-
};
|
|
7232
|
+
if (current.version !== version) return conflict(version, current.version);
|
|
6841
7233
|
if (canonicalJson(current.definition) === canonicalJson(next)) return {
|
|
6842
7234
|
outcome: "unchanged",
|
|
6843
7235
|
version
|
|
@@ -6859,10 +7251,7 @@ async function change(context, version, next, confirm) {
|
|
|
6859
7251
|
definition: next,
|
|
6860
7252
|
actor: actor(context),
|
|
6861
7253
|
event
|
|
6862
|
-
}) === "conflict") return
|
|
6863
|
-
outcome: "conflict",
|
|
6864
|
-
currentVersion: (await currentOf(context)).version
|
|
6865
|
-
};
|
|
7254
|
+
}) === "conflict") return conflict(version, (await currentOf(context)).version);
|
|
6866
7255
|
context.notify?.([event.id]);
|
|
6867
7256
|
await structure.changed();
|
|
6868
7257
|
return {
|
|
@@ -6990,7 +7379,7 @@ const publish$1 = async (input, context) => {
|
|
|
6990
7379
|
const mayPublish = context.principal.scopes.includes("content:publish");
|
|
6991
7380
|
if (!mayPublish && (input.group.length > 0 || input.publishAt !== void 0)) throw new HollwareError("invalid_input", "Without content:publish, publish asks for a review of one entry, now: not of a group, and not at a time.", "Publish each entry on its own, without group and publishAt; a person who may publish approves it.");
|
|
6992
7381
|
if (input.group.length > 0) return publishGroup(input, context);
|
|
6993
|
-
const actor = actorOf(context);
|
|
7382
|
+
const actor = actorOf$1(context);
|
|
6994
7383
|
const found = await context.store.findEntryById(context.site.key, input.id, "head");
|
|
6995
7384
|
if (found === null) return { outcome: "not_found" };
|
|
6996
7385
|
const { entry, revision } = found;
|
|
@@ -7136,7 +7525,7 @@ function asPublished(lookup, ids) {
|
|
|
7136
7525
|
*/
|
|
7137
7526
|
async function publishGroup(input, context) {
|
|
7138
7527
|
if (input.publishAt !== void 0) throw new HollwareError("invalid_input", "A group cannot be scheduled: publishAt schedules one entry.", "Publish the group now, without publishAt, or schedule its entries one by one.");
|
|
7139
|
-
const actor = actorOf(context);
|
|
7528
|
+
const actor = actorOf$1(context);
|
|
7140
7529
|
const members = [{
|
|
7141
7530
|
id: input.id,
|
|
7142
7531
|
version: input.version
|
|
@@ -7323,7 +7712,7 @@ const approveReview = async (input, context) => {
|
|
|
7323
7712
|
};
|
|
7324
7713
|
/** Closes a pending review with a note, without publishing. */
|
|
7325
7714
|
const requestChanges = async (input, context) => {
|
|
7326
|
-
const { userId } = actorOf(context);
|
|
7715
|
+
const { userId } = actorOf$1(context);
|
|
7327
7716
|
const closed = await context.store.requestChanges(context.site.key, {
|
|
7328
7717
|
id: input.id,
|
|
7329
7718
|
note: input.note,
|
|
@@ -7342,7 +7731,7 @@ const requestChanges = async (input, context) => {
|
|
|
7342
7731
|
const commentReview = async (input, context) => {
|
|
7343
7732
|
const answer = await context.store.commentReview(context.site.key, {
|
|
7344
7733
|
reviewId: input.id,
|
|
7345
|
-
actor: actorOf(context),
|
|
7734
|
+
actor: actorOf$1(context),
|
|
7346
7735
|
body: input.body
|
|
7347
7736
|
});
|
|
7348
7737
|
return answer.outcome === "commented" ? {
|
|
@@ -7438,7 +7827,7 @@ const restoreEntry = async (input, context) => {
|
|
|
7438
7827
|
* leaves an entry that is not published as it is.
|
|
7439
7828
|
*/
|
|
7440
7829
|
const unpublish = async (input, context) => {
|
|
7441
|
-
const actor = actorOf(context);
|
|
7830
|
+
const actor = actorOf$1(context);
|
|
7442
7831
|
const found = await context.store.findEntryById(context.site.key, input.id, "head");
|
|
7443
7832
|
if (found === null) return { outcome: "not_found" };
|
|
7444
7833
|
const { entry } = found;
|
|
@@ -7482,7 +7871,7 @@ const unpublish = async (input, context) => {
|
|
|
7482
7871
|
* no revision and no version, which is what makes importing the same files twice a no-op.
|
|
7483
7872
|
*/
|
|
7484
7873
|
const updateDraft = async (input, context) => {
|
|
7485
|
-
const actor = actorOf(context);
|
|
7874
|
+
const actor = actorOf$1(context);
|
|
7486
7875
|
const found = await context.store.findEntryById(context.site.key, input.id, "head");
|
|
7487
7876
|
if (found === null) return { outcome: "not_found" };
|
|
7488
7877
|
const { entry, revision } = found;
|
|
@@ -7583,9 +7972,11 @@ const handlers = defineHandlers({
|
|
|
7583
7972
|
delete_entry: deleteEntry,
|
|
7584
7973
|
delete_site: deleteSite,
|
|
7585
7974
|
describe_site: describeSite$1,
|
|
7975
|
+
get_asset: getAsset,
|
|
7586
7976
|
get_entry: getEntry$1,
|
|
7587
7977
|
get_structure: getStructure,
|
|
7588
7978
|
get_translations: getTranslations$1,
|
|
7979
|
+
list_assets: listAssets,
|
|
7589
7980
|
list_entries: listEntries$1,
|
|
7590
7981
|
search: search$1,
|
|
7591
7982
|
list_reviews: listReviews,
|
|
@@ -7599,6 +7990,7 @@ const handlers = defineHandlers({
|
|
|
7599
7990
|
unpublish,
|
|
7600
7991
|
update_site: updateSite,
|
|
7601
7992
|
update_draft: updateDraft,
|
|
7993
|
+
upload_asset: uploadAsset,
|
|
7602
7994
|
validate
|
|
7603
7995
|
});
|
|
7604
7996
|
//#endregion
|