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,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A magnet's completion setting — its data boundary (#8757, spec
|
|
3
|
+
* docs/specs/8757-a-per-magnet-completion-setting-spec.md D3).
|
|
4
|
+
*
|
|
5
|
+
* `flow_definitions.completion` / `checkout_offering` are new columns, and
|
|
6
|
+
* `lib/tenant-db.ts` — the magnet routes' only other data path — stays closed
|
|
7
|
+
* under the parallel contract with #8746 (D7), so the two columns get this
|
|
8
|
+
* named boundary of their own. Every statement is scoped by a server-resolved
|
|
9
|
+
* `workspaceId` (a tenant's, or the public lead route's resolved workspace):
|
|
10
|
+
*
|
|
11
|
+
* - `completionsFor` — one `findMany` by workspace AND id list;
|
|
12
|
+
* - `setCompletion` — one `updateMany` by id AND workspace, writing
|
|
13
|
+
* exactly the two columns;
|
|
14
|
+
* - `liveCompletionBySlug` — one `findFirst` of a LIVE magnet by workspace
|
|
15
|
+
* AND slug (its pair, title and config — the
|
|
16
|
+
* config for a declared `completionEmail`);
|
|
17
|
+
* - `inviteSendCounts` — two `lead.count`s for the invite email's caps
|
|
18
|
+
* (D6), by workspace AND `flow:<slug>` source.
|
|
19
|
+
*
|
|
20
|
+
* No list without an id set, no other model, no read by any other key.
|
|
21
|
+
* Base tree (SEC-8757-D): no `oss: false` marker and no `MODULE_SLICES`
|
|
22
|
+
* entry, because the never-gate lead route imports it. `architecture.yaml`
|
|
23
|
+
* lists this file under the tenant-scope rule's `paths_scoped` with exactly
|
|
24
|
+
* these four call sites' counts, so a further access fails the lint.
|
|
25
|
+
*/
|
|
26
|
+
import {
|
|
27
|
+
FLOW_COMPLETION_DEFAULT,
|
|
28
|
+
flowCompletionSettingSchema,
|
|
29
|
+
type FlowCompletionSetting,
|
|
30
|
+
} from "@working-theory/validation";
|
|
31
|
+
|
|
32
|
+
import { prisma } from "./prisma.js";
|
|
33
|
+
|
|
34
|
+
const DEFAULT_SETTING: FlowCompletionSetting = {
|
|
35
|
+
completion: FLOW_COMPLETION_DEFAULT,
|
|
36
|
+
checkoutOffering: null,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A stored pair as the vocabulary reads it. The database's CHECKs make every
|
|
41
|
+
* stored pair valid; a row that carries neither column (a test fixture, or a
|
|
42
|
+
* read before the migration) reads as the default, as `customersEnabled` does.
|
|
43
|
+
*/
|
|
44
|
+
function toSetting(row: { completion?: unknown; checkoutOffering?: unknown }) {
|
|
45
|
+
const parsed = flowCompletionSettingSchema.safeParse({
|
|
46
|
+
completion: row.completion,
|
|
47
|
+
checkoutOffering: row.checkoutOffering ?? null,
|
|
48
|
+
});
|
|
49
|
+
return parsed.success ? parsed.data : DEFAULT_SETTING;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The setting of each of `ids` in `workspaceId`. An id absent from the map reads as `lead`. */
|
|
53
|
+
export async function completionsFor(
|
|
54
|
+
workspaceId: string,
|
|
55
|
+
ids: readonly string[],
|
|
56
|
+
): Promise<Map<string, FlowCompletionSetting>> {
|
|
57
|
+
const out = new Map<string, FlowCompletionSetting>();
|
|
58
|
+
if (ids.length === 0) return out;
|
|
59
|
+
const rows = await prisma.flowDefinition.findMany({
|
|
60
|
+
where: { workspaceId, id: { in: [...ids] } },
|
|
61
|
+
select: { id: true, completion: true, checkoutOffering: true },
|
|
62
|
+
});
|
|
63
|
+
for (const row of rows) out.set(row.id, toSetting(row));
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** `completionsFor`'s answer for one id. */
|
|
68
|
+
export function completionOf(
|
|
69
|
+
settings: Map<string, FlowCompletionSetting>,
|
|
70
|
+
id: string,
|
|
71
|
+
): FlowCompletionSetting {
|
|
72
|
+
return settings.get(id) ?? DEFAULT_SETTING;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Write the pair onto magnet `id` of `workspaceId`; nothing else on the row. */
|
|
76
|
+
export async function setCompletion(
|
|
77
|
+
workspaceId: string,
|
|
78
|
+
id: string,
|
|
79
|
+
setting: FlowCompletionSetting,
|
|
80
|
+
): Promise<"set" | "not_found"> {
|
|
81
|
+
const result = await prisma.flowDefinition.updateMany({
|
|
82
|
+
where: { id, workspaceId },
|
|
83
|
+
data: { completion: setting.completion, checkoutOffering: setting.checkoutOffering },
|
|
84
|
+
});
|
|
85
|
+
return result.count > 0 ? "set" : "not_found";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type LiveCompletion = FlowCompletionSetting & {
|
|
89
|
+
title: string;
|
|
90
|
+
/** The stored config, unparsed — the invite email reads its `completionEmail`. */
|
|
91
|
+
config: unknown;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** The LIVE magnet at `slug` in `workspaceId`: its setting, title and config, or `null`. */
|
|
95
|
+
export async function liveCompletionBySlug(
|
|
96
|
+
workspaceId: string,
|
|
97
|
+
slug: string,
|
|
98
|
+
): Promise<LiveCompletion | null> {
|
|
99
|
+
const row = await prisma.flowDefinition.findFirst({
|
|
100
|
+
where: { workspaceId, slug, status: "live" },
|
|
101
|
+
select: { title: true, config: true, completion: true, checkoutOffering: true },
|
|
102
|
+
});
|
|
103
|
+
return row === null ? null : { ...toSetting(row), title: row.title, config: row.config };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
107
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* D6's two caps, counted in the database so they hold across instances and
|
|
111
|
+
* against a spoofed `x-forwarded-for`: this magnet's leads for `email`
|
|
112
|
+
* (case-insensitively) over the last day, and all of its leads over the last
|
|
113
|
+
* hour. Both include the lead the caller just wrote.
|
|
114
|
+
*/
|
|
115
|
+
export async function inviteSendCounts(
|
|
116
|
+
workspaceId: string,
|
|
117
|
+
slug: string,
|
|
118
|
+
email: string,
|
|
119
|
+
now: Date,
|
|
120
|
+
): Promise<{ recipientDay: number; siteHour: number }> {
|
|
121
|
+
const source = `flow:${slug}`;
|
|
122
|
+
const [recipientDay, siteHour] = await Promise.all([
|
|
123
|
+
prisma.lead.count({
|
|
124
|
+
where: {
|
|
125
|
+
workspaceId,
|
|
126
|
+
source,
|
|
127
|
+
email: { equals: email, mode: "insensitive" },
|
|
128
|
+
createdAt: { gte: new Date(now.getTime() - DAY_MS) },
|
|
129
|
+
},
|
|
130
|
+
}),
|
|
131
|
+
prisma.lead.count({
|
|
132
|
+
where: { workspaceId, source, createdAt: { gte: new Date(now.getTime() - HOUR_MS) } },
|
|
133
|
+
}),
|
|
134
|
+
]);
|
|
135
|
+
return { recipientDay, siteHour };
|
|
136
|
+
}
|
|
@@ -102,6 +102,38 @@ export async function findClientByClientId(
|
|
|
102
102
|
});
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/** What the hub's sign-in page may show about a spoke (#8790 D2). */
|
|
106
|
+
export interface SignInClientInfo {
|
|
107
|
+
clientId: string;
|
|
108
|
+
/** The spoke's display name from the registry. */
|
|
109
|
+
name: string;
|
|
110
|
+
/** The origin of the client's first registered redirect URI. */
|
|
111
|
+
origin: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The public shape of one spoke's sign-in client, or `null` when the client
|
|
116
|
+
* is missing, disabled, or its spoke is revoked (#8790 D2). Only the
|
|
117
|
+
* registry decides what a sign-in page may name — never a query parameter.
|
|
118
|
+
*/
|
|
119
|
+
export async function findSignInClientInfo(
|
|
120
|
+
clientId: string,
|
|
121
|
+
): Promise<SignInClientInfo | null> {
|
|
122
|
+
const client = await findClientByClientId(clientId);
|
|
123
|
+
if (client === null || !client.enabled) return null;
|
|
124
|
+
const spoke = await findSpokeById(clientId);
|
|
125
|
+
if (spoke === null || spoke.revokedAt !== null) return null;
|
|
126
|
+
const first = client.redirectUris[0];
|
|
127
|
+
if (first === undefined) return null;
|
|
128
|
+
let origin: string;
|
|
129
|
+
try {
|
|
130
|
+
origin = new URL(first).origin;
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
return { clientId, name: spoke.name, origin };
|
|
135
|
+
}
|
|
136
|
+
|
|
105
137
|
/**
|
|
106
138
|
* Create or update a spoke's sign-in client (the operator CLI). Re-running
|
|
107
139
|
* rotates the secret: `clientSecret` is replaced by the new digest, so the
|
|
@@ -161,7 +193,10 @@ export function canonicalRedirectUri(raw: string): string | null {
|
|
|
161
193
|
|
|
162
194
|
export type RegisterSignInClientResult =
|
|
163
195
|
| { ok: true; spokeId: string; secret: string }
|
|
164
|
-
| {
|
|
196
|
+
| {
|
|
197
|
+
ok: false;
|
|
198
|
+
reason: "invalid-redirect-uri" | "spoke-not-found" | "spoke-revoked";
|
|
199
|
+
};
|
|
165
200
|
|
|
166
201
|
/**
|
|
167
202
|
* The operator CLI's `sign-in-client` (D3): refuse an invalid callback, an
|
|
@@ -173,7 +208,8 @@ export async function registerSignInClient(params: {
|
|
|
173
208
|
redirectUri: string;
|
|
174
209
|
}): Promise<RegisterSignInClientResult> {
|
|
175
210
|
const redirectUri = canonicalRedirectUri(params.redirectUri);
|
|
176
|
-
if (redirectUri === null)
|
|
211
|
+
if (redirectUri === null)
|
|
212
|
+
return { ok: false, reason: "invalid-redirect-uri" };
|
|
177
213
|
|
|
178
214
|
const spoke = await findSpokeById(params.spokeId);
|
|
179
215
|
if (!spoke) return { ok: false, reason: "spoke-not-found" };
|
|
@@ -8,13 +8,15 @@
|
|
|
8
8
|
* every scaffold without the member slice (the #5677 failure). Its one piece
|
|
9
9
|
* write therefore lives here, in the base tree, importing only `prisma.ts`.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
11
|
+
* Three statements and no others, all scoped by a workspace id AND a piece id
|
|
12
12
|
* taken from a Stripe-signed `checkout.session.completed` event's metadata —
|
|
13
13
|
* written by this server when it opened the session (`ailk_proposal`,
|
|
14
14
|
* `ailk_workspace`; `services/proposal-payments.ts`), never settable by the
|
|
15
|
-
* payer: a guarded `updateMany` that stamps an unpaid proposal paid,
|
|
16
|
-
* `findFirst` by the same pair reading back which session paid it
|
|
17
|
-
*
|
|
15
|
+
* payer: a guarded `updateMany` that stamps an unpaid proposal paid, a
|
|
16
|
+
* `findFirst` by the same pair reading back which session paid it, and a
|
|
17
|
+
* `findFirst` by the same pair reading the proposal's Person — the subject of
|
|
18
|
+
* the checkout's terms grant (#8765 D14). No list, no other model, no read by
|
|
19
|
+
* any other key; never called by a route.
|
|
18
20
|
* `architecture.yaml` names this file as a tenant-scope exclusion with that
|
|
19
21
|
* reason.
|
|
20
22
|
*/
|
|
@@ -47,3 +49,18 @@ export async function stampProposalPaid(
|
|
|
47
49
|
if (row === null) return "not_found";
|
|
48
50
|
return row.paidSessionId === sessionId ? "replay" : "already_paid";
|
|
49
51
|
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The proposal's Person (#8765 D14), or `null` when no proposal of that id
|
|
55
|
+
* is in that workspace.
|
|
56
|
+
*/
|
|
57
|
+
export async function proposalPersonId(
|
|
58
|
+
workspaceId: string,
|
|
59
|
+
pieceId: string,
|
|
60
|
+
): Promise<string | null> {
|
|
61
|
+
const row = await prisma.piece.findFirst({
|
|
62
|
+
where: { id: pieceId, workspaceId, kind: "proposal" },
|
|
63
|
+
select: { personId: true },
|
|
64
|
+
});
|
|
65
|
+
return row?.personId ?? null;
|
|
66
|
+
}
|
|
@@ -53,7 +53,11 @@ let spokeA: SeededSpoke;
|
|
|
53
53
|
let spokeB: SeededSpoke;
|
|
54
54
|
let handlerRuns: string[];
|
|
55
55
|
|
|
56
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* D1's 18 strings plus #8435's two contact routes, #8438's two consents
|
|
58
|
+
* routes, #8765's account-agreements pair and #8515's onboarding pair (26),
|
|
59
|
+
* in the list's order.
|
|
60
|
+
*/
|
|
57
61
|
const ADMITTED = [
|
|
58
62
|
"GET /v1/hub-sign-in/me",
|
|
59
63
|
"GET /v1/member/pieces",
|
|
@@ -77,6 +81,9 @@ const ADMITTED = [
|
|
|
77
81
|
"POST /v1/member/deletion-request",
|
|
78
82
|
"GET /v1/member/consents",
|
|
79
83
|
"POST /v1/member/consents",
|
|
84
|
+
// #8765 D5 — the caller's account-level sign-up agreements.
|
|
85
|
+
"GET /v1/consent/account",
|
|
86
|
+
"POST /v1/consent/account",
|
|
80
87
|
// #8515 D13 — the client's onboarding read and write.
|
|
81
88
|
"GET /v1/member/onboarding",
|
|
82
89
|
"POST /v1/member/onboarding",
|
|
@@ -86,6 +93,8 @@ const ADMITTED = [
|
|
|
86
93
|
const OFF_LIST = [
|
|
87
94
|
"GET /v1/consent/platform",
|
|
88
95
|
"POST /v1/consent/platform",
|
|
96
|
+
// #8765 D2 — the anonymous sign-up evidence is never a credential's route.
|
|
97
|
+
"GET /v1/consent/sign-up",
|
|
89
98
|
"GET /v1/member/other",
|
|
90
99
|
"GET /v1/member/messages/:id",
|
|
91
100
|
"GET /v1/waitlist-signups/me",
|
|
@@ -177,9 +186,9 @@ afterEach(async () => {
|
|
|
177
186
|
});
|
|
178
187
|
|
|
179
188
|
describe("HUB_SESSION_ROUTES", () => {
|
|
180
|
-
it("is exactly the
|
|
189
|
+
it("is exactly the 26 entries, by method and registered URL — never a prefix (AC-1, #8515 AC-6, #8765 AC-6)", () => {
|
|
181
190
|
expect([...HUB_SESSION_ROUTES]).toEqual(ADMITTED);
|
|
182
|
-
expect(HUB_SESSION_ROUTES.length).toBe(
|
|
191
|
+
expect(HUB_SESSION_ROUTES.length).toBe(26);
|
|
183
192
|
for (const route of HUB_SESSION_ROUTES) {
|
|
184
193
|
expect({
|
|
185
194
|
route,
|
|
@@ -222,6 +231,8 @@ describe("hub credential — admitted routes", () => {
|
|
|
222
231
|
|
|
223
232
|
describe("hub credential — every other route is 404 with no handler run (AC-7)", () => {
|
|
224
233
|
it.each([
|
|
234
|
+
["GET", "/v1/consent/sign-up"],
|
|
235
|
+
["GET", "/v1/consent/platform"],
|
|
225
236
|
["GET", "/v1/member/other"],
|
|
226
237
|
["GET", "/v1/member/messages/lead_1"],
|
|
227
238
|
["GET", "/v1/waitlist-signups/me"],
|
|
@@ -34,9 +34,10 @@
|
|
|
34
34
|
* spoke's key (`x-ailk-spoke-key`), only while its grant is unrevoked and
|
|
35
35
|
* unexpired, its client enabled and its person's email verified, and only
|
|
36
36
|
* on `HUB_SESSION_ROUTES` — an exact list of METHOD + registered URL
|
|
37
|
-
* pairs — the credential's own `me
|
|
38
|
-
* (#8220, #8515); any other
|
|
39
|
-
*
|
|
37
|
+
* pairs — the credential's own `me`, the member routes and the
|
|
38
|
+
* account-agreements pair, 26 in all (#8220, #8515, #8765); any other
|
|
39
|
+
* route is 404 (never 403, so the account area's `read()` loads
|
|
40
|
+
* "nothing for this caller"). It acts as its person
|
|
40
41
|
* (`request.userId`) and carries `request.hubSession` — never
|
|
41
42
|
* `request.sessionTenant` — and its request logger names the spoke
|
|
42
43
|
* (`spokeId`). Each person is limited, at each spoke, to 60 requests and
|
|
@@ -153,12 +154,14 @@ export type HubSession = {
|
|
|
153
154
|
* The only routes a hub-issued credential reaches (#7894 D6, #8220 D1): an
|
|
154
155
|
* EXACT list of method + registered URL, never a prefix and never computed,
|
|
155
156
|
* so a route added later joins only by a reviewed edit of this list
|
|
156
|
-
* (SEC-7894-3).
|
|
157
|
+
* (SEC-7894-3). Six groups, in this order: the credential's own `me` and
|
|
157
158
|
* #7877's three member reads; #8169's eight member reads; #8170's six member
|
|
158
159
|
* writes (roadmap §7.2 row order); #8438's member consents read and write;
|
|
159
|
-
* #
|
|
160
|
+
* #8765's account-agreements read and write; #8515's onboarding read and
|
|
161
|
+
* write — 26 in all.
|
|
160
162
|
* `GET /v1/consent/platform` is not admitted — it is the Admin owner's own
|
|
161
|
-
* consent to Working Theory, on the hub's cookie session.
|
|
163
|
+
* consent to Working Theory, on the hub's cookie session. Nor is the
|
|
164
|
+
* anonymous `GET /v1/consent/sign-up` (#8765 D2): it takes no credential. The member profile write IS admitted — it
|
|
162
165
|
* is the person's own account, which roadmap D9 saves through it. Not
|
|
163
166
|
* admitted, deliberately: `GET /v1/waitlist-signups/me`,
|
|
164
167
|
* `GET /v1/flow-checkouts/me`, `GET /v1/subscription/me` and
|
|
@@ -191,6 +194,10 @@ export const HUB_SESSION_ROUTES: readonly string[] = Object.freeze([
|
|
|
191
194
|
// admitted: those are the Admin owner's, on the hub's own cookie session.
|
|
192
195
|
"GET /v1/member/consents",
|
|
193
196
|
"POST /v1/member/consents",
|
|
197
|
+
// #8765 D5 — the caller's account-level sign-up agreements, so a spoke's
|
|
198
|
+
// account layout reads and accepts the one grant every site shares.
|
|
199
|
+
"GET /v1/consent/account",
|
|
200
|
+
"POST /v1/consent/account",
|
|
194
201
|
// #8515 D13 — the client's onboarding read and write.
|
|
195
202
|
"GET /v1/member/onboarding",
|
|
196
203
|
"POST /v1/member/onboarding",
|
|
@@ -758,7 +758,12 @@ export function buildOpenApiSpec(
|
|
|
758
758
|
"lead's id with a 201; with anything different it is a 422. Keys are " +
|
|
759
759
|
"scoped per workspace, are first-write-wins, and should be unguessable " +
|
|
760
760
|
"(a UUID, a Stripe Checkout Session id). Without a database there is no " +
|
|
761
|
-
"stored lead to match, so the key has no effect."
|
|
761
|
+
"stored lead to match, so the key has no effect. A contact lead — " +
|
|
762
|
+
"`source` `contact-form`, `conversational-intake` or `flow:contact` — " +
|
|
763
|
+
"must carry `consent: true` (the visitor accepted the site's terms), " +
|
|
764
|
+
"else it is a 400 `consent_required`; the server then records " +
|
|
765
|
+
"`payload.consent` (`statement`, `acceptedAt`) itself, replacing any the " +
|
|
766
|
+
"caller sent. Other sources do not read `consent`.",
|
|
762
767
|
parameters: [
|
|
763
768
|
{
|
|
764
769
|
name: "Idempotency-Key",
|
|
@@ -789,10 +794,17 @@ export function buildOpenApiSpec(
|
|
|
789
794
|
},
|
|
790
795
|
"400": {
|
|
791
796
|
description:
|
|
792
|
-
"Validation error — invalid request body, or a malformed
|
|
797
|
+
"Validation error — invalid request body, or a malformed " +
|
|
798
|
+
"Idempotency-Key; or `consent_required`, a contact lead without " +
|
|
799
|
+
"`consent: true`",
|
|
793
800
|
content: {
|
|
794
801
|
"application/json": {
|
|
795
|
-
schema: {
|
|
802
|
+
schema: {
|
|
803
|
+
oneOf: [
|
|
804
|
+
{ $ref: "#/components/schemas/InvalidInputResponse" },
|
|
805
|
+
{ $ref: "#/components/schemas/ConsentRequiredResponse" },
|
|
806
|
+
],
|
|
807
|
+
},
|
|
796
808
|
},
|
|
797
809
|
},
|
|
798
810
|
},
|
|
@@ -1472,6 +1484,13 @@ export function buildOpenApiSpec(
|
|
|
1472
1484
|
"Free-form caller-set channel tag (e.g. `agent`); no CR/LF",
|
|
1473
1485
|
},
|
|
1474
1486
|
email: { type: "string", format: "email", maxLength: 254 },
|
|
1487
|
+
consent: {
|
|
1488
|
+
type: "boolean",
|
|
1489
|
+
description:
|
|
1490
|
+
"`true` when the visitor accepted the site's terms. Required (as " +
|
|
1491
|
+
"`true`) for a contact lead; read for no other source, and never " +
|
|
1492
|
+
"stored as sent — the server records `payload.consent` instead.",
|
|
1493
|
+
},
|
|
1475
1494
|
payload: {
|
|
1476
1495
|
type: "object",
|
|
1477
1496
|
maxProperties: 64,
|
|
@@ -1575,6 +1594,14 @@ export function buildOpenApiSpec(
|
|
|
1575
1594
|
message: { type: "string" },
|
|
1576
1595
|
},
|
|
1577
1596
|
},
|
|
1597
|
+
ConsentRequiredResponse: {
|
|
1598
|
+
type: "object",
|
|
1599
|
+
required: ["error", "message"],
|
|
1600
|
+
properties: {
|
|
1601
|
+
error: { type: "string", enum: ["consent_required"] },
|
|
1602
|
+
message: { type: "string" },
|
|
1603
|
+
},
|
|
1604
|
+
},
|
|
1578
1605
|
PersistFailedResponse: {
|
|
1579
1606
|
type: "object",
|
|
1580
1607
|
required: ["error"],
|
|
@@ -35,6 +35,7 @@ import { authPreHandlerPlugin } from "../../../middleware/auth.js";
|
|
|
35
35
|
import { __resetHubSignInBuckets } from "../../../services/hub-sign-in.js";
|
|
36
36
|
import {
|
|
37
37
|
hubSignInClientRoutes,
|
|
38
|
+
hubSignInPublicRoutes,
|
|
38
39
|
hubSignInSessionRoutes,
|
|
39
40
|
} from "../hub-sign-in.js";
|
|
40
41
|
|
|
@@ -56,6 +57,7 @@ function buildApp(): FastifyInstance {
|
|
|
56
57
|
await authScope.register(hubSignInSessionRoutes);
|
|
57
58
|
});
|
|
58
59
|
server.register(hubSignInClientRoutes);
|
|
60
|
+
server.register(hubSignInPublicRoutes);
|
|
59
61
|
return server;
|
|
60
62
|
}
|
|
61
63
|
|
|
@@ -481,3 +483,50 @@ describe("GET /v1/hub-sign-in/me", () => {
|
|
|
481
483
|
expect(wrongKey.statusCode).toBe(401);
|
|
482
484
|
});
|
|
483
485
|
});
|
|
486
|
+
|
|
487
|
+
describe("GET /v1/hub-sign-in/clients/:clientId (#8790 D2)", () => {
|
|
488
|
+
const read = (clientId: string) =>
|
|
489
|
+
app.inject({
|
|
490
|
+
method: "GET",
|
|
491
|
+
url: `/v1/hub-sign-in/clients/${encodeURIComponent(clientId)}`,
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it("answers a live client's name and origin, and nothing else", async () => {
|
|
495
|
+
const res = await read(spoke.spoke.id);
|
|
496
|
+
expect(res.statusCode).toBe(200);
|
|
497
|
+
expect(res.headers["cache-control"]).toBe("no-store");
|
|
498
|
+
expect(res.json()).toEqual({
|
|
499
|
+
ok: true,
|
|
500
|
+
data: {
|
|
501
|
+
clientId: spoke.spoke.id,
|
|
502
|
+
name: spoke.spoke.name,
|
|
503
|
+
origin: new URL(spoke.client.redirectUris[0]!).origin,
|
|
504
|
+
},
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it("is a 404 for an unknown client id", async () => {
|
|
509
|
+
const res = await read("spoke_nope");
|
|
510
|
+
expect(res.statusCode).toBe(404);
|
|
511
|
+
expect(res.json()).toEqual({
|
|
512
|
+
ok: false,
|
|
513
|
+
error: { code: "not_found", message: "Not found" },
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it("is the same 404 for a disabled client", async () => {
|
|
518
|
+
spoke.client.enabled = false;
|
|
519
|
+
const res = await read(spoke.spoke.id);
|
|
520
|
+
expect(res.statusCode).toBe(404);
|
|
521
|
+
expect(res.json()).toEqual({
|
|
522
|
+
ok: false,
|
|
523
|
+
error: { code: "not_found", message: "Not found" },
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it("is the same 404 for a revoked spoke", async () => {
|
|
528
|
+
spoke.spoke.revokedAt = new Date();
|
|
529
|
+
const res = await read(spoke.spoke.id);
|
|
530
|
+
expect(res.statusCode).toBe(404);
|
|
531
|
+
});
|
|
532
|
+
});
|
|
@@ -35,11 +35,13 @@
|
|
|
35
35
|
* `/v1/auth/tokens`; recorded in apps/api/CLAUDE.md).
|
|
36
36
|
*/
|
|
37
37
|
import { logger } from "@working-theory/observability";
|
|
38
|
+
import { findSignInClientInfo } from "../../lib/hub-sign-in-store.js";
|
|
38
39
|
import {
|
|
39
40
|
hubSignInCodeResponseSchema,
|
|
40
41
|
hubSignInMeResponseSchema,
|
|
41
42
|
hubSignInRevokeResponseSchema,
|
|
42
43
|
hubSignInTokenResponseSchema,
|
|
44
|
+
hubSignInClientInfoResponseSchema,
|
|
43
45
|
} from "@working-theory/validation";
|
|
44
46
|
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify";
|
|
45
47
|
import type { ZodType } from "zod";
|
|
@@ -159,6 +161,45 @@ export const hubSignInSessionRoutes: FastifyPluginAsync = async (fastify) => {
|
|
|
159
161
|
});
|
|
160
162
|
};
|
|
161
163
|
|
|
164
|
+
/**
|
|
165
|
+
* `clients/:clientId` — public, no auth (#8790 D2). The hub's own sign-in page
|
|
166
|
+
* asks what it may say about the spoke that sent a visitor: the spoke's name
|
|
167
|
+
* and origin, from the registry. A missing, disabled, or revoked client is
|
|
168
|
+
* a 404 with the same body as an unknown one, so the route confirms nothing
|
|
169
|
+
* about ids that are not live. Registered in `server.ts`'s public block.
|
|
170
|
+
*/
|
|
171
|
+
export const hubSignInPublicRoutes: FastifyPluginAsync = async (fastify) => {
|
|
172
|
+
fastify.get<{ Params: { clientId: string } }>(
|
|
173
|
+
"/v1/hub-sign-in/clients/:clientId",
|
|
174
|
+
async (request, reply) => {
|
|
175
|
+
const { clientId } = request.params;
|
|
176
|
+
reply.header("Cache-Control", "no-store");
|
|
177
|
+
if (clientId.length === 0 || clientId.length > 128) {
|
|
178
|
+
return notFound(reply);
|
|
179
|
+
}
|
|
180
|
+
const info = await findSignInClientInfo(clientId);
|
|
181
|
+
if (info === null) return notFound(reply);
|
|
182
|
+
const parsed = hubSignInClientInfoResponseSchema.safeParse(info);
|
|
183
|
+
if (!parsed.success) {
|
|
184
|
+
logger.error(
|
|
185
|
+
{
|
|
186
|
+
route: "hub-sign-in/clients",
|
|
187
|
+
issues: parsed.error.issues.map((i) => i.path.join(".")),
|
|
188
|
+
},
|
|
189
|
+
"[hub-sign-in] response failed its own contract schema",
|
|
190
|
+
);
|
|
191
|
+
return error(
|
|
192
|
+
reply,
|
|
193
|
+
500,
|
|
194
|
+
"internal_error",
|
|
195
|
+
"Response failed contract validation",
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
return reply.status(200).send({ ok: true, data: parsed.data });
|
|
199
|
+
},
|
|
200
|
+
);
|
|
201
|
+
};
|
|
202
|
+
|
|
162
203
|
/** `token` and `revoke` — registered in their own rate-limited scope. */
|
|
163
204
|
export const hubSignInClientRoutes: FastifyPluginAsync = async (fastify) => {
|
|
164
205
|
fastify.post(
|