@iann29/rastro 0.1.0-alpha.2 → 0.1.0-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/README.md +325 -63
  2. package/agent/integration.md +651 -0
  3. package/agent/manifest.json +183 -0
  4. package/agent/manifest.schema.json +405 -0
  5. package/dist/client/federation.d.ts +205 -0
  6. package/dist/client/federation.d.ts.map +1 -0
  7. package/dist/client/federation.js +179 -0
  8. package/dist/client/federation.js.map +1 -0
  9. package/dist/client/index.d.ts +1600 -8
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +210 -2
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/api.d.ts +9 -5
  14. package/dist/component/_generated/api.d.ts.map +1 -1
  15. package/dist/component/_generated/api.js.map +1 -1
  16. package/dist/component/_generated/component.d.ts +161 -1
  17. package/dist/component/_generated/component.d.ts.map +1 -1
  18. package/dist/component/_generated/server.d.ts +4 -0
  19. package/dist/component/_generated/server.d.ts.map +1 -1
  20. package/dist/component/_generated/server.js.map +1 -1
  21. package/dist/component/cardinality.d.ts +12 -0
  22. package/dist/component/cardinality.d.ts.map +1 -0
  23. package/dist/component/cardinality.js +94 -0
  24. package/dist/component/cardinality.js.map +1 -0
  25. package/dist/component/constants.d.ts +10 -0
  26. package/dist/component/constants.d.ts.map +1 -1
  27. package/dist/component/constants.js +10 -0
  28. package/dist/component/constants.js.map +1 -1
  29. package/dist/component/convex.config.d.ts +7 -2
  30. package/dist/component/convex.config.d.ts.map +1 -1
  31. package/dist/component/convex.config.js +9 -4
  32. package/dist/component/convex.config.js.map +1 -1
  33. package/dist/component/coverage.d.ts +18 -0
  34. package/dist/component/coverage.d.ts.map +1 -0
  35. package/dist/component/coverage.js +19 -0
  36. package/dist/component/coverage.js.map +1 -0
  37. package/dist/component/diagnostics.d.ts +9 -0
  38. package/dist/component/diagnostics.d.ts.map +1 -0
  39. package/dist/component/diagnostics.js +47 -0
  40. package/dist/component/diagnostics.js.map +1 -0
  41. package/dist/component/errors.d.ts +1 -1
  42. package/dist/component/errors.d.ts.map +1 -1
  43. package/dist/component/errors.js.map +1 -1
  44. package/dist/component/eventStore.d.ts +11 -9
  45. package/dist/component/eventStore.d.ts.map +1 -1
  46. package/dist/component/eventStore.js +55 -227
  47. package/dist/component/eventStore.js.map +1 -1
  48. package/dist/component/geo.d.ts +71 -0
  49. package/dist/component/geo.d.ts.map +1 -0
  50. package/dist/component/geo.js +610 -0
  51. package/dist/component/geo.js.map +1 -0
  52. package/dist/component/http.d.ts.map +1 -1
  53. package/dist/component/http.js +168 -17
  54. package/dist/component/http.js.map +1 -1
  55. package/dist/component/ingest.d.ts.map +1 -1
  56. package/dist/component/ingest.js +272 -32
  57. package/dist/component/ingest.js.map +1 -1
  58. package/dist/component/live.d.ts.map +1 -1
  59. package/dist/component/live.js +6 -3
  60. package/dist/component/live.js.map +1 -1
  61. package/dist/component/reports.d.ts +117 -8
  62. package/dist/component/reports.d.ts.map +1 -1
  63. package/dist/component/reports.js +641 -79
  64. package/dist/component/reports.js.map +1 -1
  65. package/dist/component/retention.d.ts +75 -1
  66. package/dist/component/retention.d.ts.map +1 -1
  67. package/dist/component/retention.js +517 -54
  68. package/dist/component/retention.js.map +1 -1
  69. package/dist/component/sanitize.d.ts +4 -1
  70. package/dist/component/sanitize.d.ts.map +1 -1
  71. package/dist/component/sanitize.js +9 -4
  72. package/dist/component/sanitize.js.map +1 -1
  73. package/dist/component/schema.d.ts +202 -53
  74. package/dist/component/schema.js +113 -16
  75. package/dist/component/schema.js.map +1 -1
  76. package/dist/component/sites.d.ts.map +1 -1
  77. package/dist/component/sites.js +5 -1
  78. package/dist/component/sites.js.map +1 -1
  79. package/dist/component/validators.d.ts +29 -51
  80. package/dist/component/validators.d.ts.map +1 -1
  81. package/dist/component/validators.js +11 -12
  82. package/dist/component/validators.js.map +1 -1
  83. package/dist/tracker/generated.d.ts +4 -4
  84. package/dist/tracker/generated.d.ts.map +1 -1
  85. package/dist/tracker/generated.js +4 -4
  86. package/dist/tracker/generated.js.map +1 -1
  87. package/dist/tracker/tracker.js +7 -6
  88. package/dist/tracker/tracker.js.map +1 -1
  89. package/dist/tracker.min.js +1 -1
  90. package/docs/benchmarks/2026-08-20-realistic.md +5 -5
  91. package/docs/benchmarks/2026-08-21-formal-certification.md +333 -0
  92. package/docs/federation-setup.md +395 -0
  93. package/docs/federation.md +258 -0
  94. package/docs/upgrading.md +130 -0
  95. package/llms.txt +65 -0
  96. package/package.json +29 -7
  97. package/scripts/benchmark-ingest.mjs +81 -32
  98. package/scripts/generate-federation-keys.mjs +20 -0
  99. package/src/component/_generated/api.ts +9 -5
  100. package/src/component/_generated/component.ts +224 -1
  101. package/src/component/_generated/server.ts +4 -0
  102. package/src/component/cardinality.ts +116 -0
  103. package/src/component/constants.ts +10 -0
  104. package/src/component/convex.config.ts +11 -5
  105. package/src/component/coverage.ts +25 -0
  106. package/src/component/diagnostics.ts +64 -0
  107. package/src/component/errors.ts +2 -1
  108. package/src/component/eventStore.ts +78 -280
  109. package/src/component/geo.ts +779 -0
  110. package/src/component/http.ts +259 -22
  111. package/src/component/ingest.ts +436 -27
  112. package/src/component/live.ts +8 -3
  113. package/src/component/reports.ts +895 -87
  114. package/src/component/retention.ts +624 -88
  115. package/src/component/sanitize.ts +15 -4
  116. package/src/component/schema.ts +125 -16
  117. package/src/component/sites.ts +5 -1
  118. package/src/component/validators.ts +12 -13
  119. package/src/test.ts +0 -2
  120. package/dist/component/migrations.d.ts +0 -17
  121. package/dist/component/migrations.d.ts.map +0 -1
  122. package/dist/component/migrations.js +0 -44
  123. package/dist/component/migrations.js.map +0 -1
  124. package/src/component/migrations.ts +0 -52
@@ -0,0 +1,395 @@
1
+ # Federated dashboard setup
2
+
3
+ This runbook connects an existing Convex or Synapse project to the Amage Rastro
4
+ control plane without moving telemetry out of that project. Read
5
+ [`federation.md`](federation.md) for the protocol and threat model.
6
+
7
+ ## Package eligibility requirement
8
+
9
+ Do not infer federation availability from this website, a dist-tag, or a source
10
+ checkout. Discover the current tag without changing the host:
11
+
12
+ ```bash
13
+ npm view @iann29/rastro@alpha version
14
+ ```
15
+
16
+ After the operator approves that exact version, execute the complete static
17
+ tarball, export-map, AST, and TypeScript consumer probe in the **Package
18
+ eligibility gate** of the canonical
19
+ [`agent/integration.md`](https://www.amagerastro.com/agent/integration.md). Do
20
+ not replace it with an `import()` test, which executes package code, or a text
21
+ search, which can accept comments and broken re-exports.
22
+
23
+ If verification fails, stop and request an eligible release. If it passes,
24
+ record that exact version but do not install it yet. Detect the currently
25
+ installed version with `npm ls @iann29/rastro --depth=0`, then follow
26
+ [`upgrading.md`](upgrading.md): snapshot/rehearse maintained deployments and
27
+ complete every required migration bridge. Only then install with
28
+ `npm install --ignore-scripts --save-exact @iann29/rastro@EXACT_VERSION` and
29
+ commit the lockfile. Never fetch a mutable branch or install a source tarball
30
+ supplied by this website.
31
+
32
+ ## 1. Verify local analytics first
33
+
34
+ Do not configure federation until the customer deployment already has:
35
+
36
+ - the Rastro component mounted under `/rastro/`;
37
+ - at least one site with the correct allowed browser domains;
38
+ - a tracker using the deployment's HTTP-actions URL;
39
+ - an accepted `POST /rastro/events` request;
40
+ - a protected local report returning that site's telemetry.
41
+ - an audit through authorized local reports of retained routes, visitor/session
42
+ IDs, live location, campaigns, affiliates, conversions, and custom properties,
43
+ proving that no unexpected personal data, credentials, secrets, or form values
44
+ will become visible to the intended dashboard organization members and that
45
+ retention is acceptable.
46
+
47
+ The README quickstart covers those steps. Federation only exposes existing
48
+ reports; it does not install the tracker or create sites.
49
+
50
+ ## 2. Record both deployment URLs
51
+
52
+ The connection form requires two different base URLs with no path, query, hash,
53
+ username, or password:
54
+
55
+ | Field | Used for | Convex Cloud example | Synapse example |
56
+ | -------------- | --------------------------------- | ------------------------------ | -------------------------------------- |
57
+ | URL de funções | Reactive queries and JWT audience | `https://product.convex.cloud` | `https://product.synapse.example` |
58
+ | URL HTTP | Tracker and event ingestion | `https://product.convex.site` | `https://product.site.synapse.example` |
59
+
60
+ Custom domains are valid when they route to the corresponding Convex surface.
61
+ The control-plane operator must add each custom functions origin to
62
+ `RASTRO_FEDERATION_ALLOWED_DEPLOYMENT_ORIGINS` as an exact, comma-separated
63
+ HTTPS origin before it can be registered. Do not derive one URL by appending
64
+ paths to the other or derive the allowlist from browser input. If the dashboard
65
+ returns `UNTRUSTED_DEPLOYMENT_HOST`, stop and ask the Amage Rastro operator who
66
+ issued the organization access to add the exact functions origin. Provide only
67
+ that public HTTPS origin, never credentials; retry after the operator confirms
68
+ the allowlist update.
69
+
70
+ ## 3. Create the pending connection
71
+
72
+ In the central dashboard:
73
+
74
+ 1. Sign in and select the intended organization.
75
+ 2. Open **Conexões**.
76
+ 3. Enter a recognizable name, the functions URL, and the HTTP URL.
77
+ 4. Create the connection, but do not verify it yet.
78
+ 5. Copy the displayed issuer, audience, connection ID, and organization ID.
79
+
80
+ The audience must exactly equal the normalized functions URL. The connection
81
+ remains pending until its local grant is installed and verification succeeds.
82
+
83
+ ## 4. Add the authoritative local grant
84
+
85
+ Before editing, inventory every existing schema table/index, component mount/env
86
+ declaration, and authentication provider. After editing, prove each remains in
87
+ the diff and typechecks. The snippets in this runbook are insertion fragments,
88
+ not replacement files.
89
+
90
+ Insert this table property into the pilot project's existing
91
+ `defineSchema({ ... })` object:
92
+
93
+ ```ts
94
+ rastroFederationGrants: defineTable({
95
+ connectionId: v.string(),
96
+ organizationId: v.string(),
97
+ siteIds: v.array(v.string()),
98
+ revokedAt: v.optional(v.number()),
99
+ createdAt: v.number(),
100
+ updatedAt: v.number(),
101
+ }).index("by_connectionId", ["connectionId"]),
102
+ ```
103
+
104
+ The grant, not the JWT, owns the allowed `siteIds`. Add operator-only
105
+ provisioning functions:
106
+
107
+ ```ts
108
+ // convex/rastroFederationAdmin.ts
109
+ import { ConvexError, v } from "convex/values";
110
+ import { Rastro } from "@iann29/rastro";
111
+ import { components } from "./_generated/api";
112
+ import { internalMutation } from "./_generated/server";
113
+
114
+ const rastro = new Rastro(components.rastroAnalytics);
115
+
116
+ export const provisionConnection = internalMutation({
117
+ args: {
118
+ connectionId: v.string(),
119
+ organizationId: v.string(),
120
+ ownerId: v.string(),
121
+ siteIds: v.array(v.string()),
122
+ },
123
+ returns: v.null(),
124
+ handler: async (ctx, args) => {
125
+ const siteIds = [...new Set(args.siteIds)];
126
+ if (
127
+ args.connectionId.length === 0 ||
128
+ args.connectionId.length > 128 ||
129
+ args.organizationId.length === 0 ||
130
+ args.organizationId.length > 128 ||
131
+ siteIds.length === 0 ||
132
+ siteIds.length > 10 ||
133
+ siteIds.length !== args.siteIds.length
134
+ ) {
135
+ throw new ConvexError({ code: "INVALID_FEDERATION_GRANT" });
136
+ }
137
+
138
+ for (const siteId of siteIds) {
139
+ const site = await rastro.getSite(ctx, siteId);
140
+ if (!site || site.ownerId !== args.ownerId) {
141
+ throw new ConvexError({ code: "FEDERATION_SITE_NOT_OWNED" });
142
+ }
143
+ }
144
+
145
+ const existing = await ctx.db
146
+ .query("rastroFederationGrants")
147
+ .withIndex("by_connectionId", (query) =>
148
+ query.eq("connectionId", args.connectionId),
149
+ )
150
+ .unique();
151
+ const now = Date.now();
152
+ const grant = {
153
+ connectionId: args.connectionId,
154
+ organizationId: args.organizationId,
155
+ siteIds,
156
+ revokedAt: undefined,
157
+ updatedAt: now,
158
+ };
159
+ if (existing) {
160
+ await ctx.db.patch("rastroFederationGrants", existing._id, grant);
161
+ } else {
162
+ await ctx.db.insert("rastroFederationGrants", {
163
+ ...grant,
164
+ createdAt: now,
165
+ });
166
+ }
167
+ return null;
168
+ },
169
+ });
170
+
171
+ export const revokeConnection = internalMutation({
172
+ args: { connectionId: v.string() },
173
+ returns: v.null(),
174
+ handler: async (ctx, args) => {
175
+ const grant = await ctx.db
176
+ .query("rastroFederationGrants")
177
+ .withIndex("by_connectionId", (query) =>
178
+ query.eq("connectionId", args.connectionId),
179
+ )
180
+ .unique();
181
+ if (!grant) {
182
+ throw new ConvexError({ code: "FEDERATION_GRANT_NOT_FOUND" });
183
+ }
184
+ if (grant.revokedAt === undefined) {
185
+ const now = Date.now();
186
+ await ctx.db.patch("rastroFederationGrants", grant._id, {
187
+ revokedAt: now,
188
+ updatedAt: now,
189
+ });
190
+ }
191
+ return null;
192
+ },
193
+ });
194
+ ```
195
+
196
+ These mutations are `internalMutation`s so a browser cannot call them. Run them
197
+ only with deployment administrator credentials. A host that needs self-service
198
+ pairing may wrap equivalent logic in its own authenticated owner/admin mutation;
199
+ never accept an owner or user ID from an untrusted caller.
200
+
201
+ ## 5. Trust the federation issuer
202
+
203
+ The displayed issuer must exactly equal the independently verified production
204
+ manifest issuer, `https://site.api.amagerastro.com/federation`; abort on any
205
+ mismatch. Derive the audience locally from the normalized functions URL and
206
+ compare it with the displayed audience. For Convex Cloud DEV:
207
+
208
+ ```bash
209
+ npx convex env set --deployment dev RASTRO_FEDERATION_ISSUER \
210
+ 'https://site.api.amagerastro.com/federation'
211
+ npx convex env set --deployment dev RASTRO_FEDERATION_AUDIENCE \
212
+ 'https://product.convex.cloud'
213
+ ```
214
+
215
+ Use `--deployment prod` only with fresh production consent. For an existing
216
+ Synapse-managed deployment, target its live Convex environment explicitly:
217
+
218
+ ```bash
219
+ synapse convex --dev env set \
220
+ RASTRO_FEDERATION_ISSUER 'https://site.api.amagerastro.com/federation'
221
+ synapse convex --dev env set \
222
+ RASTRO_FEDERATION_AUDIENCE 'https://product.synapse.example'
223
+ ```
224
+
225
+ Use `synapse env set NAME=value --for=dev` only when defining project defaults
226
+ before a new deployment is created; defaults do not update an existing live
227
+ deployment.
228
+
229
+ Insert only these two properties into the pilot project's existing
230
+ `defineApp({ env: { ... } })` object. If no `env` object exists, add it without
231
+ changing any other `defineApp` option. Do not add a second Rastro mount:
232
+
233
+ ```ts
234
+ RASTRO_FEDERATION_ISSUER: v.string(),
235
+ RASTRO_FEDERATION_AUDIENCE: v.string(),
236
+ ```
237
+
238
+ Insert this conditional spread into the existing `providers` array; never
239
+ replace the array or its current entries:
240
+
241
+ ```ts
242
+ // convex/auth.config.ts
243
+ import type { AuthConfig } from "convex/server";
244
+
245
+ const issuer = process.env.RASTRO_FEDERATION_ISSUER;
246
+ const audience = process.env.RASTRO_FEDERATION_AUDIENCE;
247
+
248
+ ...(issuer && audience
249
+ ? [
250
+ {
251
+ domain: issuer.replace(/\/$/, ""),
252
+ applicationID: audience.replace(/\/$/, ""),
253
+ },
254
+ ]
255
+ : []),
256
+ ```
257
+
258
+ An exact issuer mismatch is rejected even when the JWT signature is otherwise
259
+ valid. Convex discovers keys from `<issuer>/.well-known/openid-configuration`.
260
+
261
+ ## 6. Expose the read-only host module
262
+
263
+ Create the canonical module with this exact filename:
264
+
265
+ ```ts
266
+ // convex/rastroFederation.ts
267
+ import {
268
+ exposeFederatedAnalyticsApi,
269
+ type FederatedAnalyticsConnection,
270
+ } from "@iann29/rastro";
271
+ import { components } from "./_generated/api";
272
+ import { env, type QueryCtx } from "./_generated/server";
273
+
274
+ const federated = exposeFederatedAnalyticsApi(components.rastroAnalytics, {
275
+ issuer: env.RASTRO_FEDERATION_ISSUER.replace(/\/$/, ""),
276
+ resolveConnection: async (ctx, identity) => {
277
+ const db = ctx.db as unknown as QueryCtx["db"];
278
+ const grant = await db
279
+ .query("rastroFederationGrants")
280
+ .withIndex("by_connectionId", (query) =>
281
+ query.eq("connectionId", identity.connectionId),
282
+ )
283
+ .unique();
284
+ if (!grant || grant.organizationId !== identity.organizationId) return null;
285
+ return {
286
+ connectionId: grant.connectionId,
287
+ organizationId: grant.organizationId,
288
+ siteIds: grant.siteIds,
289
+ revokedAt: grant.revokedAt,
290
+ } satisfies FederatedAnalyticsConnection;
291
+ },
292
+ });
293
+
294
+ export const {
295
+ manifest,
296
+ connectionStatus,
297
+ listSites,
298
+ overview,
299
+ liveVisitors,
300
+ listSessions,
301
+ sessionJourney,
302
+ listConversions,
303
+ visitorJourney,
304
+ goalsReport,
305
+ funnelsReport,
306
+ affiliatesReport,
307
+ dataCoverage,
308
+ } = federated;
309
+ ```
310
+
311
+ The manifest is public static metadata. Every other function is read-only and
312
+ requires both a valid JWT and a matching non-revoked local grant.
313
+
314
+ ## 7. Deploy and provision
315
+
316
+ Push the customer host changes first:
317
+
318
+ ```bash
319
+ # Convex Cloud DEV, one push
320
+ npx convex dev --once
321
+
322
+ # Synapse DEV
323
+ synapse dev --once
324
+ ```
325
+
326
+ Then provision the exact site scope. Replace every placeholder with values
327
+ copied from the pending connection and the host's existing site records:
328
+
329
+ ```bash
330
+ # Convex Cloud DEV
331
+ npx convex run --deployment dev rastroFederationAdmin:provisionConnection \
332
+ '{"connectionId":"CONNECTION_ID","organizationId":"ORGANIZATION_ID","ownerId":"OPAQUE_HOST_OWNER_ID","siteIds":["SITE_ID"]}'
333
+
334
+ # Synapse DEV
335
+ synapse convex --dev run rastroFederationAdmin:provisionConnection \
336
+ '{"connectionId":"CONNECTION_ID","organizationId":"ORGANIZATION_ID","ownerId":"OPAQUE_HOST_OWNER_ID","siteIds":["SITE_ID"]}'
337
+ ```
338
+
339
+ Use the corresponding explicit production target only after rehearsing DEV. The
340
+ command is idempotent for one `connectionId` and replaces its site scope.
341
+
342
+ ## 8. Verify in order
343
+
344
+ 1. Run `rastroFederation:manifest` without auth and confirm protocol version 1.
345
+ 2. Return to **Conexões** in the central dashboard.
346
+ 3. Click **Verificar conexão**.
347
+ 4. Confirm the connection becomes active and reports the expected site count.
348
+ 5. Open Visão geral and confirm the deployment selector names the new host.
349
+ 6. Trigger one real browser pageview and confirm Ao vivo updates reactively.
350
+ 7. Check Metas/Afiliados with a complete UTC-day range when those features are
351
+ configured.
352
+
353
+ The central verification signs a short-lived RS256 token, calls authenticated
354
+ `connectionStatus` and `listSites`, and refuses incompatible manifests or
355
+ inconsistent site counts.
356
+
357
+ ## 9. Revoke safely
358
+
359
+ For immediate denial, revoke the local grant first:
360
+
361
+ ```bash
362
+ npx convex run --deployment dev rastroFederationAdmin:revokeConnection \
363
+ '{"connectionId":"CONNECTION_ID"}'
364
+
365
+ # Synapse DEV
366
+ synapse convex --dev run rastroFederationAdmin:revokeConnection \
367
+ '{"connectionId":"CONNECTION_ID"}'
368
+ ```
369
+
370
+ For production, repeat the rehearsed command with the platform's explicit
371
+ production target: `npx convex run --deployment prod ...` or
372
+ `synapse convex --prod run ...`. Never infer the target from the current shell
373
+ state.
374
+
375
+ Then revoke the connection in the central dashboard. Central revocation stops
376
+ new tokens; local revocation rejects already-issued tokens immediately. Without
377
+ the local step, an existing token remains usable until its ten-minute expiry.
378
+
379
+ ## Troubleshooting
380
+
381
+ | Symptom or code | Check |
382
+ | ------------------------------ | ----------------------------------------------------------------------------- |
383
+ | Manifest not found | File must be `convex/rastroFederation.ts`; deploy/codegen must have completed |
384
+ | `FEDERATION_UNAUTHENTICATED` | Federation provider missing, token absent, or deployment not redeployed |
385
+ | `FEDERATION_ISSUER_MISMATCH` | `auth.config.ts` domain and helper issuer differ, often by a trailing slash |
386
+ | JWT audience failure | `RASTRO_FEDERATION_AUDIENCE` must equal the functions URL, not the HTTP URL |
387
+ | `FEDERATION_CONNECTION_DENIED` | Grant missing, organization mismatched, empty, malformed, or revoked |
388
+ | `FEDERATION_SITE_DENIED` | Dashboard requested a site outside the local grant |
389
+ | Tracker 404 | Tracker uses the functions URL instead of the HTTP-actions URL |
390
+ | `ORIGIN_NOT_ALLOWED` | Browser host is absent from the site's exact/wildcard domains |
391
+ | `REPORT_INCOMPLETE` | Use complete UTC buckets or reduce a range that exceeds a bounded read budget |
392
+ | `UNTRUSTED_DEPLOYMENT_HOST` | Ask the control-plane operator to allow the exact custom functions origin |
393
+
394
+ Do not fix authorization errors by weakening the resolver, copying site IDs into
395
+ JWT claims, or exposing a permissive report API.
@@ -0,0 +1,258 @@
1
+ # Federated Amage Rastro dashboard
2
+
3
+ This document is the protocol reference. Follow
4
+ [`federation-setup.md`](federation-setup.md) for the executable customer-host
5
+ installation, pairing, verification, and revocation sequence.
6
+
7
+ The federated dashboard model keeps telemetry in the customer's Convex or
8
+ Synapse deployment while a browser loaded from `amagerastro.com` subscribes
9
+ directly to authorized host functions in that deployment.
10
+
11
+ ```text
12
+ tracker -> customer deployment -> Rastro component tables
13
+
14
+ amagerastro.com browser -> customer deployment -> reactive Rastro reports
15
+ ```
16
+
17
+ The Amage Rastro control plane stores account and connection metadata. It does
18
+ not need to proxy or persist the customer's analytics events.
19
+
20
+ ## Connector protocol v1
21
+
22
+ The typed `FEDERATED_ANALYTICS_CONNECTOR_MANIFEST` constant defines the stable
23
+ v1 contract:
24
+
25
+ - protocol: `amage-rastro-analytics`
26
+ - protocol version: `1`
27
+ - canonical host module: `rastroFederation`
28
+ - canonical function names and supported capabilities
29
+ - public request, connection, range, live visitor, and journey limits
30
+
31
+ The host module must be `convex/rastroFederation.ts`, so its public references
32
+ match the function names advertised by the manifest. The generated `manifest`
33
+ query is public because it contains only static protocol metadata. It does not
34
+ resolve a connection or expose customer data.
35
+
36
+ The exact generated surface is:
37
+
38
+ - `manifest`
39
+ - `connectionStatus`
40
+ - `listSites`
41
+ - `overview`
42
+ - `liveVisitors`
43
+ - `listSessions`
44
+ - `sessionJourney`
45
+ - `listConversions`
46
+ - `visitorJourney`
47
+ - `goalsReport`
48
+ - `funnelsReport`
49
+ - `affiliatesReport`
50
+ - `dataCoverage`
51
+
52
+ There are no ingestion, configuration-write, retention-write, raw-site, or site
53
+ mutation functions in this surface. The read-only goal, funnel, and affiliate
54
+ reports include their bounded definitions, and `listConversions` returns only
55
+ the trusted server-side conversion ledger. Browser `conversion` telemetry
56
+ remains untrusted journey data and never enters that ledger. Journey events
57
+ include bounded custom properties. `exposeFederatedAnalyticsApi` accepts only
58
+ `FederatedAnalyticsAuthorizerOptions`; it does not accept or expose an arbitrary
59
+ host `AnalyticsAuthorizer`.
60
+
61
+ `connectionStatus` authenticates and resolves the authoritative connection on
62
+ every call. Its response is deliberately redacted to status, protocol version,
63
+ capabilities, and site count. It does not return connection IDs, organization
64
+ IDs, identity claims, or site IDs.
65
+
66
+ `listSites` takes no arguments. It loads sites only from the `siteIds` in the
67
+ validated local connection and returns `FederatedSiteSummary` values containing
68
+ only `siteId`, `name`, `currency`, `timezone`, and `cookieless`. In particular,
69
+ it never returns `ownerId`, `domains`, or `networkId`.
70
+
71
+ ## Authentication contract
72
+
73
+ The customer host must configure Convex authentication to trust the Amage Rastro
74
+ OIDC issuer. Convex validates the JWT signature, issuer, audience, and expiry
75
+ before any host function runs. The helper then requires these custom claims:
76
+
77
+ | Claim | Value |
78
+ | ------------------------ | -------------------------------- |
79
+ | `rastro_connection_id` | The paired deployment connection |
80
+ | `rastro_organization_id` | The Amage Rastro organization |
81
+ | `rastro_permissions` | Must include `analytics:read` |
82
+
83
+ Claim names and the permission constant are exported as
84
+ `FEDERATED_ANALYTICS_CLAIMS` and `FEDERATED_ANALYTICS_READ_PERMISSION`.
85
+
86
+ The audience is the customer's normalized Convex deployment URL, for example
87
+ `https://product-123.convex.cloud`. It is stable across connections to that
88
+ deployment; `rastro_connection_id` identifies the individual grant. A customer
89
+ host configures the provider with the issuer and audience issued during pairing:
90
+
91
+ ```ts
92
+ // convex/auth.config.ts
93
+ import type { AuthConfig } from "convex/server";
94
+
95
+ export default {
96
+ providers: [
97
+ // Keep the application's existing providers here.
98
+ {
99
+ domain: process.env.RASTRO_FEDERATION_ISSUER!,
100
+ applicationID: process.env.RASTRO_FEDERATION_AUDIENCE!,
101
+ },
102
+ ],
103
+ } satisfies AuthConfig;
104
+ ```
105
+
106
+ `RASTRO_FEDERATION_AUDIENCE` must exactly match the deployment URL registered in
107
+ the control plane. Insert the provider object into the host's existing
108
+ `providers` array; the complete insertion fragment is in
109
+ [`federation-setup.md`](federation-setup.md). Never replace existing providers.
110
+ The example control plane publishes OIDC discovery and JWKS endpoints under
111
+ `/federation`, signs RS256 tokens with a ten-minute lifetime, and never sends
112
+ its private key to the browser.
113
+
114
+ ## Authoritative local connection
115
+
116
+ JWT claims identify a requested connection but do not define its site access.
117
+ The customer deployment resolves a local connection record on every
118
+ authenticated query. This record is authoritative and makes revocation immediate
119
+ instead of waiting for a token to expire.
120
+
121
+ A host schema can model it as:
122
+
123
+ ```ts
124
+ import { defineSchema, defineTable } from "convex/server";
125
+ import { v } from "convex/values";
126
+
127
+ export default defineSchema({
128
+ rastroFederationGrants: defineTable({
129
+ connectionId: v.string(),
130
+ organizationId: v.string(),
131
+ siteIds: v.array(v.string()),
132
+ createdAt: v.number(),
133
+ updatedAt: v.number(),
134
+ revokedAt: v.optional(v.number()),
135
+ }).index("by_connectionId", ["connectionId"]),
136
+ });
137
+ ```
138
+
139
+ Pairing and revocation mutations belong to the host application and must use its
140
+ existing administrator authorization. The Rastro component cannot inspect host
141
+ authentication state.
142
+
143
+ ## Host API
144
+
145
+ ```ts
146
+ // convex/rastroFederation.ts
147
+ import {
148
+ exposeFederatedAnalyticsApi,
149
+ type FederatedAnalyticsConnection,
150
+ } from "@iann29/rastro";
151
+ import { components } from "./_generated/api";
152
+ import { env, type QueryCtx } from "./_generated/server";
153
+
154
+ const federated = exposeFederatedAnalyticsApi(components.rastroAnalytics, {
155
+ issuer: env.RASTRO_FEDERATION_ISSUER.replace(/\/$/, ""),
156
+ resolveConnection: async (ctx, identity) => {
157
+ const db = ctx.db as unknown as QueryCtx["db"];
158
+ const grant = await db
159
+ .query("rastroFederationGrants")
160
+ .withIndex("by_connectionId", (query) =>
161
+ query.eq("connectionId", identity.connectionId),
162
+ )
163
+ .unique();
164
+
165
+ if (!grant || grant.organizationId !== identity.organizationId) return null;
166
+ return {
167
+ connectionId: grant.connectionId,
168
+ organizationId: grant.organizationId,
169
+ siteIds: grant.siteIds,
170
+ revokedAt: grant.revokedAt,
171
+ } satisfies FederatedAnalyticsConnection;
172
+ },
173
+ });
174
+
175
+ export const {
176
+ manifest,
177
+ connectionStatus,
178
+ listSites,
179
+ overview,
180
+ liveVisitors,
181
+ listSessions,
182
+ sessionJourney,
183
+ listConversions,
184
+ visitorJourney,
185
+ goalsReport,
186
+ funnelsReport,
187
+ affiliatesReport,
188
+ dataCoverage,
189
+ } = federated;
190
+ ```
191
+
192
+ The resolver receives the authenticated `UserIdentity` as well as the parsed
193
+ connection and organization IDs, so a host may enforce additional local rules.
194
+ It must use an indexed, authoritative host lookup rather than token-provided
195
+ site IDs.
196
+
197
+ ## Stable errors
198
+
199
+ `FEDERATED_ANALYTICS_ERROR_CODES` exports all protocol v1 authorization codes:
200
+
201
+ | Code | Meaning |
202
+ | ------------------------------ | -------------------------------------------- |
203
+ | `FEDERATION_UNAUTHENTICATED` | No authenticated identity |
204
+ | `FEDERATION_ISSUER_MISMATCH` | Identity came from another issuer |
205
+ | `FEDERATION_INVALID_CLAIMS` | Required bounded claims or permission absent |
206
+ | `FEDERATION_CONNECTION_DENIED` | Connection missing, revoked, or malformed |
207
+ | `FEDERATION_INVALID_SCOPE` | Requested site list violates public limits |
208
+ | `FEDERATION_SITE_DENIED` | Requested site is outside the connection |
209
+
210
+ Consumers should branch on `ConvexError.data.code`, not human-readable messages.
211
+
212
+ ## Enforced invariants
213
+
214
+ - The identity issuer must match exactly.
215
+ - The token must include the read permission.
216
+ - Report queries must request one through ten unique, bounded site IDs.
217
+ - The resolved connection ID and organization must match the token.
218
+ - Revoked, missing, malformed, empty, or duplicate-site connections are denied.
219
+ - The resolved connection may authorize at most 10 unique site IDs.
220
+ - Every report site must exist in the local connection scope.
221
+ - Site discovery trusts only the resolved connection, never caller arguments.
222
+ - Owner-wide enumeration, raw site configuration, and every write are absent.
223
+ - `dataCoverage` is read-only and uses the same connection-scoped site
224
+ authorization.
225
+
226
+ `dataCoverage` accepts one authorized `siteId` and an inclusive integer
227
+ millisecond `from`/`to` range. It returns availability, rollup generation, and
228
+ retention boundaries for each dashboard dataset, but no events, visitor IDs,
229
+ session IDs, or site configuration. Dashboard clients should use it to explain
230
+ `complete`, `partial`, `retained`, and `unavailable` states rather than treating
231
+ an empty report as proof that no telemetry exists.
232
+
233
+ Availability is derived from bounded indexed source reads rather than a shared
234
+ per-ingest watermark. The dataset list distinguishes `overviewHour` from
235
+ `overviewDay`; clients must not apply one granularity's retention boundary to
236
+ the other. Heartbeats advance `sessions` availability only, not `events` or
237
+ either overview dataset.
238
+
239
+ ## Control-plane status
240
+
241
+ The production control plane at `https://www.amagerastro.com` and its reference
242
+ implementation under `example/` provide Better Auth sessions,
243
+ organization-scoped connections, OIDC discovery, JWKS, ten-minute RS256 tokens,
244
+ manifest verification, redacted site discovery, audit records, revocation of new
245
+ token issuance, and dynamic browser subscriptions to active deployments.
246
+
247
+ Do not assume that a dist-tag or source checkout contains this protocol. Inspect
248
+ an operator-approved exact npm registry artifact outside the host, without
249
+ lifecycle scripts, and statically verify every runtime and declaration export
250
+ listed by the machine manifest before changing a customer deployment. Automation
251
+ must stop at this gate rather than fetching source or reimplementing the
252
+ connector.
253
+
254
+ Remote local-grant provisioning is intentionally not an unauthenticated control-
255
+ plane write. The customer operator provisions the authoritative grant through an
256
+ internal or host-admin mutation. For immediate revocation, revoke that local
257
+ grant before revoking the central connection; otherwise an already-issued token
258
+ remains valid until its ten-minute expiry.