Skip to content
Merged
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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions .claude/rules/consent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
---
paths:
- "packages/core/**"
---

# Consent Standards

## State Machine

Consent is a reactive state machine with event queuing, not a boolean gate.

- **Merge, not replace:** `setState({ analytics: true })` merges with existing state
- **AND logic:** Destination requiring `["analytics", "marketing"]` needs BOTH granted
- **"necessary" and "exempt" always allowed:** Never blocked by consent
- **Pending = undefined:** Categories not yet set are pending, not denied
- **Lenient by default:** Events pass through; strict mode is opt-in

### Event Queuing

Events arriving before consent resolves are queued and replayed when consent changes.

- Deduplicated by event ID
- `sentTo` Set tracks which destinations already received each event
- Queue expires based on `queueTimeout` (default 30s)
- In strict mode, queuing is disabled — pending = denied, events drop

### CMP Fallback

~15-20% of privacy-conscious users block CMP scripts. Use `consentFallback`:

```typescript
consent: {
consentFallback: {
timeout: 3000,
state: { analytics: false, marketing: false }
}
}
```

### DNT & GPC

When `respectDNT` or `respectGPC` is enabled:
- DNT disables `analytics` and `marketing`
- GPC additionally disables `personalization`

### ConsentSignals (Separate from Destinations)

Vendor consent protocols (e.g. Google Consent Mode) are modeled as ConsentSignals, not part of destinations:

```typescript
consentSignals: [
googleConsentMode({ waitForUpdate: 500 })
]
```

## Timing Heuristics

Not all "pending" consent is equal. Junction distinguishes the signal behind why consent hasn't resolved.

| Signal | Meaning | Action |
|--------|---------|--------|
| CMP blocked | Script never loads | `consentFallback` (3s) |
| CMP loaded, no interaction | Banner visible | Queue, `queueTimeout` (30s) |
| CMP loaded, dismissed | Closed without choosing | Conservative defaults |

**Future direction:** Accept a `consentSignalHint` from CMP integrations:
- `"blocked"` → fast fallback (seconds)
- `"visible"` → patient queue (30s+)
- `"dismissed"` → conservative defaults immediately

### SPA vs MPA

- **MPA:** Navigation = unload = queue loss. First pageview uniquely fragile.
- **SPA:** User stays on page, consent resolves naturally.
- May be best addressed at integration layer (e.g. `@junctionjs/next` defaults) rather than core.

## Necessary vs. Exempt

Two always-allowed consent categories serving different layers of the privacy stack.

- **necessary**: CMP/storage layer. Cookies and web storage required for site functionality. Pinned to `true` in state. Legal basis: ePrivacy Art 5(3).
- **exempt**: Data/dispatch layer. Destinations that receive events regardless of consent state. Used for first-party observability. Legal basis: GDPR Art 6(1)(f) legitimate interest.

### Why Two Categories

They operate at different enforcement layers:
- CMPs enforce **storage** (cookies, localStorage)
- Junction enforces **dispatch** (network requests to destinations)

### Guardrails (required)

- **First-party only**: Exempt destinations must be first-party or contractually bound processors
- **Legal basis declaration**: Each exempt destination should declare its basis
- **Audit trail**: Events dispatched to exempt destinations carry `is_exempt: true` metadata
- **Transparency**: Emit `destination:exempt` events for debug panels and audit logs

| Aspect | necessary | exempt |
|--------|-----------|--------|
| Consent UI | Visible | Hidden |
| Consent queue | Participates | Bypasses |
| Legal basis | ePrivacy strictly necessary | GDPR legitimate interest |
| User can disable | No (always on) | No (operational) |
96 changes: 96 additions & 0 deletions .claude/rules/destinations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
---
paths:
- "packages/destination-*/**"
---

# Destination Standards

## Interface

Destinations are plain objects with async functions, not classes.

- Tree-shakeable (classes aren't), composable, easy to test
- Use factory functions (`createGA4()`, `http(config)`), not `new`

```typescript
interface Destination<TConfig> {
name: string;
version: string;
consent: ConsentCategory[]; // AND logic
runtime: "client" | "server" | "both";

init: (config: TConfig) => Promise<void> | void;
transform: (event: JctEvent, config: TConfig) => unknown | null;
send: (payload: unknown, config: TConfig) => Promise<void>;
onConsent?: (state: ConsentState) => void;
teardown?: () => Promise<void> | void;
}
```

- `transform` returns `null` to skip an event for this destination
- `transform` is a pure function — no side effects, no network calls
- `send` handles all network I/O
- Config is type-generic: `Destination<GA4Config>`

## Event Name Mapping

All destinations use a 3-tier fallback:

1. **Config override** — `config.eventNameMap["product:viewed"]`
2. **Default map** — Built-in mapping per destination
3. **Generated name** — `entity_action` (or destination-specific format)

```typescript
function getEventName(event: JctEvent, config: Config): string {
const key = `${event.entity}:${event.action}`;
return config.eventNameMap?.[key]
?? DEFAULT_MAP[key]
?? `${event.entity}_${event.action}`;
}
```

### Per-Destination Defaults

| entity:action | GA4 | Amplitude | Meta |
|---|---|---|---|
| page:viewed | page_view | Page Viewed | PageView |
| product:viewed | view_item | Product Viewed | ViewContent |
| product:added | add_to_cart | Product Added | AddToCart |
| order:completed | purchase | Order Completed | Purchase |

## Script Loading

Client-side destinations that load vendor scripts use a queue-before-load pattern:

- Always check `typeof window === "undefined"` first (SSR safety)
- Always check if already loaded (idempotent)
- Create queuing stub before loading script
- Use `script.async = true`
- Support custom script URLs via config
- Gate loading with `loadScript?: boolean` config (default: true)

## System Events

The `_system` entity is reserved for internal lifecycle events. All destinations must filter them:

```typescript
transform(event: JctEvent, config: Config) {
if (event.entity === "_system") return null;
// ... normal transformation
}
```

## Error Isolation

The collector must never crash. Every external boundary is wrapped in try/catch.

| Boundary | Failure behavior |
|---|---|
| `destination.init()` | Logged, destination skipped, collector continues |
| `destination.transform()` | Logged, event skipped for that destination, others still receive it |
| `destination.send()` | `.catch()` logs error, no await blocking |
| Consent listeners | Caught per-listener, other listeners still fire |

- Use `emit("destination:error", ...)` so consumers can observe failures
- Never let one destination's failure affect another
- Prefix all console output with `[Junction]`
41 changes: 41 additions & 0 deletions .claude/rules/events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
paths:
- "packages/core/**"
- "packages/client/**"
---

# Event Standards

## Entity:Action Pairs

All events use `entity:action` pairs, not flat strings.

```typescript
// Good
track("product", "viewed", { product_id: "123" })
track("order", "completed", { order_id: "456" })

// Bad
track("Product Viewed", { product_id: "123" })
```

## Naming Rules

- **Entities:** lowercase, singular or plural (`product`, `page`, `order`, `user`)
- **Actions:** lowercase, past tense preferred (`viewed`, `added`, `completed`, `signed_up`)
- **Combined key:** `entity:action` used for contract lookup and destination mapping
- Wildcard contracts (`entity:*`) match all actions for an entity
- If no contract exists, events pass through unvalidated

## Destination Mapping

Destinations map entity:action to vendor event names:

```typescript
const GA4_EVENT_MAP: Record<string, string> = {
"page:viewed": "page_view",
"product:viewed": "view_item",
"product:added": "add_to_cart",
"order:completed": "purchase",
};
```
35 changes: 35 additions & 0 deletions .claude/rules/gateway.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
paths:
- "packages/gateway/**"
---

# Gateway Standards

## Request Flow

```
POST /collect
→ CORS preflight (OPTIONS)
→ Auth check (bearer token or x-api-key)
→ Parse JSON body: { events: JctEvent[], consent?: ConsentState }
→ Apply client-reported consent
→ Per event: set context, identify if userId, track(entity, action, props)
→ Flush immediately
→ Return { ok: true, received: count }
```

## Edge-First Design

- **Flush immediately** after processing — edge functions are short-lived
- No batching or queueing at gateway level (client SDK handles that)
- Uses only Web Standard APIs: Request, Response, URL, fetch, JSON
- Zero platform-specific imports

## Server Context Enrichment

Gateway extracts server-side context per request:
- IP: `cf-connecting-ip` → `x-forwarded-for` → `x-real-ip`
- Geo: `cf-ipcountry`, `cf-ipregion`, `cf-ipcity`
- User-Agent, Referer

Merged with client context via `resolveContext` closure.
72 changes: 72 additions & 0 deletions .claude/rules/packages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
---
paths:
- "packages/**"
---

# Package Standards

## Dependency Layering

Packages form a strict hierarchy. No circular deps.

```
@junctionjs/{astro,next} <- Framework integrations
@junctionjs/destination-* <- Destinations
@junctionjs/{client,gateway} <- Runtime wrappers
@junctionjs/core <- Foundation
```

| Package type | How it depends on core |
|---|---|
| `client`, `gateway` | `dependencies` |
| `destination-*` | `peerDependencies` |
| Framework integrations | `peerDependencies` on core + client |

- Destinations must never import from `client` or `gateway`
- Core has no workspace dependencies (only Zod)
- No package may depend on a package in a higher layer

## ESM-Only Build

All packages output ESM only. No CJS.

```bash
tsup src/index.ts --format esm --dts --sourcemap --target es2022 --no-config
```

- Always: `--format esm --dts --sourcemap --target es2022 --no-config`
- Mark workspace dependencies as `--external`
- Mark framework dependencies as `--external`
- No tsup config files — CLI flags only
- Output to `dist/`

## Package Exports

All packages use `@junctionjs/*` scope.

- Destinations: `@junctionjs/destination-{provider}`
- Framework integrations: `@junctionjs/{framework}`

```json
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
}
```

- ESM only — no `require` condition
- Every export needs both `.js` and `.d.ts`
- `files` field: `["dist", "README.md"]`
- `publishConfig`: `{ "access": "public", "provenance": true }`

## Code Style (Biome)

- Double quotes, always semicolons, trailing commas everywhere
- 2-space indent, 120 char line width
- Unused variables/imports: warn (not error)
- `noExplicitAny`: off, `noNonNullAssertion`: off
- Import organization enabled (Biome sorts imports)
- Never add ESLint or Prettier configs
- Don't disable Biome rules without good reason
Loading
Loading