---
id: "9.6"
title: "Access rules"
dept: security-privacy-and-legal
status: draft
owner: Param
branded: false
last_verified: 2026-10-26
sources:
  - "brew/auth.js:2-12, 16-36, 37-38, 89-119, 139-171, 180-193, 239-246, 254-257, 264-305"
  - "brew/closed-gate.js:1-75"
  - "brew/connectors/protected.js:1-4"
  - "brew/sessions.js:1-36"
  - "brew/server.js:657-666"
  - "brew/tools/prod-env.sh:9"
  - "brew/SCOPE.md:24"
  - "memory brew-access-rules, brew-closed-store-gate, brew-shopify-app-review, brew-real-connectors, brew-prod-backend, never-disconnect-arata-meta"
  - "handbook content/engineering/changelog.md:58, 81"
---

# Access rules

## Answer

Who opens what: our own staff see every brand, a brand opens only to its own work email, an invite or a single jam guest link, and everyone else is sent to the demo stores; this page settles those rules so no new feature adds a way in that skips them.

## Today

### The roles

| Who | How they get in | What they may do | Source |
|---|---|---|---|
| Root admin | `ADMIN_EMAILS`; on production `param@joinsquareup.com` only | Everything staff do, plus sign someone out everywhere and force a change to a protected connection | [src: brew/auth.js:89-91] [src: brew/tools/prod-env.sh:9] |
| Staff | Any address at `joinsquareup.com` or `shopos.ai`, subdomains too | Every brand, every view, every make; never claim a brand that is not at their own domain | [src: brew/auth.js:10-11, 33-36, 166] |
| Brand admin, the owner | First official email to tap Claim, connect an account or confirm the read | Invite, set views, connect and disconnect, hand over; a brand always keeps one admin | [src: brew/auth.js:6-7, 159-171, 194-215] |
| Member, same company | Signs in with the claimed brand's own domain; joins on their own with every view; admins are emailed | Everything but team changes; may invite others to their own views only | [src: brew/auth.js:104-109, 294-296] |
| Member, invited | An emailed invite, 14 days, valid only for that Google account | The views the invite named | [src: brew/auth.js:78, 180-193, 264-276] |
| Jam guest | A shared jam link and a sign in | Chat, edit the brief and make drafts in that one jam; never share, send, approve, assign, discard or make paid media | [src: brew/SCOPE.md:24] [src: handbook content/engineering/changelog.md:58] |
| Outsider | Signed in with Gmail or another domain | One wall pointing to demo.joinsquareup.com; cannot start a new read or sign up for Plays emails | [src: memory brew-access-rules] |
| Visitor on the demo | Anyone | A throwaway sandbox of a prepared brand; makes are open there | [src: brew/auth.js:37-38, 148] |

### What counts as the brand's own email

- Same domain as the store, a subdomain of it, or the company domain the store sits under. [src: brew/auth.js:27-31]
- Free mail and shared platforms such as `myshopify.com`, `wixsite.com` or `vercel.app` never count. [src: brew/auth.js:16, 22-24]
- A brand our staff claimed for it, such as Arata, lets the store's own domain join. [src: brew/auth.js:106-108]
- Starting a read gives nothing on its own since 23 Oct. [src: brew/auth.js:139-140] [src: memory brew-access-rules]

### Special cases

- **A closed Shopify store**, read from its admin while behind the password page, is shown to its team and staff only; everyone else gets "This store isn't open yet". [src: brew/closed-gate.js:1-12, 50-71] [src: memory brew-closed-store-gate]
- **Inside the Shopify admin**, Shopify's session token names the person; the installer becomes admin of an unclaimed brand, the store owner becomes admin, other staff members. [src: memory brew-shopify-app-review]
- **Connecting accounts** is for brand admins and staff only; teammates may sync. [src: memory brew-real-connectors]
- **Protected connections**, `PROTECTED_CONNECTIONS=arata-in`, refuse disconnect, re-select or reconnect unless a root admin forces it. [src: brew/connectors/protected.js:1-4] [src: memory never-disconnect-arata-meta]

### Sessions and logging

- Google sign in uses PKCE and a nonce; only a Google verified email is trusted. [src: brew/auth.js:3-4, 220-238]
- The session is a signed cookie for 30 days with a session row; signing out ends it on every instance. [src: brew/auth.js:243-244, 254-257] [src: brew/sessions.js:1-4]
- A sign out link from another site does nothing. [src: brew/auth.js:254-256]
- `STAFF_REQUIRE_HD=1` would accept a staff address only from that domain's own Google Workspace; production does not set it. [src: brew/auth.js:239-241] [src: brew/tools/prod-env.sh:9]
- Every signed in email that opens a brand is logged with its access kind; only root admins read the list. [src: brew/server.js:657-666, 817] [src: handbook content/engineering/changelog.md:81]

### Decided exceptions

- A personal Gmail stays on staging's admin list, by Param's choice on 23 Oct; do not flag it again. [src: memory brew-access-rules]

## Tomorrow

| Item | Version | Tag |
|---|---|---|
| Official email, invite or guest link as the only ways in | v1 | Stays |
| `STAFF_REQUIRE_HD=1` on production | v1 | New, proposed |
| Staff by named address, not two whole domains | v1 | Changes, proposed |
| Guest links that expire and can be revoked | v1 | New, proposed |
| Proof of ownership by store admin or DNS, beside the email domain | v2 | New |
| Agency seats: one person on several brands, invited by each | v2 | New |
| Who may approve a post or a schedule, per brand | v2 | New |
| Who may set ad rules and undo a run | v4 | New |

## How we do it

1. Never add a route, page or file that opens brand data without going through `auth.roleOf`, `genOk` and the closed store gate.
2. Every new brand route falls under the closed gate's `slugIn` patterns. [src: memory brew-closed-store-gate]
3. When touching auth, test the outsider paths: Gmail, another domain, a guest, signed out.

```bash
cd /Users/param/Documents/ChatGPT/brew && AI_MOCK=1 node --test test/access.test.js test/qa-access.test.js test/qa-final-access.test.js test/qv1-roles-security.test.js
```

4. To see who opened a brand, a root admin calls `GET /api/admin/visitors?slug=<slug>`.
5. To end someone's sessions everywhere, a root admin uses `/api/admin/sessions`.

## Numbers

| Metric | Definition | Where measured | Status |
|---|---|---|---|
| Blocked opens | Signed in emails refused per brand per week | `access_log`, access blocked | Proposed |
| Staff with full access | Distinct staff emails seen in `access_log` | `access_log`, access brew | Proposed |
| Unclaimed partner brands | Partners with no brand admin of their own | `members.json` per brand | Proposed |

## Open

1. **Should every `shopos.ai` address see every brand?** Proposal: no; a named list of staff addresses on production, and the two domains only on staging.
2. **Turn on `STAFF_REQUIRE_HD=1` on production?** Proposal: yes, so a personal Google account made with a staff address never counts as staff.
3. **Should guest links expire?** Proposal: 30 days, with a Revoke button for brand admins.
