Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions migrations/sql/0054_app_update_cleanup_terminal.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
-- Immutable Hands producer receipt for Stamp's Android App Update cleanup
-- terminal. The operation, server-side inactive readback, exact event bytes,
-- subscriber, and signing generation are frozen in one D1 batch.

CREATE TABLE app_update_cleanup_terminal_receipts (
operation_id TEXT PRIMARY KEY
REFERENCES operation_logs(id) ON DELETE RESTRICT,
receipt_digest TEXT NOT NULL UNIQUE
CHECK (substr(receipt_digest, 1, 7) = 'sha256:' AND length(receipt_digest) = 71
AND substr(receipt_digest, 8) NOT GLOB '*[^0-9a-f]*'),
run_case_id TEXT NOT NULL UNIQUE,
attempt INTEGER NOT NULL CHECK (attempt > 0),
artifact_bundle_digest TEXT NOT NULL
CHECK (length(artifact_bundle_digest) = 64
AND artifact_bundle_digest NOT GLOB '*[^0-9a-f]*'),
app_id TEXT NOT NULL,
release_id TEXT NOT NULL UNIQUE,
release_revision INTEGER NOT NULL CHECK (release_revision >= 0),
build_id TEXT NOT NULL,
app_slug TEXT NOT NULL,
channel_slug TEXT NOT NULL,
target_artifact_sha256 TEXT NOT NULL
CHECK (length(target_artifact_sha256) = 64
AND target_artifact_sha256 NOT GLOB '*[^0-9a-f]*'),
target_version_code INTEGER NOT NULL CHECK (target_version_code > 0),
target_installation_digest TEXT NOT NULL
CHECK (substr(target_installation_digest, 1, 7) = 'sha256:' AND length(target_installation_digest) = 71
AND substr(target_installation_digest, 8) NOT GLOB '*[^0-9a-f]*'),
cancel_readback TEXT NOT NULL CHECK (cancel_readback = 'inactive'),
scope_deactivated INTEGER NOT NULL CHECK (scope_deactivated = 1),
scope_readback_json TEXT NOT NULL CHECK (json_valid(scope_readback_json)),
delivery_bindings_json TEXT NOT NULL CHECK (json_valid(delivery_bindings_json)),
canonical_request_json TEXT NOT NULL CHECK (json_valid(canonical_request_json)),
event_payload_json TEXT NOT NULL CHECK (json_valid(event_payload_json)),
canonical_receipt_json TEXT NOT NULL CHECK (json_valid(canonical_receipt_json)),
created_at INTEGER NOT NULL
);

-- Keep trigger bodies on one physical line for Cloudflare's remote D1 parser.
CREATE TRIGGER app_update_cleanup_terminal_receipts_no_update BEFORE UPDATE ON app_update_cleanup_terminal_receipts BEGIN SELECT RAISE(ABORT, 'App Update cleanup terminal receipts are immutable'); END;

CREATE TRIGGER app_update_cleanup_terminal_receipts_no_delete BEFORE DELETE ON app_update_cleanup_terminal_receipts BEGIN SELECT RAISE(ABORT, 'App Update cleanup terminal receipts are immutable'); END;

-- A delivery references the immutable producer receipt instead of overloading
-- feedback event foreign keys. One subscriber can receive a receipt once.
ALTER TABLE webhook_deliveries
ADD COLUMN app_update_terminal_receipt_id TEXT
REFERENCES app_update_cleanup_terminal_receipts(operation_id) ON DELETE RESTRICT;

CREATE UNIQUE INDEX idx_webhook_deliveries_app_update_terminal
ON webhook_deliveries(webhook_id, app_update_terminal_receipt_id)
WHERE app_update_terminal_receipt_id IS NOT NULL;

-- Generic operation mutation endpoints must never rewrite or delete the
-- durable intent/receipt identity, even if a future handler forgets the guard.
CREATE TRIGGER app_update_cleanup_terminal_operation_no_update BEFORE UPDATE ON operation_logs WHEN OLD.kind = 'app-update-cleanup-terminal' BEGIN SELECT RAISE(ABORT, 'App Update cleanup terminal operations are immutable'); END;

CREATE TRIGGER app_update_cleanup_terminal_operation_no_delete BEFORE DELETE ON operation_logs WHEN OLD.kind = 'app-update-cleanup-terminal' BEGIN SELECT RAISE(ABORT, 'App Update cleanup terminal operations are immutable'); END;
6 changes: 6 additions & 0 deletions worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ import {
handleUpsertReleaseCheck,
handleListReleaseChecks,
} from "./routes/releases";
import { handleEmitAppUpdateCleanupTerminal } from "./routes/app_update_cleanup";
import { handleListChannels, handleCreateChannel, handleUpdateChannel, handleDeleteChannel } from "./routes/channels";
import {
handleAddDeviceGroupMember,
Expand Down Expand Up @@ -738,6 +739,11 @@ admin.patch("/api/apps/:appId/releases/:releaseId", requireAppRole("publisher"),
admin.post("/api/apps/:appId/releases/:releaseId/publish", requireAppRole("publisher"), handlePublishRelease);
admin.delete("/api/apps/:appId/releases/:releaseId", requireAppRole("publisher"), handleDeleteRelease);
admin.post("/api/apps/:appId/releases/:releaseId/rollback", requireAppRole("publisher"), handleRollbackRelease);
admin.post(
"/api/apps/:appId/releases/:releaseId/app-update-cleanup-terminal",
requireAppRole("publisher"),
handleEmitAppUpdateCleanupTerminal,
);
admin.post("/api/apps/:appId/releases/:releaseId/bump-rollout", requireAppRole("publisher"), handleBumpRollout);
admin.post("/api/apps/:appId/releases/:releaseId/force-update", requireAppRole("publisher"), handleForceUpdate);
admin.get("/api/apps/:appId/releases/:releaseId/checks", requireAppRole("viewer"), handleListReleaseChecks);
Expand Down
33 changes: 33 additions & 0 deletions worker/src/openapi/releases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,17 @@ const PublishReleaseInput = z
.catchall(z.unknown())
.openapi("PublishReleaseInput");

const AppUpdateCleanupTerminalInput = z.object({
operation_id: z.string().min(1).max(128),
run_case_id: z.string().min(1).max(128),
attempt: z.number().int().positive(),
artifact_bundle_digest: z.string().regex(/^[0-9a-f]{64}$/),
expected_release_revision: z.number().int().nonnegative(),
target_device_id: z.string().min(1).max(200).openapi({
description: "Exact installation id used for resolver readback. Hands stores only its SHA-256 digest.",
}),
}).openapi("AppUpdateCleanupTerminalInput");

const ReleaseShare = z
.object({
id: z.string(),
Expand Down Expand Up @@ -297,6 +308,28 @@ export function registerReleaseRoutes(registry: OpenApiRegistry) {
});
}

register(registry, {
method: "post",
path: "/api/apps/{appId}/releases/{releaseId}/app-update-cleanup-terminal",
tags: ["Releases"],
summary: "Freeze one signed Android App Update cleanup terminal receipt",
description:
"Live-facing. Reads an already-cancelled current release generation through the Hands resolver, then atomically freezes one immutable operation, receipt, and signed delivery set. It never cancels or mutates the release.",
security: auth,
request: {
params: AppReleaseParams,
body: { content: json(AppUpdateCleanupTerminalInput), required: true },
},
responses: {
200: success("Exact response-loss replay of the frozen receipt.", GenericObject),
201: success("Created the immutable receipt and delivery set.", GenericObject),
400: error("Input is not the exact closed contract."),
403: error("Current principal cannot emit this terminal receipt."),
404: error("Release was not found."),
409: error("Release generation, target scope, artifact, subscriber, or immutable binding conflict."),
},
});

register(registry, {
method: "get",
path: "/api/apps/{appId}/shares",
Expand Down
Loading