create-ailk 0.14.0 → 0.16.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 +124 -0
- package/component-catalog.json +3 -3
- package/dist/add-module.d.ts +7 -0
- package/dist/add-module.js +80 -3
- package/dist/add-route.js +26 -2
- package/dist/adopt.d.ts +74 -0
- package/dist/adopt.js +314 -0
- package/dist/apply-design.d.ts +12 -1
- package/dist/apply-design.js +36 -4
- package/dist/cli.js +103 -3
- package/dist/derive-architecture.d.ts +37 -2
- package/dist/derive-architecture.js +92 -7
- package/dist/design-package.js +3 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/chrome-readers.d.ts +21 -0
- package/dist/lib/chrome-readers.js +100 -0
- package/dist/lib/declared-env.d.ts +142 -0
- package/dist/lib/declared-env.js +985 -0
- package/dist/lib/env-catalog.d.ts +17 -0
- package/dist/lib/env-catalog.js +234 -0
- package/dist/lib/extract-module-bundle.d.ts +20 -0
- package/dist/lib/extract-module-bundle.js +53 -6
- package/dist/lib/license-key-files.d.ts +6 -1
- package/dist/lib/license-key-files.js +9 -4
- package/dist/lib/module-tier.d.ts +2 -0
- package/dist/lib/module-tier.js +4 -0
- package/dist/module-architecture.d.ts +9 -0
- package/dist/module-architecture.js +82 -0
- package/dist/parse-args.d.ts +27 -0
- package/dist/parse-args.js +89 -0
- package/dist/programmatic.d.ts +28 -0
- package/dist/programmatic.js +181 -11
- package/dist/seed-flows.d.ts +65 -0
- package/dist/seed-flows.js +350 -0
- package/dist/site-chrome.d.ts +4 -0
- package/dist/site-chrome.js +34 -3
- package/dist/site-manifest.d.ts +55 -5
- package/dist/site-manifest.js +121 -12
- package/dist/site-roster.d.ts +13 -0
- package/dist/site-roster.js +22 -8
- package/dist/spoke-checkout.d.ts +42 -0
- package/dist/spoke-checkout.js +359 -0
- package/dist/surfaces.js +7 -0
- package/dist/sync-routes.js +29 -5
- package/dist/topology.d.ts +246 -0
- package/dist/topology.js +942 -0
- package/package.json +3 -3
- package/site-templates/designs/minimalism/DESIGN.md +3 -3
- package/templates/.env.example +10 -0
- package/templates/apps/api/CLAUDE.md +21 -4
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
- package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
- package/templates/apps/api/src/bin/spokes.ts +62 -1
- package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
- package/templates/apps/api/src/config/index.ts +35 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +99 -0
- package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
- package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
- package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
- package/templates/apps/api/src/lib/agent-store.ts +95 -3
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
- package/templates/apps/api/src/lib/site.ts +37 -0
- package/templates/apps/api/src/lib/spoke-store.ts +41 -3
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
- package/templates/apps/api/src/middleware/auth.ts +129 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
- package/templates/apps/api/src/openapi/spec.ts +38 -2
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
- package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +282 -0
- package/templates/apps/api/src/routes/leads/index.ts +69 -6
- package/templates/apps/api/src/routes/webhooks/README.md +39 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +133 -2
- package/templates/apps/api/src/server.ts +105 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
- package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +492 -0
- package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
- package/templates/apps/api/src/services/default-tenant.ts +41 -7
- package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
- package/templates/apps/api/src/services/lead-routing.ts +278 -30
- package/templates/apps/api/src/services/person.ts +541 -0
- package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
- package/templates/apps/mcp/__tests__/index.test.ts +5 -5
- package/templates/apps/mcp/src/index.ts +6 -4
- package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
- package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
- package/templates/apps/web/.env.example +58 -5
- package/templates/apps/web/CLAUDE.md +1 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
- package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
- package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
- package/templates/apps/web/app/[locale]/layout.tsx +83 -18
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
- package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
- package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +104 -1
- package/templates/apps/web/app/openapi.json/route.ts +72 -7
- package/templates/apps/web/auth.ts +29 -2
- package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
- package/templates/apps/web/components/signed-in-nav.tsx +17 -3
- package/templates/apps/web/instrumentation.ts +7 -4
- package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
- package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
- package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
- package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
- package/templates/apps/web/lib/__tests__/relay.test.ts +317 -0
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
- package/templates/apps/web/lib/account-config.ts +130 -1
- package/templates/apps/web/lib/api-origin.generated.ts +11 -0
- package/templates/apps/web/lib/closing-footer-path.ts +50 -0
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
- package/templates/apps/web/lib/hub-session/config.ts +104 -0
- package/templates/apps/web/lib/hub-session/index.ts +23 -0
- package/templates/apps/web/lib/hub-session/seal.ts +82 -0
- package/templates/apps/web/lib/hub-session/session.ts +180 -0
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
- package/templates/apps/web/lib/listings-config.ts +31 -0
- package/templates/apps/web/lib/listings.tsx +7 -3
- package/templates/apps/web/lib/meeting-slugs.ts +11 -1
- package/templates/apps/web/lib/next-path.ts +35 -0
- package/templates/apps/web/lib/relay.ts +209 -3
- package/templates/apps/web/lib/server-api.ts +11 -1
- package/templates/apps/web/lib/site-brand.tsx +22 -1
- package/templates/apps/web/lib/site-chrome.ts +3 -0
- package/templates/apps/web/lib/topology.generated.ts +33 -0
- package/templates/apps/web/messages/en.json +32 -3
- package/templates/apps/web/package.json +1 -1
- package/templates/database/CHANGELOG.md +51 -0
- package/templates/database/__tests__/agents.db.test.ts +85 -0
- package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
- package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
- package/templates/database/__tests__/member-area.db.test.ts +245 -0
- package/templates/database/__tests__/person.db.test.ts +584 -0
- package/templates/database/auth/schema.prisma +24 -0
- package/templates/database/config/schema.prisma +13 -0
- package/templates/database/inbox/schema.prisma +111 -0
- package/templates/database/jest.config.cjs +7 -11
- package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
- package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
- package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
- package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
- package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
- package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
- package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
- package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
- package/templates/database/package.json +1 -1
- package/templates/database/scripts/person-backfill.sql +132 -0
- package/templates/database/tenancy.ts +53 -0
- package/templates/tools/check-declared-env.ts +37 -0
- package/templates/tools/declared-env.ts +1163 -0
- package/versions.json +35 -34
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// topology.generated.ts — how this MCP server resolves its workspace store.
|
|
2
|
+
//
|
|
3
|
+
// GENERATED by create-ailk for this site's topology (#7426). Do not edit:
|
|
4
|
+
// every scaffold rewrites it. A hub resolves its workspace through the
|
|
5
|
+
// database; a spoke (`--topology spoke`) has none, so its content tools
|
|
6
|
+
// read the spoke's own MDX and its writes go to the hub at AILK_API_URL.
|
|
7
|
+
import {
|
|
8
|
+
initMcpPublicWorkspace,
|
|
9
|
+
mcpPublicWorkspace,
|
|
10
|
+
type McpWorkspaceResolution,
|
|
11
|
+
} from "./public-workspace.js";
|
|
12
|
+
|
|
13
|
+
export { registerLicenseKeyStore } from "./license-key-store.js";
|
|
14
|
+
export type { McpWorkspaceResolution };
|
|
15
|
+
|
|
16
|
+
export const TOPOLOGY: "hub" | "spoke" = "hub";
|
|
17
|
+
|
|
18
|
+
export async function initPublicWorkspace(): Promise<void> {
|
|
19
|
+
await initMcpPublicWorkspace();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function publicWorkspace(): McpWorkspaceResolution {
|
|
23
|
+
return mcpPublicWorkspace();
|
|
24
|
+
}
|
|
@@ -93,17 +93,26 @@ CONTENT_ADAPTER=mdx
|
|
|
93
93
|
# NEXT_PUBLIC_AILK_API_URL=http://localhost:3002
|
|
94
94
|
|
|
95
95
|
# =============================================================================
|
|
96
|
-
# Listicle directory (#7521) —
|
|
96
|
+
# Listicle directory (#7521) — switched on by the site's listing key (#7872)
|
|
97
97
|
# =============================================================================
|
|
98
98
|
# The site's public listing key: the SAME value apps/api reads as
|
|
99
99
|
# AILK_LISTING_PUBLIC_KEY (single-tenant) or holds as a SiteListingConfig
|
|
100
100
|
# publicKey row (multi-tenant). Public by design — it names a site, it grants
|
|
101
|
-
# nothing
|
|
102
|
-
#
|
|
103
|
-
#
|
|
104
|
-
#
|
|
101
|
+
# nothing. When it is set and the paid @working-theory/ui-signal-collectors
|
|
102
|
+
# package is installed, the page a live listicle names as its landing page
|
|
103
|
+
# shows that listicle's accepted entries, and each entry gets a page at
|
|
104
|
+
# /<landingPage>/<slug>. Unset, the listings module is off and no page reads
|
|
105
105
|
# the directory. One web deployment is one site: a hub's spokes are separate
|
|
106
106
|
# web deployments, each with its own key.
|
|
107
|
+
#
|
|
108
|
+
# Two names, one reader (lib/listings-config.ts):
|
|
109
|
+
# 1. NEXT_PUBLIC_AILK_LISTING_SITE_KEY wins when set. It is inlined into the
|
|
110
|
+
# build, so changing or rotating it needs a rebuild — and while it is set,
|
|
111
|
+
# a runtime change to AILK_LISTING_PUBLIC_KEY has no effect.
|
|
112
|
+
# 2. AILK_LISTING_PUBLIC_KEY is the fallback (server-read), so a site
|
|
113
|
+
# configured before this name existed keeps its directory on upgrade.
|
|
114
|
+
# An empty value counts as unset.
|
|
115
|
+
# NEXT_PUBLIC_AILK_LISTING_SITE_KEY=
|
|
107
116
|
# AILK_LISTING_PUBLIC_KEY=
|
|
108
117
|
|
|
109
118
|
# =============================================================================
|
|
@@ -191,6 +200,36 @@ CONTENT_ADAPTER=mdx
|
|
|
191
200
|
#
|
|
192
201
|
# AILK_AUTH_TRUSTED_PROXIES=
|
|
193
202
|
|
|
203
|
+
# =============================================================================
|
|
204
|
+
# Sign-in through the hub — a spoke scaffolded with --hub-sign-in (#7894)
|
|
205
|
+
# =============================================================================
|
|
206
|
+
# A spoke has no database and no Better Auth of its own. With --hub-sign-in, a
|
|
207
|
+
# visitor signs in on the HUB's own sign-in page and returns to this site's
|
|
208
|
+
# /account; the hub-issued credential lives in a sealed, HttpOnly cookie. All
|
|
209
|
+
# four are server-only — never rename one with a NEXT_PUBLIC_ prefix — and all
|
|
210
|
+
# four must be set, or /account says sign-in is not available yet. The routes
|
|
211
|
+
# under /api/hub-session answer 404 on any site not scaffolded in this mode.
|
|
212
|
+
#
|
|
213
|
+
# The hub's WEB origin (where its /sign-in lives), https: or http: on
|
|
214
|
+
# localhost. create-ailk writes it to .env.local.
|
|
215
|
+
# AILK_HUB_SIGN_IN_URL=https://hub.example.com
|
|
216
|
+
#
|
|
217
|
+
# This site's client id and secret, issued on the hub with
|
|
218
|
+
# pnpm --filter @working-theory/api spokes sign-in-client --id <spoke_id> \
|
|
219
|
+
# --redirect-uri <this site's origin>/api/hub-session/callback
|
|
220
|
+
# The client id is the spoke's id. The secret is shown once; keep it out of git.
|
|
221
|
+
# AILK_HUB_CLIENT_ID=
|
|
222
|
+
# AILK_HUB_CLIENT_SECRET=
|
|
223
|
+
#
|
|
224
|
+
# This site's own cookie key material: at least 32 characters, generated per
|
|
225
|
+
# site (create-ailk writes one to .env.local; `openssl rand -base64 32` makes
|
|
226
|
+
# another). Never the client secret. Changing it signs every visitor out.
|
|
227
|
+
# AILK_HUB_SESSION_SECRET=
|
|
228
|
+
#
|
|
229
|
+
# The redirect URI is built from this site's NEXT_PUBLIC_BASE_URL (else the
|
|
230
|
+
# baseUrl in content/_site.mdx) — never from a request's Host — so set it to
|
|
231
|
+
# the origin you registered on the hub.
|
|
232
|
+
|
|
194
233
|
# =============================================================================
|
|
195
234
|
# Observability
|
|
196
235
|
# =============================================================================
|
|
@@ -228,3 +267,17 @@ CONTENT_ADAPTER=mdx
|
|
|
228
267
|
# (see the relay block above, #7644), so the baseline CSP's
|
|
229
268
|
# `connect-src 'self'` already covers them — no CSP extension is needed for
|
|
230
269
|
# them, whatever NEXT_PUBLIC_AILK_API_URL is set to.
|
|
270
|
+
#
|
|
271
|
+
# On a SPOKE (create-ailk --topology spoke, #7872) this site has no apps/api:
|
|
272
|
+
# /meet lists the meeting types the hub stores for this spoke's workspace and
|
|
273
|
+
# books through the hub at AILK_API_URL with AILK_SPOKE_KEY. Then:
|
|
274
|
+
# - the FIRST segment (the owner) must name the provider account the hub
|
|
275
|
+
# uses for this spoke's workspace (its scheduling connection). It composes
|
|
276
|
+
# each booker's calLink; it is not an access control — the hub checks a
|
|
277
|
+
# spoke booking's slug, never its owner.
|
|
278
|
+
# - the SECOND segment is ignored: a workspace with no stored meeting-type
|
|
279
|
+
# list shows no meetings, because the hub books only a slug it stores.
|
|
280
|
+
# - the booker's two calls count against the relay's per-visitor budget,
|
|
281
|
+
# which holds per visitor only behind an edge that overwrites
|
|
282
|
+
# x-forwarded-for and x-real-ip (Vercel does); elsewhere every visitor
|
|
283
|
+
# shares one budget.
|
|
@@ -139,6 +139,7 @@ A visitor's browser never calls the hub. Lead capture, newsletter signup, callba
|
|
|
139
139
|
- **Only the list.** `RELAY_ROUTES` is the fixed set of (method, path) pairs: `POST /v1/leads/{submit,subscribe,callback}`, `GET /v1/schedule/availability`, `POST /v1/schedule/book`, `POST /v1/commerce/invoices`. Anything else is a 404 with no hub call. Adding a route is a reviewed change to that list, its test and `.env.example`.
|
|
140
140
|
- **No cross-site POST.** A `Sec-Fetch-Site` other than `same-origin`, or an `Origin` not on this site's own host, is refused 403 before the hub is called.
|
|
141
141
|
- **Headers are an allow-list.** The visitor's `x-forwarded-for` (else `x-real-ip`) and `user-agent` pass through so per-visitor rate limits still count each visitor; no `authorization`, client-sent `x-ailk-*` or `host` is forwarded, and a `cookie` goes only on `POST /v1/commerce/invoices`, the one session route.
|
|
142
|
+
- **The relay has its own per-visitor budget (#7707).** Each visitor gets 8 calls per 10 minutes across the five visitor routes, and the session route (`POST /v1/commerce/invoices`) gets its own, wider budget of 30 per 10 minutes; the call that exceeds either gets `429 rate_limited` before any hub call. The budget is held in this instance's own process (no shared store, no env variable) and holds only where the site's edge overwrites `x-forwarded-for` and `x-real-ip` — Vercel does; a bare self-hosted Next server does not.
|
|
142
143
|
- `/v1` is exempt from next-intl's locale rewrite in `middleware.ts`, as `/api` is.
|
|
143
144
|
|
|
144
145
|
## AEO & performance
|
|
Binary file
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The account area on a spoke that signs in through its hub (#7894 D10, D12;
|
|
3
|
+
* spec docs/specs/7894-the-hub-signs-in-its-spec.md AC-5, AC-7).
|
|
4
|
+
*
|
|
5
|
+
* The generated web seam is mocked to its `signIn: "hub"` render: the
|
|
6
|
+
* session comes from `readSession`, `forwardedCookieInit()` re-targets to the
|
|
7
|
+
* hub credential, and `SIGN_IN_PROVIDER` is `"hub"`. It sits under
|
|
8
|
+
* `account/__tests__`, which a hub-mode spoke drops with the other account
|
|
9
|
+
* suites (spec 7894 D9), so it runs in this monorepo only.
|
|
10
|
+
*
|
|
11
|
+
* Covers:
|
|
12
|
+
* - AC-7: with the hub answering 404 to the base reads (the hub credential
|
|
13
|
+
* is admitted on none of them, D6), `loadInboxItems` loads `[]` for
|
|
14
|
+
* `result` and `purchase` with `failed` false — an empty Inbox, never an
|
|
15
|
+
* error row — and every read carries the Bearer credential, not a cookie.
|
|
16
|
+
* - AC-5: the settings page renders no `NameForm` and no deletion request,
|
|
17
|
+
* and links to `<AILK_HUB_SIGN_IN_URL>/account/settings` instead; Billing
|
|
18
|
+
* is absent (its read answers 404).
|
|
19
|
+
*/
|
|
20
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
|
|
24
|
+
import { render, screen } from "@testing-library/react";
|
|
25
|
+
|
|
26
|
+
jest.mock("next-intl/server", () => {
|
|
27
|
+
const msgs: unknown = require("../../../../../messages/en.json");
|
|
28
|
+
const dig = (obj: unknown, path: string): unknown =>
|
|
29
|
+
path.split(".").reduce<unknown>(
|
|
30
|
+
(acc, k) => (acc !== null && typeof acc === "object" ? (acc as Record<string, unknown>)[k] : undefined),
|
|
31
|
+
obj,
|
|
32
|
+
);
|
|
33
|
+
return {
|
|
34
|
+
__esModule: true,
|
|
35
|
+
getTranslations: async (arg: string | { namespace?: string; locale?: string }) => {
|
|
36
|
+
const ns = typeof arg === "string" ? arg : (arg.namespace ?? "");
|
|
37
|
+
const messages = dig(msgs, ns);
|
|
38
|
+
return (key: string): string => {
|
|
39
|
+
const value = dig(messages, key);
|
|
40
|
+
return typeof value === "string" ? value : `${ns}.${key}`;
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const mockHeaders = jest.fn();
|
|
47
|
+
jest.mock("next/headers", () => ({
|
|
48
|
+
__esModule: true,
|
|
49
|
+
headers: (...args: unknown[]) => mockHeaders(...args),
|
|
50
|
+
cookies: jest.fn().mockResolvedValue({ toString: () => "" }),
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
const mockReadSession = jest.fn();
|
|
54
|
+
// eslint-disable-next-line no-restricted-syntax -- #507: the seam is generated per build (#7894 D8); its `signIn: "hub"` render is the boundary under test, and no env or HTTP surface selects it.
|
|
55
|
+
jest.mock("../../../../../lib/topology.generated", () => ({
|
|
56
|
+
__esModule: true,
|
|
57
|
+
TOPOLOGY: "spoke",
|
|
58
|
+
SIGN_IN_ENABLED: true,
|
|
59
|
+
SIGN_IN_PROVIDER: "hub",
|
|
60
|
+
SIGN_IN_PATH: "/account",
|
|
61
|
+
SIGN_UP_PATH: null,
|
|
62
|
+
SIGN_OUT_ENDPOINT: "/api/hub-session/sign-out",
|
|
63
|
+
readSession: (...args: unknown[]) => mockReadSession(...args),
|
|
64
|
+
registerSiteStores: () => Promise.resolve(),
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
const mockLeadsSubmit = jest.fn();
|
|
68
|
+
const notFound = { ok: false, status: 404, body: { ok: false, error: { code: "not_found", message: "Not found" } } };
|
|
69
|
+
const mockWaitlistMe = jest.fn();
|
|
70
|
+
const mockFlowCheckoutsMe = jest.fn();
|
|
71
|
+
const mockSubscriptionMe = jest.fn();
|
|
72
|
+
const mockMemberRead = jest.fn();
|
|
73
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
74
|
+
__esModule: true,
|
|
75
|
+
waitlistSignups: { me: (...args: unknown[]) => mockWaitlistMe(...args) },
|
|
76
|
+
flowCheckouts: { me: (...args: unknown[]) => mockFlowCheckoutsMe(...args) },
|
|
77
|
+
subscription: {
|
|
78
|
+
me: (...args: unknown[]) => mockSubscriptionMe(...args),
|
|
79
|
+
portalSession: jest.fn(),
|
|
80
|
+
},
|
|
81
|
+
leads: { submit: (...args: unknown[]) => mockLeadsSubmit(...args) },
|
|
82
|
+
// #7920 — the member kinds read the hub with the credential; 404 here.
|
|
83
|
+
member: {
|
|
84
|
+
pieces: (...args: unknown[]) => mockMemberRead(...args),
|
|
85
|
+
messages: (...args: unknown[]) => mockMemberRead(...args),
|
|
86
|
+
},
|
|
87
|
+
visitorForwardHeaders: () => null,
|
|
88
|
+
hubSignIn: { me: jest.fn() },
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
jest.mock("better-auth/react", () => ({
|
|
92
|
+
__esModule: true,
|
|
93
|
+
createAuthClient: () => ({ signOut: jest.fn(), updateUser: jest.fn() }),
|
|
94
|
+
}));
|
|
95
|
+
|
|
96
|
+
jest.mock("next/navigation", () => ({
|
|
97
|
+
__esModule: true,
|
|
98
|
+
useRouter: () => ({ push: jest.fn(), refresh: jest.fn() }),
|
|
99
|
+
usePathname: () => "/account/settings",
|
|
100
|
+
redirect: jest.fn(),
|
|
101
|
+
}));
|
|
102
|
+
|
|
103
|
+
import { seal } from "../../../../../lib/hub-session/seal";
|
|
104
|
+
import { accountSession, loadInboxItems } from "../account-data";
|
|
105
|
+
import { submitSupportRequest } from "../support-actions";
|
|
106
|
+
import Page from "../settings/page";
|
|
107
|
+
|
|
108
|
+
const HUB = "https://hub.example.com";
|
|
109
|
+
const SESSION_SECRET = "s".repeat(43);
|
|
110
|
+
const CREDENTIAL = `ailk_hst_${"t".repeat(43)}`;
|
|
111
|
+
const USER = {
|
|
112
|
+
name: "Ada Lovelace",
|
|
113
|
+
email: "ada@example.com",
|
|
114
|
+
emailVerified: true,
|
|
115
|
+
createdAt: "2026-09-01T00:00:00.000Z",
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const ENV = process.env;
|
|
119
|
+
let contentRoot: string;
|
|
120
|
+
|
|
121
|
+
beforeEach(() => {
|
|
122
|
+
contentRoot = mkdtempSync(join(tmpdir(), "ailk-hub-mode-"));
|
|
123
|
+
writeFileSync(join(contentRoot, "_site.mdx"), '---\nsiteName: "Test"\n---\n');
|
|
124
|
+
process.env = {
|
|
125
|
+
...ENV,
|
|
126
|
+
AILK_CONTENT_ROOT: contentRoot,
|
|
127
|
+
AILK_HUB_SIGN_IN_URL: HUB,
|
|
128
|
+
AILK_HUB_CLIENT_ID: "spoke_0001",
|
|
129
|
+
AILK_HUB_CLIENT_SECRET: `ailk_hcs_${"c".repeat(43)}`,
|
|
130
|
+
AILK_HUB_SESSION_SECRET: SESSION_SECRET,
|
|
131
|
+
NEXT_PUBLIC_BASE_URL: "https://spoke.example.com",
|
|
132
|
+
};
|
|
133
|
+
const sealed = seal(
|
|
134
|
+
{ v: 1, accessToken: CREDENTIAL, expiresAt: Date.now() + 60_000, user: USER },
|
|
135
|
+
SESSION_SECRET,
|
|
136
|
+
"session",
|
|
137
|
+
"__Host-ailk-hub-session",
|
|
138
|
+
);
|
|
139
|
+
mockHeaders.mockReset().mockResolvedValue(new Headers({ cookie: `__Host-ailk-hub-session=${sealed}` }));
|
|
140
|
+
mockReadSession.mockReset().mockResolvedValue({ user: USER });
|
|
141
|
+
mockWaitlistMe.mockReset().mockResolvedValue(notFound);
|
|
142
|
+
mockFlowCheckoutsMe.mockReset().mockResolvedValue(notFound);
|
|
143
|
+
mockSubscriptionMe.mockReset().mockResolvedValue(notFound);
|
|
144
|
+
mockMemberRead.mockReset().mockResolvedValue(notFound);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
afterEach(() => {
|
|
148
|
+
rmSync(contentRoot, { recursive: true, force: true });
|
|
149
|
+
process.env = ENV;
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("AC-7 — the base Inbox kinds on a hub-mode spoke", () => {
|
|
153
|
+
it("loads [] for result and purchase, with failed false, when the hub answers 404", async () => {
|
|
154
|
+
const session = await accountSession("en");
|
|
155
|
+
if (session === null) throw new Error("expected a session");
|
|
156
|
+
expect(session.ctx.init).toEqual({ headers: { authorization: `Bearer ${CREDENTIAL}` } });
|
|
157
|
+
|
|
158
|
+
const inbox = await loadInboxItems(session.ctx);
|
|
159
|
+
expect(inbox).toEqual({ items: [], failed: false });
|
|
160
|
+
expect(mockFlowCheckoutsMe).toHaveBeenCalledWith({
|
|
161
|
+
headers: { authorization: `Bearer ${CREDENTIAL}` },
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("has no session when the seam reads none", async () => {
|
|
166
|
+
mockReadSession.mockResolvedValue(null);
|
|
167
|
+
await expect(accountSession("en")).resolves.toBeNull();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("AC-5 — the settings page on a hub-mode spoke", () => {
|
|
172
|
+
async function renderPage(): Promise<void> {
|
|
173
|
+
render(await Page({ params: Promise.resolve({ locale: "en" }) }));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
it("renders no NameForm and no deletion request, and links to the hub's account settings", async () => {
|
|
177
|
+
await renderPage();
|
|
178
|
+
expect(screen.queryByTestId("account-name-form")).not.toBeInTheDocument();
|
|
179
|
+
expect(screen.queryByTestId("account-deletion-request")).not.toBeInTheDocument();
|
|
180
|
+
const href = `${HUB}/account/settings`;
|
|
181
|
+
expect(screen.getByTestId("account-settings-manage-on-hub")).toHaveAttribute("href", href);
|
|
182
|
+
expect(screen.getByTestId("account-privacy-manage-on-hub")).toHaveAttribute("href", href);
|
|
183
|
+
expect(screen.getByTestId("account-settings-name")).toHaveTextContent("Ada Lovelace");
|
|
184
|
+
expect(screen.getByText(/You sign in through our main site/)).toBeInTheDocument();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("files a posted deletion request as a support request — the hub owns the account (SEC-7894-11)", async () => {
|
|
188
|
+
mockLeadsSubmit.mockResolvedValue({ ok: true, body: {} });
|
|
189
|
+
const form = new FormData();
|
|
190
|
+
form.set("kind", "deletion");
|
|
191
|
+
form.set("subject", "Delete me");
|
|
192
|
+
form.set("message", "Please delete my account.");
|
|
193
|
+
await expect(submitSupportRequest({ kind: "idle" }, form)).resolves.toEqual({ kind: "success" });
|
|
194
|
+
expect(mockLeadsSubmit).toHaveBeenCalledWith(
|
|
195
|
+
expect.objectContaining({ email: USER.email, payload: expect.objectContaining({ kind: "support" }) }),
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("omits Billing, whose read answers 404 for the hub credential", async () => {
|
|
200
|
+
await renderPage();
|
|
201
|
+
expect(document.getElementById("billing")).not.toBeInTheDocument();
|
|
202
|
+
expect(mockSubscriptionMe).toHaveBeenCalledWith({
|
|
203
|
+
headers: { authorization: `Bearer ${CREDENTIAL}` },
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
* only: no For-you stack, no bottom sheet, no large title. The For you
|
|
18
18
|
* tab lists every For-you item in one inset card, and ✕ dismisses one.
|
|
19
19
|
* At `md`+ only the Inbox panel shows (the aside holds For you).
|
|
20
|
+
* 7. #7920 — the member area's `piece` and `message` kinds and the
|
|
21
|
+
* needs-client For-you source. Each reads the hub with `ctx.init`
|
|
22
|
+
* unchanged (the caller's own credential); 404 and an unverified 401/403
|
|
23
|
+
* load `[]`, any other failure rejects (D12). With no hub credential on
|
|
24
|
+
* `ctx.init` — a cookie-session site — they make no call and load `[]`.
|
|
20
25
|
*/
|
|
21
26
|
import { fireEvent, render, screen, within } from "@testing-library/react";
|
|
22
27
|
|
|
@@ -64,11 +69,17 @@ jest.mock("../../../../../auth", () => ({
|
|
|
64
69
|
const mockSignupMe = jest.fn();
|
|
65
70
|
const mockCheckoutsMe = jest.fn();
|
|
66
71
|
const mockSubscriptionMe = jest.fn();
|
|
72
|
+
const mockMemberPieces = jest.fn();
|
|
73
|
+
const mockMemberMessages = jest.fn();
|
|
67
74
|
jest.mock("@working-theory/api-client", () => ({
|
|
68
75
|
__esModule: true,
|
|
69
76
|
waitlistSignups: { me: (...args: unknown[]) => mockSignupMe(...args) },
|
|
70
77
|
flowCheckouts: { me: (...args: unknown[]) => mockCheckoutsMe(...args) },
|
|
71
78
|
subscription: { me: (...args: unknown[]) => mockSubscriptionMe(...args) },
|
|
79
|
+
member: {
|
|
80
|
+
pieces: (...args: unknown[]) => mockMemberPieces(...args),
|
|
81
|
+
messages: (...args: unknown[]) => mockMemberMessages(...args),
|
|
82
|
+
},
|
|
72
83
|
}));
|
|
73
84
|
|
|
74
85
|
const mockRefresh = jest.fn();
|
|
@@ -78,9 +89,18 @@ jest.mock("next/navigation", () => ({
|
|
|
78
89
|
usePathname: () => "/account",
|
|
79
90
|
}));
|
|
80
91
|
|
|
81
|
-
import {
|
|
82
|
-
|
|
83
|
-
|
|
92
|
+
import {
|
|
93
|
+
PhoneTabProvider,
|
|
94
|
+
type AccountContext,
|
|
95
|
+
type AccountTranslate,
|
|
96
|
+
} from "@working-theory/templates/account";
|
|
97
|
+
import { getTranslations } from "next-intl/server";
|
|
98
|
+
|
|
99
|
+
import {
|
|
100
|
+
accountExperimentKeys,
|
|
101
|
+
accountForYouSources,
|
|
102
|
+
accountInboxKinds,
|
|
103
|
+
} from "../../../../../lib/account-config";
|
|
84
104
|
import Page from "../page";
|
|
85
105
|
|
|
86
106
|
const SESSION = {
|
|
@@ -121,6 +141,80 @@ const NO_SUBSCRIPTION = {
|
|
|
121
141
|
body: { actionRequired: false, status: null, accessRevokesAt: null, portalAvailable: false },
|
|
122
142
|
};
|
|
123
143
|
|
|
144
|
+
const PIECE = {
|
|
145
|
+
id: "piece_01920000-0000-7000-8000-000000000001",
|
|
146
|
+
title: "Spring newsletter",
|
|
147
|
+
stage: "Drafting",
|
|
148
|
+
needsClient: false,
|
|
149
|
+
createdAt: "2026-09-05T09:00:00.000Z",
|
|
150
|
+
updatedAt: "2026-09-06T09:00:00.000Z",
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const PIECE_NEEDS_CLIENT = {
|
|
154
|
+
id: "piece_01920000-0000-7000-8000-000000000002",
|
|
155
|
+
title: "About page",
|
|
156
|
+
stage: "Review",
|
|
157
|
+
needsClient: true,
|
|
158
|
+
createdAt: "2026-09-02T09:00:00.000Z",
|
|
159
|
+
updatedAt: "2026-09-07T09:00:00.000Z",
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const MESSAGE = {
|
|
163
|
+
id: "intx_01920000-0000-7000-8000-000000000003",
|
|
164
|
+
subject: "Kickoff notes",
|
|
165
|
+
body: "Thanks for the call.",
|
|
166
|
+
file: null,
|
|
167
|
+
createdAt: "2026-09-08T09:00:00.000Z",
|
|
168
|
+
readAt: null,
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const MESSAGE_WITH_FILE = {
|
|
172
|
+
id: "intx_01920000-0000-7000-8000-000000000004",
|
|
173
|
+
subject: "Your brand guide",
|
|
174
|
+
body: "Attached.",
|
|
175
|
+
file: { filename: "brand-guide.pdf", contentType: "application/pdf" },
|
|
176
|
+
createdAt: "2026-09-09T09:00:00.000Z",
|
|
177
|
+
readAt: "2026-09-09T10:00:00.000Z",
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/** The hub credential a spoke signed in through its hub forwards (#7894 D12). */
|
|
181
|
+
const BEARER = { headers: { authorization: `Bearer ailk_hst_${"t".repeat(43)}` } };
|
|
182
|
+
|
|
183
|
+
const notFound = { ok: false, status: 404, body: { ok: false, error: { code: "not_found", message: "Not found" } } };
|
|
184
|
+
|
|
185
|
+
/** The context a hub-mode spoke hands every contribution, signed in. */
|
|
186
|
+
async function memberCtx(
|
|
187
|
+
overrides: Partial<AccountContext> = {},
|
|
188
|
+
): Promise<AccountContext> {
|
|
189
|
+
const locale = overrides.locale ?? "en";
|
|
190
|
+
// The contract's translator takes any key (account-data.ts's reasoning):
|
|
191
|
+
// forward through the mock's runtime signature.
|
|
192
|
+
const translate = (await getTranslations({ locale, namespace: "account" })) as (
|
|
193
|
+
key: string,
|
|
194
|
+
values?: Record<string, unknown>,
|
|
195
|
+
) => string;
|
|
196
|
+
const t: AccountTranslate = (key, values) => translate(key, values);
|
|
197
|
+
return {
|
|
198
|
+
locale,
|
|
199
|
+
user: { name: "Ada", email: "ada@example.com", emailVerified: true },
|
|
200
|
+
t,
|
|
201
|
+
init: BEARER,
|
|
202
|
+
...overrides,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function kindOf(id: string): (typeof accountInboxKinds)[number] {
|
|
207
|
+
const kind = accountInboxKinds.find((k) => k.kind === id);
|
|
208
|
+
if (!kind) throw new Error(`no ${id} kind`);
|
|
209
|
+
return kind;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function needsClientSource(): (typeof accountForYouSources)[number] {
|
|
213
|
+
const source = accountForYouSources.find((s) => s.id === "piece-needs-client");
|
|
214
|
+
if (!source) throw new Error("no piece-needs-client source");
|
|
215
|
+
return source;
|
|
216
|
+
}
|
|
217
|
+
|
|
124
218
|
/** Renders the page on a phone tab — the frame's PhoneTabProvider. */
|
|
125
219
|
async function renderPage(locale = "en", tab = "inbox"): Promise<void> {
|
|
126
220
|
const element = await Page({ params: Promise.resolve({ locale }) });
|
|
@@ -144,6 +238,8 @@ beforeEach(() => {
|
|
|
144
238
|
mockSignupMe.mockReset().mockResolvedValue({ ok: true, body: SIGNUP });
|
|
145
239
|
mockCheckoutsMe.mockReset().mockResolvedValue({ ok: true, body: [CHECKOUT] });
|
|
146
240
|
mockSubscriptionMe.mockReset().mockResolvedValue(NO_SUBSCRIPTION);
|
|
241
|
+
mockMemberPieces.mockReset().mockResolvedValue({ ok: true, body: { items: [PIECE, PIECE_NEEDS_CLIENT] } });
|
|
242
|
+
mockMemberMessages.mockReset().mockResolvedValue({ ok: true, body: { items: [MESSAGE, MESSAGE_WITH_FILE] } });
|
|
147
243
|
mockRefresh.mockReset();
|
|
148
244
|
});
|
|
149
245
|
|
|
@@ -375,3 +471,160 @@ describe("Hub unavailable (D12)", () => {
|
|
|
375
471
|
expect(within(panel).queryByTestId("inbox-error")).not.toBeInTheDocument();
|
|
376
472
|
});
|
|
377
473
|
});
|
|
474
|
+
|
|
475
|
+
describe("the member kinds — piece and message (#7920, spec 7423 D8 · D12)", () => {
|
|
476
|
+
it("declares piece and message as Inbox kinds in the updates section, labelled from the account namespace", async () => {
|
|
477
|
+
const ctx = await memberCtx();
|
|
478
|
+
expect(kindOf("piece").section).toBe("updates");
|
|
479
|
+
expect(kindOf("message").section).toBe("updates");
|
|
480
|
+
expect(ctx.t(kindOf("piece").label)).toBe("My pieces");
|
|
481
|
+
expect(ctx.t(kindOf("message").label)).toBe("My messages");
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it("each kind reads the hub with ctx.init unchanged — the caller's own credential", async () => {
|
|
485
|
+
const ctx = await memberCtx();
|
|
486
|
+
const pieces = await kindOf("piece").load(ctx);
|
|
487
|
+
const messages = await kindOf("message").load(ctx);
|
|
488
|
+
expect(mockMemberPieces).toHaveBeenCalledWith(BEARER);
|
|
489
|
+
expect(mockMemberMessages).toHaveBeenCalledWith(BEARER);
|
|
490
|
+
expect(pieces.map((p) => [p.id, p.title, p.at])).toEqual([
|
|
491
|
+
[PIECE.id, "Spring newsletter", PIECE.updatedAt],
|
|
492
|
+
[PIECE_NEEDS_CLIENT.id, "About page", PIECE_NEEDS_CLIENT.updatedAt],
|
|
493
|
+
]);
|
|
494
|
+
expect(messages.map((m) => [m.id, m.title, m.at])).toEqual([
|
|
495
|
+
[MESSAGE.id, "Kickoff notes", MESSAGE.createdAt],
|
|
496
|
+
[MESSAGE_WITH_FILE.id, "Your brand guide", MESSAGE_WITH_FILE.createdAt],
|
|
497
|
+
]);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it.each(["piece", "message"])("%s loads [] on a 404 — nothing for this caller", async (id) => {
|
|
501
|
+
mockMemberPieces.mockResolvedValue(notFound);
|
|
502
|
+
mockMemberMessages.mockResolvedValue(notFound);
|
|
503
|
+
await expect(kindOf(id).load(await memberCtx())).resolves.toEqual([]);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it.each([
|
|
507
|
+
["piece", 401],
|
|
508
|
+
["piece", 403],
|
|
509
|
+
["message", 401],
|
|
510
|
+
["message", 403],
|
|
511
|
+
])("%s loads [] on a %i while the email is unverified", async (id, status) => {
|
|
512
|
+
const refusal = { ok: false, status, body: { ok: false, error: { code: "email_unverified" } } };
|
|
513
|
+
mockMemberPieces.mockResolvedValue(refusal);
|
|
514
|
+
mockMemberMessages.mockResolvedValue(refusal);
|
|
515
|
+
const ctx = await memberCtx({ user: { name: "Ada", email: "ada@example.com", emailVerified: false } });
|
|
516
|
+
await expect(kindOf(id).load(ctx)).resolves.toEqual([]);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it.each([
|
|
520
|
+
["piece", 401],
|
|
521
|
+
["piece", 403],
|
|
522
|
+
["piece", 500],
|
|
523
|
+
["piece", 0],
|
|
524
|
+
["message", 401],
|
|
525
|
+
["message", 403],
|
|
526
|
+
["message", 502],
|
|
527
|
+
["message", 0],
|
|
528
|
+
])("%s rejects on a %i for a verified email — never a partial list shown as whole", async (id, status) => {
|
|
529
|
+
const failure = {
|
|
530
|
+
ok: false,
|
|
531
|
+
status,
|
|
532
|
+
body: { ok: false, error: { code: status === 0 ? "insecure_transport" : "failed", message: "x" } },
|
|
533
|
+
};
|
|
534
|
+
mockMemberPieces.mockResolvedValue(failure);
|
|
535
|
+
mockMemberMessages.mockResolvedValue(failure);
|
|
536
|
+
await expect(kindOf(id).load(await memberCtx())).rejects.toMatchObject({ status });
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it("with no hub credential on ctx.init (a cookie-session site) both kinds and the source load [] without a call", async () => {
|
|
540
|
+
const ctx = await memberCtx({ init: { headers: { cookie: "session=abc" } } });
|
|
541
|
+
await expect(kindOf("piece").load(ctx)).resolves.toEqual([]);
|
|
542
|
+
await expect(kindOf("message").load(ctx)).resolves.toEqual([]);
|
|
543
|
+
await expect(needsClientSource().load(ctx)).resolves.toEqual([]);
|
|
544
|
+
expect(mockMemberPieces).not.toHaveBeenCalled();
|
|
545
|
+
expect(mockMemberMessages).not.toHaveBeenCalled();
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it("the cookie-session Inbox stays as it was: no member rows, no error row, no member call", async () => {
|
|
549
|
+
await renderPage();
|
|
550
|
+
expect(screen.queryByTestId("inbox-error")).not.toBeInTheDocument();
|
|
551
|
+
expect(screen.queryByText("Spring newsletter")).not.toBeInTheDocument();
|
|
552
|
+
expect(screen.queryByText("Kickoff notes")).not.toBeInTheDocument();
|
|
553
|
+
expect(mockMemberPieces).not.toHaveBeenCalled();
|
|
554
|
+
expect(mockMemberMessages).not.toHaveBeenCalled();
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it("a piece's detail shows its stage and dates", async () => {
|
|
558
|
+
const ctx = await memberCtx();
|
|
559
|
+
const [first] = await kindOf("piece").load(ctx);
|
|
560
|
+
const detail = kindOf("piece").renderDetail(first!, ctx);
|
|
561
|
+
expect(detail.status).toBe("Drafting");
|
|
562
|
+
expect(detail.fields?.heading).toBe("My pieces");
|
|
563
|
+
expect(detail.fields?.items.map((i) => i.label)).toEqual(["Stage", "Started", "Updated"]);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it("a message with an asset shows a file action that opens the member file read", async () => {
|
|
567
|
+
const ctx = await memberCtx();
|
|
568
|
+
const items = await kindOf("message").load(ctx);
|
|
569
|
+
const withFile = items.find((m) => m.id === MESSAGE_WITH_FILE.id)!;
|
|
570
|
+
const detail = kindOf("message").renderDetail(withFile, ctx);
|
|
571
|
+
expect(detail.actions).toEqual([
|
|
572
|
+
{
|
|
573
|
+
key: "file",
|
|
574
|
+
label: "Download brand-guide.pdf",
|
|
575
|
+
variant: "default",
|
|
576
|
+
href: `/account/inbox/message/${MESSAGE_WITH_FILE.id}/file`,
|
|
577
|
+
},
|
|
578
|
+
]);
|
|
579
|
+
expect(detail.fields?.items).toContainEqual({ label: "File", value: "brand-guide.pdf" });
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it("the file action carries the locale prefix off the default locale", async () => {
|
|
583
|
+
const ctx = await memberCtx({ locale: "fr" });
|
|
584
|
+
const items = await kindOf("message").load(ctx);
|
|
585
|
+
const withFile = items.find((m) => m.id === MESSAGE_WITH_FILE.id)!;
|
|
586
|
+
expect(kindOf("message").renderDetail(withFile, ctx).actions?.[0]?.href).toBe(
|
|
587
|
+
`/fr/account/inbox/message/${MESSAGE_WITH_FILE.id}/file`,
|
|
588
|
+
);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it("a message without an asset has no file action", async () => {
|
|
592
|
+
const ctx = await memberCtx();
|
|
593
|
+
const [plain] = await kindOf("message").load(ctx);
|
|
594
|
+
const detail = kindOf("message").renderDetail(plain!, ctx);
|
|
595
|
+
expect(detail.actions).toBeUndefined();
|
|
596
|
+
expect(detail.fields?.items.map((i) => i.label)).not.toContain("File");
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
describe("the needs-client For-you source (#7920)", () => {
|
|
601
|
+
it("a needsClient piece is a needs-you For-you item with an Open action to its Inbox row", async () => {
|
|
602
|
+
const items = await needsClientSource().load(await memberCtx());
|
|
603
|
+
expect(items).toEqual([
|
|
604
|
+
{
|
|
605
|
+
id: `piece:${PIECE_NEEDS_CLIENT.id}`,
|
|
606
|
+
type: { id: "piece", label: "Piece", tone: "needs-you" },
|
|
607
|
+
title: "About page",
|
|
608
|
+
time: { kind: "at", at: PIECE_NEEDS_CLIENT.updatedAt },
|
|
609
|
+
action: { kind: "open", href: `/account/inbox/piece/${PIECE_NEEDS_CLIENT.id}` },
|
|
610
|
+
dismissible: true,
|
|
611
|
+
},
|
|
612
|
+
]);
|
|
613
|
+
expect(mockMemberPieces).toHaveBeenCalledWith(BEARER);
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
it("off the default locale the Open href carries the prefix", async () => {
|
|
617
|
+
const [item] = await needsClientSource().load(await memberCtx({ locale: "fr" }));
|
|
618
|
+
expect(item?.action).toEqual({
|
|
619
|
+
kind: "open",
|
|
620
|
+
href: `/fr/account/inbox/piece/${PIECE_NEEDS_CLIENT.id}`,
|
|
621
|
+
});
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it("loads [] on a 404 and rejects on any other failure", async () => {
|
|
625
|
+
mockMemberPieces.mockResolvedValue(notFound);
|
|
626
|
+
await expect(needsClientSource().load(await memberCtx())).resolves.toEqual([]);
|
|
627
|
+
mockMemberPieces.mockResolvedValue({ ok: false, status: 500, body: {} });
|
|
628
|
+
await expect(needsClientSource().load(await memberCtx())).rejects.toMatchObject({ status: 500 });
|
|
629
|
+
});
|
|
630
|
+
});
|
|
@@ -34,9 +34,9 @@ import { headers } from "next/headers";
|
|
|
34
34
|
import { getTranslations } from "next-intl/server";
|
|
35
35
|
import { cache, createElement } from "react";
|
|
36
36
|
|
|
37
|
-
import { auth } from "../../../../auth";
|
|
38
37
|
import { accountForYouSources, accountInboxKinds } from "../../../../lib/account-config";
|
|
39
38
|
import { forwardedCookieInit } from "../../../../lib/server-api";
|
|
39
|
+
import { readSession } from "../../../../lib/topology.generated";
|
|
40
40
|
|
|
41
41
|
/** The session's person, plus what the frame shows beyond the contract. */
|
|
42
42
|
export interface AccountSession {
|
|
@@ -48,18 +48,15 @@ export interface AccountSession {
|
|
|
48
48
|
/** The `AccountContext` for this request, or `null` when there is no session. */
|
|
49
49
|
export const accountSession = cache(
|
|
50
50
|
async (locale: string): Promise<AccountSession | null> => {
|
|
51
|
-
|
|
51
|
+
// Through the generated seam (#7894 D10): Better Auth on a hub, the sealed
|
|
52
|
+
// hub-session cookie on a spoke that signs in through its hub.
|
|
53
|
+
const session = await readSession(await headers());
|
|
52
54
|
if (!session) return null;
|
|
53
55
|
// The contract's translator takes any key: a contribution's keys (a
|
|
54
56
|
// kind's `label`, a section's `title`) are data, which next-intl's
|
|
55
57
|
// literal-key typing cannot see. The runtime call is the same function.
|
|
56
58
|
const t = (await getTranslations({ locale, namespace: "account" })) as unknown as AccountTranslate;
|
|
57
|
-
const { name, email, emailVerified, createdAt } = session.user
|
|
58
|
-
name?: string | null;
|
|
59
|
-
email: string;
|
|
60
|
-
emailVerified: boolean;
|
|
61
|
-
createdAt?: Date | string | null;
|
|
62
|
-
};
|
|
59
|
+
const { name, email, emailVerified, createdAt } = session.user;
|
|
63
60
|
return {
|
|
64
61
|
ctx: {
|
|
65
62
|
locale,
|