# Federated dashboard setup

This runbook connects an existing Convex or Synapse project to the Amage Rastro
control plane without moving telemetry out of that project. Read
[`federation.md`](federation.md) for the protocol and threat model.

## Package eligibility requirement

Do not infer federation availability from this website, a dist-tag, or a source
checkout. Discover the current tag without changing the host:

```bash
npm view @iann29/rastro version
```

After the operator approves that exact version, execute the complete static
tarball, export-map, AST, and TypeScript consumer probe in the **Package
eligibility gate** of the canonical
[`agent/integration.md`](https://www.amagerastro.com/agent/integration.md). Do
not replace it with an `import()` test, which executes package code, or a text
search, which can accept comments and broken re-exports.

If verification fails, stop and request an eligible release. If it passes,
record that exact version but do not install it yet. Detect the currently
installed version with `npm ls @iann29/rastro --depth=0`, then follow
[`upgrading.md`](upgrading.md): snapshot/rehearse maintained deployments and
complete every required migration bridge. Only then install with
`npm install --ignore-scripts --save-exact @iann29/rastro@EXACT_VERSION` and
commit the lockfile. Never fetch a mutable branch or install a source tarball
supplied by this website.

## 1. Verify local analytics first

Do not configure federation until the customer deployment already has:

- the Rastro component mounted under `/rastro/`;
- at least one site with the correct allowed browser domains;
- a tracker using the deployment's HTTP-actions URL;
- an accepted `POST /rastro/events` request;
- a protected local report returning that site's telemetry — `liveVisitors` or
  `sessionJourney` reflect an accepted request immediately, while `listSessions`
  and the bucketed `overview` can trail it by seconds, so an empty result from
  those two right after the first request is not a failure;
- an audit through authorized local reports of retained routes, visitor/session
  IDs, live location, campaigns, affiliates, conversions, and custom properties,
  proving that no unexpected personal data, credentials, secrets, or form values
  will become visible to the intended dashboard organization members and that
  retention is acceptable.

The README quickstart covers those steps. Federation only exposes existing
reports; it does not install the tracker or create sites.

## 2. Record both deployment URLs

The connection form requires two different base URLs with no path, query, hash,
username, or password:

| Field          | Used for                          | Convex Cloud example           | Synapse example                        |
| -------------- | --------------------------------- | ------------------------------ | -------------------------------------- |
| URL de funções | Reactive queries and JWT audience | `https://product.convex.cloud` | `https://product.synapse.example`      |
| URL HTTP       | Tracker and event ingestion       | `https://product.convex.site`  | `https://product.site.synapse.example` |

Custom domains are valid when they route to the corresponding Convex surface.
The control-plane operator must add each custom functions origin to
`RASTRO_FEDERATION_ALLOWED_DEPLOYMENT_ORIGINS` as an exact, comma-separated
HTTPS origin before it can be registered. Do not derive one URL by appending
paths to the other or derive the allowlist from browser input. If the dashboard
returns `UNTRUSTED_DEPLOYMENT_HOST`, stop and ask the Amage Rastro operator who
issued the organization access to add the exact functions origin. Provide only
that public HTTPS origin, never credentials; retry after the operator confirms
the allowlist update.

## 3. Create the pending connection

In the central dashboard:

1. Sign in and select the intended organization.
2. Open **Conexões**.
3. Enter a recognizable name, the functions URL, and the HTTP URL.
4. Create the connection, but do not verify it yet.
5. Copy the displayed issuer, audience, connection ID, and organization ID.

The audience must exactly equal the normalized functions URL. The connection
remains pending until its local grant is installed and verification succeeds.

## 4. Add the authoritative local grant

Before editing, inventory every existing schema table/index, component mount/env
declaration, and authentication provider. After editing, prove each remains in
the diff and typechecks. The snippets in this runbook are insertion fragments,
not replacement files.

Insert this table property into the pilot project's existing
`defineSchema({ ... })` object:

```ts
rastroFederationGrants: defineTable({
  connectionId: v.string(),
  organizationId: v.string(),
  siteIds: v.array(v.string()),
  // Absent means read only. "analytics:configure" lets the dashboard's
  // owners and admins manage goals, funnels, affiliates, site settings and
  // retention for the granted sites; "analytics:public" lets them publish a
  // read-only public link of those sites.
  permissions: v.optional(v.array(v.string())),
  revokedAt: v.optional(v.number()),
  createdAt: v.number(),
  updatedAt: v.number(),
}).index("by_connectionId", ["connectionId"]),
```

The grant, not the JWT, owns the allowed `siteIds` and the permissions the host
honors. Add operator-only provisioning functions:

```ts
// convex/rastroFederationAdmin.ts
import { ConvexError, v } from "convex/values";
import { federatedAnalyticsPermissionValidator, Rastro } from "@iann29/rastro";
import { components } from "./_generated/api";
import { internalMutation } from "./_generated/server";

const rastro = new Rastro(components.rastroAnalytics);

export const provisionConnection = internalMutation({
  args: {
    connectionId: v.string(),
    organizationId: v.string(),
    ownerId: v.string(),
    siteIds: v.array(v.string()),
    permissions: v.optional(v.array(federatedAnalyticsPermissionValidator)),
  },
  returns: v.null(),
  handler: async (ctx, args) => {
    const siteIds = [...new Set(args.siteIds)];
    const permissions = [...new Set(args.permissions ?? [])];
    if (
      args.connectionId.length === 0 ||
      args.connectionId.length > 128 ||
      args.organizationId.length === 0 ||
      args.organizationId.length > 128 ||
      siteIds.length === 0 ||
      siteIds.length > 10 ||
      siteIds.length !== args.siteIds.length
    ) {
      throw new ConvexError({ code: "INVALID_FEDERATION_GRANT" });
    }

    for (const siteId of siteIds) {
      const site = await rastro.getSite(ctx, siteId);
      if (!site || site.ownerId !== args.ownerId) {
        throw new ConvexError({ code: "FEDERATION_SITE_NOT_OWNED" });
      }
    }

    const existing = await ctx.db
      .query("rastroFederationGrants")
      .withIndex("by_connectionId", (query) =>
        query.eq("connectionId", args.connectionId),
      )
      .unique();
    const now = Date.now();
    const grant = {
      connectionId: args.connectionId,
      organizationId: args.organizationId,
      siteIds,
      permissions: permissions.length ? permissions : undefined,
      revokedAt: undefined,
      updatedAt: now,
    };
    if (existing) {
      await ctx.db.patch("rastroFederationGrants", existing._id, grant);
    } else {
      await ctx.db.insert("rastroFederationGrants", {
        ...grant,
        createdAt: now,
      });
    }
    return null;
  },
});

export const revokeConnection = internalMutation({
  args: { connectionId: v.string() },
  returns: v.null(),
  handler: async (ctx, args) => {
    const grant = await ctx.db
      .query("rastroFederationGrants")
      .withIndex("by_connectionId", (query) =>
        query.eq("connectionId", args.connectionId),
      )
      .unique();
    if (!grant) {
      throw new ConvexError({ code: "FEDERATION_GRANT_NOT_FOUND" });
    }
    if (grant.revokedAt === undefined) {
      const now = Date.now();
      await ctx.db.patch("rastroFederationGrants", grant._id, {
        revokedAt: now,
        updatedAt: now,
      });
    }
    return null;
  },
});
```

These mutations are `internalMutation`s so a browser cannot call them. Run them
only with deployment administrator credentials. A host that needs self-service
pairing may wrap equivalent logic in its own authenticated owner/admin mutation;
never accept an owner or user ID from an untrusted caller.

`permissions` is the opt-in for the configure scope. Leave it out for a
read-only connection: the dashboard then shows every configuration form but ends
each one in the code the host runs itself. Pass `["analytics:configure"]` to let
the dashboard organization's owners and admins create and change goals, funnels,
affiliates, the site's name, domains and timezone, and the retention policy for
the granted sites; members of the organization stay read only whatever the grant
says, because the control plane never puts the configure claim in their tokens.

Add `"analytics:public"` to let the organization's owners and admins create
public links: a page at `https://www.amagerastro.com/#/p/<slug>` that anyone
opens without a sign-in and that shows only the overview — without revenue,
conversions, goals, campaigns, or custom events — and the live visitors —
without visitor identities — of the sites a link names. The dashboard offers the
form only after a verification records the permission, and removing it from the
grant silences every link at its next token.

## 5. Trust the federation issuer

The displayed issuer must exactly equal the independently verified production
manifest issuer, `https://site.api.amagerastro.com/federation`; abort on any
mismatch. Derive the audience locally from the normalized functions URL and
compare it with the displayed audience. For Convex Cloud DEV:

```bash
npx convex env set --deployment dev RASTRO_FEDERATION_ISSUER \
  'https://site.api.amagerastro.com/federation'
npx convex env set --deployment dev RASTRO_FEDERATION_AUDIENCE \
  'https://product.convex.cloud'
```

Use `--deployment prod` only with fresh production consent. For an existing
Synapse-managed deployment, target its live Convex environment explicitly:

```bash
synapse convex --dev env set \
  RASTRO_FEDERATION_ISSUER 'https://site.api.amagerastro.com/federation'
synapse convex --dev env set \
  RASTRO_FEDERATION_AUDIENCE 'https://product.synapse.example'
```

Use `synapse env set NAME=value --for=dev` only when defining project defaults
before a new deployment is created; defaults do not update an existing live
deployment.

Insert only these two properties into the pilot project's existing
`defineApp({ env: { ... } })` object. If no `env` object exists, add it without
changing any other `defineApp` option. Do not add a second Rastro mount:

```ts
RASTRO_FEDERATION_ISSUER: v.optional(v.string()),
RASTRO_FEDERATION_AUDIENCE: v.optional(v.string()),
```

Declare them optional, as the [example host](../example/convex/convex.config.ts)
does. A required `v.string()` makes the push fail on a deployment where the
values are not set yet — including every deployment that pushes the code before
pairing. Set both values first and push afterwards: `auth.config.ts` reads them
at deploy time, so values set after a push take effect only on the next push.

Insert this conditional spread into the existing `providers` array; never
replace the array or its current entries:

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

const issuer = process.env.RASTRO_FEDERATION_ISSUER;
const audience = process.env.RASTRO_FEDERATION_AUDIENCE;

...(issuer && audience
  ? [
      {
        domain: issuer.replace(/\/$/, ""),
        applicationID: audience.replace(/\/$/, ""),
      },
    ]
  : []),
```

An exact issuer mismatch is rejected even when the JWT signature is otherwise
valid. Convex discovers keys from `<issuer>/.well-known/openid-configuration`.

## 6. Expose the host module

Create the canonical module with this exact filename:

```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";

/** The production Amage Rastro OIDC issuer; override with RASTRO_FEDERATION_ISSUER. */
export const DEFAULT_FEDERATION_ISSUER =
  "https://site.api.amagerastro.com/federation";

export const federationIssuer = (
  env.RASTRO_FEDERATION_ISSUER ?? DEFAULT_FEDERATION_ISSUER
).replace(/\/$/, "");

const federated = exposeFederatedAnalyticsApi(components.rastroAnalytics, {
  issuer: federationIssuer,
  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,
  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 manifest is public static metadata. Every report function is read-only and
requires both a valid JWT and a matching non-revoked local grant. The functions
from `siteSettings` down are the configure scope: they also require
`analytics:configure` on the token and in the grant, and fail with
`FEDERATION_CONFIGURE_FORBIDDEN` otherwise. A host that keeps every grant
read-only may omit them from the export; the dashboard never calls them for a
read-only connection. The issuer falls back to the production control plane so
the module compiles and pushes before the environment variable exists; tokens
are still rejected until `auth.config.ts` trusts the same issuer, which requires
both variables to be set and the deployment pushed afterwards.

## 7. Deploy and provision

Push the customer host changes first:

```bash
# Convex Cloud DEV, one push
npx convex dev --once

# Synapse DEV
synapse dev --once
```

Then provision the exact site scope. Replace every placeholder with values
copied from the pending connection and the host's existing site records:

```bash
# Convex Cloud DEV
npx convex run --deployment dev rastroFederationAdmin:provisionConnection \
  '{"connectionId":"CONNECTION_ID","organizationId":"ORGANIZATION_ID","ownerId":"OPAQUE_HOST_OWNER_ID","siteIds":["SITE_ID"]}'

# Synapse DEV
synapse convex --dev run rastroFederationAdmin:provisionConnection \
  '{"connectionId":"CONNECTION_ID","organizationId":"ORGANIZATION_ID","ownerId":"OPAQUE_HOST_OWNER_ID","siteIds":["SITE_ID"]}'
```

Use the corresponding explicit production target only after rehearsing DEV. The
command is idempotent for one `connectionId` and replaces its site scope and
permissions. To opt into the configure scope, add
`"permissions":["analytics:configure"]` to the same JSON; run the command again
without it to return the connection to read only.

## 8. Verify in order

1. Run `rastroFederation:manifest` without auth and confirm protocol version 1.
2. Return to **Conexões** in the central dashboard.
3. Click **Verificar conexão**.
4. Confirm the connection becomes active and reports the expected site count.
5. Open Visão geral and confirm the deployment selector names the new host.
6. Trigger one real browser pageview and confirm Ao vivo updates reactively.
7. Check Metas/Afiliados with a complete UTC-day range when those features are
   configured.
8. Confirm the connection card in **Conexões** reads "Somente leitura" or
   "Leitura e configuração" as the grant intends. When it reads "Leitura e
   configuração", create a goal from **Metas** as an owner or admin and confirm
   it appears in the host's `listGoals`; then sign in as a member and confirm
   the same form only offers the code.

The central verification signs a short-lived RS256 token with the verifying
admin's permissions, calls authenticated `connectionStatus` and `listSites`,
records the permissions the host honors, and refuses incompatible manifests or
inconsistent site counts.

## 9. Revoke safely

For immediate denial, revoke the local grant first:

```bash
npx convex run --deployment dev rastroFederationAdmin:revokeConnection \
  '{"connectionId":"CONNECTION_ID"}'

# Synapse DEV
synapse convex --dev run rastroFederationAdmin:revokeConnection \
  '{"connectionId":"CONNECTION_ID"}'
```

For production, repeat the rehearsed command with the platform's explicit
production target: `npx convex run --deployment prod ...` or
`synapse convex --prod run ...`. Never infer the target from the current shell
state.

Then revoke the connection in the central dashboard. Central revocation stops
new tokens; local revocation rejects already-issued tokens immediately. Without
the local step, an existing token remains usable until its ten-minute expiry.

## Troubleshooting

| Symptom or code                  | Check                                                                         |
| -------------------------------- | ----------------------------------------------------------------------------- |
| Manifest not found               | File must be `convex/rastroFederation.ts`; deploy/codegen must have completed |
| `FEDERATION_UNAUTHENTICATED`     | Federation provider missing, token absent, or deployment not redeployed       |
| `FEDERATION_ISSUER_MISMATCH`     | `auth.config.ts` domain and helper issuer differ, often by a trailing slash   |
| JWT audience failure             | `RASTRO_FEDERATION_AUDIENCE` must equal the functions URL, not the HTTP URL   |
| `FEDERATION_CONNECTION_DENIED`   | Grant missing, organization mismatched, empty, malformed, or revoked          |
| `FEDERATION_SITE_DENIED`         | Dashboard requested a site outside the local grant                            |
| `FEDERATION_CONFIGURE_FORBIDDEN` | Grant lacks `analytics:configure`, or the viewer is not an owner or admin     |
| `FEDERATION_PUBLIC_FORBIDDEN`    | Grant lacks `analytics:public`, or a public link asked beyond its surface     |
| Missing function on configure    | Host upgraded the package but did not re-export the configure functions       |
| Tracker 404                      | Tracker uses the functions URL instead of the HTTP-actions URL                |
| `ORIGIN_NOT_ALLOWED`             | Browser host is absent from the site's exact/wildcard domains                 |
| `REPORT_INCOMPLETE`              | Use complete UTC buckets or reduce a range that exceeds a bounded read budget |
| `UNTRUSTED_DEPLOYMENT_HOST`      | Ask the control-plane operator to allow the exact custom functions origin     |

Do not fix authorization errors by weakening the resolver, copying site IDs into
JWT claims, or exposing a permissive report API.

## Transfer a connection to another organization

A dashboard owner or admin in **both** organizations can use **Conexões →
Transferir organização**. Create a destination first through **account menu →
Criar organização**, if needed. This moves the whole connection and all its
sites, not an individual site within a shared connection. Team memberships are
unchanged. Telemetry, site IDs, local site ownership, tracker installation,
configuration and active public-link URLs stay intact. The destination manages
those active links; previously revoked links and the old audit records stay in
the source organization's history. New audit records identify both sides.

Hosts must explicitly enable the optional `transfer` capability. An older host
continues to work but cannot transfer until its operator upgrades the package
using the eligibility and migration gates above and deploys this wiring:

1. Add `version: v.optional(v.number())` to `rastroFederationGrants` and return
   `version: grant.version` from `resolveConnection`. Do not reset this field
   when provisioning or revoking a grant. Missing versions mean zero.
2. Add this option beside `resolveConnection` in `exposeFederatedAnalyticsApi`
   (import `MutationCtx` from the host's generated server):

   ```ts
   transferConnection: async (ctx, { connectionId, organizationId, version }) => {
     const db = ctx.db as unknown as MutationCtx["db"];
     const grant = await db
       .query("rastroFederationGrants")
       .withIndex("by_connectionId", (q) => q.eq("connectionId", connectionId))
       .unique();
     if (!grant) throw new Error("Federation grant disappeared during transfer");
     await db.patch("rastroFederationGrants", grant._id, {
       organizationId,
       version,
       updatedAt: Date.now(),
     });
   },
   ```

3. Export `transferConnection` alongside the existing functions from
   `convex/rastroFederation.ts`. The callback advertises the capability; the
   library checks the issuer, signed destination, configure permission, whole
   connection scope and generation before calling it in the same transaction.
   The local grant must permit `analytics:configure`.
4. Deploy the host, then use **Verificar novamente** in the dashboard.

The control plane reserves the destination and deployment URL before contacting
that host. While pending, it blocks new dashboard tokens and connection/link
changes. The host atomically changes the organization and increments the
version, invalidating old tokens, including tokens from an earlier visit to the
same organization. Ordinary reader/admin/public tokens cannot transfer: only the
server-issued destination-bound intent can invoke the mutation.

If a network response is lost, use **Retomar transferência** for the same
destination. The host recognizes the already-applied generation and the control
plane finishes exactly once. There is no automatic rollback or cancel button:
the remote write may already have committed. The destination cannot be changed
while pending. If the host becomes unavailable, restore it and retry; the local
tracker keeps collecting while dashboard access awaits completion. Retry needs
an administrator in both organizations. If that membership was removed during an
interruption, restore it or ask an administrator who manages both to resume. Do
not clear the pending fields or decrement the grant version manually.

## Move a host to a new deployment

When a host's data moves to a new deployment (a storage migration, a new region)
its URLs change, but the connection does not have to. Re-pairing would create a
new connection and lose its public links and site selections; the control-plane
operator repoints the existing one instead:

1. Copy the host's data with a snapshot export and import, so site IDs and the
   local `rastroFederationGrants` rows (including `connectionId`) survive.
2. On the new deployment set `RASTRO_FEDERATION_ISSUER` to the same issuer and
   `RASTRO_FEDERATION_AUDIENCE` to its **new** functions URL, then push.
3. If the new functions URL is a custom domain, add it to the control plane's
   `RASTRO_FEDERATION_ALLOWED_DEPLOYMENT_ORIGINS`.
4. Repoint the connection with the control plane's admin credentials:

   ```bash
   npx convex run controlPlane:repointConnection \
     '{"connectionId":"<id>","deploymentUrl":"<new functions URL>","siteUrl":"<new HTTP URL>"}'
   ```

   The connection keeps its id, status and public links, and the audit trail
   records `repointed` with the old and new URLs.

5. Point the site's tracker (`/rastro/tracker.js`) at the new HTTP URL, then use
   **Verificar novamente** in the dashboard.

Give a host a custom domain for its functions and HTTP URLs when you can: the
next move then only reattaches the domain, and the URLs never change.
