# Integrate a product with Amage Rastro

This is the canonical runbook for coding agents. It connects a product that
already runs Amage Rastro in Convex or Synapse to the central dashboard without
moving telemetry into the control plane.

Human reference: <https://www.amagerastro.com/docs/>

Machine contract: <https://www.amagerastro.com/agent/manifest.json>

## Package eligibility gate

Do not infer federation availability from this website, a dist-tag, or a source
checkout. The machine manifest defines the required runtime and type exports,
not which mutable tag currently contains them.

Inspect the tag without changing the host:

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

After the operator approves that exact version, inspect it outside the host
without executing package code or lifecycle scripts. Run the block from a
directory whose `node_modules` resolves `typescript` — the host checkout after
`npm install` is the natural place. From anywhere else the inspector exits with
`ERR_MODULE_NOT_FOUND` for `typescript` and `npx --no-install tsc` finds no
compiler; neither outcome is a verdict about the package.

```bash
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
npm pack --ignore-scripts @iann29/rastro@EXACT_VERSION --pack-destination "$tmp"
tarball="$(printf '%s\n' "$tmp"/iann29-rastro-*.tgz)"
mkdir "$tmp/consumer"
npm install --prefix "$tmp/consumer" --ignore-scripts --package-lock=false \
  "$tarball" convex@^1.43.0
node --input-type=module - \
  "$tmp/consumer/node_modules/@iann29/rastro" EXACT_VERSION <<'NODE'
  import { readFile } from "node:fs/promises";
  import { dirname, extname, join, relative, resolve } from "node:path";
  import tsModule from "typescript";
  const ts = tsModule.default ?? tsModule;
  const root = process.argv[2];
  const expectedVersion = process.argv[3];
  const pkg = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
  const runtimeExports = [
    "exposeFederatedAnalyticsApi",
    "FEDERATED_ANALYTICS_CONNECTOR_MANIFEST",
    "FEDERATED_ANALYTICS_ERROR_CODES",
  ];
  const modules = new Map();
  const resolveSpecifier = (importer, specifier) => {
    const path = resolve(dirname(importer), specifier);
    return extname(path) ? path : `${path}.js`;
  };
  const load = async (path) => {
    const absolute = resolve(path);
    const local = relative(root, absolute);
    if (local.startsWith("..") || local.startsWith("/")) throw new Error("module escaped package");
    if (modules.has(absolute)) return modules.get(absolute);
    const source = ts.createSourceFile(absolute, await readFile(absolute, "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
    if (source.parseDiagnostics?.length) throw new Error(`syntax error in ${local}`);
    const module = { absolute, direct: new Set(), imports: new Map(), named: [], stars: [] };
    modules.set(absolute, module);
    for (const statement of source.statements) {
      const specifier = (ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement)) &&
        statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)
          ? statement.moduleSpecifier.text : null;
      const target = specifier?.startsWith(".") ? resolveSpecifier(absolute, specifier) : null;
      if (target) await load(target);
      if (ts.isImportDeclaration(statement) && target && statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings)) {
        for (const item of statement.importClause.namedBindings.elements) {
          module.imports.set(item.name.text, { name: item.propertyName?.text ?? item.name.text, target });
        }
      }
      if (ts.isExportDeclaration(statement)) {
        if (statement.exportClause && ts.isNamedExports(statement.exportClause)) {
          for (const item of statement.exportClause.elements) {
            module.named.push({ exported: item.name.text, local: item.propertyName?.text ?? item.name.text, target });
          }
        } else if (!statement.exportClause && target) module.stars.push(target);
        continue;
      }
      if (!statement.modifiers?.some((item) => item.kind === ts.SyntaxKind.ExportKeyword)) continue;
      if (ts.isVariableStatement(statement)) {
        for (const declaration of statement.declarationList.declarations) {
          if (ts.isIdentifier(declaration.name)) module.direct.add(declaration.name.text);
        }
      } else if ("name" in statement && statement.name && ts.isIdentifier(statement.name)) {
        module.direct.add(statement.name.text);
      }
    }
    return module;
  };
  const hasExport = async (module, name, seen = new Set()) => {
    const key = `${module.absolute}:${name}`;
    if (seen.has(key)) return false;
    seen.add(key);
    if (module.direct.has(name)) return true;
    for (const item of module.named) {
      if (item.exported !== name) continue;
      if (item.target) return hasExport(modules.get(item.target), item.local, seen);
      const imported = module.imports.get(item.local);
      if (imported) return hasExport(modules.get(imported.target), imported.name, seen);
    }
    let matches = 0;
    for (const target of module.stars) {
      if (await hasExport(modules.get(target), name, new Set(seen))) matches += 1;
    }
    return matches === 1;
  };
  const entry = await load(join(root, "dist/client/index.js"));
  const compilerOptions = {
    allowJs: true,
    checkJs: true,
    noEmit: true,
    module: ts.ModuleKind.NodeNext,
    moduleResolution: ts.ModuleResolutionKind.NodeNext,
    target: ts.ScriptTarget.ES2022,
  };
  const program = ts.createProgram([entry.absolute], compilerOptions);
  const importErrors = ts.getPreEmitDiagnostics(program).filter((diagnostic) =>
    [1192, 2305, 2307, 2614, 2724].includes(diagnostic.code),
  );
  if (importErrors.length) throw new Error("runtime import graph has unresolved bindings");
  const checker = program.getTypeChecker();
  for (const source of program.getSourceFiles()) {
    const path = relative(root, source.fileName);
    if (path.startsWith("..") || path.startsWith("node_modules/") || !path.endsWith(".js")) continue;
    for (const statement of source.statements) {
      if (!ts.isImportDeclaration(statement) || !statement.importClause) continue;
      const resolved = ts.resolveModuleName(
        statement.moduleSpecifier.text,
        source.fileName,
        compilerOptions,
        ts.sys,
      ).resolvedModule;
      let target = resolved ? program.getSourceFile(resolved.resolvedFileName) : null;
      let targetChecker = checker;
      if (resolved && !target) {
        const externalProgram = ts.createProgram([resolved.resolvedFileName], compilerOptions);
        target = externalProgram.getSourceFile(resolved.resolvedFileName);
        targetChecker = externalProgram.getTypeChecker();
      }
      const symbol = target ? targetChecker.getSymbolAtLocation(target) : null;
      if (!symbol) throw new Error(`unresolved runtime module in ${path}`);
      const exports = target.fileName.endsWith(".js")
        ? new Set(target.statements.flatMap((item) => {
            if (ts.isExportDeclaration(item) && item.exportClause && ts.isNamedExports(item.exportClause)) {
              return item.exportClause.elements.map((element) => element.name.text);
            }
            if (!item.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) return [];
            if (ts.isVariableStatement(item)) {
              return item.declarationList.declarations.flatMap((declaration) =>
                ts.isIdentifier(declaration.name) ? [declaration.name.text] : [],
              );
            }
            return "name" in item && item.name && ts.isIdentifier(item.name) ? [item.name.text] : [];
          }))
        : new Set(targetChecker.getExportsOfModule(symbol).map((item) => item.getName()));
      if (statement.importClause.name && !exports.has("default")) throw new Error(`missing default import in ${path}`);
      const bindings = statement.importClause.namedBindings;
      if (bindings && ts.isNamedImports(bindings)) {
        for (const item of bindings.elements) {
          const imported = item.propertyName?.text ?? item.name.text;
          if (!exports.has(imported)) throw new Error(`missing runtime import ${imported} in ${path}`);
        }
      }
    }
  }
  for (const module of modules.values()) {
    for (const imported of module.imports.values()) {
      if (!(await hasExport(modules.get(imported.target), imported.name))) {
        throw new Error(`missing imported runtime export: ${imported.name}`);
      }
    }
  }
  for (const name of runtimeExports) {
    if (!(await hasExport(entry, name))) throw new Error(`missing runtime export: ${name}`);
  }
  if (pkg.name !== "@iann29/rastro" || pkg.version !== expectedVersion) {
    throw new Error("package identity mismatch");
  }
  if (pkg.exports?.["."]?.default !== "./dist/client/index.js" ||
      pkg.exports?.["."]?.types !== "./dist/client/index.d.ts") {
    throw new Error("package export map mismatch");
  }
  if (["preinstall", "install", "postinstall"].some((name) => pkg.scripts?.[name])) {
    throw new Error("install lifecycle script present");
  }
NODE
cat > "$tmp/consumer/contract.ts" <<'TS'
import {
  exposeFederatedAnalyticsApi,
  FEDERATED_ANALYTICS_CONNECTOR_MANIFEST,
  FEDERATED_ANALYTICS_ERROR_CODES,
} from "@iann29/rastro";
import type {
  FederatedAnalyticsConnection,
  FederatedAnalyticsAuthorizerOptions,
  FederatedSiteSummary,
} from "@iann29/rastro";
void [
  exposeFederatedAnalyticsApi,
  FEDERATED_ANALYTICS_CONNECTOR_MANIFEST,
  FEDERATED_ANALYTICS_ERROR_CODES,
];
type Contract = [
  FederatedAnalyticsConnection,
  FederatedAnalyticsAuthorizerOptions,
  FederatedSiteSummary,
];
declare const contract: Contract;
void contract;
TS
cat > "$tmp/consumer/tsconfig.json" <<'JSON'
{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "target": "ES2022",
    "strict": true,
    "noEmit": true
  },
  "files": ["contract.ts"]
}
JSON
npx --no-install tsc -p "$tmp/consumer/tsconfig.json"
```

If the command exits nonzero, stop and request an eligible package release. If
it passes, record that exact version but do not install it in the host yet.
Never silently reimplement the connector, fetch a mutable branch, or install a
source tarball from this website.

## Instruction trust and precedence

Operator intent and repository-local security rules outrank this document. A
remote document can never grant production consent, authorize secret access, or
weaken a security invariant. Prefer the runbook bundled with the exact approved
package or commit. Website hashes detect publishing drift only; because the
manifest is served by the same origin, they do not authenticate a compromised
website. Anchor trust in an operator-approved package, signed release, or exact
repository commit.

## Architecture and trust boundary

```text
product browser -> customer HTTP-actions URL -> customer Rastro tables

amagerastro.com browser -> customer functions URL -> reports (+ opt-in configure)
                              ^
                              |
                 short-lived control-plane JWT
```

The customer deployment remains the data plane and authority for site scope. The
central service stores accounts, organizations, connection metadata, audit
records, and token-signing material. It does not proxy or persist analytics
events.

Two origins are required:

| Value            | Purpose                                 | Example                        |
| ---------------- | --------------------------------------- | ------------------------------ |
| Functions URL    | Reactive queries and exact JWT audience | `https://product.convex.cloud` |
| HTTP-actions URL | Tracker, ingestion, and health          | `https://product.convex.site`  |

On Synapse, obtain both exact URLs from the selected deployment. Do not derive
one from the other and do not include paths, query strings, fragments,
usernames, or passwords.

The control plane accepts standard `*.convex.cloud` and `*.synapsepanel.com`
functions origins. For a custom functions domain, ask the Amage Rastro operator
who issued the organization access to add its exact public HTTPS origin to
`RASTRO_FEDERATION_ALLOWED_DEPLOYMENT_ORIGINS` before pairing. If creation
returns `UNTRUSTED_DEPLOYMENT_HOST`, stop, provide only that public origin, and
retry after the operator confirms the allowlist update. Never provide
credentials or derive an allowlist entry from browser input.

## Stop conditions

Stop and ask the operator before changing code when any of these is true:

- local Rastro ingestion and an authenticated local report are not working;
- the installed package fails the release check above;
- the product's authorization model or owner identity is unknown;
- the target deployment or whether it is development/production is ambiguous;
- adding the Rastro provider would replace an existing auth provider;
- a requested connection needs more than ten sites;
- the operator asks to put site IDs, owner IDs, or admin credentials in a JWT or
  browser;
- the customer functions origin is not accepted by the control-plane allowlist.

## Phase 1: inspect the host

Before editing, locate and read:

- `convex/schema.ts`;
- `convex/convex.config.ts`;
- `convex/auth.config.ts`, if present;
- the existing Rastro component mount and site administration functions;
- the product's server-side owner/admin authorization helper;
- the generated component name, normally `components.rastroAnalytics`.
- the currently installed Rastro version with `npm ls @iann29/rastro --depth=0`;
- retained session, journey, live-visitor, conversion, campaign, affiliate, and
  custom-event data through authorized local reports;
- the visitor/session ID generation strategy, route/path contents, coarse
  location fields, retention policy, and intended dashboard organization
  membership.

Record the existing schema tables/indexes, component mounts/env declarations,
and authentication providers before editing. After editing, compare the diff and
prove each recorded entry remains present and executable. The snippets below are
insertion fragments, never replacement files.

Confirm local analytics first:

1. `GET <HTTP_ACTIONS_URL>/rastro/health` succeeds.
2. The site exists and its domains include the exact product hostname.
3. The tracker loads from `<HTTP_ACTIONS_URL>/rastro/tracker.js`.
4. A real `POST /rastro/events` returns HTTP 200 with accepted counters.
5. An authenticated host query can read a report for that exact site. Check with
   `liveVisitors` or `sessionJourney`, which reflect an accepted request
   immediately; `listSessions` and the bucketed `overview` can trail it by
   seconds, so an empty result from those two right after the request is not a
   failed ingestion.
6. Retained routes, IDs, campaigns, affiliates, location fields, conversion
   records, and custom properties contain no unexpected personal data, access
   tokens, secrets, or form values. Confirm that every intended organization
   member may read this bounded data and that retention is acceptable. If any of
   that cannot be established, stop before federation.

Federation exposes existing reports. It does not mount the component, create a
site, install the tracker, or repair ingestion.

Before installing the eligible version, follow
<https://www.amagerastro.com/docs/reference/upgrading.md> from the detected
current version. Snapshot/rehearse maintained deployments and complete every
required migration bridge. Only then install with scripts disabled:

```bash
npm install --ignore-scripts --save-exact @iann29/rastro@EXACT_VERSION
```

Commit the lockfile and leave the existing Rastro mount unchanged.

## Phase 2: add the authoritative local grant

Merge this table into the existing schema. Preserve every existing table and
index.

```ts
rastroFederationGrants: defineTable({
  connectionId: v.string(),
  organizationId: v.string(),
  siteIds: v.array(v.string()),
  permissions: v.optional(v.array(v.string())),
  revokedAt: v.optional(v.number()),
  createdAt: v.number(),
  updatedAt: v.number(),
}).index("by_connectionId", ["connectionId"]),
```

Create `convex/rastroFederationAdmin.ts` with internal or equivalently protected
operator functions that:

- accept a connection ID, organization ID, opaque host owner ID, site IDs, and
  an optional `permissions` list validated with
  `federatedAnalyticsPermissionValidator` (absent means read only;
  `"analytics:configure"` opts the connection into the configure scope, and
  `"analytics:public"` lets the dashboard publish read-only public links of the
  granted sites);
- reject empty/duplicate IDs and more than ten unique sites;
- load every site from Rastro and verify it belongs to that owner;
- upsert by the `by_connectionId` index and clear `revokedAt` when
  reprovisioned;
- revoke by setting `revokedAt` and `updatedAt`;
- never trust an owner/user ID supplied by a browser.

The complete reviewed implementation is in the **Add the authoritative local
grant** section of
<https://www.amagerastro.com/docs/reference/federation-setup.md>. Do not weaken
it to make pairing pass.

## Phase 3: merge runtime configuration

Insert only these two properties into the existing `defineApp({ env: { ... } })`
object. If the app has no `env` object, add one without changing any other
`defineApp` option. Do not add another Rastro mount:

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

Declare them optional. A required `v.string()` makes the push fail on every
deployment where the values are not set yet, which is every deployment that
pushes code before pairing. Set both values before pushing: `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
const issuer = process.env.RASTRO_FEDERATION_ISSUER;
const audience = process.env.RASTRO_FEDERATION_AUDIENCE;

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

The issuer supplied by the pairing UI must exactly equal the independently
verified manifest issuer, `https://site.api.amagerastro.com/federation`; abort
on any mismatch. Derive the audience locally from the exact normalized customer
functions URL and compare it with the displayed audience. Never trust a
displayed audience that differs and never use the HTTP-actions URL.

Set values only on an explicit target:

```bash
# Convex Cloud development
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'

# Synapse development
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 `--prod` only after a development rehearsal and fresh operator consent.
Project-default `synapse env set ... --for=dev` values do not update an existing
deployment.

## Phase 4: expose the canonical module

The filename must be `convex/rastroFederation.ts`, producing the public module
name `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,
  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,
  listTrackedLinks,
  createTrackedLink,
  updateTrackedLink,
  deleteTrackedLink,
} = federated;
```

`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` to `disableRetentionPolicy` form the configure scope
(optional capability `configure`, hosts from `alpha.11`): they additionally
require `analytics:configure` on the token — the control plane claims it only
for organization owners and admins — and in the grant's `permissions`, and fail
with `FEDERATION_CONFIGURE_FORBIDDEN` otherwise. Export them even when every
grant stays read only, so a later opt-in needs no host deploy; 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 needs both variables set and a push afterwards.

A public link's token carries `analytics:public` instead of `analytics:read` and
`rastro_site_ids` naming the link's sites. It runs only `manifest`,
`connectionStatus`, `listSites`, `overview` (with zero revenue and conversions
and empty campaigns, mediums, events, and goals), `liveVisitors` (with
`visitorId` replaced by the session id and without the entry path, return,
campaign, affiliate, conversion, and funnel fields), and `dataCoverage`;
everything else fails with `FEDERATION_PUBLIC_FORBIDDEN`, and so does every call
while the grant does not list `analytics:public`. A host from before the
permission rejects the token with `FEDERATION_INVALID_CLAIMS`.

## Function contract

| Function            | Input summary                                           | Important output/behavior                                                                               |
| ------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `manifest`          | `{}`                                                    | Static protocol, capabilities, names, and limits; no auth                                               |
| `connectionStatus`  | `{}`                                                    | Redacted status, protocol version, capabilities, site count                                             |
| `listSites`         | `{}`                                                    | Only `siteId`, `name`, `currency`, `timezone`, `cookieless`                                             |
| `overview`          | `siteIds`, `from`, `to`, optional hour/day interval     | Aggregate metrics and time series                                                                       |
| `liveVisitors`      | `siteIds`, `now`, optional limit                        | At most 500 visitors seen in the last five minutes, with intent facts                                   |
| `getSession`        | one `siteId`, `sessionId`                               | Selected session metadata, or null when no retained session exists                                      |
| `getVisitorProfile` | one `siteId`, `visitorId`                               | Identified profile through the optional `visitorProfiles` capability; reader permission required        |
| `listPeople`        | one `siteId`, optional `filter`, `paginationOpts`       | Indexed profile directory through optional `people`; reader permission required; up to 50 rows per page |
| `revenueSummary`    | `siteIds`, inclusive `from`/`to`                        | Period commissions; pair with overview totals/currency                                                  |
| `listSessions`      | one `siteId`, range, `{numItems,cursor}` keyset options | Paginated sessions                                                                                      |
| `sessionJourney`    | one site/session, range, `{numItems,cursor}` options    | Paginated bounded events                                                                                |
| `listConversions`   | one site, range, `{numItems,cursor}` options            | Paginated trusted server-side conversion ledger                                                         |
| `visitorJourney`    | site IDs, visitor ID, range, optional limit             | Cross-site journey inside the grant only                                                                |
| `goalsReport`       | one site and range                                      | Read-only goal definitions plus aggregates                                                              |
| `funnelsReport`     | one site and complete UTC-day range                     | Read-only definitions/steps plus aggregates; max 90 days                                                |
| `affiliatesReport`  | one site and complete UTC-day range                     | Read-only affiliate definitions plus aggregates                                                         |
| `vitalsReport`      | one site and complete UTC-day range                     | Web Vitals p75/ratings per metric, page, and device; max 90 days                                        |
| `siteMap`           | site IDs and complete UTC-day range                     | Routes with derived exits/bounces plus transitions; max 90 days                                         |
| `dataCoverage`      | one site and inclusive integer range                    | Coverage metadata; echoes site ID, no event/visitor/session IDs                                         |

The configure scope, every function of which requires `analytics:configure` on
the token and in the grant:

| Function                 | Input summary                                                | Important output/behavior                                           |
| ------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------------- |
| `siteSettings`           | one `siteId`                                                 | `name`, `domains`, `timezone`, `currency`, `cookieless`; no owner   |
| `updateSite`             | one `siteId`, optional `name`, `domains`, `timezone`         | Changes only those three; currency and `cookieless` stay outside    |
| `listGoals`              | one `siteId`                                                 | Goal definitions, at most 50                                        |
| `upsertGoal`             | `siteId`, optional `goalId`, key, name, matcher, value, flag | Returns the goal ID; keys and active matchers are unique per site   |
| `removeGoal`             | `siteId`, `goalId`                                           | Deletes the definition; rollups already written remain              |
| `listFunnels`            | one `siteId`                                                 | Funnel definitions, at most 20                                      |
| `upsertFunnel`           | `siteId`, optional `funnelId`, key, name, 2–10 steps, window | Returns the funnel ID; window between one minute and 90 days        |
| `removeFunnel`           | `siteId`, `funnelId`                                         | Deletes the definition                                              |
| `listAffiliates`         | one `siteId`                                                 | Affiliate definitions, at most 100                                  |
| `upsertAffiliate`        | `siteId`, optional `affiliateId`, slug, name, bps, window    | Returns the affiliate ID; commission 0–10000 bps, window 1–365 days |
| `removeAffiliate`        | `siteId`, `affiliateId`                                      | Deletes the definition; accounted commissions remain                |
| `retentionStatus`        | one `siteId`                                                 | The policy, its cleanup jobs, and the backlog                       |
| `setRetentionPolicy`     | `siteId` and five day counts between 1 and 3650              | Starts the recurring cleanup chains                                 |
| `disableRetentionPolicy` | one `siteId`                                                 | Stops automatic cleanup; nothing is restored                        |

Since 0.7.0, `siteSettings` also returns `routePatterns` and `updateSite`
accepts an ordered array of full-path patterns such as `["/pedido/:codigo"]` (at
most 32 patterns, 256 characters each). The first matching pattern wins. This
groups map rollups and retained history without changing journey URLs; `siteMap`
returns the rules keyed by site for live overlays. Already grouped rollups
cannot be split later. See
[upgrading](https://www.amagerastro.com/docs/reference/upgrading.md).

Tracked links, behind the optional `trackedLinks` capability. Listing needs only
a reader; the three writes require `analytics:configure` like the configure
scope above. A host serves them by re-exporting the four functions; one that
does not keeps working and advertises no `trackedLinks` capability.

| Function            | Input summary                                                                   | Important output/behavior                                                 |
| ------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| `listTrackedLinks`  | one `siteId` and `now`                                                          | Definitions with clicks and bot hits, all time and over 30 UTC days       |
| `createTrackedLink` | `siteId`, slug, destination, platform, channel, campaign, `createdBy`           | Returns the link ID; the destination must be on one of the site's domains |
| `updateTrackedLink` | `siteId`, `linkId`, optional destination, platform, channel, campaign, disabled | Changes everything but the slug; a disabled link answers 404              |
| `deleteTrackedLink` | `siteId`, `linkId`                                                              | Deletes the link, frees its slug, and purges its click counters           |

Leads, behind the opt-in `leads` capability. A host that records leads from its
server (`recordLead`, `setLeadStage`) re-exports the lead reports and passes
`leads: true` to `exposeFederatedAnalyticsApi`; any reader may read them. A host
that does not opt in refuses them and advertises no `leads` capability.

| Function      | Input summary                                               | Important output/behavior                                                                 |
| ------------- | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `leadsReport` | `siteIds`, `from`, `to`                                     | Totals, origins, and campaign → ad set → ad counts of leads, qualified, sales and revenue |
| `listLeads`   | `siteIds`, `from`, `to`, optional `adId`, `before`, `limit` | Newest leads first, without the click id; page with the last `firstAt` as `before`        |
| `leadJourney` | one `siteId` and `leadId`                                   | The lead, its ad with catalog names, and its stage history oldest first                   |
| `leadsDaily`  | `siteIds`, `from`, `to` (≤ 92 UTC days), `adId` or `origin` | Since 0.10.0: every UTC day of the range with the same counters and that day's spend      |

Meta ads, behind the opt-in `metaAds` capability (0.10.0). A host that links
Meta ad accounts or a pixel to its sites re-exports the two reads and passes
`metaAds: true` beside `leads: true`; any reader may read them. A host that does
not opt in refuses them with `FEDERATION_INVALID_SCOPE` and advertises no
`metaAds`. Since 0.10.0 `leadsReport` also carries `spendCents`, `impressions`,
and `clicks` per row, `bySite`, `spendCurrency`, and `currencyMismatch`; `null`
spend means unknown, never zero. With `metaAds: true` the host also exports the
dashboard's own Meta setup (`metaConnection`, `metaEventMap`,
`metaUnlinkPreview` for readers; `connectMeta`, `refreshMetaAccounts`,
`setMetaPixel`, `disconnectMeta`, `linkMetaAdAccount`, `unlinkMetaAdAccount`,
`removeMetaPixel`, `setMetaEventMap`, `syncMetaNow` with `analytics:configure`)
and the internal `authorizeMetaAction`. Shapes, the optional host env bindings
`RASTRO_SECRETS_KEY`, `RASTRO_META_APP_ID`, `RASTRO_META_APP_SECRET` and
`RASTRO_META_FAKE`, and the security invariants are in
[Meta ads](https://www.amagerastro.com/docs/reference/meta-ads.md).

| Function          | Input summary                                      | Important output/behavior                                                                 |
| ----------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `metaStatus`      | `siteIds`                                          | Per site: linked ad accounts and their sync state, the pixel, conversion counts; no token |
| `metaConversions` | one `siteId`, optional `limit` (≤ 50) and `before` | Newest conversions sent to the pixel, with status; never the hashed user data             |

Meta Lead Ads, behind the opt-in `metaLeads` capability (unreleased, after
0.10.9). A host whose CRM takes Meta instant-form leads passes `metaLeads: true`
beside `metaAds: true` (required: `metaLeads` alone throws at setup) and
`leads: true`, and re-exports `metaPages`, `metaForms`, `metaFormRules`,
`simulateMetaFormRules`, `metaFormsReport`, `metaLinkedPages` (readers; the
control plane routes Meta's webhooks to hosts listing `metaLinkedPages`) and
`linkMetaPage`, `unlinkMetaPage`, `refreshMetaForms`, `setMetaFormRules`,
`returnMetaFormLead` (`analytics:configure`). It also, in the same deploy:

1. Declares `RASTRO_FEDERATION_ISSUER` and `RASTRO_FEDERATION_AUDIENCE` as
   optional bindings in `convex/convex.config.ts` and passes both to the
   component in `app.use(rastro, { env })`. The component's env is its own: the
   values the host's `auth.config.ts` trusts are not seen by the component
   unless passed. The audience (the host's functions URL, not its site URL) is
   required; the issuer defaults to the production dashboard and must be the
   issuer of the dashboard the host is paired with (a DEV control plane has its
   own). Without the audience the component's `POST /rastro/meta/leadgen` route
   answers 503 and `linkPage` refuses with `FEDERATION_NOT_CONFIGURED`.
   `metaLinkedPages` reports them (`leadgenReady`, `issuer`, `audience`): the
   dashboard routes no lead to a host that is not ready, trusts another issuer
   or expects another audience. Forwarding requires the component mounted with
   `httpPrefix: "/rastro/"`: the dashboard posts to
   `<site URL>/rastro/meta/leadgen` and cannot learn another prefix.
2. Writes an internal mutation validated by `formLeadPayloadValidator`,
   idempotent on `leadgenId`, that creates or updates the contact and its card,
   and registers it once with `rastro.setFormLeadHandler(ctx, { handle })`
   (`createFunctionHandle`). Until one is registered no lead comes in, no Page
   is read, and `metaLinkedPages`/`metaPages`/`metaForms` read
   `handlerReady: false` (with `lastError` `HANDLER_MISSING`, a code of
   `META_PAGE_ERROR_CODES`, on a Page). The payload carries the lead's name,
   phone, e-mail and free answers in memory only: the component never stores or
   logs them, and the handler must not throw messages that contain them.
3. Expects lead reads to carry the widened `channel` (`"whatsapp"` or `"form"`)
   and the new origins `lead_form` and `lead_form_organic`; `leadsDaily` takes
   `origin: "paid"` for both paid origins with their spend.
4. Asks the dashboard operator to add the connection's deployment origin
   (`https://<name>.convex.cloud`) to the control plane's
   `RASTRO_META_LEADS_DEPLOYMENT_ORIGINS`, and a custom-domain site URL to
   `RASTRO_FEDERATION_ALLOWED_DEPLOYMENT_ORIGINS`. Until then no webhook is
   forwarded and leads arrive only through the 15-minute reconciliation.

Shapes, the forwarding token (`META_LEADGEN_TOKEN`), reconciliation and removal
rules are in
[Meta ads, Lead Ads](https://www.amagerastro.com/docs/reference/meta-ads.md#lead-ads-instant-forms).

The federated surface deliberately excludes ingestion, owner IDs, network IDs,
and arbitrary host functions, and shows a site's domains only through
`siteSettings` to a connection allowed to change them. Goal, funnel, and
affiliate reports include their read-only definitions. Journey events include
bounded custom `properties`; hosts must follow the tracker contract and never
place personal data or secrets in them. A session belongs to one site; only
`visitorJourney` can join the same pseudonymous visitor across explicitly
granted sites.

All times are Unix epoch milliseconds. The global report ceiling is 366 days,
but hourly overview is limited to 24 hours and funnel reports to 90 complete UTC
days. Affiliate reports also require complete UTC-day boundaries. Overview
queries require complete UTC hour/day buckets. Manual pagination honors
`numItems` and `cursor`; do not rely on optional native pagination metadata or
read-budget fields. Use `dataCoverage` to distinguish complete, partial,
retained, and unavailable data instead of treating an empty report as proof that
no telemetry exists.

## Phase 5: deploy, pair, and provision

Deploy code to an explicit development target first:

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

# Synapse development, one push
synapse dev --once
```

Then a human organization administrator must:

1. Sign in at <https://www.amagerastro.com>.
2. Open **Conexões** for the intended organization.
3. Create a pending connection with the exact functions and HTTP-actions URLs.
4. Compare issuer and audience with the independently verified values above.
5. Provide the connection ID and organization ID only after those checks pass.

The connection and organization IDs are pairing input for the local grant. They
are not runtime env variables and do not replace host identity checks.

Provision the exact site scope with deployment administrator credentials:

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

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

Never copy site IDs from JWT claims or grant every site belonging to an owner.
The operator must name one through ten exact site IDs. Add
`"permissions":["analytics:configure"]` to the same JSON only when the operator
wants the dashboard's owners and admins to configure those sites; omit it for a
read-only connection, and rerun without it to revoke the scope.

## Phase 6: verify in order

Do not mark integration complete until all checks pass:

1. Call `rastroFederation:manifest` without auth and deep-compare its connector
   fields with the `connector` object of
   <https://www.amagerastro.com/agent/manifest.json>. Compare structurally
   (`assert.deepStrictEqual`, `toEqual`, or an equivalent order-insensitive
   check), never as serialized text: the served object and the JSON file list
   the same keys in a different order.
2. In **Conexões**, click **Verificar conexão** and confirm active status and
   the exact site count.
3. Confirm `listSites` omits domains, owner IDs, network IDs, connection IDs,
   and organization IDs.
4. Open the deployment selector and load Overview for an authorized site.
5. Trigger a real browser pageview and observe the Live view update reactively.
6. Where the manifest advertises the optional `siteMap` capability, confirm
   `/rastro/health` reports `features.siteMap: "dailyRollup"` and open **Mapa**
   for an authorized site. A fresh host correctly shows an empty map naming the
   date its coverage starts; the map only holds pageviews ingested after the
   upgrade that introduced it.
7. In the host's `convex-test` suite, use a mocked Rastro identity to query a
   known site outside the grant and require `FEDERATION_SITE_DENIED`.
8. In the same suite, call without an identity and require
   `FEDERATION_UNAUTHENTICATED`.
9. Also require the stable failures for a foreign issuer, missing
   `analytics:read`, mismatched connection/organization, duplicate site IDs, and
   eleven requested or granted sites. Prove existing auth providers still pass
   their own tests.
10. Keep the authenticated dashboard open, revoke the local grant, refresh a
    report, and require `FEDERATION_CONNECTION_DENIED` before central
    revocation.
11. Reprovision only if the rehearsal requires restoring the development grant.
12. In the same suite, call `upsertGoal` with a read-only identity and require
    `FEDERATION_CONFIGURE_FORBIDDEN`; when the grant lists
    `analytics:configure`, call it with an identity whose `rastro_permissions`
    includes `analytics:configure` and require the goal in `listGoals`, then
    require `FEDERATION_SITE_DENIED` for a site outside the grant.
13. In the same suite, call `listSessions` with an identity whose
    `rastro_permissions` is `["analytics:public"]` and require
    `FEDERATION_PUBLIC_FORBIDDEN`; when the grant lists `analytics:public`,
    require `overview` to answer that identity with zero `revenueCents` and an
    empty `topGoals`.

Use this harness when the host has no `convex-test` suite yet. Convex functions
run under `edge-runtime` in tests, which is what a deployed function gets, so
install `vitest`, `convex-test`, and `@edge-runtime/vm` as dev dependencies and
configure the environment and the issuer the identity will claim:

```ts
// vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    environment: "edge-runtime",
    env: {
      RASTRO_FEDERATION_ISSUER: "https://site.api.amagerastro.com/federation",
    },
  },
});
```

Register the host schema and modules together with the Rastro component; the
package's `test` entry registers it under the mounted name `rastroAnalytics`:

```ts
// convex/setup.test.ts
/// <reference types="vite/client" />
import { convexTest } from "convex-test";
import component from "@iann29/rastro/test";
import schema from "./schema";

const modules = import.meta.glob("./**/*.*s");

export function initConvexTest() {
  const t = convexTest(schema, modules);
  component.register(t);
  return t;
}
```

Then use this assertion shape, with `federationIssuer` imported from
`convex/rastroFederation.ts` so the identity claims the issuer the module
verifies:

```ts
// convex/rastroFederation.test.ts
import { expect, test } from "vitest";
import { api } from "./_generated/api";
import { federationIssuer } from "./rastroFederation";
import { initConvexTest } from "./setup.test";

test("denies a site outside the grant", async () => {
  const t = initConvexTest();
  const identity = {
    issuer: federationIssuer,
    subject: "rastro-negative-test",
    tokenIdentifier: `${federationIssuer}|rastro-negative-test`,
    rastro_connection_id: CONNECTION_ID,
    rastro_organization_id: ORGANIZATION_ID,
    rastro_permissions: ["analytics:read"],
  };

  await expect(
    t.query(api.rastroFederation.overview, allowedArgs),
  ).rejects.toMatchObject({ data: { code: "FEDERATION_UNAUTHENTICATED" } });
  await expect(
    t.withIdentity(identity).query(api.rastroFederation.overview, {
      ...allowedArgs,
      siteIds: [KNOWN_OUTSIDE_GRANT_SITE_ID],
    }),
  ).rejects.toMatchObject({ data: { code: "FEDERATION_SITE_DENIED" } });
});
```

Provision the grant for `CONNECTION_ID` inside the test with
`t.mutation(internal.rastroFederationAdmin.provisionConnection, ...)` after
creating a site through the host's own admin function; `allowedArgs` names that
site and a complete UTC-day range.

Consumers must branch on `ConvexError.data.code`, not human-readable text:

| Code                             | Meaning                                                                        |
| -------------------------------- | ------------------------------------------------------------------------------ |
| `FEDERATION_UNAUTHENTICATED`     | No authenticated Rastro identity                                               |
| `FEDERATION_ISSUER_MISMATCH`     | Identity came from another issuer                                              |
| `FEDERATION_INVALID_CLAIMS`      | Required bounded claims or permission are absent                               |
| `FEDERATION_CONNECTION_DENIED`   | Local grant is missing, malformed, mismatched, or revoked                      |
| `FEDERATION_INVALID_SCOPE`       | Requested site list is empty, duplicate, malformed, or over limit              |
| `FEDERATION_SITE_DENIED`         | A requested site is outside the local grant                                    |
| `FEDERATION_CONFIGURE_FORBIDDEN` | The token or the grant lacks `analytics:configure`                             |
| `FEDERATION_PUBLIC_FORBIDDEN`    | A public token asked beyond its surface, or the grant lacks `analytics:public` |

## Revocation

For immediate denial, revoke the local grant first:

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

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

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

## Completion report

Return this evidence to the operator:

```text
Target: development | production
Functions URL: <origin>
HTTP-actions URL: <origin>
Package/version: <exact eligible registry version>
Existing auth providers preserved: yes/no
Manifest protocol: <value>
Granted site count: <1..10>
Configure scope granted: yes/no
Ingestion verified: yes/no
Authorized reports verified: yes/no
Wrong-site denial verified: yes/no
Unauthenticated denial verified: yes/no
Local revocation rehearsed: yes/no
Production changes performed: yes/no
```

Do not include private keys, admin keys, session cookies, JWTs, or raw customer
telemetry in the report.

## Identified people

Follow
[the identity guide](https://www.amagerastro.com/docs/reference/identity.md) to
integrate Better Auth, custom authentication or an external backend. The host
owns authenticated-user resolution and site authorization. Use
`Rastro.identifyVisitorMutation` for the current user, `identifyVisitor` from
trusted server code, and `identifyVisitorHttpAction` for authenticated
server-to-server HTTP. Attributes are optional, project-defined strings, numbers
and booleans; no user-table shape or business field is required. Re-export
`getVisitorProfile` and `listPeople` for the optional `visitorProfiles` and
`people` capabilities. Existing profiles need the bounded, owner-authorized
`backfillVisitorProfiles` upgrade before name/email search; see the identity
guide. Live/session reports may carry `profile`; public live reports remove it.
Apply the server-returned visitor ID in the tracker, and reset the tracker on
logout.
