fix(apps): close Mini Apps data-inheritance hole and fix release/run attribution - #4631
Merged
Conversation
Mobile loaded the released snapshot but sent neither `application_id` nor `application_version` on the run request. The server's budget gate permits requests without an application id, so every mobile run of a released Mini App escaped its reservation, spending cap, ledger row, and settlement. `useApplicationApp` now returns the release identity and threads it through AppScreen -> ApplicationAppView -> useAppRuntime -> WorkflowRunner.run(), which puts both fields on the request. A draft run sends a null version, which is what the server reads as "app run, not a release run". Mobile also minted no job id: the first message with an unrecognized job id claimed the oldest pending invocation, so parallel runs -- or any other workflow on the same connection -- could deliver status and outputs to the wrong operation. The runtime now mints the id before dispatch, registers the invocation under it, and folds a message only when it already owns that job id. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Su7dXD6fnXtKKbdCcM2vS
… "main" Every binding the builder wrote named the operation `main`, but a blank app's first operation is created as "Operation 1" and slugified to `operation_1`. The runtime keys inputs and outputs `opId:nodeId`, so a control bound to `main:*` never reached the `operation_1:*` run and no output widget ever updated. The same literal made a second workflow-backed operation impossible to author. The builder context now carries the document's operations and a selected one, and each binding persists the id it actually targets. Multi-operation apps get an operation picker on read/write/condition fields and on run/cancel events; single-operation apps render as before. The builder loads every operation's workflow so each offers its own bindable inputs and outputs. Documents already saved with `main` still work: resolveBinding maps that token onto the sole declared operation when the scope has exactly one and it isn't named `main`. Resolution happens on read -- no saved document is rewritten -- and any other operation id passes through untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Su7dXD6fnXtKKbdCcM2vS
… ran A client running a cached release claimed its own version, the server logged the mismatch, and then reserved and ledgered the run against whatever is released now. The graph that executed was the client's, so release metrics and budget attribution described a version the run never touched. The server now checks the claim against the app's version history. A real past version is honoured and recorded, so the ledger matches what executed; a version the app never had is refused with INVALID_INPUT, the same answer the "no released version" case already gave. The client still cannot invent its own attribution -- a number is only trusted once the server has found it in the history -- and a stale client gets a warning notification telling it to reload. Runs also carried no operation id, so every reservation landed under the ledger's empty-string default and per-operation reports were flat. `operation_id` is now optional on RunJobRequest and threaded from both app runtimes through to reserveInvocation, defaulting to the old value for clients that omit it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Su7dXD6fnXtKKbdCcM2vS
…tomic Application ids are client-supplied, and deleting an app removed only the parent row. Versions, budgets, invocations and pinned graphs had no ownership column and no cascading foreign key, so a user who knew a deleted app's id could recreate it under their own account: the ownership check passed against the new parent row while the child reads returned the previous owner's orphaned releases and usage. Child tables now carry real foreign keys with ON DELETE CASCADE, and delete runs as one transaction that erases children explicitly -- foreign key enforcement is per connection, so the cascade is declared and performed rather than assumed. application_versions and application_invocations gained a user_id stamped from the parent, and the reads that take only an application id can now filter on it; the tRPC router and the websocket runner pass the caller's id. Ids are validated and claimed through an insert that fails on reuse instead of an upsert, so a released id cannot be taken over. A migration deletes orphans, backfills user_id, and rebuilds the tables with the constraints. Publishing read MAX(version), cleared the released flag and inserted the new row as separate statements, with nothing in the schema forbidding a duplicate version or a second released row -- concurrent publishes could produce either, leaving the released lookup to pick arbitrarily. Each transition is now one transaction, (application_id, version) is unique, and the lookup orders by version. Rollback verifies the target exists before clearing anything, so a bad version number no longer leaves the app with nothing released. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Su7dXD6fnXtKKbdCcM2vS
Contributor
There was a problem hiding this comment.
Pull request overview
This PR hardens the Mini Apps platform end-to-end (web builder/runtime, mobile runtime, websocket runner, and models/migrations) to prevent cross-user data inheritance via client-supplied application IDs, make publish/rollback transitions atomic, and ensure runs are budgeted/ledgered against the correct application release and operation.
Changes:
- Add real ownership + cascading relationships for application children (versions, budgets, invocations) and migrate existing data to enforce them.
- Make publishing/rollback safe under concurrency (unique versioning, single released row selection by version, transactional transitions).
- Thread app run attribution through web + mobile (
application_id,application_version,operation_id, client-mintedjob_id) and enforce correct server-side billing/telemetry.
Reviewed changes
Copilot reviewed 42 out of 42 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| web/src/stores/WorkflowRunner.ts | Adds operation_id to run_job payload for per-operation ledger attribution. |
| web/src/stores/tests/WorkflowRunner.test.ts | Tests operation_id is sent for app runs and null otherwise. |
| web/src/components/appbuilder/runtime/useAppRuntime.ts | Threads operationId into workflow runs initiated by the app runtime. |
| web/src/components/appbuilder/puck/PuckAppEditor.tsx | Provides per-operation binding surfaces and passes full document/overrides into the design runtime. |
| web/src/components/appbuilder/puck/fields.tsx | Updates binding fields to target real operation IDs and adds multi-operation pickers (bindings + events + conditions). |
| web/src/components/appbuilder/puck/config.tsx | Adds operationId to event configuration (run/cancel target operation). |
| web/src/components/appbuilder/puck/BuilderWorkflowContext.tsx | Introduces operation-aware binding scope and per-operation workflow surfaces. |
| web/src/components/appbuilder/ApplicationAppBuilder.tsx | Fetches all operation workflows (not just the first) and passes them down for binding surfaces. |
| web/src/components/appbuilder/AppBuilderShell.tsx | Plumbs operationWorkflows into the editor shell. |
| web/src/components/appbuilder/tests/fields.test.tsx | Adds coverage for operation-targeted binding behavior, including legacy main mapping. |
| packages/websocket/tests/applications-service.test.ts | Tests service-level protections: delete cascades and ID reuse/id validation behavior. |
| packages/websocket/tests/application-budget-gate.test.ts | Expands budget gate tests: stale-release attribution, invalid version refusal, and operation_id recording. |
| packages/websocket/src/unified-websocket-runner.ts | Adds operation_id to RunJobRequest; validates claimed versions against history; reserves with operation + correct version. |
| packages/websocket/src/trpc/routers/applications.ts | Passes userId through version/invocation queries and release transitions. |
| packages/websocket/src/lib/applications-service.ts | Uses createUnique + id normalization; adds explicit delete semantics; scopes released export to owner. |
| packages/protocol/src/ws-commands.ts | Extends WS run_job schema with operation_id. |
| packages/protocol/src/api-types.ts | Extends RunJobRequest with optional operation_id. |
| packages/models/tests/migrations.test.ts | Bumps expected built-in migration count for the new migration. |
| packages/models/tests/migration-application-cascade.test.ts | Verifies orphan cleanup, backfill, uniqueness, and FK cascade behavior in SQLite migration. |
| packages/models/tests/application.test.ts | Adds tests for createUnique ID behavior, delete cascade behavior, and release transition invariants. |
| packages/models/tests/application-budget.test.ts | Seeds parent applications to satisfy new FKs and adjusts tests accordingly. |
| packages/models/src/schema/applications.ts | Adds FK cascade + user_id on versions and unique (application_id, version) constraint (SQLite). |
| packages/models/src/schema/application-budgets.ts | Adds FK cascade + user_id on invocations (SQLite). |
| packages/models/src/schema-pg/applications.ts | Adds FK cascade + user_id and unique (application_id, version) constraint (Postgres). |
| packages/models/src/schema-pg/application-budgets.ts | Adds FK cascade + user_id on invocations (Postgres). |
| packages/models/src/migrations/versions.ts | Adds migration 20260801_000000 to delete orphans, backfill ownership, dedupe, and rebuild constraints. |
| packages/models/src/index.ts | Re-exports new ID validation/uniqueness helpers and errors. |
| packages/models/src/db.ts | Updates SQLite schema bootstrap and missing-column map for new application child columns/constraints. |
| packages/models/src/application.ts | Implements normalizeApplicationId, createUnique, explicit delete cascade, transactional publish/rollback, and owner-scoped reads. |
| packages/models/src/application-budget.ts | Stamps invocations with owner (from parent when omitted) and scopes invocation listing by owner. |
| packages/app-runtime/tests/bindings.test.ts | Tests legacy main binding token resolution behavior. |
| packages/app-runtime/src/bindings.ts | Maps legacy op:main/... tokens onto a sole non-main operation at resolve time. |
| mobile/src/stores/WorkflowRunner.ts | Adds RunOptions for jobId/app identity/operation; threads into run requests. |
| mobile/src/stores/WorkflowRunner.test.ts | Tests app identity + client job_id behavior in run payload and store state. |
| mobile/src/screens/AppScreen.tsx | Passes loaded application run identity into the app runtime view. |
| mobile/src/hooks/useApplications.ts | Computes and exposes ApplicationRunIdentity based on whether release or draft doc is rendered. |
| mobile/src/hooks/useApplications.test.tsx | Tests run identity for released vs draft source. |
| mobile/src/components/app_runtime/useAppRuntime.ts | Mints job IDs before dispatch, routes messages strictly by owned job_id, and threads app identity/operation into runs. |
| mobile/src/components/app_runtime/ApplicationAppView.tsx | Accepts and forwards app run identity into useAppRuntime. |
| mobile/src/components/app_runtime/tests/useAppRuntime.test.tsx | Adds coverage for job ID minting, message routing, and application identity propagation. |
| mobile/src/components/app_runtime/tests/ApplicationAppView.test.tsx | Adjusts tests for runtime-minted job IDs. |
| mobile/ARCHITECTURE.md | Documents new run identity + release identity requirements for metering/telemetry. |
Comment on lines
+151
to
+156
| if (error instanceof ApplicationIdInUseError) { | ||
| throwApiError( | ||
| ApiErrorCode.ALREADY_EXISTS, | ||
| "An application with that id already exists" | ||
| ); | ||
| } |
Comment on lines
+108
to
+112
| const fetchedKey = Object.keys(fetched).join("|"); | ||
| const operationWorkflows = useMemo( | ||
| () => fetchedRef.current, | ||
| [fetchedKey] | ||
| ); |
…refetched graphs Two review findings. createApplication checks for the id and then inserts, so an id can be claimed between the two statements. The check answered NOT_FOUND for an id someone else holds, but the insert's collision answered ALREADY_EXISTS -- making the losing side of the race the one place that confirmed an id exists. The collision path now resolves the row and answers as the check would: the owner gets their app back, so a create that races itself stays idempotent, and everyone else gets what a missing id gets. The app builder memoised its per-operation workflow map on the set of ids that had arrived, so a refetch returning a new graph for an id already in the map left the binding pickers offering the surface the workflow used to have. The key now carries each query's dataUpdatedAt, which still holds the map's identity across renders that changed nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Su7dXD6fnXtKKbdCcM2vS
Contributor
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 42 out of 42 changed files in this pull request and generated no new comments.
Suppressed comments (1)
packages/websocket/src/unified-websocket-runner.ts:2662
reserveInvocationnow supports passinguserId, but this call omits it and forces an extra DB read (ownerOfApplication) on every admitted app run. SinceuserIdis already known here, pass it through to avoid the redundant query.
const decision = await reserveInvocation({
applicationId,
version,
invocationId: jobId,
operationId: req.operation_id ?? undefined,
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes seven issues in the Mini Apps platform: four release-blocking, three correctness gaps. Each is a separate commit.
P1 — Deleted application data can be inherited by another user
Application ids are client-supplied, and deleting an app removed only the parent
applicationsrow. Versions, budgets, invocations and pinned workflow graphs had no ownership column and no cascading foreign key. A user who knew a deleted app's id could recreate it under their own account: the ownership check passed against the new parent row while child reads returned the previous owner's orphaned releases and usage data.ON DELETE CASCADEfrom every child table, in both the SQLite and Postgres schemas.Application.delete()erases children in one transaction. FK enforcement is per connection, so the cascade is declared and performed rather than assumed — better-sqlite3 does enable the pragma by default, which a test now pins.application_versionsandapplication_invocationscarry auser_idstamped from the parent; the reads that take only an application id filter on it, and the tRPC router and websocket runner pass the caller's id. Rows predating the migration have a nulluser_idand stay visible.ApplicationIdInUseError) instead of an upsert. An id held by another user returnsNOT_FOUND, so the error doesn't leak its existence.20260801_000000deletes orphans, backfillsuser_id, dedupes, and rebuilds with the constraints. Idempotent on re-run.P1 — Publishing and rollback are non-atomic
Publishing read
MAX(version), cleared the released flag and inserted the new row as separate statements, with nothing in the schema forbidding a duplicate version or a second released row. Concurrent publishes could produce either, leaving the released lookup to pick arbitrarily. Rollback to a nonexistent version cleared the current release first and could leave the app with nothing released.FOR UPDATEto serialize publishers.UNIQUE (application_id, version), with the migration deduping first.P1 — Blank and custom-operation apps produce broken bindings
The builder wrote every binding against the operation
main, but a blank app's first operation is created as "Operation 1" and slugified tooperation_1. The runtime keys inputs and outputsopId:nodeId, so controls bound tomain:*never reached the run and outputs never updated. The same literal made a second workflow-backed operation impossible to author.mainstill resolve:resolveBindingmaps that token onto the sole declared operation when the scope has exactly one and it isn't namedmain. Resolution happens on read — no saved document is rewritten — and any other operation id passes through untouched.P1 — Mobile Mini Apps bypass application budgets and telemetry
Mobile loaded the released snapshot but sent neither
application_idnorapplication_version, and the server's budget gate permits requests without an application id. Every mobile run of a released app escaped its reservation, spending cap, ledger row and settlement. The identity now threads from the loaded snapshot through to the run request; a draft run sends a null version, which the server reads as "app run, not a release run".P2 — Mobile could attach a job to the wrong invocation
Mobile minted no job id: the first message with an unrecognized job id claimed the oldest pending invocation, so parallel runs — or any other workflow on the same connection — could deliver status and outputs to the wrong operation. The runtime now mints the id before dispatch, sends it as
job_id(which the server honours), and folds a message only when it already owns that job id.P2 — Stale released clients recorded as running the current release
A client running a cached release claimed its own version, the server logged the mismatch, then reserved and ledgered the run against whatever is released now — while executing the client's graph. Release metrics and budget attribution described a version the run never touched.
The server now checks the claim against the app's version history. A real past version is honoured and recorded; a version the app never had is refused with
INVALID_INPUT, matching the existing "no released version" refusal. The client still cannot invent its own attribution — a number is only trusted once found in the history — and a stale client gets a warning notification telling it to reload.P2 — Production runs omit operation-level telemetry
Runs carried no operation id, so every reservation landed under the ledger's empty-string default and per-operation reports were flat.
operation_idis now optional onRunJobRequestand threaded from both the web and mobile app runtimes through toreserveInvocation, defaulting to the old value for clients that omit it.Not addressed here
Two points from the review are scope decisions rather than defects, and are left alone deliberately:
Both should be documented or built out before Mini Apps are described as shareable applications. Happy to do either in a follow-up.
Verification
npm run typecheck,npm run lintandnpm run testall pass on a freshly built tree — 1063 + 63 + 73 suites, ~14,145 tests, exit 0.New tests cover: cascade deletion removes children and a reclaimed id sees nothing; id reuse and malformed ids are rejected; publish is atomic with one released row and no duplicate versions; rollback to a missing version leaves the current release standing; migration orphan-cleanup, backfill and dedupe; blank-app bindings target the generated operation id; a two-operation app binds each widget to its own operation; legacy
mainbindings still resolve; the mobile run request carries release identity; concurrent mobile runs route to the correct invocation and an unknown job id is ignored; a run claiming a real older version is ledgered against it while a nonexistent version is refused;operation_idreaches the reservation.Note for reviewers:
packages/models's migration count assertion moved 54 → 55, andapplication-budget-gate.test.tsgainedseedApp()calls in onebeforeEach— its "invocation settlement" block seeded no parent rows, which the new foreign key made fail.Generated by Claude Code