# Federated Amage Rastro dashboard

This document is the protocol reference. Follow
[`federation-setup.md`](federation-setup.md) for the executable customer-host
installation, pairing, verification, and revocation sequence.

The federated dashboard model keeps telemetry in the customer's Convex or
Synapse deployment while a browser loaded from `amagerastro.com` subscribes
directly to authorized host functions in that deployment.

```text
tracker -> customer deployment -> Rastro component tables

amagerastro.com browser -> customer deployment -> reactive Rastro reports
```

The Amage Rastro control plane stores account and connection metadata. It does
not need to proxy or persist the customer's analytics events.

## Connector protocol v1

The typed `FEDERATED_ANALYTICS_CONNECTOR_MANIFEST` constant defines the stable
v1 contract:

- protocol: `amage-rastro-analytics`
- protocol version: `1`
- canonical host module: `rastroFederation`
- canonical function names and supported capabilities
- public request, connection, range, live visitor, and journey limits

The host module must be `convex/rastroFederation.ts`, so its public references
match the function names advertised by the manifest. The generated `manifest`
query is public because it contains only static protocol metadata. It does not
resolve a connection or expose customer data. Compare a served manifest with
`agent/manifest.json`'s `connector` object structurally, never as serialized
text: the two are the same value with different key order, and a byte-for-byte
comparison fails on a correct host.

The exact generated surface is:

- `manifest`
- `connectionStatus`
- `listSites`
- `overview`
- `liveVisitors`
- `listSessions`
- `sessionJourney`
- `listConversions`
- `visitorJourney`
- `goalsReport`
- `funnelsReport`
- `affiliatesReport`
- `dataCoverage`
- `getSession` (optional capability `sessionDetails`): metadata for one
  authorized site/session
- `revenueSummary` (optional capability `revenueSummary`): exact period
  commissions alongside overview revenue/count/currency
- `vitalsReport` (optional capability `vitals`, hosts from `alpha.6`)
- `siteMap` (optional capability `siteMap`, hosts from `alpha.8`)
- the configure scope (optional capability `configure`, hosts from `alpha.11`):
  `siteSettings`, `updateSite`, `listGoals`, `upsertGoal`, `removeGoal`,
  `listFunnels`, `upsertFunnel`, `removeFunnel`, `listAffiliates`,
  `upsertAffiliate`, `removeAffiliate`, `retentionStatus`, `setRetentionPolicy`,
  and `disableRetentionPolicy`
- the tracked links (optional capability `trackedLinks`, hosts from `0.5.0`):
  `listTrackedLinks` for any reader, and `createTrackedLink`,
  `updateTrackedLink`, and `deleteTrackedLink`, which need `analytics:configure`
- `getVisitorProfile` and `listPeople` (optional capabilities `visitorProfiles`
  and `people`, hosts from `0.6.0`); see
  [Identified visitor profiles](#identified-visitor-profiles)
- `transferConnection` (optional capability `transfer`, hosts from `0.2.0` that
  pass the callback); see
  [`federation-setup.md`](federation-setup.md#transfer-a-connection-to-another-organization)
- the lead reads (opt-in capability `leads`, hosts from `0.8.0` that pass
  `leads: true`): `leadsReport`, `listLeads`, `leadJourney`, and, from `0.10.0`,
  `leadsDaily`
- the Meta reads (opt-in capability `metaAds`, hosts from `0.10.0` that pass
  `metaAds: true`): `metaStatus` and `metaConversions`; see
  [Leads and Meta ads](#leads-and-meta-ads)
- the Lead Ads reads (opt-in capability `metaLeads`, unreleased hosts that pass
  `metaLeads: true` beside `metaAds: true`; alone it throws at setup):
  `metaPages`, `metaForms`, `metaFormRules`, `simulateMetaFormRules`,
  `metaFormsReport` and `metaLinkedPages`; see
  [Leads and Meta ads](#leads-and-meta-ads)

Hosts from `alpha.11` also advertise the optional capability `localDays`: their
`overview` answers a daily range that covers whole calendar days in the site's
timezone from buckets kept in that zone, and `listSites` says per site which
zone that is and since when the days are complete. Dashboards must not require
the capability.

Unreleased hosts that pass `leads: true` also advertise `leadsPaid`. It is not
an opt-in: it says their `leadsDaily` takes `origin: "paid"` (every paid origin,
instant forms included), so a dashboard asks for it on any such host, whether or
not it advertises `metaLeads`.

There are no ingestion, owner-wide enumeration, or arbitrary host functions in
this surface, and no write runs with a read-only token: every function of the
configure scope requires the `analytics:configure` permission on the token
**and** on the host's local grant, as described under
[Configure scope](#configure-scope). The read-only goal, funnel, and affiliate
reports include their bounded definitions, and `listConversions` returns only
the trusted server-side conversion ledger. Browser `conversion` telemetry
remains untrusted journey data and never enters that ledger. Journey events
include bounded custom properties. `exposeFederatedAnalyticsApi` accepts only
`FederatedAnalyticsAuthorizerOptions`; it does not accept or expose an arbitrary
host `AnalyticsAuthorizer`.

`connectionStatus` authenticates and resolves the authoritative connection on
every call. Its response is deliberately redacted to status, protocol version,
capabilities, the effective permissions, and site count. It does not return
connection IDs, organization IDs, identity claims, or site IDs.

`listSites` takes no arguments. It loads sites only from the `siteIds` in the
validated local connection and returns `FederatedSiteSummary` values containing
only `siteId`, `name`, `currency`, `timezone`, `cookieless`, and (hosts from
`alpha.11`) `localDays` — `{ timezone, since }` when the host keeps the site's
calendar days, `null` otherwise. In particular, it never returns `ownerId`,
`domains`, or `networkId`.

## Configure scope

Hosts from `alpha.11` advertise the optional `configure` capability. Behind it,
the dashboard's organization owners and admins can manage goals, funnels,
affiliates, site settings, and the retention policy of the granted sites without
a host deploy. The scope is off unless the host opts in:

- The control plane mints `rastro_permissions: ["analytics:read"]` for every
  member and adds `"analytics:configure"` only for members whose organization
  role is `owner` or `admin`.
- The host's local grant lists the permissions it honors. A grant without
  `permissions`, or without `analytics:configure` in it, is read only whatever
  the token claims.
- A configure function runs only when both agree; otherwise it fails with
  `FEDERATION_CONFIGURE_FORBIDDEN` before the site scope is examined, so a
  read-only token learns nothing about the grant by probing. `connectionStatus`
  reports the effective permissions, and the dashboard renders forms only when
  they include the configure scope; otherwise every form ends in the code the
  host runs itself.

The scope is bounded to configuration: `upsertGoal`, `removeGoal`,
`upsertFunnel`, `removeFunnel`, `upsertAffiliate`, and `removeAffiliate` mirror
the `Rastro` class, `listGoals`, `listFunnels`, and `listAffiliates` return the
definitions, `setRetentionPolicy` and `disableRetentionPolicy` manage the
recurring cleanup that `retentionStatus` reports, and `updateSite` changes only
`name`, `domains`, and `timezone` — the currency is fixed by the ledger and
`cookieless`, `networkId`, and `ownerId` stay outside the protocol.
`siteSettings` is the one place the surface shows a site's `domains`, because a
connection allowed to change them must see them; `listSites` stays redacted for
every reader. Nothing in the scope ingests telemetry or enumerates sites beyond
the grant. The constants `FEDERATED_ANALYTICS_CONFIGURE_PERMISSION`,
`FEDERATED_ANALYTICS_PERMISSIONS`, `FEDERATED_ANALYTICS_CONFIGURE_FUNCTIONS`,
the `federatedAnalyticsPermissionValidator`, and `effectiveFederatedPermissions`
are exported for hosts and connector clients.

## Public links

Hosts from `0.1.0` honor a third permission, `analytics:public`, which the
control plane claims for a **public link**: a read-only page at
`https://www.amagerastro.com/#/p/<slug>` that anyone holding the address opens
without a sign-in. Its token carries `rastro_permissions: ["analytics:public"]`
— never `analytics:read` — and `rastro_site_ids`, the sites the link was created
for. The host decides twice:

- A grant that does not list `analytics:public` refuses the token whole with
  `FEDERATION_PUBLIC_FORBIDDEN`, `connectionStatus` included, so an organization
  cannot publish a host that never agreed. A host from before the permission
  fails the token with `FEDERATION_INVALID_CLAIMS`, which is the safe failure.
- A public token runs only `manifest`, `connectionStatus`, `listSites`,
  `overview`, `liveVisitors`, and `dataCoverage`
  (`FEDERATED_ANALYTICS_PUBLIC_FUNCTIONS`); every other function, sessions and
  journeys first, fails with `FEDERATION_PUBLIC_FORBIDDEN` before the site scope
  is examined. `overview` answers it with traffic alone — `conversions` and
  `revenueCents` are zero on the totals, the series, and every item, and
  `topCampaigns`, `topMediums`, `topEvents`, and `topGoals` are empty — and
  `liveVisitors` replaces `visitorId` with the session id, since the host may
  have linked the visitor to its own signed-in user id, and drops the intent
  fields (`entryPath`, `returning`, `utmCampaign`, `affiliateSlug`,
  `conversionCount`, `funnel`).
- `rastro_site_ids` narrows every token kind, a reader's included: the scope is
  the grant's sites the claim also names, `listSites` returns only those, and a
  claim naming none of them is denied with `FEDERATION_SITE_DENIED`.

`connectionStatus` reports `permissions: ["analytics:public"]` for such a token.
The control plane creates links only for organization owners and admins, only on
a connection whose last verification recorded the host's permission, and revokes
them without a host deploy; the host's grant remains the authority, so removing
`analytics:public` from it silences every link at the next token. The permission
ladder — public below read below configure — is exported as
`federatedPermissionSatisfies`, with `FEDERATED_ANALYTICS_PUBLIC_PERMISSION`,
`FEDERATED_ANALYTICS_PUBLIC_FUNCTIONS`, and `isPublicFederatedAccess` alongside.

## Authentication contract

The customer host must configure Convex authentication to trust the Amage Rastro
OIDC issuer. Convex validates the JWT signature, issuer, audience, and expiry
before any host function runs. The helper then requires these custom claims:

| Claim                    | Value                                                                                                                              |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
| `rastro_connection_id`   | The paired deployment connection                                                                                                   |
| `rastro_organization_id` | The Amage Rastro organization                                                                                                      |
| `rastro_permissions`     | Must include `analytics:read`; owners and admins add `analytics:configure`. A public link's token carries `analytics:public` alone |
| `rastro_site_ids`        | Optional: the sites the token may read, a subset of the grant; a public link's token names the sites it was created for            |

Claim names and the permission constants are exported as
`FEDERATED_ANALYTICS_CLAIMS`, `FEDERATED_ANALYTICS_READ_PERMISSION`,
`FEDERATED_ANALYTICS_CONFIGURE_PERMISSION`, and
`FEDERATED_ANALYTICS_PUBLIC_PERMISSION`.

The audience is the customer's normalized Convex deployment URL, for example
`https://product-123.convex.cloud`. It is stable across connections to that
deployment; `rastro_connection_id` identifies the individual grant. A customer
host configures the provider with the issuer and audience issued during pairing:

```ts
// convex/auth.config.ts
import type { AuthConfig } from "convex/server";

export default {
  providers: [
    // Keep the application's existing providers here.
    {
      domain: process.env.RASTRO_FEDERATION_ISSUER!,
      applicationID: process.env.RASTRO_FEDERATION_AUDIENCE!,
    },
  ],
} satisfies AuthConfig;
```

`RASTRO_FEDERATION_AUDIENCE` must exactly match the deployment URL registered in
the control plane. Insert the provider object into the host's existing
`providers` array; the complete insertion fragment is in
[`federation-setup.md`](federation-setup.md). Never replace existing providers.
The example control plane publishes OIDC discovery and JWKS endpoints under
`/federation`, signs RS256 tokens with a ten-minute lifetime, and never sends
its private key to the browser.

## Authoritative local connection

JWT claims identify a requested connection but do not define its site access.
The customer deployment resolves a local connection record on every
authenticated query. This record is authoritative and makes revocation immediate
instead of waiting for a token to expire.

A host schema can model it as:

```ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  rastroFederationGrants: defineTable({
    connectionId: v.string(),
    organizationId: v.string(),
    siteIds: v.array(v.string()),
    // Absent means read only; "analytics:configure" opts into the scope and
    // "analytics:public" lets the organization's public links answer.
    permissions: v.optional(v.array(v.string())),
    createdAt: v.number(),
    updatedAt: v.number(),
    revokedAt: v.optional(v.number()),
  }).index("by_connectionId", ["connectionId"]),
});
```

Pairing and revocation mutations belong to the host application and must use its
existing administrator authorization. The Rastro component cannot inspect host
authentication state. The grant's `permissions`, like its `siteIds`, are the
host's decision: a token cannot grant itself the configure scope.

## Host API

```ts
// convex/rastroFederation.ts
import {
  exposeFederatedAnalyticsApi,
  type FederatedAnalyticsConnection,
  type FederatedAnalyticsPermission,
} from "@iann29/rastro";
import { components } from "./_generated/api";
import { env, type QueryCtx } from "./_generated/server";

// RASTRO_FEDERATION_ISSUER is declared optional in convex.config.ts so a push
// never depends on it; the production issuer is the default.
const issuer = (
  env.RASTRO_FEDERATION_ISSUER ?? "https://site.api.amagerastro.com/federation"
).replace(/\/$/, "");

const federated = exposeFederatedAnalyticsApi(components.rastroAnalytics, {
  issuer,
  resolveConnection: async (ctx, identity) => {
    const db = ctx.db as unknown as QueryCtx["db"];
    const grant = await db
      .query("rastroFederationGrants")
      .withIndex("by_connectionId", (query) =>
        query.eq("connectionId", identity.connectionId),
      )
      .unique();

    if (!grant || grant.organizationId !== identity.organizationId) return null;
    return {
      connectionId: grant.connectionId,
      organizationId: grant.organizationId,
      siteIds: grant.siteIds,
      permissions: grant.permissions as FederatedAnalyticsPermission[],
      revokedAt: grant.revokedAt,
    } satisfies FederatedAnalyticsConnection;
  },
});

export const {
  manifest,
  connectionStatus,
  listSites,
  overview,
  liveVisitors,
  getSession,
  getVisitorProfile,
  listPeople,
  revenueSummary,
  listSessions,
  sessionJourney,
  listConversions,
  visitorJourney,
  goalsReport,
  funnelsReport,
  affiliatesReport,
  vitalsReport,
  siteMap,
  dataCoverage,
  siteSettings,
  updateSite,
  listGoals,
  upsertGoal,
  removeGoal,
  listFunnels,
  upsertFunnel,
  removeFunnel,
  listAffiliates,
  upsertAffiliate,
  removeAffiliate,
  retentionStatus,
  setRetentionPolicy,
  disableRetentionPolicy,
} = federated;
```

The resolver receives the authenticated `UserIdentity` as well as the parsed
connection and organization IDs, so a host may enforce additional local rules.
It must use an indexed, authoritative host lookup rather than token-provided
site IDs. A host that never lists `analytics:configure` in a grant may leave the
configure functions out of the export; the manifest still advertises them, and
the dashboard never calls them for a read-only connection.

## Stable errors

`FEDERATED_ANALYTICS_ERROR_CODES` exports all protocol v1 authorization codes:

| Code                             | Meaning                                                             |
| -------------------------------- | ------------------------------------------------------------------- |
| `FEDERATION_UNAUTHENTICATED`     | No authenticated identity                                           |
| `FEDERATION_ISSUER_MISMATCH`     | Identity came from another issuer                                   |
| `FEDERATION_INVALID_CLAIMS`      | Required bounded claims or permission absent                        |
| `FEDERATION_CONNECTION_DENIED`   | Connection missing, revoked, or malformed                           |
| `FEDERATION_INVALID_SCOPE`       | Requested site list violates limits, or capability not opted in     |
| `FEDERATION_SITE_DENIED`         | Requested site is outside the connection                            |
| `FEDERATION_CONFIGURE_FORBIDDEN` | Token or grant lacks `analytics:configure`                          |
| `FEDERATION_PUBLIC_FORBIDDEN`    | Public token outside its surface, or grant lacks `analytics:public` |

Consumers should branch on `ConvexError.data.code`, not human-readable messages.

## Enforced invariants

- The identity issuer must match exactly.
- The token must include the read permission, or the public permission alone.
- A configure function runs only when the token and the grant both carry
  `analytics:configure`; a grant listing an unknown permission is denied whole.
- A public token runs only the public surface, and only when the grant lists
  `analytics:public`; `rastro_site_ids` narrows every token to the granted sites
  it names.
- A lead or Meta read runs only on a host that opted into its capability
  (`leads`, `metaAds`); connecting Meta ad accounts or a pixel is never part of
  the surface.
- Report queries must request one through ten unique, bounded site IDs.
- The resolved connection ID and organization must match the token.
- Revoked, missing, malformed, empty, or duplicate-site connections are denied.
- The resolved connection may authorize at most 10 unique site IDs.
- Every report site must exist in the local connection scope.
- Site discovery trusts only the resolved connection, never caller arguments.
- Owner-wide enumeration, ingestion, and arbitrary host functions are absent;
  the only writes are the configure scope's, and `listSites` never returns
  `ownerId`, `domains`, or `networkId`.
- `dataCoverage` is read-only and uses the same connection-scoped site
  authorization.

`dataCoverage` accepts one authorized `siteId` and an inclusive integer
millisecond `from`/`to` range. It returns availability, rollup generation, and
retention boundaries for each dashboard dataset, but no events, visitor IDs,
session IDs, or site configuration. Dashboard clients should use it to explain
`complete`, `partial`, `retained`, and `unavailable` states rather than treating
an empty report as proof that no telemetry exists.

Availability is derived from bounded indexed source reads rather than a shared
per-ingest watermark. The dataset list distinguishes `overviewHour` from
`overviewDay`; clients must not apply one granularity's retention boundary to
the other. Heartbeats advance `sessions` availability only, not `events` or
either overview dataset.

## Control-plane status

The production control plane at `https://www.amagerastro.com` and its reference
implementation under `control-plane/` provide Better Auth sessions,
organization-scoped connections, OIDC discovery, JWKS, ten-minute RS256 tokens
whose permissions follow the member's organization role, manifest verification,
redacted site discovery, audit records, revocation of new token issuance, and
dynamic browser subscriptions to active deployments. Verification records the
permissions the host honors for a manager's token that claims the read, the
configure and the public permission at once — the host reports a claimed
permission only when its grant lists it — so **Conexões** shows whether a
connection is read only, reads and configures, and whether it lets the
organization publish public links; those links mint `analytics:public` tokens
for anyone holding the address, without a sign-in and under their own rate
limits.

Do not assume that a dist-tag or source checkout contains this protocol. Inspect
an operator-approved exact npm registry artifact outside the host, without
lifecycle scripts, and statically verify every runtime and declaration export
listed by the machine manifest before changing a customer deployment. Automation
must stop at this gate rather than fetching source or reimplementing the
connector.

Remote local-grant provisioning is intentionally not an unauthenticated control-
plane write. The customer operator provisions the authoritative grant through an
internal or host-admin mutation. For immediate revocation, revoke that local
grant before revoking the central connection; otherwise an already-issued token
remains valid until its ten-minute expiry.

## Identified visitor profiles

The optional `visitorProfiles` capability adds
`getVisitorProfile({siteId, visitorId})`. It resolves pre-login aliases and
returns `{visitorId, name?, email?, attributes?, identifiedAt, updatedAt}` or
null. `liveVisitors`, `listSessions` and `getSession` include an optional
`profile` for identified people. These attributes require `analytics:read`;
public live rows remove `profile`, and public tokens cannot call
`getVisitorProfile`. Older hosts work without this capability. Profile writes
stay in the product backend, outside federation. See
[identity integration](identity.md) for Better Auth, custom auth and external
servers.

The optional `people` capability adds
`listPeople({siteId, filter?, paginationOpts})`: a directory of current
profiles, independent of the report period. It supports a name/email/ID prefix
or an exact typed attribute value, with up to 50 rows per page. Cursors belong
to one site and filter. This query requires `analytics:read`; public tokens
cannot list people. Profile writes and the bounded `backfillVisitorProfiles`
upgrade remain host-authorized operations outside federation. See
[the directory contract and upgrade steps](identity.md#project-defined-attributes-and-the-people-directory).

## Leads and Meta ads

Two opt-in capabilities carry what a host's server reports rather than what the
tracker sees. The host turns each on in `exposeFederatedAnalyticsApi`:

- `leads: true` (hosts from `0.8.0`) advertises `leads` and serves
  `leadsReport`, `listLeads`, `leadJourney` and, from `0.10.0`, `leadsDaily`:
  the leads its server records, counted by cohort per origin, per campaign → ad
  set → ad, and per day.
- `metaAds: true` (hosts from `0.10.0`) advertises `metaAds` and serves
  `metaStatus` and `metaConversions`: the Meta ad accounts and pixel each site
  links, and the conversions sent to the pixel.
- `metaLeads: true` beside `metaAds: true` (unreleased; alone it throws at
  setup) advertises `metaLeads` and serves the Lead Ads reads: each owner's
  Facebook Pages, the instant forms of a site's linked Pages with their rules, a
  rules simulation, the per-form report, and `metaLinkedPages`, the Page
  registry the dashboard's control plane routes Meta's lead webhooks by. Its
  writes (`linkMetaPage`, `unlinkMetaPage`, `refreshMetaForms`,
  `setMetaFormRules`, `returnMetaFormLead`) need `analytics:configure` and never
  touch what another owner of the host linked or set. A lead's personal data
  never crosses the surface.

Without the option a host refuses those functions with
`FEDERATION_INVALID_SCOPE` before it examines the connection, and its manifest
neither advertises the capability nor names them, so a dashboard hides what they
feed. Both are reads for any reader (`analytics:read`) inside the grant's sites;
a public link's token is refused with `FEDERATION_PUBLIC_FORBIDDEN`. The Meta
spend inside `leadsReport` and `leadsDaily` needs no flag: it is `null`, meaning
unknown, until a site links an ad account. With `metaAds: true` the surface also
serves the dashboard's own Meta setup (connecting Meta, linking ad accounts, the
pixel and the event map) as `analytics:configure` writes that act for the
dashboard connection's owner, and the host exports `authorizeMetaAction` for its
actions. No function of the surface returns a token or the hashed user data of a
conversion. See [Meta ads](meta-ads.md) for the shapes, the host environment,
and the security invariants.
