create-ailk 0.19.0 → 0.19.1
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/dist/add-module.js +7 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/scaffold-render-sweep.d.ts +23 -0
- package/dist/lib/scaffold-render-sweep.js +52 -0
- package/dist/module-architecture.js +9 -3
- package/dist/programmatic.js +8 -1
- package/dist/site-roster.js +21 -4
- package/dist/surfaces.d.ts +14 -0
- package/dist/surfaces.js +31 -1
- package/dist/topology.js +29 -1
- package/package.json +3 -3
- package/templates/apps/api/.env.example +17 -1
- package/templates/apps/api/CLAUDE.md +10 -4
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +19 -0
- package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +8 -0
- package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
- package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
- package/templates/apps/api/src/lib/consent-store.ts +335 -0
- package/templates/apps/api/src/lib/stripe.ts +97 -5
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +10 -4
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +51 -0
- package/templates/apps/api/src/middleware/auth.ts +11 -3
- package/templates/apps/api/src/middleware/rate-limit.ts +5 -0
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
- package/templates/apps/api/src/routes/checkout/session.ts +3 -3
- package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
- package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
- package/templates/apps/api/src/routes/consent/index.ts +18 -0
- package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
- package/templates/apps/api/src/routes/leads/index.ts +15 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +17 -0
- package/templates/apps/api/src/server.ts +111 -16
- package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +115 -4
- package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
- package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
- package/templates/apps/api/src/services/consent.ts +488 -3
- package/templates/apps/api/src/services/lead-routing.ts +105 -12
- package/templates/apps/api/src/services/person.ts +501 -1
- package/templates/apps/api/src/services/project-listings.ts +7 -2
- package/templates/apps/api/src/services/request-confirm.ts +52 -0
- package/templates/apps/api/src/services/revalidation.ts +11 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +17 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +156 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +89 -14
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +122 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +15 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +290 -20
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +55 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +4 -7
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +66 -4
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +2 -2
- package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +89 -37
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +41 -33
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
- package/templates/apps/web/auth.ts +20 -1
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
- package/templates/apps/web/lib/auth-email.ts +45 -10
- package/templates/apps/web/lib/topology.generated.ts +9 -0
- package/templates/apps/web/messages/en.json +54 -14
- package/templates/database/CHANGELOG.md +16 -0
- package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
- package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
- package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
- package/templates/database/__tests__/person-contact.db.test.ts +563 -0
- package/templates/database/__tests__/person.db.test.ts +3 -1
- package/templates/database/inbox/schema.prisma +76 -10
- package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
- package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
- package/templates/database/package.json +1 -1
- package/versions.json +35 -35
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The consent ledger's read boundary (#8438, spec
|
|
3
|
+
* docs/specs/8438-consent-registry-and-ledger-step-spec.md D4).
|
|
4
|
+
*
|
|
5
|
+
* `consent_grants` is append-only and a Person's current state is the newest
|
|
6
|
+
* row per purpose, so every reader here asks the database for exactly that.
|
|
7
|
+
* Nothing is read through a window: `DISTINCT ON (purpose_id) … ORDER BY
|
|
8
|
+
* purpose_id, created_at DESC, id DESC` is one indexed pass over ALL the
|
|
9
|
+
* matching rows, so no number of other rows can push an answer out
|
|
10
|
+
* (SEC-8438-2).
|
|
11
|
+
*
|
|
12
|
+
* TWO READINGS of a Person's rows (SEC-8438-1). A Person is keyed on an email
|
|
13
|
+
* anyone can type into a form, so a row an anonymous writer attached to it
|
|
14
|
+
* proves nothing about what the person themself chose:
|
|
15
|
+
*
|
|
16
|
+
* - OWN rows: `person_id = P` and a source only a verified caller or a
|
|
17
|
+
* signed link sent to the person can write — `sign_up`,
|
|
18
|
+
* `account_settings`, `email_unsubscribe` (`OWN_CONSENT_SOURCES`). The
|
|
19
|
+
* member's screens, the sign-up prompt, and every future send read only
|
|
20
|
+
* these.
|
|
21
|
+
* - ANY-SOURCE rows: every row with `person_id = P`, plus the cookie rows
|
|
22
|
+
* of every visitor linked to P. The operator's Person page reads these,
|
|
23
|
+
* marked `unverified` when the source proves nothing.
|
|
24
|
+
*
|
|
25
|
+
* Every query takes an explicit `workspaceId`, and a join to a linked
|
|
26
|
+
* visitor's rows matches `workspace_id` on both tables (SEC-8438-11) — a
|
|
27
|
+
* visitor id is client-chosen and not namespaced, so another tenant's rows
|
|
28
|
+
* under the same id must never join.
|
|
29
|
+
*
|
|
30
|
+
* This file is the ONLY non-test reader of `consent_grants` and
|
|
31
|
+
* `consent_visitor_links` (`architecture.yaml` names it as a data-access
|
|
32
|
+
* boundary, the kind `member-store.ts` is). The writes are
|
|
33
|
+
* `services/consent.ts`. Logs carry nothing: no email, visitor id, token or
|
|
34
|
+
* statement.
|
|
35
|
+
*/
|
|
36
|
+
import type { Prisma } from "@working-theory/database";
|
|
37
|
+
import {
|
|
38
|
+
consentPurposeKind,
|
|
39
|
+
type ConsentPurposeKind,
|
|
40
|
+
type ConsentSource,
|
|
41
|
+
type SiteConsentConfig,
|
|
42
|
+
} from "@working-theory/validation";
|
|
43
|
+
|
|
44
|
+
import { prisma } from "./prisma.js";
|
|
45
|
+
|
|
46
|
+
/** What every reader needs of a client: raw reads only. */
|
|
47
|
+
export type ConsentReadClient = Pick<Prisma.TransactionClient, "$queryRaw">;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The sources only a verified caller or a signed link can write (D4,
|
|
51
|
+
* SEC-8438-1). The SQL below names the same three literals; the store's test
|
|
52
|
+
* asserts the two agree.
|
|
53
|
+
*/
|
|
54
|
+
export const OWN_CONSENT_SOURCES = [
|
|
55
|
+
"sign_up",
|
|
56
|
+
"account_settings",
|
|
57
|
+
"email_unsubscribe",
|
|
58
|
+
] as const satisfies readonly ConsentSource[];
|
|
59
|
+
|
|
60
|
+
/** The sources whose rows prove nothing the client confirmed themself. */
|
|
61
|
+
const UNVERIFIED_SOURCES: ReadonlySet<ConsentSource> = new Set([
|
|
62
|
+
"magnet",
|
|
63
|
+
"api",
|
|
64
|
+
"cookie_banner",
|
|
65
|
+
"legacy",
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
/** Whether a row from `source` is unconfirmed by the client (D4). */
|
|
69
|
+
export function isUnverifiedSource(source: ConsentSource): boolean {
|
|
70
|
+
return UNVERIFIED_SOURCES.has(source);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A history page's size (D7). */
|
|
74
|
+
export const CONSENT_HISTORY_PAGE_SIZE = 500;
|
|
75
|
+
|
|
76
|
+
/** The most platform rows one user can have in view (D4). */
|
|
77
|
+
const PLATFORM_ROWS_MAX = 100;
|
|
78
|
+
|
|
79
|
+
/** The newest ledger row for one purpose, as the readers serve it. */
|
|
80
|
+
export interface ConsentStateRow {
|
|
81
|
+
id: string;
|
|
82
|
+
purposeId: string;
|
|
83
|
+
granted: boolean;
|
|
84
|
+
at: Date;
|
|
85
|
+
source: ConsentSource;
|
|
86
|
+
sourceRef: string | null;
|
|
87
|
+
policyVersion: number | null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface ConsentHistoryRow extends ConsentStateRow {
|
|
91
|
+
locale: string;
|
|
92
|
+
statement: string;
|
|
93
|
+
subjectType: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A registry purpose with the newest row read for it and what that means now. */
|
|
97
|
+
export interface PurposeConsentState {
|
|
98
|
+
purposeId: string;
|
|
99
|
+
kind: ConsentPurposeKind;
|
|
100
|
+
row: ConsentStateRow | null;
|
|
101
|
+
/** Granted now: the row's answer, else on for a preference and off otherwise. */
|
|
102
|
+
effective: boolean;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The effective answer for a purpose given its newest row (D4). */
|
|
106
|
+
export function effectiveConsent(
|
|
107
|
+
kind: ConsentPurposeKind,
|
|
108
|
+
row: { granted: boolean } | null,
|
|
109
|
+
): boolean {
|
|
110
|
+
if (row !== null) return row.granted;
|
|
111
|
+
return kind === "preference";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Fold the newest row per purpose id into the registry's purposes. */
|
|
115
|
+
export function foldRowsIntoPurposes(
|
|
116
|
+
config: SiteConsentConfig,
|
|
117
|
+
rows: readonly ConsentStateRow[],
|
|
118
|
+
): PurposeConsentState[] {
|
|
119
|
+
const byPurpose = new Map(rows.map((row) => [row.purposeId, row]));
|
|
120
|
+
return config.purposes.map((purpose) => {
|
|
121
|
+
const kind = consentPurposeKind(purpose);
|
|
122
|
+
const row = byPurpose.get(purpose.id) ?? null;
|
|
123
|
+
return {
|
|
124
|
+
purposeId: purpose.id,
|
|
125
|
+
kind,
|
|
126
|
+
row,
|
|
127
|
+
effective: effectiveConsent(kind, row),
|
|
128
|
+
};
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The newest OWN row per purpose id for a Person, unfolded (D4): one indexed
|
|
134
|
+
* `DISTINCT ON` pass over all own rows, never a window.
|
|
135
|
+
*/
|
|
136
|
+
export async function ownConsentRows(
|
|
137
|
+
workspaceId: string,
|
|
138
|
+
personId: string,
|
|
139
|
+
db: ConsentReadClient = prisma,
|
|
140
|
+
): Promise<ConsentStateRow[]> {
|
|
141
|
+
return db.$queryRaw<ConsentStateRow[]>`
|
|
142
|
+
SELECT DISTINCT ON (g."purpose_id")
|
|
143
|
+
g."id", g."purpose_id" AS "purposeId", g."granted", g."created_at" AS "at",
|
|
144
|
+
g."source", g."source_ref" AS "sourceRef", g."policy_version" AS "policyVersion"
|
|
145
|
+
FROM "consent_grants" g
|
|
146
|
+
WHERE g."workspace_id" = ${workspaceId}
|
|
147
|
+
AND g."person_id" = ${personId}
|
|
148
|
+
AND g."source" IN ('sign_up', 'account_settings', 'email_unsubscribe')
|
|
149
|
+
ORDER BY g."purpose_id", g."created_at" DESC, g."id" DESC`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The newest ANY-SOURCE row per purpose id for a Person: `person_id = P` or a
|
|
154
|
+
* cookie row of a visitor linked to P, in this workspace on both tables.
|
|
155
|
+
* Includes purposes the registry no longer declares — the operator still sees
|
|
156
|
+
* what was recorded.
|
|
157
|
+
*/
|
|
158
|
+
export async function anySourceConsentRows(
|
|
159
|
+
workspaceId: string,
|
|
160
|
+
personId: string,
|
|
161
|
+
db: ConsentReadClient = prisma,
|
|
162
|
+
): Promise<ConsentStateRow[]> {
|
|
163
|
+
return db.$queryRaw<ConsentStateRow[]>`
|
|
164
|
+
SELECT DISTINCT ON (g."purpose_id")
|
|
165
|
+
g."id", g."purpose_id" AS "purposeId", g."granted", g."created_at" AS "at",
|
|
166
|
+
g."source", g."source_ref" AS "sourceRef", g."policy_version" AS "policyVersion"
|
|
167
|
+
FROM "consent_grants" g
|
|
168
|
+
WHERE g."workspace_id" = ${workspaceId}
|
|
169
|
+
AND (
|
|
170
|
+
g."person_id" = ${personId}
|
|
171
|
+
OR (g."subject_type" = 'visitor' AND g."subject_id" IN (
|
|
172
|
+
SELECT l."visitor_id" FROM "consent_visitor_links" l
|
|
173
|
+
WHERE l."workspace_id" = ${workspaceId} AND l."person_id" = ${personId}
|
|
174
|
+
))
|
|
175
|
+
)
|
|
176
|
+
ORDER BY g."purpose_id", g."created_at" DESC, g."id" DESC`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* A Person's OWN reading, per registry purpose (D4). The member's screens and
|
|
181
|
+
* the sign-up prompt read only this.
|
|
182
|
+
*/
|
|
183
|
+
export async function ownConsentState(
|
|
184
|
+
workspaceId: string,
|
|
185
|
+
personId: string,
|
|
186
|
+
config: SiteConsentConfig,
|
|
187
|
+
db: ConsentReadClient = prisma,
|
|
188
|
+
): Promise<PurposeConsentState[]> {
|
|
189
|
+
return foldRowsIntoPurposes(
|
|
190
|
+
config,
|
|
191
|
+
await ownConsentRows(workspaceId, personId, db),
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* A Person's ANY-SOURCE reading, per registry purpose. The operator's Person
|
|
197
|
+
* page and the unsubscribe dedupe read it; a row here is never a member's own
|
|
198
|
+
* answer (D4).
|
|
199
|
+
*/
|
|
200
|
+
export async function anySourceConsentState(
|
|
201
|
+
workspaceId: string,
|
|
202
|
+
personId: string,
|
|
203
|
+
config: SiteConsentConfig,
|
|
204
|
+
db: ConsentReadClient = prisma,
|
|
205
|
+
): Promise<PurposeConsentState[]> {
|
|
206
|
+
return foldRowsIntoPurposes(
|
|
207
|
+
config,
|
|
208
|
+
await anySourceConsentRows(workspaceId, personId, db),
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** One page of a Person's any-source history, newest first. */
|
|
213
|
+
export interface ConsentHistoryPage {
|
|
214
|
+
rows: ConsentHistoryRow[];
|
|
215
|
+
/** The id of the page's last row when another page follows, else `null`. */
|
|
216
|
+
nextCursor: string | null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* A Person's any-source history, 500 rows a page, newest first, keyed by
|
|
221
|
+
* `(created_at, id)`. Every page is complete: nothing is truncated (D7).
|
|
222
|
+
* Returns `"invalid_cursor"` for a cursor that is not one of this Person's
|
|
223
|
+
* rows — checked inside the Person's own scope, so a row id of another
|
|
224
|
+
* Person or workspace reads exactly like an unknown one.
|
|
225
|
+
*/
|
|
226
|
+
export async function personConsentHistory(
|
|
227
|
+
workspaceId: string,
|
|
228
|
+
personId: string,
|
|
229
|
+
cursor: string | undefined,
|
|
230
|
+
db: ConsentReadClient = prisma,
|
|
231
|
+
): Promise<ConsentHistoryPage | "invalid_cursor"> {
|
|
232
|
+
if (cursor !== undefined) {
|
|
233
|
+
const found = await db.$queryRaw<Array<{ id: string }>>`
|
|
234
|
+
SELECT g."id" FROM "consent_grants" g
|
|
235
|
+
WHERE g."workspace_id" = ${workspaceId}
|
|
236
|
+
AND g."id" = ${cursor}
|
|
237
|
+
AND (
|
|
238
|
+
g."person_id" = ${personId}
|
|
239
|
+
OR (g."subject_type" = 'visitor' AND g."subject_id" IN (
|
|
240
|
+
SELECT l."visitor_id" FROM "consent_visitor_links" l
|
|
241
|
+
WHERE l."workspace_id" = ${workspaceId} AND l."person_id" = ${personId}
|
|
242
|
+
))
|
|
243
|
+
)
|
|
244
|
+
LIMIT 1`;
|
|
245
|
+
if (found.length === 0) return "invalid_cursor";
|
|
246
|
+
}
|
|
247
|
+
const cursorId = cursor ?? null;
|
|
248
|
+
const rows = await db.$queryRaw<ConsentHistoryRow[]>`
|
|
249
|
+
SELECT g."id", g."purpose_id" AS "purposeId", g."granted", g."created_at" AS "at",
|
|
250
|
+
g."source", g."source_ref" AS "sourceRef", g."policy_version" AS "policyVersion",
|
|
251
|
+
g."locale", g."statement", g."subject_type" AS "subjectType"
|
|
252
|
+
FROM "consent_grants" g
|
|
253
|
+
WHERE g."workspace_id" = ${workspaceId}
|
|
254
|
+
AND (
|
|
255
|
+
g."person_id" = ${personId}
|
|
256
|
+
OR (g."subject_type" = 'visitor' AND g."subject_id" IN (
|
|
257
|
+
SELECT l."visitor_id" FROM "consent_visitor_links" l
|
|
258
|
+
WHERE l."workspace_id" = ${workspaceId} AND l."person_id" = ${personId}
|
|
259
|
+
))
|
|
260
|
+
)
|
|
261
|
+
AND (${cursorId}::text IS NULL OR (g."created_at", g."id") < (
|
|
262
|
+
SELECT c."created_at", c."id" FROM "consent_grants" c WHERE c."id" = ${cursorId}::text
|
|
263
|
+
))
|
|
264
|
+
ORDER BY g."created_at" DESC, g."id" DESC
|
|
265
|
+
LIMIT ${CONSENT_HISTORY_PAGE_SIZE + 1}`;
|
|
266
|
+
const page = rows.slice(0, CONSENT_HISTORY_PAGE_SIZE);
|
|
267
|
+
const last = page[page.length - 1];
|
|
268
|
+
return {
|
|
269
|
+
rows: page,
|
|
270
|
+
nextCursor:
|
|
271
|
+
rows.length > CONSENT_HISTORY_PAGE_SIZE && last ? last.id : null,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The newest row recording a visitor's choice for one purpose — the cookie
|
|
277
|
+
* writer's dedupe: each CHANGE of choice is logged, a replay adds nothing
|
|
278
|
+
* (D3, SEC-8438-2).
|
|
279
|
+
*/
|
|
280
|
+
export async function latestVisitorChoice(
|
|
281
|
+
workspaceId: string,
|
|
282
|
+
visitorId: string,
|
|
283
|
+
purposeId: string,
|
|
284
|
+
db: ConsentReadClient = prisma,
|
|
285
|
+
): Promise<{ granted: boolean; policyVersion: number | null } | null> {
|
|
286
|
+
const rows = await db.$queryRaw<
|
|
287
|
+
Array<{ granted: boolean; policyVersion: number | null }>
|
|
288
|
+
>`
|
|
289
|
+
SELECT g."granted", g."policy_version" AS "policyVersion"
|
|
290
|
+
FROM "consent_grants" g
|
|
291
|
+
WHERE g."workspace_id" = ${workspaceId}
|
|
292
|
+
AND g."subject_type" = 'visitor'
|
|
293
|
+
AND g."subject_id" = ${visitorId}
|
|
294
|
+
AND g."purpose_id" = ${purposeId}
|
|
295
|
+
ORDER BY g."created_at" DESC, g."id" DESC
|
|
296
|
+
LIMIT 1`;
|
|
297
|
+
return rows[0] ?? null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* When the caller's own `User` row was created — the sign-up window's only
|
|
302
|
+
* input (SEC-8438-5: `via: "sign_up"` is honored for a user created within
|
|
303
|
+
* the last 24 hours). `null` when no such user exists.
|
|
304
|
+
*/
|
|
305
|
+
export async function userCreatedAt(
|
|
306
|
+
userId: string,
|
|
307
|
+
db: Pick<Prisma.TransactionClient, "user"> = prisma,
|
|
308
|
+
): Promise<Date | null> {
|
|
309
|
+
const user = await db.user.findUnique({
|
|
310
|
+
where: { id: userId },
|
|
311
|
+
select: { createdAt: true },
|
|
312
|
+
});
|
|
313
|
+
return user?.createdAt ?? null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The owner's rows for the Working Theory platform purposes (C8): subject
|
|
318
|
+
* `user` / `userId`, newest per purpose, at most 100.
|
|
319
|
+
*/
|
|
320
|
+
export async function userPlatformRows(
|
|
321
|
+
workspaceId: string,
|
|
322
|
+
userId: string,
|
|
323
|
+
db: ConsentReadClient = prisma,
|
|
324
|
+
): Promise<ConsentStateRow[]> {
|
|
325
|
+
return db.$queryRaw<ConsentStateRow[]>`
|
|
326
|
+
SELECT DISTINCT ON (g."purpose_id")
|
|
327
|
+
g."id", g."purpose_id" AS "purposeId", g."granted", g."created_at" AS "at",
|
|
328
|
+
g."source", g."source_ref" AS "sourceRef", g."policy_version" AS "policyVersion"
|
|
329
|
+
FROM "consent_grants" g
|
|
330
|
+
WHERE g."workspace_id" = ${workspaceId}
|
|
331
|
+
AND g."subject_type" = 'user'
|
|
332
|
+
AND g."subject_id" = ${userId}
|
|
333
|
+
ORDER BY g."purpose_id", g."created_at" DESC, g."id" DESC
|
|
334
|
+
LIMIT ${PLATFORM_ROWS_MAX}`;
|
|
335
|
+
}
|
|
@@ -8,7 +8,12 @@
|
|
|
8
8
|
* '@working-theory/stripe-client' — single chokepoint for AILK-internal ergonomics
|
|
9
9
|
* (logger context, request-id propagation) without touching every consumer.
|
|
10
10
|
*/
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
admitLiveRestrictedKey,
|
|
13
|
+
assertSandboxKey,
|
|
14
|
+
getStripeClient,
|
|
15
|
+
StripeConfigError,
|
|
16
|
+
} from "@working-theory/stripe-client";
|
|
12
17
|
|
|
13
18
|
export {
|
|
14
19
|
// PRECONDITION (#6113): do not narrow or bypass this sandbox-only guard for a
|
|
@@ -18,6 +23,8 @@ export {
|
|
|
18
23
|
// Widened by #6852 (the #6113 follow-up): the same function must bind only
|
|
19
24
|
// on a server-stamped metadata.ailk_user_id — an unverified Checkout email
|
|
20
25
|
// never makes a FIRST binding either.
|
|
26
|
+
// Both are on main (#6850, #6862); #8450 is the narrowing this guarded —
|
|
27
|
+
// `assertAdmittedStripeKey` below. This export itself stays sandbox-only.
|
|
21
28
|
assertSandboxKey,
|
|
22
29
|
createAdHocPrice,
|
|
23
30
|
createBillingPortalSession,
|
|
@@ -60,6 +67,89 @@ export type {
|
|
|
60
67
|
StripeWebhookEvent,
|
|
61
68
|
} from "@working-theory/stripe-client";
|
|
62
69
|
|
|
70
|
+
/**
|
|
71
|
+
* apps/api's live-key admission (#8450 D2 — D-68's apps/api leg, the half
|
|
72
|
+
* #6881 left unbuilt). Composed at runtime so this file's own text never
|
|
73
|
+
* trips the repo-wide live-key lint.
|
|
74
|
+
*/
|
|
75
|
+
const LIVE_RESTRICTED_KEY_PREFIX = ["rk", "live", ""].join("_");
|
|
76
|
+
|
|
77
|
+
let liveKeyAdmitted = false;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* `VERCEL_ENV` when set, else `NODE_ENV` — packages/spoke-checkout's own
|
|
81
|
+
* rule, with one fail-closed delta (PR #8470 security review R-1): on Vercel
|
|
82
|
+
* (`VERCEL` set) a missing `VERCEL_ENV` is not production, because
|
|
83
|
+
* `vercel.json` sets `NODE_ENV=production` in every environment, Preview
|
|
84
|
+
* included.
|
|
85
|
+
*/
|
|
86
|
+
function isProductionBuild(): boolean {
|
|
87
|
+
if (process.env.VERCEL_ENV !== undefined) {
|
|
88
|
+
return process.env.VERCEL_ENV === "production";
|
|
89
|
+
}
|
|
90
|
+
if (process.env.VERCEL !== undefined) return false;
|
|
91
|
+
return process.env.NODE_ENV === "production";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Admit this process's Stripe key at server build, or throw
|
|
96
|
+
* `StripeConfigError` (#8450 D2). Called once, by `server.ts`'s `build()`,
|
|
97
|
+
* and nowhere else.
|
|
98
|
+
*
|
|
99
|
+
* Only a restricted live key, beside `STRIPE_MODE=live`, on a production
|
|
100
|
+
* build is admitted: the package's one-way flag is set and `"live"` comes
|
|
101
|
+
* back. `STRIPE_MODE` unset or `test` is today's boot — `assertSandboxKey`,
|
|
102
|
+
* its message unchanged — and returns `"test"`. Every other combination
|
|
103
|
+
* throws. It takes no parameter and reads `process.env` only, so it vouches
|
|
104
|
+
* for the same key `getStripeClient()` later reads. No message names any
|
|
105
|
+
* part of the key.
|
|
106
|
+
*/
|
|
107
|
+
export function admitApiStripeKey(): "test" | "live" {
|
|
108
|
+
const mode = process.env.STRIPE_MODE;
|
|
109
|
+
const key = process.env.STRIPE_SECRET_KEY ?? "";
|
|
110
|
+
|
|
111
|
+
if (mode !== undefined && mode !== "test" && mode !== "live") {
|
|
112
|
+
throw new StripeConfigError('STRIPE_MODE must be "test" or "live".');
|
|
113
|
+
}
|
|
114
|
+
if (mode === "live") {
|
|
115
|
+
if (!key.startsWith(LIVE_RESTRICTED_KEY_PREFIX)) {
|
|
116
|
+
throw new StripeConfigError(
|
|
117
|
+
"STRIPE_MODE=live needs a restricted live Stripe key. " +
|
|
118
|
+
"Unrestricted, publishable and test-mode keys are refused.",
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
if (!isProductionBuild()) {
|
|
122
|
+
throw new StripeConfigError(
|
|
123
|
+
"A restricted live Stripe key is admitted only with STRIPE_MODE=live on a production build " +
|
|
124
|
+
"(VERCEL_ENV=production, or NODE_ENV=production off Vercel).",
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
admitLiveRestrictedKey();
|
|
128
|
+
liveKeyAdmitted = true;
|
|
129
|
+
return "live";
|
|
130
|
+
}
|
|
131
|
+
assertSandboxKey(key);
|
|
132
|
+
return "test";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The request-time key check for the routes that may run live (#8450 D3):
|
|
137
|
+
* the member portal session and the checkout start and read. It returns
|
|
138
|
+
* before the sandbox-only guard for the restricted live class alone, and
|
|
139
|
+
* only once `admitApiStripeKey()` admitted it; every other key meets
|
|
140
|
+
* `assertSandboxKey`'s allow-list, so an unrestricted or publishable live
|
|
141
|
+
* key is refused whether or not the process admitted.
|
|
142
|
+
*/
|
|
143
|
+
export function assertAdmittedStripeKey(key: string): void {
|
|
144
|
+
if (liveKeyAdmitted && key.startsWith(LIVE_RESTRICTED_KEY_PREFIX)) return;
|
|
145
|
+
assertSandboxKey(key);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The key class this process admitted at boot: `"live"` only after `admitApiStripeKey()` did. */
|
|
149
|
+
export function stripeKeyMode(): "test" | "live" {
|
|
150
|
+
return liveKeyAdmitted ? "live" : "test";
|
|
151
|
+
}
|
|
152
|
+
|
|
63
153
|
/**
|
|
64
154
|
* Expire a Checkout Session (#4912 FR7/D7).
|
|
65
155
|
*
|
|
@@ -67,8 +157,9 @@ export type {
|
|
|
67
157
|
* session already exists, its id never reached the `FlowCheckout` row, and
|
|
68
158
|
* nothing will ever join the two — so the session is closed rather than left
|
|
69
159
|
* live and payable (#4863). Built on the already-re-exported
|
|
70
|
-
* `getStripeClient`, whose construction asserts
|
|
71
|
-
*
|
|
160
|
+
* `getStripeClient`, whose construction asserts its key allow-list, so this
|
|
161
|
+
* call inherits the same gate as every other Stripe call here (test-mode, or
|
|
162
|
+
* the restricted live key once `admitApiStripeKey()` admitted it, #8450).
|
|
72
163
|
*/
|
|
73
164
|
export async function expireCheckoutSession(stripeSessionId: string): Promise<void> {
|
|
74
165
|
await getStripeClient().checkout.sessions.expire(stripeSessionId);
|
|
@@ -90,8 +181,9 @@ const PLAN_NAME_MAX = 200;
|
|
|
90
181
|
* first item's product name (else the price nickname); and its default
|
|
91
182
|
* payment method (else the customer's invoice default). At most three reads,
|
|
92
183
|
* no expand path deeper than three levels (SEC-8171-3). Built on
|
|
93
|
-
* `getStripeClient`, so it inherits
|
|
94
|
-
*
|
|
184
|
+
* `getStripeClient`, so it inherits its allow-list: a missing key, or a live
|
|
185
|
+
* key this process did not admit (#8450), throws `StripeConfigError`. It logs
|
|
186
|
+
* nothing and returns no id.
|
|
95
187
|
*/
|
|
96
188
|
export async function readCustomerBillingSummary(
|
|
97
189
|
customerId: string,
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* docs/specs/7894-the-hub-signs-in-its-spec.md D6; AC-4, AC-5, AC-7).
|
|
6
6
|
*
|
|
7
7
|
* A bearer starting `ailk_hst_` is admitted only beside the SAME spoke's key,
|
|
8
|
-
* only while its grant is live and its person verified, and only on the
|
|
8
|
+
* only while its grant is live and its person verified, and only on the 20
|
|
9
9
|
* routes of `HUB_SESSION_ROUTES` (#8220 D1, AC-1). Any other route answers 404
|
|
10
10
|
* with no handler run (the status `lib/account-config.ts`'s `read()` already
|
|
11
11
|
* loads as "nothing for this caller"), never 403. Each (person, spoke) is
|
|
@@ -53,7 +53,7 @@ let spokeA: SeededSpoke;
|
|
|
53
53
|
let spokeB: SeededSpoke;
|
|
54
54
|
let handlerRuns: string[];
|
|
55
55
|
|
|
56
|
-
/** D1's 18 strings, in the list's order. */
|
|
56
|
+
/** D1's 18 strings plus #8435's two contact routes and #8438's two consents routes (22), in the list's order. */
|
|
57
57
|
const ADMITTED = [
|
|
58
58
|
"GET /v1/hub-sign-in/me",
|
|
59
59
|
"GET /v1/member/pieces",
|
|
@@ -72,11 +72,17 @@ const ADMITTED = [
|
|
|
72
72
|
"POST /v1/member/requests",
|
|
73
73
|
"POST /v1/member/messages",
|
|
74
74
|
"PATCH /v1/member/profile",
|
|
75
|
+
"GET /v1/member/profile/contact",
|
|
76
|
+
"PATCH /v1/member/profile/contact",
|
|
75
77
|
"POST /v1/member/deletion-request",
|
|
78
|
+
"GET /v1/member/consents",
|
|
79
|
+
"POST /v1/member/consents",
|
|
76
80
|
];
|
|
77
81
|
|
|
78
82
|
/** Every route the credential must NOT reach, each a recording stub. */
|
|
79
83
|
const OFF_LIST = [
|
|
84
|
+
"GET /v1/consent/platform",
|
|
85
|
+
"POST /v1/consent/platform",
|
|
80
86
|
"GET /v1/member/other",
|
|
81
87
|
"GET /v1/member/messages/:id",
|
|
82
88
|
"GET /v1/waitlist-signups/me",
|
|
@@ -168,9 +174,9 @@ afterEach(async () => {
|
|
|
168
174
|
});
|
|
169
175
|
|
|
170
176
|
describe("HUB_SESSION_ROUTES", () => {
|
|
171
|
-
it("is exactly the
|
|
177
|
+
it("is exactly the 20 entries, by method and registered URL — never a prefix (AC-1)", () => {
|
|
172
178
|
expect([...HUB_SESSION_ROUTES]).toEqual(ADMITTED);
|
|
173
|
-
expect(HUB_SESSION_ROUTES.length).toBe(
|
|
179
|
+
expect(HUB_SESSION_ROUTES.length).toBe(22);
|
|
174
180
|
for (const route of HUB_SESSION_ROUTES) {
|
|
175
181
|
expect({
|
|
176
182
|
route,
|
|
@@ -836,10 +836,61 @@ describe("per-visitor forwarding, scope namespace and per-scope env limit (#7394
|
|
|
836
836
|
{ method: "POST", url: "/v1/leads/callback" },
|
|
837
837
|
{ method: "GET", url: AVAILABILITY },
|
|
838
838
|
{ method: "POST", url: BOOK },
|
|
839
|
+
{ method: "POST", url: "/v1/consent/cookie" },
|
|
840
|
+
{ method: "POST", url: "/v1/consent/unsubscribe" },
|
|
839
841
|
]);
|
|
840
842
|
});
|
|
841
843
|
});
|
|
842
844
|
|
|
845
|
+
// #8438 D5/D9 — the cookie banner's log and the unsubscribe landing. Each
|
|
846
|
+
// is registered in its OWN scope (`consent`, `unsubscribe`), exactly as
|
|
847
|
+
// server.ts does, and both are on the forwarded-visitor list.
|
|
848
|
+
describe("the consent routes count each keyed visitor separately, in scopes of their own (#8438)", () => {
|
|
849
|
+
const COOKIE = "/v1/consent/cookie";
|
|
850
|
+
const UNSUBSCRIBE = "/v1/consent/unsubscribe";
|
|
851
|
+
|
|
852
|
+
function consentApp(): FastifyInstance {
|
|
853
|
+
const instance = Fastify({ trustProxy: true });
|
|
854
|
+
for (const [scope, url] of [
|
|
855
|
+
["consent", COOKIE],
|
|
856
|
+
["unsubscribe", UNSUBSCRIBE],
|
|
857
|
+
] as const) {
|
|
858
|
+
instance.register(async (scoped) => {
|
|
859
|
+
scoped.register(rateLimitMiddleware, {
|
|
860
|
+
scope,
|
|
861
|
+
perIpPerMinute: 10,
|
|
862
|
+
trustedKeyDigests: digests(),
|
|
863
|
+
});
|
|
864
|
+
scoped.post(url, async () => ({ ok: true }));
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
return instance;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
async function post(url: string, headers: Record<string, string>): Promise<number> {
|
|
871
|
+
if (!app) throw new Error("no app");
|
|
872
|
+
return (await app.inject({ method: "POST", url, headers })).statusCode;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
it.each([COOKIE, UNSUBSCRIBE])("%s: two visitors behind one site key are counted separately", async (url) => {
|
|
876
|
+
app = consentApp();
|
|
877
|
+
await app.ready();
|
|
878
|
+
for (let i = 0; i < 10; i++) {
|
|
879
|
+
expect(await post(url, keyed("203.0.113.1"))).toBe(200);
|
|
880
|
+
}
|
|
881
|
+
expect(await post(url, keyed("203.0.113.1"))).toBe(429);
|
|
882
|
+
expect(await post(url, keyed("203.0.113.2"))).toBe(200);
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
it("an exhausted consent bucket leaves unsubscribe answering", async () => {
|
|
886
|
+
app = consentApp();
|
|
887
|
+
await app.ready();
|
|
888
|
+
for (let i = 0; i < 10; i++) await post(COOKIE, keyed("203.0.113.1"));
|
|
889
|
+
expect(await post(COOKIE, keyed("203.0.113.1"))).toBe(429);
|
|
890
|
+
expect(await post(UNSUBSCRIBE, keyed("203.0.113.1"))).toBe(200);
|
|
891
|
+
});
|
|
892
|
+
});
|
|
893
|
+
|
|
843
894
|
describe("AC-5 — visitor IP validation and normalisation", () => {
|
|
844
895
|
function identityApp(): FastifyInstance {
|
|
845
896
|
const instance = Fastify({ trustProxy: true });
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
* spoke's key (`x-ailk-spoke-key`), only while its grant is unrevoked and
|
|
35
35
|
* unexpired, its client enabled and its person's email verified, and only
|
|
36
36
|
* on `HUB_SESSION_ROUTES` — an exact list of METHOD + registered URL
|
|
37
|
-
* pairs — the credential's own `me` and the member routes,
|
|
37
|
+
* pairs — the credential's own `me` and the member routes, 20 in all
|
|
38
38
|
* (#8220); any other route is 404 (never 403, so the account area's
|
|
39
39
|
* `read()` loads "nothing for this caller"). It acts as its person
|
|
40
40
|
* (`request.userId`) and carries `request.hubSession` — never
|
|
@@ -153,9 +153,11 @@ export type HubSession = {
|
|
|
153
153
|
* The only routes a hub-issued credential reaches (#7894 D6, #8220 D1): an
|
|
154
154
|
* EXACT list of method + registered URL, never a prefix and never computed,
|
|
155
155
|
* so a route added later joins only by a reviewed edit of this list
|
|
156
|
-
* (SEC-7894-3).
|
|
156
|
+
* (SEC-7894-3). Four groups, in this order: the credential's own `me` and
|
|
157
157
|
* #7877's three member reads; #8169's eight member reads; #8170's six member
|
|
158
|
-
* writes (roadmap §7.2 row order)
|
|
158
|
+
* writes (roadmap §7.2 row order); #8438's member consents read and write.
|
|
159
|
+
* `GET /v1/consent/platform` is not admitted — it is the Admin owner's own
|
|
160
|
+
* consent to Working Theory, on the hub's cookie session. The member profile write IS admitted — it
|
|
159
161
|
* is the person's own account, which roadmap D9 saves through it. Not
|
|
160
162
|
* admitted, deliberately: `GET /v1/waitlist-signups/me`,
|
|
161
163
|
* `GET /v1/flow-checkouts/me`, `GET /v1/subscription/me` and
|
|
@@ -181,7 +183,13 @@ export const HUB_SESSION_ROUTES: readonly string[] = Object.freeze([
|
|
|
181
183
|
"POST /v1/member/requests",
|
|
182
184
|
"POST /v1/member/messages",
|
|
183
185
|
"PATCH /v1/member/profile",
|
|
186
|
+
"GET /v1/member/profile/contact",
|
|
187
|
+
"PATCH /v1/member/profile/contact",
|
|
184
188
|
"POST /v1/member/deletion-request",
|
|
189
|
+
// #8438 D6 — the member's own consents. `GET /v1/consent/platform` is NOT
|
|
190
|
+
// admitted: those are the Admin owner's, on the hub's own cookie session.
|
|
191
|
+
"GET /v1/member/consents",
|
|
192
|
+
"POST /v1/member/consents",
|
|
185
193
|
]);
|
|
186
194
|
|
|
187
195
|
/**
|
|
@@ -170,6 +170,11 @@ export const FORWARDED_VISITOR_ROUTES: readonly Readonly<{
|
|
|
170
170
|
{ method: "POST", url: "/v1/leads/callback" },
|
|
171
171
|
{ method: "GET", url: "/v1/schedule/availability" },
|
|
172
172
|
{ method: "POST", url: "/v1/schedule/book" },
|
|
173
|
+
// #8438 D5/D9 — the cookie banner's log and the unsubscribe landing: a
|
|
174
|
+
// site's server forwards each visitor's IP, so one spoke's visitors are
|
|
175
|
+
// counted one by one rather than in one bucket keyed on its egress IP.
|
|
176
|
+
{ method: "POST", url: "/v1/consent/cookie" },
|
|
177
|
+
{ method: "POST", url: "/v1/consent/unsubscribe" },
|
|
173
178
|
];
|
|
174
179
|
|
|
175
180
|
const VISITOR_IP_HEADER = "x-ailk-visitor-ip";
|