also looking at this
Add BunPress guide and pre-E2E quality gates
#6
318 files
+2174
-70006
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -3,16 +3,20 @@ import { Action } from '@stacksjs/actions' | ||
| 3 | 3 | import { config } from '@stacksjs/config' |
| 4 | 4 | import { response } from '@stacksjs/router' |
| 5 | 5 | import Contact from '../../Models/Contact' |
| 6 | import { appendConsentOnce, consentIdempotencyKey, findConsentByKey } from './consent-ledger' | |
| 6 | 7 | import { verifyPublicToken } from './signed-token' |
| 7 | 8 | |
| 8 | 9 | export default new Action({ |
| 9 | 10 | name: 'Confirm Subscription', description: 'Confirms a double opt-in token', method: 'GET', |
| 10 | 11 | async handle(request: RequestInstance) { |
| 11 | const payload = verifyPublicToken(String(request.getParam('token') || ''), String(config.app.key)) | |
| 12 | const rawToken = String(request.getParam('token') || '') | |
| 13 | const payload = verifyPublicToken(rawToken, String(config.app.key)) | |
| 12 | 14 | if (!payload || payload.purpose !== 'confirm') return response.json({ error: 'Confirmation link is invalid or expired' }, 400) |
| 13 | 15 | const contact = await Contact.where('id', payload.contactId).where('team_id', payload.teamId).first() |
| 14 | 16 | if (!contact) return response.json({ error: 'Confirmation link is invalid or expired' }, 400) |
| 15 | 17 | const recipient = String(payload.channel === 'sms' ? contact.phone : contact.email).trim().toLowerCase() |
| 18 | const idempotencyKey = consentIdempotencyKey({ scope: 'confirm', token: rawToken, channel: payload.channel, action: 'confirmed' }) | |
| 19 | if (await findConsentByKey(payload.teamId, idempotencyKey)) return response.redirect('/subscription-confirmed') | |
| 16 | 20 | const suppression = await CommunicationSuppression |
| 17 | 21 | .where('team_id', payload.teamId) |
| 18 | 22 | .where('channel', payload.channel) |
| @@ -25,7 +29,7 @@ export default new Action({ | ||
| 25 | 29 | }) |
| 26 | 30 | } |
| 27 | 31 | await contact.update({ status: 'active' }) |
| 28 | await ConsentEvent.create({ team_id: payload.teamId, recipient, channel: payload.channel, action: 'confirmed', purpose: 'marketing', source: 'double_opt_in', policyVersion: '1.0', proof: JSON.stringify({ tokenPurpose: payload.purpose }), occurredAt: new Date().toISOString() }) | |
| 32 | await appendConsentOnce({ teamId: payload.teamId, recipient, channel: payload.channel, action: 'confirmed', source: 'double_opt_in', idempotencyKey, proof: JSON.stringify({ tokenPurpose: payload.purpose }) }) | |
| 29 | 33 | return response.redirect('/subscription-confirmed') |
| 30 | 34 | }, |
| 31 | 35 | }) |
| @@ -9,6 +9,7 @@ import Contact from '../../Models/Contact' | ||
| 9 | 9 | import FormDefinition from '../../Models/FormDefinition' |
| 10 | 10 | import FormSubmission from '../../Models/FormSubmission' |
| 11 | 11 | import { buildConfirmationMessage } from '../../Mail/ConfirmSubscription' |
| 12 | import { appendConsentOnce, consentIdempotencyKey } from './consent-ledger' | |
| 12 | 13 | import { createPublicToken } from './signed-token' |
| 13 | 14 | |
| 14 | 15 | function clientIp(request: RequestInstance): string { |
| @@ -40,12 +41,27 @@ export default new Action({ | ||
| 40 | 41 | const existing = await FormSubmission.where('form_definition_id', form.id).where('dedupeKey', dedupeKey).first() |
| 41 | 42 | if (!existing) await FormSubmission.forceCreate({ team_id: teamId, form_definition_id: form.id, contact_id: contact.id, payload: JSON.stringify({ email, firstName: request.get('firstName'), lastName: request.get('lastName') }), dedupeKey, sourceUrl: request.headers.get('referer') || undefined, ipHash: createHash('sha256').update(ip).digest('hex'), status: form.doubleOptIn ? 'accepted' : 'confirmed' }) |
| 42 | 43 | |
| 43 | await ConsentEvent.create({ team_id: teamId, recipient: email, channel: 'email', action: form.doubleOptIn ? 'requested' : 'confirmed', purpose: 'marketing', source: `form:${form.uuid}`, jurisdiction: String(request.get('jurisdiction') || ''), policyVersion: '1.0', proof: JSON.stringify({ formId: form.id, userAgent: request.headers.get('user-agent') }), ipAddress: ip, occurredAt: new Date().toISOString() }) | |
| 44 | const action = form.doubleOptIn ? 'requested' : 'confirmed' | |
| 45 | const consentKey = consentIdempotencyKey({ scope: `form:${form.id}:${dedupeKey}`, token: email, channel: 'email', action }) | |
| 46 | await appendConsentOnce({ | |
| 47 | teamId, | |
| 48 | recipient: email, | |
| 49 | channel: 'email', | |
| 50 | action, | |
| 51 | source: `form:${form.uuid}`, | |
| 52 | idempotencyKey: consentKey, | |
| 53 | jurisdiction: String(request.get('jurisdiction') || ''), | |
| 54 | proof: JSON.stringify({ formId: form.id, userAgent: request.headers.get('user-agent') }), | |
| 55 | ipAddress: ip, | |
| 56 | }) | |
| 44 | 57 | |
| 45 | 58 | if (form.doubleOptIn) { |
| 46 | 59 | const token = createPublicToken({ teamId, contactId: contact.id, channel: 'email', purpose: 'confirm', expiresAt: Date.now() + 48 * 60 * 60 * 1000 }, String(config.app.key)) |
| 47 | 60 | const confirmationUrl = `${String(config.app.url).replace(/\/$/, '')}/confirm/${token}` |
| 48 | await job('SendEmail', { message: buildConfirmationMessage(email, confirmationUrl), driver: config.email.default }).onQueue('emails').dispatch() | |
| 61 | await job('SendEmail', { message: buildConfirmationMessage(email, confirmationUrl), driver: config.email.default }) | |
| 62 | .withIdempotencyKey(`confirmation:${consentKey}`) | |
| 63 | .onQueue('emails') | |
| 64 | .dispatch() | |
| 49 | 65 | } |
| 50 | 66 | |
| 51 | 67 | return response.json({ accepted: true, confirmationRequired: !!form.doubleOptIn }, 202) |
| @@ -3,19 +3,36 @@ import { Action } from '@stacksjs/actions' | ||
| 3 | 3 | import { config } from '@stacksjs/config' |
| 4 | 4 | import { response } from '@stacksjs/router' |
| 5 | 5 | import Contact from '../../Models/Contact' |
| 6 | import { appendConsentOnce, consentIdempotencyKey, consentStateMatches, ensureSuppressed, findActiveSuppression, findLatestConsent } from './consent-ledger' | |
| 6 | 7 | import { verifyPublicToken } from './signed-token' |
| 7 | 8 | |
| 8 | 9 | export default new Action({ |
| 9 | 10 | name: 'Public Unsubscribe', description: 'Applies one-click channel suppression', method: 'POST', |
| 10 | 11 | async handle(request: RequestInstance) { |
| 11 | const payload = verifyPublicToken(String(request.getParam('token') || ''), String(config.app.key)) | |
| 12 | const rawToken = String(request.getParam('token') || '') | |
| 13 | const payload = verifyPublicToken(rawToken, String(config.app.key)) | |
| 12 | 14 | if (!payload || !['unsubscribe', 'preferences'].includes(payload.purpose)) return response.json({ error: 'Unsubscribe link is invalid or expired' }, 400) |
| 13 | 15 | const contact = await Contact.where('id', payload.contactId).where('team_id', payload.teamId).first() |
| 14 | 16 | if (!contact) return response.json({ error: 'Unsubscribe link is invalid or expired' }, 400) |
| 15 | 17 | const recipient = String(payload.channel === 'sms' ? contact.phone : contact.email).trim().toLowerCase() |
| 16 | const existing = await CommunicationSuppression.where('team_id', payload.teamId).where('channel', payload.channel).where('recipient', recipient).first() | |
| 17 | if (!existing) await CommunicationSuppression.create({ team_id: payload.teamId, recipient, channel: payload.channel, reason: 'unsubscribe', source: 'one_click', suppressedAt: new Date().toISOString() }) | |
| 18 | await ConsentEvent.create({ team_id: payload.teamId, recipient, channel: payload.channel, action: 'revoked', purpose: 'marketing', source: 'one_click', policyVersion: '1.0', proof: JSON.stringify({ tokenPurpose: payload.purpose }), occurredAt: new Date().toISOString() }) | |
| 18 | const [activeSuppression, latestConsent] = await Promise.all([ | |
| 19 | findActiveSuppression(payload.teamId, payload.channel, recipient), | |
| 20 | findLatestConsent(payload.teamId, payload.channel, recipient), | |
| 21 | ]) | |
| 22 | const alreadyApplied = !!activeSuppression && consentStateMatches(latestConsent, 'revoked', 'one_click') | |
| 23 | ||
| 24 | await ensureSuppressed({ teamId: payload.teamId, recipient, channel: payload.channel, source: 'one_click' }) | |
| 25 | if (!alreadyApplied) { | |
| 26 | await appendConsentOnce({ | |
| 27 | teamId: payload.teamId, | |
| 28 | recipient, | |
| 29 | channel: payload.channel, | |
| 30 | action: 'revoked', | |
| 31 | source: 'one_click', | |
| 32 | idempotencyKey: consentIdempotencyKey({ scope: 'unsubscribe', token: rawToken, channel: payload.channel, action: 'revoked', predecessorId: latestConsent?.id }), | |
| 33 | proof: JSON.stringify({ tokenPurpose: payload.purpose }), | |
| 34 | }) | |
| 35 | } | |
| 19 | 36 | if (payload.channel === 'email') await contact.update({ status: 'unsubscribed' }) |
| 20 | 37 | return response.json({ unsubscribed: true }) |
| 21 | 38 | }, |
| @@ -5,37 +5,28 @@ import { job } from '@stacksjs/queue' | ||
| 5 | 5 | import { response } from '@stacksjs/router' |
| 6 | 6 | import Contact from '../../Models/Contact' |
| 7 | 7 | import { buildConfirmationMessage } from '../../Mail/ConfirmSubscription' |
| 8 | import { appendConsentOnce, consentIdempotencyKey, consentStateMatches, ensureSuppressed, findActiveSuppression, findLatestConsent } from './consent-ledger' | |
| 8 | 9 | import { parsePreferenceChoice } from './preference-policy' |
| 9 | 10 | import { createPublicToken, verifyPublicToken } from './signed-token' |
| 10 | 11 | |
| 11 | async function suppress(teamId: number, channel: 'email' | 'sms', recipient: string): Promise<void> { | |
| 12 | async function suppress(teamId: number, channel: 'email' | 'sms', recipient: string, token: string): Promise<void> { | |
| 12 | 13 | if (!recipient) return |
| 13 | const existing = await CommunicationSuppression | |
| 14 | .where('team_id', teamId) | |
| 15 | .where('channel', channel) | |
| 16 | .where('recipient', recipient) | |
| 17 | .whereNull('liftedAt') | |
| 18 | .first() | |
| 19 | if (!existing) { | |
| 20 | await CommunicationSuppression.create({ | |
| 21 | team_id: teamId, | |
| 22 | recipient, | |
| 23 | channel, | |
| 24 | reason: 'unsubscribe', | |
| 25 | source: 'preference_center', | |
| 26 | suppressedAt: new Date().toISOString(), | |
| 27 | }) | |
| 28 | } | |
| 29 | await ConsentEvent.create({ | |
| 30 | team_id: teamId, | |
| 14 | const [activeSuppression, latestConsent] = await Promise.all([ | |
| 15 | findActiveSuppression(teamId, channel, recipient), | |
| 16 | findLatestConsent(teamId, channel, recipient), | |
| 17 | ]) | |
| 18 | const alreadyApplied = !!activeSuppression && consentStateMatches(latestConsent, 'revoked', 'preference_center') | |
| 19 | ||
| 20 | await ensureSuppressed({ teamId, recipient, channel, source: 'preference_center' }) | |
| 21 | if (alreadyApplied) return | |
| 22 | await appendConsentOnce({ | |
| 23 | teamId, | |
| 31 | 24 | recipient, |
| 32 | 25 | channel, |
| 33 | 26 | action: 'revoked', |
| 34 | purpose: 'marketing', | |
| 35 | 27 | source: 'preference_center', |
| 36 | policyVersion: '1.0', | |
| 28 | idempotencyKey: consentIdempotencyKey({ scope: 'preferences', token, channel, action: 'revoked', predecessorId: latestConsent?.id }), | |
| 37 | 29 | proof: JSON.stringify({ signedPreferenceToken: true }), |
| 38 | occurredAt: new Date().toISOString(), | |
| 39 | 30 | }) |
| 40 | 31 | } |
| 41 | 32 | |
| @@ -44,7 +35,8 @@ export default new Action({ | ||
| 44 | 35 | description: 'Applies opt-outs and starts verified channel opt-ins', |
| 45 | 36 | method: 'POST', |
| 46 | 37 | async handle(request: RequestInstance) { |
| 47 | const payload = verifyPublicToken(String(request.getParam('token') || ''), String(config.app.key)) | |
| 38 | const rawToken = String(request.getParam('token') || '') | |
| 39 | const payload = verifyPublicToken(rawToken, String(config.app.key)) | |
| 48 | 40 | if (!payload || payload.purpose !== 'preferences') |
| 49 | 41 | return response.json({ error: 'Preference link is invalid or expired' }, 400) |
| 50 | 42 | |
| @@ -58,31 +50,44 @@ export default new Action({ | ||
| 58 | 50 | const result = { emailConfirmationRequired: false, smsStartRequired: false } |
| 59 | 51 | |
| 60 | 52 | if (wantsEmail === false) { |
| 61 | await suppress(payload.teamId, 'email', email) | |
| 53 | await suppress(payload.teamId, 'email', email, rawToken) | |
| 62 | 54 | await contact.update({ status: 'unsubscribed' }) |
| 63 | 55 | } |
| 64 | 56 | else if (wantsEmail === true && email) { |
| 65 | await ConsentEvent.create({ | |
| 66 | team_id: payload.teamId, | |
| 57 | const [activeSuppression, latestConsent] = await Promise.all([ | |
| 58 | findActiveSuppression(payload.teamId, 'email', email), | |
| 59 | findLatestConsent(payload.teamId, 'email', email), | |
| 60 | ]) | |
| 61 | const alreadyRequested = consentStateMatches(latestConsent, 'requested', 'preference_center') | |
| 62 | const alreadyEnabled = !activeSuppression && !!latestConsent && ['granted', 'confirmed'].includes(String(latestConsent.action)) | |
| 63 | const transitionKey = consentIdempotencyKey({ scope: 'preferences', token: rawToken, channel: 'email', action: 'requested', predecessorId: latestConsent?.id }) | |
| 64 | const requestKey = alreadyRequested | |
| 65 | ? String(latestConsent.idempotencyKey || latestConsent.idempotency_key || transitionKey) | |
| 66 | : transitionKey | |
| 67 | const created = alreadyRequested || alreadyEnabled ? false : await appendConsentOnce({ | |
| 68 | teamId: payload.teamId, | |
| 67 | 69 | recipient: email, |
| 68 | 70 | channel: 'email', |
| 69 | 71 | action: 'requested', |
| 70 | purpose: 'marketing', | |
| 71 | 72 | source: 'preference_center', |
| 72 | policyVersion: '1.0', | |
| 73 | idempotencyKey: requestKey, | |
| 73 | 74 | proof: JSON.stringify({ signedPreferenceToken: true }), |
| 74 | occurredAt: new Date().toISOString(), | |
| 75 | 75 | }) |
| 76 | const token = createPublicToken({ teamId: payload.teamId, contactId: contact.id, channel: 'email', purpose: 'confirm', expiresAt: Date.now() + 48 * 60 * 60 * 1000 }, String(config.app.key)) | |
| 77 | const confirmationUrl = `${String(config.app.url).replace(/\/$/, '')}/confirm/${token}` | |
| 78 | await job('SendEmail', { | |
| 79 | message: buildConfirmationMessage(email, confirmationUrl), | |
| 80 | driver: config.email.default, | |
| 81 | }).onQueue('emails').dispatch() | |
| 82 | result.emailConfirmationRequired = true | |
| 76 | if (created || alreadyRequested) { | |
| 77 | result.emailConfirmationRequired = true | |
| 78 | const token = createPublicToken({ teamId: payload.teamId, contactId: contact.id, channel: 'email', purpose: 'confirm', expiresAt: Date.now() + 48 * 60 * 60 * 1000 }, String(config.app.key)) | |
| 79 | const confirmationUrl = `${String(config.app.url).replace(/\/$/, '')}/confirm/${token}` | |
| 80 | await job('SendEmail', { | |
| 81 | message: buildConfirmationMessage(email, confirmationUrl), | |
| 82 | driver: config.email.default, | |
| 83 | }) | |
| 84 | .withIdempotencyKey(`confirmation:${requestKey}`) | |
| 85 | .onQueue('emails') | |
| 86 | .dispatch() | |
| 87 | } | |
| 83 | 88 | } |
| 84 | 89 | |
| 85 | if (wantsSms === false) await suppress(payload.teamId, 'sms', sms) | |
| 90 | if (wantsSms === false) await suppress(payload.teamId, 'sms', sms, rawToken) | |
| 86 | 91 | else if (wantsSms === true && sms) result.smsStartRequired = true |
| 87 | 92 | |
| 88 | 93 | if (request.headers.get('accept')?.includes('text/html')) |
| @@ -0,0 +1,132 @@ | ||
| 1 | import { createHash } from 'node:crypto' | |
| 2 | ||
| 3 | export type ConsentChannel = 'email' | 'sms' | |
| 4 | export type ConsentAction = 'requested' | 'granted' | 'revoked' | 'confirmed' | 'suppressed' | |
| 5 | ||
| 6 | interface ConsentKeyInput { | |
| 7 | scope: string | |
| 8 | token: string | |
| 9 | channel: ConsentChannel | |
| 10 | action: ConsentAction | |
| 11 | predecessorId?: number | string | null | |
| 12 | } | |
| 13 | ||
| 14 | interface ConsentEventInput { | |
| 15 | teamId: number | |
| 16 | recipient: string | |
| 17 | channel: ConsentChannel | |
| 18 | action: ConsentAction | |
| 19 | source: string | |
| 20 | idempotencyKey: string | |
| 21 | purpose?: string | |
| 22 | policyVersion?: string | |
| 23 | proof?: string | |
| 24 | jurisdiction?: string | |
| 25 | ipAddress?: string | |
| 26 | } | |
| 27 | ||
| 28 | interface SuppressionInput { | |
| 29 | teamId: number | |
| 30 | recipient: string | |
| 31 | channel: ConsentChannel | |
| 32 | source: string | |
| 33 | reason?: 'unsubscribe' | 'bounce' | 'complaint' | 'carrier' | 'manual' | 'legal' | |
| 34 | } | |
| 35 | ||
| 36 | export function consentIdempotencyKey(input: ConsentKeyInput): string { | |
| 37 | const tokenDigest = createHash('sha256').update(input.token).digest('hex') | |
| 38 | const predecessor = input.predecessorId == null ? 'origin' : String(input.predecessorId) | |
| 39 | return `public:${input.scope}:${input.channel}:${input.action}:${predecessor}:${tokenDigest}` | |
| 40 | } | |
| 41 | ||
| 42 | export function consentStateMatches(event: any, action: ConsentAction, source: string): boolean { | |
| 43 | return !!event && String(event.action) === action && String(event.source) === source | |
| 44 | } | |
| 45 | ||
| 46 | export async function findLatestConsent(teamId: number, channel: ConsentChannel, recipient: string): Promise<any | null> { | |
| 47 | return ConsentEvent | |
| 48 | .where('team_id', teamId) | |
| 49 | .where('channel', channel) | |
| 50 | .where('recipient', recipient) | |
| 51 | .orderByDesc('occurredAt') | |
| 52 | .first() | |
| 53 | } | |
| 54 | ||
| 55 | export async function findConsentByKey(teamId: number, idempotencyKey: string): Promise<any | null> { | |
| 56 | return ConsentEvent | |
| 57 | .where('team_id', teamId) | |
| 58 | .where('idempotency_key', idempotencyKey) | |
| 59 | .first() | |
| 60 | } | |
| 61 | ||
| 62 | export async function appendConsentOnce(input: ConsentEventInput): Promise<boolean> { | |
| 63 | const findExisting = () => findConsentByKey(input.teamId, input.idempotencyKey) | |
| 64 | if (await findExisting()) return false | |
| 65 | ||
| 66 | try { | |
| 67 | await ConsentEvent.create({ | |
| 68 | team_id: input.teamId, | |
| 69 | recipient: input.recipient, | |
| 70 | channel: input.channel, | |
| 71 | action: input.action, | |
| 72 | purpose: input.purpose ?? 'marketing', | |
| 73 | source: input.source, | |
| 74 | jurisdiction: input.jurisdiction, | |
| 75 | policyVersion: input.policyVersion ?? '1.0', | |
| 76 | idempotencyKey: input.idempotencyKey, | |
| 77 | proof: input.proof, | |
| 78 | ipAddress: input.ipAddress, | |
| 79 | occurredAt: new Date().toISOString(), | |
| 80 | }) | |
| 81 | return true | |
| 82 | } | |
| 83 | catch (error) { | |
| 84 | if (await findExisting()) return false | |
| 85 | throw error | |
| 86 | } | |
| 87 | } | |
| 88 | ||
| 89 | export async function findActiveSuppression(teamId: number, channel: ConsentChannel, recipient: string): Promise<any | null> { | |
| 90 | if (!recipient) return null | |
| 91 | return CommunicationSuppression | |
| 92 | .where('team_id', teamId) | |
| 93 | .where('channel', channel) | |
| 94 | .where('recipient', recipient) | |
| 95 | .whereNull('liftedAt') | |
| 96 | .first() | |
| 97 | } | |
| 98 | ||
| 99 | export async function ensureSuppressed(input: SuppressionInput): Promise<void> { | |
| 100 | if (!input.recipient || await findActiveSuppression(input.teamId, input.channel, input.recipient)) return | |
| 101 | ||
| 102 | const existing = await CommunicationSuppression | |
| 103 | .where('team_id', input.teamId) | |
| 104 | .where('channel', input.channel) | |
| 105 | .where('recipient', input.recipient) | |
| 106 | .first() | |
| 107 | ||
| 108 | if (existing) { | |
| 109 | await CommunicationSuppression.forceUpdate(existing.id, { | |
| 110 | reason: input.reason ?? 'unsubscribe', | |
| 111 | source: input.source, | |
| 112 | suppressedAt: new Date().toISOString(), | |
| 113 | liftedAt: null, | |
| 114 | }) | |
| 115 | return | |
| 116 | } | |
| 117 | ||
| 118 | try { | |
| 119 | await CommunicationSuppression.create({ | |
| 120 | team_id: input.teamId, | |
| 121 | recipient: input.recipient, | |
| 122 | channel: input.channel, | |
| 123 | reason: input.reason ?? 'unsubscribe', | |
| 124 | source: input.source, | |
| 125 | suppressedAt: new Date().toISOString(), | |
| 126 | }) | |
| 127 | } | |
| 128 | catch (error) { | |
| 129 | if (await findActiveSuppression(input.teamId, input.channel, input.recipient)) return | |
| 130 | throw error | |
| 131 | } | |
| 132 | } | |
| @@ -4,6 +4,7 @@ import { Job } from '@stacksjs/queue' | ||
| 4 | 4 | import CommerceConnection from '../Models/CommerceConnection' |
| 5 | 5 | import CommerceEvent from '../Models/CommerceEvent' |
| 6 | 6 | import WebhookEvent from '../Models/WebhookEvent' |
| 7 | import { appendConsentOnce, ensureSuppressed } from '../Actions/Public/consent-ledger' | |
| 7 | 8 | import { twilioConsentIdempotencyKey } from '../Actions/Webhooks/twilio-compliance' |
| 8 | 9 | import { commerceEventType, objectPayload, webhookRecipient } from '../Actions/Webhooks/payload' |
| 9 | 10 | |
| @@ -32,25 +33,25 @@ export default new Job({ | ||
| 32 | 33 | if (event.provider === 'twilio' && !payload.MessageStatus) { |
| 33 | 34 | const { intent, keyword } = classifySmsIntent(String(payload.Body || '')) |
| 34 | 35 | const idempotencyKey = twilioConsentIdempotencyKey(String(event.providerEventId)) |
| 35 | const existingConsent = await ConsentEvent | |
| 36 | .where('team_id', input.teamId) | |
| 37 | .where('idempotency_key', idempotencyKey) | |
| 38 | .first() | |
| 39 | 36 | if (intent === 'opt-out') { |
| 40 | const existing = await CommunicationSuppression.where('team_id', input.teamId).where('channel', 'sms').where('recipient', recipient).first() | |
| 41 | if (!existing) await CommunicationSuppression.create({ team_id: input.teamId, channel: 'sms', recipient, reason: 'unsubscribe', source: `twilio:${keyword}`, suppressedAt: new Date().toISOString() }) | |
| 42 | if (!existingConsent) await ConsentEvent.create({ team_id: input.teamId, recipient, channel: 'sms', action: 'revoked', purpose: 'marketing', source: 'twilio_inbound', policyVersion: '1.0', idempotencyKey, proof: JSON.stringify({ eventId: event.providerEventId, keyword }), occurredAt: new Date().toISOString() }) | |
| 37 | await ensureSuppressed({ teamId: input.teamId, channel: 'sms', recipient, source: `twilio:${keyword}` }) | |
| 38 | await appendConsentOnce({ teamId: input.teamId, recipient, channel: 'sms', action: 'revoked', source: 'twilio_inbound', idempotencyKey, proof: JSON.stringify({ eventId: event.providerEventId, keyword }) }) | |
| 43 | 39 | } |
| 44 | 40 | else if (intent === 'opt-in') { |
| 45 | 41 | const existing = await CommunicationSuppression.where('team_id', input.teamId).where('channel', 'sms').where('recipient', recipient).first() |
| 46 | 42 | if (existing && !existing.liftedAt) await CommunicationSuppression.forceUpdate(existing.id, { liftedAt: new Date().toISOString() }) |
| 47 | if (!existingConsent) await ConsentEvent.create({ team_id: input.teamId, recipient, channel: 'sms', action: 'granted', purpose: 'marketing', source: 'twilio_inbound', policyVersion: '1.0', idempotencyKey, proof: JSON.stringify({ eventId: event.providerEventId, keyword }), occurredAt: new Date().toISOString() }) | |
| 43 | await appendConsentOnce({ teamId: input.teamId, recipient, channel: 'sms', action: 'granted', source: 'twilio_inbound', idempotencyKey, proof: JSON.stringify({ eventId: event.providerEventId, keyword }) }) | |
| 48 | 44 | } |
| 49 | 45 | } |
| 50 | 46 | |
| 51 | 47 | if (event.provider === 'mail' && ['bounce', 'complaint'].some(kind => String(event.type).includes(kind))) { |
| 52 | const existing = await CommunicationSuppression.where('team_id', input.teamId).where('channel', 'email').where('recipient', recipient).first() | |
| 53 | if (!existing) await CommunicationSuppression.create({ team_id: input.teamId, channel: 'email', recipient, reason: String(event.type).includes('complaint') ? 'complaint' : 'bounce', source: 'owned_mail', suppressedAt: new Date().toISOString() }) | |
| 48 | await ensureSuppressed({ | |
| 49 | teamId: input.teamId, | |
| 50 | channel: 'email', | |
| 51 | recipient, | |
| 52 | reason: String(event.type).includes('complaint') ? 'complaint' : 'bounce', | |
| 53 | source: 'owned_mail', | |
| 54 | }) | |
| 54 | 55 | } |
| 55 | 56 | |
| 56 | 57 | if (['shopify', 'woocommerce'].includes(String(event.provider))) { |
| @@ -0,0 +1,107 @@ | ||
| 1 | import { afterEach, describe, expect, it } from 'bun:test' | |
| 2 | import { readFileSync } from 'node:fs' | |
| 3 | import { resolve } from 'node:path' | |
| 4 | import { appendConsentOnce, consentIdempotencyKey, consentStateMatches, ensureSuppressed } from '../../app/Actions/Public/consent-ledger' | |
| 5 | ||
| 6 | const originalConsentModel = (globalThis as any).ConsentEvent | |
| 7 | const originalSuppressionModel = (globalThis as any).CommunicationSuppression | |
| 8 | ||
| 9 | afterEach(() => { | |
| 10 | ;(globalThis as any).ConsentEvent = originalConsentModel | |
| 11 | ;(globalThis as any).CommunicationSuppression = originalSuppressionModel | |
| 12 | }) | |
| 13 | ||
| 14 | describe('public consent idempotency', () => { | |
| 15 | it('generates a stable key without exposing the signed token', () => { | |
| 16 | const input = { scope: 'unsubscribe', token: 'signed.secret.token', channel: 'email' as const, action: 'revoked' as const, predecessorId: 42 } | |
| 17 | const first = consentIdempotencyKey(input) | |
| 18 | ||
| 19 | expect(consentIdempotencyKey(input)).toBe(first) | |
| 20 | expect(first).not.toContain(input.token) | |
| 21 | expect(first.length).toBeLessThanOrEqual(255) | |
| 22 | }) | |
| 23 | ||
| 24 | it('allows a later state transition while deduplicating the same transition', () => { | |
| 25 | const base = { scope: 'preferences', token: 'signed-token', channel: 'email' as const, action: 'revoked' as const } | |
| 26 | ||
| 27 | expect(consentIdempotencyKey({ ...base, predecessorId: 10 })).toBe(consentIdempotencyKey({ ...base, predecessorId: 10 })) | |
| 28 | expect(consentIdempotencyKey({ ...base, predecessorId: 10 })).not.toBe(consentIdempotencyKey({ ...base, predecessorId: 11 })) | |
| 29 | }) | |
| 30 | ||
| 31 | it('matches both the consent action and its evidence source', () => { | |
| 32 | expect(consentStateMatches({ action: 'revoked', source: 'one_click' }, 'revoked', 'one_click')).toBe(true) | |
| 33 | expect(consentStateMatches({ action: 'revoked', source: 'webhook' }, 'revoked', 'one_click')).toBe(false) | |
| 34 | expect(consentStateMatches(null, 'revoked', 'one_click')).toBe(false) | |
| 35 | }) | |
| 36 | ||
| 37 | it('guards confirmation replay before lifting a newer suppression', () => { | |
| 38 | const action = readFileSync(resolve(import.meta.dir, '../../app/Actions/Public/ConfirmSubscriptionAction.ts'), 'utf8') | |
| 39 | const replayGuard = action.indexOf('if (await findConsentByKey') | |
| 40 | const suppressionLookup = action.indexOf('CommunicationSuppression') | |
| 41 | ||
| 42 | expect(replayGuard).toBeGreaterThan(-1) | |
| 43 | expect(replayGuard).toBeLessThan(suppressionLookup) | |
| 44 | }) | |
| 45 | ||
| 46 | it('reactivates a previously lifted suppression', async () => { | |
| 47 | let update: Record<string, unknown> | undefined | |
| 48 | const existing = { id: 7, liftedAt: '2026-09-01T00:00:00.000Z' } | |
| 49 | const query: any = { | |
| 50 | first: async () => existing, | |
| 51 | where: () => query, | |
| 52 | whereNull: () => ({ first: async () => null }), | |
| 53 | } | |
| 54 | ;(globalThis as any).CommunicationSuppression = { | |
| 55 | forceUpdate: async (_id: number, values: Record<string, unknown>) => { update = values }, | |
| 56 | where: () => query, | |
| 57 | } | |
| 58 | ||
| 59 | await ensureSuppressed({ teamId: 1, channel: 'email', recipient: 'person@example.com', source: 'one_click' }) | |
| 60 | ||
| 61 | expect(update).toMatchObject({ liftedAt: null, reason: 'unsubscribe', source: 'one_click' }) | |
| 62 | }) | |
| 63 | ||
| 64 | it('treats a concurrent unique-key winner as an idempotent replay', async () => { | |
| 65 | let reads = 0 | |
| 66 | const query: any = { | |
| 67 | first: async () => ++reads === 1 ? null : { id: 9 }, | |
| 68 | where: () => query, | |
| 69 | } | |
| 70 | ;(globalThis as any).ConsentEvent = { | |
| 71 | create: async () => { throw new Error('unique constraint') }, | |
| 72 | where: () => query, | |
| 73 | } | |
| 74 | ||
| 75 | const created = await appendConsentOnce({ | |
| 76 | teamId: 1, | |
| 77 | recipient: 'person@example.com', | |
| 78 | channel: 'email', | |
| 79 | action: 'revoked', | |
| 80 | source: 'one_click', | |
| 81 | idempotencyKey: 'public:unsubscribe:email:revoked:origin:digest', | |
| 82 | }) | |
| 83 | ||
| 84 | expect(created).toBe(false) | |
| 85 | expect(reads).toBe(2) | |
| 86 | }) | |
| 87 | ||
| 88 | it('routes every public consent writer through the idempotent ledger', () => { | |
| 89 | const files = [ | |
| 90 | 'SubscribeAction.ts', | |
| 91 | 'UnsubscribeAction.ts', | |
| 92 | 'ConfirmSubscriptionAction.ts', | |
| 93 | 'UpdatePreferencesAction.ts', | |
| 94 | ] | |
| 95 | ||
| 96 | for (const file of files) { | |
| 97 | const action = readFileSync(resolve(import.meta.dir, `../../app/Actions/Public/${file}`), 'utf8') | |
| 98 | expect(action).toContain('appendConsentOnce') | |
| 99 | expect(action).not.toContain('ConsentEvent.create') | |
| 100 | } | |
| 101 | ||
| 102 | const subscribe = readFileSync(resolve(import.meta.dir, '../../app/Actions/Public/SubscribeAction.ts'), 'utf8') | |
| 103 | const preferences = readFileSync(resolve(import.meta.dir, '../../app/Actions/Public/UpdatePreferencesAction.ts'), 'utf8') | |
| 104 | expect(subscribe).toContain('.withIdempotencyKey(') | |
| 105 | expect(preferences).toContain('.withIdempotencyKey(') | |
| 106 | }) | |
| 107 | }) | |