create-ailk 0.21.0 → 0.22.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.
- package/component-catalog.json +37 -3
- package/component-catalog.md +6 -5
- package/dist/add-module.js +9 -5
- package/dist/derive-architecture.d.ts +8 -1
- package/dist/derive-architecture.js +38 -3
- package/dist/design-package.js +7 -2
- package/dist/lib/env-catalog.js +5 -1
- package/dist/module-architecture.js +4 -2
- package/dist/programmatic.js +19 -5
- package/dist/site-chrome.d.ts +8 -0
- package/dist/site-chrome.js +17 -1
- package/dist/spoke-checkout.d.ts +39 -18
- package/dist/spoke-checkout.js +177 -236
- package/dist/surfaces.js +19 -14
- package/dist/sync-routes.js +3 -2
- package/dist/template-record.d.ts +4 -3
- package/dist/template-record.js +6 -3
- package/dist/topology.js +25 -1
- package/dist/update.js +5 -0
- package/package.json +3 -3
- package/site-templates/designs/minimalism/DESIGN.md +1 -1
- package/templates/apps/api/CLAUDE.md +15 -6
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
- package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
- package/templates/apps/api/src/lib/consent-store.ts +28 -0
- package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
- package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
- package/templates/apps/api/src/middleware/auth.ts +13 -6
- package/templates/apps/api/src/openapi/spec.ts +30 -3
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
- package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
- package/templates/apps/api/src/routes/consent/account.ts +266 -0
- package/templates/apps/api/src/routes/consent/index.ts +15 -7
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
- package/templates/apps/api/src/routes/content/get-page.ts +4 -0
- package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
- package/templates/apps/api/src/routes/leads/index.ts +161 -7
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +154 -19
- package/templates/apps/api/src/server.ts +28 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1038 -118
- package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
- package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
- package/templates/apps/api/src/services/consent.ts +95 -0
- package/templates/apps/api/src/services/lead-routing.ts +337 -8
- package/templates/apps/api/src/services/magnet-completion.ts +242 -0
- package/templates/apps/api/src/services/person.ts +40 -8
- package/templates/apps/api/src/services/plan-offerings.ts +59 -0
- package/templates/apps/api/src/services/public-site-resolver.ts +8 -1
- package/templates/apps/web/.env.example +14 -0
- package/templates/apps/web/CLAUDE.md +120 -9
- package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
- package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
- package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
- package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +293 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +115 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +146 -1
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +310 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +143 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +350 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +29 -3
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
- package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
- package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
- package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +17 -1
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +41 -2
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +438 -31
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +7 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +168 -57
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
- package/templates/apps/web/app/[locale]/contact/page.tsx +49 -17
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +106 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
- package/templates/apps/web/app/api/checkout/[offering]/route.ts +48 -0
- package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +120 -0
- package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +86 -0
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
- package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +92 -0
- package/templates/apps/web/app/api/webhooks/stripe/route.ts +31 -0
- package/templates/apps/web/auth.ts +28 -2
- package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
- package/templates/apps/web/lib/__tests__/checkout.test.ts +243 -0
- package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +5 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +34 -17
- package/templates/apps/web/lib/account-invite.ts +37 -0
- package/templates/apps/web/lib/checkout-config.ts +27 -0
- package/templates/apps/web/lib/checkout.ts +93 -0
- package/templates/apps/web/lib/consent-policies.ts +22 -0
- package/templates/apps/web/lib/deck-page.ts +6 -0
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
- package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
- package/templates/apps/web/lib/sign-up-agreements.ts +447 -0
- package/templates/apps/web/lib/site-chrome.ts +2 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
- package/templates/apps/web/messages/en.json +24 -7
- package/templates/apps/web/package.json +1 -0
- package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
- package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
- package/templates/apps/web/src/lib/lead-write.ts +90 -0
- package/templates/apps/web/src/lib/rate-limit.ts +79 -0
- package/templates/database/CHANGELOG.md +38 -0
- package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
- package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
- package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
- package/templates/database/__tests__/seed.test.ts +34 -1
- package/templates/database/inbox/schema.prisma +17 -2
- package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
- package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +34 -3
- package/versions.json +34 -34
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sign-up agreements, refused and recorded on the server (#8765 D3, D4, spec
|
|
3
|
+
* docs/specs/8765-sign-up-refuses-without-the-spec.md).
|
|
4
|
+
*
|
|
5
|
+
* THREE PIECES, ONE FILE. `auth.ts` installs all three:
|
|
6
|
+
*
|
|
7
|
+
* signUpAgreementsPlugin() a Better Auth plugin. While `isSignUpOpen()`,
|
|
8
|
+
* `POST /sign-up/email` without every sign-up
|
|
9
|
+
* agreement in its `agreements` body key is
|
|
10
|
+
* refused `400 AGREEMENTS_REQUIRED` — BEFORE
|
|
11
|
+
* the handler's user lookup, so the answer is
|
|
12
|
+
* the same for a new and a taken address
|
|
13
|
+
* (NFR2). The OAuth callback only warms the
|
|
14
|
+
* evidence cache, outside the transaction.
|
|
15
|
+
* withAuthTransactionCapture() wraps the client the Prisma adapter holds,
|
|
16
|
+
* so the transaction client Better Auth's
|
|
17
|
+
* sign-up transaction runs on is reachable
|
|
18
|
+
* from a database hook.
|
|
19
|
+
* signUpAgreementHooks() two database hooks: the user hook decides
|
|
20
|
+
* whether this request's new user carries
|
|
21
|
+
* agreements; the account hook writes one
|
|
22
|
+
* `sign_up` grant per agreement on the
|
|
23
|
+
* captured transaction client, so the User,
|
|
24
|
+
* its Account and the grants commit together
|
|
25
|
+
* or not at all (NFR1).
|
|
26
|
+
*
|
|
27
|
+
* THE EVIDENCE IS THE API'S. Each grant's statement, hash and version come
|
|
28
|
+
* from `GET /v1/consent/sign-up` — built by the same functions `apps/api`'s
|
|
29
|
+
* `services/consent.ts` stamps with — never from this app's copy of the
|
|
30
|
+
* registry. It is cached per process: fresh for 60 s, the last good entry
|
|
31
|
+
* served for up to 10 minutes while a refresh fails, at most one entry per
|
|
32
|
+
* enabled locale (SEC-8765-D). No usable entry → `503
|
|
33
|
+
* AGREEMENTS_UNAVAILABLE`: no user is created without evidence. The database
|
|
34
|
+
* hooks run INSIDE the transaction and never call the network; they read the
|
|
35
|
+
* cache only (SEC-8765-I).
|
|
36
|
+
*
|
|
37
|
+
* THE SECOND LEDGER WRITER. `services/consent.ts` writes every other row.
|
|
38
|
+
* This hook writes only `sign_up` rows for a user it is creating in the same
|
|
39
|
+
* transaction, so `consent.ts`'s advisory lock and write-only-on-change rule
|
|
40
|
+
* have nothing to guard here. It reaches the database through the auth
|
|
41
|
+
* client apps/web already holds as its one documented exception
|
|
42
|
+
* (`lib/prisma-for-auth.ts`), and imports no `@working-theory/database`
|
|
43
|
+
* value.
|
|
44
|
+
*
|
|
45
|
+
* NOT EVERY NEW USER HAS A GRANT (SEC-8765-C). A first social sign-in from
|
|
46
|
+
* `/sign-in`, or the `idToken` branch, carries no agreements; Better Auth
|
|
47
|
+
* still creates the user, and the account interstitial asks before the
|
|
48
|
+
* account loads.
|
|
49
|
+
*
|
|
50
|
+
* `better-auth/api` is imported lazily, inside the hooks: it is ESM-only, and
|
|
51
|
+
* the CommonJS Jest suites that build `createAuth()` mock only the
|
|
52
|
+
* `better-auth` modules `auth.ts` itself imports. Precedent for the file's
|
|
53
|
+
* shape: `lib/auth-email.ts`.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
57
|
+
|
|
58
|
+
import { consent } from "@working-theory/api-client";
|
|
59
|
+
import {
|
|
60
|
+
consentSignUpResponseSchema,
|
|
61
|
+
SIGN_UP_AGREEMENTS_FIELD,
|
|
62
|
+
signUpAgreementsSchema,
|
|
63
|
+
type ConsentSignUpResponse,
|
|
64
|
+
} from "@working-theory/validation";
|
|
65
|
+
import type { BetterAuthOptions, BetterAuthPlugin } from "better-auth";
|
|
66
|
+
|
|
67
|
+
import { isSignUpOpen } from "./auth-env";
|
|
68
|
+
|
|
69
|
+
const SIGN_UP_PATH = "/sign-up/email";
|
|
70
|
+
const CALLBACK_PREFIX = "/callback/";
|
|
71
|
+
const DEFAULT_LOCALE = "en";
|
|
72
|
+
const FRESH_MS = 60_000;
|
|
73
|
+
const STALE_MS = 10 * 60_000;
|
|
74
|
+
/** The longest one evidence read may hold a sign-up or a sign-in. */
|
|
75
|
+
const READ_TIMEOUT_MS = 2_000;
|
|
76
|
+
|
|
77
|
+
// ── The evidence cache ──────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
interface CacheEntry {
|
|
80
|
+
evidence: ConsentSignUpResponse;
|
|
81
|
+
fetchedAt: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const cache = new Map<string, CacheEntry>();
|
|
85
|
+
const refreshing = new Map<string, Promise<ConsentSignUpResponse | null>>();
|
|
86
|
+
let clock: () => number = Date.now;
|
|
87
|
+
|
|
88
|
+
/** Test seam: empties the per-process cache and sets the clock it reads. */
|
|
89
|
+
export function resetSignUpEvidenceCache(now: () => number = Date.now): void {
|
|
90
|
+
cache.clear();
|
|
91
|
+
refreshing.clear();
|
|
92
|
+
clock = now;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A body or state `locale` counts only when it is one of the site's enabled
|
|
97
|
+
* locales — the list `middleware.ts` routes. Anything else reads as `en`, so
|
|
98
|
+
* the cache holds at most one entry per enabled locale (SEC-8765-D).
|
|
99
|
+
*/
|
|
100
|
+
function normaliseLocale(raw: unknown): string {
|
|
101
|
+
return typeof raw === "string" && enabledLocales().includes(raw)
|
|
102
|
+
? raw
|
|
103
|
+
: DEFAULT_LOCALE;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function enabledLocales(): string[] {
|
|
107
|
+
const enabled = (process.env.NEXT_PUBLIC_ENABLED_LOCALES ?? DEFAULT_LOCALE)
|
|
108
|
+
.split(",")
|
|
109
|
+
.map((locale) => locale.trim())
|
|
110
|
+
.filter((locale) => locale.length > 0);
|
|
111
|
+
return enabled.length > 0 ? enabled : [DEFAULT_LOCALE];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** One read of the API's evidence. Never throws: a failure is `null`. */
|
|
115
|
+
async function fetchEvidence(
|
|
116
|
+
locale: string,
|
|
117
|
+
): Promise<ConsentSignUpResponse | null> {
|
|
118
|
+
// Bounded, so a slow API never holds a sign-up or an OAuth callback: a
|
|
119
|
+
// timeout is a failed read (sign-up fails closed; the interstitial asks an
|
|
120
|
+
// OAuth user later).
|
|
121
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
122
|
+
const timedOut = new Promise<null>((resolve) => {
|
|
123
|
+
timer = setTimeout(() => resolve(null), READ_TIMEOUT_MS);
|
|
124
|
+
});
|
|
125
|
+
try {
|
|
126
|
+
const res = await Promise.race([
|
|
127
|
+
consent.signUpEvidence({ locale }),
|
|
128
|
+
timedOut,
|
|
129
|
+
]);
|
|
130
|
+
if (res === null || !res.ok) return null;
|
|
131
|
+
const parsed = consentSignUpResponseSchema.safeParse(res.body);
|
|
132
|
+
if (!parsed.success) return null;
|
|
133
|
+
cache.set(locale, { evidence: parsed.data, fetchedAt: clock() });
|
|
134
|
+
return parsed.data;
|
|
135
|
+
} catch {
|
|
136
|
+
return null;
|
|
137
|
+
} finally {
|
|
138
|
+
clearTimeout(timer);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Concurrent misses for one locale share one read. */
|
|
143
|
+
function refresh(locale: string): Promise<ConsentSignUpResponse | null> {
|
|
144
|
+
const pending = refreshing.get(locale);
|
|
145
|
+
if (pending) return pending;
|
|
146
|
+
const read = fetchEvidence(locale).finally(() => refreshing.delete(locale));
|
|
147
|
+
refreshing.set(locale, read);
|
|
148
|
+
return read;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The cached entry while it may still be served (≤ 10 min old), no network. */
|
|
152
|
+
function cachedEvidence(locale: string): ConsentSignUpResponse | null {
|
|
153
|
+
const entry = cache.get(locale);
|
|
154
|
+
return entry && clock() - entry.fetchedAt <= STALE_MS ? entry.evidence : null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Fresh evidence, refreshing when older than 60 s; stale on a failed refresh. */
|
|
158
|
+
async function readEvidence(
|
|
159
|
+
locale: string,
|
|
160
|
+
): Promise<ConsentSignUpResponse | null> {
|
|
161
|
+
const entry = cache.get(locale);
|
|
162
|
+
if (entry && clock() - entry.fetchedAt < FRESH_MS) return entry.evidence;
|
|
163
|
+
return (await refresh(locale)) ?? cachedEvidence(locale);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Whether `raw` names every purpose the evidence asks for. */
|
|
167
|
+
function coversEvidence(
|
|
168
|
+
raw: unknown,
|
|
169
|
+
evidence: ConsentSignUpResponse,
|
|
170
|
+
): boolean {
|
|
171
|
+
const parsed = signUpAgreementsSchema.safeParse(raw);
|
|
172
|
+
if (!parsed.success) return false;
|
|
173
|
+
return evidence.agreements.every((agreement) =>
|
|
174
|
+
parsed.data.includes(agreement.purposeId),
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function apiError(
|
|
179
|
+
status: "BAD_REQUEST" | "SERVICE_UNAVAILABLE",
|
|
180
|
+
code: "AGREEMENTS_REQUIRED" | "AGREEMENTS_UNAVAILABLE",
|
|
181
|
+
message: string,
|
|
182
|
+
): Promise<Error> {
|
|
183
|
+
const { APIError } = await import("better-auth/api");
|
|
184
|
+
return new APIError(status, { code, message });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const agreementsRequired = (): Promise<Error> =>
|
|
188
|
+
apiError(
|
|
189
|
+
"BAD_REQUEST",
|
|
190
|
+
"AGREEMENTS_REQUIRED",
|
|
191
|
+
"Accept the agreements to create an account.",
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const agreementsUnavailable = (): Promise<Error> =>
|
|
195
|
+
apiError(
|
|
196
|
+
"SERVICE_UNAVAILABLE",
|
|
197
|
+
"AGREEMENTS_UNAVAILABLE",
|
|
198
|
+
"The sign-up agreements could not be read. Try again in a moment.",
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
/** A request body, as the hooks see it: never trusted, read by key. */
|
|
202
|
+
function bodyField(body: unknown, key: string): unknown {
|
|
203
|
+
return typeof body === "object" && body !== null
|
|
204
|
+
? Reflect.get(body, key)
|
|
205
|
+
: undefined;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── D3 — the plugin ─────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Refuses `POST /sign-up/email` without the agreements (D3), and warms the
|
|
212
|
+
* evidence cache on the OAuth callback so the user hook below finds it
|
|
213
|
+
* without a network call inside the transaction (SEC-8765-I).
|
|
214
|
+
*/
|
|
215
|
+
export function signUpAgreementsPlugin(): BetterAuthPlugin {
|
|
216
|
+
return {
|
|
217
|
+
id: "sign-up-agreements",
|
|
218
|
+
hooks: {
|
|
219
|
+
before: [
|
|
220
|
+
{
|
|
221
|
+
matcher: (context) => context.path === SIGN_UP_PATH,
|
|
222
|
+
handler: async (context) => {
|
|
223
|
+
// Closed sign-up refuses in the handler itself (`disableSignUp`).
|
|
224
|
+
if (!isSignUpOpen()) return;
|
|
225
|
+
const body: unknown = context.body;
|
|
226
|
+
const evidence = await readEvidence(
|
|
227
|
+
normaliseLocale(bodyField(body, "locale")),
|
|
228
|
+
);
|
|
229
|
+
if (!evidence) throw await agreementsUnavailable();
|
|
230
|
+
if (evidence.agreements.length === 0) return;
|
|
231
|
+
if (
|
|
232
|
+
!coversEvidence(
|
|
233
|
+
bodyField(body, SIGN_UP_AGREEMENTS_FIELD),
|
|
234
|
+
evidence,
|
|
235
|
+
)
|
|
236
|
+
) {
|
|
237
|
+
throw await agreementsRequired();
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
matcher: (context) =>
|
|
243
|
+
context.path?.startsWith(CALLBACK_PREFIX) ?? false,
|
|
244
|
+
// Warms only, and only while sign-up is open. The state — and so
|
|
245
|
+
// the locale the card sent — is parsed inside the handler, so every
|
|
246
|
+
// enabled locale is warmed; each is read at most once a minute and
|
|
247
|
+
// each read is bounded. Never refuses.
|
|
248
|
+
handler: async () => {
|
|
249
|
+
if (!isSignUpOpen()) return;
|
|
250
|
+
await Promise.all(
|
|
251
|
+
enabledLocales().map((locale) => readEvidence(locale)),
|
|
252
|
+
);
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
],
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── D4 — the transaction capture ────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
const capturedTx = new AsyncLocalStorage<unknown>();
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Wraps the client the Prisma adapter holds. Its `$transaction(fn, options)`
|
|
266
|
+
* calls the client's own `$transaction` and runs `fn(tx)` inside an
|
|
267
|
+
* `AsyncLocalStorage` holding `tx`; every other member is the client's own.
|
|
268
|
+
* The capture never changes what a transaction does: options pass through
|
|
269
|
+
* unchanged, and the array form is delegated as is.
|
|
270
|
+
*
|
|
271
|
+
* A client with no `$transaction` member (a test double) gets `fn(client)` —
|
|
272
|
+
* what the adapter did for it before `transaction: true` — and no `tx` is
|
|
273
|
+
* captured, so a sign-up with agreements fails closed on it.
|
|
274
|
+
*/
|
|
275
|
+
export function withAuthTransactionCapture<Client extends object>(
|
|
276
|
+
client: Client,
|
|
277
|
+
): Client {
|
|
278
|
+
const $transaction = (
|
|
279
|
+
fn: unknown,
|
|
280
|
+
...options: unknown[]
|
|
281
|
+
): Promise<unknown> => {
|
|
282
|
+
const own: unknown = Reflect.get(client, "$transaction");
|
|
283
|
+
if (typeof fn !== "function") {
|
|
284
|
+
return Promise.resolve(
|
|
285
|
+
typeof own === "function"
|
|
286
|
+
? Reflect.apply(own, client, [fn, ...options])
|
|
287
|
+
: undefined,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
if (typeof own !== "function") {
|
|
291
|
+
return Promise.resolve().then(() =>
|
|
292
|
+
Reflect.apply(fn, undefined, [client]),
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
const run = (tx: unknown): unknown =>
|
|
296
|
+
capturedTx.run(tx, () => Reflect.apply(fn, undefined, [tx]));
|
|
297
|
+
return Promise.resolve(Reflect.apply(own, client, [run, ...options]));
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
return new Proxy(client, {
|
|
301
|
+
get: (target, member) =>
|
|
302
|
+
member === "$transaction" ? $transaction : Reflect.get(target, member),
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── D4 — the database hooks ─────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
/** The one delegate method the account hook calls on the transaction client. */
|
|
309
|
+
interface ConsentGrantWriter {
|
|
310
|
+
consentGrant: {
|
|
311
|
+
createMany(args: { data: ConsentGrantRow[] }): Promise<unknown>;
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
interface ConsentGrantRow {
|
|
316
|
+
siteId: string;
|
|
317
|
+
workspaceId: string;
|
|
318
|
+
subjectType: "user";
|
|
319
|
+
subjectId: string;
|
|
320
|
+
personId: null;
|
|
321
|
+
purposeId: string;
|
|
322
|
+
granted: true;
|
|
323
|
+
source: "sign_up";
|
|
324
|
+
sourceRef: null;
|
|
325
|
+
policyVersion: number;
|
|
326
|
+
locale: string;
|
|
327
|
+
statement: string;
|
|
328
|
+
statementHash: string;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function isConsentGrantWriter(tx: unknown): tx is ConsentGrantWriter {
|
|
332
|
+
if (typeof tx !== "object" || tx === null) return false;
|
|
333
|
+
const delegate: unknown = Reflect.get(tx, "consentGrant");
|
|
334
|
+
return (
|
|
335
|
+
typeof delegate === "object" &&
|
|
336
|
+
delegate !== null &&
|
|
337
|
+
typeof Reflect.get(delegate, "createMany") === "function"
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
type DatabaseHooks = NonNullable<BetterAuthOptions["databaseHooks"]>;
|
|
342
|
+
type HookContext = Parameters<
|
|
343
|
+
NonNullable<
|
|
344
|
+
NonNullable<NonNullable<DatabaseHooks["user"]>["create"]>["before"]
|
|
345
|
+
>
|
|
346
|
+
>[1];
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* The evidence the user being created agreed to, or `null` when none is to be
|
|
350
|
+
* recorded. Reads the cache only — the transaction is already open.
|
|
351
|
+
*/
|
|
352
|
+
async function agreedEvidence(
|
|
353
|
+
context: NonNullable<HookContext>,
|
|
354
|
+
): Promise<ConsentSignUpResponse | null> {
|
|
355
|
+
const path = typeof context.path === "string" ? context.path : "";
|
|
356
|
+
|
|
357
|
+
if (path === SIGN_UP_PATH) {
|
|
358
|
+
if (!isSignUpOpen()) return null;
|
|
359
|
+
const evidence = cachedEvidence(
|
|
360
|
+
normaliseLocale(bodyField(context.body, "locale")),
|
|
361
|
+
);
|
|
362
|
+
// The entry aged out between the plugin and here: fail closed.
|
|
363
|
+
if (!evidence) throw await agreementsUnavailable();
|
|
364
|
+
if (evidence.agreements.length === 0) return null;
|
|
365
|
+
// The plugin checked the body against the entry it read; a refresh since
|
|
366
|
+
// may have changed it, so the evidence recorded is checked again here.
|
|
367
|
+
if (
|
|
368
|
+
!coversEvidence(
|
|
369
|
+
bodyField(context.body, SIGN_UP_AGREEMENTS_FIELD),
|
|
370
|
+
evidence,
|
|
371
|
+
)
|
|
372
|
+
) {
|
|
373
|
+
throw await agreementsRequired();
|
|
374
|
+
}
|
|
375
|
+
return evidence;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (path.startsWith(CALLBACK_PREFIX)) {
|
|
379
|
+
const { getOAuthState } = await import("better-auth/api");
|
|
380
|
+
const state: unknown = await getOAuthState().catch(() => null);
|
|
381
|
+
const evidence = cachedEvidence(
|
|
382
|
+
normaliseLocale(bodyField(state, "locale")),
|
|
383
|
+
);
|
|
384
|
+
if (!evidence || evidence.agreements.length === 0) return null;
|
|
385
|
+
return coversEvidence(bodyField(state, SIGN_UP_AGREEMENTS_FIELD), evidence)
|
|
386
|
+
? evidence
|
|
387
|
+
: null;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* The two database hooks (D4). They share one request's context object —
|
|
395
|
+
* Better Auth hands both the endpoint context it runs the handler in — and
|
|
396
|
+
* that object keys the `WeakMap` between them.
|
|
397
|
+
*/
|
|
398
|
+
export function signUpAgreementHooks(): DatabaseHooks {
|
|
399
|
+
const pending = new WeakMap<object, ConsentSignUpResponse>();
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
user: {
|
|
403
|
+
create: {
|
|
404
|
+
// Changes nothing on the user; only remembers what it agreed to.
|
|
405
|
+
before: async (_user, context) => {
|
|
406
|
+
if (!context) return;
|
|
407
|
+
const evidence = await agreedEvidence(context);
|
|
408
|
+
if (evidence) pending.set(context, evidence);
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
account: {
|
|
413
|
+
create: {
|
|
414
|
+
// Set only in a request creating a user, whose first account this is.
|
|
415
|
+
before: async (account, context) => {
|
|
416
|
+
if (!context) return;
|
|
417
|
+
const evidence = pending.get(context);
|
|
418
|
+
if (!evidence) return;
|
|
419
|
+
pending.delete(context);
|
|
420
|
+
const tx = capturedTx.getStore();
|
|
421
|
+
if (!isConsentGrantWriter(tx)) {
|
|
422
|
+
throw new Error(
|
|
423
|
+
"sign-up agreements: no transaction client captured; refusing to create the user without its grants",
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
await tx.consentGrant.createMany({
|
|
427
|
+
data: evidence.agreements.map((agreement) => ({
|
|
428
|
+
siteId: evidence.workspaceId,
|
|
429
|
+
workspaceId: evidence.workspaceId,
|
|
430
|
+
subjectType: "user",
|
|
431
|
+
subjectId: account.userId,
|
|
432
|
+
personId: null,
|
|
433
|
+
purposeId: agreement.purposeId,
|
|
434
|
+
granted: true,
|
|
435
|
+
source: "sign_up",
|
|
436
|
+
sourceRef: null,
|
|
437
|
+
policyVersion: agreement.policyVersion,
|
|
438
|
+
locale: agreement.locale,
|
|
439
|
+
statement: agreement.statement,
|
|
440
|
+
statementHash: agreement.statementHash,
|
|
441
|
+
})),
|
|
442
|
+
});
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
};
|
|
447
|
+
}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// - innerPages: "bare" → every non-home page renders the bare header and
|
|
17
17
|
// the one-bar footer.
|
|
18
18
|
// - deckPresentation → the deck home presents slide by slide: fitted slides, slide anchors, the mark follows the ground
|
|
19
|
+
// - deckAnchors: "bare" → the presenting deck names its slides #<id> instead of #slide-<id>
|
|
19
20
|
// - colorMode: "light" → the site is pinned to light and shows no theme
|
|
20
21
|
// switch; dark appears only as a deck ground.
|
|
21
22
|
// - chromeStyle: "minimal" → the deck's closing slide is the wordmark,
|
|
@@ -31,6 +32,7 @@ export interface SiteChrome {
|
|
|
31
32
|
home: "default" | "deck";
|
|
32
33
|
innerPages?: "bare";
|
|
33
34
|
deckPresentation?: true;
|
|
35
|
+
deckAnchors?: "bare";
|
|
34
36
|
colorMode?: "light";
|
|
35
37
|
chromeStyle?: "minimal";
|
|
36
38
|
closingFooterPaths?: readonly string[];
|
|
@@ -9,10 +9,14 @@
|
|
|
9
9
|
// `/purchase/success?session_id=cs_…` is the SAME class of bearer capability
|
|
10
10
|
// — the exact defect #5562 fixed for `?g=`, recurring on a second surface.
|
|
11
11
|
//
|
|
12
|
+
// `email` and `login_hint` joined by #8757 (D9, SEC-8757-C): an account link
|
|
13
|
+
// carries the submitter's address as `/sign-in?email=…` and the hub's
|
|
14
|
+
// authorize step as `login_hint=…` — personal data GA and Sentry must not get.
|
|
15
|
+
//
|
|
12
16
|
// Mirrored (duplicated, not shared) in packages/analytics/src/scrub.ts,
|
|
13
17
|
// since apps/* must not import @working-theory/* packages — keep both lists
|
|
14
18
|
// in sync (#5613).
|
|
15
|
-
export const TOKEN_QUERY_PARAMS = ["g", "session_id"];
|
|
19
|
+
export const TOKEN_QUERY_PARAMS = ["g", "session_id", "email", "login_hint"];
|
|
16
20
|
|
|
17
21
|
// Sentry navigation breadcrumbs (`data.to`/`data.from`) are relative
|
|
18
22
|
// (`path?search`, no origin) rather than the absolute URLs GA and
|
|
@@ -94,7 +94,8 @@
|
|
|
94
94
|
},
|
|
95
95
|
"careers": {
|
|
96
96
|
"heading": "Careers",
|
|
97
|
-
"emptyState": "Career listings coming soon."
|
|
97
|
+
"emptyState": "Career listings coming soon.",
|
|
98
|
+
"applicationSuccess": "Thank you for applying! We have your application and will be in touch."
|
|
98
99
|
},
|
|
99
100
|
"case-studies": {
|
|
100
101
|
"heading": "Case Studies",
|
|
@@ -125,6 +126,7 @@
|
|
|
125
126
|
"sending": "Sending…",
|
|
126
127
|
"sendMessage": "Send message",
|
|
127
128
|
"successMessage": "Thank you for reaching out! We will be in touch shortly.",
|
|
129
|
+
"trackInAccount": "Track this in your account",
|
|
128
130
|
"consentStatement": "I accept the [Terms](terms), [Privacy Policy](privacy), and [Working Agreement](working_agreement).",
|
|
129
131
|
"consentRequired": "Please accept the terms to send your message.",
|
|
130
132
|
"genericError": "Something went wrong, please try again.",
|
|
@@ -140,8 +142,12 @@
|
|
|
140
142
|
"namePrompt": "What's your name?",
|
|
141
143
|
"messageAck": "Good to meet you.",
|
|
142
144
|
"messagePrompt": "What are you hoping to get done?",
|
|
143
|
-
"emailAck": "Got it
|
|
145
|
+
"emailAck": "Got it.",
|
|
144
146
|
"emailPrompt": "What's the best email to reach you at?",
|
|
147
|
+
"consentAck": "Thanks — one last thing.",
|
|
148
|
+
"consentPrompt": "Do you accept our Terms, Privacy Policy and Working Agreement? They're linked at the foot of every page.",
|
|
149
|
+
"consentAccept": "Yes, I accept",
|
|
150
|
+
"consentDecline": "Not yet",
|
|
145
151
|
"skipMessage": "No worries — here's our form instead.",
|
|
146
152
|
"completionMessage": "Thanks! We'll be in touch shortly.",
|
|
147
153
|
"submitError": "We couldn't send that automatically — please review and send below."
|
|
@@ -312,7 +318,8 @@
|
|
|
312
318
|
},
|
|
313
319
|
"checkoutThankYou": {
|
|
314
320
|
"title": "Thank you",
|
|
315
|
-
"body": "Your payment went through. We'll email you a receipt and the next steps."
|
|
321
|
+
"body": "Your payment went through. We'll email you a receipt and the next steps.",
|
|
322
|
+
"openAccount": "Open your account"
|
|
316
323
|
},
|
|
317
324
|
"checkoutUnavailable": {
|
|
318
325
|
"title": "Checkout isn't available right now",
|
|
@@ -499,8 +506,7 @@
|
|
|
499
506
|
"signIn": {
|
|
500
507
|
"title": "Sign in",
|
|
501
508
|
"description": "Sign in to your account.",
|
|
502
|
-
"heading": "
|
|
503
|
-
"subhead": "Welcome back. Choose how you'd like to continue.",
|
|
509
|
+
"heading": "Welcome back",
|
|
504
510
|
"notConfiguredSubhead": "Sign-in is not configured for this site yet. Set a provider's environment variables to enable it.",
|
|
505
511
|
"ssoPrefix": "Sign in with",
|
|
506
512
|
"google": "Google",
|
|
@@ -520,7 +526,14 @@
|
|
|
520
526
|
"emailLinkSentBody": "If an account uses that address, a sign-in link is on its way. It works once and expires in 5 minutes.",
|
|
521
527
|
"emailLinkFailed": "We couldn't send a sign-in link. Try again.",
|
|
522
528
|
"emailLinkRateLimited": "Too many requests. Wait a minute, then try again.",
|
|
523
|
-
"emailLinkExpired": "That sign-in link has expired or was already used. Request a new one."
|
|
529
|
+
"emailLinkExpired": "That sign-in link has expired or was already used. Request a new one.",
|
|
530
|
+
"useEmailLink": "Email me a sign-in link instead",
|
|
531
|
+
"emailPlaceholder": "you@example.com",
|
|
532
|
+
"backTo": "Back to {site}",
|
|
533
|
+
"acknowledgement": "You acknowledge that you read and agree to our",
|
|
534
|
+
"termsLink": "Terms of Service",
|
|
535
|
+
"privacyLink": "Privacy Policy",
|
|
536
|
+
"workingAgreementLink": "Working Agreement"
|
|
524
537
|
},
|
|
525
538
|
"auth": {
|
|
526
539
|
"signUpTitle": "Create an account",
|
|
@@ -546,6 +559,7 @@
|
|
|
546
559
|
"signUpFooterPrompt": "Already have an account?",
|
|
547
560
|
"signUpFooterLink": "Sign in",
|
|
548
561
|
"signUpAgreementsLegend": "Agreements",
|
|
562
|
+
"signUpAgreementsRequired": "Accept the agreements to create an account.",
|
|
549
563
|
"signUpNotConfiguredSubhead": "Sign-up is not configured for this site yet. Set a provider's environment variables to enable it.",
|
|
550
564
|
"signUpFailed": "We couldn't create your account. Check your details and try again.",
|
|
551
565
|
"providerFailed": "We couldn't start sign-up with that provider. Try again.",
|
|
@@ -852,7 +866,9 @@
|
|
|
852
866
|
"agreements": {
|
|
853
867
|
"titleNew": "Please accept our terms",
|
|
854
868
|
"titleUpdated": "We've updated our terms",
|
|
869
|
+
"lead": "To keep using your account, accept the agreements below.",
|
|
855
870
|
"accept": "Accept",
|
|
871
|
+
"signOut": "Sign out",
|
|
856
872
|
"error": "We couldn't save that. Try again."
|
|
857
873
|
},
|
|
858
874
|
"gate": {
|
|
@@ -862,7 +878,8 @@
|
|
|
862
878
|
"homeLink": "Back to the site",
|
|
863
879
|
"hubHeadline": "Sign in to your account",
|
|
864
880
|
"hubDescription": "You sign in on our main site, then come straight back here.",
|
|
865
|
-
"hubSignIn": "Sign in"
|
|
881
|
+
"hubSignIn": "Sign in",
|
|
882
|
+
"title": "Sign in"
|
|
866
883
|
},
|
|
867
884
|
"profile": {
|
|
868
885
|
"email": "Email",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"@working-theory/schema-pack-registry": "workspace:*",
|
|
31
31
|
"@working-theory/schema-validator": "workspace:*",
|
|
32
32
|
"@working-theory/site-config": "workspace:*",
|
|
33
|
+
"@working-theory/spoke-checkout": "workspace:*",
|
|
33
34
|
"@working-theory/templates": "workspace:*",
|
|
34
35
|
"@working-theory/theme": "workspace:*",
|
|
35
36
|
"@working-theory/ui": "workspace:*",
|