@cardinali-studio/adminkit 2.2.10 → 2.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/api.d.ts +58 -2
  2. package/dist/api.js +39 -5
  3. package/dist/app/App.js +10 -4
  4. package/dist/app/app.css +5 -0
  5. package/dist/app/lib/publish.js +9 -1
  6. package/dist/app/lib/settings.d.ts +2 -0
  7. package/dist/app/lib/settings.js +54 -8
  8. package/dist/app/routes/MediaSettingsEditor.d.ts +4 -0
  9. package/dist/app/routes/MediaSettingsEditor.js +4 -0
  10. package/dist/app/routes/SiteSettingsEditor.d.ts +3 -1
  11. package/dist/app/routes/SiteSettingsEditor.js +3 -2
  12. package/dist/app/routes/UploadDock.js +31 -9
  13. package/dist/app/site.d.ts +4 -0
  14. package/dist/deployment.js +14 -1
  15. package/dist/form/media/MediaFolders.d.ts +5 -1
  16. package/dist/form/media/MediaFolders.js +3 -2
  17. package/dist/form/media/MediaGrid.js +10 -1
  18. package/dist/form/media/image-conversion.d.ts +14 -0
  19. package/dist/form/media/image-conversion.js +163 -0
  20. package/dist/form/media/media-upload-queue.d.ts +31 -0
  21. package/dist/form/media/media-upload-queue.js +230 -0
  22. package/dist/form/media/useMediaLibrary.d.ts +7 -0
  23. package/dist/form/media/useMediaLibrary.js +123 -22
  24. package/dist/functions/_shared/assets.js +3 -2
  25. package/dist/functions/_shared/d1-media.js +317 -0
  26. package/dist/functions/api/assets/delete.js +19 -1
  27. package/dist/functions/api/assets/folders.js +5 -2
  28. package/dist/functions/api/assets/media.js +30 -0
  29. package/dist/functions/api/assets/move.js +22 -6
  30. package/dist/functions/api/assets/settings.js +40 -0
  31. package/dist/functions/api/assets/upload.js +4 -1
  32. package/dist/functions/api/assets/usage.js +5 -1
  33. package/dist/functions/api/assets.js +7 -4
  34. package/dist/functions/api/content-ci.js +8 -1
  35. package/dist/functions/api/site-access.js +78 -0
  36. package/dist/functions/index.js +6 -0
  37. package/dist/functions/site.js +4 -0
  38. package/dist/index.d.ts +1 -0
  39. package/dist/index.js +1 -0
  40. package/dist/media.d.ts +52 -0
  41. package/dist/media.js +103 -1
  42. package/dist/migrations/0004_site_access.sql +41 -0
  43. package/dist/persistence.d.ts +10 -0
  44. package/dist/persistence.js +129 -8
  45. package/dist/release.d.ts +3 -0
  46. package/dist/release.js +7 -0
  47. package/dist/site-access.d.ts +48 -0
  48. package/dist/site-access.js +180 -0
  49. package/dist/theme.css +13 -0
  50. package/package.json +4 -1
@@ -14,6 +14,8 @@ import { mediaUrl } from "../../_shared/refs.js";
14
14
  import { rewriteMediaReferences } from "../../_shared/media-content.js";
15
15
  import { getBase } from "../../_shared/github.js";
16
16
  import { finalizePendingMedia, recordPendingMedia } from "../../_shared/pending-media.js";
17
+ import { contentPersistenceMode } from "../../site.js";
18
+ import { d1MoveMedia } from "../../_shared/d1-media.js";
17
19
 
18
20
  function mediaKey(url) {
19
21
  if (typeof url !== "string" || !url.startsWith("/media/")) return null;
@@ -128,8 +130,27 @@ async function confirmRewrittenCommit(env, commitSha) {
128
130
  }
129
131
  }
130
132
 
131
- export async function onRequestPost({ request, env }) {
133
+ export async function onRequestPost({ request, env, data }) {
132
134
  if (!env.MEDIA) return json({ ok: false, error: "Binding MEDIA non configurato" }, 500);
135
+ let body;
136
+ try { body = await request.json(); } catch { return json({ ok: false, error: "JSON non valido" }, 400); }
137
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
138
+ return json({ ok: false, error: "Corpo richiesta non valido" }, 400);
139
+ }
140
+
141
+ if (contentPersistenceMode() === "d1") {
142
+ /* The former two-step Git protocol must never become a second editorial
143
+ * writer after cutover. A stale browser's applyRefs call is harmless: the
144
+ * first D1 command already created the revisions and journal intent. */
145
+ if (Object.hasOwn(body, "applyRefs")) return json({ ok: true, moved: 0, pagesUpdated: [], failed: [], d1: true });
146
+ try {
147
+ const result = await d1MoveMedia({ env, data, body });
148
+ return json({ ok: true, ...result, pagesUpdated: result.documents.map((document) => document.kind), failed: [], warning: "Le sorgenti restano disponibili fino alla pubblicazione." });
149
+ } catch (error) {
150
+ const code = error?.message === "unauthenticated" ? "unauthenticated" : error?.message === "media_source_busy" ? "media_source_busy" : "media_operation_unavailable";
151
+ return json({ ok: false, code, error: code === "media_source_busy" ? "Questo Media è già in un'operazione in corso." : "Non riesco a registrare l'operazione Media D1." }, code === "unauthenticated" ? 401 : 409);
152
+ }
153
+ }
133
154
  if (!env.GITHUB_TOKEN || !env.GITHUB_REPO) {
134
155
  return json({
135
156
  ok: false,
@@ -137,11 +158,6 @@ export async function onRequestPost({ request, env }) {
137
158
  error: "Non posso spostare media senza aggiornare i riferimenti nel sito.",
138
159
  }, 503);
139
160
  }
140
- let body;
141
- try { body = await request.json(); } catch { return json({ ok: false, error: "JSON non valido" }, 400); }
142
- if (!body || typeof body !== "object" || Array.isArray(body)) {
143
- return json({ ok: false, error: "Corpo richiesta non valido" }, 400);
144
- }
145
161
 
146
162
  /* Seconda fase: le copie esistono già. Prima le verifichiamo TUTTE, poi aggiorniamo il repo,
147
163
  * infine cancelliamo gli originali. */
@@ -0,0 +1,40 @@
1
+ /* GET/POST /admin/api/assets/settings — preferenze condivise della libreria Media.
2
+ *
3
+ * Vivono nello stesso bucket delle immagini: sono per sito, visibili da qualunque browser e
4
+ * non richiedono un commit/build per cambiare un comportamento del solo pannello.
5
+ */
6
+ import { json } from "../../_shared/assets.js";
7
+
8
+ const KEY = "_adminkit/settings.json";
9
+ const defaults = { autoWebp: true };
10
+
11
+ async function read(env) {
12
+ const object = await env.MEDIA.get(KEY);
13
+ if (!object) return defaults;
14
+ try {
15
+ const stored = await object.json();
16
+ return { autoWebp: stored?.autoWebp !== false };
17
+ } catch {
18
+ return defaults;
19
+ }
20
+ }
21
+
22
+ export async function onRequestGet({ env }) {
23
+ if (!env.MEDIA) return json({ ok: false, error: "Binding MEDIA non configurato" }, 500);
24
+ return json({ ok: true, settings: await read(env) });
25
+ }
26
+
27
+ export async function onRequestPost({ request, env }) {
28
+ if (!env.MEDIA) return json({ ok: false, error: "Binding MEDIA non configurato" }, 500);
29
+ let body;
30
+ try { body = await request.json(); }
31
+ catch { return json({ ok: false, error: "JSON non valido" }, 400); }
32
+ if (typeof body?.autoWebp !== "boolean") {
33
+ return json({ ok: false, error: "autoWebp deve essere booleano" }, 400);
34
+ }
35
+ const settings = { autoWebp: body.autoWebp };
36
+ await env.MEDIA.put(KEY, JSON.stringify(settings), {
37
+ httpMetadata: { contentType: "application/json" },
38
+ });
39
+ return json({ ok: true, settings });
40
+ }
@@ -22,7 +22,10 @@ export async function onRequestPost({ request, env }) {
22
22
 
23
23
  // data di scatto (EXIF) per l'ordinamento "Data scatto"
24
24
  const custom = {};
25
- if (ctype === "image/jpeg") {
25
+ const suppliedCapturedAt = request.headers.get("X-Captured-At") || "";
26
+ if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(suppliedCapturedAt)) {
27
+ custom.capturedAt = suppliedCapturedAt;
28
+ } else if (ctype === "image/jpeg") {
26
29
  const cap = extractCapturedAt(body);
27
30
  if (cap) custom.capturedAt = cap;
28
31
  }
@@ -7,10 +7,11 @@ import { mediaUrl } from "../../_shared/refs.js";
7
7
  import {
8
8
  readMediaSources, summarizeMediaUsage, planMediaRewrite,
9
9
  } from "../../_shared/media-content.js";
10
+ import { contentPersistenceMode } from "../../site.js";
11
+ import { d1MediaUsage } from "../../_shared/d1-media.js";
10
12
 
11
13
  export async function onRequestPost({ request, env }) {
12
14
  if (!env.MEDIA) return json({ ok: false, error: "Binding MEDIA non configurato" }, 500);
13
- if (!env.GITHUB_TOKEN || !env.GITHUB_REPO) return json({ ok: false, error: "backend GitHub non configurato" }, 500);
14
15
  let body;
15
16
  try { body = await request.json(); } catch { return json({ ok: false, error: "JSON non valido" }, 400); }
16
17
  if (!body || typeof body !== "object" || Array.isArray(body)) {
@@ -27,6 +28,9 @@ export async function onRequestPost({ request, env }) {
27
28
  return json({ ok: true, usage: {}, pages: [], count: 0, selectedCount: 0, blockers: [] });
28
29
  }
29
30
 
31
+ if (contentPersistenceMode() === "d1") return json({ ok: true, ...(await d1MediaUsage(env.CONTENT_DB, keyList)) });
32
+ if (!env.GITHUB_TOKEN || !env.GITHUB_REPO) return json({ ok: false, error: "backend GitHub non configurato" }, 500);
33
+
30
34
  const { sources } = await readMediaSources(env);
31
35
  const summary = summarizeMediaUsage(sources, keyList, mediaUrl);
32
36
  const plan = planMediaRewrite(sources, { removeUrls: keyList.map(mediaUrl) });
@@ -4,6 +4,8 @@
4
4
  import { isPrefix, json, listKeysUnder } from "../_shared/assets.js";
5
5
  import { sizeFromBucket } from "../_shared/imagesize.js";
6
6
  import { pendingSourceKeys } from "../_shared/pending-media.js";
7
+ import { contentPersistenceMode } from "../site.js";
8
+ import { d1PendingSourceKeys } from "../_shared/d1-media.js";
7
9
 
8
10
  const IMG = /\.(webp|jpe?g|png|avif|gif)$/i;
9
11
 
@@ -12,7 +14,7 @@ export async function onRequestGet({ request, env }) {
12
14
  const url = new URL(request.url);
13
15
  let prefix = (url.searchParams.get("prefix") || "").trim();
14
16
  if (!isPrefix(prefix)) return json({ ok: false, error: `prefisso non valido: ${prefix}` }, 400);
15
- const pending = await pendingSourceKeys(env.MEDIA);
17
+ const pending = contentPersistenceMode() === "d1" ? await d1PendingSourceKeys(env.CONTENT_DB) : await pendingSourceKeys(env.MEDIA);
16
18
 
17
19
  /* `recursive=1` guarda TUTTO quello che c'è sotto, a qualunque profondità: serve a chi deve
18
20
  * spostare o cancellare una cartella intera, che altrimenti si porterebbe dietro solo il primo
@@ -23,7 +25,8 @@ export async function onRequestGet({ request, env }) {
23
25
  const subPrefixes = new Set();
24
26
  const files = [];
25
27
  for (const k of all) {
26
- if (k.startsWith("_trash/") || k.startsWith("_pending-media/") || pending.has(k)) continue;
28
+ if (k.startsWith("_trash/") || k.startsWith("_pending-media/") ||
29
+ k.startsWith("_adminkit/") || pending.has(k)) continue;
27
30
  const rel = k.slice(prefix.length);
28
31
  const cut = rel.lastIndexOf("/");
29
32
  if (cut >= 0) {
@@ -46,7 +49,7 @@ export async function onRequestGet({ request, env }) {
46
49
  // suo `.keep` (vedi assets/folder.js). Chi non ce l'ha mostra lo slug, come sempre.
47
50
  const folders = await Promise.all(
48
51
  (listed.delimitedPrefixes || [])
49
- .filter((p) => p !== "_trash/" && p !== "_pending-media/")
52
+ .filter((p) => p !== "_trash/" && p !== "_pending-media/" && p !== "_adminkit/")
50
53
  .map(async (p) => {
51
54
  const keep = await env.MEDIA.head(p + ".keep");
52
55
  return {
@@ -64,7 +67,7 @@ export async function onRequestGet({ request, env }) {
64
67
  // oggi. È un ripiego, non la strada normale: una volta ricaricate, il ramo non scatta più.
65
68
  const raw = (listed.objects || [])
66
69
  .filter((o) => o.key !== prefix && !o.key.endsWith("/.keep") && !o.key.endsWith("/") &&
67
- !o.key.startsWith("_pending-media/") && !pending.has(o.key));
70
+ !o.key.startsWith("_pending-media/") && !o.key.startsWith("_adminkit/") && !pending.has(o.key));
68
71
 
69
72
  const files = await Promise.all(raw.map(async (o) => {
70
73
  const cm = o.customMetadata || {};
@@ -5,6 +5,7 @@ import {
5
5
  isTerminalDeploymentPhase,
6
6
  } from "../../deployment.js";
7
7
  import { deploymentFingerprint, releaseExportFromStored } from "../../release.js";
8
+ import { finalizeD1MediaRelease } from "../_shared/d1-media.js";
8
9
 
9
10
  const MAX_BODY_BYTES = 8_000;
10
11
  const claimLifetimeMs = 20 * 60 * 1_000;
@@ -147,7 +148,13 @@ export async function handleInternalContentCi({ request, env }) {
147
148
  }
148
149
  if (path === "/internal/deployment/finalize" && request.method === "POST") {
149
150
  await attempts.finalize({ ...identity, intentKind: attempt.intentKind, tokenChecksum: checksum, now });
150
- return response({ ok: true, attempt: { ...identity, phase: "succeeded" } });
151
+ /* The release is already authoritative once this returns. Media cleanup
152
+ * is deliberately best-effort and journaled: a warning must not pretend
153
+ * to roll back a verified public deployment. */
154
+ const media = attempt.intentKind === "content"
155
+ ? await finalizeD1MediaRelease({ env, releaseId: identity.releaseId, now }).catch(() => ({ completed: 0, failed: 0, warning: true }))
156
+ : undefined;
157
+ return response({ ok: true, attempt: { ...identity, phase: "succeeded" }, ...(media ? { media } : {}) });
151
158
  }
152
159
  if (path === "/internal/deployment/fail" && request.method === "POST") {
153
160
  const detail = failure[input.code];
@@ -0,0 +1,78 @@
1
+ import { canonicalChecksum } from "../../persistence.js";
2
+ import { createSiteAccessVersion, D1SiteAccessRepository } from "../../site-access.js";
3
+ import { siteAccessId } from "../site.js";
4
+ import { json } from "../_shared/assets.js";
5
+
6
+ const MAX_BODY_BYTES = 2_000;
7
+ const id = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
8
+
9
+ function response(data, status = 200) {
10
+ const result = json(data, status);
11
+ result.headers.set("Cache-Control", "no-store");
12
+ return result;
13
+ }
14
+
15
+ function editor(data, env) {
16
+ const payload = data?.cloudflareAccess?.JWT?.payload;
17
+ if (payload?.sub) return { subject: String(payload.sub), label: String(payload.email || payload.name || "Admin").slice(0, 200) };
18
+ if (env.ADMIN_LOCAL === "1") return { subject: "local-preview", label: "Anteprima locale" };
19
+ throw new Error("unauthenticated");
20
+ }
21
+
22
+ function required(env) {
23
+ const siteId = siteAccessId();
24
+ if (!env.CONTENT_DB || !siteId) throw new Error("site_access_unavailable");
25
+ return { siteId, repository: new D1SiteAccessRepository(env.CONTENT_DB) };
26
+ }
27
+
28
+ function publicState(state) {
29
+ const clean = (value) => value ? { versionId: value.versionId, enabled: value.enabled, formatVersion: value.formatVersion } : null;
30
+ return { active: clean(state.active), draft: clean(state.draft), effective: clean(state.draft ?? state.active) };
31
+ }
32
+
33
+ function safeError(error) {
34
+ const code = error?.message;
35
+ const status = code === "unauthenticated" ? 401 : code === "invalid_request" || code === "invalid_site_access_password" ? 400
36
+ : code === "stale_site_access" ? 409 : 503;
37
+ const message = code === "unauthenticated" ? "Sessione non valida: ricarica la pagina per rientrare."
38
+ : code === "invalid_site_access_password" ? "La password deve contenere almeno 8 caratteri."
39
+ : code === "stale_site_access" ? "La protezione è stata modificata altrove: ricarica la pagina."
40
+ : status === 400 ? "Richiesta non valida." : "La gestione della password non è disponibile.";
41
+ return response({ ok: false, code: status >= 500 ? "site_access_unavailable" : code, error: message }, status);
42
+ }
43
+
44
+ export async function onRequestGet({ env, data }) {
45
+ try {
46
+ editor(data, env);
47
+ const { repository } = required(env);
48
+ await repository.initialize(Date.now());
49
+ return response({ ok: true, state: publicState(await repository.state()) });
50
+ } catch (error) { return safeError(error); }
51
+ }
52
+
53
+ export async function onRequestPost({ request, env, data }) {
54
+ try {
55
+ const origin = request.headers.get("Origin");
56
+ if (!origin || new URL(origin).origin !== new URL(request.url).origin) throw new Error("invalid_request");
57
+ if (Number(request.headers.get("Content-Length") || 0) > MAX_BODY_BYTES) throw new Error("invalid_request");
58
+ const raw = await request.text();
59
+ if (new TextEncoder().encode(raw).byteLength > MAX_BODY_BYTES) throw new Error("invalid_request");
60
+ const body = JSON.parse(raw);
61
+ const author = editor(data, env);
62
+ const { siteId, repository } = required(env);
63
+ await repository.initialize(Date.now());
64
+ if (body?.action === "discard") {
65
+ if (!id.test(body.expectedDraftVersionId || "")) throw new Error("invalid_request");
66
+ await repository.discard(body.expectedDraftVersionId, Date.now());
67
+ return response({ ok: true, discarded: true, state: publicState(await repository.state()) });
68
+ }
69
+ if (body?.action !== "save" || typeof body.enabled !== "boolean"
70
+ || (body.expectedDraftVersionId !== null && !id.test(body.expectedDraftVersionId || ""))) throw new Error("invalid_request");
71
+ const versionId = crypto.randomUUID();
72
+ const access = await createSiteAccessVersion({ versionId, siteId, enabled: body.enabled, password: body.password, keyring: env.SITE_ACCESS_KEYRING });
73
+ const requestChecksum = await canonicalChecksum({ action: "site-access.save", versionId, enabled: access.enabled, descriptorChecksum: access.descriptorChecksum });
74
+ await repository.saveDraft({ access, expectedDraftVersionId: body.expectedDraftVersionId, requestChecksum,
75
+ authorSubject: author.subject, authorLabel: author.label, now: Date.now() });
76
+ return response({ ok: true, saved: true, state: publicState(await repository.state()) });
77
+ } catch (error) { return safeError(error); }
78
+ }
@@ -30,7 +30,9 @@ import * as assetsDelete from "./api/assets/delete.js";
30
30
  import * as assetsFolder from "./api/assets/folder.js";
31
31
  import * as assetsFolders from "./api/assets/folders.js";
32
32
  import * as assetsMove from "./api/assets/move.js";
33
+ import * as assetsMedia from "./api/assets/media.js";
33
34
  import * as assetsPreview from "./api/assets/preview.js";
35
+ import * as assetsSettings from "./api/assets/settings.js";
34
36
  import * as assetsTrash from "./api/assets/trash.js";
35
37
  import * as assetsTrashDelete from "./api/assets/trash/delete.js";
36
38
  import * as assetsTrashEmpty from "./api/assets/trash/empty.js";
@@ -49,6 +51,7 @@ import * as settings from "./api/settings.js";
49
51
  import * as singleton from "./api/singleton.js";
50
52
  import * as storage from "./api/storage.js";
51
53
  import * as content from "./api/content.js";
54
+ import * as siteAccess from "./api/site-access.js";
52
55
 
53
56
  export { adminAccess } from "./_middleware.js";
54
57
  export { setServerConfig } from "./site.js";
@@ -66,7 +69,9 @@ const ROUTES = {
66
69
  "assets/folder": { mod: null, h: assetsFolder },
67
70
  "assets/folders": { mod: null, h: assetsFolders },
68
71
  "assets/move": { mod: null, h: assetsMove },
72
+ "assets/media": { mod: null, h: assetsMedia },
69
73
  "assets/preview": { mod: null, h: assetsPreview },
74
+ "assets/settings": { mod: null, h: assetsSettings },
70
75
  "assets/trash": { mod: null, h: assetsTrash },
71
76
  "assets/trash/delete": { mod: null, h: assetsTrashDelete },
72
77
  "assets/trash/empty": { mod: null, h: assetsTrashEmpty },
@@ -88,6 +93,7 @@ const ROUTES = {
88
93
  "content/document": { mod: null, h: content },
89
94
  "content/save": { mod: null, h: content },
90
95
  "content/discard": { mod: null, h: content },
96
+ "content/site-access": { mod: null, h: siteAccess },
91
97
  };
92
98
 
93
99
  const PREFIX = "/admin/api/";
@@ -58,6 +58,10 @@ export function contentPersistenceMode() {
58
58
  return current?.contentPersistence === "d1" ? "d1" : "github";
59
59
  }
60
60
 
61
+ export function siteAccessId() {
62
+ return typeof current?.siteAccess?.siteId === "string" && current.siteAccess.siteId ? current.siteAccess.siteId : null;
63
+ }
64
+
61
65
  /** D1 owns persistence, never a site's editorial meaning. A site explicitly
62
66
  * supplies both functions before its D1 mode can accept a write: one validates
63
67
  * (and may normalize) the complete document and the other produces the
package/dist/index.d.ts CHANGED
@@ -14,3 +14,4 @@ export * from "./conflict.js";
14
14
  export * from "./release.js";
15
15
  export * from "./deployment.js";
16
16
  export * from "./media.js";
17
+ export * from "./site-access.js";
package/dist/index.js CHANGED
@@ -14,3 +14,4 @@ export * from "./conflict.js";
14
14
  export * from "./release.js";
15
15
  export * from "./deployment.js";
16
16
  export * from "./media.js";
17
+ export * from "./site-access.js";
package/dist/media.d.ts CHANGED
@@ -52,6 +52,30 @@ export type CreateMediaOperation = Readonly<{
52
52
  now: number;
53
53
  entries: readonly MediaOperationEntryInput[];
54
54
  }>;
55
+ export type BoundMediaEntry = Readonly<{
56
+ operationId: string;
57
+ type: MediaOperationType;
58
+ operationState: MediaOperationState;
59
+ entryId: string;
60
+ sourceKey: string;
61
+ destinationKey: string | null;
62
+ finalizationState: "pending" | "completed" | "failed" | "cancelled";
63
+ }>;
64
+ export type MediaOperationStatus = Readonly<{
65
+ operationId: string;
66
+ type: MediaOperationType;
67
+ state: MediaOperationState;
68
+ releaseId: string | null;
69
+ updatedAt: number;
70
+ failedEntries: number;
71
+ }>;
72
+ export type StoredMediaOperationEntry = Readonly<{
73
+ entryId: string;
74
+ sourceKey: string;
75
+ destinationKey: string | null;
76
+ preparationState: string;
77
+ finalizationState: string;
78
+ }>;
55
79
  /** SQL journal adapter. R2 copy/delete happens around this adapter; it never
56
80
  * infers an object as removable simply because a caller stopped mentioning it. */
57
81
  export declare class D1MediaOperationRepository {
@@ -83,6 +107,34 @@ export declare class D1MediaOperationRepository {
83
107
  }): Promise<"pending" | "settled">;
84
108
  blockReintroducedSources(sourceKeys: readonly string[], now: number): Promise<number>;
85
109
  cancel(operationId: string, now: number): Promise<void>;
110
+ /** Sources are hidden from the normal library only while an unresolved
111
+ * operation owns them. The query deliberately never derives removability
112
+ * from this list. */
113
+ activeSourceKeys(): Promise<readonly string[]>;
114
+ /** Candidates bind every ready operation whose source has been rewritten;
115
+ * the release repository repeats the frozen-manifest no-source guard in the
116
+ * same transaction as the binding. */
117
+ readyOperationIds(): Promise<readonly string[]>;
118
+ /** A Discard block is reversible only when the current effective documents
119
+ * no longer contain any owned source. Re-evaluation changes journal state;
120
+ * it never synthesizes or rewrites content. */
121
+ refreshBlockedOperations(now: number): Promise<number>;
122
+ statuses(): Promise<readonly MediaOperationStatus[]>;
123
+ entries(operationId: string): Promise<readonly StoredMediaOperationEntry[]>;
124
+ /** Work is named by its immutable release binding: there is intentionally no
125
+ * "latest operation" cleanup path. */
126
+ finalizationWork(releaseId: string): Promise<readonly BoundMediaEntry[]>;
127
+ /** A warning is a live release with incomplete housekeeping. Retrying resets
128
+ * only failed effects, never an already-completed entry. */
129
+ resumeFinalization(operationId: string, releaseId: string, now: number): Promise<void>;
130
+ readyDeleteEntry(input: {
131
+ operationId: string;
132
+ entryId: string;
133
+ now: number;
134
+ objectChecksum: string;
135
+ etag: string | null;
136
+ metadataJson: string | null;
137
+ }): Promise<"pending" | "ready">;
86
138
  }
87
139
  type R2Metadata = Record<string, string>;
88
140
  export type MediaR2Object = {
package/dist/media.js CHANGED
@@ -164,7 +164,7 @@ export class D1MediaOperationRepository {
164
164
  }
165
165
  async beginFinalization(operationId, releaseId, now) {
166
166
  const result = await this.db.prepare(`UPDATE media_operations SET state = 'finalizing', updated_at = ?
167
- WHERE operation_id = ? AND state = 'bound' AND EXISTS (SELECT 1 FROM content_release_media_operations
167
+ WHERE operation_id = ? AND state IN ('bound', 'finalizing') AND EXISTS (SELECT 1 FROM content_release_media_operations
168
168
  WHERE operation_id = ? AND release_id = ?) AND EXISTS (SELECT 1 FROM content_releases
169
169
  WHERE release_id = ? AND status = 'published')`)
170
170
  .bind(now, operationId, operationId, releaseId, releaseId).run();
@@ -213,6 +213,108 @@ export class D1MediaOperationRepository {
213
213
  if (results[0]?.meta?.changes !== 1)
214
214
  throw new Error("media_operation_unavailable");
215
215
  }
216
+ /** Sources are hidden from the normal library only while an unresolved
217
+ * operation owns them. The query deliberately never derives removability
218
+ * from this list. */
219
+ async activeSourceKeys() {
220
+ const rows = (await this.db.prepare(`SELECT DISTINCT entry.source_key AS sourceKey
221
+ FROM media_operation_entries entry
222
+ JOIN media_operations operation ON operation.operation_id = entry.operation_id
223
+ WHERE operation.state IN ('ready', 'bound', 'finalizing', 'completed_with_warning')
224
+ AND entry.finalization_state NOT IN ('completed', 'cancelled')
225
+ ORDER BY entry.source_key`).all()).results ?? [];
226
+ return rows.map((row) => row.sourceKey);
227
+ }
228
+ /** Candidates bind every ready operation whose source has been rewritten;
229
+ * the release repository repeats the frozen-manifest no-source guard in the
230
+ * same transaction as the binding. */
231
+ async readyOperationIds() {
232
+ await this.refreshBlockedOperations(Date.now());
233
+ const rows = (await this.db.prepare("SELECT operation_id AS operationId FROM media_operations WHERE state = 'ready' ORDER BY operation_id")
234
+ .all()).results ?? [];
235
+ return rows.map((row) => row.operationId);
236
+ }
237
+ /** A Discard block is reversible only when the current effective documents
238
+ * no longer contain any owned source. Re-evaluation changes journal state;
239
+ * it never synthesizes or rewrites content. */
240
+ async refreshBlockedOperations(now) {
241
+ const result = await this.db.prepare(`UPDATE media_operations SET state = 'ready', updated_at = ?
242
+ WHERE state = 'blocked_in_use' AND NOT EXISTS (SELECT 1 FROM media_operation_entries source
243
+ JOIN content_documents document
244
+ LEFT JOIN content_revisions draft ON draft.document_id = document.document_id AND draft.revision_id = document.draft_revision_id
245
+ LEFT JOIN content_site_state site_state ON site_state.singleton = 1
246
+ LEFT JOIN content_release_entries published_entry ON published_entry.release_id = site_state.current_release_id
247
+ AND published_entry.document_id = document.document_id
248
+ LEFT JOIN content_revisions published ON published.document_id = published_entry.document_id
249
+ AND published.revision_id = published_entry.revision_id
250
+ WHERE source.operation_id = media_operations.operation_id
251
+ AND instr(COALESCE(draft.payload_json, published.payload_json, ''), '/media/' || source.source_key) > 0)`)
252
+ .bind(now).run();
253
+ return Number(result.meta?.changes ?? 0);
254
+ }
255
+ async statuses() {
256
+ return (await this.db.prepare(`SELECT operation.operation_id AS operationId, operation.type, operation.state,
257
+ binding.release_id AS releaseId, operation.updated_at AS updatedAt,
258
+ SUM(CASE WHEN entry.finalization_state = 'failed' THEN 1 ELSE 0 END) AS failedEntries
259
+ FROM media_operations operation
260
+ LEFT JOIN content_release_media_operations binding ON binding.operation_id = operation.operation_id
261
+ JOIN media_operation_entries entry ON entry.operation_id = operation.operation_id
262
+ WHERE operation.state NOT IN ('completed', 'cancelled')
263
+ GROUP BY operation.operation_id, operation.type, operation.state, binding.release_id, operation.updated_at
264
+ ORDER BY operation.updated_at DESC, operation.operation_id`).all()).results ?? [];
265
+ }
266
+ async entries(operationId) {
267
+ return (await this.db.prepare(`SELECT entry_id AS entryId, source_key AS sourceKey,
268
+ destination_key AS destinationKey, preparation_state AS preparationState,
269
+ finalization_state AS finalizationState
270
+ FROM media_operation_entries WHERE operation_id = ? ORDER BY entry_id`)
271
+ .bind(operationId).all()).results ?? [];
272
+ }
273
+ /** Work is named by its immutable release binding: there is intentionally no
274
+ * "latest operation" cleanup path. */
275
+ async finalizationWork(releaseId) {
276
+ const rows = (await this.db.prepare(`SELECT operation.operation_id AS operationId, operation.type, operation.state AS operationState,
277
+ entry.entry_id AS entryId, entry.source_key AS sourceKey, entry.destination_key AS destinationKey,
278
+ entry.finalization_state AS finalizationState
279
+ FROM content_release_media_operations binding
280
+ JOIN media_operations operation ON operation.operation_id = binding.operation_id
281
+ JOIN media_operation_entries entry ON entry.operation_id = operation.operation_id
282
+ WHERE binding.release_id = ? AND operation.state IN ('bound', 'finalizing', 'completed_with_warning')
283
+ AND entry.finalization_state IN ('pending', 'failed')
284
+ ORDER BY operation.operation_id, entry.entry_id`).bind(releaseId).all()).results ?? [];
285
+ return rows;
286
+ }
287
+ /** A warning is a live release with incomplete housekeeping. Retrying resets
288
+ * only failed effects, never an already-completed entry. */
289
+ async resumeFinalization(operationId, releaseId, now) {
290
+ const resume = this.db.prepare(`UPDATE media_operations SET state = 'finalizing', updated_at = ?, retry_count = retry_count + 1
291
+ WHERE operation_id = ? AND state = 'completed_with_warning'
292
+ AND EXISTS (SELECT 1 FROM content_release_media_operations WHERE operation_id = ? AND release_id = ?)
293
+ AND EXISTS (SELECT 1 FROM content_releases WHERE release_id = ? AND status = 'published')`)
294
+ .bind(now, operationId, operationId, releaseId, releaseId);
295
+ const reset = this.db.prepare(`UPDATE media_operation_entries SET finalization_state = 'pending', safe_error = NULL
296
+ WHERE operation_id = ? AND finalization_state = 'failed'
297
+ AND EXISTS (SELECT 1 FROM media_operations WHERE operation_id = ? AND state = 'finalizing')`)
298
+ .bind(operationId, operationId);
299
+ const results = await this.db.batch([resume, reset]);
300
+ if (results[0]?.meta?.changes !== 1)
301
+ throw new Error("media_operation_unavailable");
302
+ }
303
+ async readyDeleteEntry(input) {
304
+ const entry = this.db.prepare(`UPDATE media_operation_entries
305
+ SET object_checksum = ?, etag = ?, metadata_json = ?, preparation_state = 'ready'
306
+ WHERE operation_id = ? AND entry_id = ? AND preparation_state = 'pending'
307
+ AND EXISTS (SELECT 1 FROM media_operations WHERE operation_id = ? AND type = 'delete' AND state = 'preparing')`)
308
+ .bind(input.objectChecksum, input.etag, input.metadataJson, input.operationId, input.entryId, input.operationId);
309
+ const ready = this.db.prepare(`UPDATE media_operations SET state = 'ready', updated_at = ?
310
+ WHERE operation_id = ? AND type = 'delete' AND state = 'preparing' AND NOT EXISTS (SELECT 1 FROM media_operation_entries
311
+ WHERE operation_id = ? AND preparation_state != 'ready')`)
312
+ .bind(input.now, input.operationId, input.operationId);
313
+ const results = await this.db.batch([entry, ready]);
314
+ if (results[0]?.meta?.changes !== 1)
315
+ throw new Error("media_operation_unavailable");
316
+ return results[1]?.meta?.changes === 1 ? "ready" : "pending";
317
+ }
216
318
  }
217
319
  const MEDIA_CHECKSUM_METADATA = "adminkit-sha256";
218
320
  async function bytesChecksum(bytes) {
@@ -0,0 +1,41 @@
1
+ PRAGMA foreign_keys = ON;
2
+
3
+ CREATE TABLE site_access_versions (
4
+ version_id TEXT PRIMARY KEY,
5
+ state TEXT NOT NULL CHECK (state IN ('draft','active','superseded','expired','erased')),
6
+ enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
7
+ format_version INTEGER NOT NULL CHECK (format_version = 1),
8
+ key_id TEXT,
9
+ nonce TEXT,
10
+ ciphertext TEXT,
11
+ request_checksum TEXT NOT NULL UNIQUE CHECK (length(request_checksum) = 64 AND request_checksum NOT GLOB '*[^0-9a-f]*'),
12
+ created_by_subject TEXT NOT NULL,
13
+ created_by_label TEXT NOT NULL,
14
+ created_at INTEGER NOT NULL,
15
+ activated_at INTEGER,
16
+ superseded_at INTEGER,
17
+ rollback_expires_at INTEGER,
18
+ erased_at INTEGER,
19
+ CHECK ((enabled = 0 AND key_id IS NULL AND nonce IS NULL AND ciphertext IS NULL)
20
+ OR (enabled = 1 AND key_id IS NOT NULL AND nonce IS NOT NULL AND ciphertext IS NOT NULL))
21
+ );
22
+ CREATE UNIQUE INDEX site_access_one_draft ON site_access_versions(state) WHERE state = 'draft';
23
+ CREATE UNIQUE INDEX site_access_one_active ON site_access_versions(state) WHERE state = 'active';
24
+ CREATE TRIGGER site_access_versions_immutable
25
+ BEFORE UPDATE OF version_id, enabled, format_version, key_id, nonce, ciphertext, request_checksum,
26
+ created_by_subject, created_by_label, created_at ON site_access_versions
27
+ BEGIN SELECT RAISE(ABORT, 'site access version fields are immutable'); END;
28
+
29
+ CREATE TABLE site_access_state (
30
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
31
+ active_version_id TEXT REFERENCES site_access_versions(version_id) ON DELETE RESTRICT,
32
+ draft_version_id TEXT REFERENCES site_access_versions(version_id) ON DELETE RESTRICT,
33
+ updated_at INTEGER NOT NULL
34
+ );
35
+
36
+ CREATE TABLE content_release_site_access (
37
+ release_id TEXT PRIMARY KEY REFERENCES content_releases(release_id) ON DELETE RESTRICT,
38
+ version_id TEXT NOT NULL REFERENCES site_access_versions(version_id) ON DELETE RESTRICT
39
+ );
40
+ CREATE TRIGGER content_release_site_access_immutable BEFORE UPDATE ON content_release_site_access
41
+ BEGIN SELECT RAISE(ABORT, 'release site access binding is immutable'); END;
@@ -115,6 +115,11 @@ export type ReleaseInput = {
115
115
  documentId: string;
116
116
  revisionId: string;
117
117
  }[];
118
+ /** Operations are bound in the same D1 transaction as the immutable
119
+ * manifest. The SQL guard rejects any candidate still containing a source
120
+ * URL, so a caller cannot make cleanup race a public reference. */
121
+ mediaOperationIds?: readonly string[];
122
+ siteAccessVersionId?: string | null;
118
123
  };
119
124
  export type ReleaseDeploymentInput = ReleaseInput & {
120
125
  attempt: {
@@ -157,6 +162,11 @@ export declare class D1ContentRepository {
157
162
  * no "latest" variant: a deployment attempt must name what it exports. */
158
163
  getReleaseExport(releaseId: string): Promise<StoredReleaseExport | null>;
159
164
  save(input: SaveInput): Promise<"created" | "repeated">;
165
+ /** Multi-document editorial gestures (such as a Media rewrite) must not
166
+ * leave half their draft revisions behind when a later CAS discovers an
167
+ * editor's concurrent save. D1 batches are transactional, so we validate
168
+ * every identity and build every guarded statement before issuing one batch. */
169
+ saveMany(inputs: readonly SaveInput[]): Promise<void>;
160
170
  discard(documentId: string, expectedDraftRevisionId: string | null, baselineRevisionId: string | null, now: number): Promise<void>;
161
171
  publish(input: ReleaseInput): Promise<"created" | "repeated">;
162
172
  /** Candidate release and deployment-lane reservation are inseparable: a