Skip to content

Custom handles become free, plus invite follow-ups - #126

Merged
kipavy merged 3 commits into
devfrom
feat/free-custom-handles
Aug 16, 2026
Merged

Custom handles become free, plus invite follow-ups#126
kipavy merged 3 commits into
devfrom
feat/free-custom-handles

Conversation

@kipavy

@kipavy kipavy commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Pairs with VoltiusApp/server#6 and VoltiusApp/web#7.

Custom handles become free

AccountSection stops reading the tier entirely. The claim control is offered to everyone and disabled — with the reason — for an unverified email; hiding it is what made the tier gate confusing, and a Business teammate whose own row reads free was being told to upgrade while their employer paid.

The copy stops naming a plan and says what a custom handle does: it makes you findable outside your teams, and your current handle already works for anyone you give it to. The 402 branch is replaced by the server's 403, so the failure says "verify your email first" instead of "invalid handle".

MeResponse gains email_verified/v1/auth/me has always returned it, the client just never declared it. It stays null until that call answers, so a verified user never sees the "verify your email" line flash.

Gone with the gate: isFreeTier, isLapsedCustom, meTier, tierKnown, and the upsell / reachableNote / lapsedKeepsHandle / lapsedRenameLocked / errorTierRequired strings in four locales.

Follow-ups

Sign-in swallowed the rate limiter. Every non-ok response collapsed into one message, so the hardcoded 10/min-per-IP auth limiter rendered as "Account not found" — which sends people off to create a second account they don't need. authFailure() branches on the status at all three call sites (both in signInToCloud, plus the challenge inside login), mapping 429 to a rate-limit message and anything unexpected to the status itself.

A knock appeared in the bell twice. Its 8-second toast is archived to history on dismissal, and history rows carry no actions — so the panel showed the same knock once actionable in the inbox and once dead below it, and the dead copy is what reads like Join and Decline were lost. ToastEntry.inboxId marks a toast as an echo of an entry that owns the event; such a toast is dropped instead of archived.

The headless container ran as root and left ~30k root-owned files in the mounted worktree. It now builds its user from UID/GID build args; rust moved to /opt so a non-root user can reach it, and CARGO_TARGET_DIR is /target, outside the bind mount — a target dir under vite's root makes the watcher crawl it until the webview never loads. A .dockerignore of * stops each build tarring a multi-gigabyte worktree the image never copies from.

And then it poisoned node_modules. pnpm records an absolute storeDir there; the container stamped /app/.pnpm-store, the host read a path that does not exist locally, decided the tree was foreign and asked to purge it — which fails outside a TTY. That surfaced as every plugin-bundle test failing with a bare Command failed: pnpm vite build, nowhere near the cause. A voltius-node-modules volume keeps the two trees apart. If your checkout is already in this state: CI=true pnpm install.

i18n review of the invite-branch strings: dropped the gendered participles the (а) and Invité forms papered over, and fixed a claim that was stale in all four locales — the people search offered "by name", but display_name is gone and the server matches teammates on email and everyone else on handle. Also unified 保管库保险库 in zh.

Verification

tsc --noEmit clean. vitest run3418 passed, 1 failed of 3419, and the failure is a bare Test timed out in 5000ms in TitleBar.syncState, unrelated to any of this. A concurrent session was compiling Rust at load ~16 while it ran; stashing this entire change reproduces the identical failure, and the file passes 2/2 in isolation once the box is quiet.

Not proven here: tauri-driver coming up on the rebuilt image. The ownership and /target mechanics were verified live (0 root-owned files after a container run, cargo writing to /target as the host user), but the cold app build was still going when I stopped it — worth confirming launch_app appPath=/target/debug/voltius on the next UI run.

Deploy order

Never before the server. Portal → server → this.

kipavy added 3 commits August 16, 2026 09:52
Custom handles are free now, so AccountSection stops reading the tier
entirely. The claim control is offered to everyone and disabled — with the
reason — for an unverified email; hiding it is what made the tier gate
confusing, and a Business teammate whose own row reads `free` was being
told to upgrade while their employer paid.

The copy stops naming a plan and says what a custom handle does: it makes
you findable outside your teams, and your current handle already works for
anyone you give it to. The 402 branch is replaced by the server's 403, so
the failure says "verify your email first" instead of "invalid handle".

MeResponse gains email_verified; /v1/auth/me has always returned it. It
stays null until that call answers so a verified user never sees the
"verify your email" line flash.
Sign-in collapsed every failed auth response into one message, so the
hardcoded 10/min-per-IP limiter rendered as "Account not found" and sent
people off to create a second account. authFailure() branches on the
status at all three call sites — both in signInToCloud and the challenge
inside login — mapping 429 to a rate-limit message and anything
unexpected to the status itself.

A knock appeared in the bell twice. Its 8-second toast is archived to
history on dismissal and history rows carry no actions, so the panel
showed the same knock once actionable in the inbox and once dead below
it — and the dead copy is what reads like Join and Decline were lost.
ToastEntry.inboxId marks a toast as an echo of an entry that owns the
event, and such a toast is dropped instead of archived.

The headless container ran as root and left ~30k root-owned files in the
mounted worktree. It now builds a user from UID/GID build args; rust moved
to /opt so a non-root user can reach it, and CARGO_TARGET_DIR is /target,
outside the bind mount — a target dir under vite's root makes the watcher
crawl it until the webview never loads. A .dockerignore of "*" stops each
build tarring a multi-gigabyte worktree the image never copies from.

i18n: dropped the gendered participles the "(а)" and "Invité" forms papered
over, and fixed a claim that was stale in all four locales — the people
search offered "by name", but display_name is gone and the server matches
teammates on email and everyone else on handle.
pnpm records an absolute storeDir in node_modules/.modules.yaml. With the
worktree bind-mounted, the container's install stamped /app/.pnpm-store
there, and the next command on the host read a storeDir that does not
exist locally, decided the tree was foreign and asked to purge it — which
fails outside a TTY. That surfaced as every plugin-bundle test failing
with a bare "Command failed: pnpm vite build", nowhere near the cause.

A named volume at /app/node_modules keeps the two trees apart. Recovery
if a container built from the old image poisoned yours: CI=true pnpm install.
@kipavy
kipavy merged commit 8ef1fc1 into dev Aug 16, 2026
4 checks passed
@kipavy
kipavy deleted the feat/free-custom-handles branch August 16, 2026 13:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant