@acms-subir/core 0.3.15 → 0.3.17
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/CHANGELOG.md +12 -0
- package/cli/common.mjs +2 -2
- package/cli/main.mjs +10 -3
- package/cli/migrations.generated.mjs +49 -1
- package/cli/remote.mjs +1 -1
- package/cli/sync.mjs +876 -0
- package/docs/environment-sync.md +302 -0
- package/integration.mjs +2 -0
- package/package.json +1 -1
- package/src/components/DesignAccess.tsx +3 -0
- package/src/components/ThemeSectionsEditor.tsx +53 -46
- package/src/lib/repo/migrations.ts +2 -0
- package/src/lib/repo/sql.ts +3 -0
- package/src/lib/repo/types.ts +2 -0
- package/src/lib/schema.ts +1 -0
- package/src/lib/sync/auth.ts +24 -0
- package/src/lib/sync/catalog.ts +250 -0
- package/src/lib/sync/migration.ts +51 -0
- package/src/lib/sync/protocol.mjs +125 -0
- package/src/lib/sync/service.ts +994 -0
- package/src/lib/sync/store.ts +349 -0
- package/src/pages/admin/integrations.astro +2 -0
- package/src/pages/api/design/keys.ts +7 -3
- package/src/pages/api/sync/index.ts +114 -0
- package/src/pages/api/sync/media.ts +90 -0
- package/src/styles/theme-editor.css +2 -1
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import type { SqlDriver, SqlStatement } from "../repo/sql";
|
|
2
|
+
import type { MediaStore } from "../media/store";
|
|
3
|
+
import { ApiError } from "../api";
|
|
4
|
+
import {
|
|
5
|
+
canonical,
|
|
6
|
+
hash,
|
|
7
|
+
reference,
|
|
8
|
+
transform,
|
|
9
|
+
PROTOCOL,
|
|
10
|
+
} from "./protocol.mjs";
|
|
11
|
+
import {
|
|
12
|
+
rowData,
|
|
13
|
+
effective,
|
|
14
|
+
displayRef,
|
|
15
|
+
matchKey,
|
|
16
|
+
tables,
|
|
17
|
+
parseData,
|
|
18
|
+
type Data,
|
|
19
|
+
type Kind,
|
|
20
|
+
} from "./catalog";
|
|
21
|
+
|
|
22
|
+
export interface Entity {
|
|
23
|
+
key: string;
|
|
24
|
+
aliases: string[];
|
|
25
|
+
id: string;
|
|
26
|
+
kind: Kind;
|
|
27
|
+
ref: string;
|
|
28
|
+
match: string;
|
|
29
|
+
data: Data;
|
|
30
|
+
hash: string;
|
|
31
|
+
stateHash: string;
|
|
32
|
+
dependencies: string[];
|
|
33
|
+
live: Data;
|
|
34
|
+
draft: Data | null;
|
|
35
|
+
files?: { sourceKey: string; key: string; digest: string; size: number }[];
|
|
36
|
+
}
|
|
37
|
+
export interface Snapshot {
|
|
38
|
+
protocol: number;
|
|
39
|
+
siteId: string;
|
|
40
|
+
revision: number;
|
|
41
|
+
entities: Entity[];
|
|
42
|
+
}
|
|
43
|
+
export const MAX_OBJECT = 16 * 1024 * 1024;
|
|
44
|
+
export async function objectBytes(
|
|
45
|
+
store: MediaStore,
|
|
46
|
+
key: string,
|
|
47
|
+
): Promise<Uint8Array> {
|
|
48
|
+
const object = await store.get(key);
|
|
49
|
+
if (!object) throw new ApiError(409, `Missing media object: ${key}`);
|
|
50
|
+
if (object.size !== undefined && object.size > MAX_OBJECT)
|
|
51
|
+
throw new ApiError(413, "Sync media objects are limited to 16 MiB each.");
|
|
52
|
+
const reader = new Response(object.body).body!.getReader();
|
|
53
|
+
const chunks: Uint8Array[] = [];
|
|
54
|
+
let size = 0;
|
|
55
|
+
try {
|
|
56
|
+
for (;;) {
|
|
57
|
+
const r = await reader.read();
|
|
58
|
+
if (r.done) break;
|
|
59
|
+
size += r.value.byteLength;
|
|
60
|
+
if (size > MAX_OBJECT) {
|
|
61
|
+
await reader.cancel();
|
|
62
|
+
throw new ApiError(
|
|
63
|
+
413,
|
|
64
|
+
"Sync media objects are limited to 16 MiB each.",
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
chunks.push(r.value);
|
|
68
|
+
}
|
|
69
|
+
} finally {
|
|
70
|
+
reader.releaseLock();
|
|
71
|
+
}
|
|
72
|
+
const out = new Uint8Array(size);
|
|
73
|
+
let offset = 0;
|
|
74
|
+
for (const c of chunks) {
|
|
75
|
+
out.set(c, offset);
|
|
76
|
+
offset += c.length;
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
export class SyncStore {
|
|
81
|
+
constructor(private driver: () => Promise<SqlDriver>) {}
|
|
82
|
+
async state() {
|
|
83
|
+
const row = (
|
|
84
|
+
await (await this.driver()).all("SELECT * FROM sync_state WHERE id=1")
|
|
85
|
+
)[0];
|
|
86
|
+
return { siteId: String(row.site_id), revision: Number(row.revision) };
|
|
87
|
+
}
|
|
88
|
+
async rawSettings() {
|
|
89
|
+
return (
|
|
90
|
+
await (await this.driver()).all("SELECT * FROM settings WHERE id=1")
|
|
91
|
+
)[0] as Data;
|
|
92
|
+
}
|
|
93
|
+
async snapshot(media: MediaStore): Promise<Snapshot> {
|
|
94
|
+
const db = await this.driver(),
|
|
95
|
+
state = await this.state();
|
|
96
|
+
const aliases = await db.all("SELECT * FROM sync_aliases ORDER BY alias");
|
|
97
|
+
const authorRows = await db.all("SELECT * FROM post_authors ORDER BY ord");
|
|
98
|
+
const settings = await this.rawSettings();
|
|
99
|
+
const designDraft = settings.draft_design
|
|
100
|
+
? JSON.parse(settings.draft_design)
|
|
101
|
+
: null;
|
|
102
|
+
const liveLayouts = JSON.parse(settings.section_pages || "{}"),
|
|
103
|
+
layouts = designDraft?.sectionPages ?? liveLayouts;
|
|
104
|
+
const entities: Entity[] = [];
|
|
105
|
+
const add = (kind: Kind, id: string, live: Data, draft: Data | null) => {
|
|
106
|
+
const own = `${state.siteId}:${kind}:${id}`;
|
|
107
|
+
const linked = aliases
|
|
108
|
+
.filter((a) => a.kind === kind && a.local_id === id)
|
|
109
|
+
.map((a) => String(a.alias));
|
|
110
|
+
const key = String(
|
|
111
|
+
aliases.find((a) => a.kind === kind && a.local_id === id && a.canonical)
|
|
112
|
+
?.alias ?? own,
|
|
113
|
+
);
|
|
114
|
+
entities.push({
|
|
115
|
+
key,
|
|
116
|
+
aliases: [...new Set([key, own, ...linked])],
|
|
117
|
+
id,
|
|
118
|
+
kind,
|
|
119
|
+
live,
|
|
120
|
+
draft,
|
|
121
|
+
data: effective(kind, live, draft),
|
|
122
|
+
ref: "",
|
|
123
|
+
match: "",
|
|
124
|
+
hash: "",
|
|
125
|
+
stateHash: "",
|
|
126
|
+
dependencies: [],
|
|
127
|
+
});
|
|
128
|
+
};
|
|
129
|
+
for (const [kind, table] of Object.entries(tables) as [Kind, string][]) {
|
|
130
|
+
// Never read account credentials into the sync inventory.
|
|
131
|
+
const select = kind === "author" ? "id,name,slug" : "*";
|
|
132
|
+
const rows = await db.all(`SELECT ${select} FROM ${table} LIMIT 2001`);
|
|
133
|
+
if (rows.length > 2000 || entities.length + rows.length > 2000)
|
|
134
|
+
throw new ApiError(
|
|
135
|
+
413,
|
|
136
|
+
"Sync inventory exceeds 2,000 items; split this site before syncing.",
|
|
137
|
+
);
|
|
138
|
+
for (const row of rows) {
|
|
139
|
+
const live = rowData(kind, row);
|
|
140
|
+
if ((kind === "event" || kind === "resource") && !live.timezone)
|
|
141
|
+
live.timezone = settings.timezone || "UTC";
|
|
142
|
+
const draft = row.draft ? JSON.parse(String(row.draft)) : null;
|
|
143
|
+
if (kind === "post")
|
|
144
|
+
live.authorIds = authorRows
|
|
145
|
+
.filter((a) => a.post_id === row.id)
|
|
146
|
+
.map((a) => a.author_id);
|
|
147
|
+
if (kind === "page") live.layout = liveLayouts[String(row.id)] ?? null;
|
|
148
|
+
add(kind, String(row.id), live, draft);
|
|
149
|
+
if (kind === "page")
|
|
150
|
+
entities.at(-1)!.data = {
|
|
151
|
+
...entities.at(-1)!.data,
|
|
152
|
+
layout: layouts[String(row.id)] ?? null,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
add("design", "site", rowData("design", settings), designDraft);
|
|
157
|
+
add("settings", "site", rowData("settings", settings), null);
|
|
158
|
+
add(
|
|
159
|
+
"booking-settings",
|
|
160
|
+
"site",
|
|
161
|
+
JSON.parse(settings.calendar || "{}"),
|
|
162
|
+
null,
|
|
163
|
+
);
|
|
164
|
+
const substitutions = new Map<string, string>();
|
|
165
|
+
for (const entity of entities) {
|
|
166
|
+
if (entity.kind !== "media") {
|
|
167
|
+
if (entity.id !== "site")
|
|
168
|
+
substitutions.set(entity.id, reference(entity.key));
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const data = entity.data,
|
|
172
|
+
files: NonNullable<Entity["files"]> = [];
|
|
173
|
+
for (const sourceKey of [
|
|
174
|
+
data.key,
|
|
175
|
+
...(data.variants ?? []).map((v: Data) => v.key),
|
|
176
|
+
]) {
|
|
177
|
+
const bytes = await objectBytes(media, sourceKey),
|
|
178
|
+
digest = await hash(bytes);
|
|
179
|
+
const ext =
|
|
180
|
+
/\.([a-zA-Z0-9]{1,8})$/.exec(sourceKey)?.[1].toLowerCase() ?? "bin";
|
|
181
|
+
const key = `sync/${digest}.${ext}`;
|
|
182
|
+
files.push({ sourceKey, key, digest, size: bytes.length });
|
|
183
|
+
substitutions.set(`/media/${sourceKey}`, `/media/${key}`);
|
|
184
|
+
}
|
|
185
|
+
entity.files = files;
|
|
186
|
+
const normalized = {
|
|
187
|
+
...data,
|
|
188
|
+
key: files[0].key,
|
|
189
|
+
variants: (data.variants ?? []).map((v: Data, i: number) => ({
|
|
190
|
+
...v,
|
|
191
|
+
key: files[i + 1].key,
|
|
192
|
+
})),
|
|
193
|
+
objects: files.map(({ key, digest, size }) => ({ key, digest, size })),
|
|
194
|
+
};
|
|
195
|
+
entity.live = normalized;
|
|
196
|
+
entity.data = normalized;
|
|
197
|
+
}
|
|
198
|
+
// Longest media path first; replace whole URL path tokens, not substrings.
|
|
199
|
+
const mediaPaths = [...substitutions]
|
|
200
|
+
.filter(([k]) => k.startsWith("/media/"))
|
|
201
|
+
.sort((a, b) => b[0].length - a[0].length);
|
|
202
|
+
const convert = (s: string) => {
|
|
203
|
+
if (substitutions.has(s)) return substitutions.get(s)!;
|
|
204
|
+
// JSON editor documents are strings. Decode before rewriting IDs/URLs.
|
|
205
|
+
if (/^\s*[\[{]/.test(s)) {
|
|
206
|
+
try {
|
|
207
|
+
return JSON.stringify(transform(JSON.parse(s), convert));
|
|
208
|
+
} catch {}
|
|
209
|
+
}
|
|
210
|
+
for (const [from, to] of mediaPaths) {
|
|
211
|
+
const escaped = from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
212
|
+
const boundary = "(?=$|[\\s\"'<>?#),\\]\\\\])";
|
|
213
|
+
if (settings.site_url) {
|
|
214
|
+
const origin = String(settings.site_url)
|
|
215
|
+
.replace(/\/$/, "")
|
|
216
|
+
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
217
|
+
s = s.replace(new RegExp(origin + escaped + boundary, "g"), to);
|
|
218
|
+
}
|
|
219
|
+
s = s.replace(new RegExp(escaped + boundary, "g"), to);
|
|
220
|
+
}
|
|
221
|
+
return s;
|
|
222
|
+
};
|
|
223
|
+
const mediaByPath = new Map<string, string>(
|
|
224
|
+
entities
|
|
225
|
+
.filter((e) => e.kind === "media")
|
|
226
|
+
.flatMap((e) =>
|
|
227
|
+
(e.data.objects ?? []).map(
|
|
228
|
+
(o: Data) => [`/media/${o.key}`, e.key] as [string, string],
|
|
229
|
+
),
|
|
230
|
+
),
|
|
231
|
+
);
|
|
232
|
+
for (const entity of entities) {
|
|
233
|
+
entity.data = transform(parseData(entity.kind, entity.data), convert);
|
|
234
|
+
entity.live = transform(parseData(entity.kind, entity.live), convert);
|
|
235
|
+
entity.draft = entity.draft ? transform(entity.draft, convert) : null;
|
|
236
|
+
entity.ref = displayRef(entity.kind, entity.data);
|
|
237
|
+
entity.match = matchKey(entity.kind, entity.data);
|
|
238
|
+
entity.hash = await hash(entity.data);
|
|
239
|
+
entity.stateHash = await hash({
|
|
240
|
+
live: entity.live,
|
|
241
|
+
data: entity.data,
|
|
242
|
+
draft: entity.draft,
|
|
243
|
+
});
|
|
244
|
+
const text = canonical(entity.data);
|
|
245
|
+
const deps = entities
|
|
246
|
+
.filter((e) => e.key !== entity.key && text.includes(reference(e.key)))
|
|
247
|
+
.map((e) => e.key);
|
|
248
|
+
for (const [path, key] of mediaByPath)
|
|
249
|
+
if (entity.kind !== "media" && text.includes(path)) deps.push(key);
|
|
250
|
+
if (entity.kind === "post" || entity.kind === "event")
|
|
251
|
+
for (const tag of entities.filter(
|
|
252
|
+
(e) =>
|
|
253
|
+
e.kind === "tag" && (entity.data.tags ?? []).includes(e.data.name),
|
|
254
|
+
))
|
|
255
|
+
deps.push(tag.key);
|
|
256
|
+
entity.dependencies = [...new Set(deps)];
|
|
257
|
+
}
|
|
258
|
+
if ((await this.state()).revision !== state.revision)
|
|
259
|
+
throw new ApiError(
|
|
260
|
+
409,
|
|
261
|
+
"Content changed while reading the inventory. Retry.",
|
|
262
|
+
);
|
|
263
|
+
const snapshot = { protocol: PROTOCOL, ...state, entities };
|
|
264
|
+
if (
|
|
265
|
+
new TextEncoder().encode(JSON.stringify(snapshot)).length >
|
|
266
|
+
8 * 1024 * 1024
|
|
267
|
+
)
|
|
268
|
+
throw new ApiError(413, "Sync inventory exceeds 8 MiB.");
|
|
269
|
+
return snapshot;
|
|
270
|
+
}
|
|
271
|
+
async plan(id: string) {
|
|
272
|
+
const r = (
|
|
273
|
+
await (
|
|
274
|
+
await this.driver()
|
|
275
|
+
).all("SELECT * FROM sync_plans WHERE id=?", [id])
|
|
276
|
+
)[0];
|
|
277
|
+
return r
|
|
278
|
+
? {
|
|
279
|
+
id: String(r.id),
|
|
280
|
+
digest: String(r.digest),
|
|
281
|
+
actor: String(r.actor),
|
|
282
|
+
body: JSON.parse(String(r.body)),
|
|
283
|
+
result: r.result ? JSON.parse(String(r.result)) : null,
|
|
284
|
+
createdAt: Number(r.created_at),
|
|
285
|
+
}
|
|
286
|
+
: null;
|
|
287
|
+
}
|
|
288
|
+
async savePlan(body: Data, actor: string) {
|
|
289
|
+
const id = crypto.randomUUID(),
|
|
290
|
+
digest = await hash(body);
|
|
291
|
+
await (
|
|
292
|
+
await this.driver()
|
|
293
|
+
).run(
|
|
294
|
+
"INSERT INTO sync_plans(id,digest,actor,body,created_at) VALUES(?,?,?,?,?)",
|
|
295
|
+
[id, digest, actor, JSON.stringify(body), Date.now()],
|
|
296
|
+
);
|
|
297
|
+
return { id, digest, ...body };
|
|
298
|
+
}
|
|
299
|
+
async history() {
|
|
300
|
+
return (
|
|
301
|
+
await (
|
|
302
|
+
await this.driver()
|
|
303
|
+
).all(
|
|
304
|
+
"SELECT id,created_at,result FROM sync_plans ORDER BY created_at DESC LIMIT 100",
|
|
305
|
+
)
|
|
306
|
+
).map((r) => ({
|
|
307
|
+
id: r.id,
|
|
308
|
+
createdAt: r.created_at,
|
|
309
|
+
result: r.result ? JSON.parse(String(r.result)) : null,
|
|
310
|
+
}));
|
|
311
|
+
}
|
|
312
|
+
async commit(
|
|
313
|
+
planId: string,
|
|
314
|
+
expectedRevision: number,
|
|
315
|
+
statements: SqlStatement[],
|
|
316
|
+
result: Data,
|
|
317
|
+
versionKeys: string[],
|
|
318
|
+
) {
|
|
319
|
+
const db = await this.driver();
|
|
320
|
+
try {
|
|
321
|
+
await db.batch([
|
|
322
|
+
{
|
|
323
|
+
sql: "INSERT INTO revision_checks(expected,actual) SELECT ?,revision FROM sync_state WHERE id=1",
|
|
324
|
+
args: [expectedRevision],
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
sql: "INSERT INTO revision_checks(expected,actual) SELECT 1,CASE WHEN result IS NULL THEN 1 ELSE 0 END FROM sync_plans WHERE id=?",
|
|
328
|
+
args: [planId],
|
|
329
|
+
},
|
|
330
|
+
...statements,
|
|
331
|
+
{
|
|
332
|
+
sql: "UPDATE sync_plans SET result=json_set(?,'$.versions',json((SELECT json_group_object(entity,revision) FROM sync_versions WHERE entity IN (SELECT value FROM json_each(?))))) WHERE id=?",
|
|
333
|
+
args: [JSON.stringify(result), JSON.stringify(versionKeys), planId],
|
|
334
|
+
},
|
|
335
|
+
{ sql: "DELETE FROM revision_checks" },
|
|
336
|
+
]);
|
|
337
|
+
} catch (e) {
|
|
338
|
+
if (/CHECK constraint/i.test(String(e)))
|
|
339
|
+
throw new ApiError(
|
|
340
|
+
409,
|
|
341
|
+
"Destination changed after review. Generate a new plan.",
|
|
342
|
+
);
|
|
343
|
+
throw e;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
async query(sql: string, args: unknown[] = []) {
|
|
347
|
+
return (await this.driver()).all(sql, args);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
@@ -6,6 +6,7 @@ import { API_SCOPES, WEBHOOK_EVENTS, type ApiScope } from '../../lib/schema';
|
|
|
6
6
|
import { accessFor } from '../../lib/permissions/access';
|
|
7
7
|
import { CALENDAR, bookingsEnabled } from '../../lib/calendar/config';
|
|
8
8
|
import { issueApiKey } from '../../lib/api-keys';
|
|
9
|
+
import { sessionRole } from '../../lib/api';
|
|
9
10
|
import { attemptDelivery, generateWebhookSecret, validateWebhookUrl } from '../../lib/webhooks';
|
|
10
11
|
import { resolveConfig } from '../../lib/email/config';
|
|
11
12
|
import { env } from '../../lib/env';
|
|
@@ -32,6 +33,7 @@ if (Astro.request.method === 'POST') {
|
|
|
32
33
|
if (op === 'key') {
|
|
33
34
|
const scopes = form.getAll('scopes').map(String).filter((s): s is ApiScope => (API_SCOPES as readonly string[]).includes(s));
|
|
34
35
|
if (!scopes.length) throw new Error('Pick at least one permission.');
|
|
36
|
+
if (scopes.some(s => s.startsWith('sync:')) && (await sessionRole(Astro.request, Astro.cookies, repo)).role !== 'owner') throw new Error('Only owners can issue environment sync keys.');
|
|
35
37
|
const { key } = await issueApiKey(repo, String(form.get('name') || 'API key').slice(0, 80), scopes);
|
|
36
38
|
newKey = key;
|
|
37
39
|
} else if (op === 'revoke') {
|
|
@@ -3,21 +3,25 @@ import { z } from 'zod';
|
|
|
3
3
|
import { getRepo } from '../../../lib/repo';
|
|
4
4
|
import { apiError, json, readJson, ApiError } from '../../../lib/api';
|
|
5
5
|
import { DESIGN_SCOPES } from '../../../lib/design-service';
|
|
6
|
+
import { SYNC_SCOPES } from '../../../lib/sync/auth';
|
|
7
|
+
import { sessionRole } from '../../../lib/api';
|
|
8
|
+
const TOOL_SCOPES=[...DESIGN_SCOPES,...SYNC_SCOPES] as const;
|
|
6
9
|
import { generateAgentKey } from '../../../lib/ai-config';
|
|
7
10
|
import { requirePermission } from '../../../lib/permissions/access';
|
|
8
11
|
export const ALL:APIRoute=async({request,cookies,locals})=>{
|
|
9
12
|
try {
|
|
10
13
|
const repo=await getRepo(locals);await requirePermission(request, cookies, repo, 'integrations.manage');
|
|
11
|
-
if(request.method==='GET')return json({keys:(await repo.listApiKeys()).filter(k=>k.scopes.some(s=>
|
|
14
|
+
if(request.method==='GET')return json({keys:(await repo.listApiKeys()).filter(k=>k.scopes.some(s=>TOOL_SCOPES.includes(s as typeof TOOL_SCOPES[number])))});
|
|
12
15
|
if(request.method==='POST') {
|
|
13
|
-
const input=z.object({name:z.string().min(1).max(100),scopes:z.array(z.enum(
|
|
16
|
+
const input=z.object({name:z.string().min(1).max(100),scopes:z.array(z.enum(TOOL_SCOPES)).min(1)}).strict().parse(await readJson(request,10000));
|
|
17
|
+
if(input.scopes.some(s=>s.startsWith('sync:'))&&(await sessionRole(request,cookies,repo)).role!=='owner')throw new ApiError(403,'Only owners can issue environment sync keys.');
|
|
14
18
|
const {token,keyHash,keyPrefix}=generateAgentKey();
|
|
15
19
|
const key=await repo.createApiKey({name:input.name,scopes:input.scopes,keyHash,prefix:keyPrefix});return json({key,token},201);
|
|
16
20
|
}
|
|
17
21
|
if(request.method==='DELETE') {
|
|
18
22
|
const {id}=z.object({id:z.string()}).strict().parse(await readJson(request,10000));
|
|
19
23
|
const key=(await repo.listApiKeys()).find(k=>k.id===id);
|
|
20
|
-
if(!key||!key.scopes.some(s=>
|
|
24
|
+
if(!key||!key.scopes.some(s=>TOOL_SCOPES.includes(s as typeof TOOL_SCOPES[number])))throw new ApiError(404,'Tool key not found.');
|
|
21
25
|
await repo.deleteApiKey(id);return json({ok:true});
|
|
22
26
|
}
|
|
23
27
|
throw new ApiError(405,'Method not allowed.');
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { getRepo } from "../../../lib/repo";
|
|
4
|
+
import { getMediaStore } from "../../../lib/media/store";
|
|
5
|
+
import { apiError, ApiError, json, readJson } from "../../../lib/api";
|
|
6
|
+
import { syncIdentity } from "../../../lib/sync/auth";
|
|
7
|
+
import { applyPlan, createPlan, followupPlan } from "../../../lib/sync/service";
|
|
8
|
+
import { PROTOCOL, TYPES } from "../../../lib/sync/protocol.mjs";
|
|
9
|
+
|
|
10
|
+
export const GET: APIRoute = async ({ request, locals }) => {
|
|
11
|
+
try {
|
|
12
|
+
const identity = await syncIdentity(request, await getRepo(locals), [
|
|
13
|
+
"sync:read",
|
|
14
|
+
]);
|
|
15
|
+
return json({
|
|
16
|
+
protocol: PROTOCOL,
|
|
17
|
+
scopes: identity.scopes,
|
|
18
|
+
capabilities: {
|
|
19
|
+
sync: true,
|
|
20
|
+
types: Object.keys(TYPES),
|
|
21
|
+
maxItems: 200,
|
|
22
|
+
maxObjectBytes: 16 * 1024 * 1024,
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
} catch (e) {
|
|
26
|
+
return apiError(e);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
const RequestBody = z.discriminatedUnion("op", [
|
|
30
|
+
z.object({ op: z.literal("snapshot") }).strict(),
|
|
31
|
+
z
|
|
32
|
+
.object({
|
|
33
|
+
op: z.literal("plan"),
|
|
34
|
+
input: z.unknown(),
|
|
35
|
+
validateOnly: z.boolean().default(false),
|
|
36
|
+
})
|
|
37
|
+
.strict(),
|
|
38
|
+
z
|
|
39
|
+
.object({
|
|
40
|
+
op: z.literal("apply"),
|
|
41
|
+
id: z.string().uuid(),
|
|
42
|
+
digest: z.string().regex(/^[a-f0-9]{64}$/),
|
|
43
|
+
})
|
|
44
|
+
.strict(),
|
|
45
|
+
z.object({ op: z.literal("show"), id: z.string().uuid() }).strict(),
|
|
46
|
+
z.object({ op: z.literal("history") }).strict(),
|
|
47
|
+
z
|
|
48
|
+
.object({
|
|
49
|
+
op: z.enum(["publish", "rollback"]),
|
|
50
|
+
id: z.string().uuid(),
|
|
51
|
+
validateOnly: z.boolean().default(false),
|
|
52
|
+
})
|
|
53
|
+
.strict(),
|
|
54
|
+
]);
|
|
55
|
+
export const POST: APIRoute = async ({ request, locals }) => {
|
|
56
|
+
try {
|
|
57
|
+
const repo = await getRepo(locals);
|
|
58
|
+
// Authenticate before parsing potentially large content.
|
|
59
|
+
const who = await syncIdentity(request, repo, ["sync:read"]);
|
|
60
|
+
const body = RequestBody.parse(await readJson(request, 8 * 1024 * 1024));
|
|
61
|
+
if (
|
|
62
|
+
["plan", "apply", "publish", "rollback"].includes(body.op) &&
|
|
63
|
+
!who.scopes.includes("sync:write")
|
|
64
|
+
)
|
|
65
|
+
throw new ApiError(403, "Required scope: sync:write");
|
|
66
|
+
const media = getMediaStore(locals);
|
|
67
|
+
switch (body.op) {
|
|
68
|
+
case "snapshot":
|
|
69
|
+
return json(await repo.sync.snapshot(media));
|
|
70
|
+
case "plan":
|
|
71
|
+
return json(
|
|
72
|
+
await createPlan(
|
|
73
|
+
repo,
|
|
74
|
+
media,
|
|
75
|
+
body.input,
|
|
76
|
+
who.actor,
|
|
77
|
+
!body.validateOnly,
|
|
78
|
+
),
|
|
79
|
+
);
|
|
80
|
+
case "apply":
|
|
81
|
+
return json(
|
|
82
|
+
await applyPlan(
|
|
83
|
+
repo,
|
|
84
|
+
media,
|
|
85
|
+
body.id,
|
|
86
|
+
body.digest,
|
|
87
|
+
who.actor,
|
|
88
|
+
who.scopes.includes("sync:publish"),
|
|
89
|
+
),
|
|
90
|
+
);
|
|
91
|
+
case "show": {
|
|
92
|
+
const p = await repo.sync.plan(body.id);
|
|
93
|
+
if (!p) throw new ApiError(404, "Plan not found.");
|
|
94
|
+
return json(p);
|
|
95
|
+
}
|
|
96
|
+
case "history":
|
|
97
|
+
return json({ runs: await repo.sync.history() });
|
|
98
|
+
case "publish":
|
|
99
|
+
case "rollback":
|
|
100
|
+
return json(
|
|
101
|
+
await followupPlan(
|
|
102
|
+
repo,
|
|
103
|
+
media,
|
|
104
|
+
body.id,
|
|
105
|
+
body.op,
|
|
106
|
+
who.actor,
|
|
107
|
+
!body.validateOnly,
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
} catch (e) {
|
|
112
|
+
return apiError(e);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { getRepo } from "../../../lib/repo";
|
|
3
|
+
import { getMediaStore } from "../../../lib/media/store";
|
|
4
|
+
import { apiError, ApiError, json } from "../../../lib/api";
|
|
5
|
+
import { syncIdentity } from "../../../lib/sync/auth";
|
|
6
|
+
import { objectBytes, MAX_OBJECT } from "../../../lib/sync/store";
|
|
7
|
+
import { safeMediaKey } from "../../../lib/sync/catalog";
|
|
8
|
+
import { hash } from "../../../lib/sync/protocol.mjs";
|
|
9
|
+
|
|
10
|
+
export const ALL: APIRoute = async ({ request, locals }) => {
|
|
11
|
+
try {
|
|
12
|
+
const repo = await getRepo(locals),
|
|
13
|
+
write = request.method === "PUT";
|
|
14
|
+
await syncIdentity(
|
|
15
|
+
request,
|
|
16
|
+
repo,
|
|
17
|
+
write ? ["sync:read", "sync:write"] : ["sync:read"],
|
|
18
|
+
);
|
|
19
|
+
if (!["GET", "HEAD", "PUT"].includes(request.method))
|
|
20
|
+
throw new ApiError(405, "Method not allowed.");
|
|
21
|
+
const key = safeMediaKey(
|
|
22
|
+
new URL(request.url).searchParams.get("key") ?? "",
|
|
23
|
+
);
|
|
24
|
+
const media = getMediaStore(locals);
|
|
25
|
+
const digest = /^sync\/([a-f0-9]{64})\.[a-z0-9]{1,8}$/.exec(key)?.[1];
|
|
26
|
+
if (write) {
|
|
27
|
+
if (!request.body) throw new ApiError(400, "An upload body is required.");
|
|
28
|
+
if (!digest)
|
|
29
|
+
throw new ApiError(
|
|
30
|
+
400,
|
|
31
|
+
"Sync uploads require a content-addressed key.",
|
|
32
|
+
);
|
|
33
|
+
const bytes = await objectBytes(
|
|
34
|
+
{
|
|
35
|
+
get: async () => ({
|
|
36
|
+
body: request.body!,
|
|
37
|
+
contentType:
|
|
38
|
+
request.headers.get("content-type") ?? "application/octet-stream",
|
|
39
|
+
size: Number(request.headers.get("content-length")) || undefined,
|
|
40
|
+
}),
|
|
41
|
+
put: media.put.bind(media),
|
|
42
|
+
delete: media.delete.bind(media),
|
|
43
|
+
},
|
|
44
|
+
key,
|
|
45
|
+
);
|
|
46
|
+
if (bytes.length > MAX_OBJECT || (await hash(bytes)) !== digest)
|
|
47
|
+
throw new ApiError(400, "Media checksum mismatch.");
|
|
48
|
+
const existing = await media.get(key);
|
|
49
|
+
if (existing) {
|
|
50
|
+
if ((await hash(await objectBytes(media, key))) !== digest)
|
|
51
|
+
throw new ApiError(
|
|
52
|
+
409,
|
|
53
|
+
"Existing content-addressed object is damaged.",
|
|
54
|
+
);
|
|
55
|
+
return json({ ok: true, skipped: true });
|
|
56
|
+
}
|
|
57
|
+
await media.put(
|
|
58
|
+
key,
|
|
59
|
+
bytes.buffer as ArrayBuffer,
|
|
60
|
+
request.headers.get("content-type") ?? "application/octet-stream",
|
|
61
|
+
);
|
|
62
|
+
return json({ ok: true, skipped: false });
|
|
63
|
+
}
|
|
64
|
+
const rows = await repo.listMedia();
|
|
65
|
+
if (
|
|
66
|
+
!digest &&
|
|
67
|
+
!rows.some((m) => m.key === key || m.variants.some((v) => v.key === key))
|
|
68
|
+
)
|
|
69
|
+
throw new ApiError(404, "Media not found.");
|
|
70
|
+
const obj = await media.get(key);
|
|
71
|
+
if (!obj) throw new ApiError(404, "Media not found.");
|
|
72
|
+
if (request.method === "HEAD") {
|
|
73
|
+
const actual = await hash(await objectBytes(media, key));
|
|
74
|
+
if (digest && actual !== digest)
|
|
75
|
+
throw new ApiError(409, "Media checksum mismatch.");
|
|
76
|
+
return new Response(null, {
|
|
77
|
+
headers: { ETag: actual, "Cache-Control": "no-store" },
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return new Response(obj.body, {
|
|
81
|
+
headers: {
|
|
82
|
+
"Content-Type": obj.contentType,
|
|
83
|
+
"Cache-Control": "no-store",
|
|
84
|
+
"Content-Disposition": "attachment",
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
} catch (e) {
|
|
88
|
+
return apiError(e);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
@@ -82,7 +82,8 @@
|
|
|
82
82
|
.te-add .btn { margin-bottom: 1px; }
|
|
83
83
|
.te-savebar {
|
|
84
84
|
display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap;
|
|
85
|
-
|
|
85
|
+
position: sticky; bottom: 0; z-index: 2; background: var(--panel);
|
|
86
|
+
margin-top: 16px; padding: 12px 0; border-top: 1px solid var(--line);
|
|
86
87
|
}
|
|
87
88
|
.te-savestate { margin-right: auto; font-size: 12px; color: var(--faint); }
|
|
88
89
|
.te-savestate.is-dirty { color: var(--amber); font-weight: 600; }
|