@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.
- package/dist/api.d.ts +58 -2
- package/dist/api.js +39 -5
- package/dist/app/App.js +10 -4
- package/dist/app/app.css +5 -0
- package/dist/app/lib/publish.js +9 -1
- package/dist/app/lib/settings.d.ts +2 -0
- package/dist/app/lib/settings.js +54 -8
- package/dist/app/routes/MediaSettingsEditor.d.ts +4 -0
- package/dist/app/routes/MediaSettingsEditor.js +4 -0
- package/dist/app/routes/SiteSettingsEditor.d.ts +3 -1
- package/dist/app/routes/SiteSettingsEditor.js +3 -2
- package/dist/app/routes/UploadDock.js +31 -9
- package/dist/app/site.d.ts +4 -0
- package/dist/deployment.js +14 -1
- package/dist/form/media/MediaFolders.d.ts +5 -1
- package/dist/form/media/MediaFolders.js +3 -2
- package/dist/form/media/MediaGrid.js +10 -1
- package/dist/form/media/image-conversion.d.ts +14 -0
- package/dist/form/media/image-conversion.js +163 -0
- package/dist/form/media/media-upload-queue.d.ts +31 -0
- package/dist/form/media/media-upload-queue.js +230 -0
- package/dist/form/media/useMediaLibrary.d.ts +7 -0
- package/dist/form/media/useMediaLibrary.js +123 -22
- package/dist/functions/_shared/assets.js +3 -2
- package/dist/functions/_shared/d1-media.js +317 -0
- package/dist/functions/api/assets/delete.js +19 -1
- package/dist/functions/api/assets/folders.js +5 -2
- package/dist/functions/api/assets/media.js +30 -0
- package/dist/functions/api/assets/move.js +22 -6
- package/dist/functions/api/assets/settings.js +40 -0
- package/dist/functions/api/assets/upload.js +4 -1
- package/dist/functions/api/assets/usage.js +5 -1
- package/dist/functions/api/assets.js +7 -4
- package/dist/functions/api/content-ci.js +8 -1
- package/dist/functions/api/site-access.js +78 -0
- package/dist/functions/index.js +6 -0
- package/dist/functions/site.js +4 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/media.d.ts +52 -0
- package/dist/media.js +103 -1
- package/dist/migrations/0004_site_access.sql +41 -0
- package/dist/persistence.d.ts +10 -0
- package/dist/persistence.js +129 -8
- package/dist/release.d.ts +3 -0
- package/dist/release.js +7 -0
- package/dist/site-access.d.ts +48 -0
- package/dist/site-access.js +180 -0
- package/dist/theme.css +13 -0
- package/package.json +4 -1
package/dist/persistence.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 = [
|
|
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[
|
|
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.
|
|
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
|
}
|