create-ailk 0.19.0 → 0.20.0

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.
Files changed (197) hide show
  1. package/README.md +64 -7
  2. package/dist/add-module.js +7 -2
  3. package/dist/cli.js +35 -0
  4. package/dist/copy.d.ts +41 -0
  5. package/dist/copy.js +87 -21
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/lib/fetch-template-snapshot.d.ts +21 -0
  9. package/dist/lib/fetch-template-snapshot.js +205 -0
  10. package/dist/lib/merge-three-way.d.ts +31 -0
  11. package/dist/lib/merge-three-way.js +65 -0
  12. package/dist/lib/scaffold-render-sweep.d.ts +23 -0
  13. package/dist/lib/scaffold-render-sweep.js +52 -0
  14. package/dist/module-architecture.js +64 -5
  15. package/dist/programmatic.d.ts +7 -1
  16. package/dist/programmatic.js +40 -3
  17. package/dist/site-roster.js +21 -4
  18. package/dist/spoke-checkout.d.ts +7 -0
  19. package/dist/spoke-checkout.js +8 -1
  20. package/dist/surfaces.d.ts +14 -0
  21. package/dist/surfaces.js +98 -3
  22. package/dist/template-record.d.ts +59 -0
  23. package/dist/template-record.js +183 -0
  24. package/dist/topology.js +29 -1
  25. package/dist/update.d.ts +48 -0
  26. package/dist/update.js +504 -0
  27. package/package.json +3 -3
  28. package/templates/.claude/cloudparity-repo-hooks.json +9 -0
  29. package/templates/.claude/hooks/architecture-check.sh +8 -0
  30. package/templates/.claude/rules/loop-contract.md +129 -0
  31. package/templates/.claude/settings.json +3 -2
  32. package/templates/.env.example +13 -0
  33. package/templates/CLAUDE.md +1 -1
  34. package/templates/CONVENTIONS.md +3 -2
  35. package/templates/_gitignore +162 -0
  36. package/templates/apps/api/.env.example +17 -1
  37. package/templates/apps/api/CLAUDE.md +18 -8
  38. package/templates/apps/api/package.json +2 -1
  39. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +41 -2
  40. package/templates/apps/api/src/__tests__/setup.ts +7 -0
  41. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
  42. package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
  43. package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
  44. package/templates/apps/api/src/lib/__mocks__/prisma.ts +8 -0
  45. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
  46. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
  47. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
  48. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
  49. package/templates/apps/api/src/lib/consent-store.ts +335 -0
  50. package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
  51. package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
  52. package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
  53. package/templates/apps/api/src/lib/stripe.ts +97 -5
  54. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +13 -4
  55. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
  56. package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
  57. package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
  58. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +72 -14
  59. package/templates/apps/api/src/middleware/auth.ts +16 -4
  60. package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
  61. package/templates/apps/api/src/middleware/rate-limit.ts +295 -82
  62. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
  63. package/templates/apps/api/src/routes/checkout/session.ts +3 -3
  64. package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
  65. package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
  66. package/templates/apps/api/src/routes/consent/index.ts +18 -0
  67. package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
  68. package/templates/apps/api/src/routes/leads/index.ts +15 -0
  69. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
  70. package/templates/apps/api/src/routes/webhooks/README.md +33 -5
  71. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
  72. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
  73. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
  74. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
  75. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
  76. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
  77. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
  78. package/templates/apps/api/src/routes/webhooks/stripe.ts +201 -6
  79. package/templates/apps/api/src/server.ts +125 -18
  80. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
  81. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
  82. package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
  83. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +115 -4
  84. package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
  85. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
  86. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
  87. package/templates/apps/api/src/services/audit-log.ts +72 -10
  88. package/templates/apps/api/src/services/consent.ts +488 -3
  89. package/templates/apps/api/src/services/default-tenant.ts +12 -3
  90. package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
  91. package/templates/apps/api/src/services/lead-routing.ts +105 -12
  92. package/templates/apps/api/src/services/person.ts +501 -1
  93. package/templates/apps/api/src/services/project-listings.ts +7 -2
  94. package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
  95. package/templates/apps/api/src/services/request-confirm.ts +52 -0
  96. package/templates/apps/api/src/services/revalidation.ts +11 -1
  97. package/templates/apps/api/vercel.json +1 -1
  98. package/templates/apps/mcp/CLAUDE.md +19 -7
  99. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
  100. package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
  101. package/templates/apps/mcp/src/tools/proposals.ts +27 -0
  102. package/templates/apps/mcp/src/workspace-server.ts +16 -0
  103. package/templates/apps/web/_gitignore +6 -0
  104. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
  105. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
  106. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
  107. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +17 -3
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +154 -53
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +116 -27
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
  114. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
  115. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +124 -18
  116. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +116 -18
  117. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
  118. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
  119. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +290 -20
  120. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +56 -0
  121. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
  122. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
  123. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
  124. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
  125. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
  126. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
  127. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
  129. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
  132. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
  133. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +15 -9
  134. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +3 -3
  135. package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
  136. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +70 -27
  137. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
  138. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +65 -19
  139. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
  140. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
  141. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
  142. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
  143. package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
  144. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
  145. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +89 -37
  146. package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
  147. package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
  148. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +44 -34
  149. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
  150. package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
  151. package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
  152. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
  153. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
  154. package/templates/apps/web/app/[locale]/layout.tsx +23 -2
  155. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
  156. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
  157. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
  158. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
  159. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
  160. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
  161. package/templates/apps/web/app/robots.txt/route.ts +13 -10
  162. package/templates/apps/web/auth.ts +20 -1
  163. package/templates/apps/web/jest.config.cjs +3 -0
  164. package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
  165. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
  166. package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
  167. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
  168. package/templates/apps/web/lib/account-config.ts +31 -16
  169. package/templates/apps/web/lib/auth-email.ts +45 -10
  170. package/templates/apps/web/lib/consent-visitor.ts +103 -0
  171. package/templates/apps/web/lib/relay.ts +20 -0
  172. package/templates/apps/web/lib/topology.generated.ts +9 -0
  173. package/templates/apps/web/messages/en.json +121 -18
  174. package/templates/apps/web/next.config.mjs +8 -2
  175. package/templates/database/CHANGELOG.md +55 -0
  176. package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
  177. package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
  178. package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
  179. package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
  180. package/templates/database/__tests__/person-contact.db.test.ts +563 -0
  181. package/templates/database/__tests__/person-notes.db.test.ts +249 -0
  182. package/templates/database/__tests__/person.db.test.ts +3 -1
  183. package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
  184. package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
  185. package/templates/database/_gitignore +8 -0
  186. package/templates/database/inbox/schema.prisma +114 -22
  187. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
  188. package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
  189. package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
  190. package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
  191. package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
  192. package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
  193. package/templates/database/ops/schema.prisma +16 -0
  194. package/templates/database/package.json +1 -1
  195. package/templates/project.yaml +6 -3
  196. package/versions.json +35 -35
  197. package/templates/.claude/rules/preserve-existing-style.md +0 -51
@@ -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
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The proposal paid stamp's data boundary (#8516, spec
3
+ * docs/specs/8516-proposals-as-an-artifact-kind-spec.md D13).
4
+ *
5
+ * The Stripe webhook (`routes/webhooks/stripe.ts`) is a commerce primitive
6
+ * registered on every build, so it may import no slice-owned file —
7
+ * `lib/member-store.ts` is one, and importing it would pull the webhook out of
8
+ * every scaffold without the member slice (the #5677 failure). Its one piece
9
+ * write therefore lives here, in the base tree, importing only `prisma.ts`.
10
+ *
11
+ * Two statements and no others, both scoped by a workspace id AND a piece id
12
+ * taken from a Stripe-signed `checkout.session.completed` event's metadata —
13
+ * written by this server when it opened the session (`ailk_proposal`,
14
+ * `ailk_workspace`; `services/proposal-payments.ts`), never settable by the
15
+ * payer: a guarded `updateMany` that stamps an unpaid proposal paid, and a
16
+ * `findFirst` by the same pair reading back which session paid it. No list,
17
+ * no other model, no read by any other key; never called by a route.
18
+ * `architecture.yaml` names this file as a tenant-scope exclusion with that
19
+ * reason.
20
+ */
21
+ import { prisma } from "./prisma.js";
22
+
23
+ /**
24
+ * - `stamped` — this session paid an unpaid proposal; stamped now.
25
+ * - `replay` — the proposal was already stamped by THIS session.
26
+ * - `already_paid` — a DIFFERENT session already paid it (a second charge).
27
+ * - `not_found` — no proposal of that id in that workspace.
28
+ */
29
+ export type ProposalPaidOutcome = "stamped" | "replay" | "already_paid" | "not_found";
30
+
31
+ /** Stamp a proposal paid by `sessionId`, at most once. */
32
+ export async function stampProposalPaid(
33
+ workspaceId: string,
34
+ pieceId: string,
35
+ sessionId: string,
36
+ now: Date,
37
+ ): Promise<ProposalPaidOutcome> {
38
+ const stamped = await prisma.piece.updateMany({
39
+ where: { id: pieceId, workspaceId, kind: "proposal", paidAt: null },
40
+ data: { paidAt: now, paidSessionId: sessionId },
41
+ });
42
+ if (stamped.count > 0) return "stamped";
43
+ const row = await prisma.piece.findFirst({
44
+ where: { id: pieceId, workspaceId, kind: "proposal" },
45
+ select: { paidSessionId: true },
46
+ });
47
+ if (row === null) return "not_found";
48
+ return row.paidSessionId === sessionId ? "replay" : "already_paid";
49
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * The live rate-limiter probe's logic (#5040 D9, FR-7). Pure: takes its
3
+ * `fetch` and `sleep` so the CLI bin (`bin/rate-limit-probe.ts`) and the test
4
+ * suite can both drive it without a real network or a real 60-second wait.
5
+ * See the bin's header for what it answers and why.
6
+ */
7
+
8
+ const TEST_NET_3_PREFIX = "203.0.113.";
9
+
10
+ export type ProbeFetch = (
11
+ url: string,
12
+ init: { headers: Record<string, string> },
13
+ ) => Promise<{ status: number }>;
14
+
15
+ export interface RunRateLimitProbeOptions {
16
+ origin: string;
17
+ fetch: ProbeFetch;
18
+ limit?: number;
19
+ windowMs?: number;
20
+ sleep?: (ms: number) => Promise<void>;
21
+ log?: (msg: string) => void;
22
+ }
23
+
24
+ function isAllowedOrigin(origin: string): boolean {
25
+ let url: URL;
26
+ try {
27
+ url = new URL(origin);
28
+ } catch {
29
+ return false;
30
+ }
31
+ if (url.protocol === "https:") return true;
32
+ if (url.protocol !== "http:") return false;
33
+ const host = url.hostname;
34
+ return host === "localhost" || host === "127.0.0.1" || host === "::1";
35
+ }
36
+
37
+ function randomTestNet3(exclude: ReadonlySet<string>): string {
38
+ let candidate: string;
39
+ do {
40
+ candidate = `${TEST_NET_3_PREFIX}${1 + Math.floor(Math.random() * 254)}`;
41
+ } while (exclude.has(candidate));
42
+ return candidate;
43
+ }
44
+
45
+ function describeError(err: unknown): string {
46
+ if (
47
+ err !== null &&
48
+ typeof err === "object" &&
49
+ "code" in err &&
50
+ typeof (err as { code?: unknown }).code === "string"
51
+ ) {
52
+ return (err as { code: string }).code;
53
+ }
54
+ return err instanceof Error ? err.message : String(err);
55
+ }
56
+
57
+ const PROBE_PATH = "/v1/aeo/score/example";
58
+
59
+ /**
60
+ * Runs the two-fact probe (#5040 D9) against `origin`. Returns the process
61
+ * exit code the bin should use: `0` on `burst: holds`, `1` on every other
62
+ * conclusive-but-not-holding outcome (`limiter: not-observed`,
63
+ * `burst: exceeded`, `burst: inconclusive`), `2` on a refused origin or a
64
+ * network/status error.
65
+ */
66
+ export async function runRateLimitProbe(
67
+ opts: RunRateLimitProbeOptions,
68
+ ): Promise<number> {
69
+ const {
70
+ origin,
71
+ fetch,
72
+ limit = 10,
73
+ windowMs = 60_000,
74
+ sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
75
+ log = (msg: string) => console.log(msg),
76
+ } = opts;
77
+
78
+ if (!isAllowedOrigin(origin)) {
79
+ log("probe: error invalid-origin");
80
+ return 2;
81
+ }
82
+
83
+ const url = `${origin}${PROBE_PATH}`;
84
+ const used = new Set<string>();
85
+ const identityA = randomTestNet3(used);
86
+ used.add(identityA);
87
+
88
+ async function send(identity: string): Promise<number> {
89
+ const res = await fetch(url, { headers: { "x-forwarded-for": identity } });
90
+ return res.status;
91
+ }
92
+
93
+ let lastStatus: number | undefined;
94
+ try {
95
+ for (let i = 0; i < limit + 1; i++) {
96
+ const status = await send(identityA);
97
+ if (status !== 200 && status !== 429) {
98
+ log(`probe: error ${status}`);
99
+ return 2;
100
+ }
101
+ lastStatus = status;
102
+ }
103
+ } catch (err) {
104
+ log(`probe: error ${describeError(err)}`);
105
+ return 2;
106
+ }
107
+
108
+ if (lastStatus !== 429) {
109
+ log("limiter: not-observed");
110
+ return 1;
111
+ }
112
+
113
+ const identityB = randomTestNet3(used);
114
+ used.add(identityB);
115
+ let survives: boolean;
116
+ try {
117
+ const status = await send(identityB);
118
+ if (status !== 200 && status !== 429) {
119
+ log(`probe: error ${status}`);
120
+ return 2;
121
+ }
122
+ survives = status === 200;
123
+ } catch (err) {
124
+ log(`probe: error ${describeError(err)}`);
125
+ return 2;
126
+ }
127
+ log(survives ? "xff: survives" : "xff: overwritten");
128
+
129
+ let burstIdentity: string;
130
+ if (survives) {
131
+ burstIdentity = randomTestNet3(used);
132
+ } else {
133
+ await sleep(windowMs + 2_000);
134
+ burstIdentity = identityA;
135
+ }
136
+
137
+ const total = 3 * limit;
138
+ let successes = 0;
139
+ try {
140
+ const results = await Promise.all(
141
+ Array.from({ length: total }, () => send(burstIdentity)),
142
+ );
143
+ for (const status of results) {
144
+ if (status !== 200 && status !== 429) {
145
+ log(`probe: error ${status}`);
146
+ return 2;
147
+ }
148
+ if (status === 200) successes += 1;
149
+ }
150
+ } catch (err) {
151
+ log(`probe: error ${describeError(err)}`);
152
+ return 2;
153
+ }
154
+
155
+ if (successes === 0) {
156
+ log(`burst: inconclusive (0/${total})`);
157
+ return 1;
158
+ }
159
+ if (successes > limit) {
160
+ log(`burst: exceeded (${successes}/${total})`);
161
+ return 1;
162
+ }
163
+ log(`burst: holds (${successes}/${total})`);
164
+ return 0;
165
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * The rate limiter's database store (#5040 D1/D3/D4). Counts a bucket in
3
+ * `rate_limit_counters` so one visitor's limit holds across every concurrent
4
+ * isolate on a deployment with a database — see
5
+ * `apps/api/src/middleware/rate-limit.ts`'s `SHARED STORE` header for the
6
+ * full picture (what this protects against, and what it does not).
7
+ *
8
+ * The only file naming `rate_limit_counters` outside `database/`. All SQL is
9
+ * `db.$queryRaw` / `db.$executeRaw` tagged templates — never `…Unsafe` — so
10
+ * every value is a bound parameter, never string-interpolated.
11
+ */
12
+ import { createHash } from "node:crypto";
13
+
14
+ import type { Prisma } from "@working-theory/database";
15
+
16
+ /** One bucket's state, as both the memory and database stores report it. */
17
+ export type RateLimitBucket = {
18
+ count: number;
19
+ windowStartMs: number;
20
+ };
21
+
22
+ /** What a store call reports when it ran against a backing database. */
23
+ export type RateLimitStore = {
24
+ /** Count one request against `key`'s visitor bucket. */
25
+ hit(key: string, now: number, windowMs: number): Promise<RateLimitBucket>;
26
+ /** Count one request against `key`'s forwarding-key bucket (#7394 DD-8). */
27
+ hitKey(key: string, now: number, windowMs: number): Promise<RateLimitBucket>;
28
+ /** True when `key` has a bucket whose window has not yet ended. */
29
+ hasOpenWindow(key: string, now: number, windowMs: number): Promise<boolean>;
30
+ /** Evict expired buckets. `reason` is why the sweep fired (D6). */
31
+ sweep(now: number, windowMs: number, reason: "interval" | "limited"): Promise<void>;
32
+ };
33
+
34
+ /** What `createDatabaseRateLimitStore` needs of a Prisma client (D3). */
35
+ export type RateLimitSqlClient = Pick<
36
+ Prisma.TransactionClient,
37
+ "$queryRaw" | "$executeRaw"
38
+ >;
39
+
40
+ function sha256Hex(value: string): string {
41
+ return createHash("sha256").update(value).digest("hex");
42
+ }
43
+
44
+ /**
45
+ * The database store (D1/D3): one atomic upsert per hit, same window rules as
46
+ * the memory store. `bucketKey` is the exact string the memory store uses as
47
+ * its Map key; this store hashes it before it ever reaches SQL (FR-5) — the
48
+ * table never holds a plaintext IP or bucket key.
49
+ */
50
+ export function createDatabaseRateLimitStore(
51
+ db: RateLimitSqlClient,
52
+ ): RateLimitStore {
53
+ async function countHit(
54
+ bucketKey: string,
55
+ now: number,
56
+ windowMs: number,
57
+ ): Promise<RateLimitBucket> {
58
+ const hash = sha256Hex(bucketKey);
59
+ const rows = await db.$queryRaw<
60
+ Array<{ count: number; window_start_ms: bigint | string }>
61
+ >`
62
+ INSERT INTO "rate_limit_counters" ("key_hash", "window_start_ms", "window_ms", "count")
63
+ VALUES (${hash}, ${now}::bigint, ${windowMs}::integer, 1)
64
+ ON CONFLICT ("key_hash") DO UPDATE SET
65
+ "count" = CASE WHEN ${now}::bigint - "rate_limit_counters"."window_start_ms" >= ${windowMs}::integer
66
+ THEN 1 ELSE "rate_limit_counters"."count" + 1 END,
67
+ "window_start_ms" = CASE WHEN ${now}::bigint - "rate_limit_counters"."window_start_ms" >= ${windowMs}::integer
68
+ THEN ${now}::bigint ELSE "rate_limit_counters"."window_start_ms" END,
69
+ "window_ms" = ${windowMs}::integer
70
+ RETURNING "count", "window_start_ms"
71
+ `;
72
+ const row = rows[0];
73
+ if (!row) {
74
+ throw new Error("rate_limit_counters upsert returned no row");
75
+ }
76
+ return {
77
+ count: Number(row.count),
78
+ windowStartMs: Number(row.window_start_ms),
79
+ };
80
+ }
81
+
82
+ async function hasOpenWindow(
83
+ bucketKey: string,
84
+ now: number,
85
+ windowMs: number,
86
+ ): Promise<boolean> {
87
+ const hash = sha256Hex(bucketKey);
88
+ const rows = await db.$queryRaw<Array<{ one: number }>>`
89
+ SELECT 1 AS "one" FROM "rate_limit_counters"
90
+ WHERE "key_hash" = ${hash}
91
+ AND ${now}::bigint - "window_start_ms" < ${windowMs}::integer
92
+ LIMIT 1
93
+ `;
94
+ return rows.length > 0;
95
+ }
96
+
97
+ return {
98
+ hit: (key, now, windowMs) => countHit(key, now, windowMs),
99
+ hitKey: (key, now, windowMs) => countHit(key, now, windowMs),
100
+ hasOpenWindow,
101
+ async sweep(now, _windowMs, reason) {
102
+ // D6 — the database store sweeps on the interval trigger only. Sweeping
103
+ // on every 429 would let an attacker buy a table scan per refused
104
+ // request.
105
+ if (reason !== "interval") return;
106
+ await db.$executeRaw`
107
+ DELETE FROM "rate_limit_counters"
108
+ WHERE "window_start_ms" + "window_ms" <= ${now}::bigint
109
+ `;
110
+ },
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Which store a registration with no explicit `store` option uses (D4),
116
+ * resolved once per process and memoized by the middleware. `warn` is called
117
+ * exactly once for each of the two misconfiguration rows below.
118
+ */
119
+ export type RateLimitStoreEnv = {
120
+ DATABASE_URL?: string | undefined;
121
+ AILK_RATE_LIMIT_STORE?: string | undefined;
122
+ };
123
+
124
+ export function resolveRateLimitStoreKind(
125
+ env: RateLimitStoreEnv,
126
+ warn: (msg: string) => void,
127
+ ): "memory" | "database" {
128
+ const hasDatabase = !!env.DATABASE_URL;
129
+ const raw = env.AILK_RATE_LIMIT_STORE;
130
+
131
+ if (raw === undefined || raw === "") {
132
+ return hasDatabase ? "database" : "memory";
133
+ }
134
+ if (raw === "memory") return "memory";
135
+ if (raw === "database") {
136
+ if (hasDatabase) return "database";
137
+ warn("AILK_RATE_LIMIT_STORE=database needs DATABASE_URL; using memory");
138
+ return "memory";
139
+ }
140
+ warn("AILK_RATE_LIMIT_STORE is not memory or database and is ignored");
141
+ return hasDatabase ? "database" : "memory";
142
+ }