A cloud instance's licence is issued in HQ and written into the control
plane by admin/internal/inject, straight to the database. The customer has
nothing to paste, so /settings/license offered them a form that could only
ever fail — and on an expired cloud instance, failed at the exact moment
they were looking for a way out.
GET /license now reports `deployment`, and the page swaps the paste form
for a short panel saying where the licence comes from, with a link to the
portal when HQ_URL is set. That is the same treatment hq-managed members
already get in the members table: read-only here, and a pointer to where
it is actually managed.
POST /license refuses with 409 cloud_managed on a cloud deployment. Hiding
a form is a courtesy; this codebase's rule is that the API is the boundary,
and the endpoint was reachable regardless of what the page rendered. It
cannot break injection, which never goes through HTTP at all.
Verified: server builds and vets clean, web builds clean.
This screen was never finished. It had no page padding, so it sat flush
against the sidebar while every other screen has p-8; it used raw button,
textarea and file inputs instead of the design system; and it rendered
state as "State: valid" in a sentence.
It also dropped `reason` entirely. An instance with an invalid licence was
told "State: invalid" and nothing else — not that the signature failed, not
that it was issued against a different instance, not what to do. That was
the real defect, and no layout survives having nowhere to put the most
important thing on the page.
A licence is a document: issued, dated, signed, carrying a reference you
quote to support. The page now reads that way, which is also how adminsite/
presents the same object from the issuing side — state as a label with a
shape, the reference on a mono record line, entitlement as keyed fields.
- a record panel opens the page: the state as a statement, the reason
underneath in this app's own words rather than the API's identifiers,
then Expires / Source / Instance ID as keyed fields. `source` and
`days_remaining` were being returned and never shown
- counted limits become meters, because that is already how the console
shows headroom on a server's disks — same question, same reading. They
turn amber at 80% and red at the cap
- an unlimited allowance gets no bar. A full-width one would read as "at
the limit", which is the opposite of what it means
- features render as included/not with a glyph as well as a colour
- the file input is a styled label over a visually hidden input, and now
reports which file it loaded
Two things found reviewing my own work: Card's p-6 is emitted after p-4, so
`<Card className="p-4">` silently rendered at p-6 — the allowance cards pass
padding={false} instead. And the state had a coloured dot next to a coloured
word on a colour-ruled card, which is one telling too many; the dot is gone.
`Group` moves to components/settings/ so this page and /settings share the
band label rather than growing a second copy.
/settings/instance held two cards — Members and Single sign-on — behind a
sidebar entry called "Instance", next to one called "Settings". Nothing in
either name told you which held what, and the split left both pages thin.
They are now the Access group at the top of /settings, above Monitoring and
Integrations. Six cards on one page needs sorting into groups or it reads as
a pile, so each group carries site/'s keyed-label eyebrow on a hairline.
The cards move to web/components/settings/ rather than into the page, which
would have made it ~600 lines. That is also where the Field and inputClass
pair now lives: the two pages each had a byte-identical copy, and folding
them together is exactly when three copies would have started to drift.
Members and SSO adopt SectionCard, which the rest of the page already used.
A card that kept its own header treatment would read as a different kind of
thing rather than another setting, which is the problem being fixed.
next.config.ts keeps a permanent redirect from the old path, so bookmarks
and any support reply linking it still land somewhere useful.
web/ was the last app on its own palette — a neutral #0f1117 ground with an
indigo accent, unrelated to the logo navy that site/ and adminsite/ are built
on. It now draws from the same tokens, so all three apps are one system.
It stays locked to dark, taking site/'s dark values. That is what keeps
adminsite/'s light default meaningful: an operator with both open tells them
apart by the ground before clicking anything destructive, and now that both
are the same palette, the ground is the only thing left doing that work.
The colour names stay this app's own — text-primary, border, surface rather
than ink, rule, panel — because every screen already reads that way, and
adminsite/ already establishes that each app names the shared tokens after
its own subject.
Tokens are stored as RGB channels with the hex in a trailing comment. The
console leans on Tailwind opacity modifiers far more than the other two
(bg-danger/10, border-accent/50, ring-accent/30), and <alpha-value> only
compiles against channels; the comments keep the three token blocks
diffable by eye.
Beyond colour:
- radii collapse to site/'s 4px in tailwind.config.ts rather than rewriting
~140 rounded-lg classes; rounded-full is untouched for dots and pills
- badges become site/'s chip — mono, uppercase, tracked, currentColor rule,
no fill — keeping their dot so state is never colour alone
- table column heads take the keyed-label idiom, at text-secondary rather
than tertiary, which lands under 4.5:1 at that size
- sidebar marks the active item with an accent bar, the device site/ uses
for the chosen plan, instead of a filled pill that reads as pressable
- filled accent and danger buttons take accent-ink; the dark accent is a
light blue and danger a coral, and white on either was unreadable
- login's packet pulses shift from green to the accent, so the sign-in
screen is the same two blues as the marketing hero
The last hex literals and stock-palette classes are gone; the only ones left
are NetworkBackground's canvas fills, which cannot read a CSS variable and
are commented with the token each came from.
Only the web image rebuilds from this.
Every push rebuilt all six images regardless of what it touched. A git diff
against github.event.before now gates each build step.
Two things this needs to work at all: fetch-depth 0, because the default
shallow clone has a single commit and nothing to diff against, and git
installed in the dind container, which had node and npm but not git.
The path mapping follows the build contexts rather than intuition — the Go
images use a root context and COPY shared/, so shared/ fans out to all
three, while the Next images use their own directory and cannot be affected
from outside it. Anything that leaves no trustworthy base commit — manual
run, new branch, force-push whose old head is gone — lists every tracked
file instead, so the fallback is one code path rather than two.
Known gap, documented: a repo variable change pushes no commit, so nothing
rebuilds. workflow_dispatch is the escape hatch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Free has no features, so a nil Go slice served "features": null. The
portal's type said string[], called .length on it, and took the page down.
The guarantee lives on a named Features type with its own MarshalJSON
rather than at each of the six places a licence or plan is serialised,
because the seventh is the one that would have been forgotten. Applying it
at marshal time also fixes rows already holding null in Mongo, which a
write-side fix alone would not.
Same class, found while checking the rest: staffListInstances built its
expiring filter from a nil []string, marshalling to $in: null, which Mongo
rejects — so the quiet week when nothing is expiring is precisely when the
staff Operations page's own query would have failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The console had components but no shell: a brand bar and a nav strip stacked
into 100px carrying eight words, no sign-out, no account identity, and an
Overview link hardcoded to text-accent so it read as the current page on
every screen. Nine pages each hand-rolled their own header.
AppBar replaces both bars and derives its active state from usePathname.
Settings moves into AccountMenu — it is your password, not a destination —
taking appearance with it, which finally sets the data-theme attribute the
token blocks have supported in both directions since they were written.
That leaves three customer destinations: Overview, People, Billing.
PageFrame adds a support rail so a page has a floor, and InstanceRecord
replaces InstanceCard with one component that opens and closes: an account
with a single instance used to render a third of a row of summary with its
substance a click away. It defaults open when the instance is the only one
or needs attention.
No plan card in the rail: tier, limits and expiry belong to a licence and a
licence belongs to one instance, so an account holding a Free cloud instance
and a Professional self-hosted one has no single plan. The rail carries only
what is account-wide.
Tokens and globals.css are untouched — they stay verbatim shared with site/.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Records why instance_members is an index and not the authority, why hqsync
is not part of inject, and why an invitation carries no password.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The lock is a courtesy — the API answers 409 either way. NEXT_PUBLIC_HQ_URL
defaults empty so a self-hosted install shows a plain label rather than a
link to a portal that does not serve them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The members panel is absent for self-hosted instances rather than disabled:
the backend refuses those, and a panel rendering controls the server will
reject is a panel that lies.
/auth/me now reports the caller's account role, so the UI hides what the
backend would refuse rather than discovering it in an error toast.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The API is the boundary; hiding the control in web/ is a courtesy. A role
editable in two places is a role with two answers.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Its own package rather than a pass inside inject: inject writes three
licence fields and nothing else, and that narrowness is what makes admin's
reach into the control plane reviewable.
Repairs by copying HQ's hash, not by re-hashing — two bcrypt hashes of one
password differ by salt, so a re-hash would never converge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Best-effort by design: refusing the change because one instance was
unreachable would leave the customer holding the password they were trying
to replace. A failure is flagged and hqsync repairs it.
Also corrects two pieces of copy this makes false — CreateInstance's doc
comment and the instance-ready email both claimed the two passwords
diverge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A grant writes a real control-plane user; the instance_members row is only
admin's index of it, which is why a failed insert unwinds the projection.
Self-hosted instances refuse all three mutations: their users live in a
deployment we cannot see.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An invitation carries no password. The HQ password is what signs someone
into every instance they are granted, so a password the inviter chose would
be a shared credential to all of them — the invited row has an empty hash,
which cannot authenticate, until /accept-invite sets one.
Removing a person revokes every projected instance user first, and refuses
outright if any of those is an instance's last owner.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A grant is a real control-plane users row with auth_source hq, not a
federation shim: the instance authenticates it with no runtime dependency
on admin. CountOtherOwners counts control-plane owners so a locally-created
owner satisfies the last-owner rule too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 2 created cloud instances without recording who owns them on this
side, because the collection did not exist. The boot backfill reconstructs
one member row per instance from the hq-sourced control-plane owner, and
marks every existing customer_user an owner — they all created their own
account.
Backfill lives in models rather than db: db is the connection layer and
models already imports it for SeedPlans, so db -> models would cycle.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two bugs, one symptom: signup created an account and a customer_user but
no verification email ever arrived.
net/smtp.SendMail only speaks STARTTLS. Against a port-465 server, which
expects a TLS handshake immediately, it never delivers. The transport now
wraps the connection before speaking SMTP on 465, exactly as
sitesvc/internal/mail already did — the two are duplicated, so change both
or consolidate into shared/. Also adds Date and Message-ID, whose absence
gets a message scored as spam, and a 15s deadline on the conversation.
The rollbacks ran on the HTTP request's context. A stalled mail server
holds the request until the browser gives up, which cancels that context
and turns both rollbacks into silent no-ops — stranding the exact rows
they exist to remove. They now run detached with their own timeout, and
log when they fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/start now posts account signups straight to admin, so the browser sends
a cross-origin preflight from vantage.hostxtra.co.uk. With only the
console origin in ADMIN_ORIGIN it was refused.
The failure mode is worth naming: cors() omits Access-Control-Allow-Origin
but still answers the preflight 204, so the browser blocks the request and
admin logs nothing. It looks like the endpoint is down when curl says it
is fine.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
FREE_INSTANCE_REAP_AFTER is set only in docker-compose.site.yml, so a
self-hosted deployment can never reap. Admin and server must carry the
same value: one names the deletion date in warnings, the other acts on it.
Records that admin now has a second control-plane write path, cloudprov,
and that deletion lives in the control plane because that is where the
knowledge of what an instance is made of belongs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Account creation moved to admin, which owns accounts, and the marketing
form now posts there. sitesvc keeps the contact mailer only.
DEPLOY LAST: sitesvc's verify endpoint must stay live until every
outstanding pending signup has expired, or an in-flight verification link
breaks. Do not roll this out until the site change has been live 24 hours
and site_pending_signups is empty.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The form posts to admin's signup and the slug preview goes: there is no
instance at this point, and previewing one promises something the
submission does not create. Creating the instance is now a step in the
portal, which the page's What happens next panel spells out.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the create form with a live slug preview, a renew action inside the
seven-day window, and a deletion countdown that renders only when the
backend has actually promised a date.
The progress bar denominator now follows the tier; a 30-day Free licence
was rendering as an 8% sliver against the hardcoded 365.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Without this the row stays active forever, the reconciler re-logs the
same miss every fifteen minutes, and the lifecycle sweep keeps emailing
about an instance that no longer exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The control plane owns deletion because it is the only service that knows
what an instance is made of; mirroring that collection list into admin
would drift, and a drift here deletes the wrong rows.
Defaults OFF. Eligibility is three positive assertions — Free tier, an
expiry that exists, and an expiry past the window — so a missing or stale
field is never eligible. The instance document is deleted last, making an
interrupted purge retryable rather than orphaning rows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Hourly sweep marks expired Free instances lapsed and sends at most one
notice per instance per pass, most urgent first, recorded on the document
so a restart cannot re-send.
Deletion warnings are suppressed when the reaper is off. Promising a
deletion that will never happen is a lie, and a scarier one than silence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Available from seven days before expiry and, deliberately, at any point
after it up to deletion, so the same button rescues a lapsed instance.
Renewal is manual because it is the entire reclaim signal: an instance
nobody renews is one nobody is using, which is what makes reaping safe.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Provisions the control-plane instance and its owner, records the
admin_instances row, issues and injects a Free licence, and emails the
customer where it is and when it expires.
Licence issuance and email cannot fail the request. The instance exists
and the customer can sign in; rolling back something they can already see
would be worse than shipping it unlicensed for staff to fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Admin's second and final write path into the control plane. It creates
instances and users and nothing else; inject still owns exactly three
licence fields and is untouched.
The owner's password hash is copied from the HQ account, not shared. The
two diverge on the next password change, which is accepted: propagating a
hash across two databases is worse than two passwords that started equal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ten tasks: cloudprov, instance creation, renewal, the notice sweep, the
reaper, the reconciler tie-in, the HQ and marketing UI, the sitesvc
cutover, and an end-to-end verification.
The reaper sweeps once at boot as inject's reconciler does, which is what
makes it observable at all — the ticker is hourly and deletion is
measured in days.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The old index was load-bearing because two lookups were unscoped. Both
are scoped now and the unscoped helper is gone, so the property that
matters is the absence of any unscoped lookup by email. Says so, and
documents auth_source hq.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
HQ sign-in names no instance, so a lookup of control-plane users by email
alone cannot be scoped — and users.email is no longer globally unique, so
it would return an arbitrary match. Every customer authenticates against
customer_users instead.
Legacy cloud customers get an HQ login from staff via the new
POST /api/staff/accounts/:id/users, alongside the manual instance attach
the spec README already describes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
users is unique on (instance_id, email) now, so an unscoped FindOne could
return an arbitrary one of several matching users. On the login path that
means signing someone into a tenant that is not theirs.
GetUserByEmail is deleted rather than left unused. Local sign-in resolves
its instance from the host, falling back to the single instance a
self-hosted deployment has, and refuses to guess otherwise. The OIDC
cross-instance guard goes: a scoped lookup cannot return another
instance's user, which is a stronger guarantee than the check it replaces.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing writes them yet. They land now so phases 2 and 3 do not require a
second rebuild of every service that consumes the shared module.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One address is one user within an instance, not globally, so an account's
people can be projected into every instance they are granted.
The replacement index is created before email_1 is dropped, so a failure
at any point leaves a working constraint. The drop is idempotent and
tolerates two services racing it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seven tasks: the index swap, the hq fields, the three scoped lookups,
removing admin's unscoped control-plane login, and an end-to-end
verification that two users sharing one address sign in to different
instances.
Also corrects the spec's phase list, which claimed phase 1 projects the
creator as owner. Projection needs instance creation, which is phase 2.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An account has people, and those people belong to several cloud
instances. That requires dropping the global unique index on users.email
for a compound (instance_id, email), scoping the two unscoped lookups
that made the global index load-bearing, and projecting HQ users into
instances as real control-plane users.
Self-hosted instances are never projected into; their users stay local.
Restructured into three phases: identity, creation and lifecycle,
membership.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Separates having an account from having an instance. Signup creates only
the HQ account; the customer then creates a Free cloud instance from the
portal, which provisions the control-plane instance, the admin_instances
row and a Free licence in one path.
Supersedes spec 5's signup-migration section.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Everything checkable without a browser, against scratch databases:
- All five Go modules and the adminsite production build compile clean.
- Scoping holds: own instance 200, another account's 404, nonexistent 404 --
indistinguishable, so no existence oracle -- and a customer session on the
staff API gets 401 where staff gets 200.
- max_relinks comes from the API (3), not a constant mirrored in TypeScript.
- The inline blob and the download endpoint return byte-identical content, so
the fallback is faithful rather than approximate.
- ADMIN_API_URL really is baked at build time: 9999 in the deliberately
broken image, 8083 in the good one.
- The served stylesheet carries site/'s tokens with matching values, plus
prefers-color-scheme and both data-theme overrides.
- With admin stopped the control plane still reports valid and mutations
still succeed -- instances never call admin.
- Admin touched only the three licence fields on instances; every other
control-plane collection is as the control plane left it.
Caught one stale-image bug doing this: the running admin predated the
customer blob change, so the licence response had no blob field at all.
Rebuilt and re-verified.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit missed this file: the repo tracks it as lowercase
claude.md, so staging CLAUDE.md matched nothing in the index.
admin/ was never documented here at all -- the backend plan's wiring task
covered compose and CI but not the file every session loads. So this adds
both services to the structure, admin's whole REST surface with its
404-never-403 rule, the three visual identities, and the coupling that
matters most: adminsite/ and site/ share one token set with nothing enforcing
the match, the same hazard shape as sitesvc's mirrored slug rules.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Publishes adminsite on 3004 -- 3000 is web, 3003 is the marketing site since
the port shuffle -- and adds the sixth CI image.
CLAUDE.md gains both new services. admin/ was never documented there at all:
the backend plan's wiring task covered compose and CI but not the file every
session actually loads. So this records admin's whole REST surface, its
404-not-403 rule, the three visual identities and, most importantly, that
adminsite/ and site/ share one token set with nothing enforcing the match --
the same hazard shape as sitesvc's mirrored slug rules.
Also notes that admin's REDIS_ADDR reaches admin only, because the base
compose hardcodes redis:6379 for server.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Licences and audit are both filterable client-side: the endpoints cap at 500
rows and staff are narrowing a list already in front of them.
Plans carry both guard rails spec 4 asks for. The confirmation names each
field that changes and states how many licences are already issued and
unaffected -- existing licences snapshotted their plan at issue time, and
saying so is what stops a well-meaning edit being followed by a panicked
reissue. Deployment is displayed and never editable, because moving a tier
between cloud and self-hosted would break the cloud-only rule spec 1 leans
on; that is a code review, not a form field.
The two edit buttons are the concrete changes staff need on day one. A
general-purpose limits editor waits until somebody asks for it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removes vitest, React Testing Library, the config, the setup file and all
eleven test files, plus the test scripts and dev dependencies. Done at the
user's direction; it matches the rest of the repo, which has no automated
tests in any language.
All eleven were observed passing before removal, and their assertions are
kept in the plan as acceptance criteria to check by hand rather than deleted
outright -- they are the clearest statement of what each component has to do.
Consequence worth stating: Task 16's manual pass is now the only verification
that exists for spec 4. Four behaviours it must cover carefully, because each
is easy to break invisibly: 404-not-403 scoping, the expired card naming what
still works, relink disabling at zero, and the blob fallback when a download
fails.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The screen that answers "why did this stop working on the 14th". Read top to
bottom it is one instance's whole history: what was issued, why, by whom, and
what replaced it.
Superseded entries stay visible and overprinted rather than disappearing,
because licences are append-only and hiding them would destroy the only
record that answers the question. Each links to its successor.
Injection state is shown live for cloud instances and omitted for
self-hosted, where the customer holds the blob and there is nothing for us to
have written. Staff relinks carry no cap, with the reason stated inline: the
customer cap exists to put a human in the loop, and this is that human.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>