ReviewOS

stacks/analyticshq

[feature] Teams / members / roles / permissions

#19
Closed glennmichael123 opened this 9 days ago ·
9 days ago

Gap

Strictly one owner per site (owner_id, requireSiteOwner at routes/analytics.ts:342-353). No members/roles/invites. Framework team/role/permission middleware exists but is unused (app/Middleware.ts:16,28-29). Competitors offer teams (+ SSO on higher tiers).

Proposed

Team/membership model with roles (owner/admin/viewer) and per-site access. Needed for agency/business use.

Priority: P1.


From the 2026-07-20 privacy-first competitive audit (vs. Plausible / Fathom / Simple Analytics / Umami).

9 days ago

Shipped in 0916206.

What landed

Three ranks, named at every site-scoped endpoint:

rankcanendpoints
viewerread reports10
admin+ settings, goals, share links, members6
owner+ destroy3

app/Analytics/access.ts resolves an effective role; requireSiteOwner is gone as the universal gate and survives only as requireSiteRole(..., 'owner') for destructive operations. Members CRUD under /api/sites/{siteId}/members.

On the framework team middleware this issue points at

I did not use it, and that is the one decision here worth arguing with.

A team is a group a user belongs to, which fits a product where people share one workspace. An analytics product is the other shape: an agency holds sites for a dozen clients who must never see each other, and a freelancer is a viewer on one site and an admin on another. That is an edge between a user and a site, not a group both belong to. Expressing it with teams means one team per client — this table with an extra hop, plus a second place where "who can see this site" is answered.

sites.owner_id stays rather than becoming an ordinary membership row. An effective role is the higher of owner and membership, so granting the owner a viewer row cannot demote them, and there is no way to reach a site with no owner — which nothing in billing or deletion has an answer for.

Destructive stays owner-only on purpose: an admin is trusted with reports and settings, not with irreversibly erasing a client's history.

A real bug this surfaced: GET /api/sites was WHERE owner_id = ?, so an invited member could authenticate, hold a valid role, and still see an empty site switcher. It now returns owned and shared sites.

Verified against real Postgres, not mocks: a stranger holding the (public) site id resolves to no role; each rank stops exactly where it should in both directions; an owner carrying a viewer row still resolves owner; a removed member loses access immediately.

Deliberately not included

  • No invitations for people without an account. Creating a user from an unauthenticated address inside a member endpoint is how invite flows become account-takeover flows — an unknown address is a 404 until that exists properly.
  • No members UI; endpoints are API-only.
  • No per-role hiding of dashboard controls. GET /api/sites returns each site's role so the UI can, but no endpoint trusts it — access is re-resolved server-side on every request.

Worth a follow-up issue for the invite flow, since that is the piece an agency actually notices missing.

Sign in to comment on this issue.