Skip to content

fix(apps): close Mini Apps data-inheritance hole and fix release/run attribution - #4631

Merged
georgi merged 5 commits into
mainfrom
claude/mini-apps-security-fixes-n3qjf1
Aug 1, 2026
Merged

fix(apps): close Mini Apps data-inheritance hole and fix release/run attribution#4631
georgi merged 5 commits into
mainfrom
claude/mini-apps-security-fixes-n3qjf1

Conversation

@georgi

@georgi georgi commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

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 applications row. 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.

  • Real FKs with ON DELETE CASCADE from 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_versions and application_invocations carry a user_id stamped 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 null user_id and stay visible.
  • Ids are validated and claimed through an insert that fails on reuse (ApplicationIdInUseError) instead of an upsert. An id held by another user returns NOT_FOUND, so the error doesn't leak its existence.
  • Migration 20260801_000000 deletes orphans, backfills user_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.

  • Each transition is one transaction; Postgres locks the parent row FOR UPDATE to serialize publishers.
  • UNIQUE (application_id, version), with the migration deduping first.
  • Rollback verifies the target exists and belongs to the app before clearing anything.
  • The released lookup orders by version rather than trusting a single flag.

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 to operation_1. The runtime keys inputs and outputs opId:nodeId, so controls bound to main:* never reached the run and outputs never updated. The same literal made a second workflow-backed operation impossible to author.

  • The builder context carries the document's operations and a selected one; 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 unchanged.
  • Documents already saved with main still resolve: 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.

P1 — Mobile Mini Apps bypass application budgets and telemetry

Mobile loaded the released snapshot but sent neither application_id nor application_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_id is now optional on RunJobRequest and threaded from both the web and mobile app runtimes through to reserveInvocation, 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:

  • Publishing still behaves as owner-only version management rather than distribution — released document access is ownership-gated and no route serves another user's application.
  • Mobile supports only the first workflow-backed operation.

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 lint and npm run test all 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 main bindings 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_id reaches the reservation.

Note for reviewers: packages/models's migration count assertion moved 54 → 55, and application-budget-gate.test.ts gained seedApp() calls in one beforeEach — its "invocation settlement" block seeded no parent rows, which the new foreign key made fail.


Generated by Claude Code

claude added 4 commits August 1, 2026 16:26
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
Copilot AI review requested due to automatic review settings August 1, 2026 16:51

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-minted job_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
Copilot AI review requested due to automatic review settings August 1, 2026 17:08

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • reserveInvocation now supports passing userId, but this call omits it and forces an extra DB read (ownerOfApplication) on every admitted app run. Since userId is already known here, pass it through to avoid the redundant query.
      const decision = await reserveInvocation({
        applicationId,
        version,
        invocationId: jobId,
        operationId: req.operation_id ?? undefined,

@georgi
georgi merged commit 73bb074 into main Aug 1, 2026
26 checks passed
@georgi
georgi deleted the claude/mini-apps-security-fixes-n3qjf1 branch August 1, 2026 17:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants