Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
109 changes: 107 additions & 2 deletions apps/backend/src/routes/event-capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,15 @@ import {
CaptureDependencyUnavailableError,
CaptureInternalServerError,
CaptureRateLimitedError,
CaptureRejectedRecord,
EventCaptureApi,
MeasurementDeletionAcceptedResponse,
ProtectedEvidenceAcceptedResponse,
} from "@voidhash/api-contracts/event-capture";
import { EventCaptureService } from "@voidhash/core/services/analyticsIngest/EventCaptureService";
import { MeasurementConfigurationService } from "@voidhash/core/services/measurement/MeasurementConfigurationService";
import { MeasurementDeletionService } from "@voidhash/core/services/measurement/MeasurementDeletionService";
import { ProtectedEvidenceService } from "@voidhash/core/services/measurement/ProtectedEvidenceService";
import { Effect } from "effect";
import * as HttpEffect from "effect/unstable/http/HttpEffect";
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
Expand Down Expand Up @@ -60,6 +66,9 @@ export const EventCaptureGroupLive = HttpApiBuilder.group(
(handlers) =>
Effect.gen(function* () {
const captureService = yield* EventCaptureService;
const configurationService = yield* MeasurementConfigurationService;
const deletionService = yield* MeasurementDeletionService;
const protectedEvidenceService = yield* ProtectedEvidenceService;
return handlers
.handle("capture", ({ request, payload }) =>
Effect.gen(function* () {
Expand Down Expand Up @@ -113,7 +122,9 @@ export const EventCaptureGroupLive = HttpApiBuilder.group(

return new CaptureAcceptedResponse({
accepted: result.accepted,
rejected: result.rejected,
rejected: result.rejected.map((record) =>
new CaptureRejectedRecord(record),
),
});
}),
)
Expand Down Expand Up @@ -169,9 +180,103 @@ export const EventCaptureGroupLive = HttpApiBuilder.group(

return new CaptureAcceptedResponse({
accepted: result.accepted,
rejected: result.rejected,
rejected: result.rejected.map((record) =>
new CaptureRejectedRecord(record),
),
});
}),
)
.handle("protected", ({ payload }) =>
protectedEvidenceService.put(payload).pipe(
Effect.map(
(result) =>
new ProtectedEvidenceAcceptedResponse({
accepted: true,
blobId: result.blobId,
}),
),
Effect.catchTag("EffectDrizzleQueryError", () =>
Effect.fail(
new CaptureDependencyUnavailableError({
code: "dependency_unavailable",
error: "protected evidence dependency is unavailable",
}),
),
),
Effect.catchDefect(() =>
Effect.fail(
new CaptureInternalServerError({
code: "internal_error",
error: "internal server error",
}),
),
),
),
)
.handle("deleteMeasurementData", ({ payload }) =>
deletionService.request(payload).pipe(
Effect.map(
(result) =>
new MeasurementDeletionAcceptedResponse({
accepted: true,
deletedProtectedEvidence: result.deletedProtectedEvidence,
requestId: result.requestId,
status: "completed",
}),
),
Effect.catchTag("EffectDrizzleQueryError", () =>
Effect.fail(
new CaptureDependencyUnavailableError({
code: "dependency_unavailable",
error: "measurement deletion dependency is unavailable",
}),
),
),
Effect.catchTag("SqlError", () =>
Effect.fail(
new CaptureDependencyUnavailableError({
code: "dependency_unavailable",
error: "measurement deletion dependency is unavailable",
}),
),
),
Effect.catchDefect(() =>
Effect.fail(
new CaptureInternalServerError({
code: "internal_error",
error: "internal server error",
}),
),
),
),
)
.handle("getMeasurementConfiguration", ({ headers }) =>
configurationService.get(headers["x-publishable-key"]).pipe(
Effect.catchTag("MeasurementConfigSigningError", () =>
Effect.fail(
new CaptureDependencyUnavailableError({
code: "dependency_unavailable",
error: "measurement configuration signing is unavailable",
}),
),
),
Effect.catchTag("EffectDrizzleQueryError", () =>
Effect.fail(
new CaptureDependencyUnavailableError({
code: "dependency_unavailable",
error: "measurement configuration dependency is unavailable",
}),
),
),
Effect.catchDefect(() =>
Effect.fail(
new CaptureInternalServerError({
code: "internal_error",
error: "internal server error",
}),
),
),
),
);
}),
);
49 changes: 49 additions & 0 deletions apps/backend/src/routes/links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {
CreateLinkResponse,
LinksApi,
LinkInvalidRequestError,
LinkRateLimitedError,
LinkServiceUnavailableError,
LinkUnauthorizedError,
} from "@voidhash/api-contracts/links";
import { LinkRedirectService } from "@voidhash/core/services/measurement/LinkRedirectService";
import { Effect } from "effect";
import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";

const publicOrigin = (headers: Readonly<Record<string, string | undefined>>): string => {
const host = headers["x-forwarded-host"]?.split(",")[0]?.trim() ?? headers.host?.trim();
if (!host) return "https://links.voidhash.com";
const protocol = headers["x-forwarded-proto"]?.split(",")[0]?.trim() ?? "https";
return `${protocol}://${host}`;
};

const mapLinkError = (error: unknown):
| LinkInvalidRequestError
| LinkUnauthorizedError
| LinkRateLimitedError
| LinkServiceUnavailableError => {
if (
error instanceof LinkInvalidRequestError ||
error instanceof LinkUnauthorizedError ||
error instanceof LinkRateLimitedError ||
error instanceof LinkServiceUnavailableError
) return error;
return new LinkServiceUnavailableError({ code: "service_unavailable", error: "link dependency is unavailable" });
};

/** HTTP handlers for signed-link creation and deterministic deferred resolution. */
export const LinksGroupLive = HttpApiBuilder.group(LinksApi, "links", (handlers) =>
Effect.gen(function* () {
const service = yield* LinkRedirectService;
return handlers
.handle("createLink", ({ payload, request }) =>
service.create(payload, publicOrigin(request.headers)).pipe(
Effect.map((result) => new CreateLinkResponse(result)),
Effect.mapError(mapLinkError),
),
)
.handle("resolveDeferredLink", ({ payload }) =>
service.resolveDeferred(payload).pipe(Effect.mapError(mapLinkError)),
);
}),
);
43 changes: 43 additions & 0 deletions docs/react-native-measurement/api-reference.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# React Native unified SDK API

Create the client with `createVoidhashClient(publishableKey, options)`. The common client methods are `init`, `capture`, `identify`, `reset`, `purchase`, `restorePurchases`, `flush`, and `end`. Captures use the identity, consent revision, session, and configuration that exist at capture time.

## `client.measurement`

- `configure(patch)` validates and applies collection, session, purchase, context, currency, locale, and protected-identity settings. Purchase observation supports iOS and Android purchase-kind enrichment callbacks; the returned object is validated and snapshotted at observation time.
- `start(options?)` starts or returns the current measurement session.
- `stop(options?)` independently stops collection, upload, or partner sharing.
- `handle(input)` records an explicit location or ATT observation.
- `on(event, listener)` subscribes to `error`, `attribution`, `attributionError`, `conversion`, `delivery`, `purchaseValidation`, or `session`.
- `getState()` returns a redacted local inspector.
- `createSupportBundle()` returns an opt-in, classifier-checked diagnostic document with hashed installation/session IDs.
- `getInstallationId()` returns the opaque local installation identifier.
- `createInviteLink(input)`, `trackInviteShare(input)`, and `trackCrossPromotion(input)` implement signed owned-media links.
- `trackAdRevenue(input)` records a decimal, currency-qualified ad impression.
- `validatePurchase(input)` returns a correlated `valid`, `invalid`, or `indeterminate` result. Inline receipts and legacy Android keys/signatures are rejected.
- `deleteData()` durably records deletion before protected local purge.
- `setTestDevice(enabled)` persists project test-device diagnostics across cold start.

## `client.links`

- `handle({ url, source, receivedAt? })` normalizes a manual or native link through allowlists, wrapped-domain limits, dedupe, and route projection.
- `on("deepLink", listener)` receives the single direct/deferred result stream. Results are `found`, `notFound`, or `error`; raw URLs are never returned.

## `client.consent`

- `set(snapshot)` requires a monotonically increasing revision and records the transition.
- `get()` returns the source snapshot and effective analytics, attribution, upload, and partner-sharing decisions.

## `client.notifications`

- `getPermissionStatus()` observes permission without prompting.
- `requestPermission(options?)` is the only permission-prompting path.
- `register()`, `unregister()`, and `getRegistration()` manage an opaque `pushDeviceTokenId`; raw platform tokens are protected and deleted after registration.
- `setBadgeCount(count)` sets or clears the native badge.
- `on(event, listener)` subscribes to `received`, `opened`, `tokenChanged`, and `registrationError`.

## Errors and capability results

All unified failures derive from `MeasurementError`. `MeasurementConfigurationError` identifies invalid configuration, `MeasurementInputError` invalid inputs, `MeasurementPolicyBlocked` an observable policy denial, and `MeasurementCapabilityUnavailable` an unavailable build/runtime capability. Capability reasons are `notConfigured`, `notImplemented`, `notInstalled`, `unsupported`, or `disabled`; calls do not silently succeed.

Release measurement logs are disabled unless an internal Ed25519-signed diagnostic session is valid for the current project and time. Debug and authorized release logs use the same recursive protected-field redaction.
9 changes: 9 additions & 0 deletions docs/react-native-measurement/bare-react-native.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Bare React Native integration

Install `@voidhash/react-native`, pods, and the Android Gradle dependencies, then make the same native changes produced by the Expo plugin.

On iOS, add associated domains and URL schemes, forward application/scene/SwiftUI URLs to the Voidhash link collector, add `aps-environment` and remote-notification background mode when push is enabled, forward APNs registration/receipt/open callbacks, and set the SKAdNetwork and AdAttributionKit HTTPS postback origins. Select StoreKit 1, StoreKit 2, or disabled purchase observation explicitly. A strict-no-IDFA build must not link an IDFA collector.

On Android, add verified App Link/custom-scheme intent filters and forward `onNewIntent`, register the lifecycle collector before React starts, configure Firebase and notification permission/channel policy, explicitly include or remove AD_ID, retain the measurement database under `noBackupFilesDir`, and include Google Play referrer plus only the requested optional-store providers. Select Billing 8 or disabled observation explicitly.

Configure cloud or self-host origins through `endpoints`. Origins must not contain credentials, path, query, or fragment; production uses HTTPS. Self-host deployments configure rotating signed-measurement keys and a positive configuration version. Run `npx voidhash-doctor`; resolve every error before building the release archive/APK.
35 changes: 35 additions & 0 deletions docs/react-native-measurement/data-dictionary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Measurement data dictionary

Every `MeasurementEnvelopeV1` contains `schemaVersion`, `recordId`, `type`, `occurredAt`, `queuedAt`, `installationId`, `installationSequence`, capture-time `identity` and `consent`, `app`, `device`, `source`, `publicPayload`, and optionally an opaque `protectedPayloadRef`. Session state and monotonic time are included when available. Product analytics carries standardized metadata under `context`, not duplicated inside event properties.

| Record type | Public purpose |
| --- | --- |
| `installation.created.v1` | First open, app release, and collector capability baseline. |
| `installation.updated.v1` | App release transition. |
| `session.started.v1` | Session sequence, reason, and readiness. |
| `session.ended.v1` | End reason and monotonic duration. |
| `identity.changed.v1` | Immutable previous/current identity revisions. |
| `consent.changed.v1` | Immutable previous/current consent and effective policy. |
| `link.received.v1` | Source, app state, time, and protected raw-link reference. |
| `link.resolved.v1` | Direct/deferred status and allowlisted route/campaign projection. |
| `link.routed.v1` | Application routing outcome. |
| `android.install_referrer.v1` | Store outcome, timestamps/version/verification, protected referrer. |
| `android.preinstall.v1` | Typed OEM/preinstall attribution. |
| `ios.adservices.v1` | Availability/timing and protected Apple Ads result. |
| `ios.att.changed.v1` | ATT status transition and source. |
| `identifier.observed.v1` | Identifier kind, policy basis, outcome, and protected reference. |
| `push.token.v1` | Provider, environment, rotation reason, and opaque device-token ID. |
| `push.received.v1` | Allowlisted push metadata and protected payload reference. |
| `push.opened.v1` | Notification/open/link correlation. |
| `revenue.ad_impression.v1` | Impression ID, network, mediation, decimal revenue, currency, and safe dimensions. |
| `purchase.observed.v1` | Store transaction projection and protected receipt/token reference. |
| `purchase.validation_requested.v1` | Correlated validation request, environment, and idempotency key. |
| `purchase.validation_result.v1` | Valid/invalid/indeterminate outcome, store state, and failure class. |
| `diagnostic.capability.v1` | Collector/build capability and redacted error state. |
| `partner.context_changed.v1` | Partner IDs, configuration revision, and protected partner-context reference. |

Protected vault purposes are `advertising-identifier`, `diagnostic-authorization`, `email`, `install-referrer`, `link-capture`, `partner-context`, `phone`, `purchase-receipt`, and `push-token`. A vault row carries its opaque blob ID, consent revision, retention class, encryption-key version, deletion state, and upload state. Ciphertext and raw values are excluded from public records, reports, state, and support bundles.

`purchase.validation_result.v1` may carry normalized cancellation, pause/resume, offer, replacement, prepaid/top-up, price-change, line-item, and test-environment state. It never carries the raw store response; that response uses `purchase-receipt` protected storage.

Standard event aliases are: `add payment info`, `add to cart`, `add to wishlist`, `complete registration`, `initiated checkout`, `invite shared`, `level achieved`, `location`, `login`, `purchase`, `rate`, `search`, `share`, `spent credits`, `subscribe`, `tutorial completion`, `unlock achievement`, `viewed content`, and the SDK-only automatic `opened from push notification` event. Revenue is represented only by explicit purchase/ad-revenue fields; aliases do not infer revenue.
13 changes: 13 additions & 0 deletions docs/react-native-measurement/operations-runbook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Measurement operations and support runbook

Use `measurement.getState()` first. Record SDK/native/config versions, readiness, signed-config version, capability states, outbox counts, and last delivery outcome. Use `measurement.createSupportBundle()` only with operator/user consent; never request raw URLs, store receipts, push tokens, advertising identifiers, email, phone, protected ciphertext, or configuration key material.

For delivery backlog, separate policy-blocked, retry-scheduled, and quarantined records. A 429 must preserve the server `Retry-After`; 5xx/network failures use bounded backoff; 413 recursively splits and quarantines only a failing single record. Verify protected evidence is acknowledged before investigating its referencing public record. Do not manually acknowledge or delete evidence to clear an alert.

For self-hosted deployments, verify the API, ingest, and links origins independently. Rotate configuration signing keys by publishing the new public key ID alongside the old ID, deploying the new signer, confirming a higher signed version is accepted, and only then removing the old trust entry. Never lower a configuration version or reuse a signing key ID with different key material.

Deletion incidents are tracked by request ID and installation/person scope. Confirm the durable client request, protected-vault purge, raw/derived-data deletion, and partner-send suppression. Retention exceptions require a documented legal basis and must remain unavailable to ordinary analytics reads.

Partner incidents are investigated from append-only send/suppression audit rows: trigger ID, partner, current consent revision, filtered fields, result, and reason. Do not replay a postback until its idempotency key and current send-time policy have been checked.

Release operators attach physical-device results for the fourteen scenarios, the Android/iOS matrix cells, the offline soak, self-host run, store/campaign runs, privacy/store disclosures, retention review, and security/legal/support approvals to the release decision record.
Loading
Loading