# Port 00 — Employees & Store Access (foundational module)

The staffing/identity foundation every ported BK module builds on. Decided with Ryan
2026-09-02: **employees are full Alqove users** (login for clock-in/out and general
use), **multi-store employment from day one**, **four-tier store role system**
(Owner, Manager, Key Holder/Shift Lead, Sales), **mobile-first `(staff)` web
surface**, and **capability-based checks from v1** — roles are hardcoded capability
presets now, custom per-store roles later must land without a rewrite (§2b).

Hardened 2026-09-02 by an adversarial design review against both codebases; the
inventory in §5 and the rules in §2 reflect verified code, not assumptions.

This module exists because of a confirmed security gap: today, "has access to store"
is inferred from `users.store_id` alone — `EnsureStoreOwner` checks only
`$user->store_id === {store}` (`app/Http/Middleware/EnsureStoreOwner.php:27`, no role
check) and the seller layout gates only on `user.store_id` truthiness
(`(seller)/layout.tsx:34`). Giving an employee a `store_id` under the current model
would hand them the entire seller admin (payouts, orders, Stripe settings).
**Port 00's job is to decouple "belongs to a store" from "runs the store."**

---

## 1. Core model: `StoreMembership`

One row = one person's employment at one store. Employment IS a user↔store
relationship, so it's modeled as exactly that.

`store_memberships` (shared model `app/Models/StoreMembership.php`, UUID PK):

| Column | Type | Notes |
|---|---|---|
| `store_id` | foreignUuid | |
| `user_id` | foreignUuid | |
| `role` | string ← `StoreRole` enum | `owner` / `manager` / `shift_lead` / `sales` |
| `status` | string ← `MembershipStatus` enum | `active` / `terminated` — **the** lifecycle; no softDeletes (a second overlapping lifecycle enables duplicate-row bugs; review flaw 11) |
| `default_position_id` | nullable foreignUuid | default schedulable position (§3); must belong to the same store |
| `is_exempt` | boolean, default false | salaried OT exemption (BK `users.isExempt`) |
| `hired_at`, `terminated_at` | nullable timestamps | |
| `created_by` | nullable foreignUuid users | |
| timestamps | | |

Constraints & invariants:
- **Unique `(store_id, user_id)`** — hard constraint, no soft-delete escape hatch.
  Rehire = flip `status` back to active on the existing row, preserving pay-rate
  history. **Rehire is owner-only** (it silently resurrects an owner-set pay rate —
  money impact; review flaw 4).
- **Exactly one `owner` membership per store**, kept in lockstep with
  `stores.owner_user_id` (service-enforced **with a dedicated invariant test**;
  ownership transfer is a deferred flow). The owner membership cannot be terminated,
  demoted, or re-roled.
- Multi-store: a user may hold any number of active memberships across stores. All
  per-store people-data (pay rates, shifts, punches, timesheets) keys off
  **`store_membership_id`** — one person, different rates/roles/positions per store.
- Termination fires a **`MembershipTerminated` event inside the transaction**;
  consumers register listeners centrally (port-01's future-shift unassignment listens
  to it — Team never imports scheduling; review flaw 12, framework §2 convention).

`membership_pay_rates` (UUID PK): `store_membership_id`, `hourly_rate_cents`
(unsignedInteger), `effective_at` (UTC), `created_by`. Append-only history,
latest-`effective_at`-before-date wins (BK `userPayRates` semantics).

### Platform roles vs store roles — two systems, on purpose

- **spatie roles stay platform-level** (`buyer` / `seller` / `admin`) — who you are
  on Alqove. Employees keep their `buyer` role; employment does NOT grant `seller`.
- **Store authority lives on the membership row** — store-scoped by construction,
  trivially queryable, can't leak across stores.
- Known consequence: the role-aware rate limiter (`AppServiceProvider.php:52-60`)
  gives buyers 60/min — managers doing full-shift admin work ride the buyer budget.
  Rate-limit by max membership tier (or bump `/v1/stores/*/my/*` limits) when
  port-01's clock/polling traffic arrives (review flaw 13).

### Capabilities are the check primitive; roles are presets (decided 2026-09-02)

Ryan wants a robust role system in v1 and custom per-store roles later without a
rewrite. The way to get both: **capabilities** are what the code checks; **roles**
are named bundles of them. Endpoints never ask "is this a manager?"; they ask "can
this membership `schedule.publish`?". How a membership acquires capabilities can
then evolve (fixed presets → per-store matrix tweaks → fully custom roles) with zero
endpoint churn — check sites are the expensive thing to retrofit, so we get them
right on day one. (BK validates this shape: fixed permission strings like
`uri_schedule_timesheet_approve`, later a per-store `schedulePermissions` JSON
matrix — configurable matrix, never ad-hoc role taxonomies.)

- `StoreCapability` backed enum (`app/Support/Enums/StoreCapability.php`), v1 set:
  `store.admin` (the existing marketplace seller surface: listings, orders, payouts,
  statements, store settings, returns, reviews, threads), `team.view`, `team.manage`,
  `pay.view`, `pay.manage`, `schedule.view`, `schedule.view_drafts`,
  `schedule.manage`, `schedule.publish`, `punches.manage`, `timesheets.approve`,
  `payroll.export`, `settings.schedule`. Self-service (own clock/schedule/
  timesheets) is NOT a capability — it attaches to having an active membership.
- `StoreRole` backed enum (`owner(40) > manager(30) > shift_lead(20) > sales(10)`)
  with `capabilities(): array` — the preset map lives in this enum, in code, as the
  single source of truth, pinned by a snapshot test. `rank()` exists for the
  people-management rules and future seniority features (open-shift min-role), never
  for feature gating.
- `StoreMembership::can(StoreCapability $c): bool` — v1 resolves via the role
  preset. Custom roles later slot in behind this one method (§2b).

### v1 preset matrix (the contract for all ported modules)

| Capability | Owner | Manager | Shift Lead | Sales |
|---|---|---|---|---|
| `store.admin` (existing seller surface) | ✓ | — | — | — |
| `team.view` (roster: names, positions) | ✓ | ✓ | ✓ | ✓ |
| `team.manage` (invite/remove, roles; targets below own rank) | ✓ | ✓ | — | — |
| `pay.view` / `pay.manage` | ✓ | — | — | — |
| `schedule.view` (published; includes reading positions) | ✓ | ✓ | ✓ | ✓ |
| `schedule.view_drafts` | ✓ | ✓ | — | — |
| `schedule.manage` (build/edit/copy; manage positions) | ✓ | ✓ | — | — |
| `schedule.publish` | ✓ | ✓ | — | — |
| `punches.manage` (corrections) | ✓ | ✓ | — | — |
| `timesheets.approve` | ✓ | ✓ | — | — |
| `payroll.export` (money boundary) | ✓ | — | — | — |
| `settings.schedule` | ✓ | — | — | — |
| Self: clock in/out, own schedule, own timesheets | any active membership | | | |

Principles: pay data and money movement are owner-only in v1; managers manage people
and time, not money; nobody edits at-or-above their own rank; every member can read
the published schedule (and the names/positions needed to render it — review flaw 8)
and act on their own data.

## 2. Authorization redesign

### Middleware mechanics (Laravel aliases map to classes, not class:param — review flaw 16)

Two classes, three aliases, one shared resolver:

- `EnsureStoreCapability::handle($req, $next, string $capability = 'store.admin')` —
  aliases **`store.can`** (used as `store.can:schedule.manage`) and **`store.owner`**
  (legacy alias, no param → the `store.admin` default; bare `store.can` also falls
  back to `store.admin`, i.e. the MOST restrictive capability — fail-closed).
- `EnsureStoreMembership` — alias **`store.member`**, for `/my/...` self-service
  routes; requires a real active membership, capability-free.

Both preserve the existing string-vs-model route-param handling
(`EnsureStoreOwner.php:21-25`) and resolve through one seam:

```
resolve(user, store) -> StoreViewer:
  platform admin      → StoreViewer{capabilities: ALL, membership: null, rank: synthetic-owner}
  active membership   → StoreViewer{capabilities: membership->capabilities(), membership, rank}
  otherwise           → null (middleware 403s)
```

`StoreViewer` goes into request attributes; controllers and Resources read it —
**never `auth()->user()->store_id`, never a role name**. Admin therefore acts as a
synthetic owner on management endpoints (explicit, not fall-through), and field
stripping is by viewer capability — fail-closed. **`/my/...` routes require a real
membership: platform admins without one get 403 like anyone else** (review flaw 7).

### The single seam rule (review flaw 1 — the second authorization mechanism)

`StoreViewer` resolution is the ONLY way any code answers "what may this user do at
this store." The review found a parallel live mechanism — raw
`stores.owner_user_id === $user->id` checks — in Returns
(`SellerReturnController`, `ReturnAccess:30`, `ReturnTransitioner`), Messaging
(`MessageThreadAccess:30`, `MyThreadsController:25`),
`StoreController::showPublic:53` (which returns `stripe_connect_id` on a bare
`store_id` equality match), `StorefrontController::canPreview:151`, and
`SellerReviewsController:29` — none behind `store.owner` middleware. Slice 2 routes
ALL of these through the seam (check becomes "viewer has `store.admin`"), so there
is exactly one authorization system the day memberships go live. Their user-scoped
route shapes (`/seller/returns`, `/seller/reviews`, `/me/threads` — ambiguous under
multi-store) are migrated to `/v1/stores/{store}/...` **before any of those domains'
capabilities are ever granted to non-owners**; tracked as its own slice.

### Nested-resource scoping rule (review flaw 2 — cross-store IDOR)

Middleware validates the actor at `{store}`; it cannot validate that a child id
belongs to `{store}`. **Every controller loading a nested route param
(`{membership}`, `{invitation}`, `{position}`, and every port-01 child) must assert
`$child->store_id === $store->id` or 404** — the existing house convention
(`ParcelPresetController.php:41`, `StorePayoutsController.php:52`). Same rule for
ids in request bodies (`default_position_id` on invites/PATCHes). Feature tests MUST
pair store A's actor with store B's child ids on every nested endpoint — testing the
actor's membership alone would pass while shipping the hole.

### Target-rank rules (review flaw 4)

"Below own rank" applies to the **target's current rank**, not just the assigned
role: PATCH (role/status/position) and DELETE (terminate) require
`target->rank < actor->rank`. Blocks manager-demotes-peer-manager and
escalation-by-eviction (terminate a peer, re-invite them at sales). Rehire
(terminated → active) is owner-only (§1). The rank rules live in FormRequests/
services (they need the target), not middleware.

### 2b. Custom roles — v2, designed-for now, deliberately not in v1

Later feature, pure data thanks to the capability primitive: a `store_roles` table
(`store_id`, `name`, `rank` int, `capabilities` JSON of `StoreCapability` values)
+ nullable `store_memberships.custom_role_id` overriding the preset inside
`StoreMembership::can()` + a role-editor UI. Zero endpoint changes; the frontend
already renders from resolved capability lists, so custom roles work there for free.

Why not v1: a permission-picker UI is a real feature with real support burden;
the testing matrix stops being enumerable; and "manage below your rank" needs its
own design when roles are user-defined. Stepping stone available first: a per-store
*matrix tweak* (BK's `schedulePermissions` pattern) if demand shows up.

Rules that hold regardless: `owner`'s capability set is immutable and never
customizable; `payroll.export`/`pay.manage` can never be granted to a role ranked
below manager without explicit owner action; capability strings live only in the
`StoreCapability` enum (framework trap: string drift).

### Route shapes

- Management: `/v1/stores/{store}/...` behind `store.can:{capability}`.
- Self-service: `/v1/stores/{store}/my/...` behind `store.member`. "My" always means
  the resolved membership, never a client-supplied member id.
- Membership discovery: `GET /v1/me/memberships` (auth only) — each store + role +
  **resolved capability list**; the frontend renders nav/actions from capabilities,
  never role names.

## 3. Positions vs roles — kept separate

**Role = what you may do. Position = what you're scheduled as.** BK conflated these
and grew both anyway (`store_roles` + `schedulePositions`). `store_positions`
(UUID PK): `store_id`, `name`, `color`, `sort_order`, `is_active`, softDeletes.
Seeded per store (on store creation + backfill) with defaults mirroring the role
names; stores add custom positions (Buyer, GM) without touching access control.

## 4. Invitations (hardened per review flaw 3)

`store_invitations` (UUID PK): `store_id`, `email` (**lowercase-normalized**),
`invited_user_id` nullable, `role` (StoreRole, below inviter's rank — owner never
invitable), `default_position_id` nullable (same-store checked), `invited_by`,
`token_hash` (sha256 of a **≥32-random-byte** token; raw token only in the email
link; compared via `hash_equals`), `expires_at` (7 days), `accepted_at`,
`revoked_at`, timestamps. Partial unique on pending `(store_id, email)` as a
backstop — but the create service **auto-revokes expired/superseded invites inside
the create transaction**, since a unique index can't express `expires_at > now()`
and would otherwise deadlock re-invites forever (review flaw 10).

Flow: inviter (needs `team.manage`) invites by email:
- If a user with that normalized email exists, the invite is **bound to
  `invited_user_id` at send time** — only that authenticated user can accept.
  (Emails are self-service mutable and unverified in this codebase —
  `UpdateProfileRequest`, `AuthService` never sets `email_verified_at` — so a
  match-at-accept-time check alone is spoofable by anyone holding the token.)
- Otherwise: recipient registers through the **standard registration form including
  phone** — the existing phone-merge path (`AuthService.php:90-118`) then unifies
  them with any phone-keyed kiosk customer row, avoiding duplicate humans with
  loyalty on one account and employment on another (review flaw 13).

Accept: `POST /v1/invitations/accept` with the **token in the request body** (never
the URL path — paths land in access logs/proxies), throttled, authenticated;
validates unexpired/unrevoked, `invited_user_id` match when bound else normalized
email match, no existing active membership (terminated → owner-only rehire path).
Platform-wide email verification is a noted deferred item; until then the residual
risk is explicitly token-possession-bound. Acceptance/revocation logged via
`activity('team')`.

## 5. What happens to `users.store_id` — full verified inventory

The review corrected the first draft's list (4 sites) to the real one. Two
mechanisms exist and BOTH route through the §2 seam in slice 2:

**A. `users.store_id` readers (API):** `EnsureStoreOwner.php:27` (gate),
`StoreController.php:92` (writer — unconditionally overwrites at store creation:
an admin creating a second store for an owner silently clobbers it),
`StoreController::showPublic:53` (**returns `stripe_connect_id` on equality**),
`StorefrontController::canPreview:151`, `SellerReviewsController.php:29-33`
(not behind `store.owner` at all), `Auth/Resources/UserResource.php:21`
(serializes into `/auth/me`), `StoreFactory.php:60-68`, `UserFactory.php:29`,
`contracts/openapi.yaml` User schema, and the "user with store_id +
`assignRole('seller')`" idiom across ~15 feature-test files.

**B. `stores.owner_user_id` checkers (API):** the Returns / Messaging / Reviews /
storefront-preview list in §2, plus every seller notification and money listener
resolving `Store::owner()` (`SendSellerOrderPaidEmail` etc., `PayoutService`,
`LedgerAdjustmentService:74`, `ReviewWriter:78`, `MessagePoster:120`,
`IntakeListingConverter:51`). Notification routing stays owner-singular in v1 —
membership-aware routing is a named deferred item, and the owner-membership ↔
`owner_user_id` lockstep invariant has a dedicated test (review flaw 14).

**C. Web readers of `user.store_id`:** not 1 but **14 files** — the `(seller)`
layout gate plus every seller page deriving its API scope from it (`seller/page.tsx:42`,
settings pages, listings, statements, orders, payouts, reviews clients). See §7.

Retirement plan:
1. **Pre-backfill audit** (review flaw 5 — the sync invariant is already broken in
   code): report users whose `store_id` ≠ any store they own; store_ids shared by
   multiple users (**no unique constraint exists** on `users.store_id` — today ALL
   of them pass `EnsureStoreOwner`; under memberships only the true owner will, and
   that's the intended fix, not a regression — each contaminated row gets an
   explicit decision); stores/users soft-deleted on either side of the pair.
2. **Backfill** from `stores.withTrashed()` → owner memberships (restored store must
   have its owner membership waiting); skip ghost owners (soft-deleted users) with
   a report line.
3. **Parity test at slice 2**: for every user with a `store_id`, old gate and new
   seam agree — divergences must match the audit's expected list exactly.
4. Store creation writes the owner membership in the same transaction as
   `owner_user_id` (and still sets `users.store_id` during the transition).
5. New code reads memberships only. `AuthUser.store_id` stays in the contract,
   documented "owned store id, deprecated — use memberships".
6. Column removal is a separate cleanup once §7's frontend migration and the
   user-scoped route migration (§2) land.

## 6. API surface (Port 00 endpoints)

Module `app/Modules/Team/` (Controllers, Requests, Resources, Services, routes.php,
README, Tests) + shared models in `app/Models/`. All hand-added to
`contracts/openapi.yaml` under a `Team` tag, then contract-synced to alqove-web.
Every nested param and body id obeys the §2 scoping rule.

| Endpoint | Gate | Purpose |
|---|---|---|
| `GET /v1/me/memberships` | auth | stores + role + resolved capabilities (switcher, frontend gating) |
| `GET /v1/stores/{store}/members` | `store.can:team.view` | roster (fields stripped by viewer capability*) |
| `PATCH /v1/stores/{store}/members/{membership}` | `store.can:team.manage` | role/position/status; target-rank rules §2; rehire owner-only |
| `DELETE /v1/stores/{store}/members/{membership}` | `store.can:team.manage` | terminate (target-rank rules; emits `MembershipTerminated`) |
| `GET/POST /v1/stores/{store}/members/{membership}/pay-rates` | `store.can:pay.view` / `pay.manage` | rate history, append-only |
| `GET/POST /v1/stores/{store}/invitations`, `DELETE .../{invitation}` | `store.can:team.manage` | invite lifecycle (§4) |
| `POST /v1/invitations/accept` | auth + throttle | token in body (§4) |
| `GET /v1/stores/{store}/positions` | `store.member` | names/colors — needed by every member's schedule view |
| `POST/PATCH /v1/stores/{store}/positions/...` | `store.can:schedule.manage` | position taxonomy |

\* names/positions for `team.view` holders (everyone); contact fields need
`team.manage`; pay fields need `pay.view`. `StoreMemberResource` strips by the
*viewer's* capabilities server-side — never rely on the client to hide pay data
(framework trap: fail-open defaults).

## 7. Frontend (alqove-web)

**Memberships have ONE source** (review flaw 15): `GET /v1/me/memberships`, cached
in the auth store next to `AuthUser`. `UserResource`/`AuthUser` are NOT extended
with memberships — no second, staler copy.

**`(staff)` route group** — new, mobile-first:
- `layout.tsx`: gate = authenticated + ≥1 active membership; store switcher when
  memberships > 1; selected store persisted (Zustand). The persisted selection is
  **reconciled against `/me/memberships` on every load** — a terminated membership
  or memberships→0 must eject cleanly, since Sanctum tokens never expire
  (`config/sanctum.php:55`) and the API failing closed doesn't un-render a stale UI
  (review flaw 9).
- Port 00 ships the shell + `/staff` home + invitation accept page. Clock and My
  Schedule tabs land in port-01. **Shared-tablet/kiosk clock modes must never ride
  personal tokens** — that's the `pos.device` pattern, later.

**`(seller)` migration — its own slice, sequenced honestly** (review flaw 6): the
seller surface is `user.store_id`-native in 14 files, so "managers get in once
scheduling pages exist" requires real work first:
- Introduce a seller store-context (selected membership with `store.admin`/relevant
  capabilities; switcher for multi-store owners — today an owner of two stores is
  silently pinned to whichever was created last) and migrate all 14 files from
  `user.store_id` to it.
- **Until that slice lands, the `(seller)` gate stays owner-membership + `store_id`**
  — do not loosen the gate before the pages can handle a viewer whose store doesn't
  come from `user.store_id`.
- Then: nav filtered by capabilities from `/me/memberships`, never role names.
- New **Team** section (`/seller/team`): roster, invite dialog, role/position
  editing (house form style), pay-rate history gated by `pay.view`/`pay.manage`.

api-client: `endpoints/team.ts` factory + `use-team.ts` hooks per the standard
anatomy. Contract changes: `openapi.yaml` User schema note (deprecated store_id) +
new Team paths; the **hand-written `AuthUser` in `packages/types/src/index.ts:70`**
must be updated deliberately — it does not regenerate.

## 8. Test plan

- Unit: capability preset snapshot (`StoreRole::capabilities()` asserted literally —
  matrix changes are reviewed diffs); rank comparisons; invitation token lifecycle
  (expiry, revocation, bound-user mismatch, unbound email mismatch, normalization,
  re-invite after expiry); pay-rate latest-before-date resolution.
- Feature — the security-critical set:
  - **capability enforcement per endpoint**: presets lacking the capability → 403
    (sales vs management endpoints; manager vs `pay.*`/`payroll.export`); assert
    per-capability so custom roles inherit coverage.
  - **cross-store IDOR pairs**: store A actor × store B's `{membership}`,
    `{invitation}`, `{position}`, and body `default_position_id` → 404, on every
    nested endpoint.
  - **target-rank rules**: manager PATCH/DELETE on peer manager → 403; rehire by
    manager → 403; owner-membership demote/terminate → 403 always.
  - middleware: `EnsureStoreOwnerTest` ported 1:1 + no-membership /
    terminated-membership / admin-bypass / admin-on-`/my/*`-403 / bare-alias
    fail-closed cases.
  - **seam unification**: Returns/Messaging/Reviews/`showPublic`/`canPreview`
    answers match membership-derived answers; `showPublic` never returns
    `stripe_connect_id` to a non-`store.admin` viewer.
  - **backfill**: every store (incl. soft-deleted) ends with exactly one owner
    membership; parity test old-gate == new-seam for all users with `store_id`;
    lockstep invariant owner-membership ↔ `owner_user_id`.
  - invitation accept: existing user, new user (+phone merge), expired, revoked,
    wrong account, already-member, role-at-or-above-inviter.
- Frontend: Vitest on gates (seller layout for non-owner member; staff layout with
  zero memberships; switcher reconciliation ejecting a terminated store); e2e
  invite→accept→appears-in-switcher.
- Test infra shipped in slice 1: `StoreMembershipFactory` with `->owner()`,
  `->manager()`, `->shiftLead()`, `->sales()` states — the NEW feature-test idiom
  replacing "user with store_id + assignRole('seller')" (~15 files embody the old
  one; framework doc updated to match).

## 9. Build order (PR slices)

1. Enums (`StoreRole`, `StoreCapability`, preset map + snapshot test) + migrations
   (`store_memberships`, `membership_pay_rates`, `store_positions`,
   `store_invitations`) + models + factories (incl. membership states) +
   pre-backfill audit + backfill.
2. **The seam**: `StoreViewer` resolver + `EnsureStoreCapability` /
   `EnsureStoreMembership` + `store.owner` remap + **rewire the §5-B
   `owner_user_id` checks (Returns, Messaging, Reviews, showPublic, canPreview)
   through it** + parity test. Behavior-identical by test, but it is NOT a trivial
   alias swap — budget accordingly.
3. Team module API (roster, roles, pay rates, positions; scoping + target-rank
   rules) + activity logging + OpenAPI + feature tests.
4. Invitations + emails via Notifications module.
5. alqove-web: contract sync, `AuthUser` hand-type update, team endpoints/hooks,
   `/seller/team`, auth-store memberships from `/me/memberships`.
6. Seller store-context + migrate the 14 `user.store_id` files + capability-aware
   gate/nav (gate loosens only here).
7. `(staff)` shell + switcher with reconciliation + invitation accept page + e2e.
8. (Scheduled, pre-manager-grants) migrate user-scoped seller routes
   (`/seller/returns`, `/seller/reviews`, `/me/threads`) to `/v1/stores/{store}/...`.

## 10. Accepted v1 risks & deferred items (explicit)

- Single Sanctum token, never expiring, grants all memberships — acceptable v1
  because every store API resolves membership per-request (fails closed); revisit
  token expiry platform-wide.
- Email verification does not exist platform-wide; invitation risk is reduced to
  token possession (§4) but not below it.
- Notification routing stays owner-singular; membership-aware routing deferred.
- Ownership transfer flow deferred (lockstep invariant + immutable owner membership
  hold until then).
- Custom roles (§2b) and per-store matrix tweaks deferred by design.
