@crombolo/server 0.0.0-stage → 0.10.0
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/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +41 -4
- package/src/access.ts +114 -0
- package/src/adapters/assistant.ts +229 -0
- package/src/adapters/blueprints.ts +163 -0
- package/src/adapters/bundles.ts +507 -0
- package/src/adapters/content.ts +783 -0
- package/src/adapters/dictionary.ts +76 -0
- package/src/adapters/documents.ts +637 -0
- package/src/adapters/elements.ts +403 -0
- package/src/adapters/file-system.ts +195 -0
- package/src/adapters/media.ts +347 -0
- package/src/adapters/member-groups.ts +90 -0
- package/src/adapters/members.ts +261 -0
- package/src/adapters/public-access.ts +116 -0
- package/src/adapters/redirects.ts +73 -0
- package/src/adapters/references.ts +67 -0
- package/src/adapters/schema-files.ts +204 -0
- package/src/adapters/schema-proposals.ts +180 -0
- package/src/adapters/template-files.ts +58 -0
- package/src/adapters/template-query.ts +127 -0
- package/src/adapters/users.ts +963 -0
- package/src/assistant.ts +487 -0
- package/src/backup.ts +52 -0
- package/src/bundle-install.ts +417 -0
- package/src/bundle-sections.ts +424 -0
- package/src/bundle-tables.ts +161 -0
- package/src/bundles.ts +609 -0
- package/src/component-snippets.ts +163 -0
- package/src/components.ts +266 -0
- package/src/config.ts +556 -0
- package/src/content-type-udt.ts +256 -0
- package/src/database.ts +148 -0
- package/src/diagnostics.ts +143 -0
- package/src/dictionary-transfer.ts +123 -0
- package/src/domains.ts +384 -0
- package/src/editor-types.ts +108 -0
- package/src/email-http.ts +79 -0
- package/src/email-notice.ts +36 -0
- package/src/email-postmark.ts +78 -0
- package/src/email-resend.ts +69 -0
- package/src/email-ses.ts +307 -0
- package/src/email.ts +402 -0
- package/src/extensions-notice.ts +35 -0
- package/src/file-intake.ts +84 -0
- package/src/form-files.ts +241 -0
- package/src/form-submit.ts +397 -0
- package/src/form-workflows.ts +397 -0
- package/src/forms.ts +170 -0
- package/src/git-github.ts +171 -0
- package/src/git-routes.ts +154 -0
- package/src/git.ts +88 -0
- package/src/imaging.ts +434 -0
- package/src/index.ts +65 -0
- package/src/jobs.ts +140 -0
- package/src/listen.ts +36 -0
- package/src/log-expressions.ts +524 -0
- package/src/log-viewer.ts +190 -0
- package/src/logging.ts +126 -0
- package/src/marketplace.ts +382 -0
- package/src/media-files.ts +276 -0
- package/src/media-store-azure.ts +218 -0
- package/src/media-store-s3.ts +113 -0
- package/src/media-store.ts +248 -0
- package/src/member-auth.ts +384 -0
- package/src/models-builder.ts +106 -0
- package/src/no-nodes.ts +64 -0
- package/src/not-implemented.ts +44 -0
- package/src/oembed.ts +144 -0
- package/src/ports.ts +553 -0
- package/src/redirects.ts +132 -0
- package/src/schema-classify.ts +82 -0
- package/src/schema-store.ts +145 -0
- package/src/schema.ts +388 -0
- package/src/seo.ts +371 -0
- package/src/server-events.ts +131 -0
- package/src/server.ts +1642 -0
- package/src/status.ts +275 -0
- package/src/tar.ts +85 -0
- package/src/udt.ts +168 -0
- package/src/value-schemas.ts +64 -0
- package/src/view-runtime.ts +42 -0
- package/src/zip.ts +152 -0
- package/README.md +0 -3
package/src/assistant.ts
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The assistant's HTTP surface, mounted only when `assistant` is configured.
|
|
3
|
+
*
|
|
4
|
+
* Every route needs a signed-in backoffice user, and every CMS call the assistant
|
|
5
|
+
* makes is re-issued through the Management API router carrying that user's own
|
|
6
|
+
* cookies — so `authorization.ts` applies the same section, start-node and
|
|
7
|
+
* permission checks it applies to the editor's own clicks. Nothing here has a
|
|
8
|
+
* privileged path to the database.
|
|
9
|
+
*
|
|
10
|
+
* The approval routes are the only way a proposal reaches the CMS, and they are
|
|
11
|
+
* reachable only from the backoffice: the MCP endpoint deliberately has no
|
|
12
|
+
* equivalent.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { hasSection, type Principal } from '@crombolo/api-management'
|
|
16
|
+
import {
|
|
17
|
+
type AssistantProvider,
|
|
18
|
+
applyChange,
|
|
19
|
+
type Changeset,
|
|
20
|
+
type ChangesetStore,
|
|
21
|
+
createToolHost,
|
|
22
|
+
describeChange,
|
|
23
|
+
EDITABLE_FIELDS,
|
|
24
|
+
effectOf,
|
|
25
|
+
handleMcpRequest,
|
|
26
|
+
type ManagementCall,
|
|
27
|
+
type ProposedChange,
|
|
28
|
+
type ProviderMessage,
|
|
29
|
+
type ProviderStatus,
|
|
30
|
+
reviewChange,
|
|
31
|
+
runConversation,
|
|
32
|
+
type SchemaProposalPort,
|
|
33
|
+
scanTemplateSource,
|
|
34
|
+
systemPrompt,
|
|
35
|
+
} from '@crombolo/assistant'
|
|
36
|
+
import { listOperations, loadSpec } from '@crombolo/contracts'
|
|
37
|
+
import type { Db } from '@crombolo/data'
|
|
38
|
+
import { createChangesetStore } from './adapters/assistant.ts'
|
|
39
|
+
import { logger } from './logging.ts'
|
|
40
|
+
|
|
41
|
+
const log = logger('Assistant')
|
|
42
|
+
|
|
43
|
+
const PART_KINDS = new Set(['text', 'tool-use', 'tool-result'])
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* How much of a client-supplied transcript is sent back to the model.
|
|
47
|
+
*
|
|
48
|
+
* The browser holds the conversation and returns it each turn, so without a
|
|
49
|
+
* ceiling the caller decides how many tokens the site pays for. Generous enough
|
|
50
|
+
* that a real session never notices.
|
|
51
|
+
*/
|
|
52
|
+
const MAX_HISTORY_MESSAGES = 40
|
|
53
|
+
const MAX_HISTORY_BYTES = 256 * 1024
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The conversation the client sends back, kept to the shape the provider can map.
|
|
57
|
+
*
|
|
58
|
+
* It is the user's own browser, and a fabricated tool result buys nothing — the
|
|
59
|
+
* tools enforce the boundary, not the transcript — but a malformed part would
|
|
60
|
+
* otherwise reach the provider and come back as an unexplained 502.
|
|
61
|
+
*/
|
|
62
|
+
function asHistory(value: unknown): ProviderMessage[] {
|
|
63
|
+
if (!Array.isArray(value)) return []
|
|
64
|
+
const messages: ProviderMessage[] = []
|
|
65
|
+
for (const entry of value) {
|
|
66
|
+
const message = entry as Partial<ProviderMessage>
|
|
67
|
+
if (message?.role !== 'user' && message?.role !== 'assistant') continue
|
|
68
|
+
if (!Array.isArray(message.parts)) continue
|
|
69
|
+
const parts = message.parts.filter((part) =>
|
|
70
|
+
PART_KINDS.has((part as { kind?: string })?.kind ?? ''),
|
|
71
|
+
)
|
|
72
|
+
if (parts.length > 0) messages.push({ role: message.role, parts })
|
|
73
|
+
}
|
|
74
|
+
// The transcript comes from the client and is billed on every turn, so its
|
|
75
|
+
// size is not the client's to decide. The newest turns are the ones that
|
|
76
|
+
// matter to the answer, so a long conversation loses its oldest.
|
|
77
|
+
const trimmed = messages.slice(-MAX_HISTORY_MESSAGES)
|
|
78
|
+
let budget = MAX_HISTORY_BYTES
|
|
79
|
+
const kept: ProviderMessage[] = []
|
|
80
|
+
for (let i = trimmed.length - 1; i >= 0; i--) {
|
|
81
|
+
const message = trimmed[i] as ProviderMessage
|
|
82
|
+
budget -= JSON.stringify(message.parts).length
|
|
83
|
+
if (budget < 0) break
|
|
84
|
+
kept.unshift(message)
|
|
85
|
+
}
|
|
86
|
+
return kept
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface AssistantConfig {
|
|
90
|
+
/** The model. Absent configuration means the feature does not exist. */
|
|
91
|
+
provider: AssistantProvider
|
|
92
|
+
/** Also serve the same tools over MCP, for Claude Code and Claude Desktop. */
|
|
93
|
+
mcp?: boolean
|
|
94
|
+
/** Model calls per message before the loop gives up. */
|
|
95
|
+
maxTurns?: number
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface AssistantHostOptions {
|
|
99
|
+
config: AssistantConfig
|
|
100
|
+
db: Db
|
|
101
|
+
/** Reading, validating and applying schema proposals; absent with no schema directory. */
|
|
102
|
+
schema?: SchemaProposalPort
|
|
103
|
+
siteName: string
|
|
104
|
+
version: string
|
|
105
|
+
/** Sends a request through the Management API, as the signed-in caller. */
|
|
106
|
+
dispatch: (request: Request) => Promise<Response>
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The public shape of a change, which never includes the prepared request body. */
|
|
110
|
+
const toJson = (change: ProposedChange) => ({
|
|
111
|
+
key: change.key,
|
|
112
|
+
kind: change.kind,
|
|
113
|
+
summary: change.summary,
|
|
114
|
+
status: change.status,
|
|
115
|
+
problems: change.problems,
|
|
116
|
+
error: change.error,
|
|
117
|
+
effect: effectOf(change.kind),
|
|
118
|
+
createDate: change.createDate,
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const changesetJson = (changeset: Changeset) => ({
|
|
122
|
+
key: changeset.key,
|
|
123
|
+
title: changeset.title,
|
|
124
|
+
origin: changeset.origin,
|
|
125
|
+
createDate: changeset.createDate,
|
|
126
|
+
updateDate: changeset.updateDate,
|
|
127
|
+
changes: changeset.changes.map(toJson),
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
export interface AssistantHost {
|
|
131
|
+
/** Handles a request under the assistant's path, or undefined when it is not one. */
|
|
132
|
+
handle(request: Request, pathname: string, principal: Principal): Promise<Response | undefined>
|
|
133
|
+
/**
|
|
134
|
+
* Whether the model is usable, for the boot log and the drawer. `deep` asks
|
|
135
|
+
* Bedrock itself rather than only resolving credentials.
|
|
136
|
+
*/
|
|
137
|
+
status(deep?: boolean): Promise<ProviderStatus>
|
|
138
|
+
store: ChangesetStore
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A provider that cannot check itself is taken at its word; only one that says it
|
|
143
|
+
* is broken is reported as broken. The alternative — calling the model to find
|
|
144
|
+
* out — costs tokens on every boot and every time the drawer opens.
|
|
145
|
+
*/
|
|
146
|
+
const UNCHECKED: ProviderStatus = { ok: true, detail: 'ready' }
|
|
147
|
+
|
|
148
|
+
export function createAssistantHost(options: AssistantHostOptions): AssistantHost {
|
|
149
|
+
const store = createChangesetStore(options.db)
|
|
150
|
+
const paths = new Map(
|
|
151
|
+
listOperations(loadSpec()).map((operation) => [operation.operationId, operation.path]),
|
|
152
|
+
)
|
|
153
|
+
const pathOf = (operationId: string) => paths.get(operationId)
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* One Management API call as the caller. The user's cookies are forwarded
|
|
157
|
+
* rather than a privileged token minted, so the assistant cannot outrun the
|
|
158
|
+
* permissions of the person it is helping.
|
|
159
|
+
*/
|
|
160
|
+
const callFor = (source: Request): ManagementCall => {
|
|
161
|
+
const origin = new URL(source.url).origin
|
|
162
|
+
const cookie = source.headers.get('cookie')
|
|
163
|
+
const authorization = source.headers.get('authorization')
|
|
164
|
+
return async ({ method, path, query, body }) => {
|
|
165
|
+
const url = new URL(path, origin)
|
|
166
|
+
for (const [name, value] of Object.entries(query ?? {})) url.searchParams.set(name, value)
|
|
167
|
+
const headers = new Headers({ accept: 'application/json' })
|
|
168
|
+
if (cookie) headers.set('cookie', cookie)
|
|
169
|
+
if (authorization) headers.set('authorization', authorization)
|
|
170
|
+
if (body !== undefined) headers.set('content-type', 'application/json')
|
|
171
|
+
const response = await options.dispatch(
|
|
172
|
+
new Request(url, {
|
|
173
|
+
method: method.toUpperCase(),
|
|
174
|
+
headers,
|
|
175
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
176
|
+
}),
|
|
177
|
+
)
|
|
178
|
+
const text = await response.text()
|
|
179
|
+
let parsed: unknown = text === '' ? null : text
|
|
180
|
+
if (text !== '') {
|
|
181
|
+
try {
|
|
182
|
+
parsed = JSON.parse(text)
|
|
183
|
+
} catch {
|
|
184
|
+
// A non-JSON body is passed through as text; the tools report it as-is.
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { status: response.status, body: parsed }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const toolsFor = (request: Request, changeset: () => Promise<string>) =>
|
|
192
|
+
createToolHost({
|
|
193
|
+
call: callFor(request),
|
|
194
|
+
changesets: store,
|
|
195
|
+
changeset,
|
|
196
|
+
schema: options.schema,
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Whether this caller may change types. A dispatched operation would have gone
|
|
201
|
+
* through `authorization.ts`; a schema proposal writes a file instead, so the
|
|
202
|
+
* same question is asked here — Settings, as that file requires for every
|
|
203
|
+
* `document-type` operation.
|
|
204
|
+
*/
|
|
205
|
+
const maySetSchema = (principal: Principal): boolean => hasSection(principal, 'settings')
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* One changeset per MCP run: the newest with something still awaiting review is
|
|
209
|
+
* reused, so an agent's proposals arrive as one group, and a fresh one starts
|
|
210
|
+
* once the last has been dealt with.
|
|
211
|
+
*/
|
|
212
|
+
const mcpChangeset = async (userKey: string): Promise<string> => {
|
|
213
|
+
const open = await store.openFor(userKey, 'mcp')
|
|
214
|
+
if (open) return open.key
|
|
215
|
+
const created = await store.create({ userKey, title: 'Proposed over MCP', origin: 'mcp' })
|
|
216
|
+
return created.key
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const chat = async (request: Request, principal: Principal): Promise<Response> => {
|
|
220
|
+
const body = (await request.json().catch(() => ({}))) as {
|
|
221
|
+
message?: unknown
|
|
222
|
+
changesetKey?: unknown
|
|
223
|
+
viewing?: unknown
|
|
224
|
+
history?: unknown
|
|
225
|
+
}
|
|
226
|
+
const message = typeof body.message === 'string' ? body.message.trim() : ''
|
|
227
|
+
if (message === '') return Response.json({ error: 'A message is required.' }, { status: 400 })
|
|
228
|
+
|
|
229
|
+
const existing =
|
|
230
|
+
typeof body.changesetKey === 'string' ? await store.load(body.changesetKey) : undefined
|
|
231
|
+
const changeset =
|
|
232
|
+
existing && existing.userKey === principal.id
|
|
233
|
+
? existing
|
|
234
|
+
: await store.create({
|
|
235
|
+
userKey: principal.id,
|
|
236
|
+
title: message.slice(0, 120),
|
|
237
|
+
origin: 'backoffice',
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
const history = asHistory(body.history)
|
|
241
|
+
const tools = toolsFor(request, async () => changeset.key)
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
const run = await runConversation({
|
|
245
|
+
provider: options.config.provider,
|
|
246
|
+
tools,
|
|
247
|
+
maxTurns: options.config.maxTurns,
|
|
248
|
+
system: systemPrompt({
|
|
249
|
+
siteName: options.siteName,
|
|
250
|
+
userName: principal.name,
|
|
251
|
+
viewing: typeof body.viewing === 'string' ? body.viewing : undefined,
|
|
252
|
+
}),
|
|
253
|
+
messages: [...history, { role: 'user', parts: [{ kind: 'text', text: message }] }],
|
|
254
|
+
})
|
|
255
|
+
const settled = await store.load(changeset.key)
|
|
256
|
+
return Response.json({
|
|
257
|
+
changesetKey: changeset.key,
|
|
258
|
+
text: run.text,
|
|
259
|
+
exhausted: run.exhausted,
|
|
260
|
+
history: run.messages,
|
|
261
|
+
changes: (settled?.changes ?? []).map(toJson),
|
|
262
|
+
})
|
|
263
|
+
} catch (error) {
|
|
264
|
+
log.error('The assistant could not answer: {message}', {
|
|
265
|
+
message: (error as Error).message,
|
|
266
|
+
})
|
|
267
|
+
// The model being unreachable is not a CMS fault: report it and leave
|
|
268
|
+
// everything else working.
|
|
269
|
+
return Response.json(
|
|
270
|
+
{ changesetKey: changeset.key, error: (error as Error).message },
|
|
271
|
+
{ status: 502 },
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** A change of the caller's own, so knowing a key is not enough to approve one. */
|
|
277
|
+
const findChange = (principal: Principal, changeKey: string) =>
|
|
278
|
+
store.changeFor(principal.id, changeKey)
|
|
279
|
+
|
|
280
|
+
const settleChange = async (
|
|
281
|
+
request: Request,
|
|
282
|
+
principal: Principal,
|
|
283
|
+
changeKey: string,
|
|
284
|
+
action: 'approve' | 'discard',
|
|
285
|
+
): Promise<Response> => {
|
|
286
|
+
const change = await findChange(principal, changeKey)
|
|
287
|
+
if (!change) return new Response('Not Found', { status: 404 })
|
|
288
|
+
|
|
289
|
+
if (action === 'discard') {
|
|
290
|
+
await store.settle(changeKey, { status: 'discarded' })
|
|
291
|
+
return Response.json({ key: changeKey, status: 'discarded' })
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const outcome = await applyChange(change, {
|
|
295
|
+
call: callFor(request),
|
|
296
|
+
changesets: store,
|
|
297
|
+
schema: options.schema,
|
|
298
|
+
maySetSchema: maySetSchema(principal),
|
|
299
|
+
})
|
|
300
|
+
log.info('{user} {verb} assistant change {key} ({kind})', {
|
|
301
|
+
user: principal.userName,
|
|
302
|
+
verb: outcome.applied ? 'applied' : 'could not apply',
|
|
303
|
+
key: changeKey,
|
|
304
|
+
kind: change.kind,
|
|
305
|
+
})
|
|
306
|
+
return Response.json(
|
|
307
|
+
{
|
|
308
|
+
key: changeKey,
|
|
309
|
+
status: outcome.applied ? 'applied' : 'failed',
|
|
310
|
+
error: outcome.error,
|
|
311
|
+
},
|
|
312
|
+
{ status: outcome.applied ? 200 : 409 },
|
|
313
|
+
)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The last check, so the drawer can open without asking Bedrock every time — a
|
|
318
|
+
* deep check costs a token or two. Boot fills it; a refresh replaces it.
|
|
319
|
+
*/
|
|
320
|
+
let cached: ProviderStatus | undefined
|
|
321
|
+
/**
|
|
322
|
+
* When a request last asked for a deep check, which costs a real model call.
|
|
323
|
+
* The boot check does not count: a refresh must work immediately after it.
|
|
324
|
+
*/
|
|
325
|
+
let lastRefreshAt = 0
|
|
326
|
+
const REFRESH_INTERVAL_MS = 30_000
|
|
327
|
+
|
|
328
|
+
const status = async (deep = false): Promise<ProviderStatus> => {
|
|
329
|
+
const provider = options.config.provider
|
|
330
|
+
if (!provider.check) return UNCHECKED
|
|
331
|
+
if (!deep && cached) return cached
|
|
332
|
+
try {
|
|
333
|
+
const result = await provider.check(deep)
|
|
334
|
+
cached = result
|
|
335
|
+
return result
|
|
336
|
+
} catch (error) {
|
|
337
|
+
// A check that throws is itself a failure to report, not one to propagate.
|
|
338
|
+
const failed = { ok: false, detail: (error as Error).message }
|
|
339
|
+
cached = failed
|
|
340
|
+
return failed
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
store,
|
|
346
|
+
status,
|
|
347
|
+
|
|
348
|
+
async handle(request, pathname, principal) {
|
|
349
|
+
if (pathname === '/status' && request.method === 'GET') {
|
|
350
|
+
const privileged = hasSection(principal, 'settings')
|
|
351
|
+
// `?refresh=1` asks Bedrock again, model included; a plain read does not.
|
|
352
|
+
// Only whoever could act on the answer may ask, and no faster than a
|
|
353
|
+
// person would — otherwise it is a paid call any account can repeat.
|
|
354
|
+
const asked = new URL(request.url).searchParams.get('refresh') !== null
|
|
355
|
+
const refresh = asked && privileged && Date.now() - lastRefreshAt > REFRESH_INTERVAL_MS
|
|
356
|
+
if (refresh) lastRefreshAt = Date.now()
|
|
357
|
+
const current = await status(refresh)
|
|
358
|
+
return Response.json({
|
|
359
|
+
...current,
|
|
360
|
+
// The model id, the region, which profile the credentials came from and
|
|
361
|
+
// when they run out are what whoever can fix the configuration needs.
|
|
362
|
+
// An editor needs to know whether it works.
|
|
363
|
+
...(privileged
|
|
364
|
+
? {}
|
|
365
|
+
: {
|
|
366
|
+
detail: current.ok
|
|
367
|
+
? 'The assistant is available.'
|
|
368
|
+
: 'The assistant is unavailable. An administrator can see why.',
|
|
369
|
+
remedy: undefined,
|
|
370
|
+
expires: undefined,
|
|
371
|
+
}),
|
|
372
|
+
// So the drawer does not offer a re-check to somebody it would refuse.
|
|
373
|
+
canRefresh: privileged,
|
|
374
|
+
provider: options.config.provider.name,
|
|
375
|
+
mcp: options.config.mcp === true,
|
|
376
|
+
})
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (pathname === '/chat' && request.method === 'POST') {
|
|
380
|
+
return await chat(request, principal)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (pathname === '/changesets' && request.method === 'GET') {
|
|
384
|
+
const changesets = await store.listFor(principal.id)
|
|
385
|
+
return Response.json(changesets.map(changesetJson))
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// The full proposal, body included, for the pane that shows and edits it.
|
|
389
|
+
// The list deliberately leaves the body out: it is the prepared request and
|
|
390
|
+
// most of the payload.
|
|
391
|
+
const detail = /^\/changes\/([^/]+)$/.exec(pathname)
|
|
392
|
+
if (detail && request.method === 'GET') {
|
|
393
|
+
const change = await findChange(principal, detail[1] as string)
|
|
394
|
+
if (!change) return new Response('Not Found', { status: 404 })
|
|
395
|
+
return Response.json({ ...toJson(change), params: change.params, body: change.body })
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// A person editing what the assistant drafted. Only the body moves; the kind
|
|
399
|
+
// and the path parameters are fixed at proposal time, so this cannot turn a
|
|
400
|
+
// draft save into a different operation, and edited TSX is scanned again.
|
|
401
|
+
const edit = /^\/changes\/([^/]+)$/.exec(pathname)
|
|
402
|
+
if (edit && request.method === 'PUT') {
|
|
403
|
+
const change = await findChange(principal, edit[1] as string)
|
|
404
|
+
if (!change) return new Response('Not Found', { status: 404 })
|
|
405
|
+
if (change.status !== 'proposed') {
|
|
406
|
+
return Response.json(
|
|
407
|
+
{ error: `This change is already ${change.status}.` },
|
|
408
|
+
{ status: 409 },
|
|
409
|
+
)
|
|
410
|
+
}
|
|
411
|
+
const payload = (await request.json().catch(() => undefined)) as
|
|
412
|
+
| { body?: unknown }
|
|
413
|
+
| undefined
|
|
414
|
+
if (!payload || typeof payload.body !== 'object' || payload.body === null) {
|
|
415
|
+
return Response.json({ error: 'A body object is required.' }, { status: 400 })
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Only the fields this kind is edited through, merged onto what was
|
|
419
|
+
// proposed. Refused rather than quietly dropped: a silent drop looks like
|
|
420
|
+
// a save and is not one.
|
|
421
|
+
const allowed = EDITABLE_FIELDS[change.kind]
|
|
422
|
+
const sent = payload.body as Record<string, unknown>
|
|
423
|
+
const rejected = Object.keys(sent).filter((field) => !allowed.includes(field))
|
|
424
|
+
if (rejected.length > 0) {
|
|
425
|
+
return Response.json(
|
|
426
|
+
{
|
|
427
|
+
error: `A ${change.kind} proposal is edited through ${allowed.join(', ')}; ${rejected.join(', ')} ${rejected.length === 1 ? 'is' : 'are'} fixed at proposal time.`,
|
|
428
|
+
},
|
|
429
|
+
{ status: 400 },
|
|
430
|
+
)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const body = { ...(change.body as Record<string, unknown>), ...sent }
|
|
434
|
+
const problems = change.kind.startsWith('template')
|
|
435
|
+
? scanTemplateSource(
|
|
436
|
+
`${String(body.alias ?? 'template')}.tsx`,
|
|
437
|
+
typeof body.content === 'string' ? body.content : '',
|
|
438
|
+
)
|
|
439
|
+
: change.kind.startsWith('document-type') || change.kind.startsWith('data-type')
|
|
440
|
+
? await (options.schema?.validate(
|
|
441
|
+
change.kind,
|
|
442
|
+
String(body.alias ?? ''),
|
|
443
|
+
String(body.toml ?? ''),
|
|
444
|
+
) ?? Promise.resolve([]))
|
|
445
|
+
: []
|
|
446
|
+
await store.replaceBody(change.key, body, problems)
|
|
447
|
+
return Response.json({ key: change.key, problems })
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const diff = /^\/changes\/([^/]+)\/diff$/.exec(pathname)
|
|
451
|
+
if (diff && request.method === 'GET') {
|
|
452
|
+
const found = await findChange(principal, diff[1] as string)
|
|
453
|
+
if (!found) return new Response('Not Found', { status: 404 })
|
|
454
|
+
const call = callFor(request)
|
|
455
|
+
const [shown, described] = await Promise.all([
|
|
456
|
+
reviewChange(found, {
|
|
457
|
+
call,
|
|
458
|
+
operationPath: (operationId) => pathOf(operationId),
|
|
459
|
+
schema: options.schema,
|
|
460
|
+
}),
|
|
461
|
+
describeChange(found, { call }),
|
|
462
|
+
])
|
|
463
|
+
return Response.json({ ...shown, ...described })
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const settle = /^\/changes\/([^/]+)\/(approve|discard)$/.exec(pathname)
|
|
467
|
+
if (settle && request.method === 'POST') {
|
|
468
|
+
return await settleChange(
|
|
469
|
+
request,
|
|
470
|
+
principal,
|
|
471
|
+
settle[1] as string,
|
|
472
|
+
settle[2] as 'approve' | 'discard',
|
|
473
|
+
)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (pathname === '/mcp' && options.config.mcp === true) {
|
|
477
|
+
return await handleMcpRequest(request, {
|
|
478
|
+
tools: toolsFor(request, () => mcpChangeset(principal.id)),
|
|
479
|
+
serverName: options.siteName,
|
|
480
|
+
version: options.version,
|
|
481
|
+
})
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return undefined
|
|
485
|
+
},
|
|
486
|
+
}
|
|
487
|
+
}
|
package/src/backup.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rollback is a backup (docs/packaging-and-upgrades.md): SQLite is copied
|
|
3
|
+
* beside the database; Postgres runs the operator's `CROMBOLO_PG_DUMP` command
|
|
4
|
+
* or refuses unless the caller vouches with `--backup-taken`.
|
|
5
|
+
*/
|
|
6
|
+
import { copyFileSync, existsSync } from 'node:fs'
|
|
7
|
+
import type { CromboloConfig } from './config.ts'
|
|
8
|
+
|
|
9
|
+
export interface BackupOptions {
|
|
10
|
+
/** The operator has taken a Postgres backup themselves. */
|
|
11
|
+
backupTaken?: boolean
|
|
12
|
+
/** What the backup is for; goes into the file name. */
|
|
13
|
+
reason: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface BackupResult {
|
|
17
|
+
kind: 'sqlite-copy' | 'pg-dump' | 'operator' | 'none'
|
|
18
|
+
path?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class BackupRequiredError extends Error {
|
|
22
|
+
constructor() {
|
|
23
|
+
super(
|
|
24
|
+
'Postgres needs a backup before this writes: set CROMBOLO_PG_DUMP to a command that takes one, or pass --backup-taken.',
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function backupBefore(
|
|
30
|
+
config: CromboloConfig,
|
|
31
|
+
options: BackupOptions,
|
|
32
|
+
): Promise<BackupResult> {
|
|
33
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
34
|
+
if (config.dialect === 'sqlite') {
|
|
35
|
+
if (config.sqliteFile === ':memory:' || !existsSync(config.sqliteFile)) return { kind: 'none' }
|
|
36
|
+
const path = `${config.sqliteFile}.${stamp}.${options.reason}.bak`
|
|
37
|
+
copyFileSync(config.sqliteFile, path)
|
|
38
|
+
return { kind: 'sqlite-copy', path }
|
|
39
|
+
}
|
|
40
|
+
if (config.pgDump) {
|
|
41
|
+
const proc = Bun.spawn(['sh', '-c', config.pgDump], {
|
|
42
|
+
env: { ...process.env, CROMBOLO_BACKUP_STAMP: stamp, CROMBOLO_BACKUP_REASON: options.reason },
|
|
43
|
+
stdout: 'inherit',
|
|
44
|
+
stderr: 'inherit',
|
|
45
|
+
})
|
|
46
|
+
const code = await proc.exited
|
|
47
|
+
if (code !== 0) throw new Error(`CROMBOLO_PG_DUMP exited with ${code}; not continuing.`)
|
|
48
|
+
return { kind: 'pg-dump' }
|
|
49
|
+
}
|
|
50
|
+
if (options.backupTaken) return { kind: 'operator' }
|
|
51
|
+
throw new BackupRequiredError()
|
|
52
|
+
}
|