# The Service account and the lens: how S3 is built

What S3 built (plan CORE §3.3; TuteLoop AUTH_MODEL §A2 and Part C; TuteDesk's lens; CORE-D4) and what later slices write
against. The Service is the platform's owner. It sees **nothing of any workspace** until it opens a **lens**: one
workspace, for a while, with a reason, as one of its members, looking only or able to change; the workspace sees that it
did.

## The Service

| Piece | What it does |
|---|---|
| `config/service.php` | `identity` (`SERVICE_IDENTITY`: the SHA-256 of the e-mail, lower-cased and trimmed; unset means nobody); `lens_minutes` (30, 60, 120; a setting in S6); `lens_reason_min` (10) |
| `HasServiceIdentity` (on `User`) | `isService()` hashes the e-mail as stored and compares in constant time; deleting the account or changing its e-mail throws `ServiceAccountProtected` (a 403) except inside `User::withServiceMutation()`; `notService()`, `onlyService()`, `excludeService()` find it in the database by the same hash |
| The gate (`AccessServiceProvider`) | Outside a lens the Service passes everything, a permission that doesn't exist included. With no workspace current it still reads no workspace's records (the fail-closed scope) |
| `ServiceAccount` | Finds the account, masks its e-mail for output, writes the identity into the environment file |
| `RecordServiceAction` | Every change the Service asks for (not GET or HEAD, not sign-out or its own display choices) is `service_action` in the sign-in log: route, method, answer, lens |
| `Context::actorName()` | The logs keep the Service as **":company support"** (Tutelora support), never the person's name: the workspace reads those rows (the house rule on staff names) |

Without a lens, its menu and search offer only the platform's pages and its own (`Nav::offers`): it passes every permission,
but a workspace's page would only say to open a lens (`platform/NoLens`, still what a typed address gets). It is never in a list or a picker (`PersonList` and the lens's picker leave it out), never a match (`PersonMatcher`),
never merged (`MergePeople`), and never added to a workspace.

**The commands** (the only way to change it):

```bash
php artisan service:create you@example.com --first="Kwame" --last="Boateng"
                                                      # a new installation: the person, the account and the identity
                                                      # together, the password shown once. Refused once there is a Service
php artisan service:identity you@example.com          # prints SERVICE_IDENTITY=<hash>; never echoes the e-mail
php artisan service:identity you@example.com --set    # writes it into .env (asks before replacing; --force doesn't)
php artisan service:whoami                            # unset · set but no account · "The Service is Kwame Boateng <k***e@…>"
php artisan service:reset                             # a new password, shown once (its password is never reset by e-mail)
php artisan service:transfer someone@example.com      # moves the identity to another account; the old one's lenses end
```

A cached config follows by itself (`ServiceAccount::write`; plan WA's rehearsal found a deploy's cache hiding a new identity). Since S8 the Service must use two-factor sign-in where
`SERVICE_REQUIRE_TWO_FACTOR` is on (on in production), is told by e-mail when it signs in from a browser it hasn't used
before, and never gets a reset e-mail. Making it set its own password after `service:reset` is still to come.

## Making a workspace

The Service's Workspaces list has **New workspace** (`workspace.create`, step-up on the save; plan WA §4). The form asks
the workspace's name and its Owner's first name, family name and e-mail; `App\Actions\Tenancy\CreateWorkspace` does the
rest, all or nothing:

1. the workspace, with a slug made from its name (a number when taken; it appears in no address) and its standard roles;
2. the Owner through `AddPerson`, so someone already on the platform is linked and not made twice, given the role
   flagged `is_account_holder` (by the flag, never its name);
3. `workspace.created` in the audit log, beside the `tenant.created` the model writes;
4. no account at that address: the invitation (`InvitePerson`), which names the company rather than the Service
   (`invited_by.name`, the house rule); an account already there: they hold it at once and are told
   (`workspace.owner_added`, reworded but never silenced).

The Service's own address is refused: it is never in a workspace. An empty list says what comes first.

## Self sign-up

Off as shipped (`sign_up.open`, under Platform settings › Sign up): TuteDesk's is on, but Atrium is a base, and a copy
shouldn't open public sign-up by being deployed. Off, every `/sign-up` route is a 404 and the sign-in page has no link.

On, the form (`SignUpController`) waits as a `sign_up` row, its password and code hashed, for the 6-digit code e-mailed to
its address (`App\Actions\Tenancy\SignUps`, TuteDesk's flow). Only a right code makes the account and, through
`CreateWorkspace`, the workspace and its Owner, who is then signed in and sets up two-factor. The Service hears the
workspace's name (`workspace.signed_up`, a rule it may turn off).

What stops abuse: named limiters a minute (`sign-up`, `sign-up-code`, `sign-up-resend`), hourly caps per network and per
address, a honeypot, a minimum time on the form, the `disposable_email_domain` list, and removal after
`sign_up.pending_hours` (`model:prune`).

**Departs from TuteDesk on one point.** TuteDesk's form says "That e-mail already has a login". Here an address with an
account, like a bot, is shown the code page and gets no code, and its owner is told by e-mail (`sign_up.already`), so the
form can't test which addresses have accounts (the reasoning of WA-6).

## The lens

| Piece | What it does |
|---|---|
| `lens_session` · `LensSession` | The workspace, the Service's account, the member seen through, the reason, `look` or `change`, the minutes, when it started and ends, when and how it ended (`ended`, `expired`, `signed_out`, `replaced`, `paused`). A workspace record; closed once and otherwise never changed (a trigger) |
| `OpenLens` | After the Service's password again (the route's step-up, S8), into an **active** workspace only, as one of its **active** members, with a reason of `lens_reason_min` characters, for one of the `lens.lengths` setting. An open one ends first (`replaced`). Logged in the lens log, the sign-in log (`lens_started`) and the workspace's own audit log (`lens.started`); raises `LensStarted` |
| `EndLens` | By the Service, by time, by signing out, by another lens, or by the workspace being paused. `lens_ended`, `lens.ended`, `LensEnded` |
| `CurrentLens` | The lens in the session, read afresh each request; one past its time is ended there and then |
| `ResolveTenant` (Service branch) | Through a lens: the lens's workspace, as its member. Without one: no workspace; the platform's pages open, a workspace's page shows `platform/NoLens` |
| `ApplyLens` | Look only: every request but GET and HEAD refused. Closed inside any lens: the platform's pages, switching workspace, the account's pages. Always through: ending the lens, signing out (which ends it), the Service's display choices |
| The gate, inside a lens | Exactly the member's permissions; never a platform one; looking only, only the keys marked `read` |
| `lens:sweep` | Every minute (`routes/console.php`): closes lenses past their time |

Every audit row made inside a lens carries `lens_session_id` and blames the Service (as ":company support").

**Both sides are told.** The Service: the lens banner on every page, and the lens log. The workspace: people with
`lens.notice` (the Owner's standard role) see a notice on every page while a lens is open, and the recent lenses on
their home page; the workspace's audit log keeps `lens.started` and `lens.ended`. `LensStarted` and `LensEnded` are
for the mail engine (S7) and notifications (S14) to tell them by e-mail and notice too.

## Pages

`platform/Home` (the Service's home with no lens), `platform/Workspace` (every workspace: pause and reactivate with a
reason, a lens on one), `platform/LensCreate`, `platform/LensLog` (on the list engine; the one list that reads every
workspace, so it declares `crossWorkspace()` and needs a platform permission), `platform/NoLens`, the `LensBanner` and
`LensNotice` in the shell, and the "No lens" chip.

## The guards

| Test | Proves |
|---|---|
| `Service/ServiceIdentityTest` | a permission that doesn't exist passes for the Service only; case and spaces don't matter; unset means nobody; no delete or e-mail change but through the commands; **a web delete is a 403**; in no list, match or merge; every change logged; the four commands |
| `Service/LensTest` | **the Service sees nothing without a lens**; the no-lens page draws with its props; platform pages closed to others; opening asks the password again (step-up), then checks reason, length, workspace and member; exactly the member's permissions (a member with no account too); **look-only refuses every write route**, and draws nothing to change; change mode audited as the lens and the company's support; **both sides are told**; ending five ways; closed once; pausing a workspace |
| `Guards/ListGuardTest` | a list that reads every workspace needs a platform permission |
