# 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.

## 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.
