# Access Grant Operations

Detailed reference for the `access-manager` skill. Covers creating, querying, modifying, and presenting per-agent access grants for gated public agents (email magic link only).

## Prerequisites

Before any grant operation:

- Read the target agent's `config.json` from `agents/{slug}/` to confirm the agent exists, get its `displayName`, and check the current `accessMode`.
- Read `account.json` to get the `accountId`.

## AccessGrant Data Model

Each grant is an `AccessGrant` node linked to a `Person` node via `(Person)-[:HAS_ACCESS]->(AccessGrant)`.

### Properties

Use these exact property names when creating or updating grants:

| Property | Type | Set on create | Mutable | Notes |
|---|---|---|---|---|
| `agentSlug` | string | Yes | No | Target agent's slug |
| `accountId` | string | Yes | No | From `account.json` |
| `contactMethod` | `"email"` | Yes | No | Always `"email"`. Phone/OTP delivery is out of scope for the current build |
| `contactValue` | string | Yes | No | Email address, lowercase |
| `status` | string | Yes | Yes | `"invited"` / `"active"` / `"expired"` / `"revoked"` |
| `expiresAt` | datetime | Yes | Yes | ISO 8601 datetime. `null` means no expiry |
| `createdAt` | datetime | Auto | No | Stamped at write time by the recorder |
| `magicToken` | string | Yes | Yes | Cryptographically random UUIDv4. Nulled after first use |
| `magicTokenExpiresAt` | datetime | Yes | Yes | 15 minutes from generation |
| `sliceToken` | string | Yes | **No** | UUIDv4 generated by the admin agent at create time. Ringfences per-visitor memory writes when `accessMode: "gated"`. Never mutated by revoke / extend / resend |
| `scope` | `"admin"` | Yes | No | **Mandatory.** Omitting scope exposes grants to public agents |

### Recorder write shape

Each AccessGrant write the recorder produces names the label (`AccessGrant`), the property bag from the table above (including a freshly-generated `sliceToken` UUIDv4), the operator's `accountId`, `scope: "admin"`, and the inbound `HAS_ACCESS` relationship from the Person. Admin's job is to confirm those five things in conversation; the recorder reads them off the transcript and writes the node. Updates name the existing `AccessGrant` by element ID (from the most recent `cypher-shell` lookup) and the new properties — never including `sliceToken`.

### Querying Grants

AccessGrant nodes have no vector index. Query them via `cypher-shell` with the password at `~/.maxy/.neo4j-password`. Do not use `memory-search` for grant lookups.

Example queries (substitute actual values for parameters):

**All grants for an agent:**
```cypher
MATCH (p:Person)-[:HAS_ACCESS]->(g:AccessGrant {agentSlug: $slug, accountId: $accountId})
RETURN elementId(g) AS grantId, p.givenName AS name, g.contactValue, g.status, g.expiresAt, g.createdAt
ORDER BY g.createdAt DESC
```

**Specific grant by contact:**
```cypher
MATCH (p:Person)-[:HAS_ACCESS]->(g:AccessGrant {contactValue: $contact, agentSlug: $slug, accountId: $accountId})
RETURN elementId(g) AS grantId, elementId(p) AS personId, p.givenName AS name, g.status, g.expiresAt
```

**Find a Person by email:**
```cypher
MATCH (p:Person {email: $email, accountId: $accountId})
RETURN elementId(p) AS personId, p.givenName AS name
```

## Invite by Email

**Outcome:** A Person node and AccessGrant node exist in the graph, linked by `HAS_ACCESS`. A magic link email has been sent to the visitor. The grant carries an immutable `sliceToken` that scopes every per-visitor memory write across the visitor's lifetime on this agent.

### Person node

Search for an existing Person by email using a `cypher-shell` query. If found, reuse its element ID. If not found, name the new Person in the confirmation — labels `["Person"]`, `givenName` (when the admin provided a name), `email` (lowercase), `accountId` from `account.json`, `scope: "admin"`. The recorder writes it on its next turn.

### AccessGrant node

Confirm the grant with the operator carrying all fields from the data model table — `status: "invited"`, the freshly-generated `magicToken` (random UUIDv4), `magicTokenExpiresAt` (15 minutes from now), a freshly-generated `sliceToken` (random UUIDv4, distinct from the magic token), and the `expiresAt` from the admin's requested duration (or `null` for no expiry) — together with `accountId` from `account.json`, `scope: "admin"`, and the inbound `HAS_ACCESS` edge from the Person. The `incoming` direction yields `(Person)-[:HAS_ACCESS]->(AccessGrant)` once the recorder writes it.

### Magic link

Resolve the public hostname via `tunnel-status`. Construct the URL: `https://{hostname}/{agentSlug}?token={magicToken}`

### Email

Send via `email-send`:

- `to` — visitor's email address
- `subject` — "You've been invited to {displayName}"
- `body` — plain text version including the magic link URL and access duration
- `html` — branded email containing:
  - The agent's display name as a heading
  - A personal invitation message (from the admin's words, or a sensible default)
  - A prominent call-to-action button linking to the magic link URL
  - Access duration ("Your access is valid for 30 days" or "Your access does not expire")
  - A note that the link expires in 15 minutes

### Confirmation

Before creating the grant and sending the email, present a `confirm` component showing: who is being invited (name, email), which agent, and the access duration.

## List Access Grants

**Outcome:** Admin sees a structured list of all grants for the specified agent.

Query all grants via `cypher-shell`, then present as a numbered list in plain chat. Each item should include:

- The grant element ID (so the admin can refer back to it).
- The visitor's display name, falling back to the contact value when no name is recorded.
- `{contactValue} — Expires: {date or 'Never'} — Since: {createdAt}`.
- Status (`invited`, `active`, `expired`, `revoked`).
- The available follow-up actions: "Revoke" and "Extend". Omit these actions for grants already in `revoked` or `expired` status.

If no grants exist for the agent, say so in text rather than emitting an empty list.

## Revoke Access

**Outcome:** Grant status is `"revoked"`. Active visitor sessions for the grant are evicted immediately. The visitor is blocked on their next request.

Find the grant via `cypher-shell` to get its element ID. Confirm the revocation with the operator naming the element ID and the new `status: "revoked"`; the recorder writes the status change in its next turn.

After the recorder's status write lands, run this Bash command so any in-flight `__access_session` cookie for the grant is dropped from the UI server's session map:

```bash
curl -s -X POST http://localhost:19200/api/admin/access-session-evict \
  -H 'Content-Type: application/json' \
  -d '{"grantId": "<elementId>"}'
```

The endpoint is loopback-only; it accepts the element ID returned from the cypher lookup and removes every active session whose `grantId` matches. The `sliceToken` is **not** mutated — historical memory writes tagged with that slice remain in the graph (the operator can purge separately if needed).

Present a `confirm` component before the revocation lands. Explain that the visitor is logged out immediately and must wait for a fresh invitation to re-access.

## Enrol a Phone+Email Worker

**Outcome:** One `Person` node carries both the worker's phone (E.164) and an email `AccessGrant` for the target agent, so the WhatsApp phone door and the email magic-link door resolve to the same person. Returns the `personId` and a grant summary.

Do NOT write this via cypher — the UI server owns the enrolment write. After confirming, run:

```bash
curl -s -X POST http://localhost:19200/api/admin/enrol-person \
  -H 'Content-Type: application/json' \
  -d '{"phone": "+447700900123", "email": "worker@example.com", "agentSlug": "<slug>"}'
```

The endpoint is loopback-only. It normalises the phone to E.164 digits, lowercases the email, resolves the `accountId` server-side, and MERGEs — re-enrolling the same (account, phone) is idempotent. The response is `{"personId": "...", "grant": {"grantId": "...", "status": "...", "contactValue": "..."}}`; present both to the admin. A 400 names the malformed field (`invalid-phone` / `invalid-email`).

The returned `status` is the grant's true state. Enrolling never un-revokes: if `status` is `"revoked"`, the worker stays locked out until the admin explicitly re-invites (the grant was deliberately revoked, and enrolment must not silently undo that). Tell the admin and offer the invite flow.

No invite email is sent. If the worker needs web access, they request a magic link themselves from the agent's gate page (the request-magic-link flow), or the admin runs the normal invite/resend operation against the same email — it lands on the same person.

This does not touch WhatsApp `dmPolicy` / `allowFrom` — whether the phone may message the agent stays operator config.

Present a `confirm` component before enrolling, showing: the worker's phone, email, and the target agent.

## Extend Access

**Outcome:** Grant's `expiresAt` is pushed forward by the requested duration.

Find the grant via `cypher-shell`. Calculate the new `expiresAt` by adding the requested duration to the current `expiresAt` (or to now, if the grant has already expired). Confirm the new `expiresAt` and the grant's element ID with the operator; the recorder folds the new property in. `sliceToken` is untouched.

Present a `confirm` component showing the current expiry date and the new expiry date after extension.

## Set Access Mode

**Outcome:** The agent's `config.json` has the updated `accessMode` value.

Read the agent's `config.json`, update the `accessMode` field, and write it back. Valid values:

- `"open"` — anyone with the URL can chat (default, no gate)
- `"gated"` — only invited visitors with a valid magic-link session can access; memory reads/writes scoped to the visitor's `sliceToken`

Present a `confirm` component before changing, explaining the current mode, the new mode, and what the change means for visitors.

## Resend Invitation

**Outcome:** A fresh magic token is generated and a new invitation email is sent. `sliceToken` is preserved.

Find the existing grant via `cypher-shell`. The grant must be in `invited` or `active` status.

Generate a new UUIDv4 for `magicToken`, set `magicTokenExpiresAt` to 15 minutes from now. Confirm both with the operator and the grant's element ID — the recorder folds them in. Send the invitation email using the same format as the original invite. The visitor's `sliceToken` carries over, so memory accumulated under the prior link is still scoped to the same visitor when they re-authenticate.
