Repository navigation
Expand file tree
/
Copy pathroute.ts
More file actions
322 lines (305 loc) · 16.4 KB
/
Copy pathroute.ts
File metadata and controls
322 lines (305 loc) · 16.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
import { NextRequest } from "next/server";
import {
ObjectRouteError,
handleObjectRequest,
optionalStringArray,
readObjectRouteBody,
requireString,
} from "@/lib/api/object-route";
import { isObjectEditOutcomeShape, isObjectEditPlanShape } from "@/lib/api/object-edit-wire";
import { verifyPlanToken } from "@/lib/api/object-edit-plan-token";
import { ApiErrorCode } from "@/lib/api/error-codes";
import {
EDIT_BODY_BYTE_LIMIT,
EDIT_PLAN_EXECUTABLE_LIMIT,
auditReadingFor,
planExecutableLength,
} from "@/lib/db/object-edit";
import { connectionFingerprint } from "@/lib/db/connection-fingerprint";
import { requireEditableKind } from "@/lib/db/object-kinds";
import { emitAuditEvent } from "@/lib/audit";
import { logger } from "@/lib/logger";
import { clientAddress } from "@/lib/api/client-address";
import type { GovernedConnection } from "@/lib/governance/visible";
import { admitQueryExecution } from "@/lib/query-events/admission";
import { queryEventStart } from "@/lib/query-events/build";
import { recordQueryEvent } from "@/lib/query-events/sink";
import type { QueryEventInput, QueryEventStatus } from "@/lib/query-events/types";
import type { ObjectEditOutcome, ObjectEditPlan, ObjectEditUnit } from "@/lib/db/types";
export const dynamic = "force-dynamic";
const ROUTE = "api/db/objects/edit-apply";
/**
* `POST /api/db/objects/edit-apply`: the half that WRITES (#789 Phase 3, discussion #778).
*
* IT TAKES THE PLAN AND NEVER THE TEXT AGAIN, which is ruling 1a in the letter: the bytes the user
* approved in the preview are `plan.unit`, and `plan.unit` is what the engine receives. The seal
* (`planToken`) is what makes that binding unforgeable while leaving the plan READABLE, which is
* adjudication 2d: "opaque" means sealed, not unreadable.
*
* THIS IS THE FIRST RECORD OF A USER'S WRITE ANYWHERE IN THIS PRODUCT, and ruling 1c is why it is a
* new `AuditEventType` arm rather than a reused `query_execution`: MEASURED, three DDL statements
* through `POST /api/db/query` added ZERO events to the ring while one VACUUM on the same
* connection in the same minute added exactly one.
*
* THE TWO EVENTS AND WHY THEY ARE TWO. The DECISION event is emitted BEFORE the provider is called
* and is NOT wrapped in a try/catch, so any audit-sink failure propagates and the apply never
* happens: the path fails closed on an unauditable write rather than performing it silently. That
* is `src/lib/db/operations/execution.ts:11-20`'s rule for the agent path, applied here for the
* same reason. The OUTCOME event is emitted after and IS wrapped, which is the rule of the
* completed-run audit row in `POST` of `src/app/api/db/maintenance/route.ts`, and its stated
* reason: the engine has
* already acted, and a broken sink must never turn a completed apply into a 500 that invites a
* retry that would be a SECOND DDL.
*
* WHAT AN EVENT MAY NEVER CARRY: the statement, the command payload, the reader's text, the
* pre-image, the engine's message, the engine's code, the revision token, the plan token, or any
* other part of the plan. `emitAuditEvent`'s docblock in `src/lib/audit.ts` forbids SQL text,
* request bodies and raw `Error.message` by name, `AuditReason` is closed precisely so no path can
* put a driver string into a record, and `MAX_AUDIT_FIELD_LENGTH` is 254, so an unvalidated string
* would be TRUNCATED rather than refused. What the two events carry instead is the object address,
* the kind, the part, the resolved strategy and one correlation id, which is `plan.planId`.
*
* `target` IS `plan.path.join("/")`, which is design 5.5's own spelling and is used here unchanged,
* with its limit named rather than discovered: it is a FOURTH spelling of a path key in a
* repository whose `pathKey` docblock says by name that a slash collides, so `["a/b"]` and
* `["a","b"]` produce the same audit target. It is inherited rather than introduced, the audit
* target is a human-readable label and never a lookup key, and changing it here would put the
* audit's spelling out of step with the design.
*
* NO ADMIN GATE, and the ruling is MEASURED rather than preferred: it is recorded in full on
* `edit-plan/route.ts` beside the same decision, with the limit that the measurement covers SQL
* engines and Redis was not probed. This route inherits the seed role filter for free by going
* through `resolveConnection` and adds no role test of its own.
*
* ONCE `applyObjectEdit` HAS BEEN CALLED, NO ERROR ESCAPES AS AN HTTP ERROR. Every throw becomes
* `{ outcome: "interrupted", committed: "unknown" }` at 200, and so does an answer the outcome
* predicate cannot read. That one rule closes three measured defects at once: a deliberate engine
* refusal can never be a 500, a timeout can never answer `retryable: true`, and a provider that
* forgets to classify one of its engine's errors degrades to "the disposition is unknown" rather
* than to a false success. There is NO `retryable` on this route at any status: a client that
* retries an apply whose disposition is unknown applies twice.
*/
export async function POST(req: NextRequest) {
return handleObjectRequest(
req,
ROUTE,
async (provider, body, context) => {
// 1. THE SHAPE, BEFORE THE SEAL. The seal's own refusal emits an audit event that reads
// `plan.kind`, `plan.path` and `plan.planId`, and a body that is not a plan has none of
// them, so checking the seal first would write `undefined:undefined:undefined` into an
// operator's log. It is also design 7.9's second seam: a `command` unit carrying `steps`
// compiles, and only this predicate stops it.
const plan: unknown = body.plan;
if (!isObjectEditPlanShape(plan)) {
throw new ObjectRouteError('"plan" must be an object edit plan this server can read', 400);
}
const planToken = requireString(body, "planToken");
const audit = auditFields(plan, context);
// 2. THE SEAL, against the fingerprint of the connection THIS request resolved. The
// fingerprint is recomputed here and never taken from the plan, which is the whole of the
// different-server check: a plan is bound to a SERVER and never to `connection.id`, and
// MEASURED, `resolveConnection` returns an inline connection object verbatim, id included,
// so an id check is satisfied by a caller typing the id it wants.
const verdict = await verifyPlanToken(planToken, plan, await connectionFingerprint(context.connection));
if (!verdict.valid) {
// ONE event, and no provider call. A refused plan is a decision this route made about a
// write that never happened, and it is the one an operator most needs to see.
emitAuditEvent({ ...audit, result: "failure", reason: "object_edit_plan_invalid" });
throw new ObjectRouteError(verdict.reason, 400, ApiErrorCode.EDIT_PLAN_INVALID);
}
// 3. THE EXECUTABLE BOUND, RE-CHECKED HERE, enforcing rather than trusting (design 5.2). A
// valid token proves the plan is the one this server issued; it proves NOTHING about what
// this server's bound is TODAY. A plan minted before the bound moved, or by a build path a
// later phase adds, meets the number here rather than the framework's silent truncation.
// The sentence is the same sentence `edit-plan/route.ts` answers, written out in both
// files because a Next route module may export nothing but its handler.
const executable = planExecutableLength(plan.unit);
if (executable > EDIT_PLAN_EXECUTABLE_LIMIT) {
throw new ObjectRouteError(
`this apply would send ${executable} characters and this server sends at most ` +
`${EDIT_PLAN_EXECUTABLE_LIMIT}`,
400,
);
}
// 4. THE ACKNOWLEDGEMENT, enforced by the SERVER. A client-only confirmation satisfies
// nothing a server can assert: the shipped query confirmation is a client modal with a
// `skipSafety` bypass. Every consequence class the plan names must be in `acknowledged`,
// and an acknowledgement of some other class is not an acknowledgement of this one.
const acknowledged = optionalStringArray(body, "acknowledged") ?? [];
const unacknowledged = plan.consequences.filter((consequence) => !acknowledged.includes(consequence.loses));
if (unacknowledged.length > 0) {
throw new ObjectRouteError(
`this apply destroys something the plan warned about and the request did not acknowledge: ` +
`${unacknowledged.map((consequence) => consequence.loses).join(", ")}`,
400,
);
}
// 5. EDITABILITY, re-resolved on the CONNECTED provider (D57). `requireEditableKind` throws a
// `QueryError`, which `createErrorResponse` already answers 400 with the refusal's own
// sentence, so it is not re-wrapped: two mappings of one refusal is how two sentences
// drift apart.
requireEditableKind(provider.getCapabilities(), plan.kind, {
displayName: provider.type,
type: provider.type,
});
// BOUND to the provider, for `requireSourceReader`'s reason: read off the instance as a
// value, a method reaching its own pool through `this` would be called with no receiver. The
// absence is refused BEFORE the decision event, so an apply that could never run leaves no
// record claiming it was about to.
const applyEdit = provider.applyObjectEdit;
if (applyEdit === undefined) {
throw new ObjectRouteError(`${provider.type} declares no edit applier for kind "${plan.kind}"`, 400);
}
// 6. THE ADMISSION GATE (docs/GOVERNANCE.md, "Query events"), before the decision event: while a governed
// Studio cannot record query events it refuses the apply with 503 AUDIT_UNAVAILABLE, and the one event it
// leaves is the `refused` query event. With governance off it never refuses and nothing is recorded.
const eventStart = queryEventStart();
const recordEditEvent = (status: QueryEventStatus, errorCode?: string) =>
recordQueryEvent(objectEditEvent(plan, context, req, eventStart, status, errorCode));
try {
admitQueryExecution();
} catch (error) {
recordEditEvent("refused", ApiErrorCode.AUDIT_UNAVAILABLE);
throw error;
}
// 7. THE DECISION EVENT, NOT WRAPPED, so an unauditable apply never happens.
emitAuditEvent({ ...audit, action: "PLAN", result: "success" });
// 8. THE APPLY. Everything below answers 200.
const started = Date.now();
let outcome: ObjectEditOutcome;
try {
outcome = await applyEdit.call(provider, plan);
} catch (error) {
logger.error("Object edit apply threw", error, { route: ROUTE });
outcome = interrupted(Date.now() - started);
}
if (!isObjectEditOutcomeShape(outcome)) {
// The engine has already acted, so the honest answer is that the disposition is unknown. A
// 400 would say the CALLER made a mistake, and a success would be the lie this arm exists
// to prevent.
logger.error("Object edit apply answered an outcome this server cannot read", undefined, { route: ROUTE });
outcome = interrupted(Date.now() - started);
}
// 9. THE QUERY EVENT, once the outcome is known, with the engine's code of a refusal that gave one.
// `recordQueryEvent` never throws, so it needs no try/catch of its own.
recordEditEvent(
auditReadingFor(outcome).result === "success" ? "success" : "error",
outcome.outcome === "refused" ? outcome.refusal.code : undefined,
);
// 10. THE OUTCOME EVENT, in its OWN try/catch.
try {
const reading = auditReadingFor(outcome);
emitAuditEvent({
...audit,
action: plan.strategy,
result: reading.result,
...(reading.reason === undefined ? {} : { reason: reading.reason }),
duration: outcome.duration,
});
} catch (auditError) {
logger.error("Failed to record object edit outcome audit event", auditError, { route: ROUTE });
}
return outcome;
},
{ readBody: (request) => readObjectRouteBody(request, EDIT_BODY_BYTE_LIMIT) },
);
}
/**
* The fields both events share, built once so the two records cannot disagree about which object
* was addressed or which edit they belong to.
*
* `action` and `result` are supplied per event and `reason` and `duration` only by the outcome, so
* they are not here: a shared default for either would be a value one of the two events did not
* mean.
*
* THE TWO FALLBACKS ARE NOT THE SAME KIND OF THING, and the difference is measured rather than
* assumed. `connectionName`'s two arms both have a live population: `resolveConnection` returns an
* INLINE caller-supplied connection object verbatim, so a connection whose `name` is empty is the
* caller's to send, and both the database arm and the `"unknown"` arm are driven by
* `tests/api/db/objects/edit-apply.test.ts`. `user`'s arm has NONE: `guardRoute` keys the rate
* limiter on `session.username` before this handler runs, and a session without one dies at
* `truncatedKey` in `src/lib/api/rate-limit.ts` with
* `TypeError: undefined is not an object (evaluating 'key.slice')`, which is what a draft of that
* test MEASURED. `SessionPayload.username` is a required `string`, so the fallback here is an
* obligation of `ObjectRouteContext`'s looser `username?: string` and nothing more; it is kept
* because the type requires it and because `src/app/api/db/maintenance/route.ts` spells the same
* expression the same way.
*/
function auditFields(
plan: ObjectEditPlan,
context: { readonly connection: { name?: string; database?: string }; readonly session: { username?: string } },
) {
return {
type: "object_edit",
action: "PLAN",
target: `${plan.kind}:${plan.path.join("/")}:${plan.partId}`,
details: plan.strategy,
correlationId: plan.planId,
connectionName: context.connection.name || context.connection.database || "unknown",
user: context.session.username || "unknown",
} as const;
}
/**
* The one outcome this ROUTE may mint, and the only one it ever does.
*
* `committed: "unknown"` and never `"rolled-back"`: that claim may be made ONLY by a provider that
* opened and closed the transaction itself, and this route opened nothing. The sentence carries no
* engine words, because it is not the engine speaking: nothing arrived.
*/
function interrupted(duration: number): ObjectEditOutcome {
return {
outcome: "interrupted",
committed: "unknown",
sentence:
"This apply was sent and no answer this server could read came back, so whether it was applied is unknown. " +
"Re-read the object before trying again: a retry would be a second apply.",
duration,
};
}
/**
* The query event of one apply (docs/GOVERNANCE.md, "Query events"): source `object-edit`, `correlation_id` set to
* `plan.planId`, the id the two audit events carry too, and the statement `plan.unit`, the text the reader approved
* and the engine received. Under governance the connection is a governed record, which carries the level of its
* credential, and the session carries the identity provider subject its grant names.
*/
function objectEditEvent(
plan: ObjectEditPlan,
context: {
readonly connection: { id: string; name: string; type: GovernedConnection["type"] };
readonly session: { role: string; username?: string; idpSubject?: string };
},
req: NextRequest,
started: QueryEventInput["started"],
status: QueryEventStatus,
errorCode: string | undefined,
): QueryEventInput {
const { connection, session } = context;
return {
source: "object-edit",
principal: {
subject: session.idpSubject ?? "",
username: session.username ?? "",
role: session.role === "admin" ? "admin" : "user",
},
connection: {
id: connection.id,
name: connection.name,
engine: connection.type,
accessLevel: (connection as Partial<GovernedConnection>).grantLevel ?? null,
},
statement: unitText(plan.unit),
status,
started,
errorCode,
correlationId: plan.planId,
clientIp: clientAddress(req),
};
}
/**
* The text of a unit as the engine receives it: the statements of each round trip, one per line, or the command line
* with its payload between the tokens before and after it.
*/
function unitText(unit: ObjectEditUnit): string {
if (unit.medium === "statement") return unit.steps.map((step) => step.text).join("\n");
return [unit.name, ...unit.arguments, unit.payload.text, ...(unit.trailing ?? [])].join(" ");
}