fix: reject invalid keyed mutation intents

This commit is contained in:
DongHyeonka
2026-08-02 01:21:50 +09:00
parent cbcc7b5ed7
commit fa2f699125
3 changed files with 272 additions and 8 deletions
+109 -3
View File
@@ -4,7 +4,11 @@ import {
type InstalledHttpContract,
} from "../../contracts/external-contract-runtime.ts";
import type { CacheScopeSnapshot } from "../../contracts/server-state-scope.ts";
import type { MutationIntent } from "../../contracts/mutation-intent.ts";
import {
defineMutationIntent,
MUTATION_INTENT_BOUNDS,
type MutationIntent,
} from "../../contracts/mutation-intent.ts";
import {
decodeJsonBytes,
isEffectivelyEmpty,
@@ -40,6 +44,8 @@ export type SafeResponseMetadata = Readonly<{
}>;
export type HttpContractViolationKind =
| "MISSING_IDEMPOTENCY_KEY"
| "UNEXPECTED_IDEMPOTENCY_KEY"
| "UNEXPECTED_STATUS"
| "UNEXPECTED_EMPTY_BODY"
| "UNEXPECTED_BODY"
@@ -174,6 +180,98 @@ const RETRY_BASE_DELAY_MS = 250;
const RETRY_MAX_LOCAL_DELAY_MS = 2_000;
const RETRY_AFTER_CEILING_MS = 5_000;
type MutationIntentValidation =
| Readonly<{ ok: true; intent?: MutationIntent }>
| Readonly<{
ok: false;
violation: "MISSING_IDEMPOTENCY_KEY" | "UNEXPECTED_IDEMPOTENCY_KEY";
}>;
function validateMutationIntent(
contract: Readonly<{
operationId: string;
retrySemantics: "SAFE" | "IDEMPOTENT" | "KEYED" | "NEVER";
commandEffect: unknown | null;
}>,
intent: MutationIntent | undefined,
): MutationIntentValidation {
const isCommand = contract.commandEffect !== null;
const requiresKey = contract.retrySemantics === "KEYED";
if (!isCommand) {
return intent === undefined
? Object.freeze({ ok: true })
: Object.freeze({
ok: false,
violation: "UNEXPECTED_IDEMPOTENCY_KEY",
});
}
if (intent === undefined) {
return requiresKey
? Object.freeze({ ok: false, violation: "MISSING_IDEMPOTENCY_KEY" })
: Object.freeze({ ok: true });
}
let validated: MutationIntent;
try {
validated = defineMutationIntent(intent);
} catch {
return Object.freeze({
ok: false,
violation: requiresKey
? "MISSING_IDEMPOTENCY_KEY"
: "UNEXPECTED_IDEMPOTENCY_KEY",
});
}
if (validated.operationId !== contract.operationId) {
return Object.freeze({
ok: false,
violation: requiresKey
? "MISSING_IDEMPOTENCY_KEY"
: "UNEXPECTED_IDEMPOTENCY_KEY",
});
}
const key = validated.idempotencyKey;
if (requiresKey && !validIdempotencyKey(key)) {
return Object.freeze({
ok: false,
violation: "MISSING_IDEMPOTENCY_KEY",
});
}
if (!requiresKey && key !== undefined) {
return Object.freeze({
ok: false,
violation: "UNEXPECTED_IDEMPOTENCY_KEY",
});
}
return Object.freeze({ ok: true, intent: validated });
}
const UTF8 = new TextEncoder();
function validIdempotencyKey(value: unknown): value is string {
return (
typeof value === "string" &&
value.trim().length > 0 &&
UTF8.encode(value).byteLength <=
MUTATION_INTENT_BOUNDS.idempotencyKeyMaxBytes &&
!hasControlCharacter(value)
);
}
function hasControlCharacter(value: string): boolean {
for (const character of value) {
const codePoint = character.codePointAt(0) ?? 0;
if (
codePoint <= 0x1f ||
(codePoint >= 0x7f && codePoint <= 0x9f)
) {
return true;
}
}
return false;
}
export function createContractHttpExecutor(
dependencies: ContractHttpExecutorDependencies,
): ContractHttpExecutor {
@@ -274,6 +372,14 @@ export function createContractHttpExecutor(
return finish(scopeFenced(preDispatchEffect(isCommand)), "NOT_STARTED");
}
const intentValidation = validateMutationIntent(contract, context.intent);
if (!intentValidation.ok) {
return finish(
violation(intentValidation.violation, "REQUEST", "NOT_STARTED"),
"NOT_STARTED",
);
}
const validated = invokeValidator(contract.inputValidator, input);
if (validated.outcome === "THROWN") {
return finish(
@@ -370,8 +476,8 @@ export function createContractHttpExecutor(
if (contract.requestBody === "JSON") {
headers["Content-Type"] = "application/json";
}
if (isCommand && context.intent?.idempotencyKey) {
headers["Idempotency-Key"] = context.intent.idempotencyKey;
if (intentValidation.intent?.idempotencyKey !== undefined) {
headers["Idempotency-Key"] = intentValidation.intent.idempotencyKey;
}
const retryCeiling = Math.min(