@cardinali-studio/adminkit 2.1.0 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api.d.ts +62 -1
- package/dist/api.js +19 -2
- package/dist/app/app.css +21 -0
- package/dist/app/lib/content-persistence.d.ts +9 -0
- package/dist/app/lib/content-persistence.js +25 -0
- package/dist/app/lib/home.js +24 -2
- package/dist/app/lib/publish.js +12 -0
- package/dist/app/routes/SiteSettingsEditor.js +5 -4
- package/dist/app/site.d.ts +11 -0
- package/dist/app/site.js +4 -0
- package/dist/app/ui/ConflictResolutionDialog.d.ts +8 -0
- package/dist/app/ui/ConflictResolutionDialog.js +30 -0
- package/dist/conflict.d.ts +30 -0
- package/dist/conflict.js +48 -0
- package/dist/deployment.d.ts +92 -0
- package/dist/deployment.js +180 -0
- package/dist/functions/api/content-ci.js +141 -0
- package/dist/functions/api/content.js +140 -0
- package/dist/functions/index.js +9 -1
- package/dist/functions/site.js +28 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/materializer.d.ts +20 -0
- package/dist/materializer.js +27 -0
- package/dist/media.d.ts +136 -0
- package/dist/media.js +270 -0
- package/dist/migrations/0001_content_persistence.sql +128 -0
- package/dist/migrations/0002_content_ci_claims.sql +6 -0
- package/dist/migrations/0003_deployment_lane.sql +5 -0
- package/dist/persistence.d.ts +184 -0
- package/dist/persistence.js +322 -0
- package/dist/release.d.ts +48 -0
- package/dist/release.js +57 -0
- package/package.json +10 -4
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { sha256Hex } from "./persistence.js";
|
|
2
|
+
export const deploymentPhases = [
|
|
3
|
+
"waiting", "validating", "materializing", "building", "deploying",
|
|
4
|
+
"verifying_deployment_url", "verifying_canonical", "succeeded", "failed", "ambiguous", "superseded",
|
|
5
|
+
];
|
|
6
|
+
const terminal = new Set(["succeeded", "failed", "ambiguous", "superseded"]);
|
|
7
|
+
const next = {
|
|
8
|
+
waiting: ["validating", "failed", "ambiguous", "superseded"],
|
|
9
|
+
validating: ["materializing", "failed", "ambiguous", "superseded"],
|
|
10
|
+
materializing: ["building", "failed", "ambiguous", "superseded"],
|
|
11
|
+
building: ["deploying", "failed", "ambiguous", "superseded"],
|
|
12
|
+
deploying: ["verifying_deployment_url", "failed", "ambiguous", "superseded"],
|
|
13
|
+
verifying_deployment_url: ["verifying_canonical", "failed", "ambiguous", "superseded"],
|
|
14
|
+
verifying_canonical: ["succeeded", "failed", "ambiguous", "superseded"],
|
|
15
|
+
};
|
|
16
|
+
export function isTerminalDeploymentPhase(phase) {
|
|
17
|
+
return terminal.has(phase);
|
|
18
|
+
}
|
|
19
|
+
export function assertDeploymentTransition(from, to) {
|
|
20
|
+
if (!next[from]?.includes(to))
|
|
21
|
+
throw new Error("invalid_attempt_transition");
|
|
22
|
+
}
|
|
23
|
+
/** A claim is a single-use, short-lived capability. The CI shared credential
|
|
24
|
+
* authenticates the caller; the claim token binds its later calls to one exact
|
|
25
|
+
* attempt so a captured request cannot be replayed against another release. */
|
|
26
|
+
export async function claimTokenChecksum(token) {
|
|
27
|
+
if (!/^[0-9a-f]{32,128}$/i.test(token))
|
|
28
|
+
throw new Error("invalid_claim_token");
|
|
29
|
+
return sha256Hex(`content-ci-claim:v1:${token}`);
|
|
30
|
+
}
|
|
31
|
+
export async function constantTimeSecretEqual(expected, provided) {
|
|
32
|
+
/* Hash both inputs before a fixed-length comparison. This works in Workers
|
|
33
|
+
* without Node's timingSafeEqual and never retains the supplied secret. */
|
|
34
|
+
const [left, right] = await Promise.all([sha256Hex(expected), sha256Hex(provided)]);
|
|
35
|
+
let difference = left.length ^ right.length;
|
|
36
|
+
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
|
|
37
|
+
difference |= (left.charCodeAt(index) || 0) ^ (right.charCodeAt(index) || 0);
|
|
38
|
+
}
|
|
39
|
+
return difference === 0;
|
|
40
|
+
}
|
|
41
|
+
/** Pure state model used both to pin the allowed lifecycle in tests and to
|
|
42
|
+
* keep the SQL repository from growing an implicit transition graph. */
|
|
43
|
+
export class DeploymentAttemptKernel {
|
|
44
|
+
attempts = new Map();
|
|
45
|
+
claim(attemptId, releaseId, tokenChecksum, expiresAt, now) {
|
|
46
|
+
const attempt = this.required(attemptId, releaseId);
|
|
47
|
+
if (attempt.phase !== "waiting" || attempt.claimTokenChecksum || expiresAt <= now)
|
|
48
|
+
throw new Error("attempt_unavailable");
|
|
49
|
+
const claimed = { ...attempt, claimTokenChecksum: tokenChecksum, claimExpiresAt: expiresAt };
|
|
50
|
+
this.attempts.set(attemptId, claimed);
|
|
51
|
+
return claimed;
|
|
52
|
+
}
|
|
53
|
+
advance(attemptId, releaseId, tokenChecksum, to, now) {
|
|
54
|
+
const attempt = this.authorized(attemptId, releaseId, tokenChecksum, now);
|
|
55
|
+
assertDeploymentTransition(attempt.phase, to);
|
|
56
|
+
const advanced = { ...attempt, phase: to };
|
|
57
|
+
this.attempts.set(attemptId, advanced);
|
|
58
|
+
return advanced;
|
|
59
|
+
}
|
|
60
|
+
authorized(attemptId, releaseId, tokenChecksum, now) {
|
|
61
|
+
const attempt = this.required(attemptId, releaseId);
|
|
62
|
+
if (isTerminalDeploymentPhase(attempt.phase) || attempt.claimTokenChecksum !== tokenChecksum || !attempt.claimExpiresAt || attempt.claimExpiresAt <= now) {
|
|
63
|
+
throw new Error("attempt_unavailable");
|
|
64
|
+
}
|
|
65
|
+
return attempt;
|
|
66
|
+
}
|
|
67
|
+
required(attemptId, releaseId) {
|
|
68
|
+
const attempt = this.attempts.get(attemptId);
|
|
69
|
+
if (!attempt || attempt.releaseId !== releaseId)
|
|
70
|
+
throw new Error("attempt_not_found");
|
|
71
|
+
return attempt;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function asAttempt(row) {
|
|
75
|
+
if (!row?.releaseId)
|
|
76
|
+
return null;
|
|
77
|
+
return {
|
|
78
|
+
attemptId: row.attemptId, intentKind: row.intentKind, releaseId: row.releaseId, codeSha: row.codeSha,
|
|
79
|
+
phase: row.phase, claimTokenChecksum: row.claimTokenChecksum, claimExpiresAt: row.claimExpiresAt,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** D1 adapter for the CI-only surface. It deliberately has no query that can
|
|
83
|
+
* select a "latest" release or an attempt from another site binding. */
|
|
84
|
+
export class D1DeploymentAttemptRepository {
|
|
85
|
+
db;
|
|
86
|
+
constructor(db) {
|
|
87
|
+
this.db = db;
|
|
88
|
+
}
|
|
89
|
+
async get(attemptId) {
|
|
90
|
+
const row = await this.db.prepare(`SELECT attempt_id AS attemptId, intent_kind AS intentKind, release_id AS releaseId,
|
|
91
|
+
code_sha AS codeSha, phase, ci_claim_token_checksum AS claimTokenChecksum, ci_claim_expires_at AS claimExpiresAt
|
|
92
|
+
FROM production_deployment_attempts WHERE attempt_id = ?`).bind(attemptId).first();
|
|
93
|
+
return asAttempt(row);
|
|
94
|
+
}
|
|
95
|
+
/** Registers the exact code/content pair before a workflow is dispatched.
|
|
96
|
+
* `active_slot` is the shared lane lock: code and content cannot overtake
|
|
97
|
+
* one another by merely reaching GitHub in a different queue order. */
|
|
98
|
+
async register(input) {
|
|
99
|
+
const existing = await this.db.prepare(`SELECT attempt_id AS attemptId, intent_kind AS intentKind,
|
|
100
|
+
requested_release_id AS requestedReleaseId, requested_code_sha AS requestedCodeSha, release_id AS releaseId,
|
|
101
|
+
code_sha AS codeSha, expected_fingerprint AS expectedFingerprint
|
|
102
|
+
FROM production_deployment_attempts WHERE attempt_id = ?`).bind(input.attemptId).first();
|
|
103
|
+
if (existing) {
|
|
104
|
+
if (existing.intentKind === input.intentKind && existing.requestedReleaseId === input.requestedReleaseId
|
|
105
|
+
&& existing.requestedCodeSha === input.requestedCodeSha && existing.releaseId === input.releaseId
|
|
106
|
+
&& existing.codeSha === input.codeSha && existing.expectedFingerprint === input.expectedFingerprint)
|
|
107
|
+
return "repeated";
|
|
108
|
+
throw new Error("duplicate_attempt");
|
|
109
|
+
}
|
|
110
|
+
const result = await this.db.prepare(`INSERT INTO production_deployment_attempts
|
|
111
|
+
(attempt_id, intent_kind, requested_release_id, requested_code_sha, code_sha, release_id, expected_fingerprint,
|
|
112
|
+
phase, active_slot, created_at, updated_at)
|
|
113
|
+
SELECT ?, ?, ?, ?, ?, ?, ?, 'waiting', 1, ?, ?
|
|
114
|
+
WHERE NOT EXISTS (SELECT 1 FROM production_deployment_attempts WHERE active_slot = 1)
|
|
115
|
+
AND EXISTS (SELECT 1 FROM content_releases WHERE release_id = ? AND status IN ('publishing', 'published'))`)
|
|
116
|
+
.bind(input.attemptId, input.intentKind, input.requestedReleaseId, input.requestedCodeSha, input.codeSha, input.releaseId, input.expectedFingerprint, input.now, input.now, input.releaseId).run();
|
|
117
|
+
if (result.meta?.changes !== 1)
|
|
118
|
+
throw new Error("deployment_lane_busy");
|
|
119
|
+
return "created";
|
|
120
|
+
}
|
|
121
|
+
async claim(input) {
|
|
122
|
+
const result = await this.db.prepare(`UPDATE production_deployment_attempts
|
|
123
|
+
SET ci_claim_token_checksum = ?, ci_claimed_at = ?, ci_claim_expires_at = ?, updated_at = ?
|
|
124
|
+
WHERE attempt_id = ? AND release_id = ? AND phase = 'waiting'
|
|
125
|
+
AND ci_claim_token_checksum IS NULL AND ci_claim_expires_at IS NULL`)
|
|
126
|
+
.bind(input.tokenChecksum, input.now, input.expiresAt, input.now, input.attemptId, input.releaseId).run();
|
|
127
|
+
if (result.meta?.changes !== 1)
|
|
128
|
+
throw new Error("attempt_unavailable");
|
|
129
|
+
}
|
|
130
|
+
async advance(input) {
|
|
131
|
+
assertDeploymentTransition(input.from, input.to);
|
|
132
|
+
const result = await this.db.prepare(`UPDATE production_deployment_attempts SET phase = ?, updated_at = ?,
|
|
133
|
+
active_slot = CASE WHEN ? IN ('succeeded', 'failed', 'ambiguous', 'superseded') THEN NULL ELSE 1 END,
|
|
134
|
+
finished_at = CASE WHEN ? IN ('succeeded', 'failed', 'ambiguous', 'superseded') THEN ? ELSE NULL END
|
|
135
|
+
WHERE attempt_id = ? AND release_id = ? AND phase = ? AND ci_claim_token_checksum = ?
|
|
136
|
+
AND ci_claim_expires_at > ?`)
|
|
137
|
+
.bind(input.to, input.now, input.to, input.to, input.now, input.attemptId, input.releaseId, input.from, input.tokenChecksum, input.now).run();
|
|
138
|
+
if (result.meta?.changes !== 1)
|
|
139
|
+
throw new Error("attempt_unavailable");
|
|
140
|
+
}
|
|
141
|
+
async fail(input) {
|
|
142
|
+
if (isTerminalDeploymentPhase(input.phase))
|
|
143
|
+
throw new Error("invalid_attempt_transition");
|
|
144
|
+
const result = await this.db.prepare(`UPDATE production_deployment_attempts
|
|
145
|
+
SET phase = 'failed', active_slot = NULL, error_code = ?, error_stage = ?, safe_message = ?, updated_at = ?, finished_at = ?
|
|
146
|
+
WHERE attempt_id = ? AND release_id = ? AND phase = ? AND ci_claim_token_checksum = ? AND ci_claim_expires_at > ?`)
|
|
147
|
+
.bind(input.code, input.stage, input.message, input.now, input.now, input.attemptId, input.releaseId, input.phase, input.tokenChecksum, input.now).run();
|
|
148
|
+
if (result.meta?.changes !== 1)
|
|
149
|
+
throw new Error("attempt_unavailable");
|
|
150
|
+
}
|
|
151
|
+
/** Promotes the release and succeeds the exact verified attempt in one D1
|
|
152
|
+
* batch. Every mutating statement repeats the capability guard, so a stale
|
|
153
|
+
* claim cannot publish content even if a prior read was valid. */
|
|
154
|
+
async finalize(input) {
|
|
155
|
+
const guard = `EXISTS (SELECT 1 FROM production_deployment_attempts WHERE attempt_id = ? AND release_id = ?
|
|
156
|
+
AND phase = 'verifying_canonical' AND ci_claim_token_checksum = ? AND ci_claim_expires_at > ?)`;
|
|
157
|
+
const statements = [
|
|
158
|
+
this.db.prepare(`UPDATE content_releases SET status = 'published', active_slot = NULL, finished_at = ?
|
|
159
|
+
WHERE release_id = ? AND status = 'publishing' AND base_release_id IS
|
|
160
|
+
(SELECT current_release_id FROM content_site_state WHERE singleton = 1) AND ${guard}`)
|
|
161
|
+
.bind(input.now, input.releaseId, input.attemptId, input.releaseId, input.tokenChecksum, input.now),
|
|
162
|
+
this.db.prepare(`UPDATE content_site_state SET current_release_id = ?, updated_at = ? WHERE singleton = 1
|
|
163
|
+
AND current_release_id IS (SELECT base_release_id FROM content_releases WHERE release_id = ?)
|
|
164
|
+
AND EXISTS (SELECT 1 FROM content_releases WHERE release_id = ? AND status = 'published') AND ${guard}`)
|
|
165
|
+
.bind(input.releaseId, input.now, input.releaseId, input.releaseId, input.attemptId, input.releaseId, input.tokenChecksum, input.now),
|
|
166
|
+
this.db.prepare(`UPDATE content_documents SET draft_revision_id = NULL, updated_at = ?
|
|
167
|
+
WHERE draft_revision_id IN (SELECT revision_id FROM content_release_entries WHERE release_id = ?)
|
|
168
|
+
AND document_id IN (SELECT document_id FROM content_release_entries WHERE release_id = ?) AND ${guard}`)
|
|
169
|
+
.bind(input.now, input.releaseId, input.releaseId, input.attemptId, input.releaseId, input.tokenChecksum, input.now),
|
|
170
|
+
this.db.prepare(`UPDATE production_deployment_attempts SET phase = 'succeeded', active_slot = NULL, updated_at = ?, finished_at = ?
|
|
171
|
+
WHERE attempt_id = ? AND release_id = ? AND phase = 'verifying_canonical' AND ci_claim_token_checksum = ?
|
|
172
|
+
AND ci_claim_expires_at > ? AND EXISTS (SELECT 1 FROM content_site_state WHERE singleton = 1 AND current_release_id = ?)`)
|
|
173
|
+
.bind(input.now, input.now, input.attemptId, input.releaseId, input.tokenChecksum, input.now, input.releaseId),
|
|
174
|
+
];
|
|
175
|
+
const results = await this.db.batch(statements);
|
|
176
|
+
if (results[0]?.meta?.changes !== 1 || results[1]?.meta?.changes !== 1 || results[3]?.meta?.changes !== 1) {
|
|
177
|
+
throw new Error("attempt_unavailable");
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { json } from "../_shared/assets.js";
|
|
2
|
+
import { D1ContentRepository } from "../../persistence.js";
|
|
3
|
+
import {
|
|
4
|
+
D1DeploymentAttemptRepository, claimTokenChecksum, constantTimeSecretEqual,
|
|
5
|
+
isTerminalDeploymentPhase,
|
|
6
|
+
} from "../../deployment.js";
|
|
7
|
+
import { releaseExportFromStored } from "../../release.js";
|
|
8
|
+
|
|
9
|
+
const MAX_BODY_BYTES = 8_000;
|
|
10
|
+
const claimLifetimeMs = 20 * 60 * 1_000;
|
|
11
|
+
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;
|
|
12
|
+
const phases = new Set(["validating", "materializing", "building", "deploying", "verifying_deployment_url", "verifying_canonical"]);
|
|
13
|
+
const failure = {
|
|
14
|
+
materialization_failed: { stage: "materializing", message: "La generazione del sito non è riuscita." },
|
|
15
|
+
build_failed: { stage: "building", message: "La compilazione del sito non è riuscita." },
|
|
16
|
+
deployment_failed: { stage: "deploying", message: "La distribuzione non è riuscita." },
|
|
17
|
+
verification_failed: { stage: "verifying", message: "La verifica della pubblicazione non è riuscita." },
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function response(data, status = 200) {
|
|
21
|
+
const result = json(data, status);
|
|
22
|
+
result.headers.set("Cache-Control", "no-store");
|
|
23
|
+
result.headers.set("Pragma", "no-cache");
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function diagnosticId() {
|
|
28
|
+
return `diag_${crypto.randomUUID().replaceAll("-", "").slice(0, 12)}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function safeError(code, stage, status = 400) {
|
|
32
|
+
const message = code === "ci_unauthorized" ? "Credenziale CI non valida."
|
|
33
|
+
: code === "attempt_not_available" ? "Tentativo di pubblicazione non disponibile."
|
|
34
|
+
: code === "invalid_request" ? "Richiesta CI non valida."
|
|
35
|
+
: "Esportazione contenuti non disponibile.";
|
|
36
|
+
return response({ ok: false, code, stage, error: message, diagnosticId: diagnosticId(), retryable: status >= 500 }, status);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function hasBrowserSession(request) {
|
|
40
|
+
return Boolean(request.headers.get("Cookie") || request.headers.get("CF-Access-Jwt-Assertion") || request.headers.get("Origin"));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function authorize(request, env) {
|
|
44
|
+
const configured = typeof env.CONTENT_CI_TOKEN === "string" ? env.CONTENT_CI_TOKEN : "";
|
|
45
|
+
const match = /^Bearer ([^\s]+)$/.exec(request.headers.get("Authorization") || "");
|
|
46
|
+
if (!configured || !match || hasBrowserSession(request) || !await constantTimeSecretEqual(configured, match[1])) {
|
|
47
|
+
throw new Error("ci_unauthorized");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function requiredIdentity(value) {
|
|
52
|
+
if (!value || typeof value !== "object" || !id.test(value.attemptId || "") || !id.test(value.releaseId || "")) {
|
|
53
|
+
throw new Error("invalid_request");
|
|
54
|
+
}
|
|
55
|
+
return { attemptId: value.attemptId, releaseId: value.releaseId };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function body(request) {
|
|
59
|
+
if (Number(request.headers.get("Content-Length") || 0) > MAX_BODY_BYTES) throw new Error("invalid_request");
|
|
60
|
+
const raw = await request.text();
|
|
61
|
+
if (new TextEncoder().encode(raw).byteLength > MAX_BODY_BYTES) throw new Error("invalid_request");
|
|
62
|
+
try {
|
|
63
|
+
const parsed = JSON.parse(raw);
|
|
64
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("invalid_request");
|
|
65
|
+
return parsed;
|
|
66
|
+
} catch { throw new Error("invalid_request"); }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function repositories(env) {
|
|
70
|
+
if (!env.CONTENT_DB) throw new Error("content_unavailable");
|
|
71
|
+
return { attempts: new D1DeploymentAttemptRepository(env.CONTENT_DB), content: new D1ContentRepository(env.CONTENT_DB) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function claimedAttempt(request, attempts, identity, now) {
|
|
75
|
+
const token = request.headers.get("X-Content-CI-Claim") || "";
|
|
76
|
+
let checksum;
|
|
77
|
+
try { checksum = await claimTokenChecksum(token); } catch { throw new Error("attempt_not_available"); }
|
|
78
|
+
const attempt = await attempts.get(identity.attemptId);
|
|
79
|
+
if (!attempt || attempt.releaseId !== identity.releaseId || isTerminalDeploymentPhase(attempt.phase)
|
|
80
|
+
|| attempt.claimTokenChecksum !== checksum || !attempt.claimExpiresAt || attempt.claimExpiresAt <= now) {
|
|
81
|
+
throw new Error("attempt_not_available");
|
|
82
|
+
}
|
|
83
|
+
return { attempt, checksum };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Site-owned Pages Functions intentionally call this directly from their
|
|
87
|
+
* `/internal/[[path]].js` file. It is not registered in the Access-protected
|
|
88
|
+
* admin router, so browser sessions cannot accidentally reach a CI route. */
|
|
89
|
+
export async function handleInternalContentCi({ request, env }) {
|
|
90
|
+
try {
|
|
91
|
+
await authorize(request, env);
|
|
92
|
+
const url = new URL(request.url);
|
|
93
|
+
const path = url.pathname;
|
|
94
|
+
const now = Date.now();
|
|
95
|
+
const { attempts, content } = repositories(env);
|
|
96
|
+
|
|
97
|
+
if (path === "/internal/deployment/claim" && request.method === "POST") {
|
|
98
|
+
const identity = requiredIdentity(await body(request));
|
|
99
|
+
const attempt = await attempts.get(identity.attemptId);
|
|
100
|
+
if (!attempt || attempt.releaseId !== identity.releaseId || attempt.phase !== "waiting" || attempt.claimTokenChecksum) throw new Error("attempt_not_available");
|
|
101
|
+
const token = crypto.randomUUID().replaceAll("-", "");
|
|
102
|
+
const expiresAt = now + claimLifetimeMs;
|
|
103
|
+
await attempts.claim({ ...identity, tokenChecksum: await claimTokenChecksum(token), now, expiresAt });
|
|
104
|
+
return response({ ok: true, attempt: { ...identity, phase: "waiting", expiresAt }, claimToken: token });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (path === "/internal/release/export" && request.method === "GET") {
|
|
108
|
+
const identity = requiredIdentity({ attemptId: url.searchParams.get("attemptId"), releaseId: url.searchParams.get("releaseId") });
|
|
109
|
+
const { attempt } = await claimedAttempt(request, attempts, identity, now);
|
|
110
|
+
const snapshot = await content.getReleaseExport(attempt.releaseId);
|
|
111
|
+
if (!snapshot) throw new Error("attempt_not_available");
|
|
112
|
+
return response({ ok: true, attempt: { attemptId: attempt.attemptId, releaseId: attempt.releaseId, phase: attempt.phase }, release: await releaseExportFromStored(snapshot) });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const input = await body(request);
|
|
116
|
+
const identity = requiredIdentity(input);
|
|
117
|
+
const { attempt, checksum } = await claimedAttempt(request, attempts, identity, now);
|
|
118
|
+
if (path === "/internal/deployment/phase" && request.method === "POST") {
|
|
119
|
+
if (!phases.has(input.phase)) throw new Error("invalid_request");
|
|
120
|
+
await attempts.advance({ ...identity, tokenChecksum: checksum, from: attempt.phase, to: input.phase, now });
|
|
121
|
+
return response({ ok: true, attempt: { ...identity, phase: input.phase } });
|
|
122
|
+
}
|
|
123
|
+
if (path === "/internal/deployment/finalize" && request.method === "POST") {
|
|
124
|
+
await attempts.finalize({ ...identity, tokenChecksum: checksum, now });
|
|
125
|
+
return response({ ok: true, attempt: { ...identity, phase: "succeeded" } });
|
|
126
|
+
}
|
|
127
|
+
if (path === "/internal/deployment/fail" && request.method === "POST") {
|
|
128
|
+
const detail = failure[input.code];
|
|
129
|
+
if (!detail) throw new Error("invalid_request");
|
|
130
|
+
await attempts.fail({ ...identity, tokenChecksum: checksum, phase: attempt.phase, now, code: input.code, ...detail });
|
|
131
|
+
return response({ ok: true, attempt: { ...identity, phase: "failed" } });
|
|
132
|
+
}
|
|
133
|
+
return safeError("invalid_request", "routing", 405);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
const code = error?.message;
|
|
136
|
+
if (code === "ci_unauthorized") return safeError(code, "authorization", 401);
|
|
137
|
+
if (code === "invalid_request" || code === "invalid_attempt_transition") return safeError("invalid_request", "validation", 400);
|
|
138
|
+
if (code === "attempt_not_available" || code === "attempt_unavailable") return safeError("attempt_not_available", "attempt", 409);
|
|
139
|
+
return safeError("content_unavailable", "persistence", 503);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { json } from "../_shared/assets.js";
|
|
2
|
+
import { D1ContentRepository, canonicalChecksum, contentSaveRequestChecksum } from "../../persistence.js";
|
|
3
|
+
import { presentContentConflict, validateContentDocument } from "../site.js";
|
|
4
|
+
|
|
5
|
+
const MAX_BODY_BYTES = 512_000;
|
|
6
|
+
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;
|
|
7
|
+
const checksum = /^[0-9a-f]{64}$/;
|
|
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 diagnosticId() {
|
|
16
|
+
return `diag_${crypto.randomUUID().replaceAll("-", "").slice(0, 12)}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function repository(env) {
|
|
20
|
+
if (!env.CONTENT_DB) throw new Error("content_unavailable");
|
|
21
|
+
return new D1ContentRepository(env.CONTENT_DB);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function safeError(error, stage) {
|
|
25
|
+
const code = error?.message;
|
|
26
|
+
const safeCode = ["content_unavailable", "unauthenticated", "invalid_request", "invalid_document", "stale_revision", "duplicate_request"].includes(code)
|
|
27
|
+
? code : "content_unavailable";
|
|
28
|
+
const status = safeCode === "unauthenticated" ? 401
|
|
29
|
+
: safeCode === "stale_revision" || safeCode === "duplicate_request" ? 409
|
|
30
|
+
: safeCode === "invalid_request" || safeCode === "invalid_document" ? 400 : 503;
|
|
31
|
+
const message = safeCode === "unauthenticated" ? "Sessione non valida: ricarica la pagina per rientrare."
|
|
32
|
+
: safeCode === "stale_revision" ? "Questo contenuto è stato modificato altrove: ricarica e risolvi il conflitto."
|
|
33
|
+
: code === "duplicate_request" ? "Questo identificatore di salvataggio è già associato a dati diversi."
|
|
34
|
+
: safeCode === "invalid_request" || safeCode === "invalid_document" ? "Il documento non è valido."
|
|
35
|
+
: "La persistenza contenuti non è disponibile.";
|
|
36
|
+
return response({ ok: false, code: safeCode, stage, error: message, diagnosticId: diagnosticId(), retryable: status >= 500 }, status);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function author(data) {
|
|
40
|
+
const payload = data?.cloudflareAccess?.JWT?.payload;
|
|
41
|
+
if (!payload?.sub) throw new Error("unauthenticated");
|
|
42
|
+
return { subject: String(payload.sub), label: String(payload.email || payload.name || "Admin").slice(0, 200) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseStoredRevision(revision) {
|
|
46
|
+
if (!revision) return null;
|
|
47
|
+
return {
|
|
48
|
+
revisionId: revision.revisionId,
|
|
49
|
+
schemaVersion: revision.schemaVersion,
|
|
50
|
+
tombstone: Boolean(revision.tombstone),
|
|
51
|
+
payload: revision.payload === null ? null : JSON.parse(revision.payload),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function conflict(repo, { documentId, baseRevisionId, mine }) {
|
|
56
|
+
const currentDocument = await repo.getEffectiveDocument(documentId);
|
|
57
|
+
const current = currentDocument?.draftRevisionId ? await repo.getRevision(currentDocument.draftRevisionId) : null;
|
|
58
|
+
const base = baseRevisionId ? await repo.getRevision(baseRevisionId) : null;
|
|
59
|
+
return presentContentConflict({
|
|
60
|
+
base: parseStoredRevision(base),
|
|
61
|
+
current: parseStoredRevision(current),
|
|
62
|
+
mine,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function onRequestGet({ request, env }) {
|
|
67
|
+
try {
|
|
68
|
+
const repo = repository(env);
|
|
69
|
+
const url = new URL(request.url);
|
|
70
|
+
if (url.pathname.endsWith("/state")) {
|
|
71
|
+
const state = await env.CONTENT_DB.prepare("SELECT current_release_id, current_code_sha, current_pages_deployment_id, current_fingerprint, schema_generation, updated_at FROM content_site_state WHERE singleton = 1").first();
|
|
72
|
+
return response({ ok: true, state: state || null });
|
|
73
|
+
}
|
|
74
|
+
const documentId = url.searchParams.get("id") || "";
|
|
75
|
+
if (!url.pathname.endsWith("/document")) return response({ ok: false, code: "invalid_request", stage: "validation", error: "Metodo non ammesso.", diagnosticId: diagnosticId(), retryable: false }, 405);
|
|
76
|
+
if (!id.test(documentId)) return response({ ok: false, code: "invalid_request", stage: "validation", error: "Identificatore documento non valido.", diagnosticId: diagnosticId(), retryable: false }, 400);
|
|
77
|
+
const document = await repo.getEffectiveDocument(documentId);
|
|
78
|
+
return document ? response({ ok: true, document }) : response({ ok: false, code: "not_found", stage: "validation", error: "Documento non trovato.", diagnosticId: diagnosticId(), retryable: false }, 404);
|
|
79
|
+
} catch (error) { return safeError(error, "validation"); }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function onRequestPost({ request, env, data }) {
|
|
83
|
+
if (Number(request.headers.get("Content-Length") || 0) > MAX_BODY_BYTES) return response({ ok: false, code: "invalid_request", stage: "validation", error: "Richiesta troppo grande.", diagnosticId: diagnosticId(), retryable: false }, 413);
|
|
84
|
+
let body;
|
|
85
|
+
try {
|
|
86
|
+
const raw = await request.text();
|
|
87
|
+
if (new TextEncoder().encode(raw).byteLength > MAX_BODY_BYTES) throw new Error("too_large");
|
|
88
|
+
body = JSON.parse(raw);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
const tooLarge = error?.message === "too_large";
|
|
91
|
+
return response({ ok: false, code: "invalid_request", stage: "validation", error: tooLarge ? "Richiesta troppo grande." : "JSON non valido.", diagnosticId: diagnosticId(), retryable: false }, tooLarge ? 413 : 400);
|
|
92
|
+
}
|
|
93
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
94
|
+
return response({ ok: false, code: "invalid_request", stage: "validation", error: "JSON non valido.", diagnosticId: diagnosticId(), retryable: false }, 400);
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
if (new URL(request.url).pathname.endsWith("/discard")) {
|
|
99
|
+
author(data);
|
|
100
|
+
if (!id.test(body.documentId) || (body.expectedDraftRevisionId !== null && !id.test(body.expectedDraftRevisionId)) || (body.baselineRevisionId !== null && !id.test(body.baselineRevisionId))) throw new Error("invalid_request");
|
|
101
|
+
const repo = repository(env);
|
|
102
|
+
try {
|
|
103
|
+
await repo.discard(body.documentId, body.expectedDraftRevisionId, body.baselineRevisionId, now);
|
|
104
|
+
return response({ ok: true, discarded: true });
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (error?.message !== "stale_revision") throw error;
|
|
107
|
+
return response({
|
|
108
|
+
ok: false, code: "stale_revision", stage: "discard",
|
|
109
|
+
error: "Questo contenuto è stato modificato altrove: ricarica e risolvi il conflitto.",
|
|
110
|
+
diagnosticId: diagnosticId(), retryable: false,
|
|
111
|
+
conflict: await conflict(repo, {
|
|
112
|
+
documentId: body.documentId, baseRevisionId: body.baselineRevisionId,
|
|
113
|
+
mine: { action: "discard", expectedDraftRevisionId: body.expectedDraftRevisionId, baselineRevisionId: body.baselineRevisionId },
|
|
114
|
+
}),
|
|
115
|
+
}, 409);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (!new URL(request.url).pathname.endsWith("/save")) return response({ ok: false, code: "invalid_request", stage: "validation", error: "Metodo non ammesso.", diagnosticId: diagnosticId(), retryable: false }, 405);
|
|
119
|
+
const editor = author(data);
|
|
120
|
+
if (!id.test(body.revisionId) || !id.test(body.documentId) || (body.baseRevisionId !== null && !id.test(body.baseRevisionId)) || (body.expectedDraftRevisionId !== undefined && body.expectedDraftRevisionId !== null && !id.test(body.expectedDraftRevisionId)) || (body.translationGroupId !== null && body.translationGroupId !== undefined && !id.test(body.translationGroupId)) || !checksum.test(body.requestChecksum || "") || !Number.isInteger(body.schemaVersion) || body.schemaVersion < 1 || typeof body.kind !== "string" || body.kind.length > 120 || typeof body.locale !== "string" || body.locale.length > 35 || (body.payload !== null && (typeof body.payload !== "object" || Array.isArray(body.payload)))) throw new Error("invalid_request");
|
|
121
|
+
const document = validateContentDocument({ documentId: body.documentId, kind: body.kind, locale: body.locale, translationGroupId: body.translationGroupId ?? null, payload: body.payload, tombstone: body.tombstone === true, schemaVersion: body.schemaVersion });
|
|
122
|
+
if (document.documentId !== body.documentId || document.kind !== body.kind || document.locale !== body.locale || document.schemaVersion !== body.schemaVersion || document.tombstone !== (body.tombstone === true)) throw new Error("invalid_document");
|
|
123
|
+
const expectedPayloadChecksum = await canonicalChecksum(document.tombstone ? null : document.payload);
|
|
124
|
+
if (body.payloadChecksum !== expectedPayloadChecksum) throw new Error("invalid_document");
|
|
125
|
+
const normalized = { ...body, ...document, expectedDraftRevisionId: body.expectedDraftRevisionId === undefined ? body.baseRevisionId : body.expectedDraftRevisionId, payloadChecksum: expectedPayloadChecksum };
|
|
126
|
+
const expectedRequestChecksum = await contentSaveRequestChecksum(normalized);
|
|
127
|
+
if (body.requestChecksum !== expectedRequestChecksum) throw new Error("invalid_document");
|
|
128
|
+
const repo = repository(env);
|
|
129
|
+
try {
|
|
130
|
+
const outcome = await repo.save({ ...normalized, now, authorSubject: editor.subject, authorLabel: editor.label });
|
|
131
|
+
return response({ ok: true, saved: true, repeated: outcome === "repeated", revisionId: body.revisionId });
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (error?.message !== "stale_revision") throw error;
|
|
134
|
+
return response({ ok: false, code: "stale_revision", stage: "save", error: "Questo contenuto è stato modificato altrove: ricarica e risolvi il conflitto.", diagnosticId: diagnosticId(), retryable: false, conflict: await conflict(repo, {
|
|
135
|
+
documentId: normalized.documentId, baseRevisionId: normalized.baseRevisionId,
|
|
136
|
+
mine: { revisionId: normalized.revisionId, schemaVersion: normalized.schemaVersion, tombstone: normalized.tombstone === true, payload: normalized.payload },
|
|
137
|
+
}) }, 409);
|
|
138
|
+
}
|
|
139
|
+
} catch (error) { return safeError(error, "save"); }
|
|
140
|
+
}
|
package/dist/functions/index.js
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* sull'acchiappatutto (verificato, non dedotto). È il motivo per cui Prenotazioni può restare
|
|
23
23
|
* dov'è senza inventare nessun punto di innesto.
|
|
24
24
|
*/
|
|
25
|
-
import { setServerConfig, moduleEnabled } from "./site.js";
|
|
25
|
+
import { setServerConfig, moduleEnabled, contentPersistenceMode } from "./site.js";
|
|
26
26
|
|
|
27
27
|
import * as analytics from "./api/analytics.js";
|
|
28
28
|
import * as assets from "./api/assets.js";
|
|
@@ -48,6 +48,7 @@ import * as requests from "./api/requests.js";
|
|
|
48
48
|
import * as settings from "./api/settings.js";
|
|
49
49
|
import * as singleton from "./api/singleton.js";
|
|
50
50
|
import * as storage from "./api/storage.js";
|
|
51
|
+
import * as content from "./api/content.js";
|
|
51
52
|
|
|
52
53
|
export { adminAccess } from "./_middleware.js";
|
|
53
54
|
export { setServerConfig } from "./site.js";
|
|
@@ -83,6 +84,10 @@ const ROUTES = {
|
|
|
83
84
|
"settings": { mod: null, h: settings },
|
|
84
85
|
"singleton": { mod: null, h: singleton },
|
|
85
86
|
"storage": { mod: null, h: storage },
|
|
87
|
+
"content/state": { mod: null, h: content },
|
|
88
|
+
"content/document": { mod: null, h: content },
|
|
89
|
+
"content/save": { mod: null, h: content },
|
|
90
|
+
"content/discard": { mod: null, h: content },
|
|
86
91
|
};
|
|
87
92
|
|
|
88
93
|
const PREFIX = "/admin/api/";
|
|
@@ -105,6 +110,9 @@ export async function handleAdminApi(context, config) {
|
|
|
105
110
|
const path = url.pathname.slice(PREFIX.length).split("/").filter(Boolean).join("/");
|
|
106
111
|
const route = ROUTES[path];
|
|
107
112
|
if (!route) return json({ ok: false, error: "rotta sconosciuta" }, 404);
|
|
113
|
+
if (path.startsWith("content/") && contentPersistenceMode() !== "d1") {
|
|
114
|
+
return json({ ok: false, code: "feature_inactive", error: "Persistenza D1 non attiva per questo sito." }, 404);
|
|
115
|
+
}
|
|
108
116
|
|
|
109
117
|
/* Un modulo spento risponde 404, non 403: dall'esterno quella funzione non esiste, e un 403
|
|
110
118
|
* racconterebbe che esiste ma è vietata. */
|
package/dist/functions/site.js
CHANGED
|
@@ -51,3 +51,31 @@ export function languages() {
|
|
|
51
51
|
const available = [...new Set(config.available.filter((language) => typeof language === "string" && language))];
|
|
52
52
|
return available.includes(config.default) ? { default: config.default, available } : null;
|
|
53
53
|
}
|
|
54
|
+
|
|
55
|
+
/** Git remains the compatibility default. D1 is opt-in per site and fails
|
|
56
|
+
* closed when its dedicated binding is absent. */
|
|
57
|
+
export function contentPersistenceMode() {
|
|
58
|
+
return current?.contentPersistence === "d1" ? "d1" : "github";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** D1 owns persistence, never a site's editorial meaning. A site explicitly
|
|
62
|
+
* supplies both functions before its D1 mode can accept a write: one validates
|
|
63
|
+
* (and may normalize) the complete document and the other produces the
|
|
64
|
+
* intentionally displayable side of a three-way conflict. */
|
|
65
|
+
export function validateContentDocument(document) {
|
|
66
|
+
const validate = current?.content?.validateDocument;
|
|
67
|
+
if (typeof validate !== "function") throw new Error("content_unavailable");
|
|
68
|
+
const result = validate(document);
|
|
69
|
+
if (!result || result.ok !== true || !result.document || typeof result.document !== "object") {
|
|
70
|
+
throw new Error("invalid_document");
|
|
71
|
+
}
|
|
72
|
+
return result.document;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function presentContentConflict(comparison) {
|
|
76
|
+
const present = current?.content?.presentConflict;
|
|
77
|
+
if (typeof present !== "function") throw new Error("content_unavailable");
|
|
78
|
+
const result = present(comparison);
|
|
79
|
+
if (!result || typeof result !== "object") throw new Error("content_unavailable");
|
|
80
|
+
return result;
|
|
81
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -9,3 +9,8 @@ export * from "./prose.js";
|
|
|
9
9
|
export * from "./gallery.js";
|
|
10
10
|
export * from "./normalize.js";
|
|
11
11
|
export * from "./i18n.js";
|
|
12
|
+
export * from "./persistence.js";
|
|
13
|
+
export * from "./conflict.js";
|
|
14
|
+
export * from "./release.js";
|
|
15
|
+
export * from "./deployment.js";
|
|
16
|
+
export * from "./media.js";
|
package/dist/index.js
CHANGED
|
@@ -9,3 +9,8 @@ export * from "./prose.js";
|
|
|
9
9
|
export * from "./gallery.js";
|
|
10
10
|
export * from "./normalize.js";
|
|
11
11
|
export * from "./i18n.js";
|
|
12
|
+
export * from "./persistence.js";
|
|
13
|
+
export * from "./conflict.js";
|
|
14
|
+
export * from "./release.js";
|
|
15
|
+
export * from "./deployment.js";
|
|
16
|
+
export * from "./media.js";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ImmutableReleaseExport, ReleaseExportEntry } from "./release.js";
|
|
2
|
+
export type MaterializedFile = {
|
|
3
|
+
path: string;
|
|
4
|
+
content: string;
|
|
5
|
+
};
|
|
6
|
+
export type ReleaseMaterializer = {
|
|
7
|
+
/** Site-wide validation happens before generating any file. This is where a
|
|
8
|
+
* site rejects collisions that are meaningful to Hugo (URLs, menus, etc.)
|
|
9
|
+
* but cannot be inferred from one document in isolation. */
|
|
10
|
+
validate?(entries: readonly ReleaseExportEntry[]): void;
|
|
11
|
+
materialize(entry: ReleaseExportEntry): readonly MaterializedFile[];
|
|
12
|
+
};
|
|
13
|
+
/** Pure materialization planning. The CI driver owns the disposable directory
|
|
14
|
+
* and writes these files there; keeping filesystem effects out of this module
|
|
15
|
+
* makes it impossible for a package consumer to accidentally rewrite tracked
|
|
16
|
+
* content while preparing a release. */
|
|
17
|
+
export declare function materializeRelease(release: ImmutableReleaseExport, adapter: ReleaseMaterializer, fingerprint: {
|
|
18
|
+
version: 1;
|
|
19
|
+
fingerprint: string;
|
|
20
|
+
}): readonly MaterializedFile[];
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { deploymentFingerprintJson } from "./release.js";
|
|
2
|
+
const SAFE_OUTPUT_PATH = /^(?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+$/;
|
|
3
|
+
/** Pure materialization planning. The CI driver owns the disposable directory
|
|
4
|
+
* and writes these files there; keeping filesystem effects out of this module
|
|
5
|
+
* makes it impossible for a package consumer to accidentally rewrite tracked
|
|
6
|
+
* content while preparing a release. */
|
|
7
|
+
export function materializeRelease(release, adapter, fingerprint) {
|
|
8
|
+
adapter.validate?.(release.entries);
|
|
9
|
+
const files = [];
|
|
10
|
+
for (const entry of release.entries) {
|
|
11
|
+
if (entry.tombstone)
|
|
12
|
+
continue;
|
|
13
|
+
for (const file of adapter.materialize(entry)) {
|
|
14
|
+
if (!SAFE_OUTPUT_PATH.test(file.path) || file.path.startsWith(".") || typeof file.content !== "string")
|
|
15
|
+
throw new Error("invalid_materialized_file");
|
|
16
|
+
files.push(file);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
files.push({ path: ".well-known/adminkit-deployment.json", content: deploymentFingerprintJson(fingerprint) });
|
|
20
|
+
const seen = new Set();
|
|
21
|
+
for (const file of files) {
|
|
22
|
+
if (seen.has(file.path))
|
|
23
|
+
throw new Error("duplicate_materialized_file");
|
|
24
|
+
seen.add(file.path);
|
|
25
|
+
}
|
|
26
|
+
return files.sort((left, right) => left.path.localeCompare(right.path));
|
|
27
|
+
}
|