backend-manager 5.2.12 → 5.2.14

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/CHANGELOG.md CHANGED
@@ -14,6 +14,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
14
14
  - `Fixed` for any bug fixes.
15
15
  - `Security` in case of vulnerabilities.
16
16
 
17
+ # [5.2.14] - 2026-05-28
18
+
19
+ ### Removed
20
+
21
+ - **Dropped the `marketing-webhooks` Firestore idempotency ledger.** The marketing-webhook dispatcher (`src/manager/routes/marketing/webhook/post.js`) no longer reads/writes `marketing-webhooks/{eventId}` docs for dedup. Both handler side effects — writing `consent.marketing.status = 'revoked'` and the cross-provider `mailer.remove()` — are naturally idempotent, so a provider retry or duplicate parent fan-out re-runs to the same end state with no extra side effects. (This is the key difference from `payments-webhooks`, where dedup is load-bearing because payment side effects are NOT idempotent.) Events with no `eventId` are now processed instead of skipped, since dedup is no longer required. Removed `marketing-webhooks` from the test runner's pre-test cleanup list (`src/test/test-accounts.js`). Consumers with an existing `marketing-webhooks` collection can safely delete it — nothing reads or writes it anymore.
22
+
23
+ ### Fixed
24
+
25
+ - **`libraries/infer-contact.js`: guard the optional `assistant` arg.** All log/error calls now use `assistant?.` so `inferContact(email)` works without an assistant. Previously threw a `TypeError` when `BACKEND_MANAGER_OPENAI_API_KEY` was unset and no assistant was passed.
26
+ - **`libraries/email/marketing/index.js`: gate `Marketing.remove()` behind test mode.** `remove()` now short-circuits with `if (assistant.isTesting() && !process.env.TEST_EXTENDED_MODE) return {}`, matching `add()` and `sync()`. Closes a gap where auth `onDelete` (fired ~44× at test startup during user cleanup) could hit the live SendGrid/Beehiiv remove APIs during a normal test run. The gate lives at the library SSOT so every caller (onDelete, webhook processors, contact-delete route) inherits it.
27
+
28
+ ### Changed
29
+
30
+ - **Signup timestamps stamped from Firebase Auth `creationTime`.** Both write paths — the `onCreate` auth event (`src/manager/events/auth/on-create.js`) and the `user/signup` fallback route (`src/manager/routes/user/signup/post.js`) — now set `metadata.created` and `consent.{legal,marketing}.grantedAt`/`revokedAt` from `user.metadata.creationTime` instead of "now"/request-time. The OMEGA user migration treats Auth's `creationTime` as the SSOT and reconciles every doc against it; the prior few-seconds drift meant every new signup got re-fixed on the next migration run. Stamping from `creationTime` makes new docs match the migration's expected value exactly, ending the recurring churn.
31
+ - **`user/signup`: `flags.signupProcessed` is the sole idempotency gate.** Removed the 5-minute account-age reject (`MAX_ACCOUNT_AGE_MS`). A genuinely-unprocessed account can now complete signup whenever it retries — fixes the failure where a slow/missing `onCreate` plus a retry after 5 minutes caused a legitimate signup to be rejected. The frontend (UJM) gate is being moved to the same doc flag in a parallel change.
32
+ - **CLI: port-conflict prompt auto-confirms after 5s.** The "Kill these processes to free the ports?" prompt in `src/cli/commands/base-command.js` now auto-confirms `Y` after 5 seconds of no input (via `AbortSignal.timeout` → `AbortPromptError` → default `true`), so unattended test/dev loops (`emulator`, `serve`, `test`) no longer hang waiting for input. Manual `y`/`n` and Ctrl+C still work.
33
+ - **`AI` `claude-code` provider rewritten for serverside use** (`src/manager/libraries/ai/providers/claude-code.js`). Was a local-only wrapper around `@anthropic-ai/claude-agent-sdk` that spawned the `claude` binary (`forceLoginMethod: 'claudeai'`, keychain OAuth) — would not run in Cloud Functions. Now calls the Claude Messages API over plain HTTPS via `@anthropic-ai/sdk` using the OAuth token as `Authorization: Bearer` + `anthropic-beta: oauth-2025-04-20`, so it bills the Claude Pro/Max subscription (not API credits) and runs anywhere Node runs. Token resolution: `options.apiKey` → `config.claude_code.oauth_token` → `process.env.CLAUDE_CODE_OAUTH_TOKEN`. Renewal is a manual yearly `claude setup-token` (no auto-refresh). Verified live (text + JSON-schema paths). See `docs/ai-library.md`.
34
+ - **Dependency bumps**: `@anthropic-ai/claude-agent-sdk` ^0.3.152 → ^0.3.153, `stripe` ^22.1.1 → ^22.2.0.
35
+ - **`templates/_.env`**: consolidate OpenAI/Anthropic keys under an "AI" section and add `CLAUDE_CODE_OAUTH_TOKEN`.
36
+ - **Marketing webhook tests** (`test/routes/marketing/webhook.js`): renamed `*-duplicate-event-skipped` → `*-reprocessed-idempotently` (assert re-delivery reprocesses and the user stays revoked); `sendgrid-event-without-eventId-*` now asserts the event is processed; beehiiv idempotency variant skips when no publication is configured. Updated `docs/consent.md` and `docs/testing.md` to describe the no-ledger design.
37
+
17
38
  # [5.2.12] - 2026-05-27
18
39
 
19
40
  ### Changed
@@ -6,18 +6,32 @@
6
6
  |---|---|---|
7
7
  | `openai` | `gpt-5-mini` | Better at structured JSON via JSON schema |
8
8
  | `anthropic` | `claude-sonnet-4-6` | Better at SVG illustrations and creative output |
9
+ | `claude-code` | `claude-opus-4-7` | Same Claude models as `anthropic`, but bills a Claude Pro/Max **subscription** instead of API credits |
9
10
 
10
11
  Return shape (same for all providers): `{ content, output, tokens, raw }`.
11
12
 
12
- `options.response: 'json'` triggers JSON parsing — both providers strip fences and parse with JSON5 for robustness. `options.schema` enforces structure on OpenAI (real JSON schema) and is injected into the system prompt on Anthropic.
13
+ `options.response: 'json'` triggers JSON parsing — all providers strip fences and parse with JSON5 for robustness. `options.schema` enforces structure on OpenAI (real JSON schema) and is injected into the system prompt on Anthropic / claude-code.
13
14
 
14
15
  API keys: `BACKEND_MANAGER_OPENAI_API_KEY`, `BACKEND_MANAGER_ANTHROPIC_API_KEY` (process.env or config).
15
16
 
17
+ ## `claude-code` provider — subscription billing
18
+
19
+ The `claude-code` provider hits the same Claude Messages API as `anthropic`, but authenticates with a **Claude Code OAuth token** (`Authorization: Bearer ...` + `anthropic-beta: oauth-2025-04-20`) so usage bills the Claude Pro/Max subscription tied to the token rather than API credits.
20
+
21
+ It is **pure HTTPS** — no `claude` binary, no subprocess — so it runs in Cloud Functions / CI / anywhere Node runs.
22
+
23
+ Token resolution (first match wins): `options.apiKey` / constructor key → `config.claude_code.oauth_token` → `process.env.CLAUDE_CODE_OAUTH_TOKEN`.
24
+
25
+ Mint the token with `claude setup-token` (valid ~1 year). When it expires, requests 401 — re-mint and update the `CLAUDE_CODE_OAUTH_TOKEN` secret. There is no automatic refresh; renewal is a manual yearly step.
26
+
27
+ > **Caveats:** the Bearer/beta subscription path is undocumented and may change. Usage is subject to the subscription's rate limits (not API-tier limits). For high-volume production traffic, prefer `anthropic` + `BACKEND_MANAGER_ANTHROPIC_API_KEY`.
28
+
16
29
  The legacy `src/manager/libraries/openai.js` is a thin compatibility shim that re-exports the OpenAI provider class — existing callers using `new OpenAI(assistant, key)` still work unchanged.
17
30
 
18
31
  | File | Purpose |
19
32
  |---|---|
20
33
  | `src/manager/libraries/ai/index.js` | Unified `AI` class (dispatches by provider) |
21
34
  | `src/manager/libraries/ai/providers/openai.js` | OpenAI provider (original `openai.js` content) |
22
- | `src/manager/libraries/ai/providers/anthropic.js` | Anthropic provider (Claude Messages API) |
35
+ | `src/manager/libraries/ai/providers/anthropic.js` | Anthropic provider (Claude Messages API, x-api-key, API credits) |
36
+ | `src/manager/libraries/ai/providers/claude-code.js` | claude-code provider (Claude Messages API, OAuth Bearer, subscription billing) |
23
37
  | `src/manager/libraries/openai.js` | Back-compat shim → providers/openai.js |
package/docs/consent.md CHANGED
@@ -121,9 +121,9 @@ POST /backend-manager/marketing/webhook?provider=beehiiv&key=<BACKEND_MANAGER_WE
121
121
  The dispatcher loads `processors/{provider}.js`, parses the event(s), and for each event:
122
122
 
123
123
  1. Checks `isSupported(eventType)` — filters out non-revoke events like `delivered` / `open`.
124
- 2. Idempotency lookup in `marketing-webhooks/{eventId}` skips if already processed.
125
- 3. Calls `handleEvent({ Manager, assistant, parsed })` on the processor.
126
- 4. Marks the idempotency doc `status: 'completed'` (or `'failed'` with error details).
124
+ 2. Calls `handleEvent({ Manager, assistant, parsed })` on the processor.
125
+
126
+ There is **no idempotency ledger**. Both handler side effects — writing `consent.marketing.status = 'revoked'` and calling `mailer.remove()` — are idempotent, so a provider retry (or a duplicate fan-out from the parent) re-runs to the same end state with no extra side effects. This is the key difference from `payments-webhooks`, where dedup is load-bearing because payment side effects are not idempotent.
127
127
 
128
128
  Each processor's `handleEvent` does the same shape of work:
129
129
 
@@ -159,7 +159,7 @@ The parent forwarder:
159
159
  2. Reads the `brands` collection from the parent's own Firestore.
160
160
  3. For each brand: derives the child API URL by inserting `api.` into the brand's URL (`https://somiibo.com` → `https://api.somiibo.com/backend-manager/marketing/webhook?provider=X&key=Y`).
161
161
  4. POSTs the raw provider body to every child in parallel via `Promise.allSettled`.
162
- 5. Returns 200 even if some children fail — child idempotency makes provider retries safe.
162
+ 5. Returns 200 even if some children fail — idempotent child handlers make provider retries (and re-fans) safe.
163
163
 
164
164
  ### Why fan-out instead of central processing
165
165
 
@@ -167,7 +167,7 @@ Each brand has its own Firebase project, so its `users` collection is separate.
167
167
 
168
168
  - **Correct per-brand updates** — only brands where the user actually has an account update their user docs.
169
169
  - **Failure isolation** — one child being down doesn't block updates on the others.
170
- - **Local idempotency** — each child tracks `marketing-webhooks/{eventId}` in its own Firestore.
170
+ - **Idempotent handlers** — re-processing the same event (provider retry or re-fan) produces the same end state, so no dedup ledger is needed.
171
171
  - **No new schema** — no need for the parent to maintain a brand → publication map; each child filters on its own.
172
172
 
173
173
  ### Why self IS in the fan-out
package/docs/testing.md CHANGED
@@ -19,7 +19,7 @@ What the runner wipes pre-test (in [src/test/test-accounts.js](../src/test/test-
19
19
 
20
20
  1. **`meta/stats`** doc ensured (required for on-create batch writes).
21
21
  2. **`users/_test-*`** Firebase Auth users + Firestore docs (delete).
22
- 3. **Mixed Firestore collections** — `payments-orders`, `payments-webhooks`, `payments-intents`, `payments-disputes`, `marketing-webhooks`. Two-pass cleanup per collection:
22
+ 3. **Mixed Firestore collections** — `payments-orders`, `payments-webhooks`, `payments-intents`, `payments-disputes`. Two-pass cleanup per collection:
23
23
  - Pass 1 — owner-keyed: `where('owner', 'in', [...testUids])` (batched at 30 uids per `in` query).
24
24
  - Pass 2 — id-keyed: any doc whose ID starts with `_test-` (catches ownerless test docs like dispute alerts and raw test webhooks).
25
25
  4. **Test-only Firestore collections** — `_test`, `_test_query` — wiped in full.
@@ -52,6 +52,8 @@ The rule: **never put cleanup at the END of a test file or suite for the purpose
52
52
 
53
53
  Several routes/handlers skip external API calls (SendGrid, Beehiiv, Stripe webhooks, dispute handlers, marketing libraries) when `process.env.TEST_EXTENDED_MODE` is unset, so unit tests don't fire real emails or webhook side effects. Set the flag to opt **in** to those side effects for a full end-to-end run.
54
54
 
55
+ The marketing library gates at the SSOT level: `Marketing.add()`, `Marketing.sync()`, and `Marketing.remove()` each short-circuit with `if (assistant.isTesting() && !process.env.TEST_EXTENDED_MODE) return {}` before touching any provider. Callers (auth `onDelete`, webhook processors, contact-delete route) inherit the gate for free — do NOT rely on a per-caller guard for provider safety; add the gate to the library method itself when introducing a new provider-touching method.
56
+
55
57
  **Live sync — no env coordination across terminals.** The flag flows automatically from the test command to the running emulator via a small shared state file at `<projectRoot>/.temp/test-mode.json`. The test command writes the file pre-flight; the emulator's function workers watch it via `fs.watch` and mutate their own `process.env.TEST_EXTENDED_MODE` in place. Effect: you only need to set the flag on **the test command**. The emulator follows.
56
58
 
57
59
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.2.12",
3
+ "version": "5.2.14",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -49,7 +49,7 @@
49
49
  }
50
50
  },
51
51
  "dependencies": {
52
- "@anthropic-ai/claude-agent-sdk": "^0.3.152",
52
+ "@anthropic-ai/claude-agent-sdk": "^0.3.153",
53
53
  "@anthropic-ai/sdk": "^0.99.0",
54
54
  "@firebase/rules-unit-testing": "^5.0.1",
55
55
  "@google-cloud/firestore": "^7.11.6",
@@ -86,7 +86,7 @@
86
86
  "pushid": "^1.0.0",
87
87
  "sanitize-html": "^2.17.4",
88
88
  "sharp": "^0.34.5",
89
- "stripe": "^22.1.1",
89
+ "stripe": "^22.2.0",
90
90
  "uid-generator": "^2.0.0",
91
91
  "uuid": "^14.0.0",
92
92
  "wonderful-fetch": "^2.0.5",
@@ -130,10 +130,25 @@ class BaseCommand {
130
130
  }
131
131
  }
132
132
 
133
- const shouldKill = await confirm({
134
- message: 'Kill these processes to free the ports?',
135
- default: true,
136
- });
133
+ // Auto-confirm (Y) after a few seconds of no input so unattended test/dev loops don't
134
+ // hang. When the timeout fires the prompt is aborted via AbortSignal and we fall back to
135
+ // the default (true). inquirer owns the cursor and can't live-update its own message, so
136
+ // the countdown is shown statically in the prompt.
137
+ const AUTO_CONFIRM_SECONDS = 5;
138
+ let shouldKill;
139
+ try {
140
+ shouldKill = await confirm(
141
+ { message: `Kill these processes to free the ports? (auto-Y in ${AUTO_CONFIRM_SECONDS}s)`, default: true },
142
+ { signal: AbortSignal.timeout(AUTO_CONFIRM_SECONDS * 1000) },
143
+ );
144
+ } catch (error) {
145
+ // AbortPromptError (timeout reached) → take the default (true). Re-throw anything else.
146
+ if (error?.name !== 'AbortPromptError') {
147
+ throw error;
148
+ }
149
+ this.log(chalk.gray(' No input — auto-confirming (Y).'));
150
+ shouldKill = true;
151
+ }
137
152
 
138
153
  if (!shouldKill) {
139
154
  this.log(chalk.gray('\n Aborting. Free the ports and try again.\n'));
@@ -72,6 +72,18 @@ module.exports = async ({ Manager, assistant, user, context, libraries }) => {
72
72
  const meta = Manager.Metadata().set({ tag: 'auth:on-create' });
73
73
  userRecord.metadata = { ...userRecord.metadata, ...meta };
74
74
 
75
+ // Stamp metadata.created from Firebase Auth's creationTime (the canonical account-creation
76
+ // moment) rather than the User schema's "now" — otherwise the doc lands a beat after Auth,
77
+ // and the OMEGA user migration reconciles every new signup against Auth on its next run.
78
+ const creationTime = user.metadata?.creationTime;
79
+ if (creationTime) {
80
+ const createdDate = new Date(creationTime);
81
+ userRecord.metadata.created = {
82
+ timestamp: createdDate.toISOString(),
83
+ timestampUNIX: Math.round(createdDate.getTime() / 1000),
84
+ };
85
+ }
86
+
75
87
  assistant.log(`onCreate: Creating user doc for ${user.uid}`, userRecord);
76
88
 
77
89
  // Write user doc with retry
@@ -1,30 +1,40 @@
1
1
  /**
2
- * Claude Code provider — uses @anthropic-ai/claude-agent-sdk to call Claude via
3
- * the local user's Claude Code subscription (no API key, no Anthropic credits).
2
+ * Claude Code provider — calls Claude over plain HTTPS using a Claude Code /
3
+ * Claude Pro/Max OAuth token (NOT API credits).
4
4
  *
5
- * Pass `forceLoginMethod: 'claudeai'` so the SDK auths via the OS keychain
6
- * OAuth session that the user already logged into via the `claude` CLI.
5
+ * Unlike the Anthropic provider (which authenticates with an x-api-key and bills
6
+ * API credits), this provider sends the OAuth token as `Authorization: Bearer ...`
7
+ * plus the `anthropic-beta: oauth-2025-04-20` header, so usage bills against the
8
+ * Claude subscription tied to the token.
7
9
  *
8
- * This is strictly a local-development provider:
9
- * - Requires a logged-in Claude Code session on the host machine
10
- * - Will not work in Cloud Functions / CI / production
11
- * - Subject to your Claude Pro/Max rate limits (not API-tier limits)
10
+ * Token resolution (first match wins):
11
+ * 1. explicit key passed to the constructor / options.apiKey
12
+ * 2. Manager config: config.claude_code.oauth_token
13
+ * 3. process.env.CLAUDE_CODE_OAUTH_TOKEN (from `claude setup-token`)
12
14
  *
13
- * Returns the same { content, output, tokens, raw } shape as the other providers
14
- * so callers don't care which is in use.
15
+ * This is pure HTTPS no `claude` binary, no subprocess so it runs in Cloud
16
+ * Functions / CI / anywhere Node runs. It is subject to the token's subscription
17
+ * rate limits (not API-tier limits).
18
+ *
19
+ * The OAuth token is minted with `claude setup-token` (valid ~1 year). When it
20
+ * expires (requests 401), re-mint and update the CLAUDE_CODE_OAUTH_TOKEN secret.
21
+ * NOTE: the Bearer/beta subscription path is undocumented and may change.
22
+ *
23
+ * Returns the same { content, output, tokens, raw } shape as the other providers.
15
24
  */
16
- const DEFAULT_MODEL = 'claude-opus-4-7';
17
-
18
- // Lazy import — only load the SDK if this provider is actually used
19
- let _query;
20
-
21
- function loadQuery() {
22
- if (!_query) {
23
- _query = require('@anthropic-ai/claude-agent-sdk').query;
24
- }
25
+ const _ = require('lodash');
26
+ const JSON5 = require('json5');
25
27
 
26
- return _query;
27
- }
28
+ const DEFAULT_MODEL = 'claude-opus-4-7';
29
+ const OAUTH_BETA = 'oauth-2025-04-20';
30
+
31
+ // Pricing per 1M tokens (USD) — informational only; subscription billing is flat.
32
+ const MODEL_TABLE = {
33
+ 'claude-opus-4-7': { input: 15.00, output: 75.00 },
34
+ 'claude-opus-4-6': { input: 15.00, output: 75.00 },
35
+ 'claude-sonnet-4-6': { input: 3.00, output: 15.00 },
36
+ 'claude-haiku-4-5': { input: 1.00, output: 5.00 },
37
+ };
28
38
 
29
39
  function ClaudeCode(assistant, key) {
30
40
  const self = this;
@@ -32,7 +42,9 @@ function ClaudeCode(assistant, key) {
32
42
  self.assistant = assistant;
33
43
  self.Manager = assistant?.Manager;
34
44
  self.user = assistant?.user;
35
- // key is ignored — claude-code uses OS keychain OAuth via forceLoginMethod: 'claudeai'
45
+ self.token = key
46
+ || self.Manager?.config?.claude_code?.oauth_token
47
+ || process.env.CLAUDE_CODE_OAUTH_TOKEN;
36
48
 
37
49
  self.tokens = {
38
50
  total: { count: 0, price: 0 },
@@ -47,121 +59,110 @@ ClaudeCode.prototype.request = async function (options) {
47
59
  const self = this;
48
60
  const assistant = self.assistant;
49
61
 
50
- options = options || {};
51
- const model = options.model || DEFAULT_MODEL;
62
+ options = _.merge({}, options);
63
+ options.model = options.model || DEFAULT_MODEL;
64
+ options.maxTokens = options.maxTokens || 2048;
65
+ options.temperature = typeof options.temperature === 'undefined' ? 0.7 : options.temperature;
52
66
 
53
- // Build prompt + system from the unified options shape
54
- const { system, prompt } = extractPromptAndSystem(options);
67
+ const token = options.apiKey || self.token;
55
68
 
56
- if (!prompt) {
57
- throw new Error('claude-code provider requires options.message.content or options.messages with a user turn');
69
+ if (!token) {
70
+ throw new Error('claude-code provider requires a Claude OAuth token (set CLAUDE_CODE_OAUTH_TOKEN via `claude setup-token`)');
58
71
  }
59
72
 
60
- const query = loadQuery();
61
- const startTime = Date.now();
73
+ // Lazy-require the SDK so projects that don't use this provider don't need it.
74
+ // We use the SDK purely as an HTTP client — `authToken` makes it send
75
+ // `Authorization: Bearer <token>` instead of `x-api-key`, and the beta header
76
+ // selects the subscription (OAuth) billing path. No `claude` binary involved.
77
+ const SDK = require('@anthropic-ai/sdk');
78
+ const client = new SDK({
79
+ authToken: token,
80
+ defaultHeaders: { 'anthropic-beta': OAUTH_BETA },
81
+ });
62
82
 
63
- // Build SDK options
64
- const sdkOptions = {
65
- model,
66
- forceLoginMethod: 'claudeai', // Use Claude Pro/Max subscription, not API key
67
- allowedTools: [], // Disable all built-in tools — we just want text/JSON in/out
68
- settingSources: [], // Don't load .claude/ or ~/.claude/ settings
69
- includePartialMessages: false,
83
+ const { system, messages } = buildMessages(options);
84
+
85
+ let systemFinal = system;
86
+
87
+ if (options.response === 'json') {
88
+ systemFinal = `${systemFinal || ''}\n\nYou MUST respond with valid JSON only. No prose, no markdown fences, no explanation — just the JSON object.${options.schema ? `\n\nThe JSON must conform to this schema:\n${JSON.stringify(options.schema)}` : ''}`.trim();
89
+ }
90
+
91
+ const requestBody = {
92
+ model: options.model,
93
+ max_tokens: options.maxTokens,
94
+ messages,
70
95
  };
71
96
 
72
- if (system) {
73
- sdkOptions.systemPrompt = system;
97
+ if (systemFinal) {
98
+ requestBody.system = systemFinal;
74
99
  }
75
100
 
76
- if (options.response === 'json' && options.schema) {
77
- sdkOptions.outputFormat = {
78
- type: 'json_schema',
79
- schema: options.schema,
80
- };
101
+ if (options.temperature !== undefined) {
102
+ requestBody.temperature = options.temperature;
81
103
  }
82
104
 
83
- let resultText = '';
84
- let structuredOutput = null;
85
- let usage = null;
86
- let totalCostUSD = 0;
105
+ let raw;
87
106
 
88
107
  try {
89
- for await (const message of query({ prompt, options: sdkOptions })) {
90
- // Collect text from assistant messages
91
- if (message.type === 'assistant') {
92
- for (const block of message.message?.content || []) {
93
- if (block.type === 'text') {
94
- resultText += block.text;
95
- }
96
- }
97
- }
98
-
99
- // Capture final result + usage from the result message
100
- if (message.type === 'result') {
101
- if (message.subtype === 'success') {
102
- structuredOutput = message.structured_output || null;
103
- resultText = message.result || resultText;
104
- }
105
-
106
- usage = message.usage;
107
- totalCostUSD = message.total_cost_usd || 0;
108
-
109
- if (message.is_error) {
110
- throw new Error(`claude-code: ${resultText || 'unknown error'}`);
111
- }
112
- }
113
- }
108
+ raw = await client.messages.create(requestBody);
114
109
  } catch (e) {
115
- assistant?.error?.(`claude-code request failed: ${e.message}`);
110
+ assistant?.error?.(`claude-code request failed: ${e.message}`, e);
116
111
  throw e;
117
112
  }
118
113
 
119
- // Update token counters
120
- if (usage) {
121
- self.tokens.input.count += usage.input_tokens || 0;
122
- self.tokens.output.count += usage.output_tokens || 0;
123
- self.tokens.total.count = self.tokens.input.count + self.tokens.output.count;
124
- self.tokens.total.price = totalCostUSD;
125
- }
114
+ const outputText = (raw.content || [])
115
+ .filter((c) => c.type === 'text')
116
+ .map((c) => c.text.trim())
117
+ .join('\n')
118
+ .trim();
126
119
 
127
- // Resolve content prefer structured_output (validated against schema)
128
- let content;
120
+ const modelConfig = MODEL_TABLE[options.model] || MODEL_TABLE[DEFAULT_MODEL];
129
121
 
130
- if (structuredOutput != null) {
131
- content = structuredOutput;
132
- } else if (options.response === 'json') {
133
- content = parseJsonLoose(resultText);
134
- } else {
135
- content = resultText;
136
- }
122
+ self.tokens.input.count += raw.usage?.input_tokens || 0;
123
+ self.tokens.output.count += raw.usage?.output_tokens || 0;
124
+ self.tokens.total.count = self.tokens.input.count + self.tokens.output.count;
125
+ self.tokens.input.price = (self.tokens.input.count * modelConfig.input) / 1_000_000;
126
+ self.tokens.output.price = (self.tokens.output.count * modelConfig.output) / 1_000_000;
127
+ self.tokens.total.price = self.tokens.input.price + self.tokens.output.price;
128
+
129
+ let parsed = outputText;
137
130
 
138
- assistant?.log?.(`claude-code: ${Date.now() - startTime}ms, ${usage?.output_tokens || 0} output tokens, $${totalCostUSD?.toFixed(4) || '0.0000'}`);
131
+ if (options.response === 'json') {
132
+ parsed = parseJsonLoose(outputText);
133
+ }
139
134
 
140
135
  return {
141
- output: [{ type: 'output_text', text: resultText }],
142
- content,
136
+ output: raw.content || [],
137
+ content: parsed,
143
138
  tokens: self.tokens,
144
- raw: { usage, totalCostUSD },
139
+ raw,
145
140
  };
146
141
  };
147
142
 
148
143
  /**
149
- * Map unified options into a system prompt + a single user prompt string.
144
+ * Build Anthropic system + messages from the unified option shape.
145
+ *
146
+ * Accepts either:
147
+ * - options.messages: [{ role: 'system'|'user'|'assistant', content: string }]
148
+ * - options.prompt.content (system) + options.message.content (user)
150
149
  */
151
- function extractPromptAndSystem(options) {
150
+ function buildMessages(options) {
152
151
  if (Array.isArray(options.messages) && options.messages.length) {
153
152
  const system = options.messages.find((m) => m.role === 'system')?.content;
154
- const lastUser = [...options.messages].reverse().find((m) => m.role !== 'system');
153
+ const messages = options.messages
154
+ .filter((m) => m.role !== 'system')
155
+ .map((m) => ({ role: m.role, content: stringifyContent(m.content) }));
155
156
 
156
- return {
157
- system: stringifyContent(system),
158
- prompt: stringifyContent(lastUser?.content),
159
- };
157
+ return { system: stringifyContent(system), messages };
160
158
  }
161
159
 
160
+ const system = stringifyContent(options.prompt?.content || '');
161
+ const userContent = stringifyContent(options.message?.content || '');
162
+
162
163
  return {
163
- system: stringifyContent(options.prompt?.content),
164
- prompt: stringifyContent(options.message?.content),
164
+ system,
165
+ messages: [{ role: 'user', content: userContent }],
165
166
  };
166
167
  }
167
168
 
@@ -185,19 +186,22 @@ function stringifyContent(content) {
185
186
  }
186
187
 
187
188
  function parseJsonLoose(text) {
188
- if (!text) return text;
189
+ let cleaned = (text || '').trim();
190
+
191
+ cleaned = cleaned.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '');
189
192
 
190
- let cleaned = text.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '');
191
- const firstObj = cleaned.indexOf('{');
192
- const firstArr = cleaned.indexOf('[');
193
- const start = [firstObj, firstArr].filter((i) => i >= 0).sort((a, b) => a - b)[0];
193
+ const candidates = [cleaned.indexOf('{'), cleaned.indexOf('[')].filter((i) => i >= 0);
194
194
 
195
- if (start > 0) {
196
- cleaned = cleaned.slice(start);
195
+ if (candidates.length) {
196
+ const firstBrace = Math.min(...candidates);
197
+
198
+ if (firstBrace > 0) {
199
+ cleaned = cleaned.slice(firstBrace);
200
+ }
197
201
  }
198
202
 
199
203
  try {
200
- return JSON.parse(cleaned);
204
+ return JSON5.parse(cleaned);
201
205
  } catch {
202
206
  return text;
203
207
  }
@@ -120,6 +120,7 @@
120
120
  "24mail.top",
121
121
  "25u.com",
122
122
  "2anom.com",
123
+ "2bd.net",
123
124
  "2chmail.net",
124
125
  "2ether.net",
125
126
  "2fdgdfgdfgdf.tk",
@@ -335,6 +336,7 @@
335
336
  "air2token.com",
336
337
  "airmailbox.website",
337
338
  "airsworld.net",
339
+ "aisubpro.click",
338
340
  "aiworldx.com",
339
341
  "aixnv.com",
340
342
  "ajaxapp.net",
@@ -1183,6 +1185,7 @@
1183
1185
  "dim-coin.com",
1184
1186
  "dimalk.com",
1185
1187
  "dingbone.com",
1188
+ "dino.icu",
1186
1189
  "diolang.com",
1187
1190
  "directmail24.net",
1188
1191
  "dis.hopto.org",
@@ -2054,6 +2057,7 @@
2054
2057
  "gowikitv.com",
2055
2058
  "grandmamail.com",
2056
2059
  "grandmasmail.com",
2060
+ "graphicenda.site",
2057
2061
  "grassdev.com",
2058
2062
  "gravityengine.cc",
2059
2063
  "great-host.in",
@@ -2709,6 +2713,7 @@
2709
2713
  "laste.ml",
2710
2714
  "lastmail.co",
2711
2715
  "lastmail.com",
2716
+ "launders.money",
2712
2717
  "lawlita.com",
2713
2718
  "laxex.ru",
2714
2719
  "laxex.store",
@@ -2757,11 +2762,13 @@
2757
2762
  "link2mail.net",
2758
2763
  "linkedintuts2016.pw",
2759
2764
  "linkmail.info",
2765
+ "linkpc.net",
2760
2766
  "linlshe.com",
2761
2767
  "linshiyou.com",
2762
2768
  "linshiyouxiang.net",
2763
2769
  "linuxmail.so",
2764
2770
  "liocbrco.com",
2771
+ "liopsacac.shop",
2765
2772
  "lista.cc",
2766
2773
  "litedrop.com",
2767
2774
  "liveradio.tk",
@@ -3418,6 +3425,7 @@
3418
3425
  "newfilm24.ru",
3419
3426
  "newideasfornewpeople.info",
3420
3427
  "newmail.top",
3428
+ "newmano.store",
3421
3429
  "newyork.io.vn",
3422
3430
  "next.ovh",
3423
3431
  "nextmail.info",
@@ -3498,6 +3506,7 @@
3498
3506
  "nospam4.us",
3499
3507
  "nospamfor.us",
3500
3508
  "nospamthanks.info",
3509
+ "nosubcriber.shop",
3501
3510
  "notboxletters.com",
3502
3511
  "nothingtoseehere.ca",
3503
3512
  "notif.me",
@@ -3697,6 +3706,7 @@
3697
3706
  "payspun.com",
3698
3707
  "pazard.com",
3699
3708
  "pazuric.com",
3709
+ "pbhak.dev",
3700
3710
  "pckage.com",
3701
3711
  "pdf-cutter.com",
3702
3712
  "pe.hu",
@@ -3957,6 +3967,7 @@
3957
3967
  "rapidefr.fr.nf",
3958
3968
  "rapt.be",
3959
3969
  "raqid.com",
3970
+ "ravavo.bond",
3960
3971
  "rawr.foo",
3961
3972
  "rax.la",
3962
3973
  "raxtest.com",
@@ -5114,6 +5125,7 @@
5114
5125
  "vuket.org",
5115
5126
  "vulca.sbs",
5116
5127
  "vusra.com",
5128
+ "vutrugay.org",
5117
5129
  "vvatxiy.com",
5118
5130
  "vwhins.com",
5119
5131
  "vxsolar.com",
@@ -5243,6 +5255,7 @@
5243
5255
  "womp-wo.mp",
5244
5256
  "woofidog.fr.nf",
5245
5257
  "woopros.com",
5258
+ "work.gd",
5246
5259
  "workingtall.com",
5247
5260
  "worldlylife.store",
5248
5261
  "worldspace.link",
@@ -230,6 +230,11 @@ Marketing.prototype.remove = async function (email) {
230
230
  return {};
231
231
  }
232
232
 
233
+ if (assistant.isTesting() && !process.env.TEST_EXTENDED_MODE) {
234
+ assistant.log('Marketing.remove(): Skipping providers (testing mode)');
235
+ return {};
236
+ }
237
+
233
238
  assistant.log('Marketing.remove():', { email });
234
239
 
235
240
  const results = {};
@@ -26,9 +26,9 @@ async function inferContact(email, assistant) {
26
26
  if (aiResult) {
27
27
  return aiResult;
28
28
  }
29
- assistant.log(`inferContact: AI returned null for ${email} — falling back to empty result`);
29
+ assistant?.log(`inferContact: AI returned null for ${email} — falling back to empty result`);
30
30
  } else {
31
- assistant.log(`inferContact: BACKEND_MANAGER_OPENAI_API_KEY not set — skipping AI inference for ${email}`);
31
+ assistant?.log(`inferContact: BACKEND_MANAGER_OPENAI_API_KEY not set — skipping AI inference for ${email}`);
32
32
  }
33
33
 
34
34
  return { firstName: '', lastName: '', company: '', confidence: 0, method: 'none' };
@@ -68,14 +68,14 @@ async function inferContactWithAI(email, assistant) {
68
68
  method: 'ai',
69
69
  };
70
70
  if (!inferred.firstName && !inferred.lastName && !inferred.company) {
71
- assistant.log(`inferContactWithAI: AI parsed response had ALL fields empty for ${email}. Raw:`, parsed);
71
+ assistant?.log(`inferContactWithAI: AI parsed response had ALL fields empty for ${email}. Raw:`, parsed);
72
72
  }
73
73
  return inferred;
74
74
  }
75
75
 
76
- assistant.log(`inferContactWithAI: AI response missing firstName for ${email}. Raw result:`, result);
76
+ assistant?.log(`inferContactWithAI: AI response missing firstName for ${email}. Raw result:`, result);
77
77
  } catch (e) {
78
- assistant.error('inferContactWithAI: Failed:', e);
78
+ assistant?.error('inferContactWithAI: Failed:', e);
79
79
  }
80
80
 
81
81
  return null;
@@ -6,7 +6,7 @@
6
6
  * 2. Optionally rejects mismatched brand via ?brand= filter
7
7
  * 3. Loads the matching processor module from ./processors/{provider}.js
8
8
  * 4. Parses the webhook payload into one or more normalized events
9
- * 5. For each event: idempotency check via marketing-webhooks/{eventId}, then dispatch
9
+ * 5. For each supported event: dispatch to the processor's handler
10
10
  * 6. Returns 200 immediately so the provider doesn't retry
11
11
  *
12
12
  * Each processor module defines:
@@ -14,15 +14,13 @@
14
14
  * - isSupported(type) — returns true if this event should be processed
15
15
  * - handleEvent(ctx) — does the work for one event (user doc + cross-provider sync)
16
16
  *
17
- * Mirrors the existing payments-webhook pattern. Processes events inline rather than
18
- * via a Firestore triggermarketing webhooks are lower volume and lighter work than
19
- * payments, so the extra async layer isn't justified.
17
+ * No idempotency ledger: every supported handler (revoke consent + cross-provider
18
+ * remove) is naturally idempotentre-processing a provider retry produces the same
19
+ * end state with no extra side effects, so duplicate suppression buys nothing.
20
20
  */
21
21
  const path = require('path');
22
- const powertools = require('node-powertools');
23
22
 
24
- module.exports = async ({ assistant, Manager, libraries }) => {
25
- const { admin } = libraries;
23
+ module.exports = async ({ assistant, Manager }) => {
26
24
  const query = assistant.request.query;
27
25
 
28
26
  const provider = query.provider;
@@ -78,7 +76,7 @@ module.exports = async ({ assistant, Manager, libraries }) => {
78
76
  // Use Promise.allSettled so we return success only after all events have been
79
77
  // attempted.
80
78
  const results = await Promise.allSettled(
81
- events.map((event) => processOneEvent({ Manager, assistant, admin, provider, event, processorModule }))
79
+ events.map((event) => processOneEvent({ Manager, assistant, provider, event, processorModule }))
82
80
  );
83
81
 
84
82
  let processed = 0;
@@ -100,81 +98,23 @@ module.exports = async ({ assistant, Manager, libraries }) => {
100
98
  };
101
99
 
102
100
  /**
103
- * Process a single event end-to-end: idempotency check, support check, dispatch to handler.
101
+ * Process a single event: support check, then dispatch to the processor's handler.
102
+ * Handlers are idempotent, so provider retries re-run safely with no dedup ledger.
104
103
  * Returns { processed: bool, skipped?: string, error?: any }.
105
104
  */
106
- async function processOneEvent({ Manager, assistant, admin, provider, event, processorModule }) {
107
- const { eventId, eventType } = event;
108
-
109
- // No eventId means we can't dedupe — skip rather than risk double-processing
110
- if (!eventId) {
111
- assistant.log(`marketing webhook: ${provider} event missing eventId (type=${eventType}), skipping`);
112
- return { processed: false, skipped: 'missing-event-id' };
113
- }
105
+ async function processOneEvent({ Manager, assistant, provider, event, processorModule }) {
106
+ const { eventType } = event;
114
107
 
115
108
  // Filter by supported event types
116
109
  if (processorModule.isSupported && !processorModule.isSupported(eventType)) {
117
110
  return { processed: false, skipped: 'unsupported-event-type' };
118
111
  }
119
112
 
120
- // Idempotency: skip if we've already processed this event
121
- const idempotencyRef = admin.firestore().doc(`marketing-webhooks/${eventId}`);
122
- const existingDoc = await idempotencyRef.get();
123
-
124
- if (existingDoc.exists) {
125
- const existingStatus = existingDoc.data()?.status;
126
- if (existingStatus !== 'failed') {
127
- assistant.log(`marketing webhook: ${provider} duplicate event ${eventId} (status=${existingStatus}), skipping`);
128
- return { processed: false, skipped: 'duplicate' };
129
- }
130
- assistant.log(`marketing webhook: ${provider} retrying previously failed event ${eventId}`);
131
- }
132
-
133
- // Build the audit doc
134
- const now = powertools.timestamp(new Date(), { output: 'string' });
135
- const nowUNIX = powertools.timestamp(now, { output: 'unix' });
136
-
137
- // Write 'pending' state before dispatching so concurrent deliveries see the lock
138
- await idempotencyRef.set({
139
- id: eventId,
140
- provider,
141
- status: 'pending',
142
- raw: event.raw || null,
143
- event: {
144
- type: eventType,
145
- email: event.email || null,
146
- timestamp: event.timestamp || null,
147
- },
148
- error: null,
149
- metadata: {
150
- created: { timestamp: now, timestampUNIX: nowUNIX },
151
- completed: { timestamp: null, timestampUNIX: null },
152
- },
153
- });
154
-
155
- // Dispatch to the processor's event handler
156
- let handlerResult;
157
113
  try {
158
- handlerResult = await processorModule.handleEvent({ Manager, assistant, parsed: event });
159
-
160
- // Mark completed
161
- await idempotencyRef.set({
162
- status: 'completed',
163
- result: handlerResult || null,
164
- metadata: {
165
- completed: { timestamp: powertools.timestamp(new Date(), { output: 'string' }), timestampUNIX: powertools.timestamp(new Date(), { output: 'unix' }) },
166
- },
167
- }, { merge: true });
168
-
114
+ await processorModule.handleEvent({ Manager, assistant, parsed: event });
169
115
  return { processed: true };
170
116
  } catch (e) {
171
- assistant.error(`marketing webhook: handler failed for ${provider} event ${eventId}:`, e);
172
-
173
- await idempotencyRef.set({
174
- status: 'failed',
175
- error: { message: e.message || String(e), stack: e.stack || null },
176
- }, { merge: true }).catch(() => {});
177
-
117
+ assistant.error(`marketing webhook: handler failed for ${provider} event ${event.eventId} (${eventType}):`, e);
178
118
  return { processed: false, error: e };
179
119
  }
180
120
  }
@@ -19,7 +19,8 @@
19
19
  * - getPublicationId() reads from config.marketing.beehiiv.publicationId
20
20
  * or fuzzy-matches by brand name against the Beehiiv API.
21
21
  *
22
- * Idempotency is enforced by the dispatcher via marketing-webhooks/{eventId} doc.
22
+ * No idempotency ledger the revoke + cross-provider remove are idempotent, so a
23
+ * provider retry re-runs safely with the same end state.
23
24
  */
24
25
 
25
26
  const REVOKE_EVENT_TYPES = new Set([
@@ -85,7 +86,7 @@ function isSupported(eventType) {
85
86
  }
86
87
 
87
88
  /**
88
- * Process a single parsed event. Called by the dispatcher AFTER idempotency check passes.
89
+ * Process a single parsed event. Called by the dispatcher for each supported event.
89
90
  * Returns a result object summarizing what happened.
90
91
  */
91
92
  async function handleEvent({ Manager, assistant, parsed }) {
@@ -19,7 +19,8 @@
19
19
  * GROUPS.marketing (25928) is account-global across all brands, an unsub from group 25928
20
20
  * legitimately removes the user from marketing across the entire SendGrid account.
21
21
  *
22
- * Idempotency is enforced by the dispatcher via marketing-webhooks/{eventId} doc.
22
+ * No idempotency ledger the revoke + cross-provider remove are idempotent, so a
23
+ * provider retry re-runs safely with the same end state.
23
24
  */
24
25
 
25
26
  const REVOKE_EVENT_TYPES = new Set([
@@ -61,7 +62,7 @@ function parseWebhook(req) {
61
62
  }
62
63
 
63
64
  return events.map((event) => {
64
- // sg_event_id is SendGrid's per-event unique ID, used for idempotency.
65
+ // sg_event_id is SendGrid's per-event unique ID, retained for log context.
65
66
  // smtp-id is another stable identifier we fall back to.
66
67
  const eventId = event.sg_event_id || event['smtp-id'] || event.smtpId || null;
67
68
  const eventType = event.event;
@@ -88,7 +89,7 @@ function isSupported(eventType) {
88
89
  }
89
90
 
90
91
  /**
91
- * Process a single parsed event. Called by the dispatcher AFTER idempotency check passes.
92
+ * Process a single parsed event. Called by the dispatcher for each supported event.
92
93
  *
93
94
  * Returns a result object summarizing what happened (for logging/response).
94
95
  */
@@ -4,14 +4,13 @@ const { validate: validateEmail, isDisposable } = require('../../../libraries/em
4
4
 
5
5
  const MAX_POLL_TIME_MS = 30000;
6
6
  const POLL_INTERVAL_MS = 500;
7
- const MAX_ACCOUNT_AGE_MS = 5 * 60 * 1000; // 5 minutes
8
7
 
9
8
  /**
10
9
  * POST /user/signup - Complete user signup
11
10
  *
12
11
  * Called by client after account creation to:
13
12
  * 1. Poll for user doc to exist (waits for onCreate to complete)
14
- * 2. Validate (already processed, account age)
13
+ * 2. Validate (reject only if flags.signupProcessed is already true)
15
14
  * 3. Gather all data (client details, inferred contact)
16
15
  * 4. Write everything to user doc in one merge
17
16
  * 5. Process affiliate referral (writes to referrer's doc)
@@ -49,23 +48,20 @@ module.exports = async ({ assistant, user, settings, libraries }) => {
49
48
  return assistant.respond('Signup has already been processed', { code: 400 });
50
49
  }
51
50
 
52
- // 3. Backup check: reject if account is older than 5 minutes
51
+ // 3. Fetch the Auth user needed for the canonical creationTime used to stamp
52
+ // metadata.created and consent timestamps. flags.signupProcessed (checked above) is
53
+ // the sole idempotency gate; there is intentionally no account-age window, so a
54
+ // legitimately-unprocessed account can complete signup whenever it retries.
53
55
  const authUser = await admin.auth().getUser(uid).catch((e) => e);
54
56
 
55
57
  if (authUser instanceof Error) {
56
58
  return assistant.respond(`Failed to get auth user: ${authUser.message}`, { code: 500 });
57
59
  }
58
60
 
59
- const accountAgeMs = Date.now() - new Date(authUser.metadata.creationTime).getTime();
60
-
61
- if (accountAgeMs > MAX_ACCOUNT_AGE_MS) {
62
- return assistant.respond('Account is too old to process signup', { code: 400 });
63
- }
64
-
65
61
  // 4. Gather all data, then write once
66
62
  const email = user.auth.email;
67
63
  const inferred = await inferUserContact(assistant, email);
68
- const userRecord = buildUserRecord(assistant, settings, inferred);
64
+ const userRecord = buildUserRecord(assistant, settings, inferred, authUser.metadata.creationTime);
69
65
 
70
66
  assistant.log(`signup(): Writing user record for ${uid}`, userRecord);
71
67
 
@@ -117,7 +113,7 @@ async function pollForUserDoc(assistant, uid) {
117
113
  /**
118
114
  * Build the full user record: client details, attribution, and inferred contact
119
115
  */
120
- function buildUserRecord(assistant, settings, inferred) {
116
+ function buildUserRecord(assistant, settings, inferred, creationTime) {
121
117
  const Manager = assistant.Manager;
122
118
  const attribution = settings.attribution;
123
119
 
@@ -142,10 +138,22 @@ function buildUserRecord(assistant, settings, inferred) {
142
138
  },
143
139
  },
144
140
  attribution: attribution || {},
145
- consent: buildConsentRecord(assistant, settings.consent),
141
+ consent: buildConsentRecord(assistant, settings.consent, creationTime),
146
142
  metadata: Manager.Metadata().set({ tag: 'user/signup' }),
147
143
  };
148
144
 
145
+ // Stamp metadata.created from Firebase Auth's creationTime so it matches Auth's canonical
146
+ // value. Normally onCreate sets this, but if onCreate didn't fire this merge write is the
147
+ // doc's first creation — without this the doc lands with no created date and the OMEGA
148
+ // migration has to backfill it. Idempotent: when onCreate already wrote it, this matches.
149
+ if (creationTime) {
150
+ const createdDate = new Date(creationTime);
151
+ record.metadata.created = {
152
+ timestamp: createdDate.toISOString(),
153
+ timestampUNIX: Math.round(createdDate.getTime() / 1000),
154
+ };
155
+ }
156
+
149
157
  // Add inferred name/company if available
150
158
  if (inferred) {
151
159
  record.personal = {
@@ -168,18 +176,24 @@ function buildUserRecord(assistant, settings, inferred) {
168
176
  * Client sends: { legal: { granted, text }, marketing: { granted, text } }
169
177
  * Server writes: { legal: { status, grantedAt: {...} }, marketing: { status, grantedAt: {...}, revokedAt: {...} } }
170
178
  *
171
- * Server time (not client-supplied) is authoritative — defends against clock manipulation.
179
+ * Server-derived time (not client-supplied) is authoritative — defends against clock
180
+ * manipulation. Uses Auth's creationTime so consent timestamps match metadata.created.
172
181
  * IP is captured from request geolocation.
173
182
  *
174
183
  * Legal is REQUIRED — the client must send legal.granted=true. If missing/false we still
175
184
  * record what the client sent, but the route will not have reached this point in practice
176
185
  * (the signup-form HTML5-requires the legal checkbox).
177
186
  */
178
- function buildConsentRecord(assistant, clientConsent) {
187
+ function buildConsentRecord(assistant, clientConsent, creationTime) {
179
188
  const consent = clientConsent || {};
180
189
  const ip = assistant.request.geolocation?.ip || null;
181
- const timestamp = assistant.meta.startTime.timestamp;
182
- const timestampUNIX = assistant.meta.startTime.timestampUNIX;
190
+
191
+ // Stamp grantedAt/revokedAt from Auth's creationTime so consent timestamps match
192
+ // metadata.created (the OMEGA migration treats metadata.created as the SSOT and reconciles
193
+ // consent.grantedAt against it). Fall back to request start time if creationTime is absent.
194
+ const createdDate = creationTime ? new Date(creationTime) : null;
195
+ const timestamp = createdDate ? createdDate.toISOString() : assistant.meta.startTime.timestamp;
196
+ const timestampUNIX = createdDate ? Math.round(createdDate.getTime() / 1000) : assistant.meta.startTime.timestampUNIX;
183
197
 
184
198
  // Build empty leaf shape — used wherever grantedAt or revokedAt is "not set"
185
199
  const emptyMeta = { timestamp: null, timestampUNIX: null, source: null, ip: null, text: null };
@@ -694,7 +694,7 @@ async function deleteTestUsers(admin) {
694
694
  // identified solely by an `_test-` doc id prefix (id-keyed). All must be wiped
695
695
  // at the start of every run so a test that died mid-execution leaves no
696
696
  // ghosts. New collections that participate in tests MUST be added here too.
697
- const testDataCollections = ['payments-orders', 'payments-webhooks', 'payments-intents', 'payments-disputes', 'marketing-webhooks'];
697
+ const testDataCollections = ['payments-orders', 'payments-webhooks', 'payments-intents', 'payments-disputes'];
698
698
  // Collections that exist solely for tests — wipe in full. All docs in these
699
699
  // collections come from tests, so a single recursive delete handles cleanup.
700
700
  const testOnlyCollections = ['_test', '_test_query'];
package/templates/_.env CHANGED
@@ -9,11 +9,10 @@ BACKEND_MANAGER_ANTHROPIC_API_KEY=""
9
9
  # GitHub
10
10
  GITHUB_TOKEN=""
11
11
 
12
- # OpenAI
12
+ # AI
13
13
  OPENAI_API_KEY=""
14
-
15
- # Anthropic
16
14
  ANTHROPIC_API_KEY=""
15
+ CLAUDE_CODE_OAUTH_TOKEN=""
17
16
 
18
17
  # Payment Processors
19
18
  PAYPAL_CLIENT_SECRET=""
@@ -8,7 +8,7 @@
8
8
  * - Auth via ?key= query param
9
9
  * - Provider validation
10
10
  * - Brand filter (ignore mismatched brand)
11
- * - Idempotency via marketing-webhooks/{eventId} doc
11
+ * - Idempotent re-delivery (handlers re-run safely; no dedup ledger)
12
12
  *
13
13
  * SendGrid processor tests:
14
14
  * - Various event types (group_unsubscribe, unsubscribe, spamreport, bounce, dropped)
@@ -120,12 +120,6 @@ module.exports = {
120
120
  assert.equal(userDoc?.consent?.marketing?.status, 'revoked', 'marketing.status should be revoked');
121
121
  assert.equal(userDoc?.consent?.marketing?.revokedAt?.source, 'sendgrid', 'revokedAt.source should be sendgrid');
122
122
  assert.equal(userDoc?.consent?.marketing?.revokedAt?.timestampUNIX, eventTimestamp, 'revokedAt.timestampUNIX should match event timestamp');
123
-
124
- // Idempotency doc should exist with status=completed
125
- const webhookDoc = await firestore.get(`marketing-webhooks/${eventId}`);
126
- assert.ok(webhookDoc, 'Idempotency doc should exist');
127
- assert.equal(webhookDoc?.status, 'completed', 'Idempotency doc should be marked completed');
128
- assert.equal(webhookDoc?.provider, 'sendgrid', 'Idempotency doc should record provider');
129
123
  },
130
124
  },
131
125
 
@@ -244,9 +238,9 @@ module.exports = {
244
238
  [sgEvent({ id: eventId, type: 'group_unsubscribe', email: '_test.never-existed@example.com' })]
245
239
  );
246
240
 
247
- // Dispatcher still processes the event (idempotency doc written, handler runs and returns
248
- // handled:false). From the dispatcher's POV this counts as 'processed=1' since the handler
249
- // didn't throw. The handler's internal "user-not-found" branch is silent by design.
241
+ // Dispatcher still runs the handler (which returns handled:false). From the
242
+ // dispatcher's POV this counts as 'processed=1' since the handler didn't throw.
243
+ // The handler's internal "user-not-found" branch is silent by design.
250
244
  assert.isSuccess(response, 'Should accept unknown-email gracefully');
251
245
  assert.propertyEquals(response, 'data.failed', 0, 'No failures for unknown email');
252
246
  },
@@ -277,28 +271,19 @@ module.exports = {
277
271
  assert.propertyEquals(response, 'data.processed', 2, '2 supported events should be processed');
278
272
  assert.propertyEquals(response, 'data.skipped', 1, '1 unsupported event should be skipped');
279
273
 
280
- // Each processed event gets its own idempotency doc
281
- const doc1 = await firestore.get(`marketing-webhooks/${e1}`);
282
- const doc3 = await firestore.get(`marketing-webhooks/${e3}`);
283
- assert.ok(doc1 && doc1.status === 'completed', 'First event idempotency doc completed');
284
- assert.ok(doc3 && doc3.status === 'completed', 'Third event idempotency doc completed');
285
-
286
- // The skipped event should NOT have an idempotency doc (we filter by isSupported before writing)
287
- const doc2 = await firestore.get(`marketing-webhooks/${e2}`);
288
- assert.ok(!doc2, 'Unsupported event should NOT have an idempotency doc');
289
-
290
274
  // User doc should be revoked
291
275
  const userDoc = await firestore.get(`users/${uid}`);
292
276
  assert.equal(userDoc?.consent?.marketing?.status, 'revoked');
293
277
  },
294
278
  },
295
279
 
296
- // ─── Idempotency ───
280
+ // ─── Idempotent re-delivery (no dedup ledger) ───
297
281
 
298
282
  {
299
- name: 'sendgrid-duplicate-event-skipped',
283
+ name: 'sendgrid-duplicate-event-reprocessed-idempotently',
300
284
  auth: 'none',
301
285
  async run({ http, firestore, assert, accounts }) {
286
+ const uid = accounts.basic.uid;
302
287
  const email = accounts.basic.email;
303
288
  const eventId = sgEventId('duplicate');
304
289
 
@@ -310,27 +295,27 @@ module.exports = {
310
295
  assert.isSuccess(response1);
311
296
  assert.propertyEquals(response1, 'data.processed', 1, 'First delivery should process');
312
297
 
313
- // Second delivery — same eventId
298
+ // Second delivery — same eventId. With no dedup ledger the handler runs
299
+ // again, but the revoke is idempotent so the end state is unchanged.
314
300
  const response2 = await http.as('none').post(
315
301
  `marketing/webhook?provider=sendgrid&key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`,
316
302
  [sgEvent({ id: eventId, type: 'group_unsubscribe', email })]
317
303
  );
318
304
  assert.isSuccess(response2);
319
- assert.propertyEquals(response2, 'data.processed', 0, 'Duplicate should NOT reprocess');
320
- assert.propertyEquals(response2, 'data.skipped', 1, 'Duplicate should be skipped');
305
+ assert.propertyEquals(response2, 'data.processed', 1, 'Re-delivery reprocesses (idempotent), not skipped');
321
306
 
322
- // Idempotency doc should still be there and completed
323
- const webhookDoc = await firestore.get(`marketing-webhooks/${eventId}`);
324
- assert.equal(webhookDoc?.status, 'completed');
307
+ const userDoc = await firestore.get(`users/${uid}`);
308
+ assert.equal(userDoc?.consent?.marketing?.status, 'revoked', 'User remains revoked after re-delivery');
325
309
  },
326
310
  },
327
311
 
328
- // ─── Missing event ID ───
312
+ // ─── Missing event ID — still processed (no dedup requirement) ───
329
313
 
330
314
  {
331
- name: 'sendgrid-event-without-eventId-skipped',
315
+ name: 'sendgrid-event-without-eventId-processed',
332
316
  auth: 'none',
333
- async run({ http, assert, accounts }) {
317
+ async run({ http, firestore, assert, accounts }) {
318
+ const uid = accounts.basic.uid;
334
319
  const email = accounts.basic.email;
335
320
 
336
321
  const response = await http.as('none').post(
@@ -339,8 +324,10 @@ module.exports = {
339
324
  );
340
325
 
341
326
  assert.isSuccess(response, 'Should accept the request');
342
- assert.propertyEquals(response, 'data.processed', 0, 'Event without eventId cannot be deduped, so it is skipped');
343
- assert.propertyEquals(response, 'data.skipped', 1, 'Event should be skipped');
327
+ assert.propertyEquals(response, 'data.processed', 1, 'Event without eventId is still processed (no dedup needed)');
328
+
329
+ const userDoc = await firestore.get(`users/${uid}`);
330
+ assert.equal(userDoc?.consent?.marketing?.status, 'revoked', 'User should be revoked');
344
331
  },
345
332
  },
346
333
 
@@ -351,14 +338,16 @@ module.exports = {
351
338
  {
352
339
  name: 'beehiiv-subscription-unsubscribed-writes-consent',
353
340
  auth: 'none',
354
- async run({ http, firestore, assert, accounts, config }) {
341
+ async run({ http, firestore, assert, accounts, config, skip }) {
355
342
  const uid = accounts.basic.uid;
356
343
  const email = accounts.basic.email;
357
344
  const eventId = `_test-bh-unsub-${Date.now()}`;
358
345
  const eventISO = new Date().toISOString();
359
346
  const publicationId = config.marketing?.beehiiv?.publicationId;
360
347
 
361
- assert.ok(publicationId, 'Test brand must have a Beehiiv publication ID configured');
348
+ if (!publicationId) {
349
+ return skip('No Beehiiv publication ID configured for this brand');
350
+ }
362
351
 
363
352
  const response = await http.as('none').post(
364
353
  `marketing/webhook?provider=beehiiv&key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`,
@@ -378,12 +367,6 @@ module.exports = {
378
367
  assert.equal(userDoc?.consent?.marketing?.status, 'revoked', 'marketing.status should be revoked');
379
368
  assert.equal(userDoc?.consent?.marketing?.revokedAt?.source, 'beehiiv', 'revokedAt.source should be beehiiv');
380
369
  assert.ok(userDoc?.consent?.marketing?.revokedAt?.timestamp, 'revokedAt.timestamp should be set');
381
-
382
- // Idempotency doc should exist
383
- const webhookDoc = await firestore.get(`marketing-webhooks/${eventId}`);
384
- assert.ok(webhookDoc, 'Idempotency doc should exist');
385
- assert.equal(webhookDoc?.status, 'completed');
386
- assert.equal(webhookDoc?.provider, 'beehiiv');
387
370
  },
388
371
  },
389
372
 
@@ -472,8 +455,8 @@ module.exports = {
472
455
  );
473
456
 
474
457
  // The dispatcher counts this as 'processed' from its POV (the handler
475
- // ran without error and the idempotency doc was written), but the
476
- // handler returned { handled: false, reason: 'publication-mismatch' }.
458
+ // ran without error), but the handler returned
459
+ // { handled: false, reason: 'publication-mismatch' }.
477
460
  // What matters: the user doc should NOT have been mutated.
478
461
  assert.isSuccess(response, 'Pub-mismatch event should be accepted gracefully');
479
462
 
@@ -542,13 +525,18 @@ module.exports = {
542
525
  },
543
526
 
544
527
  {
545
- name: 'beehiiv-duplicate-event-skipped',
528
+ name: 'beehiiv-duplicate-event-reprocessed-idempotently',
546
529
  auth: 'none',
547
- async run({ http, firestore, assert, accounts, config }) {
530
+ async run({ http, firestore, assert, accounts, config, skip }) {
531
+ const uid = accounts.basic.uid;
548
532
  const email = accounts.basic.email;
549
533
  const publicationId = config.marketing?.beehiiv?.publicationId;
550
534
  const eventId = `_test-bh-dup-${Date.now()}`;
551
535
 
536
+ if (!publicationId) {
537
+ return skip('No Beehiiv publication ID configured for this brand');
538
+ }
539
+
552
540
  const payload = {
553
541
  id: eventId,
554
542
  event: 'subscription.unsubscribed',
@@ -565,17 +553,17 @@ module.exports = {
565
553
  assert.isSuccess(r1);
566
554
  assert.propertyEquals(r1, 'data.processed', 1, 'First delivery should process');
567
555
 
568
- // Second delivery — same id
556
+ // Second delivery — same id. No dedup ledger, so it reprocesses; the
557
+ // revoke is idempotent so the end state is unchanged.
569
558
  const r2 = await http.as('none').post(
570
559
  `marketing/webhook?provider=beehiiv&key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`,
571
560
  payload
572
561
  );
573
562
  assert.isSuccess(r2);
574
- assert.propertyEquals(r2, 'data.processed', 0, 'Duplicate should NOT reprocess');
575
- assert.propertyEquals(r2, 'data.skipped', 1, 'Duplicate should be skipped');
563
+ assert.propertyEquals(r2, 'data.processed', 1, 'Re-delivery reprocesses (idempotent), not skipped');
576
564
 
577
- const webhookDoc = await firestore.get(`marketing-webhooks/${eventId}`);
578
- assert.equal(webhookDoc?.status, 'completed');
565
+ const userDoc = await firestore.get(`users/${uid}`);
566
+ assert.equal(userDoc?.consent?.marketing?.status, 'revoked', 'User remains revoked after re-delivery');
579
567
  },
580
568
  },
581
569
  ],
@@ -244,8 +244,6 @@ module.exports = {
244
244
  };
245
245
 
246
246
  // Use absurdly-old client timestamp to prove server time wins (defense vs clock skew)
247
- const beforeMs = Date.now();
248
-
249
247
  const signupResponse = await http.as('consent-granted').post('user/signup', {
250
248
  consent: {
251
249
  legal: { granted: true, text: consentText.legal, timestamp: '2000-01-01T00:00:00.000Z' },
@@ -255,7 +253,6 @@ module.exports = {
255
253
 
256
254
  assert.isSuccess(signupResponse, `Signup should succeed: ${JSON.stringify(signupResponse, null, 2)}`);
257
255
 
258
- const afterMs = Date.now();
259
256
  const userDoc = await firestore.get(`users/${accounts['consent-granted'].uid}`);
260
257
 
261
258
  // Legal
@@ -265,14 +262,16 @@ module.exports = {
265
262
  assert.ok(userDoc?.consent?.legal?.grantedAt?.timestamp, 'legal grantedAt.timestamp should be set');
266
263
  assert.equal(typeof userDoc?.consent?.legal?.grantedAt?.timestampUNIX, 'number', 'legal grantedAt.timestampUNIX should be number');
267
264
 
268
- // Server time MUST be used (the client-supplied 2000-01-01 should NOT appear)
265
+ // Server-derived time MUST be used (the client-supplied 2000-01-01 should NOT appear).
266
+ // grantedAt is stamped from Auth's creationTime, the same source as metadata.created,
267
+ // so the two must be equal — and the client's 2000-01-01 (UNIX 946684800) is rejected.
269
268
  const legalUNIX = userDoc.consent.legal.grantedAt.timestampUNIX;
270
- const beforeUNIX = Math.floor(beforeMs / 1000);
271
- const afterUNIX = Math.floor(afterMs / 1000);
272
- assert.ok(
273
- legalUNIX >= beforeUNIX && legalUNIX <= afterUNIX,
274
- `legal grantedAt.timestampUNIX (${legalUNIX}) must be server time, not client time. expected between ${beforeUNIX} and ${afterUNIX}`
269
+ const createdUNIX = userDoc.metadata.created.timestampUNIX;
270
+ assert.equal(
271
+ legalUNIX, createdUNIX,
272
+ `legal grantedAt.timestampUNIX (${legalUNIX}) should match metadata.created (${createdUNIX}) both from Auth creationTime`
275
273
  );
274
+ assert.notEqual(legalUNIX, 946684800, 'legal grantedAt must NOT be the client-supplied 2000-01-01 time');
276
275
 
277
276
  // Marketing
278
277
  assert.equal(userDoc?.consent?.marketing?.status, 'granted', 'consent.marketing.status should be granted');