@iann29/rastro 0.1.0-alpha.3 → 0.1.0-alpha.5
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.
- package/README.md +374 -62
- package/agent/integration.md +651 -0
- package/agent/manifest.json +183 -0
- package/agent/manifest.schema.json +405 -0
- package/dist/client/federation.d.ts +205 -0
- package/dist/client/federation.d.ts.map +1 -0
- package/dist/client/federation.js +179 -0
- package/dist/client/federation.js.map +1 -0
- package/dist/client/index.d.ts +1658 -8
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +228 -2
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +10 -0
- package/dist/component/_generated/api.d.ts.map +1 -1
- package/dist/component/_generated/api.js.map +1 -1
- package/dist/component/_generated/component.d.ts +169 -1
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/cardinality.d.ts +12 -0
- package/dist/component/cardinality.d.ts.map +1 -0
- package/dist/component/cardinality.js +94 -0
- package/dist/component/cardinality.js.map +1 -0
- package/dist/component/constants.d.ts +11 -0
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +11 -0
- package/dist/component/constants.js.map +1 -1
- package/dist/component/coverage.d.ts +18 -0
- package/dist/component/coverage.d.ts.map +1 -0
- package/dist/component/coverage.js +19 -0
- package/dist/component/coverage.js.map +1 -0
- package/dist/component/errors.d.ts +1 -1
- package/dist/component/errors.d.ts.map +1 -1
- package/dist/component/errors.js.map +1 -1
- package/dist/component/eventStore.d.ts +1 -0
- package/dist/component/eventStore.d.ts.map +1 -1
- package/dist/component/http.d.ts.map +1 -1
- package/dist/component/http.js +85 -36
- package/dist/component/http.js.map +1 -1
- package/dist/component/identity.d.ts +13 -0
- package/dist/component/identity.d.ts.map +1 -0
- package/dist/component/identity.js +58 -0
- package/dist/component/identity.js.map +1 -0
- package/dist/component/ingest.d.ts +1 -0
- package/dist/component/ingest.d.ts.map +1 -1
- package/dist/component/ingest.js +258 -19
- package/dist/component/ingest.js.map +1 -1
- package/dist/component/live.d.ts.map +1 -1
- package/dist/component/live.js +6 -3
- package/dist/component/live.js.map +1 -1
- package/dist/component/reports.d.ts +120 -2
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +659 -91
- package/dist/component/reports.js.map +1 -1
- package/dist/component/retention.d.ts +75 -1
- package/dist/component/retention.d.ts.map +1 -1
- package/dist/component/retention.js +517 -54
- package/dist/component/retention.js.map +1 -1
- package/dist/component/sanitize.d.ts +4 -1
- package/dist/component/sanitize.d.ts.map +1 -1
- package/dist/component/sanitize.js +13 -5
- package/dist/component/sanitize.js.map +1 -1
- package/dist/component/schema.d.ts +224 -7
- package/dist/component/schema.js +113 -1
- package/dist/component/schema.js.map +1 -1
- package/dist/component/sites.d.ts.map +1 -1
- package/dist/component/sites.js +5 -1
- package/dist/component/sites.js.map +1 -1
- package/dist/component/useragent.d.ts +9 -0
- package/dist/component/useragent.d.ts.map +1 -0
- package/dist/component/useragent.js +152 -0
- package/dist/component/useragent.js.map +1 -0
- package/dist/component/validators.d.ts +42 -11
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +10 -1
- package/dist/component/validators.js.map +1 -1
- package/dist/component/visitors.d.ts +19 -0
- package/dist/component/visitors.d.ts.map +1 -0
- package/dist/component/visitors.js +86 -0
- package/dist/component/visitors.js.map +1 -0
- package/dist/tracker/generated.d.ts +4 -4
- package/dist/tracker/generated.d.ts.map +1 -1
- package/dist/tracker/generated.js +4 -4
- package/dist/tracker/generated.js.map +1 -1
- package/dist/tracker/tracker.js +23 -11
- package/dist/tracker/tracker.js.map +1 -1
- package/dist/tracker.min.js +1 -1
- package/docs/benchmarks/2026-08-20-realistic.md +4 -4
- package/docs/benchmarks/2026-08-21-formal-certification.md +333 -0
- package/docs/federation-setup.md +395 -0
- package/docs/federation.md +258 -0
- package/docs/upgrading.md +130 -0
- package/llms.txt +65 -0
- package/package.json +41 -11
- package/scripts/generate-federation-keys.mjs +20 -0
- package/src/component/_generated/api.ts +10 -0
- package/src/component/_generated/component.ts +228 -1
- package/src/component/cardinality.ts +116 -0
- package/src/component/constants.ts +11 -0
- package/src/component/coverage.ts +25 -0
- package/src/component/errors.ts +2 -1
- package/src/component/http.ts +129 -38
- package/src/component/identity.ts +74 -0
- package/src/component/ingest.ts +414 -13
- package/src/component/live.ts +8 -3
- package/src/component/reports.ts +910 -98
- package/src/component/retention.ts +624 -88
- package/src/component/sanitize.ts +19 -5
- package/src/component/schema.ts +125 -0
- package/src/component/sites.ts +5 -1
- package/src/component/useragent.ts +171 -0
- package/src/component/validators.ts +11 -0
- package/src/component/visitors.ts +105 -0
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
# Integrate a product with Amage Rastro
|
|
2
|
+
|
|
3
|
+
This is the canonical runbook for coding agents. It connects a product that
|
|
4
|
+
already runs Amage Rastro in Convex or Synapse to the central dashboard without
|
|
5
|
+
moving telemetry into the control plane.
|
|
6
|
+
|
|
7
|
+
Human reference: <https://www.amagerastro.com/docs/>
|
|
8
|
+
|
|
9
|
+
Machine contract: <https://www.amagerastro.com/agent/manifest.json>
|
|
10
|
+
|
|
11
|
+
## Package eligibility gate
|
|
12
|
+
|
|
13
|
+
Do not infer federation availability from this website, a dist-tag, or a source
|
|
14
|
+
checkout. The machine manifest defines the required runtime and type exports,
|
|
15
|
+
not which mutable tag currently contains them.
|
|
16
|
+
|
|
17
|
+
Inspect the tag without changing the host:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm view @iann29/rastro@alpha version
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
After the operator approves that exact version, inspect it outside the host
|
|
24
|
+
without executing package code or lifecycle scripts:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
|
|
28
|
+
npm pack --ignore-scripts @iann29/rastro@EXACT_VERSION --pack-destination "$tmp"
|
|
29
|
+
tarball="$(printf '%s\n' "$tmp"/iann29-rastro-*.tgz)"
|
|
30
|
+
mkdir "$tmp/consumer"
|
|
31
|
+
npm install --prefix "$tmp/consumer" --ignore-scripts --package-lock=false \
|
|
32
|
+
"$tarball" convex@^1.43.0
|
|
33
|
+
node --input-type=module - \
|
|
34
|
+
"$tmp/consumer/node_modules/@iann29/rastro" EXACT_VERSION <<'NODE'
|
|
35
|
+
import { readFile } from "node:fs/promises";
|
|
36
|
+
import { dirname, extname, join, relative, resolve } from "node:path";
|
|
37
|
+
import tsModule from "typescript";
|
|
38
|
+
const ts = tsModule.default ?? tsModule;
|
|
39
|
+
const root = process.argv[2];
|
|
40
|
+
const expectedVersion = process.argv[3];
|
|
41
|
+
const pkg = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
|
|
42
|
+
const runtimeExports = [
|
|
43
|
+
"exposeFederatedAnalyticsApi",
|
|
44
|
+
"FEDERATED_ANALYTICS_CONNECTOR_MANIFEST",
|
|
45
|
+
"FEDERATED_ANALYTICS_ERROR_CODES",
|
|
46
|
+
];
|
|
47
|
+
const modules = new Map();
|
|
48
|
+
const resolveSpecifier = (importer, specifier) => {
|
|
49
|
+
const path = resolve(dirname(importer), specifier);
|
|
50
|
+
return extname(path) ? path : `${path}.js`;
|
|
51
|
+
};
|
|
52
|
+
const load = async (path) => {
|
|
53
|
+
const absolute = resolve(path);
|
|
54
|
+
const local = relative(root, absolute);
|
|
55
|
+
if (local.startsWith("..") || local.startsWith("/")) throw new Error("module escaped package");
|
|
56
|
+
if (modules.has(absolute)) return modules.get(absolute);
|
|
57
|
+
const source = ts.createSourceFile(absolute, await readFile(absolute, "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
|
|
58
|
+
if (source.parseDiagnostics?.length) throw new Error(`syntax error in ${local}`);
|
|
59
|
+
const module = { absolute, direct: new Set(), imports: new Map(), named: [], stars: [] };
|
|
60
|
+
modules.set(absolute, module);
|
|
61
|
+
for (const statement of source.statements) {
|
|
62
|
+
const specifier = (ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement)) &&
|
|
63
|
+
statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)
|
|
64
|
+
? statement.moduleSpecifier.text : null;
|
|
65
|
+
const target = specifier?.startsWith(".") ? resolveSpecifier(absolute, specifier) : null;
|
|
66
|
+
if (target) await load(target);
|
|
67
|
+
if (ts.isImportDeclaration(statement) && target && statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings)) {
|
|
68
|
+
for (const item of statement.importClause.namedBindings.elements) {
|
|
69
|
+
module.imports.set(item.name.text, { name: item.propertyName?.text ?? item.name.text, target });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (ts.isExportDeclaration(statement)) {
|
|
73
|
+
if (statement.exportClause && ts.isNamedExports(statement.exportClause)) {
|
|
74
|
+
for (const item of statement.exportClause.elements) {
|
|
75
|
+
module.named.push({ exported: item.name.text, local: item.propertyName?.text ?? item.name.text, target });
|
|
76
|
+
}
|
|
77
|
+
} else if (!statement.exportClause && target) module.stars.push(target);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (!statement.modifiers?.some((item) => item.kind === ts.SyntaxKind.ExportKeyword)) continue;
|
|
81
|
+
if (ts.isVariableStatement(statement)) {
|
|
82
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
83
|
+
if (ts.isIdentifier(declaration.name)) module.direct.add(declaration.name.text);
|
|
84
|
+
}
|
|
85
|
+
} else if ("name" in statement && statement.name && ts.isIdentifier(statement.name)) {
|
|
86
|
+
module.direct.add(statement.name.text);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return module;
|
|
90
|
+
};
|
|
91
|
+
const hasExport = async (module, name, seen = new Set()) => {
|
|
92
|
+
const key = `${module.absolute}:${name}`;
|
|
93
|
+
if (seen.has(key)) return false;
|
|
94
|
+
seen.add(key);
|
|
95
|
+
if (module.direct.has(name)) return true;
|
|
96
|
+
for (const item of module.named) {
|
|
97
|
+
if (item.exported !== name) continue;
|
|
98
|
+
if (item.target) return hasExport(modules.get(item.target), item.local, seen);
|
|
99
|
+
const imported = module.imports.get(item.local);
|
|
100
|
+
if (imported) return hasExport(modules.get(imported.target), imported.name, seen);
|
|
101
|
+
}
|
|
102
|
+
let matches = 0;
|
|
103
|
+
for (const target of module.stars) {
|
|
104
|
+
if (await hasExport(modules.get(target), name, new Set(seen))) matches += 1;
|
|
105
|
+
}
|
|
106
|
+
return matches === 1;
|
|
107
|
+
};
|
|
108
|
+
const entry = await load(join(root, "dist/client/index.js"));
|
|
109
|
+
const compilerOptions = {
|
|
110
|
+
allowJs: true,
|
|
111
|
+
checkJs: true,
|
|
112
|
+
noEmit: true,
|
|
113
|
+
module: ts.ModuleKind.NodeNext,
|
|
114
|
+
moduleResolution: ts.ModuleResolutionKind.NodeNext,
|
|
115
|
+
target: ts.ScriptTarget.ES2022,
|
|
116
|
+
};
|
|
117
|
+
const program = ts.createProgram([entry.absolute], compilerOptions);
|
|
118
|
+
const importErrors = ts.getPreEmitDiagnostics(program).filter((diagnostic) =>
|
|
119
|
+
[1192, 2305, 2307, 2614, 2724].includes(diagnostic.code),
|
|
120
|
+
);
|
|
121
|
+
if (importErrors.length) throw new Error("runtime import graph has unresolved bindings");
|
|
122
|
+
const checker = program.getTypeChecker();
|
|
123
|
+
for (const source of program.getSourceFiles()) {
|
|
124
|
+
const path = relative(root, source.fileName);
|
|
125
|
+
if (path.startsWith("..") || path.startsWith("node_modules/") || !path.endsWith(".js")) continue;
|
|
126
|
+
for (const statement of source.statements) {
|
|
127
|
+
if (!ts.isImportDeclaration(statement) || !statement.importClause) continue;
|
|
128
|
+
const resolved = ts.resolveModuleName(
|
|
129
|
+
statement.moduleSpecifier.text,
|
|
130
|
+
source.fileName,
|
|
131
|
+
compilerOptions,
|
|
132
|
+
ts.sys,
|
|
133
|
+
).resolvedModule;
|
|
134
|
+
let target = resolved ? program.getSourceFile(resolved.resolvedFileName) : null;
|
|
135
|
+
let targetChecker = checker;
|
|
136
|
+
if (resolved && !target) {
|
|
137
|
+
const externalProgram = ts.createProgram([resolved.resolvedFileName], compilerOptions);
|
|
138
|
+
target = externalProgram.getSourceFile(resolved.resolvedFileName);
|
|
139
|
+
targetChecker = externalProgram.getTypeChecker();
|
|
140
|
+
}
|
|
141
|
+
const symbol = target ? targetChecker.getSymbolAtLocation(target) : null;
|
|
142
|
+
if (!symbol) throw new Error(`unresolved runtime module in ${path}`);
|
|
143
|
+
const exports = target.fileName.endsWith(".js")
|
|
144
|
+
? new Set(target.statements.flatMap((item) => {
|
|
145
|
+
if (ts.isExportDeclaration(item) && item.exportClause && ts.isNamedExports(item.exportClause)) {
|
|
146
|
+
return item.exportClause.elements.map((element) => element.name.text);
|
|
147
|
+
}
|
|
148
|
+
if (!item.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) return [];
|
|
149
|
+
if (ts.isVariableStatement(item)) {
|
|
150
|
+
return item.declarationList.declarations.flatMap((declaration) =>
|
|
151
|
+
ts.isIdentifier(declaration.name) ? [declaration.name.text] : [],
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return "name" in item && item.name && ts.isIdentifier(item.name) ? [item.name.text] : [];
|
|
155
|
+
}))
|
|
156
|
+
: new Set(targetChecker.getExportsOfModule(symbol).map((item) => item.getName()));
|
|
157
|
+
if (statement.importClause.name && !exports.has("default")) throw new Error(`missing default import in ${path}`);
|
|
158
|
+
const bindings = statement.importClause.namedBindings;
|
|
159
|
+
if (bindings && ts.isNamedImports(bindings)) {
|
|
160
|
+
for (const item of bindings.elements) {
|
|
161
|
+
const imported = item.propertyName?.text ?? item.name.text;
|
|
162
|
+
if (!exports.has(imported)) throw new Error(`missing runtime import ${imported} in ${path}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
for (const module of modules.values()) {
|
|
168
|
+
for (const imported of module.imports.values()) {
|
|
169
|
+
if (!(await hasExport(modules.get(imported.target), imported.name))) {
|
|
170
|
+
throw new Error(`missing imported runtime export: ${imported.name}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
for (const name of runtimeExports) {
|
|
175
|
+
if (!(await hasExport(entry, name))) throw new Error(`missing runtime export: ${name}`);
|
|
176
|
+
}
|
|
177
|
+
if (pkg.name !== "@iann29/rastro" || pkg.version !== expectedVersion) {
|
|
178
|
+
throw new Error("package identity mismatch");
|
|
179
|
+
}
|
|
180
|
+
if (pkg.exports?.["."]?.default !== "./dist/client/index.js" ||
|
|
181
|
+
pkg.exports?.["."]?.types !== "./dist/client/index.d.ts") {
|
|
182
|
+
throw new Error("package export map mismatch");
|
|
183
|
+
}
|
|
184
|
+
if (["preinstall", "install", "postinstall"].some((name) => pkg.scripts?.[name])) {
|
|
185
|
+
throw new Error("install lifecycle script present");
|
|
186
|
+
}
|
|
187
|
+
NODE
|
|
188
|
+
cat > "$tmp/consumer/contract.ts" <<'TS'
|
|
189
|
+
import {
|
|
190
|
+
exposeFederatedAnalyticsApi,
|
|
191
|
+
FEDERATED_ANALYTICS_CONNECTOR_MANIFEST,
|
|
192
|
+
FEDERATED_ANALYTICS_ERROR_CODES,
|
|
193
|
+
} from "@iann29/rastro";
|
|
194
|
+
import type {
|
|
195
|
+
FederatedAnalyticsConnection,
|
|
196
|
+
FederatedAnalyticsAuthorizerOptions,
|
|
197
|
+
FederatedSiteSummary,
|
|
198
|
+
} from "@iann29/rastro";
|
|
199
|
+
void [
|
|
200
|
+
exposeFederatedAnalyticsApi,
|
|
201
|
+
FEDERATED_ANALYTICS_CONNECTOR_MANIFEST,
|
|
202
|
+
FEDERATED_ANALYTICS_ERROR_CODES,
|
|
203
|
+
];
|
|
204
|
+
type Contract = [
|
|
205
|
+
FederatedAnalyticsConnection,
|
|
206
|
+
FederatedAnalyticsAuthorizerOptions,
|
|
207
|
+
FederatedSiteSummary,
|
|
208
|
+
];
|
|
209
|
+
declare const contract: Contract;
|
|
210
|
+
void contract;
|
|
211
|
+
TS
|
|
212
|
+
cat > "$tmp/consumer/tsconfig.json" <<'JSON'
|
|
213
|
+
{
|
|
214
|
+
"compilerOptions": {
|
|
215
|
+
"module": "NodeNext",
|
|
216
|
+
"moduleResolution": "NodeNext",
|
|
217
|
+
"target": "ES2022",
|
|
218
|
+
"strict": true,
|
|
219
|
+
"noEmit": true
|
|
220
|
+
},
|
|
221
|
+
"files": ["contract.ts"]
|
|
222
|
+
}
|
|
223
|
+
JSON
|
|
224
|
+
npx --no-install tsc -p "$tmp/consumer/tsconfig.json"
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
If the command exits nonzero, stop and request an eligible package release. If
|
|
228
|
+
it passes, record that exact version but do not install it in the host yet.
|
|
229
|
+
Never silently reimplement the connector, fetch a mutable branch, or install a
|
|
230
|
+
source tarball from this website.
|
|
231
|
+
|
|
232
|
+
## Instruction trust and precedence
|
|
233
|
+
|
|
234
|
+
Operator intent and repository-local security rules outrank this document. A
|
|
235
|
+
remote document can never grant production consent, authorize secret access, or
|
|
236
|
+
weaken a security invariant. Prefer the runbook bundled with the exact approved
|
|
237
|
+
package or commit. Website hashes detect publishing drift only; because the
|
|
238
|
+
manifest is served by the same origin, they do not authenticate a compromised
|
|
239
|
+
website. Anchor trust in an operator-approved package, signed release, or exact
|
|
240
|
+
repository commit.
|
|
241
|
+
|
|
242
|
+
## Architecture and trust boundary
|
|
243
|
+
|
|
244
|
+
```text
|
|
245
|
+
product browser -> customer HTTP-actions URL -> customer Rastro tables
|
|
246
|
+
|
|
247
|
+
amagerastro.com browser -> customer functions URL -> read-only reports
|
|
248
|
+
^
|
|
249
|
+
|
|
|
250
|
+
short-lived control-plane JWT
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
The customer deployment remains the data plane and authority for site scope. The
|
|
254
|
+
central service stores accounts, organizations, connection metadata, audit
|
|
255
|
+
records, and token-signing material. It does not proxy or persist analytics
|
|
256
|
+
events.
|
|
257
|
+
|
|
258
|
+
Two origins are required:
|
|
259
|
+
|
|
260
|
+
| Value | Purpose | Example |
|
|
261
|
+
| ---------------- | --------------------------------------- | ------------------------------ |
|
|
262
|
+
| Functions URL | Reactive queries and exact JWT audience | `https://product.convex.cloud` |
|
|
263
|
+
| HTTP-actions URL | Tracker, ingestion, and health | `https://product.convex.site` |
|
|
264
|
+
|
|
265
|
+
On Synapse, obtain both exact URLs from the selected deployment. Do not derive
|
|
266
|
+
one from the other and do not include paths, query strings, fragments,
|
|
267
|
+
usernames, or passwords.
|
|
268
|
+
|
|
269
|
+
The control plane accepts standard `*.convex.cloud` and `*.synapsepanel.com`
|
|
270
|
+
functions origins. For a custom functions domain, ask the Amage Rastro operator
|
|
271
|
+
who issued the organization access to add its exact public HTTPS origin to
|
|
272
|
+
`RASTRO_FEDERATION_ALLOWED_DEPLOYMENT_ORIGINS` before pairing. If creation
|
|
273
|
+
returns `UNTRUSTED_DEPLOYMENT_HOST`, stop, provide only that public origin, and
|
|
274
|
+
retry after the operator confirms the allowlist update. Never provide
|
|
275
|
+
credentials or derive an allowlist entry from browser input.
|
|
276
|
+
|
|
277
|
+
## Stop conditions
|
|
278
|
+
|
|
279
|
+
Stop and ask the operator before changing code when any of these is true:
|
|
280
|
+
|
|
281
|
+
- local Rastro ingestion and an authenticated local report are not working;
|
|
282
|
+
- the installed package fails the release check above;
|
|
283
|
+
- the product's authorization model or owner identity is unknown;
|
|
284
|
+
- the target deployment or whether it is development/production is ambiguous;
|
|
285
|
+
- adding the Rastro provider would replace an existing auth provider;
|
|
286
|
+
- a requested connection needs more than ten sites;
|
|
287
|
+
- the operator asks to put site IDs, owner IDs, or admin credentials in a JWT or
|
|
288
|
+
browser;
|
|
289
|
+
- the customer functions origin is not accepted by the control-plane allowlist.
|
|
290
|
+
|
|
291
|
+
## Phase 1: inspect the host
|
|
292
|
+
|
|
293
|
+
Before editing, locate and read:
|
|
294
|
+
|
|
295
|
+
- `convex/schema.ts`;
|
|
296
|
+
- `convex/convex.config.ts`;
|
|
297
|
+
- `convex/auth.config.ts`, if present;
|
|
298
|
+
- the existing Rastro component mount and site administration functions;
|
|
299
|
+
- the product's server-side owner/admin authorization helper;
|
|
300
|
+
- the generated component name, normally `components.rastroAnalytics`.
|
|
301
|
+
- the currently installed Rastro version with `npm ls @iann29/rastro --depth=0`;
|
|
302
|
+
- retained session, journey, live-visitor, conversion, campaign, affiliate, and
|
|
303
|
+
custom-event data through authorized local reports;
|
|
304
|
+
- the visitor/session ID generation strategy, route/path contents, coarse
|
|
305
|
+
location fields, retention policy, and intended dashboard organization
|
|
306
|
+
membership.
|
|
307
|
+
|
|
308
|
+
Record the existing schema tables/indexes, component mounts/env declarations,
|
|
309
|
+
and authentication providers before editing. After editing, compare the diff and
|
|
310
|
+
prove each recorded entry remains present and executable. The snippets below are
|
|
311
|
+
insertion fragments, never replacement files.
|
|
312
|
+
|
|
313
|
+
Confirm local analytics first:
|
|
314
|
+
|
|
315
|
+
1. `GET <HTTP_ACTIONS_URL>/rastro/health` succeeds.
|
|
316
|
+
2. The site exists and its domains include the exact product hostname.
|
|
317
|
+
3. The tracker loads from `<HTTP_ACTIONS_URL>/rastro/tracker.js`.
|
|
318
|
+
4. A real `POST /rastro/events` returns HTTP 200 with accepted counters.
|
|
319
|
+
5. An authenticated host query can read a report for that exact site.
|
|
320
|
+
6. Retained routes, IDs, campaigns, affiliates, location fields, conversion
|
|
321
|
+
records, and custom properties contain no unexpected personal data, access
|
|
322
|
+
tokens, secrets, or form values. Confirm that every intended organization
|
|
323
|
+
member may read this bounded data and that retention is acceptable. If any of
|
|
324
|
+
that cannot be established, stop before federation.
|
|
325
|
+
|
|
326
|
+
Federation exposes existing reports. It does not mount the component, create a
|
|
327
|
+
site, install the tracker, or repair ingestion.
|
|
328
|
+
|
|
329
|
+
Before installing the eligible version, follow
|
|
330
|
+
<https://www.amagerastro.com/docs/reference/upgrading.md> from the detected
|
|
331
|
+
current version. Snapshot/rehearse maintained deployments and complete every
|
|
332
|
+
required migration bridge. Only then install with scripts disabled:
|
|
333
|
+
|
|
334
|
+
```bash
|
|
335
|
+
npm install --ignore-scripts --save-exact @iann29/rastro@EXACT_VERSION
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
Commit the lockfile and leave the existing Rastro mount unchanged.
|
|
339
|
+
|
|
340
|
+
## Phase 2: add the authoritative local grant
|
|
341
|
+
|
|
342
|
+
Merge this table into the existing schema. Preserve every existing table and
|
|
343
|
+
index.
|
|
344
|
+
|
|
345
|
+
```ts
|
|
346
|
+
rastroFederationGrants: defineTable({
|
|
347
|
+
connectionId: v.string(),
|
|
348
|
+
organizationId: v.string(),
|
|
349
|
+
siteIds: v.array(v.string()),
|
|
350
|
+
revokedAt: v.optional(v.number()),
|
|
351
|
+
createdAt: v.number(),
|
|
352
|
+
updatedAt: v.number(),
|
|
353
|
+
}).index("by_connectionId", ["connectionId"]),
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
Create `convex/rastroFederationAdmin.ts` with internal or equivalently protected
|
|
357
|
+
operator functions that:
|
|
358
|
+
|
|
359
|
+
- accept a connection ID, organization ID, opaque host owner ID, and site IDs;
|
|
360
|
+
- reject empty/duplicate IDs and more than ten unique sites;
|
|
361
|
+
- load every site from Rastro and verify it belongs to that owner;
|
|
362
|
+
- upsert by the `by_connectionId` index and clear `revokedAt` when
|
|
363
|
+
reprovisioned;
|
|
364
|
+
- revoke by setting `revokedAt` and `updatedAt`;
|
|
365
|
+
- never trust an owner/user ID supplied by a browser.
|
|
366
|
+
|
|
367
|
+
The complete reviewed implementation is in the **Add the authoritative local
|
|
368
|
+
grant** section of
|
|
369
|
+
<https://www.amagerastro.com/docs/reference/federation-setup.md>. Do not weaken
|
|
370
|
+
it to make pairing pass.
|
|
371
|
+
|
|
372
|
+
## Phase 3: merge runtime configuration
|
|
373
|
+
|
|
374
|
+
Insert only these two properties into the existing `defineApp({ env: { ... } })`
|
|
375
|
+
object. If the app has no `env` object, add one without changing any other
|
|
376
|
+
`defineApp` option. Do not add another Rastro mount:
|
|
377
|
+
|
|
378
|
+
```ts
|
|
379
|
+
RASTRO_FEDERATION_ISSUER: v.string(),
|
|
380
|
+
RASTRO_FEDERATION_AUDIENCE: v.string(),
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
Insert this conditional spread into the existing `providers` array; never
|
|
384
|
+
replace the array or its current entries:
|
|
385
|
+
|
|
386
|
+
```ts
|
|
387
|
+
const issuer = process.env.RASTRO_FEDERATION_ISSUER;
|
|
388
|
+
const audience = process.env.RASTRO_FEDERATION_AUDIENCE;
|
|
389
|
+
|
|
390
|
+
...(issuer && audience
|
|
391
|
+
? [
|
|
392
|
+
{
|
|
393
|
+
domain: issuer.replace(/\/$/, ""),
|
|
394
|
+
applicationID: audience.replace(/\/$/, ""),
|
|
395
|
+
},
|
|
396
|
+
]
|
|
397
|
+
: []),
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
The issuer supplied by the pairing UI must exactly equal the independently
|
|
401
|
+
verified manifest issuer, `https://site.api.amagerastro.com/federation`; abort
|
|
402
|
+
on any mismatch. Derive the audience locally from the exact normalized customer
|
|
403
|
+
functions URL and compare it with the displayed audience. Never trust a
|
|
404
|
+
displayed audience that differs and never use the HTTP-actions URL.
|
|
405
|
+
|
|
406
|
+
Set values only on an explicit target:
|
|
407
|
+
|
|
408
|
+
```bash
|
|
409
|
+
# Convex Cloud development
|
|
410
|
+
npx convex env set --deployment dev RASTRO_FEDERATION_ISSUER \
|
|
411
|
+
'https://site.api.amagerastro.com/federation'
|
|
412
|
+
npx convex env set --deployment dev RASTRO_FEDERATION_AUDIENCE \
|
|
413
|
+
'https://product.convex.cloud'
|
|
414
|
+
|
|
415
|
+
# Synapse development
|
|
416
|
+
synapse convex --dev env set RASTRO_FEDERATION_ISSUER \
|
|
417
|
+
'https://site.api.amagerastro.com/federation'
|
|
418
|
+
synapse convex --dev env set RASTRO_FEDERATION_AUDIENCE \
|
|
419
|
+
'https://product.synapse.example'
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
Use `--prod` only after a development rehearsal and fresh operator consent.
|
|
423
|
+
Project-default `synapse env set ... --for=dev` values do not update an existing
|
|
424
|
+
deployment.
|
|
425
|
+
|
|
426
|
+
## Phase 4: expose the canonical module
|
|
427
|
+
|
|
428
|
+
The filename must be `convex/rastroFederation.ts`, producing the public module
|
|
429
|
+
name `rastroFederation`.
|
|
430
|
+
|
|
431
|
+
```ts
|
|
432
|
+
import {
|
|
433
|
+
exposeFederatedAnalyticsApi,
|
|
434
|
+
type FederatedAnalyticsConnection,
|
|
435
|
+
} from "@iann29/rastro";
|
|
436
|
+
import { components } from "./_generated/api";
|
|
437
|
+
import { env, type QueryCtx } from "./_generated/server";
|
|
438
|
+
|
|
439
|
+
const federated = exposeFederatedAnalyticsApi(components.rastroAnalytics, {
|
|
440
|
+
issuer: env.RASTRO_FEDERATION_ISSUER.replace(/\/$/, ""),
|
|
441
|
+
resolveConnection: async (ctx, identity) => {
|
|
442
|
+
const db = ctx.db as unknown as QueryCtx["db"];
|
|
443
|
+
const grant = await db
|
|
444
|
+
.query("rastroFederationGrants")
|
|
445
|
+
.withIndex("by_connectionId", (query) =>
|
|
446
|
+
query.eq("connectionId", identity.connectionId),
|
|
447
|
+
)
|
|
448
|
+
.unique();
|
|
449
|
+
|
|
450
|
+
if (!grant || grant.organizationId !== identity.organizationId) return null;
|
|
451
|
+
return {
|
|
452
|
+
connectionId: grant.connectionId,
|
|
453
|
+
organizationId: grant.organizationId,
|
|
454
|
+
siteIds: grant.siteIds,
|
|
455
|
+
revokedAt: grant.revokedAt,
|
|
456
|
+
} satisfies FederatedAnalyticsConnection;
|
|
457
|
+
},
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
export const {
|
|
461
|
+
manifest,
|
|
462
|
+
connectionStatus,
|
|
463
|
+
listSites,
|
|
464
|
+
overview,
|
|
465
|
+
liveVisitors,
|
|
466
|
+
listSessions,
|
|
467
|
+
sessionJourney,
|
|
468
|
+
listConversions,
|
|
469
|
+
visitorJourney,
|
|
470
|
+
goalsReport,
|
|
471
|
+
funnelsReport,
|
|
472
|
+
affiliatesReport,
|
|
473
|
+
dataCoverage,
|
|
474
|
+
} = federated;
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
`manifest` is public static metadata. Every other function is read-only and
|
|
478
|
+
requires both a valid JWT and a matching, non-revoked local grant.
|
|
479
|
+
|
|
480
|
+
## Function contract
|
|
481
|
+
|
|
482
|
+
| Function | Input summary | Important output/behavior |
|
|
483
|
+
| ------------------ | ------------------------------------------------------- | --------------------------------------------------------------- |
|
|
484
|
+
| `manifest` | `{}` | Static protocol, capabilities, names, and limits; no auth |
|
|
485
|
+
| `connectionStatus` | `{}` | Redacted status, protocol version, capabilities, site count |
|
|
486
|
+
| `listSites` | `{}` | Only `siteId`, `name`, `currency`, `timezone`, `cookieless` |
|
|
487
|
+
| `overview` | `siteIds`, `from`, `to`, optional hour/day interval | Aggregate metrics and time series |
|
|
488
|
+
| `liveVisitors` | `siteIds`, `now`, optional limit | At most 500 current visitors |
|
|
489
|
+
| `listSessions` | one `siteId`, range, `{numItems,cursor}` keyset options | Paginated sessions |
|
|
490
|
+
| `sessionJourney` | one site/session, range, `{numItems,cursor}` options | Paginated bounded events |
|
|
491
|
+
| `listConversions` | one site, range, `{numItems,cursor}` options | Paginated trusted server-side conversion ledger |
|
|
492
|
+
| `visitorJourney` | site IDs, visitor ID, range, optional limit | Cross-site journey inside the grant only |
|
|
493
|
+
| `goalsReport` | one site and range | Read-only goal definitions plus aggregates |
|
|
494
|
+
| `funnelsReport` | one site and complete UTC-day range | Read-only definitions/steps plus aggregates; max 90 days |
|
|
495
|
+
| `affiliatesReport` | one site and complete UTC-day range | Read-only affiliate definitions plus aggregates |
|
|
496
|
+
| `dataCoverage` | one site and inclusive integer range | Coverage metadata; echoes site ID, no event/visitor/session IDs |
|
|
497
|
+
|
|
498
|
+
The federated surface deliberately excludes ingestion, site mutation, retention
|
|
499
|
+
writes, configuration writes, domains, owner IDs, network IDs, and arbitrary
|
|
500
|
+
host functions. Goal, funnel, and affiliate reports include their read-only
|
|
501
|
+
definitions. Journey events include bounded custom `properties`; hosts must
|
|
502
|
+
follow the tracker contract and never place personal data or secrets in them. A
|
|
503
|
+
session belongs to one site; only `visitorJourney` can join the same
|
|
504
|
+
pseudonymous visitor across explicitly granted sites.
|
|
505
|
+
|
|
506
|
+
All times are Unix epoch milliseconds. The global report ceiling is 366 days,
|
|
507
|
+
but hourly overview is limited to 24 hours and funnel reports to 90 complete UTC
|
|
508
|
+
days. Affiliate reports also require complete UTC-day boundaries. Overview
|
|
509
|
+
queries require complete UTC hour/day buckets. Manual pagination honors
|
|
510
|
+
`numItems` and `cursor`; do not rely on optional native pagination metadata or
|
|
511
|
+
read-budget fields. Use `dataCoverage` to distinguish complete, partial,
|
|
512
|
+
retained, and unavailable data instead of treating an empty report as proof that
|
|
513
|
+
no telemetry exists.
|
|
514
|
+
|
|
515
|
+
## Phase 5: deploy, pair, and provision
|
|
516
|
+
|
|
517
|
+
Deploy code to an explicit development target first:
|
|
518
|
+
|
|
519
|
+
```bash
|
|
520
|
+
# Convex Cloud development, one push
|
|
521
|
+
npx convex dev --once
|
|
522
|
+
|
|
523
|
+
# Synapse development, one push
|
|
524
|
+
synapse dev --once
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
Then a human organization administrator must:
|
|
528
|
+
|
|
529
|
+
1. Sign in at <https://www.amagerastro.com>.
|
|
530
|
+
2. Open **Conexões** for the intended organization.
|
|
531
|
+
3. Create a pending connection with the exact functions and HTTP-actions URLs.
|
|
532
|
+
4. Compare issuer and audience with the independently verified values above.
|
|
533
|
+
5. Provide the connection ID and organization ID only after those checks pass.
|
|
534
|
+
|
|
535
|
+
The connection and organization IDs are pairing input for the local grant. They
|
|
536
|
+
are not runtime env variables and do not replace host identity checks.
|
|
537
|
+
|
|
538
|
+
Provision the exact site scope with deployment administrator credentials:
|
|
539
|
+
|
|
540
|
+
```bash
|
|
541
|
+
# Convex Cloud development
|
|
542
|
+
npx convex run --deployment dev rastroFederationAdmin:provisionConnection \
|
|
543
|
+
'{"connectionId":"CONNECTION_ID","organizationId":"ORGANIZATION_ID","ownerId":"OPAQUE_HOST_OWNER_ID","siteIds":["SITE_ID"]}'
|
|
544
|
+
|
|
545
|
+
# Synapse development
|
|
546
|
+
synapse convex --dev run rastroFederationAdmin:provisionConnection \
|
|
547
|
+
'{"connectionId":"CONNECTION_ID","organizationId":"ORGANIZATION_ID","ownerId":"OPAQUE_HOST_OWNER_ID","siteIds":["SITE_ID"]}'
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
Never copy site IDs from JWT claims or grant every site belonging to an owner.
|
|
551
|
+
The operator must name one through ten exact site IDs.
|
|
552
|
+
|
|
553
|
+
## Phase 6: verify in order
|
|
554
|
+
|
|
555
|
+
Do not mark integration complete until all checks pass:
|
|
556
|
+
|
|
557
|
+
1. Call `rastroFederation:manifest` without auth and deep-compare its connector
|
|
558
|
+
fields with <https://www.amagerastro.com/agent/manifest.json>.
|
|
559
|
+
2. In **Conexões**, click **Verificar conexão** and confirm active status and
|
|
560
|
+
the exact site count.
|
|
561
|
+
3. Confirm `listSites` omits domains, owner IDs, network IDs, connection IDs,
|
|
562
|
+
and organization IDs.
|
|
563
|
+
4. Open the deployment selector and load Overview for an authorized site.
|
|
564
|
+
5. Trigger a real browser pageview and observe the Live view update reactively.
|
|
565
|
+
6. In the host's `convex-test` suite, use a mocked Rastro identity to query a
|
|
566
|
+
known site outside the grant and require `FEDERATION_SITE_DENIED`.
|
|
567
|
+
7. In the same suite, call without an identity and require
|
|
568
|
+
`FEDERATION_UNAUTHENTICATED`.
|
|
569
|
+
8. Also require the stable failures for a foreign issuer, missing
|
|
570
|
+
`analytics:read`, mismatched connection/organization, duplicate site IDs, and
|
|
571
|
+
eleven requested or granted sites. Prove existing auth providers still pass
|
|
572
|
+
their own tests.
|
|
573
|
+
9. Keep the authenticated dashboard open, revoke the local grant, refresh a
|
|
574
|
+
report, and require `FEDERATION_CONNECTION_DENIED` before central revocation.
|
|
575
|
+
10. Reprovision only if the rehearsal requires restoring the development grant.
|
|
576
|
+
|
|
577
|
+
Use this assertion shape in the host's existing `convex-test` harness after
|
|
578
|
+
registering its schema, modules, component, and `api.rastroFederation`:
|
|
579
|
+
|
|
580
|
+
```ts
|
|
581
|
+
const identity = {
|
|
582
|
+
issuer: RASTRO_FEDERATION_ISSUER,
|
|
583
|
+
subject: "rastro-negative-test",
|
|
584
|
+
tokenIdentifier: `${RASTRO_FEDERATION_ISSUER}|rastro-negative-test`,
|
|
585
|
+
rastro_connection_id: CONNECTION_ID,
|
|
586
|
+
rastro_organization_id: ORGANIZATION_ID,
|
|
587
|
+
rastro_permissions: ["analytics:read"],
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
await expect(
|
|
591
|
+
t.query(api.rastroFederation.overview, allowedArgs),
|
|
592
|
+
).rejects.toMatchObject({ data: { code: "FEDERATION_UNAUTHENTICATED" } });
|
|
593
|
+
await expect(
|
|
594
|
+
t.withIdentity(identity).query(api.rastroFederation.overview, {
|
|
595
|
+
...allowedArgs,
|
|
596
|
+
siteIds: [KNOWN_OUTSIDE_GRANT_SITE_ID],
|
|
597
|
+
}),
|
|
598
|
+
).rejects.toMatchObject({ data: { code: "FEDERATION_SITE_DENIED" } });
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
Consumers must branch on `ConvexError.data.code`, not human-readable text:
|
|
602
|
+
|
|
603
|
+
| Code | Meaning |
|
|
604
|
+
| ------------------------------ | ----------------------------------------------------------------- |
|
|
605
|
+
| `FEDERATION_UNAUTHENTICATED` | No authenticated Rastro identity |
|
|
606
|
+
| `FEDERATION_ISSUER_MISMATCH` | Identity came from another issuer |
|
|
607
|
+
| `FEDERATION_INVALID_CLAIMS` | Required bounded claims or permission are absent |
|
|
608
|
+
| `FEDERATION_CONNECTION_DENIED` | Local grant is missing, malformed, mismatched, or revoked |
|
|
609
|
+
| `FEDERATION_INVALID_SCOPE` | Requested site list is empty, duplicate, malformed, or over limit |
|
|
610
|
+
| `FEDERATION_SITE_DENIED` | A requested site is outside the local grant |
|
|
611
|
+
|
|
612
|
+
## Revocation
|
|
613
|
+
|
|
614
|
+
For immediate denial, revoke the local grant first:
|
|
615
|
+
|
|
616
|
+
```bash
|
|
617
|
+
# Convex Cloud development
|
|
618
|
+
npx convex run --deployment dev rastroFederationAdmin:revokeConnection \
|
|
619
|
+
'{"connectionId":"CONNECTION_ID"}'
|
|
620
|
+
|
|
621
|
+
# Synapse development
|
|
622
|
+
synapse convex --dev run rastroFederationAdmin:revokeConnection \
|
|
623
|
+
'{"connectionId":"CONNECTION_ID"}'
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
Then revoke the central connection in the dashboard. Central revocation stops
|
|
627
|
+
new token issuance; local revocation rejects already-issued tokens immediately.
|
|
628
|
+
Without the local step, a token can remain usable until its ten-minute expiry.
|
|
629
|
+
|
|
630
|
+
## Completion report
|
|
631
|
+
|
|
632
|
+
Return this evidence to the operator:
|
|
633
|
+
|
|
634
|
+
```text
|
|
635
|
+
Target: development | production
|
|
636
|
+
Functions URL: <origin>
|
|
637
|
+
HTTP-actions URL: <origin>
|
|
638
|
+
Package/version: <exact eligible registry version>
|
|
639
|
+
Existing auth providers preserved: yes/no
|
|
640
|
+
Manifest protocol: <value>
|
|
641
|
+
Granted site count: <1..10>
|
|
642
|
+
Ingestion verified: yes/no
|
|
643
|
+
Authorized reports verified: yes/no
|
|
644
|
+
Wrong-site denial verified: yes/no
|
|
645
|
+
Unauthenticated denial verified: yes/no
|
|
646
|
+
Local revocation rehearsed: yes/no
|
|
647
|
+
Production changes performed: yes/no
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
Do not include private keys, admin keys, session cookies, JWTs, or raw customer
|
|
651
|
+
telemetry in the report.
|