# Access Grant Operations

Detailed reference for the `access-manager` skill. Covers creating, querying, modifying, and presenting per-agent access grants.

## 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"` or `"phone"` | Yes | No | How the invitation was delivered |
| `contactValue` | string | Yes | No | Email address (lowercase) or phone number (E.164) |
| `status` | string | Yes | Yes | `"invited"` / `"active"` / `"expired"` / `"revoked"` |
| `expiresAt` | datetime | Yes | Yes | ISO 8601 datetime. `null` means no expiry |
| `createdAt` | datetime | Auto | No | Auto-set by `memory-write` |
| `magicToken` | string | Email only | Yes | Cryptographically random UUID. Nulled after first use |
| `magicTokenExpiresAt` | datetime | Email only | Yes | 15 minutes from generation |
| `otpCode` | string | Phone only | Yes | Random 6-digit numeric code |
| `otpExpiresAt` | datetime | Phone only | Yes | 10 minutes from generation |
| `passwordHash` | string | No | Yes | scrypt hash. Set by the access gate API when the visitor creates credentials — not by the admin agent |
| `otpAttempts` | integer | Phone only | Yes | Failed OTP entries. Max 3 before regeneration required |
| `scope` | `"admin"` | Yes | No | **Mandatory.** Omitting scope exposes grants to public agents |

### Tool Parameters

`memory-write` requires `labels`, `properties`, `accountId`, `scope`, and optionally `relationships`. The `accountId` and `scope` are top-level parameters — not inside `properties`.

`memory-update` requires `nodeId` (element ID from a prior query), `properties` (fields to update), and `accountId`. Always include all three.

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

### Person node

Search for an existing Person by email using a `cypher-shell` query. If found, reuse its element ID. If not found, create one via `memory-write`:

- Labels: `["Person"]`
- Properties: `givenName` (if the admin provided a name), `email` (lowercase)
- `accountId`: from `account.json`
- `scope`: `"admin"`

### AccessGrant node

Create via `memory-write`:

- Labels: `["AccessGrant"]`
- Properties: all fields from the data model table. Set `status` to `"invited"`, generate the `magicToken` (random UUID), calculate `magicTokenExpiresAt` (15 minutes from now), and calculate `expiresAt` from the admin's requested duration (or `null` for no expiry).
- `accountId`: from `account.json`
- `scope`: `"admin"`
- Relationships: `[{ type: "HAS_ACCESS", direction: "incoming", targetNodeId: "<person-element-id>" }]`

The `incoming` direction creates `(Person)-[:HAS_ACCESS]->(AccessGrant)`.

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

## Invite by Phone

Same as email invitation with these differences:

- `contactMethod`: `"phone"`
- `contactValue`: phone number in E.164 format (e.g. `+447700900123`)
- Generate a random 6-digit `otpCode` instead of a magic token. Set `otpExpiresAt` (10 minutes from now) and `otpAttempts` to `0`. Do not set `magicToken` or `magicTokenExpiresAt`.

### SMS delivery

After creating the grant, deliver the OTP code via SMS. Run this Bash command:

```bash
curl -s -X POST http://localhost:19200/api/access/send-otp \
  -H 'Content-Type: application/json' \
  -d '{"phone": "<contactValue>", "agentSlug": "<agentSlug>"}'
```

Replace `<contactValue>` with the E.164 phone number and `<agentSlug>` with the target agent's slug.

If the response contains `"success": true`, confirm to the admin that the OTP was sent. If it contains an error, report the error message — common causes: Brevo API key not configured (`~/.maxy/.brevo-api-key` missing), invalid phone number format, or Brevo rate limit reached.

### Brevo SMS setup

Before phone invitations can send SMS, the Brevo API key must be configured. When the admin asks to set up SMS or the first phone invitation fails with a missing key error:

1. Ask the admin for their Brevo API key (from their Brevo account → SMTP & API → API Keys).
2. Store the key: run `echo "<api-key>" > ~/.maxy/.brevo-api-key && chmod 600 ~/.maxy/.brevo-api-key` via Bash.
3. Verify delivery by sending a test SMS to the admin's phone. Run the send-otp command above with a test grant, or ask the admin for a phone number and create a temporary test grant.

## List Access Grants

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

Query all grants via `cypher-shell`, then present via `render-component` with `action-list`. Each item should include:

- `id` — grant element ID
- `label` — visitor's display name (fall back to contact value if no name)
- `description` — "{contactMethod}: {contactValue} -- Expires: {date or 'Never'} -- Since: {createdAt}"
- `status` — grant status (`invited`, `active`, `expired`, `revoked`)
- `actions` — "Revoke" and "Extend" buttons. Omit these actions for grants already in `revoked` or `expired` status.

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

## Revoke Access

**Outcome:** Grant status is `"revoked"`. The visitor is blocked on their next login attempt.

Find the grant via `cypher-shell` to get its element ID. Update via `memory-update` with `properties: { status: "revoked" }`.

Present a `confirm` component before revoking. Explain that active sessions continue until they naturally expire (max 24 hours) — revocation takes effect on the visitor's next login.

## 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). Update via `memory-update` with the new `expiresAt` value.

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 credentials can access
- `"paid"` — same behaviour as gated (payment integration deferred to a future phase)

Both `gated` and `paid` follow the same code path — the distinction is a semantic label for future use.

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 or OTP is generated and a new invitation is sent.

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

**For email grants:** Generate a new UUID for `magicToken`, set `magicTokenExpiresAt` to 15 minutes from now. Update via `memory-update`. Send the invitation email using the same format as the original invite.

**For phone grants:** Generate a new 6-digit `otpCode`, reset `otpAttempts` to `0`, set `otpExpiresAt` to 10 minutes from now. Update via `memory-update`. Then deliver the new OTP via the SMS delivery command described in the "Invite by Phone" section.
