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.
- package/README.md +64 -7
- package/dist/add-module.js +7 -2
- package/dist/cli.js +35 -0
- package/dist/copy.d.ts +41 -0
- package/dist/copy.js +87 -21
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/fetch-template-snapshot.d.ts +21 -0
- package/dist/lib/fetch-template-snapshot.js +205 -0
- package/dist/lib/merge-three-way.d.ts +31 -0
- package/dist/lib/merge-three-way.js +65 -0
- package/dist/lib/scaffold-render-sweep.d.ts +23 -0
- package/dist/lib/scaffold-render-sweep.js +52 -0
- package/dist/module-architecture.js +64 -5
- package/dist/programmatic.d.ts +7 -1
- package/dist/programmatic.js +40 -3
- package/dist/site-roster.js +21 -4
- package/dist/spoke-checkout.d.ts +7 -0
- package/dist/spoke-checkout.js +8 -1
- package/dist/surfaces.d.ts +14 -0
- package/dist/surfaces.js +98 -3
- package/dist/template-record.d.ts +59 -0
- package/dist/template-record.js +183 -0
- package/dist/topology.js +29 -1
- package/dist/update.d.ts +48 -0
- package/dist/update.js +504 -0
- package/package.json +3 -3
- package/templates/.claude/cloudparity-repo-hooks.json +9 -0
- package/templates/.claude/hooks/architecture-check.sh +8 -0
- package/templates/.claude/rules/loop-contract.md +129 -0
- package/templates/.claude/settings.json +3 -2
- package/templates/.env.example +13 -0
- package/templates/CLAUDE.md +1 -1
- package/templates/CONVENTIONS.md +3 -2
- package/templates/_gitignore +162 -0
- package/templates/apps/api/.env.example +17 -1
- package/templates/apps/api/CLAUDE.md +18 -8
- package/templates/apps/api/package.json +2 -1
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +41 -2
- package/templates/apps/api/src/__tests__/setup.ts +7 -0
- package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
- package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
- package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +8 -0
- package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
- package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
- package/templates/apps/api/src/lib/consent-store.ts +335 -0
- package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
- package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
- package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
- package/templates/apps/api/src/lib/stripe.ts +97 -5
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +13 -4
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +72 -14
- package/templates/apps/api/src/middleware/auth.ts +16 -4
- package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +295 -82
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
- package/templates/apps/api/src/routes/checkout/session.ts +3 -3
- package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
- package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
- package/templates/apps/api/src/routes/consent/index.ts +18 -0
- package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
- package/templates/apps/api/src/routes/leads/index.ts +15 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
- package/templates/apps/api/src/routes/webhooks/README.md +33 -5
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +201 -6
- package/templates/apps/api/src/server.ts +125 -18
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
- package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +115 -4
- package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
- package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
- package/templates/apps/api/src/services/audit-log.ts +72 -10
- package/templates/apps/api/src/services/consent.ts +488 -3
- package/templates/apps/api/src/services/default-tenant.ts +12 -3
- package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
- package/templates/apps/api/src/services/lead-routing.ts +105 -12
- package/templates/apps/api/src/services/person.ts +501 -1
- package/templates/apps/api/src/services/project-listings.ts +7 -2
- package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
- package/templates/apps/api/src/services/request-confirm.ts +52 -0
- package/templates/apps/api/src/services/revalidation.ts +11 -1
- package/templates/apps/api/vercel.json +1 -1
- package/templates/apps/mcp/CLAUDE.md +19 -7
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
- package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
- package/templates/apps/mcp/src/tools/proposals.ts +27 -0
- package/templates/apps/mcp/src/workspace-server.ts +16 -0
- package/templates/apps/web/_gitignore +6 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +17 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +154 -53
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +116 -27
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +124 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +116 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +290 -20
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +56 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +15 -9
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +70 -27
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +65 -19
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +89 -37
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +44 -34
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
- package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
- package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
- package/templates/apps/web/app/[locale]/layout.tsx +23 -2
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
- package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
- package/templates/apps/web/app/robots.txt/route.ts +13 -10
- package/templates/apps/web/auth.ts +20 -1
- package/templates/apps/web/jest.config.cjs +3 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
- package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
- package/templates/apps/web/lib/account-config.ts +31 -16
- package/templates/apps/web/lib/auth-email.ts +45 -10
- package/templates/apps/web/lib/consent-visitor.ts +103 -0
- package/templates/apps/web/lib/relay.ts +20 -0
- package/templates/apps/web/lib/topology.generated.ts +9 -0
- package/templates/apps/web/messages/en.json +121 -18
- package/templates/apps/web/next.config.mjs +8 -2
- package/templates/database/CHANGELOG.md +55 -0
- package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
- package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
- package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
- package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
- package/templates/database/__tests__/person-contact.db.test.ts +563 -0
- package/templates/database/__tests__/person-notes.db.test.ts +249 -0
- package/templates/database/__tests__/person.db.test.ts +3 -1
- package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
- package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
- package/templates/database/_gitignore +8 -0
- package/templates/database/inbox/schema.prisma +114 -22
- package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
- package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
- package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
- package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
- package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
- package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
- package/templates/database/ops/schema.prisma +16 -0
- package/templates/database/package.json +1 -1
- package/templates/project.yaml +6 -3
- package/versions.json +35 -35
- package/templates/.claude/rules/preserve-existing-style.md +0 -51
|
@@ -57,6 +57,8 @@ import { logger } from "@working-theory/observability";
|
|
|
57
57
|
import { isModuleEnabled } from "../config/modules.js";
|
|
58
58
|
import { prisma } from "../lib/prisma.js";
|
|
59
59
|
|
|
60
|
+
import { findOrCreatePerson, normalizeTelephone } from "./person.js";
|
|
61
|
+
|
|
60
62
|
// ─── The credential (spec D5) ──────────────────────────────────────────────
|
|
61
63
|
|
|
62
64
|
/**
|
|
@@ -580,6 +582,56 @@ export async function recordPaymentLink(
|
|
|
580
582
|
where: { id },
|
|
581
583
|
data: { stripePaymentLinkId: paymentLinkId },
|
|
582
584
|
});
|
|
585
|
+
// #8435 D5 — only now, with the Payment Link recorded: the Person step can
|
|
586
|
+
// never strand a spent token without its link (SEC-8435-G).
|
|
587
|
+
await linkConfirmedCallbackPerson(id);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Link a confirmed callback to its Person (#8435 D5): when the capture did not
|
|
592
|
+
* (`personId` null — no `payload.email`) and the tenant typed a buyer email on
|
|
593
|
+
* the confirm form, find or create that email's Person in the callback's own
|
|
594
|
+
* workspace and stamp it, guarded on `personId: null` so a link a capture
|
|
595
|
+
* already made is never replaced. Best-effort like every Person step: it never
|
|
596
|
+
* throws and never changes `recordPaymentLink`'s outcome. Logs the outcome
|
|
597
|
+
* kind only.
|
|
598
|
+
*/
|
|
599
|
+
async function linkConfirmedCallbackPerson(id: string): Promise<void> {
|
|
600
|
+
let outcome: "linked" | "skipped" | "failed";
|
|
601
|
+
try {
|
|
602
|
+
// fence: global-key — `Callback.id`, the row `recordPaymentLink` just
|
|
603
|
+
// wrote. The Person it names is found or created in THIS row's own
|
|
604
|
+
// `workspaceId`, so the link can only ever name a Person of that workspace.
|
|
605
|
+
const callback = await prisma.callback.findFirst({
|
|
606
|
+
where: { id },
|
|
607
|
+
select: { workspaceId: true, buyerEmail: true, name: true, phone: true, personId: true },
|
|
608
|
+
});
|
|
609
|
+
if (!callback || callback.personId !== null || callback.buyerEmail === null) {
|
|
610
|
+
outcome = "skipped";
|
|
611
|
+
} else {
|
|
612
|
+
const telephone = normalizeTelephone(callback.phone);
|
|
613
|
+
const person = await findOrCreatePerson(callback.workspaceId, {
|
|
614
|
+
email: callback.buyerEmail,
|
|
615
|
+
name: callback.name,
|
|
616
|
+
contact: telephone === null ? {} : { telephone },
|
|
617
|
+
});
|
|
618
|
+
if (!person.ok) {
|
|
619
|
+
outcome = person.error.kind === "invalid_email" ? "skipped" : "failed";
|
|
620
|
+
} else {
|
|
621
|
+
// fence: global-key — same `Callback.id`; `workspaceId` is the row's
|
|
622
|
+
// own, and `personId: null` keeps a capture's link.
|
|
623
|
+
await prisma.callback.updateMany({
|
|
624
|
+
where: { id, workspaceId: callback.workspaceId, personId: null },
|
|
625
|
+
data: { personId: person.value.personId },
|
|
626
|
+
});
|
|
627
|
+
outcome = "linked";
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
} catch {
|
|
631
|
+
outcome = "failed";
|
|
632
|
+
}
|
|
633
|
+
if (outcome === "failed") logger.error({ event: "callback-person", outcome }, "callback person failed");
|
|
634
|
+
else logger.info({ event: "callback-person", outcome }, "callback person");
|
|
583
635
|
}
|
|
584
636
|
|
|
585
637
|
/** What the buyer is told, once the tenant has confirmed. */
|
|
@@ -94,9 +94,18 @@ export interface RevalidateTarget {
|
|
|
94
94
|
|
|
95
95
|
export interface RevalidateOptions {
|
|
96
96
|
event: "publish" | "unpublish";
|
|
97
|
+
/**
|
|
98
|
+
* Waits out one backoff. A test seam (#8418): the publish routes never pass
|
|
99
|
+
* it, so production always waits on the real timer. A test passes one that
|
|
100
|
+
* records the delay and resolves at once, so the retry schedule is asserted
|
|
101
|
+
* as scheduled rather than measured against a loaded runner's clock.
|
|
102
|
+
*
|
|
103
|
+
* @internal
|
|
104
|
+
*/
|
|
105
|
+
sleep?: (ms: number) => Promise<void>;
|
|
97
106
|
}
|
|
98
107
|
|
|
99
|
-
function
|
|
108
|
+
function realSleep(ms: number): Promise<void> {
|
|
100
109
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
101
110
|
}
|
|
102
111
|
|
|
@@ -125,6 +134,7 @@ export async function notifyRevalidate(
|
|
|
125
134
|
locale: target.locale,
|
|
126
135
|
});
|
|
127
136
|
|
|
137
|
+
const { sleep = realSleep } = options;
|
|
128
138
|
const url = config.CONTENT_REVALIDATE_URL;
|
|
129
139
|
const secret = config.CONTENT_REVALIDATE_SECRET;
|
|
130
140
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 2,
|
|
3
|
-
"ignoreCommand": "[ -n \"$VERCEL_GIT_PREVIOUS_SHA\" ] && cd ../.. && git diff --quiet \"$VERCEL_GIT_PREVIOUS_SHA\" HEAD -- apps/api packages database pnpm-lock.yaml || exit 1",
|
|
3
|
+
"ignoreCommand": "[ -n \"$VERCEL_GIT_PREVIOUS_SHA\" ] && cd ../.. && git diff --quiet \"$VERCEL_GIT_PREVIOUS_SHA\" HEAD -- apps/api packages database pnpm-lock.yaml package.json pnpm-workspace.yaml turbo.json 'tsconfig*.json' || exit 1",
|
|
4
4
|
"buildCommand": "cd ../.. && pnpm run --filter @working-theory/api build:vercel",
|
|
5
5
|
"installCommand": "cd ../.. && pnpm install --frozen-lockfile",
|
|
6
6
|
"outputDirectory": "public",
|
|
@@ -83,7 +83,7 @@ Every tool maps 1:1 to an `apps/api` endpoint. When you change a tool, change th
|
|
|
83
83
|
|
|
84
84
|
- **20 in the `TOOLS` array / parity test**: the `TOOLS` array in
|
|
85
85
|
`packages/metadata/src/mcp-tool-catalog.ts` defines 20 anonymous tools; the
|
|
86
|
-
parity test counts that full array. It also holds the
|
|
86
|
+
parity test counts that full array. It also holds the fifteen signed-in
|
|
87
87
|
workspace tools (#3591), which no anonymous view returns — see § The
|
|
88
88
|
signed-in workspace surface below.
|
|
89
89
|
- **19 registered on a KEYLESS deployment, 20 on a commerce-claimed one**
|
|
@@ -126,16 +126,24 @@ here. Spec: `docs/specs/3591-tenant-scoped-agent-auth-secure-spec.md` D13–D16.
|
|
|
126
126
|
- **apps/mcp checks only the key's shape.** The key is attached as the SDK's `req.auth` for that
|
|
127
127
|
request alone and reaches a tool as `extra.authInfo.token`. apps/api is the one place it is
|
|
128
128
|
checked, and its tenant guard and the caller's clamped role decide every answer.
|
|
129
|
-
- **
|
|
129
|
+
- **Fifteen tools, each a `twin`.** The catalog rows carry `surface: "workspace"`, `tier: "pro"`
|
|
130
130
|
and `modulesAnyOf` (the modules any one of which registers the tool — its twin route's gate):
|
|
131
|
-
`get_agent_context`, the four message tools, the three submission tools
|
|
132
|
-
application tools
|
|
131
|
+
`get_agent_context`, the four message tools, the three submission tools, the three
|
|
132
|
+
application tools and (#8514) the two People notes tools, `get_person_notes` and
|
|
133
|
+
`set_person_notes` — the team's private note on one client, which an agent reads and writes
|
|
134
|
+
only with the `member-pieces-manage` grant; `set_person_notes` requires the `ifUpdatedAt` it
|
|
135
|
+
read and is refused `notes_changed` when someone saved since — and (#8516) the two proposals
|
|
136
|
+
tools, `list_proposals` and `create_proposal`, twinning `GET`/`POST
|
|
137
|
+
/v1/workspaces/:workspaceId/people/:personId/proposals` with the same grant: a proposal is
|
|
138
|
+
private to one client, `payments` says whether approval leads to a Stripe page, and an agent's
|
|
139
|
+
price is refused `proposal_needs_person` (a person prices). Each takes its route's params, query and body and returns the route's
|
|
133
140
|
`data`. They are in the parity guard like every other tool, and never in
|
|
134
141
|
`/.well-known/mcp.json`.
|
|
135
142
|
- **Handlers forward, nothing else.** `workspace-server.ts` resolves each tool to a paid-slice
|
|
136
143
|
handler file (`tools/agent_context.ts`, `inbox_messages.ts`, `inbox_submissions.ts`,
|
|
137
|
-
`inbox_applications.ts`, all `// oss: false`) through a
|
|
138
|
-
Each calls `@working-theory/api-client`'s `inbox` surface
|
|
144
|
+
`inbox_applications.ts`, `person_notes.ts`, `proposals.ts`, all `// oss: false`) through a
|
|
145
|
+
specifier table, as `server.ts` does. Each calls `@working-theory/api-client`'s `inbox` surface
|
|
146
|
+
(the notes and proposals tools, its `people` surface) with the key. A refusal from the
|
|
139
147
|
route is an MCP tool error naming the route's `error.code` and HTTP status only.
|
|
140
148
|
- **Never log or return the key.** Don't log or serialise `extra`, `extra.authInfo` or
|
|
141
149
|
`extra.requestInfo` — the SDK puts the raw `authorization` header there.
|
|
@@ -211,7 +219,11 @@ Example (lead routing):
|
|
|
211
219
|
- Don't add a tool without the matching `apps/api` endpoint.
|
|
212
220
|
- Don't put business logic in tool handlers — they're adapters.
|
|
213
221
|
|
|
214
|
-
Last updated: 2026-10-
|
|
222
|
+
Last updated: 2026-10-14 — #8516: the signed-in workspace surface is fifteen tools — `list_proposals` and `create_proposal` (`tools/proposals.ts`) twin `GET`/`POST /v1/workspaces/:workspaceId/people/:personId/proposals` — verified against `packages/metadata/src/mcp-tool-catalog.ts`, `apps/mcp/src/workspace-server.ts` and `tests/parity/api-parity/kinds/site.ts`.
|
|
223
|
+
|
|
224
|
+
Prior: 2026-10-13 — #8514: the signed-in workspace surface is thirteen tools — `get_person_notes` and `set_person_notes` (`tools/person_notes.ts`) twin `GET`/`PUT /v1/workspaces/:workspaceId/people/:personId/notes` — verified against `packages/metadata/src/mcp-tool-catalog.ts`, `apps/mcp/src/workspace-server.ts` and `tests/parity/api-parity/kinds/site.ts`.
|
|
225
|
+
|
|
226
|
+
Prior: 2026-10-10 — #8071: `create_booking` and `submit_flow_step` join the HTTP write branch — § The free anonymous tools names its six members, the Booking and Flow rows record it, and § What a spoke's MCP host on Vercel enforces lists the two new daily caps — verified against `apps/mcp/src/http-transport.ts` and `apps/mcp/src/abuse/index.ts`.
|
|
215
227
|
|
|
216
228
|
Prior: 2026-10-10 — #8070 (amendment 2): the Vercel entry exports a handler, no `vercel.json`; § Two entrypoints, one boot — the Vercel entry `src/app.ts`, the shared `src/boot.ts`, `MCP_HTTP_STATELESS`, `mcpContentRoot()`, `create_project` withheld on a spoke, and what a spoke's MCP host enforces — verified against `apps/mcp/src/{app,boot,serverless-app,index,http-transport,server}.ts` and `lib/content-adapter.ts`.
|
|
217
229
|
|
|
@@ -41,8 +41,29 @@ import {
|
|
|
41
41
|
startMcpClaimRefresh,
|
|
42
42
|
type McpCommerceClaim,
|
|
43
43
|
} from "../src/lib/commerce-claim.js";
|
|
44
|
+
import { UNSERVED_MCP_TOOLS } from "../src/lib/topology.generated.js";
|
|
44
45
|
import { createMcpServer, listRegisteredTools } from "../src/server.js";
|
|
45
46
|
|
|
47
|
+
// The topology seam's UNSERVED_MCP_TOOLS (#8070 D7), made switchable the way
|
|
48
|
+
// server.test.ts's AC-8b suite does: a getter returning an override when one
|
|
49
|
+
// is set, else the real module's value, so the 16-state loop below reads
|
|
50
|
+
// this scaffold's own seam ([] in the monorepo and a hub, ["create_project"]
|
|
51
|
+
// in a spoke) and the #8513 D2 describe can force either state regardless.
|
|
52
|
+
const mockSeam: { unserved: readonly string[] | undefined } = {
|
|
53
|
+
unserved: undefined,
|
|
54
|
+
};
|
|
55
|
+
// eslint-disable-next-line no-restricted-syntax -- the generated topology seam IS the boundary under test (#8513 D2); every other export passes through from the real module
|
|
56
|
+
jest.mock("../src/lib/topology.generated.js", () => {
|
|
57
|
+
const actual: { UNSERVED_MCP_TOOLS: readonly string[] } =
|
|
58
|
+
jest.requireActual("../src/lib/topology.generated.js");
|
|
59
|
+
const mocked = { ...actual };
|
|
60
|
+
Object.defineProperty(mocked, "UNSERVED_MCP_TOOLS", {
|
|
61
|
+
enumerable: true,
|
|
62
|
+
get: () => mockSeam.unserved ?? actual.UNSERVED_MCP_TOOLS,
|
|
63
|
+
});
|
|
64
|
+
return mocked;
|
|
65
|
+
});
|
|
66
|
+
|
|
46
67
|
/** The injected licence reader — same seam `commerce-claim.test.ts` types. */
|
|
47
68
|
type CheckLicenseSeam = NonNullable<
|
|
48
69
|
NonNullable<Parameters<typeof initMcpCommerceClaim>[0]>["checkLicense"]
|
|
@@ -125,6 +146,17 @@ function catalogOptionsFor(
|
|
|
125
146
|
};
|
|
126
147
|
}
|
|
127
148
|
|
|
149
|
+
/**
|
|
150
|
+
* The catalog this topology actually serves — the full catalog minus the
|
|
151
|
+
* seam's own UNSERVED_MCP_TOOLS, read at use (never destructured at module
|
|
152
|
+
* load) so a mocked override above is seen (#8513 D2, C3).
|
|
153
|
+
*/
|
|
154
|
+
function servedCatalog(options: McpCatalogOptions): McpToolCatalogEntry[] {
|
|
155
|
+
return getMcpToolCatalog(options).filter(
|
|
156
|
+
(t) => !UNSERVED_MCP_TOOLS.includes(t.name),
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
128
160
|
describe.each(CLAIM_STATES)(
|
|
129
161
|
"MCP catalog ↔ server drift — commerce %s, mode %s, held %j",
|
|
130
162
|
(claim, mode, held) => {
|
|
@@ -150,18 +182,18 @@ describe.each(CLAIM_STATES)(
|
|
|
150
182
|
}
|
|
151
183
|
});
|
|
152
184
|
|
|
153
|
-
it("registered tool names equal catalog
|
|
185
|
+
it("registered tool names equal the catalog minus this topology's unserved tools", () => {
|
|
154
186
|
const registered = registeredToolsOf(createMcpServer());
|
|
155
187
|
const registeredNames = Object.keys(registered).sort();
|
|
156
|
-
const catalogNames =
|
|
188
|
+
const catalogNames = servedCatalog(options)
|
|
157
189
|
.map((t) => t.name)
|
|
158
190
|
.sort();
|
|
159
191
|
expect(registeredNames).toEqual(catalogNames);
|
|
160
192
|
});
|
|
161
193
|
|
|
162
|
-
it("registered tool descriptions equal catalog descriptions", () => {
|
|
194
|
+
it("registered tool descriptions equal the served catalog's descriptions", () => {
|
|
163
195
|
const registered = registeredToolsOf(createMcpServer());
|
|
164
|
-
for (const entry of
|
|
196
|
+
for (const entry of servedCatalog(options)) {
|
|
165
197
|
expect(registered[entry.name]).toBeDefined();
|
|
166
198
|
expect(registered[entry.name]!.description).toBe(entry.description);
|
|
167
199
|
}
|
|
@@ -193,6 +225,57 @@ describe.each(CLAIM_STATES)(
|
|
|
193
225
|
},
|
|
194
226
|
);
|
|
195
227
|
|
|
228
|
+
describe("MCP catalog ↔ server drift — UNSERVED_MCP_TOOLS subtracts from the served catalog (#8513 D2, AC-2)", () => {
|
|
229
|
+
const priorEnabledModules = process.env.AILK_ENABLED_MODULES;
|
|
230
|
+
|
|
231
|
+
beforeEach(() => {
|
|
232
|
+
process.env.AILK_ENABLED_MODULES = PAID_MODULES;
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
afterEach(() => {
|
|
236
|
+
mockSeam.unserved = undefined;
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
afterAll(() => {
|
|
240
|
+
if (priorEnabledModules === undefined) {
|
|
241
|
+
delete process.env.AILK_ENABLED_MODULES;
|
|
242
|
+
} else {
|
|
243
|
+
process.env.AILK_ENABLED_MODULES = priorEnabledModules;
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("a spoke's seam (['create_project']) withholds it from registration and the served catalog", () => {
|
|
248
|
+
mockSeam.unserved = ["create_project"];
|
|
249
|
+
const options = catalogOptionsFor("unclaimed", "warn", []);
|
|
250
|
+
|
|
251
|
+
const registeredNames = Object.keys(
|
|
252
|
+
registeredToolsOf(createMcpServer()),
|
|
253
|
+
).sort();
|
|
254
|
+
const servedNames = servedCatalog(options)
|
|
255
|
+
.map((t) => t.name)
|
|
256
|
+
.sort();
|
|
257
|
+
|
|
258
|
+
expect(registeredNames).toEqual(servedNames);
|
|
259
|
+
expect(registeredNames).not.toContain("create_project");
|
|
260
|
+
expect(servedNames).not.toContain("create_project");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("a hub's seam ([]) registers the full catalog, create_project included", () => {
|
|
264
|
+
mockSeam.unserved = [];
|
|
265
|
+
const options = catalogOptionsFor("unclaimed", "warn", []);
|
|
266
|
+
|
|
267
|
+
const registeredNames = Object.keys(
|
|
268
|
+
registeredToolsOf(createMcpServer()),
|
|
269
|
+
).sort();
|
|
270
|
+
const fullCatalogNames = getMcpToolCatalog(options)
|
|
271
|
+
.map((t) => t.name)
|
|
272
|
+
.sort();
|
|
273
|
+
|
|
274
|
+
expect(registeredNames).toEqual(fullCatalogNames);
|
|
275
|
+
expect(registeredNames).toContain("create_project");
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
196
279
|
describe("MCP catalog ↔ server agreement after a bounded re-read (#7156 AC-1)", () => {
|
|
197
280
|
const priorEnabledModules = process.env.AILK_ENABLED_MODULES;
|
|
198
281
|
|
|
@@ -227,11 +310,11 @@ describe("MCP catalog ↔ server agreement after a bounded re-read (#7156 AC-1)"
|
|
|
227
310
|
};
|
|
228
311
|
const bootNames = Object.keys(registeredToolsOf(createMcpServer())).sort();
|
|
229
312
|
expect(bootNames).toEqual(
|
|
230
|
-
|
|
313
|
+
servedCatalog(bootOptions)
|
|
231
314
|
.map((t) => t.name)
|
|
232
315
|
.sort(),
|
|
233
316
|
);
|
|
234
|
-
expect(bootNames).toHaveLength(20);
|
|
317
|
+
expect(bootNames).toHaveLength(20 - UNSERVED_MCP_TOOLS.length);
|
|
235
318
|
|
|
236
319
|
startMcpClaimRefresh({ checkLicense: resolving({ features: [] }) });
|
|
237
320
|
await jest.advanceTimersByTimeAsync(MCP_CLAIM_REFRESH_MS);
|
|
@@ -246,14 +329,18 @@ describe("MCP catalog ↔ server agreement after a bounded re-read (#7156 AC-1)"
|
|
|
246
329
|
.sort();
|
|
247
330
|
expect(catalogNames).toHaveLength(14);
|
|
248
331
|
|
|
332
|
+
const servedNames = servedCatalog(refreshedOptions)
|
|
333
|
+
.map((t) => t.name)
|
|
334
|
+
.sort();
|
|
335
|
+
|
|
249
336
|
const serverNames = Object.keys(
|
|
250
337
|
registeredToolsOf(createMcpServer()),
|
|
251
338
|
).sort();
|
|
252
|
-
expect(serverNames).toEqual(
|
|
339
|
+
expect(serverNames).toEqual(servedNames);
|
|
253
340
|
|
|
254
341
|
const listedNames = listRegisteredTools()
|
|
255
342
|
.map((t) => t.name)
|
|
256
343
|
.sort();
|
|
257
|
-
expect(listedNames).toEqual(
|
|
344
|
+
expect(listedNames).toEqual(servedNames);
|
|
258
345
|
});
|
|
259
346
|
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// oss: false
|
|
2
|
+
/**
|
|
3
|
+
* The signed-in workspace surface's People notes tools (#8514 D8) — the twins
|
|
4
|
+
* of `apps/api/src/routes/workspaces/people.ts`'s notes pair:
|
|
5
|
+
*
|
|
6
|
+
* get_person_notes GET /v1/workspaces/:workspaceId/people/:personId/notes
|
|
7
|
+
* set_person_notes PUT /v1/workspaces/:workspaceId/people/:personId/notes
|
|
8
|
+
*
|
|
9
|
+
* Each takes the route's params and body (the SDK has already parsed them
|
|
10
|
+
* against the catalog's input shape, which is the route's own schemas),
|
|
11
|
+
* forwards the caller's agent key through `@working-theory/api-client`, and
|
|
12
|
+
* returns the route's answer untouched. apps/api checks the key, the grant and
|
|
13
|
+
* the workspace; this file only carries the key, and never logs or returns it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { people, type PersonRef } from "@working-theory/api-client";
|
|
17
|
+
import type { PersonNotesPutInput } from "@working-theory/validation";
|
|
18
|
+
|
|
19
|
+
export function ailkGetPersonNotes(key: string, input: PersonRef) {
|
|
20
|
+
return people.notes(key, input);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function ailkSetPersonNotes(key: string, input: PersonRef & PersonNotesPutInput) {
|
|
24
|
+
return people.setNotes(key, input);
|
|
25
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// oss: false
|
|
2
|
+
/**
|
|
3
|
+
* The signed-in workspace surface's proposals tools (#8516 D7) — the twins of
|
|
4
|
+
* `apps/api/src/routes/workspaces/pieces.ts`'s proposals pair:
|
|
5
|
+
*
|
|
6
|
+
* list_proposals GET /v1/workspaces/:workspaceId/people/:personId/proposals
|
|
7
|
+
* create_proposal POST /v1/workspaces/:workspaceId/people/:personId/proposals
|
|
8
|
+
*
|
|
9
|
+
* Each takes the route's params and body (the SDK has already parsed them
|
|
10
|
+
* against the catalog's input shape, which is the route's own schemas),
|
|
11
|
+
* forwards the caller's agent key through `@working-theory/api-client`, and
|
|
12
|
+
* returns the route's answer untouched. apps/api checks the key, the grant,
|
|
13
|
+
* the workspace and the money gate (an agent's price is `403
|
|
14
|
+
* proposal_needs_person`); this file only carries the key, and never logs or
|
|
15
|
+
* returns it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { people, type PersonRef } from "@working-theory/api-client";
|
|
19
|
+
import type { ProposalCreateBody } from "@working-theory/validation";
|
|
20
|
+
|
|
21
|
+
export function ailkListProposals(key: string, input: PersonRef) {
|
|
22
|
+
return people.proposals(key, input);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function ailkCreateProposal(key: string, input: PersonRef & ProposalCreateBody) {
|
|
26
|
+
return people.createProposal(key, input);
|
|
27
|
+
}
|
|
@@ -95,6 +95,22 @@ const WORKSPACE_TOOL_ENTRIES: Record<
|
|
|
95
95
|
specifier: "./tools/inbox_applications.js",
|
|
96
96
|
exportName: "ailkDecideInboxApplication",
|
|
97
97
|
},
|
|
98
|
+
get_person_notes: {
|
|
99
|
+
specifier: "./tools/person_notes.js",
|
|
100
|
+
exportName: "ailkGetPersonNotes",
|
|
101
|
+
},
|
|
102
|
+
set_person_notes: {
|
|
103
|
+
specifier: "./tools/person_notes.js",
|
|
104
|
+
exportName: "ailkSetPersonNotes",
|
|
105
|
+
},
|
|
106
|
+
list_proposals: {
|
|
107
|
+
specifier: "./tools/proposals.js",
|
|
108
|
+
exportName: "ailkListProposals",
|
|
109
|
+
},
|
|
110
|
+
create_proposal: {
|
|
111
|
+
specifier: "./tools/proposals.js",
|
|
112
|
+
exportName: "ailkCreateProposal",
|
|
113
|
+
},
|
|
98
114
|
};
|
|
99
115
|
|
|
100
116
|
async function resolveWorkspaceHandler(
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for SignUpCard's sign-up agreements (#8438 D13, AC-3).
|
|
3
|
+
*
|
|
4
|
+
* Every submit — the password form and each OAuth provider — is gated by
|
|
5
|
+
* the site's `signUp` agreements, rendered as a `ConsentGroup` below
|
|
6
|
+
* `AuthCard`. Nothing happens on any entry point until every required
|
|
7
|
+
* agreement is ticked. The password path also mints the carried-agreement
|
|
8
|
+
* cookie (`rememberSignUpAgreementsAction`) before calling Better Auth.
|
|
9
|
+
*/
|
|
10
|
+
import { render, screen } from "@testing-library/react";
|
|
11
|
+
import userEvent from "@testing-library/user-event";
|
|
12
|
+
|
|
13
|
+
const mockSignUpEmail = jest.fn();
|
|
14
|
+
const mockSignInSocial = jest.fn();
|
|
15
|
+
jest.mock("better-auth/react", () => ({
|
|
16
|
+
__esModule: true,
|
|
17
|
+
createAuthClient: () => ({
|
|
18
|
+
signUp: { email: (...args: unknown[]) => mockSignUpEmail(...args) },
|
|
19
|
+
signIn: { social: (...args: unknown[]) => mockSignInSocial(...args) },
|
|
20
|
+
}),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
const mockRememberAgreements = jest.fn();
|
|
24
|
+
// eslint-disable-next-line no-restricted-syntax -- test-seam mock: the Server Action module is a "use server" boundary; the card only passes it through.
|
|
25
|
+
jest.mock("../sign-up.actions", () => ({
|
|
26
|
+
__esModule: true,
|
|
27
|
+
rememberSignUpAgreementsAction: (...args: unknown[]) => mockRememberAgreements(...args),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
import { SignUpCard, type SignUpCardProps } from "../sign-up-card";
|
|
31
|
+
|
|
32
|
+
const AGREEMENT = {
|
|
33
|
+
id: "account_terms",
|
|
34
|
+
required: true,
|
|
35
|
+
statement: "You agree to our Terms and Privacy Policy.",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function props(overrides: Partial<SignUpCardProps> = {}): SignUpCardProps {
|
|
39
|
+
return {
|
|
40
|
+
providers: ["google"],
|
|
41
|
+
method: "password",
|
|
42
|
+
next: "/account",
|
|
43
|
+
signInHref: "/sign-in",
|
|
44
|
+
labels: {
|
|
45
|
+
signUpHeading: "Create your account",
|
|
46
|
+
signUpSubhead: "",
|
|
47
|
+
signUpSubmit: "Sign up",
|
|
48
|
+
signUpFooterPrompt: "Already have an account?",
|
|
49
|
+
signUpFooterLink: "Sign in",
|
|
50
|
+
ssoPrefix: "Continue with",
|
|
51
|
+
google: "Google",
|
|
52
|
+
github: "GitHub",
|
|
53
|
+
email: "Email",
|
|
54
|
+
password: "Password",
|
|
55
|
+
orContinue: "or continue with",
|
|
56
|
+
},
|
|
57
|
+
notConfiguredSubhead: "Sign-up is not configured.",
|
|
58
|
+
errorLabels: { signUpFailed: "Sign-up failed.", providerFailed: "Provider failed." },
|
|
59
|
+
agreements: [AGREEMENT],
|
|
60
|
+
agreementPolicies: { terms: "/terms", privacy: "/privacy" },
|
|
61
|
+
agreementsLegend: "Agreements",
|
|
62
|
+
...overrides,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function fillAndSubmit(): Promise<void> {
|
|
67
|
+
const user = userEvent.setup();
|
|
68
|
+
await user.type(screen.getByTestId("auth-email"), "ada@example.com");
|
|
69
|
+
await user.type(screen.getByTestId("auth-password"), "correct horse battery staple");
|
|
70
|
+
await user.click(screen.getByTestId("auth-submit"));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function tick(purposeId: string): Promise<void> {
|
|
74
|
+
const user = userEvent.setup();
|
|
75
|
+
await user.click(screen.getByTestId(`consent-${purposeId}`));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
mockSignUpEmail.mockReset().mockResolvedValue({ data: { status: true }, error: null });
|
|
80
|
+
mockSignInSocial.mockReset().mockResolvedValue({ data: { status: true }, error: null });
|
|
81
|
+
mockRememberAgreements.mockReset().mockResolvedValue(undefined);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("the password submit is gated (AC-3)", () => {
|
|
85
|
+
it("renders the agreement, unticked, and the card marked disabled", () => {
|
|
86
|
+
render(<SignUpCard {...props()} />);
|
|
87
|
+
expect(screen.getByTestId("consent-account_terms")).toHaveAttribute("aria-checked", "false");
|
|
88
|
+
expect(screen.getByTestId("sign-up-card-gate")).toHaveAttribute("aria-disabled", "true");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("does nothing on submit while the agreement is unticked", async () => {
|
|
92
|
+
render(<SignUpCard {...props()} />);
|
|
93
|
+
await fillAndSubmit();
|
|
94
|
+
expect(mockSignUpEmail).not.toHaveBeenCalled();
|
|
95
|
+
expect(mockRememberAgreements).not.toHaveBeenCalled();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("mints the carried-agreement cookie, then signs up, once every required agreement is ticked", async () => {
|
|
99
|
+
render(<SignUpCard {...props()} />);
|
|
100
|
+
await tick("account_terms");
|
|
101
|
+
expect(screen.getByTestId("sign-up-card-gate")).toHaveAttribute("aria-disabled", "false");
|
|
102
|
+
await fillAndSubmit();
|
|
103
|
+
expect(mockRememberAgreements).toHaveBeenCalledWith("ada@example.com", ["account_terms"]);
|
|
104
|
+
expect(mockSignUpEmail).toHaveBeenCalledWith({
|
|
105
|
+
email: "ada@example.com",
|
|
106
|
+
password: "correct horse battery staple",
|
|
107
|
+
name: "ada@example.com",
|
|
108
|
+
callbackURL: "/account",
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("re-disables after unticking", async () => {
|
|
113
|
+
render(<SignUpCard {...props()} />);
|
|
114
|
+
await tick("account_terms");
|
|
115
|
+
await tick("account_terms");
|
|
116
|
+
expect(screen.getByTestId("sign-up-card-gate")).toHaveAttribute("aria-disabled", "true");
|
|
117
|
+
await fillAndSubmit();
|
|
118
|
+
expect(mockSignUpEmail).not.toHaveBeenCalled();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("with two required agreements, both must be ticked", async () => {
|
|
122
|
+
render(
|
|
123
|
+
<SignUpCard
|
|
124
|
+
{...props({
|
|
125
|
+
agreements: [
|
|
126
|
+
AGREEMENT,
|
|
127
|
+
{ id: "service_terms", required: true, statement: "You agree to being contacted." },
|
|
128
|
+
],
|
|
129
|
+
})}
|
|
130
|
+
/>,
|
|
131
|
+
);
|
|
132
|
+
await tick("account_terms");
|
|
133
|
+
await fillAndSubmit();
|
|
134
|
+
expect(mockSignUpEmail).not.toHaveBeenCalled();
|
|
135
|
+
await tick("service_terms");
|
|
136
|
+
await fillAndSubmit();
|
|
137
|
+
expect(mockSignUpEmail).toHaveBeenCalledTimes(1);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("a failed cookie mint still submits — convenience only", async () => {
|
|
141
|
+
mockRememberAgreements.mockRejectedValue(new Error("no secret"));
|
|
142
|
+
render(<SignUpCard {...props()} />);
|
|
143
|
+
await tick("account_terms");
|
|
144
|
+
await fillAndSubmit();
|
|
145
|
+
expect(mockSignUpEmail).toHaveBeenCalledTimes(1);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("provider buttons are gated too (AC-3)", () => {
|
|
150
|
+
it("a click on Google does nothing while unticked", async () => {
|
|
151
|
+
render(<SignUpCard {...props({ method: "sso", providers: ["google"] })} />);
|
|
152
|
+
const user = userEvent.setup();
|
|
153
|
+
await user.click(screen.getByTestId("auth-sso-google"));
|
|
154
|
+
expect(mockSignInSocial).not.toHaveBeenCalled();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("launches the provider once ticked, with no cookie mint", async () => {
|
|
158
|
+
render(<SignUpCard {...props({ method: "sso", providers: ["google"] })} />);
|
|
159
|
+
await tick("account_terms");
|
|
160
|
+
const user = userEvent.setup();
|
|
161
|
+
await user.click(screen.getByTestId("auth-sso-google"));
|
|
162
|
+
expect(mockSignInSocial).toHaveBeenCalledWith({ provider: "google", callbackURL: "/account" });
|
|
163
|
+
expect(mockRememberAgreements).not.toHaveBeenCalled();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -15,6 +15,11 @@
|
|
|
15
15
|
|
|
16
16
|
import { localeHref } from "@working-theory/ui";
|
|
17
17
|
import { AuthCard, type AuthCardLabels } from "@working-theory/ui/blocks";
|
|
18
|
+
import {
|
|
19
|
+
resolveConsentStatement,
|
|
20
|
+
SITE_CONSENT_CONFIG_DEFAULTS,
|
|
21
|
+
signUpAgreements,
|
|
22
|
+
} from "@working-theory/validation";
|
|
18
23
|
import type { Metadata } from "next";
|
|
19
24
|
import { getTranslations } from "next-intl/server";
|
|
20
25
|
import type { ReactElement } from "react";
|
|
@@ -79,6 +84,17 @@ export default async function SignUpPage({
|
|
|
79
84
|
orContinue: t("orContinue"),
|
|
80
85
|
};
|
|
81
86
|
|
|
87
|
+
// #8438 D13 — the site's sign-up agreements, resolved to this locale. No
|
|
88
|
+
// per-site registry row is wired yet (C1), so this always reads the
|
|
89
|
+
// platform default — the same registry the member Privacy tab and the
|
|
90
|
+
// account-settings write both read from.
|
|
91
|
+
const config = SITE_CONSENT_CONFIG_DEFAULTS;
|
|
92
|
+
const agreements = signUpAgreements(config).map((purpose) => ({
|
|
93
|
+
id: purpose.id,
|
|
94
|
+
required: purpose.required,
|
|
95
|
+
statement: resolveConsentStatement(purpose, locale).statement,
|
|
96
|
+
}));
|
|
97
|
+
|
|
82
98
|
return (
|
|
83
99
|
<SignUpCard
|
|
84
100
|
providers={providers}
|
|
@@ -91,6 +107,9 @@ export default async function SignUpPage({
|
|
|
91
107
|
signUpFailed: t("signUpFailed"),
|
|
92
108
|
providerFailed: t("providerFailed"),
|
|
93
109
|
}}
|
|
110
|
+
agreements={agreements}
|
|
111
|
+
agreementPolicies={config.policies}
|
|
112
|
+
agreementsLegend={t("signUpAgreementsLegend")}
|
|
94
113
|
/>
|
|
95
114
|
);
|
|
96
115
|
}
|