create-ailk 0.17.0 → 0.19.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/LICENSE +6 -5
- package/NOTICE +2 -0
- package/dist/add-module.js +4 -1
- package/dist/category-profile.d.ts +10 -0
- package/dist/category-profile.js +21 -0
- package/dist/derive-architecture.d.ts +5 -1
- package/dist/derive-architecture.js +8 -3
- package/dist/lib/env-catalog.js +5 -9
- package/dist/lib/license-key-files.d.ts +6 -3
- package/dist/lib/license-key-files.js +8 -2
- package/dist/lib/scaffold-render-sweep.d.ts +9 -0
- package/dist/lib/scaffold-render-sweep.js +24 -3
- package/dist/license-set.d.ts +1 -1
- package/dist/license-set.js +13 -8
- package/dist/module-architecture.js +119 -0
- package/dist/programmatic.js +3 -0
- package/dist/spoke-checkout.d.ts +11 -3
- package/dist/spoke-checkout.js +62 -12
- package/dist/starter-grid.d.ts +1 -1
- package/dist/surfaces.js +140 -1
- package/dist/template-manifest.d.ts +4 -3
- package/dist/topology.d.ts +15 -2
- package/dist/topology.js +83 -4
- package/package.json +5 -4
- package/templates/.env.example +3 -3
- package/templates/.github/workflows/ci.yml +5 -0
- package/templates/apps/api/CLAUDE.md +25 -7
- package/templates/apps/api/LICENSE +6 -5
- package/templates/apps/api/NOTICE +2 -0
- package/templates/apps/api/jest.config.cjs +4 -29
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +9 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
- package/templates/apps/api/src/lib/stripe.ts +81 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +266 -39
- package/templates/apps/api/src/middleware/auth.ts +206 -46
- package/templates/apps/api/src/routes/leads/index.ts +4 -0
- package/templates/apps/api/src/server.ts +107 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +58 -0
- package/templates/apps/api/src/services/lead-routing.ts +30 -2
- package/templates/apps/api/vercel.json +2 -2
- package/templates/apps/mcp/.env.example +10 -0
- package/templates/apps/mcp/CLAUDE.md +58 -7
- package/templates/apps/mcp/LICENSE +6 -5
- package/templates/apps/mcp/NOTICE +2 -0
- package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
- package/templates/apps/mcp/__tests__/app-entry.test.ts +325 -0
- package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
- package/templates/apps/mcp/__tests__/boot.test.ts +280 -0
- package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
- package/templates/apps/mcp/__tests__/index.test.ts +86 -47
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +5 -6
- package/templates/apps/mcp/__tests__/lib/content-root.test.ts +93 -0
- package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
- package/templates/apps/mcp/__tests__/server.test.ts +63 -6
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
- package/templates/apps/mcp/jest.config.cjs +3 -44
- package/templates/apps/mcp/src/abuse/index.ts +117 -21
- package/templates/apps/mcp/src/app.ts +32 -0
- package/templates/apps/mcp/src/boot.ts +167 -0
- package/templates/apps/mcp/src/http-transport.ts +132 -88
- package/templates/apps/mcp/src/index.ts +19 -126
- package/templates/apps/mcp/src/lib/audit-single-page.ts +7 -6
- package/templates/apps/mcp/src/lib/content-adapter.ts +34 -5
- package/templates/apps/mcp/src/lib/topology.generated.ts +3 -0
- package/templates/apps/mcp/src/request-context.ts +52 -0
- package/templates/apps/mcp/src/server.ts +10 -2
- package/templates/apps/mcp/src/serverless-app.ts +88 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
- package/templates/apps/mcp/src/tools/get_business_hours.ts +6 -5
- package/templates/apps/mcp/src/tools/get_faq.ts +6 -5
- package/templates/apps/mcp/src/tools/get_locations.ts +6 -5
- package/templates/apps/mcp/src/tools/get_offerings.ts +6 -5
- package/templates/apps/mcp/src/tools/get_pricing.ts +6 -5
- package/templates/apps/mcp/src/tools/get_site_info.ts +6 -5
- package/templates/apps/mcp/src/tools/list_pages.ts +8 -7
- package/templates/apps/mcp/src/tools/read_content.ts +7 -7
- package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
- package/templates/apps/mcp/src/tools/search_content.ts +6 -5
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
- package/templates/apps/mcp/tsconfig.json +2 -0
- package/templates/apps/web/LICENSE +6 -5
- package/templates/apps/web/NOTICE +2 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +16 -4
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +3 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +568 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +272 -42
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +115 -16
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +126 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +139 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +20 -390
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +114 -50
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +25 -32
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +177 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +131 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +4 -1
- package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
- package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +80 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +108 -0
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
- package/templates/apps/web/app/[locale]/layout.tsx +23 -4
- package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
- package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
- package/templates/apps/web/app/robots.txt/route.ts +10 -4
- package/templates/apps/web/auth.ts +5 -1
- package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +42 -0
- package/templates/apps/web/lib/account-config.ts +188 -52
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
- package/templates/apps/web/lib/hub-session/index.ts +1 -0
- package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
- package/templates/apps/web/lib/hub-session/seal.ts +13 -9
- package/templates/apps/web/lib/site-brand.tsx +22 -6
- package/templates/apps/web/lib/topology.generated.ts +27 -0
- package/templates/apps/web/messages/en.json +156 -35
- package/templates/database/CHANGELOG.md +21 -0
- package/templates/database/LICENSE +6 -5
- package/templates/database/NOTICE +2 -0
- package/templates/database/README.md +1 -1
- package/templates/database/__tests__/member-area.db.test.ts +15 -3
- package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
- package/templates/database/__tests__/member-contract.db.test.ts +413 -0
- package/templates/database/__tests__/seed.test.ts +54 -2
- package/templates/database/inbox/schema.prisma +90 -15
- package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
- package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
- package/templates/database/package.json +3 -2
- package/templates/database/seed.ts +34 -2
- package/versions.json +36 -36
- package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
- package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
- package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
- package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
- package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
- package/templates/apps/api/src/__tests__/server.test.ts +0 -591
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
- package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
- package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
- package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
- package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
- package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
- package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
- package/templates/database/__tests__/build.test.ts +0 -131
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use client"; // owns the save call and the router.refresh() that follows it
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GeneralSettings — User settings › General's adapter over the shared
|
|
5
|
+
* `AccountSettings` composition (#8339 D8; builds on #8232 D3, D4).
|
|
6
|
+
*
|
|
7
|
+
* Rewritten in place from `ProfileForm` (#7430 → #8232), which rendered its
|
|
8
|
+
* own form. The form, the color logic and the markup now live in
|
|
9
|
+
* `@working-theory/templates/account`'s `AccountSettings` (spec 8339 D4);
|
|
10
|
+
* this file supplies `profile` and `onSave` from `via` (General's
|
|
11
|
+
* `loadProfileSource`, D3):
|
|
12
|
+
*
|
|
13
|
+
* - `member` — `saveProfile` (`PATCH /v1/member/profile`, D6). `image` is
|
|
14
|
+
* sent only when the photo field changed (an untouched field never
|
|
15
|
+
* clears a photo, 8232 D4/D6); a refusal keeps both fields as typed.
|
|
16
|
+
* - `session` — a build with no member API: the name saves through
|
|
17
|
+
* Better Auth's own `updateUser`, and no photo field shows
|
|
18
|
+
* (`profile="name-only"`).
|
|
19
|
+
* - `none` — a hub-mode spoke whose hub answers no member route: no form,
|
|
20
|
+
* the name as read-only text (`profile="read-only"`).
|
|
21
|
+
*
|
|
22
|
+
* On success, both paths call `router.refresh()` so the rail's name follows
|
|
23
|
+
* the save.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
AccountSettings,
|
|
28
|
+
type AccountGeneralLabels,
|
|
29
|
+
type AccountSaveResult,
|
|
30
|
+
type AccountSettingsFrameProps,
|
|
31
|
+
} from "@working-theory/templates/account";
|
|
32
|
+
import { createAuthClient } from "better-auth/react";
|
|
33
|
+
import { useRouter } from "next/navigation";
|
|
34
|
+
import type { ReactElement, ReactNode } from "react";
|
|
35
|
+
|
|
36
|
+
import { saveProfile } from "../profile-actions";
|
|
37
|
+
|
|
38
|
+
const authClient = createAuthClient();
|
|
39
|
+
|
|
40
|
+
export interface GeneralSettingsLabels extends AccountGeneralLabels {
|
|
41
|
+
/** The `saveProfile` refusal lines not already in `AccountGeneralLabels`. */
|
|
42
|
+
unverified: string;
|
|
43
|
+
unavailable: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function GeneralSettings({
|
|
47
|
+
frame,
|
|
48
|
+
user,
|
|
49
|
+
labels,
|
|
50
|
+
colorMode,
|
|
51
|
+
via,
|
|
52
|
+
children,
|
|
53
|
+
}: {
|
|
54
|
+
frame: Omit<AccountSettingsFrameProps, "name" | "children">;
|
|
55
|
+
user: { name: string; email: string; image: string | null };
|
|
56
|
+
labels: GeneralSettingsLabels;
|
|
57
|
+
colorMode: { cookieName: string } | null;
|
|
58
|
+
via: "member" | "session" | "none";
|
|
59
|
+
children?: ReactNode;
|
|
60
|
+
}): ReactElement {
|
|
61
|
+
const router = useRouter();
|
|
62
|
+
|
|
63
|
+
async function onSave({
|
|
64
|
+
name,
|
|
65
|
+
image,
|
|
66
|
+
imageChanged,
|
|
67
|
+
}: {
|
|
68
|
+
name: string;
|
|
69
|
+
image: string | null;
|
|
70
|
+
imageChanged: boolean;
|
|
71
|
+
}): Promise<AccountSaveResult> {
|
|
72
|
+
if (via === "session") {
|
|
73
|
+
const { error } = await authClient.updateUser({ name });
|
|
74
|
+
if (error) return { ok: false, message: labels.saveError };
|
|
75
|
+
router.refresh();
|
|
76
|
+
return { ok: true };
|
|
77
|
+
}
|
|
78
|
+
const res = await saveProfile(imageChanged ? { name, image } : { name });
|
|
79
|
+
if (!res.ok) {
|
|
80
|
+
if (res.reason === "photo") return { ok: false, field: "photo", message: labels.photoInvalid };
|
|
81
|
+
if (res.reason === "required") return { ok: false, field: "name", message: labels.nameRequired };
|
|
82
|
+
if (res.reason === "unverified") return { ok: false, message: labels.unverified };
|
|
83
|
+
if (res.reason === "unavailable") return { ok: false, message: labels.unavailable };
|
|
84
|
+
return { ok: false, message: labels.saveError };
|
|
85
|
+
}
|
|
86
|
+
router.refresh();
|
|
87
|
+
// exactOptionalPropertyTypes: omit `name` entirely rather than set it to
|
|
88
|
+
// `undefined` — AccountSaveResult's `name?: string` has no `undefined` value.
|
|
89
|
+
return { ok: true, image: res.image, ...(res.name !== null ? { name: res.name } : {}) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<AccountSettings
|
|
94
|
+
frame={frame}
|
|
95
|
+
user={user}
|
|
96
|
+
labels={labels}
|
|
97
|
+
colorMode={colorMode}
|
|
98
|
+
profile={via === "member" ? "editable" : via === "session" ? "name-only" : "read-only"}
|
|
99
|
+
{...(via === "none" ? {} : { onSave })}
|
|
100
|
+
>
|
|
101
|
+
{children}
|
|
102
|
+
</AccountSettings>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* general/profile-source.ts (server code) — who saves User settings ›
|
|
3
|
+
* General's profile and takes its deletion request, and the photo the
|
|
4
|
+
* profile form starts with (#8232, spec
|
|
5
|
+
* docs/specs/8232-member-settings-account-general-and-spec.md D3, C2, C5).
|
|
6
|
+
*
|
|
7
|
+
* `via` — whether the member API answers, read once per request from
|
|
8
|
+
* `member.pieces`, the cheapest member read:
|
|
9
|
+
*
|
|
10
|
+
* - `member` — any answer but a 404: a 2xx, any other refusal, a preflight
|
|
11
|
+
* refusal (status 0) or a throw. A refusal of another kind belongs to the
|
|
12
|
+
* write, which reports it honestly. A `503 tenant_unresolved` is
|
|
13
|
+
* `member` too: the writes answer the same 503, shown as unavailable.
|
|
14
|
+
* - on a 404, the sign-in seam (`signInMethod()`, D7) says who holds the
|
|
15
|
+
* account. A `site` sign-in gives `session`: this site's own sign-in
|
|
16
|
+
* holds it, and the 404 is a build with no tenancy module — every member
|
|
17
|
+
* read and write registers in one module block — so the name saves
|
|
18
|
+
* through Better Auth as before and deletion points at Support. A `hub`
|
|
19
|
+
* sign-in gives `none`: the hub holds the account and answers no member
|
|
20
|
+
* route for this spoke, and the spoke has no `/api/auth` of its own, so
|
|
21
|
+
* no write can reach the account from here.
|
|
22
|
+
*
|
|
23
|
+
* A 404 is a module-presence test, as `read()` in `lib/account-config.ts`
|
|
24
|
+
* already makes; this file names no topology constant itself.
|
|
25
|
+
*
|
|
26
|
+
* `image` — the session's photo, which Better Auth's session carries at run
|
|
27
|
+
* time though the generated `SiteSession` type does not declare it. A hub
|
|
28
|
+
* session carries none (C5), so on a spoke the form starts empty.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { member } from "@working-theory/api-client";
|
|
32
|
+
import type { AccountContext } from "@working-theory/templates/account";
|
|
33
|
+
import { headers } from "next/headers";
|
|
34
|
+
import { cache } from "react";
|
|
35
|
+
|
|
36
|
+
import { readSession } from "../../../../../lib/topology.generated";
|
|
37
|
+
import { signInMethod } from "../sign-in-method";
|
|
38
|
+
|
|
39
|
+
export interface ProfileSource {
|
|
40
|
+
via: "member" | "session" | "none";
|
|
41
|
+
image: string | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** `user.image` as a trimmed non-blank string, else `null`. */
|
|
45
|
+
function sessionImage(session: unknown): string | null {
|
|
46
|
+
if (typeof session !== "object" || session === null) return null;
|
|
47
|
+
const user = (session as { user?: unknown }).user;
|
|
48
|
+
if (typeof user !== "object" || user === null) return null;
|
|
49
|
+
const image = (user as { image?: unknown }).image;
|
|
50
|
+
return typeof image === "string" && image.trim() !== "" ? image.trim() : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function memberApiAnswers(ctx: AccountContext): Promise<boolean> {
|
|
54
|
+
try {
|
|
55
|
+
const res = await member.pieces(ctx.init);
|
|
56
|
+
return res.ok || res.status !== 404;
|
|
57
|
+
} catch {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const loadProfileSource = cache(async (ctx: AccountContext): Promise<ProfileSource> => {
|
|
63
|
+
// The photo is optional: a failed session read starts the field empty
|
|
64
|
+
// rather than failing the page.
|
|
65
|
+
const [answers, session] = await Promise.all([
|
|
66
|
+
memberApiAnswers(ctx),
|
|
67
|
+
headers()
|
|
68
|
+
.then((h) => readSession(h))
|
|
69
|
+
.catch(() => null),
|
|
70
|
+
]);
|
|
71
|
+
const via = answers ? "member" : signInMethod().kind === "site" ? "session" : "none";
|
|
72
|
+
return { via, image: sessionImage(session) };
|
|
73
|
+
});
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
* `forwardedCookieInit()` and streams the bytes back. The api-client's
|
|
13
13
|
* `member.messageFile` is the one external boundary mocked; the seal, the
|
|
14
14
|
* cookie read and the route's decisions are real. Covers:
|
|
15
|
-
* - a
|
|
15
|
+
* - on a single site (`SIGN_IN_PROVIDER` `"site"`) the read carries the
|
|
16
|
+
* caller's session cookie to the site's own API, and serves the same
|
|
17
|
+
* way (#8231 D9) — the route itself tests no topology;
|
|
16
18
|
* - the read carries the signed-in caller's own credential, and the bytes
|
|
17
19
|
* come back with all five hub headers unchanged;
|
|
18
20
|
* - a hub answer missing any of the five is refused 502 — never bytes on
|
|
@@ -36,10 +38,11 @@ jest.mock("../../../../../../../../../lib/topology.generated", () => ({
|
|
|
36
38
|
}));
|
|
37
39
|
|
|
38
40
|
const mockHeaders = jest.fn();
|
|
41
|
+
const mockCookies = jest.fn();
|
|
39
42
|
jest.mock("next/headers", () => ({
|
|
40
43
|
__esModule: true,
|
|
41
44
|
headers: (...args: unknown[]) => mockHeaders(...args),
|
|
42
|
-
cookies:
|
|
45
|
+
cookies: (...args: unknown[]) => mockCookies(...args),
|
|
43
46
|
}));
|
|
44
47
|
|
|
45
48
|
import { seal } from "../../../../../../../../../lib/hub-session/seal";
|
|
@@ -102,6 +105,7 @@ beforeEach(() => {
|
|
|
102
105
|
};
|
|
103
106
|
mockProvider.value = "hub";
|
|
104
107
|
mockHeaders.mockReset().mockResolvedValue(new Headers());
|
|
108
|
+
mockCookies.mockReset().mockResolvedValue({ toString: () => "" });
|
|
105
109
|
mockMessageFile.mockReset().mockResolvedValue(fileResult());
|
|
106
110
|
});
|
|
107
111
|
|
|
@@ -194,12 +198,18 @@ describe("the member file read on a spoke that signs in through its hub", () =>
|
|
|
194
198
|
});
|
|
195
199
|
});
|
|
196
200
|
|
|
197
|
-
describe("on a site
|
|
198
|
-
it
|
|
199
|
-
mockProvider.value =
|
|
200
|
-
|
|
201
|
+
describe("on a single site — its own sign-in (#8231 D9)", () => {
|
|
202
|
+
it("reads the file with the caller's session cookie and serves it with all five headers unchanged", async () => {
|
|
203
|
+
mockProvider.value = "site";
|
|
204
|
+
mockCookies.mockResolvedValue({ toString: () => "session=abc" });
|
|
201
205
|
const res = await get();
|
|
202
|
-
expect(
|
|
203
|
-
expect(
|
|
206
|
+
expect(mockMessageFile).toHaveBeenCalledWith(ID, { headers: { cookie: "session=abc" } });
|
|
207
|
+
expect(res.status).toBe(200);
|
|
208
|
+
expect(res.headers.get("content-type")).toBe(FILE_HEADERS.contentType);
|
|
209
|
+
expect(res.headers.get("content-disposition")).toBe(FILE_HEADERS.contentDisposition);
|
|
210
|
+
expect(res.headers.get("x-content-type-options")).toBe(FILE_HEADERS.contentTypeOptions);
|
|
211
|
+
expect(res.headers.get("content-security-policy")).toBe(FILE_HEADERS.contentSecurityPolicy);
|
|
212
|
+
expect(res.headers.get("cache-control")).toBe(FILE_HEADERS.cacheControl);
|
|
213
|
+
await expect(res.text()).resolves.toBe("%PDF-1.7 bytes");
|
|
204
214
|
});
|
|
205
215
|
});
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A message's file action (the `message` Inbox kind, `lib/account-config.ts`)
|
|
5
5
|
* links here. The browser holds neither the client's hub credential (sealed in
|
|
6
|
-
* the `hub-session` cookie) nor this site's spoke key, so this route reads
|
|
7
|
-
*
|
|
6
|
+
* the `hub-session` cookie) nor this site's spoke key, so this route reads
|
|
7
|
+
* `GET /v1/member/messages/:id/file` with `forwardedCookieInit()` — the
|
|
8
8
|
* caller's own credential, so a client only ever gets their own file — and
|
|
9
9
|
* streams the bytes back unread.
|
|
10
10
|
*
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
* A hub answer missing any of the five is refused 502 rather than served
|
|
17
17
|
* without it.
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
19
|
+
* On a single site the read carries the caller's session cookie; on a spoke
|
|
20
|
+
* that signs in through its hub, the client's hub credential (#8231 D9).
|
|
21
21
|
* A refusal has no body: no credential (signed out) is a 401, a hub 4xx keeps
|
|
22
22
|
* its status, and a hub 5xx, a transport the api-client refuses or a hub call
|
|
23
23
|
* that throws is a 502.
|
|
@@ -25,7 +25,6 @@
|
|
|
25
25
|
import { member, type MemberFileHeaders } from "@working-theory/api-client";
|
|
26
26
|
|
|
27
27
|
import { forwardedCookieInit } from "../../../../../../../../lib/server-api";
|
|
28
|
-
import { SIGN_IN_PROVIDER } from "../../../../../../../../lib/topology.generated";
|
|
29
28
|
|
|
30
29
|
interface Context {
|
|
31
30
|
params: Promise<{ locale: string; id: string }>;
|
|
@@ -55,7 +54,6 @@ function refusalStatus(status: number, body: unknown): number {
|
|
|
55
54
|
|
|
56
55
|
// platform-decisions-waiver: api-versioning — a page-scoped download link on the site's own account area, not an API contract; it re-serves the versioned hub route /v1/member/messages/:id/file
|
|
57
56
|
export async function GET(_request: Request, { params }: Context): Promise<Response> {
|
|
58
|
-
if (SIGN_IN_PROVIDER !== "hub") return refusal(404);
|
|
59
57
|
const { id } = await params;
|
|
60
58
|
|
|
61
59
|
// The api-client throws, rather than answering `{ ok: false }`, on a
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
*
|
|
5
5
|
* RSC. Every `/account` route renders inside `SignedInShell` (through the
|
|
6
6
|
* client `AccountFrame`): the site's logo as the rail's top tile, linking
|
|
7
|
-
* home;
|
|
7
|
+
* home; the rail's rows — Inbox, a Library group holding Artifacts, then
|
|
8
|
+
* Settings (#8231 D1); a footer of the avatar and ? alone;
|
|
8
9
|
* the card avatar menu (name, then Settings and Log out); the ? help menu
|
|
9
10
|
* (Contact support, and Help center when `AILK_HELP_CENTER_URL` is set);
|
|
10
11
|
* and below `md` a tab bar reading avatar · Inbox · For you · Help (#7469).
|
|
@@ -69,6 +70,8 @@ export default async function AccountLayout({
|
|
|
69
70
|
inbox: t("frame.inbox"),
|
|
70
71
|
forYou: t("frame.forYou"),
|
|
71
72
|
settings: t("frame.settings"),
|
|
73
|
+
library: t("frame.library"),
|
|
74
|
+
artifacts: t("frame.artifacts"),
|
|
72
75
|
signOut: t("frame.signOut"),
|
|
73
76
|
help: t("frame.help"),
|
|
74
77
|
accountMenu: t("frame.accountMenu"),
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* account/new-actions.ts — the New menu's reads and writes (#8233, spec
|
|
5
|
+
* docs/specs/8233-member-new-menu-the-new-spec.md D5, D6; the writes are
|
|
6
|
+
* #8170's).
|
|
7
|
+
*
|
|
8
|
+
* A popup reads its data when it opens (D5) and posts through a write here:
|
|
9
|
+
*
|
|
10
|
+
* - `readMeetingTypes` — `member.meetingTypes`, the types the provider
|
|
11
|
+
* offers its clients.
|
|
12
|
+
* - `readSlots` — `member.availability` for one type, from now to
|
|
13
|
+
* `SLOT_WINDOW_DAYS` later. The range is computed here; the browser
|
|
14
|
+
* sends only the slug and its time zone.
|
|
15
|
+
* - `readRequestForm` — `member.requestForm`, the request-quote magnet's
|
|
16
|
+
* current steps, or `form: null` while Request quote is switched off.
|
|
17
|
+
* - `sendMessage` · `requestQuote` · `bookService` — `member.sendMessage`,
|
|
18
|
+
* `member.requestQuote` and `member.book`.
|
|
19
|
+
*
|
|
20
|
+
* Every call sends `forwardedCookieInit()`'s init as built: on a single site
|
|
21
|
+
* the session cookie plus the request's `Origin` (`apps/api`'s cookie CSRF
|
|
22
|
+
* check), on a hub-mode spoke the hub credential — so nothing here tests the
|
|
23
|
+
* topology, and no identity is posted: the server takes the name and the
|
|
24
|
+
* verified email from the credential (8170 D7).
|
|
25
|
+
*
|
|
26
|
+
* A value is sent exactly as typed — never trimmed, never given a scheme: the
|
|
27
|
+
* server's `plainText` and `isHttpsLink` are the one rule. An optional value
|
|
28
|
+
* left empty or all-space is omitted, because the server refuses `""`. A
|
|
29
|
+
* refusal maps to its `error.code` when a popup has words for it, else to
|
|
30
|
+
* `failed`.
|
|
31
|
+
*
|
|
32
|
+
* Not a `*.actions.ts` file and no `*Action`-suffixed export: apps/mcp's
|
|
33
|
+
* parity test reads that suffix, and these member writes have no MCP twin by
|
|
34
|
+
* decision (8170's header). Logs carry the function, the status and the code
|
|
35
|
+
* only — never a typed value or a credential.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { member } from "@working-theory/api-client";
|
|
39
|
+
import type {
|
|
40
|
+
MemberMeetingType,
|
|
41
|
+
MemberRequestFormResponse,
|
|
42
|
+
SchedulingSlot,
|
|
43
|
+
} from "@working-theory/validation";
|
|
44
|
+
|
|
45
|
+
import { forwardedCookieInit } from "../../../../lib/server-api";
|
|
46
|
+
|
|
47
|
+
/** How far ahead Schedule service offers times (the read's own cap is 60 days). */
|
|
48
|
+
const SLOT_WINDOW_DAYS = 14;
|
|
49
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
50
|
+
|
|
51
|
+
/** The refusal codes a popup has its own words for; anything else is `failed`. */
|
|
52
|
+
const KNOWN_CODES = [
|
|
53
|
+
"link_not_https",
|
|
54
|
+
"answers_invalid",
|
|
55
|
+
"request_form_off",
|
|
56
|
+
"meeting_type_not_offered",
|
|
57
|
+
"slot_unavailable",
|
|
58
|
+
"too_many_requests",
|
|
59
|
+
"invalid_input",
|
|
60
|
+
] as const;
|
|
61
|
+
|
|
62
|
+
export type NewActionErrorCode = (typeof KNOWN_CODES)[number] | "failed";
|
|
63
|
+
|
|
64
|
+
/** What a popup's form shows after a submit. */
|
|
65
|
+
export type NewActionState =
|
|
66
|
+
| { kind: "idle" }
|
|
67
|
+
| { kind: "success" }
|
|
68
|
+
| { kind: "error"; code: NewActionErrorCode };
|
|
69
|
+
|
|
70
|
+
export type NewReadResult<T> = ({ kind: "ok" } & T) | { kind: "error" };
|
|
71
|
+
|
|
72
|
+
/** Structural mirror of `@working-theory/api-client`'s `ApiResult<T>` (not re-exported). */
|
|
73
|
+
type ApiResult<T> = { ok: true; body: T } | { ok: false; status: number; body: unknown };
|
|
74
|
+
|
|
75
|
+
/** PII-safe error identity for logs (the `checkout.actions.ts` idiom: `err.name` only). */
|
|
76
|
+
function errorName(err: unknown): string {
|
|
77
|
+
return err instanceof Error ? err.name : "unknown";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The `error.code` of a member refusal body, when it has one. */
|
|
81
|
+
function errorCode(body: unknown): string | null {
|
|
82
|
+
if (typeof body !== "object" || body === null) return null;
|
|
83
|
+
const error = (body as { error?: unknown }).error;
|
|
84
|
+
if (typeof error !== "object" || error === null) return null;
|
|
85
|
+
const code = (error as { code?: unknown }).code;
|
|
86
|
+
return typeof code === "string" ? code : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function knownCode(code: string | null): NewActionErrorCode {
|
|
90
|
+
return KNOWN_CODES.find((known) => known === code) ?? "failed";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A posted value as typed; `undefined` when it is absent or not a string. */
|
|
94
|
+
function value(formData: FormData, name: string): string | undefined {
|
|
95
|
+
const entry = formData.get(name);
|
|
96
|
+
return typeof entry === "string" ? entry : undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** An optional posted value as typed, or `undefined` when it is empty or all-space. */
|
|
100
|
+
function optional(formData: FormData, name: string): string | undefined {
|
|
101
|
+
const entry = value(formData, name);
|
|
102
|
+
return entry !== undefined && entry.trim() !== "" ? entry : undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function runRead<T, R>(
|
|
106
|
+
name: string,
|
|
107
|
+
call: (init: { headers: Record<string, string> }) => Promise<ApiResult<T>>,
|
|
108
|
+
shape: (body: T) => R,
|
|
109
|
+
): Promise<NewReadResult<R>> {
|
|
110
|
+
try {
|
|
111
|
+
const res = await call(await forwardedCookieInit());
|
|
112
|
+
if (res.ok) return { kind: "ok", ...shape(res.body) };
|
|
113
|
+
console.error(`[new-actions] ${name} status=${res.status} code=${errorCode(res.body) ?? "none"}`);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
console.error(`[new-actions] ${name} errName=${errorName(err)}`);
|
|
116
|
+
}
|
|
117
|
+
return { kind: "error" };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Action input is untrusted: a hand-built call may post anything but FormData. */
|
|
121
|
+
function isFormData(input: unknown): input is FormData {
|
|
122
|
+
return input instanceof FormData;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function runWrite(
|
|
126
|
+
name: string,
|
|
127
|
+
call: (init: { headers: Record<string, string> }) => Promise<ApiResult<unknown>>,
|
|
128
|
+
): Promise<NewActionState> {
|
|
129
|
+
try {
|
|
130
|
+
const res = await call(await forwardedCookieInit());
|
|
131
|
+
if (res.ok) return { kind: "success" };
|
|
132
|
+
const code = knownCode(errorCode(res.body));
|
|
133
|
+
console.error(`[new-actions] ${name} status=${res.status} code=${code}`);
|
|
134
|
+
return { kind: "error", code };
|
|
135
|
+
} catch (err) {
|
|
136
|
+
console.error(`[new-actions] ${name} errName=${errorName(err)}`);
|
|
137
|
+
return { kind: "error", code: "failed" };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Reads (D5) ───────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
export async function readMeetingTypes(): Promise<NewReadResult<{ items: MemberMeetingType[] }>> {
|
|
144
|
+
return runRead("readMeetingTypes", (init) => member.meetingTypes(init), ({ items }) => ({ items }));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function readSlots(input: {
|
|
148
|
+
slug: string;
|
|
149
|
+
timeZone?: string;
|
|
150
|
+
}): Promise<NewReadResult<{ slots: SchedulingSlot[] }>> {
|
|
151
|
+
// Action input is untrusted: a hand-built call may send anything.
|
|
152
|
+
const { slug, timeZone } = (typeof input === "object" && input !== null ? input : {}) as {
|
|
153
|
+
slug?: unknown;
|
|
154
|
+
timeZone?: unknown;
|
|
155
|
+
};
|
|
156
|
+
if (typeof slug !== "string") return { kind: "error" };
|
|
157
|
+
const from = new Date();
|
|
158
|
+
const to = new Date(from.getTime() + SLOT_WINDOW_DAYS * DAY_MS);
|
|
159
|
+
const query = {
|
|
160
|
+
slug,
|
|
161
|
+
from: from.toISOString(),
|
|
162
|
+
to: to.toISOString(),
|
|
163
|
+
...(typeof timeZone === "string" && timeZone !== "" ? { timeZone } : {}),
|
|
164
|
+
};
|
|
165
|
+
return runRead("readSlots", (init) => member.availability(query, init), ({ slots }) => ({ slots }));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function readRequestForm(): Promise<
|
|
169
|
+
NewReadResult<{ form: MemberRequestFormResponse["form"] }>
|
|
170
|
+
> {
|
|
171
|
+
return runRead("readRequestForm", (init) => member.requestForm(init), ({ form }) => ({ form }));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── Writes (D6) — `(prev, formData)` for `useActionState` ───────────────────
|
|
175
|
+
|
|
176
|
+
export async function sendMessage(
|
|
177
|
+
_prev: NewActionState,
|
|
178
|
+
formData: FormData,
|
|
179
|
+
): Promise<NewActionState> {
|
|
180
|
+
if (!isFormData(formData)) return { kind: "error", code: "invalid_input" };
|
|
181
|
+
const link = optional(formData, "link");
|
|
182
|
+
const body = {
|
|
183
|
+
subject: value(formData, "subject") ?? "",
|
|
184
|
+
body: value(formData, "body") ?? "",
|
|
185
|
+
...(link === undefined ? {} : { link }),
|
|
186
|
+
};
|
|
187
|
+
return runWrite("sendMessage", (init) => member.sendMessage(body, init));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The answers are the entries named `answer.<field name>`; a blank one is omitted. */
|
|
191
|
+
export async function requestQuote(
|
|
192
|
+
_prev: NewActionState,
|
|
193
|
+
formData: FormData,
|
|
194
|
+
): Promise<NewActionState> {
|
|
195
|
+
if (!isFormData(formData)) return { kind: "error", code: "invalid_input" };
|
|
196
|
+
const answers: Record<string, string> = {};
|
|
197
|
+
for (const key of new Set(formData.keys())) {
|
|
198
|
+
if (!key.startsWith("answer.")) continue;
|
|
199
|
+
const answer = optional(formData, key);
|
|
200
|
+
if (answer !== undefined) answers[key.slice("answer.".length)] = answer;
|
|
201
|
+
}
|
|
202
|
+
return runWrite("requestQuote", (init) => member.requestQuote({ answers }, init));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function bookService(
|
|
206
|
+
_prev: NewActionState,
|
|
207
|
+
formData: FormData,
|
|
208
|
+
): Promise<NewActionState> {
|
|
209
|
+
if (!isFormData(formData)) return { kind: "error", code: "invalid_input" };
|
|
210
|
+
const timeZone = optional(formData, "timeZone");
|
|
211
|
+
const notes = optional(formData, "notes");
|
|
212
|
+
const body = {
|
|
213
|
+
slug: value(formData, "slug") ?? "",
|
|
214
|
+
start: value(formData, "start") ?? "",
|
|
215
|
+
...(timeZone === undefined ? {} : { timeZone }),
|
|
216
|
+
...(notes === undefined ? {} : { notes }),
|
|
217
|
+
};
|
|
218
|
+
return runWrite("bookService", (init) => member.book(body, init));
|
|
219
|
+
}
|