Simulators
Dropbox simulator
dropbox-twin is a local, stateful simulator of the Dropbox API v2 — one account’s files and folders, listings and search with their cursors, shared links, file requests, shared folders and file members, upload sessions, batch jobs and save_url, and tags — serving both api.dropboxapi.com’s RPC routes and content.dropboxapi.com’s upload and download routes on one port. It never calls Dropbox: no OAuth, no email is sent and no real account changes.
Quickstart
You need an API key from Account → API keys (how).
curl -fsSL https://www.rystic.ai/install.sh | sh
rystic login rys_… # your API key
rystic run dropbox-twin -d --port 0 --scenario dropbox_personal_archive # prints the URL it serves on
curl -s http://localhost:<port>/_rystic/version
Expect "product":"dropbox-twin". The examples below use localhost:8080; use the port rystic run printed.
On Enterprise, rystic login ./rystic-license.json takes the place of the key (license files).
Read the account and its tree, then upload a file and download it back:
curl -s localhost:8080/2/users/get_current_account -H 'Authorization: Bearer sl.placeholder' \
-H 'Content-Type: application/json' -d null
# -> {"account_id":"dbid:AAApersonalarchive00000000000000001","account_type":{".tag":"pro"},...,"email":"ada.sealy@example.test",...}
curl -s localhost:8080/2/files/list_folder -H 'Authorization: Bearer sl.placeholder' \
-H 'Content-Type: application/json' -d '{"path":""}'
# -> {"cursor":"…","entries":[{".tag":"folder",...,"path_display":"/Apps",...},{...,"path_display":"/Documents",...},{".tag":"file",...,"path_display":"/notes.md",...,"size":1794},{...,"path_display":"/Photos",...}],"has_more":false}
curl -s localhost:8080/2/files/upload -H 'Authorization: Bearer sl.placeholder' \
-H 'Content-Type: application/octet-stream' \
-H 'Dropbox-API-Arg: {"path":"/hello.txt","mode":"add"}' --data-binary 'hello'
# -> {"client_modified":"2026-02-02T00:00:00Z",...,"name":"hello.txt","path_display":"/hello.txt",...,"size":5}
curl -s localhost:8080/2/files/download -H 'Authorization: Bearer sl.placeholder' \
-H 'Dropbox-API-Arg: {"path":"/hello.txt"}'
# -> hello (the metadata is in the Dropbox-API-Result response header)
Point the official JavaScript SDK at the twin through a fetch that rewrites its two hosts:
import { Dropbox } from "dropbox";
const twin = "http://localhost:8080";
const dbx = new Dropbox({
accessToken: "sl.placeholder",
fetch: (url, init) => fetch(url.replace(/^https:\/\/(api|content)\.dropboxapi\.com/, twin), init),
});
const me = await dbx.usersGetCurrentAccount(); // me.result.email === "ada.sealy@example.test"
Plans
dropbox-twin is on Pro and Enterprise, not Free (plans); on a Free key, rystic pull dropbox-twin and a start are refused with product_not_in_plan. Both plans serve the whole Dropbox surface and the whole control plane below, custom latency and fault injection included. Without --scenario the twin boots one Basic account with no files, and POST /_rystic/reset wipes it back to that.
Enterprise runs on a license file instead of an API key.
Auth
Dropbox reads an access token as Authorization: Bearer sl.…. The twin accepts any bearer value and checks nothing else; no Dropbox app, OAuth flow or token is involved.
- A request with no
Authorizationheader, or with another scheme, answers 400 with a JSON string body,Error in call to API function "…": request is malformed, onusers/get_current_account, and 401invalid_access_tokenon the files and sharing routes. Dropbox answers 400text/plainthere (RYS-2663). - In
dropbox_team_sharedthe token picks the member:sl.rosa-desktopis Rosa,sl.toni-laptopis Toni, and any other value is Rosa, the world’s default caller. Every other world has one account, which every token reaches.
Coverage
93 routes across 17 resources, counted from the sim’s literal registrations.
| Resource | Operations |
|---|---|
| Asynchronous job (7) | POST /2/files/copy_batch/check_v2; POST /2/files/create_folder_batch/check; POST /2/files/delete_batch; POST /2/files/delete_batch/check; POST /2/files/move_batch/check_v2; POST /2/files/save_url; POST /2/files/save_url/check_job_status |
| File request (7) | POST /2/file_requests/count; POST /2/file_requests/create; POST /2/file_requests/delete; POST /2/file_requests/delete_all_closed; POST /2/file_requests/get; POST /2/file_requests/list; POST /2/file_requests/update |
| File revision (content) (1) | POST /2/files/restore |
| File-request list cursor (2) | POST /2/file_requests/list/continue; POST /2/file_requests/list_v2 |
| File/folder entry (23) | POST /2/files/copy_batch_v2; POST /2/files/copy_v2; POST /2/files/create_folder_batch; POST /2/files/create_folder_v2; POST /2/files/delete_v2; GET, POST /2/files/download; GET, POST /2/files/download_zip; POST /2/files/get_metadata; GET, POST /2/files/get_preview; POST /2/files/get_temporary_link; GET, POST /2/files/get_thumbnail_v2; POST /2/files/list_revisions; POST /2/files/move_batch_v2; POST /2/files/move_v2; POST /2/files/tags/add; POST /2/files/tags/get; POST /2/files/tags/remove; POST /2/files/upload; POST /2/files/upload_session/finish; POST /2/sharing/get_file_metadata; POST /2/sharing/get_file_metadata/batch; POST /2/sharing/list_file_members/batch; POST /2/sharing/list_shared_links |
| list_folder cursor (3) | POST /2/files/list_folder; POST /2/files/list_folder/continue; POST /2/files/list_folder/get_latest_cursor |
| Per-file sharing record (1) | POST /2/sharing/update_file_policy |
| Search cursor (2) | POST /2/files/search/continue_v2; POST /2/files/search_v2 |
| Shared folder (3) | POST /2/sharing/get_folder_metadata; POST /2/sharing/share_folder; POST /2/sharing/update_folder_policy |
| Shared link (5) | POST /2/sharing/create_shared_link_with_settings; GET, POST /2/sharing/get_shared_link_file; POST /2/sharing/get_shared_link_metadata; POST /2/sharing/modify_shared_link_settings; POST /2/sharing/revoke_shared_link |
| Shared-file membership (5) | POST /2/sharing/add_file_member; POST /2/sharing/relinquish_file_membership; POST /2/sharing/remove_file_member_2; POST /2/sharing/unshare_file; POST /2/sharing/update_file_member |
| Shared-folder membership (5) | POST /2/sharing/add_folder_member; POST /2/sharing/mount_folder; POST /2/sharing/relinquish_folder_membership; POST /2/sharing/unmount_folder; POST /2/sharing/update_folder_member |
| Sharing asynchronous job (5) | POST /2/sharing/check_job_status; POST /2/sharing/check_remove_member_job_status; POST /2/sharing/check_share_job_status; POST /2/sharing/remove_folder_member; POST /2/sharing/unshare_folder |
| Sharing listing cursor (10) | POST /2/sharing/list_file_members; POST /2/sharing/list_file_members/continue; POST /2/sharing/list_folder_members; POST /2/sharing/list_folder_members/continue; POST /2/sharing/list_folders; POST /2/sharing/list_folders/continue; POST /2/sharing/list_mountable_folders; POST /2/sharing/list_mountable_folders/continue; POST /2/sharing/list_received_files; POST /2/sharing/list_received_files/continue |
| Space usage (1) | POST /2/users/get_space_usage |
| Upload session (5) | POST /2/files/upload_session/append_batch; POST /2/files/upload_session/append_v2; POST /2/files/upload_session/finish_batch_v2; POST /2/files/upload_session/start; POST /2/files/upload_session/start_batch |
| User account (3) | POST /2/users/get_account; POST /2/users/get_account_batch; POST /2/users/get_current_account |
- RPC routes take a JSON body under
Content-Type: application/json;users/get_current_account,users/get_space_usage,file_requests/list,countanddelete_all_closedtake the literalnull. The content routes —files/upload,download,download_zip,get_thumbnail_v2,get_preview,upload_session/start,append_v2andfinish, andsharing/get_shared_link_file— take their argument in theDropbox-API-Argheader beside the bytes and answer their result in theDropbox-API-Resultheader. The download routes also take it as theargquery parameter (GET /2/files/download?arg=<json>), anddownloadandget_shared_link_fileanswer a byteRangewith 206 andContent-Range.GET /_rystic/versionnames both hosts inservice_hosts. - Paths are matched case-insensitively and answered as
path_lowerbeside thepath_displayyou gave. A path that holds nothing answers 409path/not_found; a create, or an add-mode upload, over an existing entry answers 409path/conflict, andautorename: truelands the upload asname (1). Every error carries Dropbox’serrorobject anderror_summary. delete_v2is a soft delete: the entry no longer reads back by path, andfiles/downloadof it answers 409path/not_found.list_folderwithinclude_deleted: truelists its tombstone, andget_metadatawithinclude_deleted: trueanswers it asDeletedMetadata(.tag,name,path_lower,path_display). A deleted file is still a file tofiles/list_revisions, which answers its revisions withis_deleted: trueandserver_deleted, and any of them restores throughfiles/restore. Every upload, session finish, restore and move of a file records its ownrev;list_revisionsinidmode lists a file’s revisions across renames, andmodetakes the bare tag ("id") as well as{".tag": "id"}.files/list_folderpages bylimitand the cursorlist_folder/continuewalks;files/search_v2matches the words of a name, case-insensitively, scoped byoptions.path, and pages throughsearch/continue_v2. A cursor the twin never handed out answers 400.save_url,copy_batch_v2,move_batch_v2anddelete_batchanswer anasync_job_id; the check answersin_progresson the first read andcompleteon the next, or once the clock has moved two seconds past the launch, as Dropbox’s do. Each entry of a copy, move or delete batch is copied, moved or deleted ascopy_v2,move_v2anddelete_v2do it: a copied file downloads its bytes under its ownrev, a folder goes with everything beneath it, and a file’s members and shared links follow it or go with it.create_folder_batchanswerscompleteinline, and a completedsave_urljob has written its file at the job’s path. Anupload_session/append_v2whosecursor.offsetdiffers from the bytes held answers 409incorrect_offsetwithcorrect_offset; a secondfinishat the held offset commits the bytes again, and an append after finish answers 409closed.- Each account holds its own files: in
dropbox_team_shared, Toni’s/Draftsanswerspath/not_foundto Rosa, and what reaches her is the file Toni shared, throughsharing/list_received_filesandsharing/get_file_metadata. - The account that shares a folder is its owner: after
share_folder,list_folderslists the share,get_folder_metadataandlist_folder_membersanswer it withaccess_typeowner, andunshare_folderlaunches its job.
Not modelled, by decision: the team, paper, file_properties, contacts, account, auth, check and openid families, longpoll and webhooks, the deprecated routes whose successors are served, and the Business-only permanently_delete. An unserved route answers 404 {"message":"No route in twin for …"}, not Dropbox’s error.
Control plane
Every route under /_rystic/ is listed in routes by plan. The twin serves no longpoll and sends no webhook.
Scenarios
Five shipped worlds, each on 2026-02-02T00:00:00Z.
| Scenario | What it holds |
|---|---|
dropbox_personal_archive | Ada Sealy’s verified Pro account and a nine-entry tree with revs, hashes and sizes: /Apps, /Documents, /notes.md, /Photos/2025 |
dropbox_fresh_signup | a just-created Basic account, email unverified, holding only the auto-created /Apps |
dropbox_deleted_backlog | four deleted files and a deleted folder across /Archive and /Drafts, listed as tombstones, beside the one file that survived; each deleted file lists its revision and restores |
dropbox_paired_identity | a personal Basic account paired with a Northwind work account, both is_paired, each with its own namespace and tree |
dropbox_team_shared | two Acme members with their own tokens (sl.rosa-desktop, sl.toni-laptop), a shared team folder tree beside one private file, and a timeline the clock plays: Toni creates /Drafts, uploads a sketch and shares it with Rosa, then Rosa files her scratch note and deletes /Scratch |
Seeding state
POST /_rystic/seed takes a register map — state_account (keyed account_id), state_entry (keyed path_lower), state_revision, state_shared_link, state_file_request, state_shared_folder, state_shared_folder_member, state_file_member and the rest GET /_rystic/registers lists with their fields — and lands it directly. --seed <file> or RYSTIC_SEED=<file> boots one.
Determinism & reset
Ids, revs, cursors, job ids and link URLs come from sequences the reset rewinds, so the first folder you create after a reload has the same id: every time. The clock starts at 2026-01-01T00:00:00Z in a bare boot and at 2026-02-02T00:00:00Z in every world, and stays there — never moving by wall time — until POST /_rystic/clock sets or advances it, so two identical runs from the same reset give byte-identical entries, timestamps included. POST /_rystic/reset wipes back to one Basic account with no files; POST /_rystic/scenario loads a world again without a restart.
Egress
The twin calls nothing out: save_url fetches nothing (its completed job writes a placeholder derived from the URL), a shared link, a file request or a member added to a file sends no email, and there is no webhook. GET /_rystic/egress answers an empty log.
Limitations
Known issues.
- The scenario pack and one repair record describe calls the twin does not serve (RYS-2230)
- A request with no
Authorizationheader answers a 400 or 401 JSON body where Dropbox answers 400 text/plain (RYS-2663) - The twin’s 500 answers, such as the one for a malformed upload-session id, are never compared against Dropbox’s (RYS-2664)
- Dropbox’s text/plain error bodies, such as the 400 for a malformed argument, are answered as JSON (RYS-2665)
files/tags/geton a folder addressed by theidthatcreate_folder_v2answered is unmeasured: the probe sends the id where Dropbox keys by path (RYS-2685)- An upload or move into a missing parent folder creates no intermediate folders, where Dropbox creates them; create the folder with
create_folder_v2first (RYS-2697) list_folder/continueis a positional window over the current listing, not Dropbox’s change feed: a delete, a move’s source, an overwrite and an entry that sorts earlier never reach a cursor, and already-listed entries are re-sent (RYS-2772)- No file over 1 MiB can be stored:
files/uploadabove 1 MiB answers 413 with a non-Dropbox body and an upload session’s second MiB answers 409too_large, where Dropbox accepts 150 MB (RYS-2773) - A shared folder is not one namespace: a member’s mount lists nothing and each member’s writes stay invisible to the other; the worlds’ seeded
shared_folder_ids answerinvalid_idandlist_foldersis empty; a shared folder and its children never carrysharing_info(RYS-2774) move_v2writes under a file, moves a tombstone and moves a folder onto/(it vanishes), refuses a move onto a deleted path withto/conflict/otherand a case-only rename withduplicated_or_nested_paths; an upload with a trailing slash storesname ""(RYS-2776)sharing/get_file_metadata/batchandlist_file_members/batchanswer 500 when any entry is not a file, and a malformed cursor answers 500 onfile_requests/list/continueandlist_received_files/continueand is ignored bylist_shared_links, where Dropbox answers per-entry errors and 409 (RYS-2777)files/uploadignoresstrict_conflict, mints a newrevfor an identical-bytes overwrite where Dropbox keeps the rev, and accepts an unknownmode(RYS-2778)- A request with a wrong
Content-Type(form-urlencoded or text/plain on a JSON route, anything but octet-stream on an upload) is accepted where Dropbox answers 400, so a client that works on the twin is refused by Dropbox (RYS-2791) create_shared_link_with_settingswithrequested_visibility: team_onlyon an account with no team answers 200 with a public link; what Dropbox answers there is unmeasured (RYS-2806)- A content
GETthat carries its token only in theauthorizationquery parameter answers 401, andDropbox-API-Path-Rootandns:paths are ignored, so every call reads the personal tree (RYS-2807) - Two accounts’ files at the same path share one set of file members and share records, so one owner’s sharing shows on the other’s file (RYS-2817)
add_folder_memberwith noaccess_leveladds an editor, where Dropbox adds a viewer; passaccess_levelexplicitly (RYS-2818)check_remove_member_job_statuswords its warning differently from Dropbox, and relinquishing a mounted folder completes where Dropbox documents amountederror (RYS-2819)- In the
dropbox_personal_archiveworld, seeded folders carry the file-only fieldsis_downloadable,revandsize(RYS-2820)
Not served. 195 of the 283 operations in the published spec are not served.
- files, 37 of 67 served:
POST /2/files/alpha/get_metadata,POST /2/files/alpha/upload,POST /2/files/copy,POST /2/files/copy_batch,POST /2/files/copy_batch/check,POST /2/files/copy_reference/get,POST /2/files/copy_reference/save,POST /2/files/create_folder, … and 22 more - sharing, 38 of 44 served:
POST /2/sharing/create_shared_link,POST /2/sharing/get_shared_links,POST /2/sharing/relinquish_access,POST /2/sharing/remove_file_member,POST /2/sharing/set_access_inheritance,POST /2/sharing/transfer_folder - users, 4 of 5 served:
POST /2/users/features/get_values - Not served at all: account (3), auth (2), check (2), contacts (2), file_properties (16), openid (1), paper (18), riviera (15), team (97), team_log (2)
Not measured. The fidelity suite runs 252 probes, and 466 documented branches have none. 149 need a credential the measuring account does not hold (401 ×80, 403 ×68, 409 ×1). The other 317:
| Operation | Status | Why |
|---|---|---|
GET, POST /2/files/download | 200 | nothing the twin serves creates the record it needs |
GET, POST /2/files/download | 206 | nothing the twin serves creates the record it needs |
GET, POST /2/files/download | 409 | the model describes no input that reaches it (2 branches) |
GET, POST /2/files/download | 416 | the model describes no input that reaches it |
GET, POST /2/files/download | 429 | no request a client sends provokes it |
GET, POST /2/files/download | 500 | no request a client sends provokes it |
GET, POST /2/files/download_zip | 200 | nothing the twin serves creates the record it needs |
GET, POST /2/files/download_zip | 409 | the model describes no input that reaches it (3 branches) |
GET, POST /2/files/download_zip | 429 | no request a client sends provokes it |
GET, POST /2/files/download_zip | 500 | no request a client sends provokes it |
GET, POST /2/files/get_preview | 200 | nothing the twin serves creates the record it needs |
GET, POST /2/files/get_preview | 409 | a well-formed request does not provoke it |
GET, POST /2/files/get_preview | 409 | the model describes no input that reaches it (4 branches) |
GET, POST /2/files/get_preview | 429 | no request a client sends provokes it |
GET, POST /2/files/get_preview | 500 | no request a client sends provokes it |
GET, POST /2/files/get_thumbnail_v2 | 200 | nothing the twin serves creates the record it needs |
GET, POST /2/files/get_thumbnail_v2 | 409 | nothing the twin serves creates the record it needs |
GET, POST /2/files/get_thumbnail_v2 | 409 | the model describes no input that reaches it (8 branches) |
GET, POST /2/files/get_thumbnail_v2 | 429 | no request a client sends provokes it |
GET, POST /2/files/get_thumbnail_v2 | 500 | no request a client sends provokes it |
| … and 225 more |
Not modeled. What the model’s operations state they leave out:
- The 400 body is text/plain, not JSON; its exact wording is undocumented, so the branch is modelled shape-only and the response encoding is left as the default (a plain-text body on either side may read as an encoding gap rather than a value diff). (
GET, POST /2/files/download) - The 409 LookupError tags restricted_content, unsupported_content_type and locked, and DownloadError.other, are documented but have no derivable recipe on a fixture account; they are not given their own branches. (
GET, POST /2/files/download) - The deprecated rev argument field is declared in the header shape but no branch distinguishes its use from a rev: path. (
GET, POST /2/files/download) - This route is served from https://content.dropboxapi.com , not the api.dropboxapi.com host the state model records for the RPC routes; the host difference is recorded here but not expressed in the vector. (
GET, POST /2/files/download) - state_entry’s documented producers in this model include create_folder_v2 (a folder) and copy/move; a success probe needs a FILE at the referenced path, which only files/upload creates. No prior-step recipe is declared because files/upload takes its own argument in a JSON-valued header with an opaque payload body, which x_requires_steps cannot express as a body here. (
GET, POST /2/files/download) - The 400 plain-text message for the sealed malformed-header recipe (‘{not json’) was measured live (round 20261010T112419Z) and sealed as the branch’s example_body; messages for other malformed-header variants (absent header, wrong-typed path, pattern violation) follow the same ‘Error in call to API function “files/download_zip”:
’ format but their exact texts are unmeasured. ( GET, POST /2/files/download_zip) - The 500 InternalServerError branch has no documented body and no trigger condition, so it carries no example, no field policies and no probe recipe. (
GET, POST /2/files/download_zip) - The route also accepts GET with the same Dropbox-API-Arg header (x-rystic-accepted-methods); only POST is modeled. (
GET, POST /2/files/download_zip) - too_large and too_many_files carry no executable recipe: provisioning a 20 GB folder or 10,000 entries on the live account is out of reach. (
GET, POST /2/files/download_zip) - LookupError members malformed_path, restricted_content, unsupported_content_type and locked are reachable under PreviewError.path but are not modeled as separate branches: no evidence names a request that provokes them on this route. (
GET, POST /2/files/get_preview) - No fixture binding exists for an existing previewable file: the success example’s Dropbox-API-Arg path is a ${path:json} placeholder resolved through the state_entry reference, which needs a producer that uploads a file with a supported extension (files/upload carries its own argument in a header and an opaque byte body, which x_requires_steps cannot express today). (
GET, POST /2/files/get_preview) - The deprecated PreviewArg.rev field is declared but carries no reference or guard: the spec directs callers to put the revision in
pathinstead. (GET, POST /2/files/get_preview) - The route also accepts GET (x-rystic-accepted-methods); only the POST form is modeled. (
GET, POST /2/files/get_preview) - path/not_file has no recipe: it needs an existing folder path, and no fixture-seeded folder is declared. (
GET, POST /2/files/get_preview) - unsupported_extension, unsupported_content and in_progress carry no executable recipe: each needs a file of a particular kind (or a preview mid-generation) staged in the live account, and no evidence names a value that provokes them against the fixture. (
GET, POST /2/files/get_preview) - No recipe for the 200 branch: reaching it needs a source file whose bytes are a real image of a supported extension, and the only producer on this surface (files/upload) takes its payload as opaque bytes in a request body with the path in a JSON-valued header, which x_requires_steps bodies cannot express. (
GET, POST /2/files/get_thumbnail_v2) - The 200 payload is a server-generated thumbnail image: a hermetic twin cannot reproduce the exact bytes, so the declared bytes comparison (length + SHA-256) will differ from the oracle’s even on an otherwise correct answer. Recorded as a known fidelity gap rather than a weaker success claim. (
GET, POST /2/files/get_thumbnail_v2) - The 20MB photo limit is documented in prose only; which of unsupported_image / conversion_error it produces is not stated. (
GET, POST /2/files/get_thumbnail_v2) - The 400 body is text/plain (the response_encoding vocabulary has no text form); the branch is declared with no example_body, so only its status is modelled. (
GET, POST /2/files/get_thumbnail_v2) - The route also accepts GET (x-rystic-accepted-methods); only the POST form is modelled. (
GET, POST /2/files/get_thumbnail_v2) - … and 325 more
Sharing members are measured only as far as Dropbox’s daily invite cap allows: Dropbox refuses add_file_member and add_folder_member with 409 rate_limit or banned_member once an account has sent its day’s invites, so each round scores 19 member probes inconclusive rather than comparing them. Search matches names only, never a file’s content; content matching is declared unmodeled. Thumbnails and previews are placeholder bytes answered as application/octet-stream, and a shared link, a temporary link or a file request’s URL is a minted value its URL does not serve: sharing/get_shared_link_file answers a shared link’s bytes. A shipped world’s seeded files download their seeded bytes, and a file you upload downloads back byte for byte. The twin never answers 429.
Fidelity is scored differentially against a live Dropbox account, per response field: the last full-suite round matched 233 of 252 probes on 2026-10-13 (convergence_log.json 20261013T132744Z), with 13 inconclusive — 12 sharing-member probes the daily invite cap leaves rate-limited and one search page Dropbox’s index had not yet reached — against a target of 80%. Each release states its own number.
Release notes: Changelog