@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
@@ -135,20 +135,31 @@ export class D1ContentRepository {
135
135
  release.schema_package_version AS schemaPackageVersion, release.materializer_version AS materializerVersion,
136
136
  release.manifest_checksum AS manifestChecksum, entry.document_id AS documentId, entry.revision_id AS revisionId,
137
137
  document.kind, document.locale, revision.schema_version AS schemaVersion, revision.is_tombstone AS tombstone,
138
- revision.payload_json AS payloadJson, revision.payload_checksum AS payloadChecksum
138
+ revision.payload_json AS payloadJson, revision.payload_checksum AS payloadChecksum,
139
+ access.version_id AS accessVersionId, access.enabled AS accessEnabled, access.format_version AS accessFormatVersion,
140
+ access.key_id AS accessKeyId, access.nonce AS accessNonce, access.ciphertext AS accessCiphertext
139
141
  FROM content_releases release
140
142
  JOIN content_release_entries entry ON entry.release_id = release.release_id
141
143
  JOIN content_documents document ON document.document_id = entry.document_id
142
144
  JOIN content_revisions revision ON revision.document_id = entry.document_id AND revision.revision_id = entry.revision_id
145
+ LEFT JOIN content_release_site_access release_access ON release_access.release_id = release.release_id
146
+ LEFT JOIN site_access_versions access ON access.version_id = release_access.version_id
143
147
  WHERE release.release_id = ? AND release.status IN ('publishing', 'published')
144
148
  ORDER BY entry.document_id`).bind(releaseId).all()).results ?? [];
145
149
  if (!rows.length)
146
150
  return null;
147
151
  const [first] = rows;
152
+ let siteAccess = null;
153
+ if (first.accessVersionId && first.accessFormatVersion === 1) {
154
+ const covered = { versionId: first.accessVersionId, enabled: Boolean(first.accessEnabled), formatVersion: 1,
155
+ keyId: first.accessKeyId, nonce: first.accessNonce, ciphertext: first.accessCiphertext };
156
+ siteAccess = { ...covered, descriptorChecksum: await canonicalChecksum(covered) };
157
+ }
148
158
  return {
149
159
  releaseId: first.releaseId, status: first.status, schemaPackageVersion: first.schemaPackageVersion,
150
160
  materializerVersion: first.materializerVersion, manifestChecksum: first.manifestChecksum,
151
161
  entries: rows.map(({ documentId, revisionId, kind, locale, schemaVersion, tombstone, payloadJson, payloadChecksum }) => ({ documentId, revisionId, kind, locale, schemaVersion, tombstone: Boolean(tombstone), payloadJson, payloadChecksum })),
162
+ siteAccess,
152
163
  };
153
164
  }
154
165
  async save(input) {
@@ -183,12 +194,79 @@ export class D1ContentRepository {
183
194
  throw new Error("stale_revision");
184
195
  return "created";
185
196
  }
197
+ /** Multi-document editorial gestures (such as a Media rewrite) must not
198
+ * leave half their draft revisions behind when a later CAS discovers an
199
+ * editor's concurrent save. D1 batches are transactional, so we validate
200
+ * every identity and build every guarded statement before issuing one batch. */
201
+ async saveMany(inputs) {
202
+ if (!inputs.length || new Set(inputs.map((input) => input.documentId)).size !== inputs.length
203
+ || new Set(inputs.map((input) => input.revisionId)).size !== inputs.length)
204
+ throw new Error("invalid_document");
205
+ const statements = [];
206
+ const guards = [];
207
+ for (const input of inputs) {
208
+ const existing = await this.db.prepare("SELECT revision_id, request_checksum FROM content_revisions WHERE revision_id = ?").bind(input.revisionId).first();
209
+ if (existing) {
210
+ assertIdempotent({ requestChecksum: existing.request_checksum }, input.requestChecksum);
211
+ continue;
212
+ }
213
+ if ((input.payload === null) === !input.tombstone)
214
+ throw new Error("invalid_document");
215
+ const document = await this.db.prepare("SELECT document_id, kind, locale, draft_revision_id, abandoned_at FROM content_documents WHERE document_id = ?").bind(input.documentId).first();
216
+ if (document && (document.kind !== input.kind || document.locale !== input.locale))
217
+ throw new Error("invalid_document");
218
+ const expectedDraftRevisionId = input.expectedDraftRevisionId === undefined ? input.baseRevisionId : input.expectedDraftRevisionId;
219
+ if (document && document.draft_revision_id !== expectedDraftRevisionId)
220
+ throw new Error("stale_revision");
221
+ const payload = input.payload === null ? null : canonicalJson(input.payload);
222
+ guards.push({ documentId: input.documentId, kind: input.kind, locale: input.locale, expectedDraftRevisionId, revisionId: input.revisionId });
223
+ const hasBase = expectedDraftRevisionId !== null;
224
+ const guard = hasBase ? "draft_revision_id = ?" : "draft_revision_id IS NULL";
225
+ statements.push(this.db.prepare(`INSERT INTO content_documents (document_id, kind, locale, translation_group_id, draft_revision_id, created_at, updated_at)
226
+ SELECT ?, ?, ?, ?, NULL, ?, ? WHERE NOT EXISTS (SELECT 1 FROM content_documents WHERE document_id = ?)`)
227
+ .bind(input.documentId, input.kind, input.locale, input.translationGroupId ?? null, input.now, input.now, input.documentId));
228
+ statements.push(this.db.prepare(`INSERT INTO content_revisions (revision_id, document_id, base_revision_id, payload_json, is_tombstone, schema_version, payload_checksum, request_checksum, author_subject, author_label, created_at)
229
+ SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? FROM content_documents WHERE document_id = ? AND ${guard}`)
230
+ .bind(input.revisionId, input.documentId, input.baseRevisionId, payload, Number(input.tombstone), input.schemaVersion, input.payloadChecksum, input.requestChecksum, input.authorSubject, input.authorLabel, input.now, input.documentId, ...(hasBase ? [expectedDraftRevisionId] : [])));
231
+ statements.push(this.db.prepare(`UPDATE content_documents SET draft_revision_id = ?, updated_at = ?, abandoned_at = NULL WHERE document_id = ? AND ${guard}`)
232
+ .bind(input.revisionId, input.now, input.documentId, ...(hasBase ? [expectedDraftRevisionId] : [])));
233
+ }
234
+ if (!statements.length)
235
+ return;
236
+ const guardSql = guards.map(() => `(NOT EXISTS (SELECT 1 FROM content_documents WHERE document_id = ?) AND ? IS NULL
237
+ OR EXISTS (SELECT 1 FROM content_documents WHERE document_id = ? AND kind = ? AND locale = ? AND draft_revision_id IS ?))
238
+ AND NOT EXISTS (SELECT 1 FROM content_revisions WHERE revision_id = ?)`).join(" AND ");
239
+ /* A zero-row CAS is not a SQL error and therefore would not roll a D1
240
+ * batch back. This statement turns any stale precondition into a CHECK
241
+ * violation before the first revision write; singleton=2 can never be a
242
+ * valid site-state row. */
243
+ const abortOnStale = this.db.prepare(`INSERT INTO content_site_state
244
+ (singleton, current_release_id, schema_generation, updated_at)
245
+ SELECT 2, NULL, 0, ? WHERE NOT (${guardSql})`)
246
+ .bind(inputs[0].now, ...guards.flatMap((guard) => [guard.documentId, guard.expectedDraftRevisionId,
247
+ guard.documentId, guard.kind, guard.locale, guard.expectedDraftRevisionId, guard.revisionId]));
248
+ const results = await this.db.batch([abortOnStale, ...statements]);
249
+ for (let index = 1; index < results.length; index += 3) {
250
+ if (results[index + 1]?.meta?.changes !== 1 || results[index + 2]?.meta?.changes !== 1)
251
+ throw new Error("stale_revision");
252
+ }
253
+ }
186
254
  async discard(documentId, expectedDraftRevisionId, baselineRevisionId, now) {
187
255
  const expected = expectedDraftRevisionId === null ? "draft_revision_id IS NULL" : "draft_revision_id = ?";
188
256
  const set = baselineRevisionId === null ? null : baselineRevisionId;
189
257
  const update = this.db.prepare(`UPDATE content_documents SET draft_revision_id = ?, abandoned_at = CASE WHEN ? IS NULL THEN ? ELSE NULL END, updated_at = ? WHERE document_id = ? AND ${expected}`)
190
258
  .bind(set, set, now, now, documentId, ...(expectedDraftRevisionId === null ? [] : [expectedDraftRevisionId]));
191
- const [result] = await this.db.batch([update]);
259
+ /* Restoring a baseline can reintroduce a source that a ready destructive
260
+ * operation was about to remove. This lives in the same transaction as the
261
+ * pointer CAS: Discard still succeeds, but the operation becomes visibly
262
+ * blocked rather than deleting a newly referenced public asset. */
263
+ const blockReintroduced = this.db.prepare(`UPDATE media_operations SET state = 'blocked_in_use', updated_at = ?
264
+ WHERE state = 'ready' AND ? IS NOT NULL AND EXISTS (SELECT 1 FROM media_operation_entries entry
265
+ JOIN content_revisions baseline ON baseline.revision_id = ? AND baseline.document_id = ?
266
+ WHERE entry.operation_id = media_operations.operation_id AND baseline.payload_json IS NOT NULL
267
+ AND instr(baseline.payload_json, '/media/' || entry.source_key) > 0)`)
268
+ .bind(now, baselineRevisionId, baselineRevisionId, documentId);
269
+ const [result] = await this.db.batch([update, blockReintroduced]);
192
270
  if (result?.meta?.changes === 1)
193
271
  return;
194
272
  const current = await this.db.prepare("SELECT document_id, draft_revision_id, abandoned_at FROM content_documents WHERE document_id = ?").bind(documentId).first();
@@ -252,20 +330,63 @@ export class D1ContentRepository {
252
330
  : [];
253
331
  const manifest = new Map(inherited.map((entry) => [entry.document_id, entry.revision_id]));
254
332
  const selected = [...input.entries];
255
- if (!selected.length)
333
+ const mediaOperationIds = [...new Set(input.mediaOperationIds ?? [])];
334
+ if (!selected.length && !mediaOperationIds.length && !input.siteAccessVersionId)
256
335
  throw new Error("invalid_document");
257
336
  for (const entry of selected)
258
337
  manifest.set(entry.documentId, entry.revisionId);
259
- const selectionGuard = selected.map(() => "EXISTS (SELECT 1 FROM content_documents WHERE document_id = ? AND draft_revision_id = ?)").join(" AND ");
338
+ const selectionGuard = selected.length ? selected.map(() => "EXISTS (SELECT 1 FROM content_documents WHERE document_id = ? AND draft_revision_id = ?)").join(" AND ") : "1 = 1";
339
+ const accessGuard = input.siteAccessVersionId ? `AND EXISTS (SELECT 1 FROM site_access_versions version JOIN site_access_state access_state ON access_state.singleton = 1
340
+ WHERE version.version_id = ? AND version.state IN ('draft','active') AND version.version_id IN (access_state.draft_version_id, access_state.active_version_id))` : "";
260
341
  const candidate = this.db.prepare(`INSERT INTO content_releases (release_id, base_release_id, request_checksum, status, active_slot, manifest_checksum, schema_package_version, materializer_version, created_by_subject, created_at)
261
342
  SELECT ?, current_release_id, ?, 'publishing', 1, ?, ?, ?, ?, ? FROM content_site_state
262
343
  WHERE singleton = 1 AND current_release_id IS ? AND NOT EXISTS (SELECT 1 FROM content_releases WHERE active_slot = 1)
263
- AND NOT EXISTS (SELECT 1 FROM production_deployment_attempts WHERE active_slot = 1) AND ${selectionGuard}`)
264
- .bind(input.releaseId, input.requestChecksum, input.manifestChecksum, input.schemaPackageVersion, input.materializerVersion, input.authorSubject, input.now, state.current_release_id, ...selected.flatMap((entry) => [entry.documentId, entry.revisionId]));
265
- const statements = [candidate];
344
+ AND NOT EXISTS (SELECT 1 FROM production_deployment_attempts WHERE active_slot = 1) AND ${selectionGuard} ${accessGuard}`)
345
+ .bind(input.releaseId, input.requestChecksum, input.manifestChecksum, input.schemaPackageVersion, input.materializerVersion, input.authorSubject, input.now, state.current_release_id, ...selected.flatMap((entry) => [entry.documentId, entry.revisionId]), ...(input.siteAccessVersionId ? [input.siteAccessVersionId] : []));
346
+ const statements = [];
347
+ if (mediaOperationIds.length) {
348
+ const revisionIds = [...manifest.values()];
349
+ const eligible = mediaOperationIds.map(() => `(
350
+ EXISTS (SELECT 1 FROM media_operations WHERE operation_id = ? AND state = 'ready')
351
+ AND NOT EXISTS (SELECT 1 FROM media_operation_entries source
352
+ JOIN content_revisions revision ON revision.revision_id IN (${revisionIds.map(() => "?").join(",")})
353
+ WHERE source.operation_id = ? AND revision.payload_json IS NOT NULL
354
+ AND instr(revision.payload_json, '/media/' || source.source_key) > 0))`).join(" AND ");
355
+ /* As with multi-document Save, zero affected rows do not abort a D1
356
+ * batch. Convert an ineligible Media binding into a CHECK violation
357
+ * before the candidate exists, so no release/attempt can commit alone. */
358
+ statements.push(this.db.prepare(`INSERT INTO content_site_state
359
+ (singleton, current_release_id, schema_generation, updated_at)
360
+ SELECT 2, NULL, 0, ? WHERE NOT (${eligible})`)
361
+ .bind(input.now, ...mediaOperationIds.flatMap((operationId) => [operationId, ...revisionIds, operationId])));
362
+ }
363
+ const candidateIndex = statements.length;
364
+ statements.push(candidate);
266
365
  for (const [documentId, revisionId] of manifest)
267
366
  statements.push(this.db.prepare("INSERT INTO content_release_entries (release_id, document_id, revision_id) SELECT ?, ?, ? WHERE EXISTS (SELECT 1 FROM content_revisions WHERE document_id = ? AND revision_id = ?)")
268
367
  .bind(input.releaseId, documentId, revisionId, documentId, revisionId));
368
+ if (input.siteAccessVersionId)
369
+ statements.push(this.db.prepare(`INSERT INTO content_release_site_access (release_id, version_id)
370
+ SELECT ?, ? WHERE EXISTS (SELECT 1 FROM site_access_versions version JOIN site_access_state access_state ON access_state.singleton = 1
371
+ WHERE version.version_id = ? AND version.state IN ('draft','active') AND version.version_id IN (access_state.draft_version_id, access_state.active_version_id))`)
372
+ .bind(input.releaseId, input.siteAccessVersionId, input.siteAccessVersionId));
373
+ for (const operationId of mediaOperationIds) {
374
+ /* `instr` is intentionally evaluated against the frozen release entries,
375
+ * not drafts or a mutable current pointer. A URL is JSON-escaped neither
376
+ * by `/` nor by the canonical serializer, so the exact R2 public prefix
377
+ * remains a reliable conservative guard. */
378
+ statements.push(this.db.prepare(`UPDATE media_operations SET state = 'bound', updated_at = ?
379
+ WHERE operation_id = ? AND state = 'ready'
380
+ AND NOT EXISTS (SELECT 1 FROM media_operation_entries source
381
+ JOIN content_release_entries entry ON entry.release_id = ?
382
+ JOIN content_revisions revision ON revision.document_id = entry.document_id AND revision.revision_id = entry.revision_id
383
+ WHERE source.operation_id = media_operations.operation_id
384
+ AND revision.payload_json IS NOT NULL AND instr(revision.payload_json, '/media/' || source.source_key) > 0)`)
385
+ .bind(input.now, operationId, input.releaseId));
386
+ statements.push(this.db.prepare(`INSERT INTO content_release_media_operations (release_id, operation_id)
387
+ SELECT ?, ? WHERE EXISTS (SELECT 1 FROM media_operations WHERE operation_id = ? AND state = 'bound')`)
388
+ .bind(input.releaseId, operationId, operationId));
389
+ }
269
390
  statements.push(this.db.prepare(`INSERT INTO production_deployment_attempts
270
391
  (attempt_id, intent_kind, requested_release_id, requested_code_sha, code_sha, release_id, expected_fingerprint, phase, active_slot, created_at, updated_at)
271
392
  SELECT ?, ?, ?, ?, ?, ?, ?, 'waiting', 1, ?, ?
@@ -273,7 +394,7 @@ export class D1ContentRepository {
273
394
  AND EXISTS (SELECT 1 FROM content_releases WHERE release_id = ? AND status = 'publishing')`)
274
395
  .bind(input.attempt.attemptId, input.attempt.intentKind, input.attempt.requestedReleaseId, input.attempt.requestedCodeSha, input.attempt.codeSha, input.releaseId, input.attempt.expectedFingerprint, input.now, input.now, input.releaseId));
275
396
  const results = await this.db.batch(statements);
276
- if (results[0]?.meta?.changes !== 1 || results.slice(1, -1).some((result) => result.meta?.changes !== 1)
397
+ if (results[candidateIndex]?.meta?.changes !== 1 || results.slice(candidateIndex + 1, -1).some((result) => result.meta?.changes !== 1)
277
398
  || results.at(-1)?.meta?.changes !== 1)
278
399
  throw new Error("deployment_lane_busy");
279
400
  return "created";
package/dist/release.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type JsonValue } from "./persistence.js";
2
+ import type { SiteAccessReleaseDescriptor } from "./site-access.js";
2
3
  export type ReleaseExportEntry = {
3
4
  documentId: string;
4
5
  revisionId: string;
@@ -15,6 +16,7 @@ export type ImmutableReleaseExport = {
15
16
  materializerVersion: string;
16
17
  manifestChecksum: string;
17
18
  entries: readonly ReleaseExportEntry[];
19
+ siteAccess?: SiteAccessReleaseDescriptor | null;
18
20
  };
19
21
  /** Raw D1 snapshot. The repository returns this exact release only; converting
20
22
  * it to an export revalidates every payload/checksum before a CI consumer sees
@@ -28,6 +30,7 @@ export type StoredReleaseExport = {
28
30
  entries: readonly (Omit<ReleaseExportEntry, "payload"> & {
29
31
  payloadJson: string | null;
30
32
  })[];
33
+ siteAccess?: SiteAccessReleaseDescriptor | null;
31
34
  };
32
35
  /** A release export is an immutable, sorted full snapshot. It is never a
33
36
  * convenience view of current drafts, so a CI attempt can reproduce exactly
package/dist/release.js CHANGED
@@ -17,10 +17,16 @@ export async function createReleaseExport(input) {
17
17
  if (entry.payloadChecksum !== await canonicalChecksum(entry.payload))
18
18
  throw new Error("invalid_release_export");
19
19
  }
20
+ if (input.siteAccess) {
21
+ const { descriptorChecksum, ...covered } = input.siteAccess;
22
+ if (descriptorChecksum !== await canonicalChecksum(covered))
23
+ throw new Error("invalid_release_export");
24
+ }
20
25
  const manifestChecksum = await canonicalChecksum({
21
26
  schemaPackageVersion: input.schemaPackageVersion,
22
27
  materializerVersion: input.materializerVersion,
23
28
  entries: entries.map(({ documentId, revisionId, kind, locale, schemaVersion, tombstone, payloadChecksum }) => ({ documentId, revisionId, kind, locale, schemaVersion, tombstone, payloadChecksum })),
29
+ siteAccess: input.siteAccess ? { versionId: input.siteAccess.versionId, descriptorChecksum: input.siteAccess.descriptorChecksum } : null,
24
30
  });
25
31
  return { ...input, entries, manifestChecksum };
26
32
  }
@@ -34,6 +40,7 @@ export async function releaseExportFromStored(snapshot) {
34
40
  schemaVersion: entry.schemaVersion, tombstone: entry.tombstone,
35
41
  payload: entry.payloadJson === null ? null : JSON.parse(entry.payloadJson), payloadChecksum: entry.payloadChecksum,
36
42
  })),
43
+ ...(snapshot.siteAccess ? { siteAccess: snapshot.siteAccess } : {}),
37
44
  });
38
45
  if (release.manifestChecksum !== snapshot.manifestChecksum)
39
46
  throw new Error("invalid_release_export");
@@ -0,0 +1,48 @@
1
+ import { type D1DatabaseLike } from "./persistence.js";
2
+ export declare const siteAccessPbkdf2Iterations = 100000;
3
+ export type SiteAccessReleaseDescriptor = Readonly<{
4
+ versionId: string;
5
+ enabled: boolean;
6
+ formatVersion: 1;
7
+ keyId: string | null;
8
+ nonce: string | null;
9
+ ciphertext: string | null;
10
+ descriptorChecksum: string;
11
+ }>;
12
+ export type SiteAccessState = Readonly<{
13
+ active: SiteAccessReleaseDescriptor | null;
14
+ draft: SiteAccessReleaseDescriptor | null;
15
+ }>;
16
+ export declare function createSiteAccessVersion(input: {
17
+ versionId: string;
18
+ siteId: string;
19
+ enabled: boolean;
20
+ password?: string;
21
+ keyring?: unknown;
22
+ }): Promise<SiteAccessReleaseDescriptor>;
23
+ export declare function verifySiteAccessPassword(input: {
24
+ siteId: string;
25
+ access: SiteAccessReleaseDescriptor;
26
+ keyring: unknown;
27
+ password: string;
28
+ }): Promise<boolean>;
29
+ export declare function siteAccessSessionToken(input: {
30
+ siteId: string;
31
+ access: SiteAccessReleaseDescriptor;
32
+ keyring: unknown;
33
+ }): Promise<string>;
34
+ export declare class D1SiteAccessRepository {
35
+ private readonly db;
36
+ constructor(db: D1DatabaseLike);
37
+ initialize(now: number): Promise<void>;
38
+ state(): Promise<SiteAccessState>;
39
+ saveDraft(input: {
40
+ access: SiteAccessReleaseDescriptor;
41
+ expectedDraftVersionId: string | null;
42
+ requestChecksum: string;
43
+ authorSubject: string;
44
+ authorLabel: string;
45
+ now: number;
46
+ }): Promise<void>;
47
+ discard(expectedDraftVersionId: string, now: number): Promise<void>;
48
+ }
@@ -0,0 +1,180 @@
1
+ import { canonicalChecksum, canonicalJson } from "./persistence.js";
2
+ const encoder = new TextEncoder();
3
+ const decoder = new TextDecoder();
4
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
5
+ const keyIdPattern = /^[A-Za-z0-9._-]{1,80}$/;
6
+ export const siteAccessPbkdf2Iterations = 100_000;
7
+ function asBuffer(bytes) {
8
+ return Uint8Array.from(bytes).buffer;
9
+ }
10
+ function bytesToBase64url(bytes) {
11
+ let binary = "";
12
+ for (const byte of bytes)
13
+ binary += String.fromCharCode(byte);
14
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, "");
15
+ }
16
+ function base64urlToBytes(value) {
17
+ if (!/^[A-Za-z0-9_-]+$/.test(value || ""))
18
+ throw new Error("site_access_unavailable");
19
+ const padded = value.replaceAll("-", "+").replaceAll("_", "/") + "=".repeat((4 - value.length % 4) % 4);
20
+ try {
21
+ const binary = atob(padded);
22
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
23
+ }
24
+ catch {
25
+ throw new Error("site_access_unavailable");
26
+ }
27
+ }
28
+ function parseKeyring(raw) {
29
+ let parsed;
30
+ try {
31
+ parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
32
+ }
33
+ catch {
34
+ throw new Error("site_access_unavailable");
35
+ }
36
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
37
+ throw new Error("site_access_unavailable");
38
+ const value = parsed;
39
+ if (typeof value.current !== "string" || !keyIdPattern.test(value.current) || !value.keys || typeof value.keys !== "object" || Array.isArray(value.keys)) {
40
+ throw new Error("site_access_unavailable");
41
+ }
42
+ const keys = {};
43
+ for (const [keyId, encoded] of Object.entries(value.keys)) {
44
+ if (!keyIdPattern.test(keyId) || typeof encoded !== "string")
45
+ throw new Error("site_access_unavailable");
46
+ const bytes = base64urlToBytes(encoded);
47
+ if (bytes.byteLength !== 32)
48
+ throw new Error("site_access_unavailable");
49
+ keys[keyId] = bytes;
50
+ }
51
+ if (!keys[value.current])
52
+ throw new Error("site_access_unavailable");
53
+ return { current: value.current, keys };
54
+ }
55
+ async function hkdfKey(root, siteId, keyId, purpose, algorithm, usages) {
56
+ const material = await crypto.subtle.importKey("raw", asBuffer(root), "HKDF", false, ["deriveKey"]);
57
+ return crypto.subtle.deriveKey({
58
+ name: "HKDF", hash: "SHA-256",
59
+ salt: encoder.encode("adminkit.site-access.v1"),
60
+ info: encoder.encode(`${purpose}|${siteId}|${keyId}`),
61
+ }, material, algorithm, false, usages);
62
+ }
63
+ function aad(siteId, versionId) {
64
+ return encoder.encode(`adminkit.site-access.v1|${siteId}|${versionId}`);
65
+ }
66
+ async function descriptor(input) {
67
+ if (!uuid.test(input.versionId) || input.formatVersion !== 1)
68
+ throw new Error("invalid_site_access");
69
+ if (input.enabled !== Boolean(input.keyId && input.nonce && input.ciphertext))
70
+ throw new Error("invalid_site_access");
71
+ const descriptorChecksum = await canonicalChecksum(input);
72
+ return { ...input, descriptorChecksum };
73
+ }
74
+ export async function createSiteAccessVersion(input) {
75
+ if (!input.enabled)
76
+ return descriptor({ versionId: input.versionId, enabled: false, formatVersion: 1, keyId: null, nonce: null, ciphertext: null });
77
+ if (typeof input.password !== "string" || input.password.length < 8 || input.password.length > 200)
78
+ throw new Error("invalid_site_access_password");
79
+ const keyring = parseKeyring(input.keyring);
80
+ const salt = crypto.getRandomValues(new Uint8Array(16));
81
+ const passwordKey = await crypto.subtle.importKey("raw", encoder.encode(input.password), "PBKDF2", false, ["deriveBits"]);
82
+ const hash = new Uint8Array(await crypto.subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt: asBuffer(salt), iterations: siteAccessPbkdf2Iterations }, passwordKey, 256));
83
+ const plaintext = encoder.encode(canonicalJson({ version: 1, kdf: "PBKDF2-SHA256", iterations: siteAccessPbkdf2Iterations, salt: bytesToBase64url(salt), hash: bytesToBase64url(hash) }));
84
+ const nonce = crypto.getRandomValues(new Uint8Array(12));
85
+ const encryptionKey = await hkdfKey(keyring.keys[keyring.current], input.siteId, keyring.current, "verifier", { name: "AES-GCM", length: 256 }, ["encrypt"]);
86
+ const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: asBuffer(nonce), additionalData: asBuffer(aad(input.siteId, input.versionId)), tagLength: 128 }, encryptionKey, plaintext));
87
+ return descriptor({ versionId: input.versionId, enabled: true, formatVersion: 1, keyId: keyring.current, nonce: bytesToBase64url(nonce), ciphertext: bytesToBase64url(ciphertext) });
88
+ }
89
+ async function decryptVerifier(input) {
90
+ const keyring = parseKeyring(input.keyring);
91
+ const keyId = input.access.keyId || "";
92
+ const root = keyring.keys[keyId];
93
+ if (!root || !input.access.nonce || !input.access.ciphertext)
94
+ throw new Error("site_access_unavailable");
95
+ try {
96
+ const key = await hkdfKey(root, input.siteId, keyId, "verifier", { name: "AES-GCM", length: 256 }, ["decrypt"]);
97
+ const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv: asBuffer(base64urlToBytes(input.access.nonce)), additionalData: asBuffer(aad(input.siteId, input.access.versionId)), tagLength: 128 }, key, asBuffer(base64urlToBytes(input.access.ciphertext)));
98
+ const value = JSON.parse(decoder.decode(plaintext));
99
+ if (value?.version !== 1 || value?.kdf !== "PBKDF2-SHA256" || value?.iterations !== siteAccessPbkdf2Iterations)
100
+ throw new Error("site_access_unavailable");
101
+ return { salt: base64urlToBytes(value.salt), hash: base64urlToBytes(value.hash), keyring, keyId };
102
+ }
103
+ catch {
104
+ throw new Error("site_access_unavailable");
105
+ }
106
+ }
107
+ function constantTimeBytes(left, right) {
108
+ let difference = left.byteLength ^ right.byteLength;
109
+ for (let index = 0; index < Math.max(left.byteLength, right.byteLength); index += 1)
110
+ difference |= (left[index] || 0) ^ (right[index] || 0);
111
+ return difference === 0;
112
+ }
113
+ export async function verifySiteAccessPassword(input) {
114
+ if (!input.access.enabled)
115
+ return false;
116
+ const verifier = await decryptVerifier(input);
117
+ const passwordKey = await crypto.subtle.importKey("raw", encoder.encode(input.password), "PBKDF2", false, ["deriveBits"]);
118
+ const candidate = new Uint8Array(await crypto.subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt: asBuffer(verifier.salt), iterations: siteAccessPbkdf2Iterations }, passwordKey, 256));
119
+ return constantTimeBytes(candidate, verifier.hash);
120
+ }
121
+ export async function siteAccessSessionToken(input) {
122
+ if (!input.access.enabled || !input.access.keyId)
123
+ throw new Error("site_access_unavailable");
124
+ const keyring = parseKeyring(input.keyring);
125
+ const root = keyring.keys[input.access.keyId];
126
+ if (!root)
127
+ throw new Error("site_access_unavailable");
128
+ const key = await hkdfKey(root, input.siteId, input.access.keyId, "session", { name: "HMAC", hash: "SHA-256" }, ["sign"]);
129
+ const signature = new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(`session|${input.access.versionId}`)));
130
+ return `${input.access.versionId}.${bytesToBase64url(signature)}`;
131
+ }
132
+ async function rowDescriptor(row) {
133
+ return row ? descriptor({ versionId: row.versionId, enabled: Boolean(row.enabled), formatVersion: row.formatVersion, keyId: row.keyId, nonce: row.nonce, ciphertext: row.ciphertext }) : null;
134
+ }
135
+ export class D1SiteAccessRepository {
136
+ db;
137
+ constructor(db) {
138
+ this.db = db;
139
+ }
140
+ async initialize(now) {
141
+ await this.db.prepare("INSERT INTO site_access_state (singleton, active_version_id, draft_version_id, updated_at) VALUES (1, NULL, NULL, ?) ON CONFLICT(singleton) DO NOTHING").bind(now).run();
142
+ }
143
+ async state() {
144
+ const rows = (await this.db.prepare(`SELECT version.version_id AS versionId, version.enabled, version.format_version AS formatVersion,
145
+ version.key_id AS keyId, version.nonce, version.ciphertext, version.state
146
+ FROM site_access_versions version JOIN site_access_state state ON state.singleton = 1
147
+ WHERE version.version_id IN (state.active_version_id, state.draft_version_id)`).all()).results ?? [];
148
+ return {
149
+ active: await rowDescriptor(rows.find((row) => row.state === "active") ?? null),
150
+ draft: await rowDescriptor(rows.find((row) => row.state === "draft") ?? null),
151
+ };
152
+ }
153
+ async saveDraft(input) {
154
+ const current = await this.db.prepare("SELECT draft_version_id AS draftVersionId FROM site_access_state WHERE singleton = 1").first();
155
+ if (!current || current.draftVersionId !== input.expectedDraftVersionId)
156
+ throw new Error("stale_site_access");
157
+ const statements = [];
158
+ if (input.expectedDraftVersionId)
159
+ statements.push(this.db.prepare(`UPDATE site_access_versions SET state = 'erased', erased_at = ?
160
+ WHERE version_id = ? AND state = 'draft'`).bind(input.now, input.expectedDraftVersionId));
161
+ statements.push(this.db.prepare(`INSERT INTO site_access_versions
162
+ (version_id, state, enabled, format_version, key_id, nonce, ciphertext, request_checksum, created_by_subject, created_by_label, created_at)
163
+ SELECT ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ? FROM site_access_state
164
+ WHERE singleton = 1 AND draft_version_id IS ?`)
165
+ .bind(input.access.versionId, Number(input.access.enabled), input.access.formatVersion, input.access.keyId, input.access.nonce, input.access.ciphertext, input.requestChecksum, input.authorSubject, input.authorLabel, input.now, input.expectedDraftVersionId));
166
+ statements.push(this.db.prepare("UPDATE site_access_state SET draft_version_id = ?, updated_at = ? WHERE singleton = 1 AND draft_version_id IS ?")
167
+ .bind(input.access.versionId, input.now, input.expectedDraftVersionId));
168
+ const results = await this.db.batch(statements);
169
+ if (results.at(-2)?.meta?.changes !== 1 || results.at(-1)?.meta?.changes !== 1)
170
+ throw new Error("stale_site_access");
171
+ }
172
+ async discard(expectedDraftVersionId, now) {
173
+ const results = await this.db.batch([
174
+ this.db.prepare("UPDATE site_access_versions SET state = 'erased', erased_at = ? WHERE version_id = ? AND state = 'draft'").bind(now, expectedDraftVersionId),
175
+ this.db.prepare("UPDATE site_access_state SET draft_version_id = NULL, updated_at = ? WHERE singleton = 1 AND draft_version_id = ?").bind(now, expectedDraftVersionId),
176
+ ]);
177
+ if (results[0]?.meta?.changes !== 1 || results[1]?.meta?.changes !== 1)
178
+ throw new Error("stale_site_access");
179
+ }
180
+ }
package/dist/theme.css CHANGED
@@ -291,6 +291,7 @@ a.ak-btn { text-decoration: none; display: inline-flex; align-items: center; }
291
291
  .mf-root-drop.is-over { border-color: var(--ak-accent); color: var(--ak-accent); background: var(--ak-panel); }
292
292
  /* Il cestino chiude la colonna, staccato: è un posto dove si va, non una cartella fra le altre. */
293
293
  .mf-tail { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--ak-line); }
294
+ .mf-settings-tail { margin-top: 8px; }
294
295
  .mf-ico { flex: none; color: var(--ak-ink-quiet); }
295
296
  .mf-item.is-on .mf-ico { color: var(--ak-accent); }
296
297
 
@@ -370,6 +371,18 @@ a.ak-btn { text-decoration: none; display: inline-flex; align-items: center; }
370
371
  background: var(--ak-panel); border: 1px solid var(--ak-accent);
371
372
  font: 500 var(--ak-fs-small)/1.4 var(--ak-font); color: var(--ak-ink);
372
373
  }
374
+ .mg-journal { display: flex; flex-direction: column; gap: 6px; }
375
+ .mg-journal-row {
376
+ display: flex; align-items: center; gap: 9px; flex-wrap: wrap;
377
+ margin: 0; padding: 8px 11px; border: 1px solid var(--ak-line); border-radius: var(--ak-radius);
378
+ background: var(--ak-panel); font: 500 var(--ak-fs-small)/1.4 var(--ak-font); color: var(--ak-ink);
379
+ }
380
+ .mg-journal-row.is-blocked_in_use, .mg-journal-row.is-completed_with_warning { border-color: var(--ak-accent); }
381
+ .mg-journal-row.is-failed { border-color: var(--ak-danger); }
382
+ .mg-journal-badge {
383
+ padding: 3px 6px; border-radius: 999px; background: var(--ak-bg);
384
+ color: var(--ak-ink-quiet); font: 600 10px/1 var(--ak-font); text-transform: uppercase; letter-spacing: .05em;
385
+ }
373
386
 
374
387
  /* Celle di ingombro uguale, immagine INTERA dentro. Prima erano quadrati con `object-fit: cover`:
375
388
  un verticale sembrava un quadrato e per riconoscere una foto bisognava aprirla. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cardinali-studio/adminkit",
3
- "version": "2.2.10",
3
+ "version": "2.2.12",
4
4
  "type": "module",
5
5
  "description": "Motore riusabile di un pannello di amministrazione per siti Hugo su Cloudflare Pages: codec del front matter guidato dallo schema, form React, libreria media su R2. NIENTE di specifico di un sito qui dentro.",
6
6
  "license": "UNLICENSED",
@@ -99,5 +99,8 @@
99
99
  "typescript": "^5.9.3",
100
100
  "wrangler": "4.114.0",
101
101
  "zod": "^4.4.3"
102
+ },
103
+ "dependencies": {
104
+ "heic-to": "^1.3.0"
102
105
  }
103
106
  }