# LearnCard Network Brain Service

## Build & Development Commands

-   Build: `pnpm build` or `pnpm build:docker`
-   Dev: `pnpm dev` - Watches files and rebuilds
-   Start server: `pnpm start` - Runs local server on port 3000
-   Start on port 4000: `pnpm start-p-4000`
-   Deploy: `pnpm serverless-deploy`

### Skill Framework Seeding

-   Auto-bootstrap seeds default public skill frameworks in non-production when the database has no `SkillFramework` nodes.
-   Disable auto-bootstrap with `SKIP_SKILL_FRAMEWORK_SEED=true`.
-   Re-seed manually with `pnpm skill-frameworks seed [local|staging]` from `services/learn-card-network/brain-service`.
-   Grant framework admin access to an existing profile with `pnpm skill-frameworks add-admin [local|staging]` and enter the profile id when prompted.
-   If no stage is provided, the CLI defaults to `local` and prints a note.
-   Use `SKILL_FRAMEWORK_SEED_OWNER_PROFILE_ID` to override the default `network-seed` owner profile when needed.
-   The seed command is idempotent and will not duplicate framework or skill nodes.

## Test Commands

-   Run all tests: `pnpm test`
-   Run all tests once (non-watch): `pnpm test -- run` (equivalent to `vitest run`)
-   Watch mode: `pnpm test:watch`
-   Run single test: `pnpm test test/consentflow.spec.ts`
-   Run single test once (non-watch): `pnpm test -- run test/consentflow.spec.ts`
-   Run specific test: `pnpm test -t "should allow setting and retrieving the image field for a contract"`

## Code Style Guidelines

-   **TypeScript**: Strict typing with interfaces/types defined in `src/types/`
-   **Imports**: Use path aliases (`@helpers/*`, `@models`, `@routes`, etc.)
-   **Error Handling**: Use trpc error codes (`UNAUTHORIZED`, `BAD_REQUEST`, etc.)
-   **Naming**:
    -   PascalCase for classes, types, interfaces
    -   camelCase for variables, functions, methods
-   **Models Structure**: Database models in `src/models/` with relationships defined
-   **Access Layer**: Database operations go in `src/accesslayer/` folders
-   **Routes**: API endpoints defined in `src/routes/` using tRPC
-   **Variable Exports**: Avoid one-line default exports; use named exports
-   **Return Types**: Always specify explicit return types for functions

## ConsentFlow Architecture

ConsentFlow is a consent management system where:

-   **Profiles** can create and consent to **Contracts**
-   **Contracts** define data access requirements (read/write permissions)
-   **Terms** record a profile's consent to a contract
-   **Transactions** record actions taken against Terms (consent, withdraw, update, sync)
-   **Credentials** can be issued through contract consent or synced by users from their existing credentials

### Relationship Flow

1. Profile → Contract: Profiles create contracts defining data requirements
2. Profile → Terms → Contract: Profiles consent to contracts via Terms
3. Transaction → Terms: Transactions record actions against Terms
4. Credential → Transaction → Terms: Credentials issued via contract are linked to a transaction
5. Contract → Boost: Contracts can define AutoBoosts that are automatically issued when a user consents
6. Sync → Terms: Users can sync existing credentials to specific categories in their terms

### Transaction Types

-   **Consent Transaction**: Created when a user initially consents to a contract
-   **Update Transaction**: Created when a user updates their terms
-   **Withdraw Transaction**: Created when a user withdraws consent
-   **Sync Transaction**: Created when a user syncs credentials to specific categories
-   **Write Transaction**: Created when credentials are issued through a contract (usually via auto-boosts)

### Neo4j & Neogma Integration

-   Neo4j is used as the graph database for storing entities and relationships
-   Neogma serves as an ORM providing model definitions with schemas
-   Models define node labels, properties, relationships, and primary keys
-   Relationships are defined with direction, name, and target model
-   QueryBuilder is used for complex queries with relationship traversal

### Handling Nested Objects

-   Neo4j can't store deeply nested objects directly
-   The system uses:
    -   `flattenObject()`: Converts nested structures to dot-notation keys
    -   `inflateObject()`: Restores flattened objects to nested structure
-   Example conversion:
    ```json
    // Original
    { "contract": { "read": { "personal": { "name": true } } } }
    // Flattened
    { "contract.read.personal.name": true }
    ```
-   This pattern is essential for storing complex objects in Neo4j

### Common Data Flow Patterns

-   **Data Queries**: Use `convertDataQueryToNeo4jQuery()` for Neo4j compatibility
-   **Category Filtering**: Use `shouldIncludeCategory()` to filter credentials by category
-   **Relationship Creation**: Follow the pattern shown in the relationship creation functions
-   **Transaction Recording**: Create transactions for all important actions

### Signing Authorities

-   Signing Authorities are external services that sign credentials on behalf of profiles
-   Each profile can register multiple signing authorities via `usesSigningAuthority` relationships
-   Signing authorities are stored in the SigningAuthority model with an endpoint URL
-   The relationship between Profile and SigningAuthority contains:
    -   `name`: A name identifier for the signing authority (e.g., "default")
    -   `did`: The DID that the signing authority will use to sign credentials
-   When issuing credentials through a signing authority:
    1. The system makes a request to the signing authority's endpoint
    2. It passes the unsigned credential, owner DID, and signing authority details
    3. The signing authority signs the credential and returns it
    4. The `issueCredentialWithSigningAuthority()` helper handles this flow
-   A common pattern is to get a signing authority with `getSigningAuthorityForUserByName(profile, endpoint, name)`

### AutoBoosts

-   AutoBoosts are credentials automatically issued when a user consents to a contract
-   An existing Boost is attached to a Contract via the `AUTO_RECEIVE` relationship
-   When creating a contract with autoboosts, an array of autoboosts must be provided, each with a boost URI and signing authority:
    ```typescript
    {
        // other contract fields
        autoboosts: [
            {
                boostUri: 'boost:123',
                signingAuthority: {
                    endpoint: 'https://signing-authority.example.com',
                    name: 'my-authority',
                },
            },
            {
                boostUri: 'boost:456',
                signingAuthority: {
                    endpoint: 'https://other-authority.example.com',
                    name: 'other-authority',
                },
            },
        ];
    }
    ```
-   The signing authority information is stored on each `AUTO_RECEIVE` relationship
-   When a user consents to a contract, all attached AutoBoosts are:
    1. Issued using the specified signing authority of the contract owner
    2. Sent to the consenting user
    3. Recorded with a 'write' action transaction on the terms
-   AutoBoosts are processed during:
    -   Initial consent via `consentToContract()`
    -   Re-consent via `reconsentTerms()`
    -   Terms update via `updateTerms()`
-   The contract owner MUST have the specified signing authority configured for each autoboost to work correctly
-   If a specified signing authority doesn't exist, that specific autoboost will be skipped with an error message
-   Multiple autoboosts can use the same signing authority or different ones

### Credential Syncing

-   Users can sync their existing credentials to a contract they've consented to
-   Syncing uses the `syncCredentialsToContract` API endpoint
-   The sync process:
    1. User identifies which credentials to share in which categories
    2. User calls `syncCredentialsToContract` with `termsUri` and a map of categories to credential URIs:
        ```typescript
        {
          termsUri: "terms:abc123",
          categories: {
            "Achievement": ["credential:123", "credential:456"],
            "Education": ["credential:789"]
          }
        }
        ```
    3. System performs validation:
        - Verifies the terms exist and belong to the requesting user
        - Ensures terms are still live (not withdrawn or expired)
        - Confirms all categories exist in the contract definition
    4. System creates a 'sync' transaction with the categorized credentials
    5. System updates the terms by adding the synced credentials to the shared arrays for each category
    6. Contract owner receives a notification about the sync action
-   The transaction and terms data use the same structure format for categories
-   All synced credentials are added to the existing shared arrays (if any) for each category
-   Duplicate credential URIs are automatically deduplicated

## Service-Level Boost Sending via Signing Authority

### Context

For certain workflows, such as sending a boost (credential) to another profile via HTTP service routes, the server cannot access the user's key material for security reasons. To enable credential issuance in these cases, the system supports **Signing Authorities**.

### Signing Authorities & Credential Issuance

-   **Signing Authority**: An external service registered by a user/profile that can sign credentials on their behalf.
-   Users register signing authorities by specifying an endpoint and a name.
-   When sending a boost via HTTP, the service uses the registered signing authority to sign the credential and deliver it to the recipient.

### Example: `/boost/send/via-signing-authority` Route

-   **Input:** `{ profileId, boostUri, signingAuthority: { name, endpoint } }`
-   **Flow:**
    1. Fetch the boost by URI.
    2. Fetch the recipient profile.
    3. Prepare an unsigned VC for the recipient.
    4. Use the specified signing authority to sign the VC.
    5. Deliver the signed VC to the recipient via the network.
-   **Testing:** E2E tests should POST directly to the route using fetch, after registering a signing authority for the sender.

### Best Practices for AI Assistants

-   Always check for the availability of key material before issuing credentials server-side. If unavailable, require a signing authority.
-   Document the expected flow, security implications, and how to set up signing authorities in both code and tests.
-   Prefer direct HTTP calls in E2E tests for service-only routes, and demonstrate signing authority setup in test code.

## Profile Connection Invites

LearnCard supports creating and managing profile connection invitation links with single-use, multi-use, and unlimited usage, as well as optional expiration.

### Overview

-   Invites are identified by a `challenge` string and are scoped to the profile that created them.

-   Each invite tracks `maxUses` and `usesRemaining`.

-   Unlimited invites have `maxUses = null` and `usesRemaining = null`.

-   Expiration is expressed in seconds. `0` means no expiration.

### Routes

#### Generate Invite

-   Method: `POST`

-   Path: `/profile/generate-invite`

-   Scope: `connections:write`

-   Input:

    ```json
    {
        "expiration": 2592000,
        "challenge": "optional-custom-string",
        "maxUses": 1
    }
    ```

    -   `expiration` (number, optional): Seconds until expiration. Defaults to 30 days. Use `0` for no expiration.

    -   `challenge` (string, optional): Custom challenge; if omitted, a UUID is generated.

    -   `maxUses` (integer, optional): Minimum `0`. Defaults to `1` (single-use). Use `0` for unlimited usage.

-   Response:

    ```json
    {
        "profileId": "alice",
        "challenge": "uuid-or-custom",
        "expiresIn": 2592000
    }
    ```

#### List Invites

-   Method: `GET`

-   Path: `/profile/invites`

-   Scope: `connections:read`

-   Output: Array of invites

    ```json
    [
        {
            "challenge": "abc123",
            "expiresIn": 1200,
            "usesRemaining": 2,
            "maxUses": 5
        },
        {
            "challenge": "unlimited",
            "expiresIn": null,
            "usesRemaining": null,
            "maxUses": null
        }
    ]
    ```

-   Notes:

    -   Exhausted invites (where `usesRemaining === 0`) are omitted.

    -   `expiresIn` is `null` if the invite never expires.

#### Invalidate Invite

-   Method: `POST`

-   Path: `/profile/invite/{challenge}/invalidate`

-   Scope: `connections:write`

-   Output: `true` on success.

#### Connect Using Invite

-   Method: `POST`

-   Path: `/profile/{profileId}/connect/{challenge}`

-   Scope: `profiles:write`

-   Behavior:

    -   Validates that the invite exists, is not expired, and has remaining uses (or is unlimited).

    -   On successful connection, consumes one use for finite invites (decrements `usesRemaining`).

    -   Returns `404` if invite is invalid or expired.

### Behavior & Storage Details

-   Cache key format: `inviteChallenge:{profileId}:{challenge}`.

-   Stored value is JSON with `{ maxUses, usesRemaining }`.

-   Unlimited usage: `maxUses = null` and `usesRemaining = null`.

-   Expiration TTL:

    -   `expiration = 0` stores without TTL (never expires); `expiresIn` will be `null`.

    -   Positive `expiration` stores the TTL in seconds; listing returns remaining seconds as `expiresIn`.

-   When `usesRemaining` reaches `0`, the invite is considered exhausted and will not be listed. The key may be deleted during consumption.

-   Backward compatibility: Older invites stored as `'valid'` or `'never'` are parsed and treated as single-use or unlimited, respectively.

### Examples

Create a 2-use invite expiring in 10 minutes:

```bash
curl -X POST \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"maxUses":2, "expiration":600}' \
  https://<host>/profile/generate-invite
```

List invites:

```bash
curl -H "Authorization: Bearer <token>" \
  https://<host>/profile/invites
```

Invalidate an invite:

```bash
curl -X POST \
  -H "Authorization: Bearer <token>" \
  https://<host>/profile/invite/<challenge>/invalidate
```

Connect using an invite:

```bash
curl -X POST \
  -H "Authorization: Bearer <token>" \
  https://<host>/profile/<profileId>/connect/<challenge>
```

### Testing

-   See `test/profiles.spec.ts` for unit/integration coverage of generation, listing, consumption, unlimited behavior, and invalidation.

## Tracing & Performance Monitoring

The brain service includes a modular tracing system for performance monitoring and debugging. Located in `src/tracing/`.

### Architecture

-   **TracingManager**: Central coordinator that manages providers and span lifecycle using `AsyncLocalStorage` for request-scoped trace context
-   **TracingProvider**: Interface for output backends (console, JSON, Sentry)
-   **Spans**: Represent timed operations with `traceId`, `spanId`, `parentSpanId`, timing, and status

### Trace Functions

Import from `@tracing`:

```typescript
import { trace, traceDb, traceHttp, traceCrypto, traceInternal } from '@tracing';
```

| Function        | Operation Type | Use Case                                      |
| --------------- | -------------- | --------------------------------------------- |
| `trace`         | Custom `op`    | Top-level route spans, custom categories      |
| `traceDb`       | `db`           | Database operations (Neo4j queries, writes)   |
| `traceHttp`     | `http`         | Outbound HTTP requests                        |
| `traceCrypto`   | `crypto`       | Cryptographic operations (signing, verifying) |
| `traceInternal` | `internal`     | Business logic, data transformations          |

### Usage Pattern

```typescript
// Top-level route tracing
.mutation(async ({ ctx, input }) => {
    return trace('route', 'send', async () => {
        // Trace database operations
        const boost = await traceDb('getBoostByUri', () => getBoostByUri(uri));

        // Trace internal logic
        const prepared = await traceInternal('prepareCredential', () =>
            prepareCredentialFromBoost(boost, options)
        );

        // Trace HTTP calls
        const signed = await traceHttp('signingAuthority', () =>
            issueCredentialWithSigningAuthority(profile, prepared, sa)
        );

        return result;
    }, { recipient: input.recipient }); // Optional metadata
});
```

### Providers

Providers are registered automatically on startup based on environment variables:

| Provider | Env Variable    | Default  | Output Format                                |
| -------- | --------------- | -------- | -------------------------------------------- |
| Console  | `TRACE_CONSOLE` | `true`   | Indented tree with timing: `✓ db:query 45ms` |
| JSON     | `TRACE_JSON`    | `false`  | Structured JSON per span (for log ingestion) |
| Sentry   | `SENTRY_DSN`    | disabled | Sentry performance transactions              |

**Console output example:**

```
[TRACE:a1b2c3]✓ route:send 234ms
[TRACE:a1b2c3]  ✓ db:getBoostByUri 12ms
[TRACE:a1b2c3]  ✓ internal:prepareCredential 45ms
[TRACE:a1b2c3]  ✓ http:signingAuthority 156ms
```

**JSON output example:**

```json
{
    "type": "TRACE_SPAN",
    "traceId": "a1b2c3",
    "spanId": "d4e5f6",
    "op": "db",
    "name": "getBoostByUri",
    "durationMs": 12,
    "status": "ok",
    "timestamp": "2024-01-15T10:30:00.000Z"
}
```

### Configuration

```bash
# Disable console tracing (enabled by default)
TRACE_CONSOLE=false

# Enable JSON tracing for log aggregation
TRACE_JSON=true

# Enable Sentry performance monitoring
SENTRY_DSN=https://xxx@sentry.io/xxx
```

### Best Practices

1. **Wrap entire route mutations** in a top-level `trace('route', 'routeName', ...)` call
2. **Use appropriate trace functions** for each operation type to enable filtering
3. **Include relevant metadata** in the optional `data` parameter for debugging
4. **Trace all async operations** that may have variable latency
5. **Name spans descriptively** using the format `operation` or `operation:variant`

### Adding New Providers

Implement the `TracingProvider` interface:

```typescript
import type { TracingProvider, SpanContext, SpanResult } from '../types';

export class MyProvider implements TracingProvider {
    name = 'my-provider';

    isEnabled(): boolean {
        return process.env.MY_PROVIDER_ENABLED === 'true';
    }

    onSpanStart(ctx: SpanContext): void {
        // Called when span begins
    }

    onSpanEnd(ctx: SpanContext, result: SpanResult): void {
        // Called when span completes (success or error)
    }
}
```

Register in `src/tracing/index.ts`:

```typescript
manager.register(new MyProvider());
```

## Credential Revocation System

### Purpose

Allows issuers to revoke credentials from recipients. When revoked:

1. Recipient removed from member/recipient lists
2. Permissions granted via claim hooks reversed
3. Connections created via auto-connect cleaned up
4. Credential marked as revoked (not deleted) for audit

### Neo4j Data Model

#### CREDENTIAL_RECEIVED relationship

```
(Credential)-[:CREDENTIAL_RECEIVED]->(Profile)
```

| Property    | Type     | Description                                                   |
| ----------- | -------- | ------------------------------------------------------------- |
| `status`    | string   | `null` (claimed), `'pending'` (not yet accepted), `'revoked'` |
| `date`      | datetime | When received/claimed                                         |
| `revokedAt` | datetime | When revoked (if revoked)                                     |

#### CONNECTED_WITH relationship

```
(Profile)-[:CONNECTED_WITH]->(Profile)
```

| Property  | Type     | Description                                       |
| --------- | -------- | ------------------------------------------------- |
| `sources` | string[] | Source keys like `['boost:uuid1', 'boost:uuid2']` |

### Key Queries

```cypher
-- Get recipients (excluding revoked)
MATCH (c:Credential)-[r:CREDENTIAL_RECEIVED]->(p:Profile)
WHERE c.boostId = $boostId
  AND (r.status IS NULL OR r.status <> 'revoked')
  AND ($includeUnacceptedBoosts = true OR r.status IS NULL)
RETURN p

-- Set revoked status
MATCH (credential:Credential { id: $credentialId })
MATCH (profile:Profile { profileId: $profileId })
MERGE (credential)-[received:CREDENTIAL_RECEIVED]->(profile)
ON CREATE SET received.status = "revoked", received.revokedAt = $revokedAt, received.date = $revokedAt
ON MATCH SET received.status = "revoked", received.revokedAt = $revokedAt
```

### Revoke Hooks (`src/helpers/revoke-hooks.helpers.ts`)

| Hook                            | Purpose                                                           |
| ------------------------------- | ----------------------------------------------------------------- |
| `processPermissionsRevokeHooks` | Removes HAS_ROLE relationships from GRANT_PERMISSIONS claim hooks |
| `processAutoConnectRevokeHooks` | Removes AUTO_CONNECT_RECIPIENT relationships                      |
| `processAdminRevokeHooks`       | Removes admin roles from ADD_ADMIN claim hooks                    |
| `processConnectionRevoke`       | Removes CONNECTED_WITH relationships sourced from the boost       |

### Connection Source Keys

-   Pattern: `boost:${boostId}`
-   Connections can have multiple sources (from multiple boosts)
-   On revoke, only the source key from the revoked boost is removed
-   Connection deleted only when all source keys are removed

### Parent Boost Handling

`processConnectionRevoke` checks:

1. Direct boost (if `autoConnectRecipients=true`)
2. All parent boosts with `autoConnectRecipients=true`
3. All boosts targeted by AUTO_CONNECT claim hooks

### getRevokedCredentials Endpoint

```typescript
// In credentials.ts router — returns revoked credential URIs for authenticated user
getRevokedCredentials: profileRoute
    .input(z.object({}).default({}))
    .output(z.array(z.string()))
    .query(async ({ ctx }) => {
        return getRevokedCredentialUrisForProfile(ctx.domain, ctx.user.profile);
    });
```

### Key Files

| File                                  | Purpose                                                                                    |
| ------------------------------------- | ------------------------------------------------------------------------------------------ |
| `src/accesslayer/credential/read.ts`  | `getReceivedCredentialsForProfile` (filters revoked), `getRevokedCredentialUrisForProfile` |
| `src/routes/credentials.ts`           | `getRevokedCredentials` endpoint                                                           |
| `src/helpers/revoke-hooks.helpers.ts` | All revoke hook processors                                                                 |
| `test/revoke-credential.spec.ts`      | Revocation tests                                                                           |
| `test/claim-hooks.spec.ts`            | Claim hook tests                                                                           |

## App Notifications

Apps can send notifications to users via the `APP_NOTIFICATION` type. There are two delivery paths:

### 1. SDK Self-Notification (via `appEvent`)

When the user has the app open in LearnCard, the Partner Connect SDK can trigger a self-notification via the `send-notification` app event type. This is handled by `handleSendNotificationEvent` in `src/routes/app-store.ts`.

-   **Rate limit**: 10 notifications per user per app per hour (Redis key: `app-notif-rate:{listingId}:{profileId}`)
-   **Auth**: User must have the app installed or own the integration

### 2. Server-to-Server Route (Primary)

App backends send notifications to arbitrary users who have the app installed.

-   **Route**: `POST /app-store/listing/{listingId}/notify`
-   **Scope**: `app-store:write`
-   **Auth**: Caller must own the listing's integration (`verifyListingOwnership`)

**Input:**

```json
{
    "listingId": "my-app-id",
    "recipient": "user123",
    "title": "New content available!",
    "body": "Check out the latest challenge",
    "actionPath": "/challenges",
    "category": "announcement",
    "priority": "normal"
}
```

-   `recipient`: Accepts a profileId, `did:web:...`, or `did:key:...` (resolved via `getProfileIdFromString`)
-   `title` / `body`: At least one required
-   `actionPath`: Deep link path within the app
-   `category`: Grouping category (e.g. `'reward'`, `'announcement'`)
-   `priority`: `'normal'` (default) or `'high'`

**Guards:**

-   Recipient must have the app installed (`hasProfileInstalledApp`)
-   Rate limit: 60 notifications per listing per hour (Redis key: `app-notif-server-rate:{listingId}`)

**Notification shape:**

```json
{
    "type": "APP_NOTIFICATION",
    "to": { "did": "did:web:...:users:user123", "profileId": "user123" },
    "from": { "did": "did:web:...:apps:my-app", "displayName": "My App" },
    "message": { "title": "...", "body": "..." },
    "data": {
        "metadata": {
            "listingId": "...",
            "listingSlug": "...",
            "actionPath": "/challenges",
            "category": "announcement",
            "priority": "normal"
        }
    }
}
```

### Querying App Notifications (lca-api)

Notifications can be filtered by app using the `data.metadata.listingId` field in the `queryNotifications` endpoint on `lca-api`.

### Key Files

| File                                   | Purpose                                                                                   |
| -------------------------------------- | ----------------------------------------------------------------------------------------- |
| `src/routes/app-store.ts`              | `sendAppNotification` route + `handleSendNotificationEvent` handler                       |
| `src/helpers/notifications.helpers.ts` | `addNotificationToQueue` — SQS / webhook delivery                                         |
| `@learncard/types` `lcn.ts`            | `LCNNotificationTypeEnumValidator` (`APP_NOTIFICATION`), `SendNotificationEventValidator` |

## App Counters

Apps can store per-user, per-app counters (e.g. coins, streaks, levels) via `APP_COUNTER` relationships in Neo4j.

### Data Model

Counters are stored as `APP_COUNTER` relationships between `Profile` and `AppStoreListing`:

```
(Profile)-[:APP_COUNTER {key, value, createdAt, updatedAt}]->(AppStoreListing)
```

Multiple parallel `APP_COUNTER` relationships (one per key) can exist between the same Profile and AppStoreListing.

### Event Types

| Event               | Validator                        | Purpose                                                   |
| ------------------- | -------------------------------- | --------------------------------------------------------- |
| `increment-counter` | `IncrementCounterEventValidator` | Atomic increment/decrement via `MERGE ON CREATE/ON MATCH` |
| `get-counter`       | `GetCounterEventValidator`       | Read single counter (returns `{ value: 0 }` if missing)   |
| `get-counters`      | `GetCountersEventValidator`      | Read multiple or all counters for a (user, app) pair      |

### Limits

-   Max **50 counter keys** per user per app (`MAX_COUNTER_KEYS_PER_USER_PER_APP`)
-   Max **100 writes** per user per app per minute (Redis rate limit key: `app-counter-rate:{listingId}:{profileId}`)
-   Counter keys: alphanumeric + `_` / `-`, max 64 chars
-   Amount: any finite integer

### Key Files

| File                                    | Purpose                                                                                   |
| --------------------------------------- | ----------------------------------------------------------------------------------------- |
| `src/accesslayer/app-counter/create.ts` | `incrementAppCounter` — atomic `MERGE`-based upsert                                       |
| `src/accesslayer/app-counter/read.ts`   | `getAppCounter`, `getAppCounters`, `countAppCounterKeys`                                  |
| `src/helpers/app-counter.helpers.ts`    | Event handlers with validation + rate limiting                                            |
| `src/types/app-counter.ts`              | TypeScript types for counter I/O                                                          |
| `@learncard/types` `lcn.ts`             | `IncrementCounterEventValidator`, `GetCounterEventValidator`, `GetCountersEventValidator` |
| `test/app-counters.spec.ts`             | Counter tests                                                                             |

### Useful Cypher Queries

```cypher
-- List all counters for a user in an app
MATCH (p:Profile {profileId: 'user123'})-[c:APP_COUNTER]->(l:AppStoreListing {listing_id: 'abc'})
RETURN c.key, c.value, c.updatedAt

-- Delete all counters (test cleanup)
MATCH ()-[r:APP_COUNTER]->() DELETE r
```

## Seed Dev App Script

Location: `scripts/seed-dev-app.ts`

An idempotent script that seeds a fully functional partner app listing into the local Neo4j + MongoDB databases for development and testing. It creates all the infrastructure needed to test notifications, counters, and credential issuance.

### What It Creates

1. **Profile** — owner profile (default: `dev-owner`)
2. **Integration** — integration node linked to the owner
3. **AppStoreListing** — listed app with embed URL, slug, display name, icon
4. **Signing Authority** — in MongoDB, linked to the listing via Neo4j relationship
5. **Boost Template** — credential template attached to the listing
6. **(Optional) Install** — installs the app for another profile via `--install-for`
7. **(Optional) Rate Limit Reset** — clears Redis rate limit keys via `--reset-rate-limits`

### Usage

```bash
cd services/learn-card-network/brain-service

# Basic (defaults to http://localhost:4321)
pnpm seed:dev-app

# Custom app URL
pnpm seed:dev-app -- --app-url http://localhost:4321

# Install for a specific user (needed for notifications)
pnpm seed:dev-app -- --install-for my-profile-id

# Reset rate limits (useful when testing repeatedly)
pnpm seed:dev-app -- --reset-rate-limits

# Custom name, slug, permissions
pnpm seed:dev-app -- --app-name "My Game" --slug my-game
pnpm seed:dev-app -- --permissions request_identity,send_credential

# Custom signing authority endpoint
pnpm seed:dev-app -- --sa-endpoint http://localhost:5100/api

# Custom promotion level
pnpm seed:dev-app -- --promotion FEATURED_CAROUSEL
```

### All Flags

| Flag                  | Default                     | Description                              |
| --------------------- | --------------------------- | ---------------------------------------- |
| `--app-url`           | `http://localhost:4321`     | Embed URL for the partner app            |
| `--app-name`          | `Dev Partner App`           | Display name                             |
| `--slug`              | derived from app-name       | URL slug                                 |
| `--profile`           | `dev-owner`                 | Owner profile ID                         |
| `--install-for`       | _(none)_                    | Profile ID to install the app for        |
| `--domain`            | hostname from app-url       | Domain for DID construction              |
| `--app-image`         | _(none)_                    | Custom app icon URL                      |
| `--template-alias`    | `default`                   | Alias for the boost template             |
| `--sa-endpoint`       | `http://localhost:5100/api` | Signing authority endpoint               |
| `--sa-seed`           | `'d'.repeat(64)`            | Seed for signing authority key           |
| `--permissions`       | all permissions             | Comma-separated permission list          |
| `--promotion`         | `FEATURED_CAROUSEL`         | Promotion level for app store visibility |
| `--reset-rate-limits` | _(flag)_                    | Clear Redis rate limit keys for this app |

### Prerequisites

-   Neo4j running (`pnpm dev:services` from `apps/learn-card-app` or local Docker)
-   Redis running (same)
-   MongoDB running (same)
-   Falls back to local docker-compose defaults if no `.env` is present

### Re-running

The script is **idempotent** — it uses slug-based lookup and updates existing nodes rather than creating duplicates. Safe to re-run at any time.

## Debugging

### Docker Logs

```bash
docker logs lcn-brain-service --tail 100 -f
```

### Useful Cypher Queries

```cypher
-- Check connection sources
MATCH (p:Profile {profileId: 'some-id'})-[r:CONNECTED_WITH]-(other)
RETURN other.profileId, r.sources

-- Check credential status
MATCH (c:Credential)-[r:CREDENTIAL_RECEIVED]->(p:Profile {profileId: 'some-id'})
RETURN c.id, r.status, r.revokedAt
```

## Email Delivery & Tenant Branding

Transactional emails (inbox-claim, endorsement-request, guardian-approval, account-approved, contact-method-verification, credential-awaiting-guardian, guardian-credential-approval, guardian-approved-claim, guardian-rejected-credential, guardian-email-otp) are rendered locally via [`@learncard/email-templates`](../../../packages/email-templates/README.md) and sent as raw HTML through Postmark.

### Architecture

```
route handler → deliveryService.send({ templateAlias, templateModel, branding, ... })
                    │
                    ▼
           PostmarkAdapter.send()
                    │
                    ├─ LOCAL_TEMPLATE_MAP[alias] → local TemplateId?
                    │     │ yes
                    │     ▼
                    │  renderEmail(id, branding, data) → { html, text, subject }
                    │     │
                    │     ▼
                    │  Postmark.sendEmail (raw HTML)
                    │
                    └─ no → Postmark.sendEmailWithTemplate (legacy fallback)
```

### Tenant Resolution

Tenant is resolved once per request in `createContext` via `resolveTenantFromRequest(headers)` from `@learncard/email-templates` and attached as `ctx.tenant`. Priority: `X-Tenant-Id` header → `Origin` / `Referer` → `DEFAULT_TENANT_ID` env → `'learncard'`.

### Rules for Route Authors

-   **Always pass `branding: ctx.tenant?.emailBranding`** to `deliveryService.send()`. Emails sent without it render with LearnCard defaults regardless of the caller's tenant.
-   **Use `getFrom({ mailbox, branding })`** for the from-address so the domain matches the tenant (e.g. `recovery@vetpass.app` for VetPass).
-   **Use the local template ID as the `templateAlias`** (e.g. `'inbox-claim'`, `'guardian-approval'`). These are pre-registered as sentinels in `LOCAL_TEMPLATE_MAP` — env-var overrides are optional, never required.
-   **Do not write plain-text fallbacks.** If local rendering fails for a sentinel alias, the adapter re-throws so the error surfaces — don't silently send unbranded text.

### Adding a New Template

1. Add the template to `@learncard/email-templates` (see that package's `AGENTS.md`).
2. Register the alias in `LOCAL_TEMPLATE_MAP` in `src/services/delivery/adapters/postmark.adapter.ts`.
3. If the service's `templateModel` shape differs from the package's `TemplateDataMap` shape, extend `mapTemplateModel()` in the adapter.
4. Call `deliveryService.send()` from the route, passing `branding: ctx.tenant?.emailBranding`.

See [Tenant-Branded Emails (architecture)](../../../docs/core-concepts/tenant-branded-emails.md) for the end-to-end flow and [Configure Tenant-Branded Emails](../../../docs/how-to-guides/configure-tenant-branded-emails.md) for operator-facing setup.
