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.
Files changed (91) hide show
  1. package/dist/add-module.js +7 -2
  2. package/dist/index.d.ts +1 -1
  3. package/dist/index.js +1 -1
  4. package/dist/lib/scaffold-render-sweep.d.ts +23 -0
  5. package/dist/lib/scaffold-render-sweep.js +52 -0
  6. package/dist/module-architecture.js +9 -3
  7. package/dist/programmatic.js +8 -1
  8. package/dist/site-roster.js +21 -4
  9. package/dist/surfaces.d.ts +14 -0
  10. package/dist/surfaces.js +31 -1
  11. package/dist/topology.js +29 -1
  12. package/package.json +3 -3
  13. package/templates/apps/api/.env.example +17 -1
  14. package/templates/apps/api/CLAUDE.md +10 -4
  15. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +19 -0
  16. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
  17. package/templates/apps/api/src/lib/__mocks__/prisma.ts +8 -0
  18. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
  19. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
  20. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
  21. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
  22. package/templates/apps/api/src/lib/consent-store.ts +335 -0
  23. package/templates/apps/api/src/lib/stripe.ts +97 -5
  24. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +10 -4
  25. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +51 -0
  26. package/templates/apps/api/src/middleware/auth.ts +11 -3
  27. package/templates/apps/api/src/middleware/rate-limit.ts +5 -0
  28. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
  29. package/templates/apps/api/src/routes/checkout/session.ts +3 -3
  30. package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
  31. package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
  32. package/templates/apps/api/src/routes/consent/index.ts +18 -0
  33. package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
  34. package/templates/apps/api/src/routes/leads/index.ts +15 -0
  35. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
  36. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
  37. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
  38. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
  39. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
  40. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
  41. package/templates/apps/api/src/routes/webhooks/stripe.ts +17 -0
  42. package/templates/apps/api/src/server.ts +111 -16
  43. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
  44. package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
  45. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +115 -4
  46. package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
  47. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
  48. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
  49. package/templates/apps/api/src/services/consent.ts +488 -3
  50. package/templates/apps/api/src/services/lead-routing.ts +105 -12
  51. package/templates/apps/api/src/services/person.ts +501 -1
  52. package/templates/apps/api/src/services/project-listings.ts +7 -2
  53. package/templates/apps/api/src/services/request-confirm.ts +52 -0
  54. package/templates/apps/api/src/services/revalidation.ts +11 -1
  55. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
  56. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +17 -3
  57. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +156 -0
  58. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +89 -14
  59. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
  60. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +122 -18
  61. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +15 -1
  62. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +290 -20
  63. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +55 -0
  64. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
  65. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +4 -7
  66. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +3 -3
  67. package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
  68. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +66 -4
  69. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
  70. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +2 -2
  71. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +89 -37
  72. package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
  73. package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
  74. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +41 -33
  75. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
  76. package/templates/apps/web/auth.ts +20 -1
  77. package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
  78. package/templates/apps/web/lib/auth-email.ts +45 -10
  79. package/templates/apps/web/lib/topology.generated.ts +9 -0
  80. package/templates/apps/web/messages/en.json +54 -14
  81. package/templates/database/CHANGELOG.md +16 -0
  82. package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
  83. package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
  84. package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
  85. package/templates/database/__tests__/person-contact.db.test.ts +563 -0
  86. package/templates/database/__tests__/person.db.test.ts +3 -1
  87. package/templates/database/inbox/schema.prisma +76 -10
  88. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
  89. package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
  90. package/templates/database/package.json +1 -1
  91. package/versions.json +35 -35
@@ -4,15 +4,22 @@
4
4
  *
5
5
  * `memberSettingsFrame` supplies the member's half of the shared
6
6
  * `AccountSettingsFrame` (@working-theory/templates/account, spec 8339 D2):
7
- * `standalone: false` (the member area already renders inside AppShell's
8
- * own `<main>`), Back to the Inbox, and the four tabs in the reference's
9
- * order. Every member settings page calls it and renders the result through
10
- * `AccountSettingsFrame` (General, via the shared `AccountSettings`) or
11
- * directly (Security, Notifications, Billing).
7
+ * `standalone: true` — the Admin's shape, full height and flush, because the
8
+ * account layout renders these routes outside its AppShell (#8430) — Back to
9
+ * the Inbox, and the tabs of `SETTINGS_ROUTES`. Every member settings page
10
+ * calls it and renders the result through `AccountSettingsFrame` (General,
11
+ * via the shared `AccountSettings`) or directly (Security, Notifications,
12
+ * Billing).
12
13
  *
13
14
  * `SettingsSection` stays as it is: contributed sections
14
15
  * (`accountSettingsSections`, `accountBillingSections`) still use it, as do
15
16
  * General's and Billing's own trailing content.
17
+ *
18
+ * #8441 — `SETTINGS_ROUTES` is the ONE place the settings routes are named:
19
+ * the tabs here and `isSettingsPath` (which `account-frame.tsx` takes these
20
+ * routes out of its shell with) both read it, so a tab added here is a tab
21
+ * that renders outside the shell — never a second `<main>` nested in the
22
+ * shell's. Adding a tab is one line here (plus its page and message).
16
23
  */
17
24
 
18
25
  import type { AccountSettingsFrameProps, AccountSettingsTab } from "@working-theory/templates/account";
@@ -29,42 +36,43 @@ import type { ReactElement, ReactNode } from "react";
29
36
  * own `t` with `as unknown as Translate`. */
30
37
  export type Translate = (key: string, values?: Record<string, unknown>) => string;
31
38
 
39
+ /** User settings' tabs, in the reference's order (#8339 D7) — each one a
40
+ * route that renders outside the account shell (#8430, #8441). */
41
+ export const SETTINGS_ROUTES = [
42
+ { key: "general", path: "/account/general", titleKey: "settings.general.title", icon: UserRound },
43
+ { key: "security", path: "/account/security", titleKey: "settings.security.title", icon: ShieldCheck },
44
+ { key: "notifications", path: "/account/notifications", titleKey: "settings.notifications.title", icon: Bell },
45
+ { key: "billing", path: "/account/billing", titleKey: "settings.billing.title", icon: CreditCard },
46
+ ] as const;
47
+
48
+ export type SettingsTabKey = (typeof SETTINGS_ROUTES)[number]["key"];
49
+
50
+ /** Whether `pathname` is one of `SETTINGS_ROUTES`: the request locale's
51
+ * `/<locale>` prefix removed, then the rest compared whole — so a sub-route
52
+ * (`/account/billing/history`) is not a settings route unless it is listed. */
53
+ export function isSettingsPath(pathname: string, locale: string): boolean {
54
+ const prefix = `/${locale}`;
55
+ const bare =
56
+ pathname === prefix || pathname.startsWith(`${prefix}/`) ? pathname.slice(prefix.length) : pathname;
57
+ return SETTINGS_ROUTES.some(({ path }) => path === bare);
58
+ }
59
+
32
60
  /** The member's half of `AccountSettingsFrameProps` — every settings page's
33
61
  * `frame` prop, less `name` (the caller's own session) and `children`. */
34
62
  export function memberSettingsFrame(
35
63
  locale: string,
36
64
  t: Translate,
37
- active: "general" | "security" | "notifications" | "billing",
65
+ active: SettingsTabKey,
38
66
  ): Omit<AccountSettingsFrameProps, "name" | "children"> {
39
- const tabs: readonly AccountSettingsTab[] = [
40
- {
41
- key: "general",
42
- label: t("settings.general.title"),
43
- href: localeHref(locale, "/account/general"),
44
- icon: <Icon icon={UserRound} size="sm" decorative className="shrink-0" />,
45
- },
46
- {
47
- key: "security",
48
- label: t("settings.security.title"),
49
- href: localeHref(locale, "/account/security"),
50
- icon: <Icon icon={ShieldCheck} size="sm" decorative className="shrink-0" />,
51
- },
52
- {
53
- key: "notifications",
54
- label: t("settings.notifications.title"),
55
- href: localeHref(locale, "/account/notifications"),
56
- icon: <Icon icon={Bell} size="sm" decorative className="shrink-0" />,
57
- },
58
- {
59
- key: "billing",
60
- label: t("settings.billing.title"),
61
- href: localeHref(locale, "/account/billing"),
62
- icon: <Icon icon={CreditCard} size="sm" decorative className="shrink-0" />,
63
- },
64
- ];
67
+ const tabs: readonly AccountSettingsTab[] = SETTINGS_ROUTES.map(({ key, path, titleKey, icon }) => ({
68
+ key,
69
+ label: t(titleKey),
70
+ href: localeHref(locale, path),
71
+ icon: <Icon icon={icon} size="sm" decorative className="shrink-0" />,
72
+ }));
65
73
 
66
74
  return {
67
- standalone: false,
75
+ standalone: true,
68
76
  back: { label: t("settings.back"), href: localeHref(locale, "/account") },
69
77
  railLabel: t("settings.title"),
70
78
  accountLabel: t("settings.account"),
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * account/sign-in-method.ts (server code) — how a member signs in, for User settings ›
3
- * General's Sign-in section (#8232, spec
3
+ * Security's sign-in block (#8232, spec
4
4
  * docs/specs/8232-member-settings-account-general-and-spec.md D7).
5
5
  *
6
6
  * One of the account tree's TWO topology seams (roadmap D1; the other is
@@ -324,6 +324,24 @@ export function createAuth(deps: CreateAuthDeps = {}) {
324
324
  const sendResetPassword = makeSendResetPassword({
325
325
  prisma,
326
326
  send: authEmailTransport,
327
+ /**
328
+ * Who is signed in on the reset request (#8431 D4) — the Security tab's
329
+ * Set password, for a person with no password yet. Read through THIS
330
+ * instance (assigned below; the callback runs only during a request, by
331
+ * which time it exists), never a second one, and never by verifying the
332
+ * cookie by hand. `disableRefresh`: the mail callback runs in the
333
+ * background after the response, where a refresh's cookie write would
334
+ * throw and drop the mail. No request (a server-side call carries no
335
+ * cookie) or no `api` (a test double of `betterAuth`) is no session.
336
+ */
337
+ sessionUserId: async (request) => {
338
+ if (request === undefined || Reflect.get(instance, "api") === undefined) return null;
339
+ const session = await instance.api.getSession({
340
+ headers: request.headers,
341
+ query: { disableRefresh: true },
342
+ });
343
+ return session?.user.id ?? null;
344
+ },
327
345
  });
328
346
  const sendVerificationEmail = makeSendVerificationEmail({
329
347
  send: authEmailTransport,
@@ -419,7 +437,7 @@ export function createAuth(deps: CreateAuthDeps = {}) {
419
437
  }
420
438
  };
421
439
 
422
- return betterAuth({
440
+ const instance = betterAuth({
423
441
  database: prismaAdapter(prisma, {
424
442
  provider: "postgresql",
425
443
  }),
@@ -643,6 +661,7 @@ export function createAuth(deps: CreateAuthDeps = {}) {
643
661
  ]
644
662
  : [nextCookies()],
645
663
  });
664
+ return instance;
646
665
  }
647
666
 
648
667
  // Lazy singleton: resolved on first access, not at module-load time.
@@ -152,6 +152,90 @@ describe("sendResetPassword (§ D14 — the credential-account check)", () => {
152
152
  });
153
153
  });
154
154
 
155
+ describe("sendResetPassword — the signed-in carve-out (#8431 D4)", () => {
156
+ const USER = { id: "user_1", email: "person@example.com" };
157
+ const RESET_URL = "https://site.example/api/auth/reset-password/tok";
158
+ const REQUEST = { headers: new Headers({ cookie: "session=abc" }) } as Request;
159
+
160
+ it("sends ONE password-reset to the account's own address when the session is that person's", async () => {
161
+ const send = jest.fn().mockResolvedValue({ ok: true, body: { ok: true } });
162
+ const { lines, log } = makeLog();
163
+ const sessionUserId = jest.fn().mockResolvedValue("user_1");
164
+ const cb = makeSendResetPassword({ prisma: makePrisma(false), send, log, sessionUserId });
165
+
166
+ await cb({ user: USER, url: RESET_URL }, REQUEST);
167
+
168
+ expect(sessionUserId).toHaveBeenCalledWith(REQUEST);
169
+ expect(send).toHaveBeenCalledTimes(1);
170
+ const [rawBody] = send.mock.calls[0] as [string];
171
+ expect(JSON.parse(rawBody)).toEqual({
172
+ kind: "password-reset",
173
+ userId: "user_1",
174
+ to: "person@example.com",
175
+ url: RESET_URL,
176
+ expiresInMinutes: 60,
177
+ });
178
+ expect(lines.some((l) => l.fields.reason === "self_service_set")).toBe(true);
179
+ expect(JSON.stringify(lines)).not.toContain("person@example.com");
180
+ });
181
+
182
+ it.each<[string, () => Promise<string | null>]>([
183
+ ["another person's id", () => Promise.resolve("user_2")],
184
+ ["null (no session)", () => Promise.resolve(null)],
185
+ ["a rejection", () => Promise.reject(new Error("db down"))],
186
+ ])("sends NOTHING and logs no address when the session lookup answers %s", async (_label, impl) => {
187
+ const send = jest.fn();
188
+ const { lines, log } = makeLog();
189
+ const cb = makeSendResetPassword({ prisma: makePrisma(false), send, log, sessionUserId: jest.fn(impl) });
190
+
191
+ await expect(cb({ user: USER, url: RESET_URL }, REQUEST)).resolves.toBeUndefined();
192
+
193
+ expect(send).not.toHaveBeenCalled();
194
+ expect(lines.some((l) => l.fields.reason === "no_credential_account")).toBe(true);
195
+ expect(JSON.stringify(lines)).not.toContain("person@example.com");
196
+ });
197
+
198
+ it("logs the failed lookup's kind only", async () => {
199
+ const { lines, log } = makeLog();
200
+ const cb = makeSendResetPassword({
201
+ prisma: makePrisma(false),
202
+ send: jest.fn(),
203
+ log,
204
+ sessionUserId: jest.fn().mockRejectedValue(new Error("person@example.com broke")),
205
+ });
206
+ await cb({ user: USER, url: RESET_URL }, REQUEST);
207
+ expect(lines).toEqual([
208
+ {
209
+ level: "warn",
210
+ fields: {
211
+ userId: "user_1",
212
+ kind: "password-reset",
213
+ reason: "no_credential_account",
214
+ errorKind: "session_lookup_failed",
215
+ },
216
+ },
217
+ ]);
218
+ });
219
+
220
+ it("with no sessionUserId it sends nothing, as before", async () => {
221
+ const send = jest.fn();
222
+ const { log } = makeLog();
223
+ const cb = makeSendResetPassword({ prisma: makePrisma(false), send, log });
224
+ await cb({ user: USER, url: RESET_URL }, REQUEST);
225
+ expect(send).not.toHaveBeenCalled();
226
+ });
227
+
228
+ it("a person WITH a password never needs the session lookup", async () => {
229
+ const send = jest.fn().mockResolvedValue({ ok: true, body: { ok: true } });
230
+ const { log } = makeLog();
231
+ const sessionUserId = jest.fn();
232
+ const cb = makeSendResetPassword({ prisma: makePrisma(true), send, log, sessionUserId });
233
+ await cb({ user: USER, url: RESET_URL });
234
+ expect(sessionUserId).not.toHaveBeenCalled();
235
+ expect(send).toHaveBeenCalledTimes(1);
236
+ });
237
+ });
238
+
155
239
  describe("the § D12 PII boundary", () => {
156
240
  it("never logs the address, the token or the assembled link", async () => {
157
241
  const send = jest.fn().mockResolvedValue({ ok: true, body: { ok: true } });
@@ -200,6 +200,12 @@ async function dispatch(
200
200
 
201
201
  export interface ResetPasswordDeps extends DispatchDeps {
202
202
  prisma: AuthEmailPrisma;
203
+ /**
204
+ * The id of the person signed in on `request`, or `null` (#8431 D4). Set,
205
+ * it lets a signed-in person ask for their OWN first password — the
206
+ * Security tab's Set password. Unset, the § D14 guard is unchanged.
207
+ */
208
+ sessionUserId?: (request: Request | undefined) => Promise<string | null>;
203
209
  }
204
210
 
205
211
  /**
@@ -217,17 +223,29 @@ export interface ResetPasswordDeps extends DispatchDeps {
217
223
  * address does not exist": the second is the enumeration AC-1 forbids and the
218
224
  * first is nearly as useful to an attacker, since it maps a site's users to
219
225
  * their identity provider.
226
+ *
227
+ * THE ONE CARVE-OUT (#8431 D4). A person with no password who is SIGNED IN
228
+ * may set one from their Security tab, by the same emailed link: the mail
229
+ * goes out when `deps.sessionUserId(request)` is that same person's id. The
230
+ * concern above is an ANONYMOUS request adding a password path around
231
+ * someone's identity provider; a request carrying that person's own live
232
+ * session is that person. The anonymous path is unchanged and silent, the
233
+ * link still goes only to the account's own address, and a failed session
234
+ * lookup counts as no session.
220
235
  */
221
236
  export function makeSendResetPassword(deps: ResetPasswordDeps) {
222
237
  const { prisma, send } = deps;
223
238
  const log = deps.log ?? defaultLog;
224
- return async function sendResetPassword({
225
- user,
226
- url,
227
- }: {
228
- user: { id: string; email: string };
229
- url: string;
230
- }): Promise<void> {
239
+ return async function sendResetPassword(
240
+ {
241
+ user,
242
+ url,
243
+ }: {
244
+ user: { id: string; email: string };
245
+ url: string;
246
+ },
247
+ request?: Request,
248
+ ): Promise<void> {
231
249
  let hasPassword: { id: string } | null;
232
250
  try {
233
251
  hasPassword = await prisma.account.findFirst({
@@ -245,12 +263,29 @@ export function makeSendResetPassword(deps: ResetPasswordDeps) {
245
263
  }
246
264
 
247
265
  if (!hasPassword) {
248
- log.warn({
266
+ let sessionUserId: string | null = null;
267
+ let lookupFailed = false;
268
+ if (deps.sessionUserId) {
269
+ try {
270
+ sessionUserId = await deps.sessionUserId(request);
271
+ } catch {
272
+ lookupFailed = true;
273
+ }
274
+ }
275
+ if (sessionUserId !== user.id) {
276
+ log.warn({
277
+ userId: user.id,
278
+ kind: "password-reset",
279
+ reason: "no_credential_account",
280
+ ...(lookupFailed ? { errorKind: "session_lookup_failed" } : {}),
281
+ });
282
+ return; // no send; the endpoint's 200 body is unchanged
283
+ }
284
+ log.info({
249
285
  userId: user.id,
250
286
  kind: "password-reset",
251
- reason: "no_credential_account",
287
+ reason: "self_service_set",
252
288
  });
253
- return; // no send; the endpoint's 200 body is unchanged
254
289
  }
255
290
 
256
291
  await dispatch(
@@ -9,6 +9,8 @@
9
9
  // on this topology (#8070).
10
10
  // loadSpokeBilling reaches the spoke billing action only on a spoke that
11
11
  // signs in through its hub and carries commerce (#8217); elsewhere, null.
12
+ // readOwnAccounts lists the signed-in person's own sign-in accounts through
13
+ // Better Auth on a hub (#8431); a spoke has none here, so it answers null.
12
14
 
13
15
  export const TOPOLOGY: "hub" | "spoke" = "hub";
14
16
  export const UNSERVED_MCP_TOOLS: readonly string[] = [];
@@ -32,6 +34,13 @@ export async function readSession(headers: Headers): Promise<SiteSession> {
32
34
  return auth.api.getSession({ headers });
33
35
  }
34
36
 
37
+ export async function readOwnAccounts(
38
+ headers: Headers,
39
+ ): Promise<ReadonlyArray<{ providerId: string }> | null> {
40
+ const { auth } = await import("../auth");
41
+ return auth.api.listUserAccounts({ headers });
42
+ }
43
+
35
44
  export async function registerSiteStores(): Promise<void> {
36
45
  const { registerLicenseKeyStore } = await import("./license-key-store");
37
46
  await registerLicenseKeyStore();
@@ -630,6 +630,7 @@
630
630
  "forYou": "For you",
631
631
  "settings": "Settings",
632
632
  "signOut": "Log out",
633
+ "signOutFailed": "We couldn't log you out. You're still signed in; try again.",
633
634
  "help": "Help",
634
635
  "contactSupport": "Contact support",
635
636
  "helpCenter": "Help center",
@@ -715,26 +716,65 @@
715
716
  "saveError": "We couldn't save your changes. Try again.",
716
717
  "photoInvalid": "Enter a link that starts with https://.",
717
718
  "unverified": "Confirm your email address first, then try again.",
718
- "unavailable": "Profile changes aren't available on this site yet."
719
+ "unavailable": "Profile changes aren't available on this site yet.",
720
+ "contact": {
721
+ "title": "Contact details",
722
+ "lead": "Kept on this site, for the people you work with here.",
723
+ "givenName": "First name",
724
+ "familyName": "Last name",
725
+ "telephone": "Phone",
726
+ "address": "Address",
727
+ "companyName": "Company",
728
+ "jobTitle": "Job title",
729
+ "save": "Save contact details",
730
+ "saving": "Saving…",
731
+ "saved": "Saved.",
732
+ "addressIncomplete": "Fill in the address line, city, state or region, postal code and country, or clear the whole address.",
733
+ "unavailable": "Contact details aren't available on this site right now.",
734
+ "errors": {
735
+ "telephone": "Enter the phone number with its country code.",
736
+ "invalid": "Some of these details couldn't be saved. Check them and try again.",
737
+ "unverified": "Confirm your email address first, then try again.",
738
+ "unavailable": "Contact details can't be saved on this site yet.",
739
+ "failed": "We couldn't save your contact details. Try again."
740
+ }
741
+ }
719
742
  },
720
743
  "security": {
721
- "title": "Security"
744
+ "title": "Security",
745
+ "methodsHeading": "Sign-in methods",
746
+ "passwordLabel": "Password",
747
+ "passwordSet": "Set",
748
+ "passwordNotSet": "Not set",
749
+ "setHint": "Add a password so you can also sign in with your email and password.",
750
+ "changeHint": "Change the password you sign in with.",
751
+ "setNote": "We'll email {email} a secure link to create your password.",
752
+ "changeNote": "We'll email {email} a secure link to change your password.",
753
+ "setButton": "Set password",
754
+ "changeButton": "Change password",
755
+ "sending": "Sending…",
756
+ "sent": "Link sent. It works once, for an hour. Saving a password signs you out everywhere.",
757
+ "sendFailed": "We couldn't send the email. Try again in a few minutes.",
758
+ "logOut": "Log out",
759
+ "logOutFailed": "Log out didn't finish. Try again.",
760
+ "signsInWith": "You sign in with {methods}.",
761
+ "signedInAs": "You're signed in as {email}.",
762
+ "methods": {
763
+ "google": "Google",
764
+ "github": "GitHub",
765
+ "magicLink": "a link sent to {email}",
766
+ "password": "your email and password"
767
+ },
768
+ "managedAt": "Your sign-in is managed at {hub}.",
769
+ "managedUnnamed": "Your sign-in is managed by our main site.",
770
+ "manageOnHub": "Manage sign-in on {hub}",
771
+ "passwordUnavailable": "We couldn't check your password settings."
722
772
  },
723
773
  "notifications": {
724
774
  "title": "Notifications"
725
775
  },
726
776
  "signIn": {
727
- "lead": "How you sign in to this site.",
728
- "methods": {
729
- "google": "Google",
730
- "github": "GitHub",
731
- "magicLink": "Email sign-in link",
732
- "password": "Password"
733
- },
734
- "available": "Available",
735
- "none": "No sign-in method is configured.",
736
- "viaHub": "You sign in through {hub}.",
737
- "viaHubUnnamed": "You sign in through our main site."
777
+ "lead": "How you sign in to this site."
738
778
  },
739
779
  "privacy": {
740
780
  "lead": "Cookies and your data.",
@@ -757,7 +797,7 @@
757
797
  "accessEnds": "Access ends {date}",
758
798
  "card": "{brand} ending in {last4}, expires {month}/{year}",
759
799
  "noCard": "No card on file",
760
- "manage": "Manage in Stripe",
800
+ "manage": "Manage billing",
761
801
  "none": "You don't have a subscription.",
762
802
  "unavailable": "We couldn't load your billing details.",
763
803
  "unreadable": "Some of your billing is with another Stripe account, so it isn't shown here.",
@@ -1,5 +1,21 @@
1
1
  # @working-theory/database
2
2
 
3
+ ## 0.40.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#8497](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/8497) [`52df359`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/52df359fb2a4d1aff488136126dba16d13e49a26) Thanks [@samuelhenry](https://github.com/samuelhenry)! - Contact details on the client record ([#8435](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/8435), spec docs/specs/8435-contact-details-on-the-client-spec.md).
8
+
9
+ A Person now carries a given name, a family name, an E.164 telephone, a six-part address, a company name and a job title, with the two columns that say who last saved them by hand. `callbacks` and `subscribers` gain a `person_id` foreign key. A capture fills only a blank field of an uncurated Person; the operator edits the contact from the Person page and a client edits their own on the site, from the account General tab. A magnet field's `mapsTo` tag names the contact field it fills: `email`, `name`, `givenName`, `familyName`, `phone`, `address`, `company`, `jobTitle`. Additive; the migration (`20261014120000_person_contact`, its second half the idempotent backfill) deploys before the code.
10
+
11
+ - [#8490](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/8490) [`64adcf9`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/64adcf9b58a3f3be3b42782e14018234b263b9bc) Thanks [@samuelhenry](https://github.com/samuelhenry)! - The consent ledger ([#8438](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/8438), the API half). The per-site consent registry gains a kind (`agreement` · `consent` · `preference`), a plain description, a policy link and a policy version per purpose, and its platform default now declares `service_terms`, `account_terms` and `analytics_cookies`; a registry stored before this change still parses and means what it meant. `consent_grants` becomes the one ledger: each row names its source (`cookie_banner` · `sign_up` · `magnet` · `account_settings` · `email_unsubscribe` · `api`), its policy version and, when known, its Person, and the database refuses to change or truncate a stored row. A new `consent_visitor_links` table joins a banner visitor to the Person a later form identifies. `@working-theory/api-client` gains `consent.recordCookieChoice`, `consent.unsubscribe`, `member.consents` and `member.setConsent`; `@working-theory/validation` gains their wire schemas and the `platform-consents` permission. Run the new migration before deploying code that writes the ledger.
12
+
13
+ ### Patch Changes
14
+
15
+ - Updated dependencies [[`52df359`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/52df359fb2a4d1aff488136126dba16d13e49a26), [`64adcf9`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/64adcf9b58a3f3be3b42782e14018234b263b9bc)]:
16
+ - @working-theory/validation@0.40.0
17
+ - @working-theory/ids@0.13.0
18
+
3
19
  ## 0.39.0
4
20
 
5
21
  ### Minor Changes