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-keyedchats/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). - 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.
- Select. This is a read-only metadata query on
storage.objectsfor one root. Objects whose chat, conversation, attachment or owner has been deleted are recorded asskippedand listed. They are never copied. - 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. - Verify. Compares the new object’s size and content type with the selected metadata. Bytes are never read.
- Update refs. One SQL statement per object, so it is one transaction. It
holds a compare-and-swap on the old object’s
idandupdated_at, plus a row lock. If the object changed since selection, the tool deletes its copy and marks the objectskipped(changed_since_selection). The same statement restoresowner,owner_idandcreated_aton the copy, because service-role copies drop ownership and AI chat attachments are ordered bycreated_at. - Delete old. This runs only after the refs commit. Afterwards the tool confirms through metadata that the old key is gone.
- Batches hold at most 50 objects.
- Each operation is retried up to 3 times with exponential backoff on network, 429 and 5xx errors.
--max-opscaps the total Storage and SQL operations for a run (500 by default).- The tool checks a durable
STOPfile (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.
SUPABASE_ACCESS_TOKEN_FILEmust 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>) andfull_path(full key): updated.public.ai_chat_messages.metadata: every whole JSON string equal to the old full key is rewritten, along with theai-file:<encodeURIComponent(key)>attachment id. This coverstoolResults[].output.storagePath,ai.parts[].output.storagePath,toolCalls[].input.storagePathandattachments[].storagePath/id. Rows are scoped bychat_idwhen the key names a chat.chats/ai/resources/<chatId>/andtemp/<userId>/have no key column. Readers list the folder, so the new same-directory key is found automatically. The restoredcreated_atkeeps 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.metadatastoragePath: rewritten. The key names no chat, so each object’s update scansai_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.metadataandpublic.ai_credit_transactions.metadatastoragePath: rewritten onimage_generationrows. These are not scoped by workspace, because theirws_idis the billing workspace, which can differ from the path. The chat sign-on-read check uses the transactionstoragePath.- The stored
imageUrlis a signed URL for the old key. The chat UI signsstoragePathon read and falls back toimageUrlonly when no path is available. That UI change must be deployed before themira/imagesrun.
.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 samestorage_path, so one object can have several rows. The single refs statement updates all of them together.
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.
Production procedure
The examples set these variables:-
Dry run (read-only). This writes
selectedandskippedrows plus a mode-600 approval token file,<ledger>.<root slug>.approve-token, where the slug ischats,mira_imagesor_tuturuuu.Attach the printed per-state counts and the skipped ids to the approval record. Then stop and get the release authority’s GO. -
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.
Exit code
3means the run stopped, either on theSTOPfile or the--max-opscap. The ledger is consistent, so you can resume. -
Resume after a stop. Remove the
STOPfile, run$REKEY --issue-token --root <root>to write a fresh token file, then run--executeagain. Rows already in thecopied,verifiedorrefs_updatedstate continue from where they stopped. A copy that landed before a crash is adopted, never duplicated. -
Retry failed rows. A
failedrow 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:Each failed row resumes at its recorded state. A row that has failed 3 times staysfailedand is listed asexhausted. Roll it back or investigate it. -
Report. Run
$REKEY --statusto 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. RemoveSTOP, run$REKEY --issue-token --root <root>, then run$REKEY --rollback --root <root> --approve-token-file $LEDGER_DIR/ledger.json.<root>.approve-token. For everyselected,copied,verified,refs_updatedorfailedrow, the rollback reverts the references to the old key, deletes the copy, and marks the rowrolled_back. - After
old_deleted: the tool refuses to roll back. It reports the count inoldDeletedNotRolledBack. 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 nostorage.objectsrow remains at the old key. - Supabase Smart CDN can briefly serve cached bytes after a delete. Before you
claim revocation,
HEADa 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 Nmints a 10-minute signed URL for every Nth object before deletion. After deletion it records theHEADstatus, 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
.tuturuuuobject. - Two vault versions that share one artifact path.
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.