Draft for team review · Testnet preview — the backend API is deployed and running · full written spec lives in Notion (Core Docs 7) · Korean edition: cns-living-spec-ko.html.

🎮 Live Demo

Pick a page on the left: its spec appears here and the interactive demo on the right. The demo is entirely mock state — no real RPC calls.

V1.0.0 Interactive Mock only · no RPC
How to use this document

① Product pages (S01~S06) are the user-facing screen specs based on the cns-experience.html implementation. ② In the admin console, the owner connects a wallet to sign & execute onlyOwner actions directly in the browser (cns-admin.html). ③ The Admin Console page embeds the real cns-admin.html — connect the owner wallet there to enable on-chain actions. ④ Press ⌘K / Ctrl+K anywhere to search names, reports, and pages and jump. ⑤ Full written spec (PRD, contract, ops, legal, etc.) lives in Notion — Core Docs 7, via the sidebar "Full spec" link.

▶ Live app (real demo — wallet connect & registration actually work) Open in new tab ↗

Admin console (owner-only): cns-admin.html ↗ — actions unlock when the owner wallet is connected.

GroupPagesSource documents
Product pagesS01~S06docs/fe-page-spec-ko.md (based on the 1.0.0 implementation)
Admin consoleembeddedcns-admin.html — owner wallet connect executes onlyOwner actions directly (Emergency pause targets 1.1.0)

📝 S02 · Registration Flow

Review modal M-2 (/{label}.ctc/register/review) → success modal M-3 (…/registered). Includes the metadata prepare (signature auth) step.

V1.0.0 P0 M-2 · M-3 Money is at stake
1Spec & policy — review modal M-2
Title/noteReview registration / Confirm the name and fee before opening your wallet.
Durationpresets 1·2·5·10 years + manual input (clamped to 1~100)
Primary toggleSet as primary — ON by default
SummaryDuration + Expires YYYY-MM-DD · Primary Yes/No · Registration fee {N} CTC · Due now {N} CTC + gas
Routingclosing the modal returns to the parent path via replaceState (avoids history pollution) · applying a route closes all modals and opens only the target one

Confirm button states:

ConditionButton textEnabled
Wallet not connectedConnect walletenabled (wallet modal → auto-resume after connect)
Availability check in flightChecking availabilitydisabled
Not registrableRegistration unavailabledisabled
RegistrableConfirm registrationenabled
In progressWaiting for wallet...disabled — cancel/close also disabled
2Confirm flow — 9 steps (each step aborts with a status message on failure)
#StepCopy on failure
1Re-judge availabilityhelper or This name cannot be registered.
2Not connected → resume flag + wallet modalConnect a wallet, then you can confirm this registration.
3Ensure network (switch/add chain)abort on failure — S06 network-switch copy
4Re-check on-chain availabilityfront-run: This name was just registered by another wallet. · failure: Could not check this name. Try again.
5Preparing registration. → metadata support probe (eth_call)NFT metadata registration is not available. Try again after the contract/API check passes.
6Preparing NFT metadata. Sign the request in your wallet. → personal_sign → POST /v1/metadata/prepare404: Metadata route is not deployed. Restart the API server. · 429: Metadata preparation is rate limited. Try again in {N}s. · other: server message or Could not prepare NFT metadata.
7Your wallet is opening. Confirm the transaction there. → balance check → preflight eth_call → send registerWithMetadatainsufficient balance: Not enough CTC. Need {N} CTC plus gas. · contract errors translated via the §10 table · rejection (4001): Transaction rejected
8Button Confirming · Transaction submitted. Waiting for Creditcoin confirmation. → receipt polling 1.5s × 30failure: Transaction failed. · timeout: Transaction submitted. Check your wallet activity.
9Success: close modal after 700ms, show success modal M-3 · optimistic local list update + background reload-
⚠️ No transaction if prepare fails. Never send registerWithMetadata before step 6 (signature auth + IPFS upload) succeeds and returns a metadataURI — prevents minting NFTs without metadata. The signed message includes label/owner/years/primary/chainId/expiresAt/issuedAt; reusing the same signature returns 401 signature_replayed (hardening).
3Screen components — success modal M-3
  • Badge Registered (Renewed for renewals) · title Your name is live (renewal: Name renewed)
  • Name + Expires pill (YYYY-MM-DD)
  • Buttons ① View transaction (when a tx hash exists — otherwise View on explorer: token instance / token / base URL fallback order) ② View all names → goes to names + row highlight + toast Opened name list
  • Close: from register, clears the input and goes to /; from renew, goes to /names
4Data sources
StepCallTagLimit/notes
metadata preparePOST /v1/metadata/prepare (Pinata/IPFS upload)API20/min · signature required · replay rejected
registration txregisterWithMetadata(label, years, primary, metadataURI)ON-CHAINvalue = annual price × years
receipteth_getTransactionReceipt pollingON-CHAIN1.5s × 30 = max 45s
5Mobile / responsive

Modal becomes a full-width sheet. The 4 duration presets form a 2×2 grid. Close-blocking while in progress is unchanged.

6QA cases
  • TC-S02-01 · P0 Given the happy path When Confirm registration Then the 9 steps run in order · success modal · optimistic list update.
  • TC-S02-02 · P0 Given wallet signature rejection (4001) When at step 6 or 7 Then Transaction rejected-family copy · modal stays open · retry possible.
  • TC-S02-03 · P0 Given prepare 429 When at step 6 Then Metadata preparation is rate limited. Try again in {N}s. · no tx is sent.
  • TC-S02-04 · P0 Given another wallet front-runs When step 4 re-checks Then This name was just registered by another wallet.
  • TC-S02-05 · P0 Given insufficient balance When at step 7 Then Not enough CTC. Need {N} CTC plus gas. · no tx is sent.
  • TC-S02-06 · P1 Given Confirm while disconnected When the wallet connects Then registration confirm auto-resumes via the resume flag.
  • TC-S02-07 · P1 Given receipt polling exceeds 30 tries When it times out Then Transaction submitted. Check your wallet activity. — never assert failure.
Interaction map — what each press does (incl. navigation)
ElementPressAction / navigation
Duration presets 1·2·5·10 yearsClickMark selection + recompute summary (Expires YYYY-MM-DD · Registration fee · Due now)
Years direct inputInputClamp 1–100 → recompute summary
Set as primary toggleClickDefault ON ↔ OFF → summary Primary Yes/No
Confirm button Connect walletClick (disconnected)Opens wallet modal M-1 → auto-resumes registration on success
Confirm button Confirm registrationClickStarts the §2 9-step confirm flow (stops with the step copy on failure)
X close / backdropClickreplaceState back to parent route — blocked while in progress (Waiting for wallet...)
M-3 View transactionClickExplorer tx page, new tab (no tx hash → View on explorer: token instance→token→base fallback)
M-3 View all namesClickGo to /names + row highlight + toast Opened name list
M-3 closeClickFrom register: reset input + / · from renew: /names

📇 S03 · My names

/names — manage the list of owned names (entry point for record editing / renewal / setting primary).

V1.0.0 P0 3-tier fallback loading
1Spec & policy — table
Routes/names · /names/connect-wallet · /names/{label}.ctc/renew|records|renewed
ColumnsName (sortable) / Expiry (sortable, default asc) / Primary
titleMy names | CNS
  • Sorting: clicking the same column toggles asc↔desc. The primary name is always pinned to the top regardless of sort. Ties break by creation date (desc) → name.
  • Name cell: avatar (or placeholder) + name + record-edit icon (pencil) + Created YYYY-MM-DD (when present).
  • Expiry cell: YYYY-MM-DD + renew button (+). Urgent styling when expiry is within 3 months (accent color).
  • Primary cell: Primary success pill if primary, otherwise a Make primary button (loading dots + disabled during the transaction).
  • Avatar: ipfs://https://ipfs.io/ipfs/ gateway conversion · https only · failed sources are not retried within the session.

Interaction map — everything clickable on this screen:

ElementPressBehavior
Name/Expiry column headersClickToggle sort asc↔desc (primary always pinned to top; ties by creation date desc → name)
Pencil icon in Name cellClickEdit-records modal M-5 (/names/{label}.ctc/records)
+ button in Expiry cellClickRenew modal M-4 (/names/{label}.ctc/renew)
Make primary buttonClickIf forward address ≠ my address: toast Update the address record first and stop · otherwise sends setPrimary, button shows loading dots + disabled · success: toast {name}.ctc is primary + wallet button text refresh · rejected Transaction rejected / failure Primary transaction failed
Empty state Connect walletClickWallet connect modal M-1
Empty list Register nameClickGo to register screen (/)
Row highlight2.6s highlight + scroll when arriving via success modal View all names or S01 Open My names
2Screen states — display by state
StateTitleBodyAction
Wallet not connectedConnect walletConnect your wallet to load the names you own.Connect wallet button → wallet modal
Loading (no cache)Loading namesReading CNS NFTs from Creditcoin Testnet.-
Empty listNo names foundRegister a name first, then manage it here.Register name button → register screen
Load failed(keeps current display)toast Could not load names-
🟢 active / 🟠 expiring / 🔴 expired row variants Variant
active: normal row. expiring (expiry ≤3 months): Expiry urgent accent. expired: the contract rejects renewal (NameExpired) — attempting renewal shows This name has expired.; with no grace period anyone can re-register immediately. Steering users to re-register is the policy.
⚡ Cache-first display Loading
Right after connecting, the CNS_NAMES_{chainId}_{address} localStorage cache is rendered first, then reloaded in the background. If the same address was already loaded, the reload is skipped (ignored when forced).
3Screen components
  • Page title + table (Name / Expiry / Primary)
  • Row actions: pencil (records modal M-5) · + (renewal modal M-4) · Make primary
  • Arriving from Register/Search via Open My names highlights the row for 2.6s + scrolls to it
4Data sources — 3-tier fallback loading
OrderSourceTagNotes
resolver API GET /v1/names/{address}INDEXER-READYexpensive route — mind the 10/min/IP limit
Blockscout GET /api/v2/addresses/{address}/nftINDEXERmax 5 pages · CNS contract filter · each label re-verified via resolveRecords
RPC eth_getLogs Transfer topic scanON-CHAINfrom startBlock 4,810,000 in 20,000-block chunks · in/out token set → rebuilt via ownerOf/labelOf/resolveRecords

Finally, primaryOf(address) syncs the primary. Results are stored in the localStorage cache.

5Mobile / responsive

Table reflows into a card list (name/expiry/primary stacked vertically). Long-name truncation rules unchanged. Row actions become a button row at the card bottom.

6QA cases
  • TC-S03-01 · P0 Given ① API fails When loading Then falls back to ② Blockscout, and to ③ log scan if ② fails — user sees a single loading state.
  • TC-S03-02 · P0 Given a primary name is owned When any sort is applied Then the primary row stays pinned to the top.
  • TC-S03-03 · P0 Given a name 80 days from expiry When rendered Then Expiry shows the urgent accent.
  • TC-S03-04 · P1 Given a cache exists + revisit When connecting Then cache renders immediately, then background refresh — no flicker.
  • TC-S03-05 · P1 Given all loads fail When a cache exists Then keep the current display + toast Could not load names.
  • TC-S03-06 · P2 Given every date When displayed Then fixed YYYY-MM-DD (never expose ISO timestamps).

🗂 S04 · Records (M-5)

/names/{label}.ctc/records — edit the address + 10 optional records. All records are public on-chain data.

V1.0.0 P0 10 records RecordsLocked
1Spec & policy
Title/noteEdit records / Set the address and optional records for this name.
Structurebasic fields + Advanced collapsible (closed by default)
Save semanticsone setRecords transaction overwrites all 10 fields
⚠️ Public data warning: every value entered here (including email and social handles) is stored as plaintext on-chain and readable by anyone. Images are stored only as ipfs:///https:// pointers, not files.

Fields + validation rules (empty values are always allowed — except Address):

FieldplaceholderValidation
Address0x...EVM address required (when connected). Default: existing record or my address
Avataripfs:// or https://ipfs:// or https://
Profile URLhttps://https:// only
Contenthashipfs:// or ipns://ipfs/ipns/bzz scheme or 0x hex
BioShort public biomax 280 chars (textarea)
X@handle or https://x.com/handlehandle (≤40 chars) or x.com/twitter.com URL
GitHubhandle or https://github.com/handlehandle or github.com URL
Discordusername or invite URLusername (2~32 chars) or discord.gg/discord.com
Telegram@handle or https://t.me/handlehandle or t.me/telegram.me
Emailname@example.comemail format
2Screen states
📄 Default (editable) Success
Prefilled with existing record values. Save button Save records. On success: local update + cache save + close modal + toast Records saved.
⚠️ Live validation failure Error
invalid class on invalid fields + status line Enter records in the correct format. + save disabled.
🔒 RecordsLocked Variant
Names soft-locked by the name owner revert in the preflight/send step. However, RecordsLocked/MetadataLocked have no dedicated user copy and surface as the generic Records transaction failed/Transaction failed (dedicated copy is a 1.1.0 candidate). The UI has no lock toggle today (contract feature, P1 extension). Lock state is read via lockStatus / the resolver response's locks.records.
👛 Not connected / name not owned Variant
Not connected: button Connect wallet (starts the connect flow on click). Name not owned: Name not found + This name is not loaded in this wallet.
⏳ Saving Loading
Button Saving (loading) · close blocked while in progress. Rejected: Transaction rejected / failed: Records transaction failed or Transaction failed.
3Screen components
  • Basic area: Address · Avatar · Profile URL · Bio
  • Advanced collapsible: Contenthash · X · GitHub · Discord · Telegram · Email
  • Status line: role="status" + aria-live="polite" (modal common §11)
4Data sources
DataSourceTag
Current recordsresolveRecords(label)ON-CHAIN
SavesetRecords(...) — overwrites all 10 fieldsON-CHAIN
Lock statelockStatus / resolver locksON-CHAIN
5Mobile / responsive

Modal becomes a full-screen sheet. Fields stack in 1 column. Advanced collapsible behavior unchanged. The Bio textarea is fixed at 3 rows.

6QA cases
  • TC-S04-01 · P0 Given invalid input in each of the 10 fields When typing Then invalid marker shows immediately + save disabled (§12-5).
  • TC-S04-02 · P0 Given Address cleared When save is attempted Then blocked — Address may not be empty (when connected).
  • TC-S04-03 · P0 Given a RecordsLocked name When saving Then revert detected in preflight → shown as the generic Records transaction failed/Transaction failed (no dedicated copy — adding it is a 1.1.0 candidate), no gas wasted on the tx.
  • TC-S04-04 · P1 Given only some fields edited When saving Then all 10 fields are overwritten — verify untouched field values are preserved.
  • TC-S04-05 · P1 Given a Bio containing <script> When displayed Then escapeHtml applied — no XSS (§12-10).
Interaction map — what each press does (incl. navigation)
ElementPressAction / navigation
Advanced disclosureClickOpen/close optional fields (Contenthash, socials) — closed by default
Each field inputInputLive validation (§1 table) — invalid highlights field + status line Enter records in the correct format. + disables save
Save Connect walletClick (disconnected)Starts wallet connect flow
Save Save recordsClickEnsure network → send setRecords (overwrites all 10 fields) → success: local update+cache+close+toast Records saved · rejected Transaction rejected / failure Records transaction failed
Save Name not foundDisabled + This name is not loaded in this wallet.
X close / backdropClickBack to /names — blocked while saving (Saving)

🔄 S05 · Renewal (M-4)

/names/{label}.ctc/renew — add years without changing records/primary. No renewal after expiry.

V1.0.0 P0 2-step confirm NameExpired
1Spec & policy
Title/noteRenew name / Add years without changing records or primary name.
Displayname / Current expiryAfter renewal date comparison / year presets (1·2·5·10) + manual input (1~100) / Due now {N} CTC + gas
New expirycurrent expiry + years (the prepare step's expiresAt uses the same calculation)

2-step confirm button: disconnected Connect walletReview renewal (1st click) → Confirm renewal (2nd click actually proceeds) → in progress Renewing.... Changing the years resets the confirm step.

Owner only: only the name (NFT) owner can renew — for any other wallet the contract reverts with NotNameOwner.

⚠️ No renewal after expiry: the contract rejects expired names with NameExpired — copy This name has expired. With no grace period anyone can re-register immediately, and the policy is that even the original owner reclaims via re-registration. (Grace period is a 1.1.0 candidate / the renew path cannot be paused even after 1.1.0 pause ships — operator actions must never let user names expire.)
2Screen states
📄 Default (2-step confirm) Success
Flow: ensure network → metadata probe (failure: NFT metadata renewal is not available.) → prepare (expiry = current expiry + years) → send renewWithMetadata → receipt → on success, success modal (Renewed) after 700ms.
🔴 Expired name Error
NameExpired in preflight → This name has expired. Steer to re-registration instead of renewal.
🟠 Expiring soon Variant
Urgent accent in My names (≤3 months) → entering via the + button is the main path.
❓ Name not in the list Empty
Close the modal and toast Name not found.
⏳ In progress Loading
Renewing... · close blocked · receipt polling 1.5s×30 — same shared tx UX (§10).
3Screen components
  • Name header · Current expiry → After renewal comparison block
  • 4 year-preset buttons + number input (clamped 1~100)
  • Summary: Due now {N} CTC + gas (annual price × years, comma-formatted)
  • Reuses success modal M-3 (badge Renewed · title Name renewed · close → /names)
4Data sources
DataSourceTag
Current expiryexpiresAt(labelHash) / local listON-CHAIN
metadata preparePOST /v1/metadata/prepare (image/JSON reflecting the new expiry)API
renewal txrenewWithMetadata(label, years, metadataURI)ON-CHAIN
5Mobile / responsive

Modal becomes a full-screen sheet. Presets 2×2. Date comparison block stacks vertically.

6QA cases
  • TC-S05-01 · P0 Given after the 1st Review renewal click When the years change Then the confirm step resets — nothing is sent immediately (§12-4).
  • TC-S05-02 · P0 Given an expired name When renewal is attempted Then This name has expired. · no tx sent.
  • TC-S05-03 · P0 Given a normal renewal When it succeeds Then After renewal = current expiry + years · success modal Renewed · list expiry updated.
  • TC-S05-04 · P1 Given prepare fails When renewing Then NFT metadata renewal is not available. · no tx sent.
  • TC-S05-05 · P2 Given 101 entered When typing manually Then clamped to 100 · the DurationOutOfRange copy is Choose between 1 and 100 years.
Interaction map — what each press does (incl. navigation)
ElementPressAction / navigation
Year presets 1·2·5·10 / direct inputClick/InputClamp 1–100 → recompute After renewal date and Due now + reset confirm step
Button Connect walletClick (disconnected)Wallet modal M-1
Button Review renewal (1st)ClickSwitch to confirm step — button becomes Confirm renewal
Button Confirm renewal (2nd)ClickEnsure network → metadata probe → prepare (expiry=current+years) → renewWithMetadata → receipt → success modal (Renewed) after 700ms
X close / backdropClickBack to /names — blocked while in progress (Renewing...)
When name not in listModal closes + toast Name not found

👛 S06 · Wallet Connect (M-1) + Set Primary

/connect-wallet-family modals + Make primary on My names rows (no modal).

V1.0.0 P0 MetaMask · WalletConnect
1Spec & policy — modal M-1
TitleConnect wallet (after connecting Wallet connected)
NoteSelect a wallet. We will switch it to {chainName} if needed. (after connecting {MetaMask|WalletConnect|Preview wallet} is ready on {chainName}.)
OptionsMetaMask (Browser wallet) / WalletConnect (Mobile wallets and QR) — both options hidden once connected

Connection error copy: not installed MetaMask is not installed. · no account No account selected. · rejected (4001) Connection rejected. · other Wallet connection failed.

Network switch (ensureCreditcoinNetwork): check eth_chainId → if different wallet_switchEthereumChain → on 4902 (chain missing) wallet_addEthereumChain then retry. Status: Checking {chainName}...Switch to {chainName} in your wallet. → (if needed) Add {chainName} in your wallet. → success {chainName} ready. Failure: rejected (4001) Network request rejected. / unsupported (4200, -32601) This wallet cannot add or switch networks automatically. / other Could not switch to {chainName}. {message}

Global behavior: auto-restore (eth_accounts — skipped if the manual disconnect record CNS_WALLET_MANUAL_DISCONNECT_{chainId} exists). accountsChanged with an empty array → disconnect + toast Wallet disconnected. chainChanged to another chain → toast Switching to {label} then auto-switch attempt.

Wallet button text states:

StateText
Not connectedConnect
Connected + has primaryprimary name (middle ... ellipsis over 18 chars)
Connected + preview modePreview
Connected (otherwise)0x1234...abcd (first 6 + last 4)
2Screen states + primary-setting rules
📄 Connect success Success
Ensure network → toast {MetaMask|WalletConnect} ready → close modal. If arriving from registration review (resume flag), registration confirm auto-resumes.
⏳ Connecting Loading
Both buttons disabled+loading · status Opening MetaMask.../Opening WalletConnect... · close blocked while connecting (except force close).
📱 WalletConnect variant Variant
Dynamic import of ethereum-provider from esm.sh + QR modal. Without a Project ID: guidance + toast WalletConnect Project ID required. On disconnect, provider.disconnect() is attempted.
⭐ Set primary (no modal) Feature
Precondition: forward address record == my address. Otherwise toast Update the address record first. Flow: ensure network → send setPrimary → row button loading dots → receipt → success toast {name}.ctc is primary + wallet button text refresh. Rejected: Transaction rejected / failed: Primary transaction failed. Contract-side rejection is ForwardRecordMismatchThe address record must point to this wallet before setting primary.
🔌 Dropdown menu Variant
① Copy address (on success hint Copy for 2s, on failure toast Could not copy address) ② Explorer link (CNS token inventory URL preferred) ③ Disconnect. Closes on outside click/ESC.
3Screen components
  • Modal M-1: title · note · 2 wallet option buttons · status line (role="status")
  • Header wallet button + dropdown (Copy address / Explorer / Disconnect)
  • My names Primary cell: Primary pill or Make primary button
4Data sources
DataSourceTag
Account restoreeth_accountsWALLET
Networketh_chainId · wallet_switchEthereumChain · wallet_addEthereumChainWALLET
primary lookupprimaryOf(address)ON-CHAIN
set primarysetPrimary(label)ON-CHAIN
manual disconnect memoryCNS_WALLET_MANUAL_DISCONNECT_{chainId} localStorageLOCAL

Test hooks (localhost only): ?mockWallet=1 · ?mockNetworkReject=1 · ?wcProjectId=.

5Mobile / responsive

On mobile, WalletConnect is the main path (deep link instead of QR). The dropdown menu becomes a bottom sheet. Button-text truncation rules unchanged.

6QA cases
  • TC-S06-01 · P0 Given a wallet without the Creditcoin chain When connecting Then proceeds automatically add chain → switch (§12-7).
  • TC-S06-02 · P0 Given a refresh after manual Disconnect When loading Then no auto-reconnect — the record is cleared on successful reconnect (§12-8).
  • TC-S06-03 · P0 Given forward address ≠ my address When Make primary Then toast Update the address record first · no tx sent (§12-6).
  • TC-S06-04 · P1 Given accountsChanged with an empty array When it fires Then disconnect handling + toast Wallet disconnected.
  • TC-S06-05 · P1 Given network switch rejected (4001) When connecting Then Network request rejected. · stays not-connected.
  • TC-S06-06 · P2 Given a 19-char primary name When shown on the wallet button Then middle ... ellipsis.
Interaction map — what each press does (incl. navigation)
ElementPressAction / navigation
MetaMask optionClickStatus Opening MetaMask... → success: ensure network → toast MetaMask ready → close (auto-resume if from review) · not installed MetaMask is not installed. / rejected Connection rejected. / no account No account selected.
WalletConnect optionClickDynamic import provider → show QR modal · no Project ID → notice + toast WalletConnect Project ID required
While connectingBoth options disabled+loading · close blocked
After connectOptions hidden · title Wallet connected · note {wallet} is ready on {chainName}.
Make primary (My names row)ClickSee S03 interaction map — setPrimary flow

🛠 Admin Console

Wallet-connected owner console — the real thing is embedded on the right.

Intent · purpose of this page

Owner connects MetaMask and signs onlyOwner actions directly: setReserved, setShortNameClaim, setMetadataURI, withdraw, transferOwnership / acceptOwnership / cancelOwnershipTransfer. Includes Lookup (name / address / price) and an NFT · metadata inspector. IPFS upload stays server-side (PINATA_JWT never in the browser). Emergency pause is a 1.1.0 item (no pause function in the contract yet). On mainnet the owner may be a Safe multisig — actions are then proposed via the wallet, not executed instantly. Access is enforced on-chain (onlyOwner); the console only enables the buttons when the connected wallet is the owner. In 1.1.0 the owner can also authorize delegated operators via setOperator, who may run setReserved / setShortNameClaim / setMetadataURI, while treasury (withdraw), ownership transfer, upgrade, and setOperator itself stay owner-only. Content moderation: reserve offensive/impersonation labels via setReserved; the app also ships a built-in offensive-word filter.

Home