> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tuturuuu.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Signed URL re-key runbook

> Revoke leaked Supabase signed URLs for private workspace objects by re-keying them to new storage keys.

A Supabase signed URL stays valid until it expires, as long as an object still
exists at its exact key. Signed URLs have no revocation list. To revoke every
token issued for a key, copy the object to a new, unpredictable key, move every
reference, and then delete the old key. The operator tool for this lives in
`scripts/storage-rekey/`. It is framework-independent and has no dependencies.

Use it only with per-root approval from the release authority. Do not rotate the
storage JWT secret instead. Rotation invalidates every signed URL and session
across the project, and it cannot be rolled back.

## Preconditions

* Storage RLS ownership (#6879) is live in production, so members cannot mint
  new long-lived tokens directly.
* The release authority has given an explicit GO for each root.
* The AI-chat filename compatibility change from the tool PR is deployed. It
  strips the `<uuidv4>-` re-key prefix from display names and from MarkItDown
  name lookup. Without it, re-keyed `chats/ai/resources/` files show the UUID
  in the UI.
* For `.tuturuuu/`, mobile deployment vault edits are frozen for the duration of
  the run (see [inventory](#reference-inventory)).
* Run off-peak, from an operator host with the ledger directory at mode 700.

## How the tool works

Each object moves through these states, recorded in a mode-600 JSON ledger:

`selected → copied → verified → refs_updated → old_deleted`, with `skipped`,
`failed` and `rolled_back` as exits.

1. **Select.** This is a read-only metadata query on `storage.objects` for one
   root. Objects whose chat, conversation, attachment or owner has been deleted
   are recorded as `skipped` and listed. They are never copied.
2. **Copy.** A server-side Storage copy to `<dir>/<uuidv4>-<file>` in the same
   directory, so prefix checks and listings still match. The new key is
   validated so it can never equal the old one.
3. **Verify.** Compares the new object's size and content type with the
   selected metadata. Bytes are never read.
4. **Update refs.** One SQL statement per object, so it is one transaction. It
   holds a compare-and-swap on the old object's `id` and `updated_at`, plus a
   row lock. If the object changed since selection, the tool deletes its copy
   and marks the object `skipped` (`changed_since_selection`). The same
   statement restores `owner`, `owner_id` and `created_at` on the copy, because
   service-role copies drop ownership and AI chat attachments are ordered by
   `created_at`.
5. **Delete old.** This runs only after the refs commit. Afterwards the tool
   confirms through metadata that the old key is gone.

Safeguards:

* Batches hold at most 50 objects.
* Each operation is retried up to 3 times with exponential backoff on network,
  429 and 5xx errors.
* `--max-ops` caps the total Storage and SQL operations for a run (500 by
  default).
* The tool checks a durable `STOP` file (default: `<ledger>.STOP`) before every
  operation.
* A ledger lock file prevents two concurrent runs.
* Logs carry only object ids, states and counts. They never include names or
  bytes.

Credentials:

* `SUPABASE_ACCESS_TOKEN_FILE` must name a mode-600 file holding a Supabase
  Management API token.
* SQL runs through the Management API query endpoint, and selection uses
  `read_only`.
* The service-role key is fetched into memory and never persisted.

## Reference inventory

Re-keying changes object keys, so each root must have every holder below updated
in the same refs step.

### `chats/`

* `private.chat_message_attachments.storage_path` (relative, `chats/<conversation>/<file>`) and `full_path` (full key): updated.
* `public.ai_chat_messages.metadata`: every whole JSON string equal to the old full key is rewritten, along with the `ai-file:<encodeURIComponent(key)>` attachment id. This covers `toolResults[].output.storagePath`, `ai.parts[].output.storagePath`, `toolCalls[].input.storagePath` and `attachments[].storagePath`/`id`. Rows are scoped by `chat_id` when the key names a chat.
* `chats/ai/resources/<chatId>/` and `temp/<userId>/` have no key column. Readers list the folder, so the new same-directory key is found automatically. The restored `created_at` keeps attachment order.
* Prefix checks in database functions (`starts_with(path, 'chats/<conversation>/')`) and in route guards still pass for same-directory keys.
* Not rewritten:
  * Idempotency payload hashes for chat send replay and external delivery. An in-flight retry that still carries an old path is rejected as a payload mismatch.
  * The native AI mirror (`<sha256>-<i>-<file>`). It is re-derived on the next turn.

### `mira/images/`

* `public.ai_chat_messages.metadata` `storagePath`: rewritten. The key names
  no chat, so each object's update scans `ai_chat_messages`. That is
  acceptable for the 3 objects in scope. Scope it by message before using the
  tool on a large Mira set.
* `private.ai_credit_reservations.metadata` and `public.ai_credit_transactions.metadata` `storagePath`: rewritten on `image_generation` rows. These are not scoped by workspace, because their `ws_id` is the billing workspace, which can differ from the path. The chat sign-on-read check uses the transaction `storagePath`.
* The stored `imageUrl` is a signed URL for the old key. The chat UI signs
  `storagePath` on read and falls back to `imageUrl` only when no path is
  available. That UI change must be deployed before the `mira/images` run.

### `.tuturuuu/`

The root workspace's mobile deployment vault has exactly one reference holder:

* `private.mobile_deployment_file_artifacts.storage_path` (relative, `storage_provider = 'supabase'`). Draft inheritance copies artifact rows between versions with the same `storage_path`, so one object can have several rows. The single refs statement updates all of them together.

Side effect: the `mobile_deployment_track_draft_write` trigger bumps
`inheritance_revision` on each affected version. It raises an error if that
version's `inheritance_initialized` is false. In that case the whole statement
rolls back and the object is marked `failed`, with no partial update. Audit
event metadata records kinds and sizes, never paths. The bundle fetch reads
`storage_path`, so it follows the database. R2-provider rows are out of scope.
`.tuturuuu` execution also requires `--references-confirmed`.

### Held outside our database

These holders cannot be rewritten by the tool:

* Mobile caches: restored chat attachments, offline upload receipts, the image cache key `assistant-tool-image:<storagePath>`, and composer drafts.
* Short-lived (1-hour) signed URLs held in web and mobile client state.
* Copies of the chat and vault helpers in `apps/infrastructure`.

Stale client caches recover after a refresh. AI providers receive inline bytes,
not keys.

## Production procedure

The examples set these variables:

```bash theme={null}
export SUPABASE_ACCESS_TOKEN_FILE=~/.config/<ops>/supabase-access.token  # mode 600
LEDGER_DIR=$(mktemp -d) && chmod 700 "$LEDGER_DIR"
REKEY="node scripts/storage-rekey/cli.mjs --project-ref $PROD_PROJECT_REF \
  --environment production --ledger $LEDGER_DIR/ledger.json"
```

Run the roots in the approved order. For each root:

1. **Dry run (read-only).** This writes `selected` and `skipped` rows plus a
   mode-600 approval token file, `<ledger>.<root slug>.approve-token`, where the
   slug is `chats`, `mira_images` or `_tuturuuu`.

   ```bash theme={null}
   $REKEY --root chats            # then: --root mira/images, --root .tuturuuu
   ```

   Attach the printed per-state counts and the skipped ids to the approval
   record. Then stop and get the release authority's GO.

2. **Execute.** The token works once and is bound to the exact set of pending
   and failed rows. If that set changes, the token is rejected. The tool reads
   the token from its mode-600 file and never accepts it on the command line,
   so it stays out of process listings and shell history.

   ```bash theme={null}
   $REKEY --execute --root chats --approve-token-file $LEDGER_DIR/ledger.json.chats.approve-token
   $REKEY --execute --root mira/images --approve-token-file $LEDGER_DIR/ledger.json.mira_images.approve-token
   $REKEY --execute --root .tuturuuu --references-confirmed \
     --approve-token-file $LEDGER_DIR/ledger.json._tuturuuu.approve-token
   ```

   Exit code `3` means the run stopped, either on the `STOP` file or the
   `--max-ops` cap. The ledger is consistent, so you can resume.

3. **Resume after a stop.** Remove the `STOP` file, run
   `$REKEY --issue-token --root <root>` to write a fresh token file, then run
   `--execute` again. Rows already in the `copied`, `verified` or
   `refs_updated` state continue from where they stopped. A copy that landed
   before a crash is adopted, never duplicated.

4. **Retry failed rows.** A `failed` row records the state it failed from. A
   failure at delete is the case that matters: refs are already on the new key,
   but the old key and its leaked URLs are still live. After the cause is
   fixed, run `$REKEY --issue-token --root <root>` and then:

   ```bash theme={null}
   $REKEY --execute --root <root> --retry-failed \
     --approve-token-file $LEDGER_DIR/ledger.json.<root>.approve-token
   ```

   Each failed row resumes at its recorded state. A row that has failed 3 times
   stays `failed` and is listed as `exhausted`. Roll it back or investigate it.

5. **Report.** Run `$REKEY --status` to print per-root counts for every state,
   plus the skipped and failed ids.

## Stop and rollback

* **Stop:** run `touch $LEDGER_DIR/ledger.json.STOP`. The job halts before its
  next Storage or SQL operation, and stays halted across restarts until you
  remove the file.
* **Rollback before `old_deleted`:** this is lossless. Remove `STOP`, run
  `$REKEY --issue-token --root <root>`, then run
  `$REKEY --rollback --root <root> --approve-token-file $LEDGER_DIR/ledger.json.<root>.approve-token`. For every
  `selected`, `copied`, `verified`, `refs_updated` or `failed` row, the
  rollback reverts the references to the old key, deletes the copy, and marks
  the row `rolled_back`.
* **After `old_deleted`:** the tool refuses to roll back. It reports the count
  in `oldDeletedNotRolledBack`. Copying the new key back to the old key revives
  every leaked token, so that is an incident decision for the release authority.
* Keep the ledger, which holds the old-to-new mapping, on the operator host for
  the retention period. Never paste it into tickets or chat.

## Revocation check and the CDN cache

* Storage serves a signed URL only while an object exists at its key. After
  `old_deleted`, the tool confirms through metadata that no `storage.objects`
  row remains at the old key.
* Supabase Smart CDN can briefly serve cached bytes after a delete. Before you
  claim revocation, `HEAD` a sample of known-old URLs taken from edge or API
  logs within their retention window, and confirm they return 400 or 404. In
  production the tool never mints signed URLs for private objects.
* In staging, `--probe-every N` mints a 10-minute signed URL for every Nth
  object before deletion. After deletion it records the `HEAD` status, and
  keeps only the status in the ledger.

## Staging rehearsal

`scripts/storage-rekey/rehearsal.mjs` creates synthetic fixtures in the seeded
staging workspace, verifies them, and removes them. It refuses any project not
named as development or staging. It covers:

* AI chat files.
* A native chat attachment, plus a deleted one.
* A file from a deleted chat.
* A Mira image.
* A `.tuturuuu` object.
* Two vault versions that share one artifact path.

```bash theme={null}
export SUPABASE_ACCESS_TOKEN_FILE=~/.config/tuturuuu-staging/supabase-access.token
R=$(mktemp -d); H="node scripts/storage-rekey/rehearsal.mjs"
$H seed --project-ref $STAGING_REF --state $R/state.json
$H seed-vault --project-ref $STAGING_REF --state $R/state.json
S="node scripts/storage-rekey/cli.mjs --project-ref $STAGING_REF --environment staging --ledger $R/ledger.json"
$S --root chats --ws-id 00000000-0000-0000-0000-000000000002      # dry run
$S --execute --root chats --probe-every 1 --approve-token-file $R/ledger.json.chats.approve-token &
sleep 3; touch $R/ledger.json.STOP; wait                         # stop mid-run: exit 3
rm $R/ledger.json.STOP; $S --issue-token --root chats             # restart
$S --execute --root chats --probe-every 1 --approve-token-file $R/ledger.json.chats.approve-token
$H verify --project-ref $STAGING_REF --state $R/state.json
# Rollback before old_deleted: stop at refs_updated with an op budget, then roll back.
$S --root mira/images --ws-id 00000000-0000-0000-0000-000000000002
$S --execute --root mira/images --max-ops 6 --approve-token-file $R/ledger.json.mira_images.approve-token
$S --issue-token --root mira/images
$S --rollback --root mira/images --approve-token-file $R/ledger.json.mira_images.approve-token
$H cleanup --project-ref $STAGING_REF --state $R/state.json      # expect zero remaining
```

The unit tests in `scripts/storage-rekey/*.test.mjs` cover:

* Ledger transitions and terminal states.
* The one-time, plan-bound approval token.
* The stop fence and the op budget.
* Restart adoption of a copy that landed before a crash.
* CAS skips and deleted-chat skips.
* Rollback, including the refusal after `old_deleted`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.