Data model
This page lists every Firestore collection and Storage path Arc CMS uses, who can read and write each one, and which feature owns it.
Where the data lives
Arc CMS keeps all its data in one Firestore database and one Storage bucket per install.
- Database.
(default), unless the install config (arccms.config.json, seearccms.config.example.json) names another withdatabaseId, such asarccms. Thebackendprofile, for an install that shares its Firebase project with another app, requires a named database. See Configure a project and Sharing a Firebase project. - How the code picks it.
npm run arc:configurewrites the value tosrc/environments/arc-install.tsfor the browser app, and tofunctions/.env.{projectId}asARC_DATABASE_IDfor the Cloud Functions. The browser opens the database insrc/app/core/config/arc-firebase.ts. Every function uses thedbhandle fromfunctions/src/init.ts, and the Firestore triggers listen on the same database. The one exception isonAppUserWritten, which watches the host app's users for the App audience. - Bucket and upload folder.
storageBucketnames the bucket (default: the one in the Firebase config).storagePrefixis one folder ending in a slash, such asarccms/, put in front of new uploads. With no prefix, uploads go to the bucket root.
How content is stored
Content has no single collection. Each content type gets three, named from the type's slug: arc_{slug}_drafts for what the editor saves, arc_{slug} for the published copy (same document id as the draft), and Tags_{slug} for its tags. Each type has a slug of its own, which the edit form locks once any of the three holds a document (data import refuses to change it then too), and deleting a content type deletes all three with every subcollection under their documents, unless another type still uses the slug (possible only on a site set up before slugs were checked). See Content below.
Firestore rules cannot match a prefix, so firestore.rules has one wildcard rule, match /{collection}/{docId}, that checks the collection name with a regular expression.
The wildcard rule applies to any collection whose name starts with arc_, Tags_ or WaitlistUserTags_. An app collection named arc_orders becomes public to read. Give app collections names without these prefixes.
Your app's collections
An app keeps its own rules in firestore.app.rules and storage.app.rules. npm run rules:build puts them into the core rules at the // @arc-app-rules line, so they can call the core helpers. See Set security rules and indexes. For how the rules are built and tested, see Security rules.
Who can read and write
The rules use a few helpers. The role comes from the arccms_role claim, never a plain role; see Roles and claims.
isAuthenticated()andisSignedIn(): the caller is signed in.isAdmin(): thearccms_roleclaim isadmin.isEditor(), "staff" below: an admin, or thearccms_roleclaim iseditor.ownsUserRecord(userDocId): thearccms_uidclaim equals thatusersdocument id.signedInByApp(): the session came from an app's own sign-in token. For app rules; core does not use it.isOwnUserDoc(): theusersdocument'suidfield equals the signed-in Auth uid.
In the tables below, "Server only" means no browser can read or write it: only Cloud Functions do, through the Admin SDK, which bypasses the rules. Every collection with no rule is server only too, because Firestore denies what no rule allows.
Content
| Collection | Holds | Read | Write | Feature | Key fields |
|---|---|---|---|---|---|
ContentTypes | One content type definition per document, each with its own slug. Deleting one deletes its arc_{slug}, arc_{slug}_drafts and Tags_{slug} collections, unless another type still uses the slug. | Public (SSR routes with it) | Staff | Content | name, singularName, slug, fields, templateFolder, hasPublicUrl, entryOrder (newest or manual), standard (pages on the built-in Info Pages type), searchFields, nameTranslations |
arc_{slug}_drafts | The type's items as the editor saves them. Subcollection translations/{lang} holds only the fields that differ in that language. | Staff | Staff | Content | title, content, urlSlug, status, summary, tags, seoTitle, metaDescription, authorId, customFields, lastPublishedAt, sortOrder (the place in a type kept in its own order), layout (the entry's detail layout, detail-{layout}.html; empty for detail.html) |
arc_{slug} | Published items, same id as the draft. Subcollections: translations/{lang} (public read, written by the publish pipeline) and PublishedHistory (a copy per publish, admin read, server write) and DeploymentLogs (the steps of each deploy to Hosting, server write). | Public | Staff; in practice the publish pipeline | Content | The draft's fields, plus publishedOn, deployStatus, deployedAt, deployedUrl, deployError |
Tags_{slug} | The tags of one content type. | Public | Staff | Authors and tags | label, color, contentTypeSlug, usageCount |
Authors | Bylines. No email and no user id, since it is public. | Public | Admin | Authors and tags | name, slug, bio, photoUrl, jobTitle, url, sameAs |
media | One document per image in the media library. The files are in Storage. | Staff | Staff | Media | downloadURL, name, title, alt, notes, uploadTime, width, height, variants, source |
mediaFiles | One document per document in the media library's Files (PDF, Word, Excel, PowerPoint, CSV, text). The files are in Storage. | Staff | Staff | Media | title, notes, name, downloadURL, path, type, contentType, bytes, pages, thumbnail, uploadTime |
_publish_queue | A request to publish, unpublish, update, delete or redeploy. processPublishQueue runs it and deletes the document. | Nobody | Staff | Content | action, contentTypeSlug, docId, timestamp |
_search_queue | One entry per draft save, id {collection}~{docId}. onSearchQueued indexes the draft and deletes the entry. | Nobody | Staff, only these fields, and collection must be a drafts collection | Search | collection, docId, at |
SearchIndex | One entry per document and language, id {source}:{collection}:{docId}:{lang}. Read only through the search callable, which enforces scope. | Server only | Server only | Search | source, scope, lang, collection, docId, tokens, fields, title, snippet, link, indexedAt |
DraftContents | Legacy. Content lives in arc_{slug}_drafts. | Admin | Admin | Content | |
Contents | Legacy. Content lives in arc_{slug}. | Admin | Admin | Content |
Settings
All site settings are documents in one collection. Most are admin only because several hold secrets.
| Collection | Holds | Read | Write | Feature | Key documents |
|---|---|---|---|---|---|
Settings | One document per settings area. | Public: email_status, site-usage, misc, cache, global-message, users, localization, onboarding_status, about, feedback. Signed in: analytics_status. Admin: every other document. | Admin, for every document | Several | email, integrations and analytics hold provider secrets. dodo-payments holds payment keys. users holds defaultRole for new sign-ups. feedback holds enabled. discoverability, search_status, search_collections, contact_fields, sms, app_audience. |
Never put a secret in a public Settings document. The documents listed as public are readable without signing in.
Users and sign-in
Every sign-up creates its users record on the server. What an app keeps per person, and what deleting an account removes, is in Follow the account contract.
| Collection | Holds | Read | Write | Feature | Key fields |
|---|---|---|---|---|---|
users | One record per person, with an auto id. The id is the arccms_uid claim. Deleting it runs onUserDeleted, which removes the person's subcollections, Storage folders, feedback and notifications, and their sign-in account unless another app owns it. | Admin, or the owner (isOwnUserDoc()) | Create and delete: admin. Update: admin, or the owner for any field except role, uid, isActive, status, authOwner, by, selfService, email, phone, phoneVerified, detachedAt, detachedReason and the premium and credit fields; emailVerified may only go to false. A locked app account also never changes its name or photo. Never a password field. | Users and roles | uid, name, email, emailVerified, phone, role, status, isActive, by, selfService (app accounts), authOwner, photo, premium fields such as premiumType and creditBalance |
email_lookup | One document per registered email, id the SHA-256 of the trimmed, lowercased address, so the sign-up page can check an address without seeing any. | Public | Admin; mostly the server | Sign-in methods | exists |
_system | Server secrets and markers: first_admin (written once by claimFirstAdmin), pin_pepper, unsubscribe_secret, sign_in_setup_alert (when the admins were last told phone sign-in needs Google Cloud setup). | Server only | Server only | Users and roles | |
signup_otps | Email codes for sign-up and for adding an email, id the email hash. Only the code's hash is stored. | Server only | Server only | Sign-in methods | purpose, codeHash, expiresAt, attempts, verified |
phone_otps | SMS codes, id the hash of the number. | Server only | Server only | SMS | purpose, uid, codeHash, expiresAt, attempts, verified |
phone_index | Which account a phone number belongs to, id the hash of the number. | Server only | Server only | SMS | userDocId, uid, createdAt |
auth_pins | The hashed PIN for phone sign-in, id the Auth uid. | Server only | Server only | SMS | salt, hash, failedAttempts |
app_pins | An app's own hashed PINs, id <namespace>__<uid>. Removed with the account. | Server only | Server only | PINs for your app | uid, namespace, salt, hash, failedAttempts |
app_phone_pin_tries | Wrong phone PINs at an app's gate, id <namespace>__<uid>. Gone after a correct PIN, and removed with the account. | Server only | Server only | PINs for your app | uid, namespace, pinId, failedAttempts |
_rate_limits | Counters that slow down repeated requests (codes, PIN tries, feedback alerts, install events, contact messages and their alerts), one per key. | Server only | Server only | Sign-in methods | windowStart, count, expiresAt |
_claim_locks | A short lease, one per account, held while its custom claims are written, so two claims writes on one account take turns. Deleted when the write ends. | Server only | Server only | Roles and claims | token, expiresAt |
account_transfers | A record each time an email or phone number moves from one account to another. | Admin | Server only | Sign-in methods | kind, shown, fromUserDocId, toUserDocId, detached, at |
Core has no rule for subcollections of users, so the browser cannot touch them until an app grants access in firestore.app.rules.
Payments
| Collection | Holds | Read | Write | Feature | Key fields |
|---|---|---|---|---|---|
Products | What can be bought, for the pricing page. | Public | Admin | Payments | name, active, type, price, currency, premiumType, tierRank, interval, trialDays, creditsGranted, tiers, providerProductIds, purchaseCount |
Transactions | One document per payment event, kept once by idempotencyKey. | Admin, or the buyer (userId is their Auth uid) | Server only | Payments | userId, productId, amount, currency, status, type, eventType, idempotencyKey, createdAt |
WebhookEvents | Every raw webhook from the payment provider, id the webhook-id header. handlePaymentEvent runs on each new one. | Admin | Server only | Payments | eventType, rawPayload, signatureValid, processed, receivedAt |
CreditLedger | Every change to a person's prepaid credits, append only. | Admin, or the owner (userId) | Server only | Payments | userId, delta, reason, balanceAfter, createdAt |
Signup forms
In the code and the collection names, a form is a waitlist. Members join through the joinForm callable.
| Collection | Holds | Read | Write | Feature | Key fields |
|---|---|---|---|---|---|
Waitlists | One document per form, usually with the form's slug as id. Subcollections: users (one per member, created by joinForm) and users/{id}/notes (admin notes). Older installs may still hold users/{id}/referrals, closed to every client until npm run forms:remove-referrals deletes it. | Form: public. Members and notes: admin; signing up goes through callables. | Admin. Signups and verification go through the form callables (joinForm, finalizeFormSignup). | Signup forms | Form: name, slug, isActive, otpEnabled, totalSignups, fields, targetListIds, defaultTagId. Member: email, firstName, emailVerified, isConfirmed, isSubscribed, tags. |
WaitlistedUsers | Legacy registry of signups across all forms. Arc CMS creates no new records here. Its old referrals subcollection is closed to every client until npm run forms:remove-referrals deletes it. | Admin | Admin | Signup forms | |
WaitlistUserTags_{formId} | The member tags of one form. | Admin | Admin | Signup forms | label, color, waitlistId, usageCount |
Email and SMS
| Collection | Holds | Read | Write | Feature | Key fields |
|---|---|---|---|---|---|
EmailTemplate | One email template per document: the defaults, per-form templates and templates made in the composer. | Admin | Admin | Email engine | type, category, scope, waitlistId, subject, previewText, template, senderName, senderEmail, isActive, editorVersion |
EmailLogs | One document per outgoing email: the send queue and the record of what happened. Created by queueEmail(); onEmailLogCreate sends it. | Admin | Admin | Email engine | toEmail, subject, template, type, category, status, attempts, createdAt, sendingTime, isOpened |
EmailLog | Legacy. No code uses it; email records are in EmailLogs. | Admin | Admin | Email engine | |
_email_counters | Sends per provider per day and per hour, for the provider's limits. Id such as {provider}_daily_{date}. | Server only | Server only | Email engine | count, provider, type |
Suppression | Addresses that must get no marketing email, id the email hash. | Admin | Server only | Email engine | email, emailHash, reason, at |
BroadcastEmails | One document per broadcast. processBroadcast runs when it is created. | Admin | Admin | Email marketing | subject, template, audience, status, scheduledAt, totalCount, sentCount, failedCount |
_broadcast_continue | A short-lived document. Creating one runs continueBroadcast, which resumes a long broadcast and deletes it. | Server only | Server only | Email marketing | broadcastId, triggeredAt |
SmsLogs | Every SMS sent, or only recorded with the log provider. | Admin | Server only | SMS | to, purpose, provider, status, error, createdAt |
Audience and automations
| Collection | Holds | Read | Write | Feature | Key fields |
|---|---|---|---|---|---|
Contacts | One person in the audience, id the email hash. Filled from users, form members, imports and payments. | Admin | Server only; admins change contacts through callables | Audience | email, emailHash, name, sources, userId, listIds, tags, fields, consent, disabled |
Lists | Audience lists: system lists such as all-users, one list per form, and lists admins make. | Admin | Admin | Audience | name, description, type, formId, conditions, memberCount |
ContactTags | Tags on contacts, id a slug of the label. Not the content tags in Tags_{slug}. | Admin | Admin | Audience | label, color, usageCount |
ErasureLog | Proof that a contact was erased, id the email hash. It holds no address. | Admin | Server only | Audience | emailHash, erasedAt, erasedByUid, removed |
DripCampaigns | One drip sequence: the list it follows and its steps. | Admin | Admin | Email marketing | name, listId, status, trigger, steps, exit, counts |
DripEnrollments | One contact's progress through one sequence, id {campaignId}_{contactId}. | Admin | Server only | Email marketing | campaignId, contactId, status, currentStep, nextSendAt |
Announcements | One announcement sent by the sendAnnouncement callable. | Admin | Server only | Email marketing | title, body, link, audience, sendEmail, status, counts |
AppEvents | The event bus: one event per document, such as user.signed_up or payment.succeeded. onAppEventCreate runs the matching actions. | Admin | Server only | Automations | type, userId, contactEmail, data, processed, createdAt |
Notifications | One in-app notification for one person. | Admin, or the person (userId is their Auth uid) | The person may change only read and readAt. Create and delete: server only. | Notifications | userId, type, title, body, link, read, createdAt |
Other collections
| Collection | Holds | Read | Write | Feature | Key fields |
|---|---|---|---|---|---|
Feedback | One item from the feedback button. onFeedbackCreated adds sender. | Admin | Create: a signed-in person, for themselves, while the button is on, with status new, known fields and short values, and files only from their own feedback folder. Update and delete: admin. | Feedback button | uid, userDocId, message, screenshotPath, voicePath, voiceSeconds, page, device, status, createdAt |
ContactMessages | One message from a contact form on the site. Only submitContactMessage writes it. | Admin | Update (status only: new, done, spam) and delete: admin. Never written from a browser. | Contact form (contact) | name, email, phone, subject, message, page, lang, status, createdAt |
PwaStats | Install prompt counts per day, id YYYY-MM-DD, written by trackPwaEvent. | Admin | Server only | Installable app (PWA) | date, prompt_shown, installed, dismissed, opened_installed |
AnalyticsDashboards | One document, analyticsData: the Google Analytics numbers the dashboard shows. | Admin | Admin; filled by the analytics callables | Analytics | metrics, acquisitionPanels, dateRange, lastSyncDate, propertyId |
Server-only collections with no rule
These have no rule in firestore.rules, so only Cloud Functions reach them.
| Collection | Holds | Feature | Key fields |
|---|---|---|---|
form_otps | Email codes for forms that verify members, id {formId}_{emailHash}. | Signup forms | waitlistId, emailHash, codeHash, expiresAt, attempts, verified |
CountedBuyers | A marker per payment or subscription, so a buyer adds to a product's purchaseCount once. | Payments | productId, countedAt |
AppAudience | Arc CMS's own state for each of the host app's users, id the SHA-256 of the person's unique key, so no app identifier is stored in the clear. | App audience | consent, consentChangedAt, deleted |
Published content items also get a DeploymentLogs subcollection: one record per static page deploy, written by the publish pipeline and never read by the browser.
Storage
Files live in the install's bucket. The browser puts storagePrefix in front of media, library files, imported files and feedback files, so with arccms/ the media library writes arccms/mediaImages/... and arccms/mediaFiles/.... Profile photos in avatars/ always go to the bucket root. The rules accept both forms: {prefix} below is that one folder.
| Path | Read | Write | Limits | What writes there |
|---|---|---|---|---|
{fileName} | Public | Staff | None | Any file at the bucket root. |
{first}/{second}/{rest=**} | Public, unless the first or second folder is users | Staff, except in a mediaFiles folder | None | The media library's images (mediaImages/{name}-{size}.{ext}, one file per size), files uploaded on the Data pages, and every other admin upload. |
mediaFiles/{fileName} and {prefix}/mediaFiles/{fileName} | Public | Staff (isLibraryFile) | Only PDF, Word, Excel, PowerPoint, CSV and text, each extension with its own content type, up to 20 MB; a PDF's -thumb.jpg under 1 MB. Delete by staff. | The media library's Files (Media). |
users/{userDocId}/{rest=**} and {prefix}/users/{userDocId}/{rest=**} | The owner (ownsUserRecord) or an admin | Staff through the general rule; apps grant owners write in storage.app.rules | Set by the app | An app's per-person files. Deleting the account deletes the folder. |
users/{userDocId}/feedback/{feedbackId}/{fileName} and {prefix}/users/{userDocId}/feedback/{feedbackId}/{fileName} | The owner or an admin | Create only, by the owner, and only a new file | screenshot.jpg as image/jpeg under 5 MB; voice.mp4, voice.webm or voice.ogg as audio under 10 MB | The feedback button. Admins delete the files with the feedback. |
avatars/{uid}/{fileName} | Public | The signed-in person whose Auth uid is {uid}, or staff | An image under 5 MB, or a delete | The profile page, which stores the photo's URL in the users record's photo. Deleting the account deletes the folder. |
Anything outside a users folder is public to read, including files an app uploads elsewhere. Keep private files in users/{userDocId}/ (under the prefix, when there is one), and grant writes there in storage.app.rules.