create-ailk 0.18.0 → 0.19.1
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 +11 -3
- 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/index.d.ts +1 -1
- package/dist/index.js +1 -1
- 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 +32 -0
- package/dist/lib/scaffold-render-sweep.js +76 -3
- package/dist/license-set.d.ts +1 -1
- package/dist/license-set.js +13 -8
- package/dist/module-architecture.js +128 -3
- package/dist/programmatic.js +11 -1
- package/dist/site-roster.js +21 -4
- 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.d.ts +14 -0
- package/dist/surfaces.js +170 -1
- package/dist/template-manifest.d.ts +4 -3
- package/dist/topology.d.ts +7 -2
- package/dist/topology.js +88 -3
- package/package.json +5 -4
- package/templates/.env.example +3 -3
- package/templates/.github/workflows/ci.yml +5 -0
- package/templates/apps/api/.env.example +17 -1
- package/templates/apps/api/CLAUDE.md +31 -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/__tests__/factories/in-memory-prisma.ts +19 -0
- package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +17 -0
- package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
- package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
- package/templates/apps/api/src/lib/consent-store.ts +335 -0
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
- package/templates/apps/api/src/lib/stripe.ts +176 -3
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +272 -39
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +51 -0
- package/templates/apps/api/src/middleware/auth.ts +214 -46
- package/templates/apps/api/src/middleware/rate-limit.ts +5 -0
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
- package/templates/apps/api/src/routes/checkout/session.ts +3 -3
- package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
- package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
- package/templates/apps/api/src/routes/consent/index.ts +18 -0
- package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
- package/templates/apps/api/src/routes/leads/index.ts +19 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +17 -0
- package/templates/apps/api/src/server.ts +218 -16
- package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +173 -4
- package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
- package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
- package/templates/apps/api/src/services/consent.ts +488 -3
- package/templates/apps/api/src/services/lead-routing.ts +135 -14
- package/templates/apps/api/src/services/person.ts +501 -1
- package/templates/apps/api/src/services/project-listings.ts +7 -2
- package/templates/apps/api/src/services/request-confirm.ts +52 -0
- package/templates/apps/api/src/services/revalidation.ts +11 -1
- package/templates/apps/api/vercel.json +2 -2
- package/templates/apps/mcp/.env.example +10 -0
- package/templates/apps/mcp/CLAUDE.md +9 -4
- 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__/audit-single-page.test.ts +15 -3
- package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
- package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
- package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
- 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/boot.ts +5 -23
- package/templates/apps/mcp/src/http-transport.ts +93 -73
- package/templates/apps/mcp/src/index.ts +2 -0
- package/templates/apps/mcp/src/request-context.ts +52 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
- package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
- 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/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +302 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +724 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +346 -41
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +232 -29
- 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 +140 -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 +409 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +65 -380
- 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 +188 -54
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
- 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 +174 -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/contact-actions.ts +81 -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 +193 -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 +30 -4
- 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 +132 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +116 -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 +25 -2
- package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
- package/templates/apps/web/lib/account-config.ts +188 -52
- package/templates/apps/web/lib/auth-email.ts +45 -10
- 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/topology.generated.ts +33 -0
- package/templates/apps/web/messages/en.json +194 -33
- package/templates/database/CHANGELOG.md +37 -0
- package/templates/database/LICENSE +6 -5
- package/templates/database/NOTICE +2 -0
- package/templates/database/README.md +1 -1
- package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
- package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
- package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
- package/templates/database/__tests__/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__/person-contact.db.test.ts +563 -0
- package/templates/database/__tests__/person.db.test.ts +3 -1
- package/templates/database/__tests__/seed.test.ts +54 -2
- package/templates/database/inbox/schema.prisma +166 -25
- 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/migrations/20261014120000_consent_ledger/migration.sql +122 -0
- package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -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
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// account-config.ts — what the /account area is made of (#4984, #7429).
|
|
2
2
|
//
|
|
3
|
-
// The module contribution contract (spec 7423 D8, D8a): a module
|
|
4
|
-
// site's account area by adding entries to the
|
|
5
|
-
// item kinds, User-settings sections
|
|
6
|
-
// entry.
|
|
7
|
-
//
|
|
8
|
-
//
|
|
3
|
+
// The module contribution contract (spec 7423 D8, D8a; #8231 D4): a module
|
|
4
|
+
// extends a site's account area by adding entries to the five arrays below —
|
|
5
|
+
// Inbox item kinds, User-settings sections, For-you sources, New actions and
|
|
6
|
+
// Billing sections — never a rail entry. An absent module is an absent entry.
|
|
7
|
+
// The contract's types live in `@working-theory/templates/account`; a kind's
|
|
8
|
+
// label is a message key in the `account` namespace, and a For-you item's
|
|
9
|
+
// strings are built through the context's `t`.
|
|
9
10
|
//
|
|
10
11
|
// `accountExperimentKeys` — mirrors `nav-config.ts`: the OSS template
|
|
11
12
|
// declares NO experiments — nothing in `apps/web` runs a diagnostic today, so
|
|
@@ -20,51 +21,93 @@
|
|
|
20
21
|
// The base contributions (D9, amended 2026-10-01 by D6a/D8a — #7448) use
|
|
21
22
|
// existing reads only, each with the caller's cookie forwarded (`ctx.init`):
|
|
22
23
|
//
|
|
23
|
-
// - Inbox
|
|
24
|
+
// - Inbox kind `result` — a row of the All tab.
|
|
24
25
|
// - For-you sources `email-unverified` and `payment-due` — needs-you items
|
|
25
|
-
// with an Open action to User settings
|
|
26
|
-
// rows. Neither read carries the time the condition began, so their time
|
|
26
|
+
// with an Open action to User settings' two pages, `/account/general`
|
|
27
|
+
// and `/account/billing` (#8232 D10), never Inbox rows. Neither read carries the time the condition began, so their time
|
|
27
28
|
// line is the date of the read rather than an invented "Waiting N".
|
|
28
29
|
//
|
|
29
30
|
// A 404 means "nothing for this caller" — no signup / no user row / commerce
|
|
30
31
|
// module off — and loads `[]`; so does a 401/403 while the email is
|
|
31
32
|
// unverified (the `email-unverified` item already says so). Any other
|
|
32
33
|
// failure rejects — a 401/403 for a verified email included — so the area
|
|
33
|
-
// shows an error row with Retry instead of an empty Inbox (D12).
|
|
34
|
-
// (the old Upcoming) stays empty in the base area; a module fills it.
|
|
34
|
+
// shows an error row with Retry instead of an empty Inbox (D12).
|
|
35
35
|
//
|
|
36
|
-
// The member
|
|
37
|
-
//
|
|
36
|
+
// The member reads (#7920, #8231 D5–D7) — the client's own records, through
|
|
37
|
+
// one member API with two callers (roadmap D1): a single site's server calls
|
|
38
|
+
// its own API with the caller's session cookie, and a spoke that signs its
|
|
39
|
+
// clients in through the hub calls the hub with the client's hub credential.
|
|
40
|
+
// `ctx.init` already carries the right one (`forwardedCookieInit()`), so no
|
|
41
|
+
// kind here tests the topology:
|
|
38
42
|
//
|
|
43
|
+
// - Inbox kind `purchase` (`GET /v1/member/purchases`) — the Purchases tab.
|
|
44
|
+
// Only a purchase linked by the verified-email rule shows (roadmap D3).
|
|
45
|
+
// - Inbox kind `appointment` (`GET /v1/member/appointments`) — the
|
|
46
|
+
// Scheduled tab, upcoming first, each item flagged by the read's own split.
|
|
39
47
|
// - Inbox kinds `piece` (`GET /v1/member/pieces`) and `message`
|
|
40
|
-
// (`GET /v1/member/messages`). A message with a file has a
|
|
41
|
-
// this site's own member file read,
|
|
42
|
-
// which re-serves the
|
|
48
|
+
// (`GET /v1/member/messages`) — rows of All. A message with a file has a
|
|
49
|
+
// file action to this site's own member file read,
|
|
50
|
+
// `account/inbox/message/[id]/file`, which re-serves the bytes — the
|
|
51
|
+
// browser holds no hub credential.
|
|
43
52
|
// - For-you source `piece-needs-client` — each piece waiting on the client,
|
|
44
53
|
// with an Open action to its Inbox row. It shares the pieces read.
|
|
45
54
|
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
55
|
+
// A build with no tenancy module registers no member route, so each of these
|
|
56
|
+
// 404s and loads `[]`. A site whose workspace does not resolve gets a 503
|
|
57
|
+
// from every member read — the error row — until it sets its own
|
|
58
|
+
// `AILK_SPOKE_KEY` (#7633).
|
|
59
|
+
//
|
|
60
|
+
// The New actions (#8233, spec 8233 D1, D2) — the New menu over the Inbox.
|
|
61
|
+
// Each is offered while its member read answers, read with `ctx.init` like
|
|
62
|
+
// the kinds above, so a scaffold without a module shows no action that 404s
|
|
63
|
+
// on click:
|
|
64
|
+
//
|
|
65
|
+
// - `schedule-service` — while `GET /v1/member/meeting-types` answers
|
|
66
|
+
// (registered with `scheduling` on), `{ items: [] }` included: the
|
|
67
|
+
// popup then says no times are offered.
|
|
68
|
+
// - `request-quote` — while `GET /v1/member/request-form` answers a form;
|
|
69
|
+
// `{ form: null }` (switched off) leaves it out like a 404.
|
|
70
|
+
// - `send-message` — while `GET /v1/member/messages` answers; the `message`
|
|
71
|
+
// kind shares that read (`memberMessages`), one call per request.
|
|
72
|
+
//
|
|
73
|
+
// A Free build registers none of the three, so it shows no New button; a
|
|
74
|
+
// failed read rejects, so the Inbox shows its error row. The popups, the
|
|
75
|
+
// menu and their server actions are BASE files, never a module slice's:
|
|
76
|
+
// gating is the read's job, and a slice owning a popup would pull this file,
|
|
77
|
+
// hence the whole /account area, out of every keyless scaffold (#8233 C1 —
|
|
78
|
+
// `paid-module-exclusions.test.ts` pins them).
|
|
79
|
+
//
|
|
80
|
+
// This file carries only each action's server half (`AccountNewRead`: id,
|
|
81
|
+
// label, `available`); the popup each one opens is bound in the route,
|
|
82
|
+
// `account/new-popups.tsx`, which composes the full `AccountNewAction` list
|
|
83
|
+
// the Inbox page consumes. The reason is create-ailk's spoke closure
|
|
84
|
+
// (`spokeExclusions`, `packages/create-ailk/src/topology.ts`): a spoke
|
|
85
|
+
// without sign-in drops the whole `(authed)` group and every file that
|
|
86
|
+
// imports a file in it, and this is a shared lib file every spoke keeps —
|
|
87
|
+
// so it may import nothing under `(authed)`, popups included.
|
|
50
88
|
|
|
51
89
|
import {
|
|
52
|
-
flowCheckouts,
|
|
53
90
|
member,
|
|
54
91
|
subscription,
|
|
55
92
|
waitlistSignups,
|
|
56
|
-
type FlowCheckoutMeItem,
|
|
57
93
|
type WaitlistSignupMeResponse,
|
|
58
94
|
} from "@working-theory/api-client";
|
|
59
95
|
import type {
|
|
96
|
+
AccountBillingSection,
|
|
60
97
|
AccountContext,
|
|
61
98
|
AccountForYouSource,
|
|
62
99
|
AccountInboxKind,
|
|
100
|
+
AccountNewAction,
|
|
63
101
|
AccountSettingsSection,
|
|
64
102
|
AccountUser,
|
|
65
103
|
} from "@working-theory/templates/account";
|
|
66
104
|
import { localeHref } from "@working-theory/ui";
|
|
67
|
-
import type {
|
|
105
|
+
import type {
|
|
106
|
+
MemberAppointment,
|
|
107
|
+
MemberMessage,
|
|
108
|
+
MemberPiece,
|
|
109
|
+
MemberPurchase,
|
|
110
|
+
} from "@working-theory/validation";
|
|
68
111
|
import { formatMoney, formatNumber } from "@working-theory/web-i18n";
|
|
69
112
|
import { cache } from "react";
|
|
70
113
|
|
|
@@ -152,53 +195,46 @@ const result: AccountInboxKind<WaitlistSignupMeResponse> = {
|
|
|
152
195
|
},
|
|
153
196
|
};
|
|
154
197
|
|
|
155
|
-
|
|
198
|
+
// ── Member Inbox kinds (#7920, #8231) ────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
const purchase: AccountInboxKind<MemberPurchase> = {
|
|
156
201
|
kind: "purchase",
|
|
157
202
|
label: "purchases.heading",
|
|
158
|
-
section: "
|
|
203
|
+
section: "purchases",
|
|
159
204
|
async load({ init, user }) {
|
|
160
|
-
const
|
|
161
|
-
return
|
|
162
|
-
id:
|
|
163
|
-
title:
|
|
164
|
-
at:
|
|
165
|
-
data:
|
|
205
|
+
const purchases = (await read(member.purchases(init), user))?.items ?? [];
|
|
206
|
+
return purchases.map((p) => ({
|
|
207
|
+
id: p.id,
|
|
208
|
+
title: p.offering,
|
|
209
|
+
at: p.purchasedAt,
|
|
210
|
+
data: p,
|
|
166
211
|
}));
|
|
167
212
|
},
|
|
168
213
|
renderDetail({ data }, { locale, t }) {
|
|
169
214
|
return {
|
|
170
|
-
status: data.status,
|
|
171
215
|
fields: {
|
|
172
216
|
heading: t("purchases.heading"),
|
|
173
217
|
items: [
|
|
174
|
-
{ label: t("purchases.offer"), value: data.
|
|
218
|
+
{ label: t("purchases.offer"), value: data.offering },
|
|
175
219
|
{
|
|
176
220
|
label: t("purchases.amount"),
|
|
177
|
-
value:
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
label: t("purchases.date"),
|
|
182
|
-
value: formatWhen(data.completedAt ?? data.createdAt, locale),
|
|
221
|
+
value:
|
|
222
|
+
data.amountMinor !== null && data.currency !== null
|
|
223
|
+
? formatMoney(data.amountMinor, data.currency, locale)
|
|
224
|
+
: t("purchases.unavailable"),
|
|
183
225
|
},
|
|
226
|
+
{ label: t("purchases.period"), value: data.period ?? t("purchases.unavailable") },
|
|
227
|
+
{ label: t("purchases.date"), value: formatWhen(data.purchasedAt, locale) },
|
|
184
228
|
],
|
|
185
229
|
},
|
|
186
230
|
};
|
|
187
231
|
},
|
|
188
232
|
};
|
|
189
233
|
|
|
190
|
-
// ── Member Inbox kinds (#7920) ───────────────────────────────────────────────
|
|
191
|
-
|
|
192
|
-
/** Whether `init` carries the client's hub credential (see the header). */
|
|
193
|
-
function hasHubCredential({ init }: AccountContext): boolean {
|
|
194
|
-
return Object.keys(init.headers).some((name) => name.toLowerCase() === "authorization");
|
|
195
|
-
}
|
|
196
|
-
|
|
197
234
|
/** One pieces read per request — the `piece` kind and the For-you source share it. */
|
|
198
235
|
const memberPieces = cache((init: AccountContext["init"]) => member.pieces(init));
|
|
199
236
|
|
|
200
237
|
async function loadPieces(ctx: AccountContext): Promise<MemberPiece[]> {
|
|
201
|
-
if (!hasHubCredential(ctx)) return [];
|
|
202
238
|
return (await read(memberPieces(ctx.init), ctx.user))?.items ?? [];
|
|
203
239
|
}
|
|
204
240
|
|
|
@@ -230,13 +266,15 @@ const piece: AccountInboxKind<MemberPiece> = {
|
|
|
230
266
|
},
|
|
231
267
|
};
|
|
232
268
|
|
|
269
|
+
/** One messages read per request — the `message` kind and the Send message action share it. */
|
|
270
|
+
const memberMessages = cache((init: AccountContext["init"]) => member.messages(init));
|
|
271
|
+
|
|
233
272
|
const message: AccountInboxKind<MemberMessage> = {
|
|
234
273
|
kind: "message",
|
|
235
274
|
label: "messages.heading",
|
|
236
275
|
section: "updates",
|
|
237
276
|
async load(ctx) {
|
|
238
|
-
|
|
239
|
-
const messages = (await read(member.messages(ctx.init), ctx.user))?.items ?? [];
|
|
277
|
+
const messages = (await read(memberMessages(ctx.init), ctx.user))?.items ?? [];
|
|
240
278
|
return messages.map((m) => ({
|
|
241
279
|
id: m.id,
|
|
242
280
|
title: m.subject,
|
|
@@ -273,6 +311,47 @@ const message: AccountInboxKind<MemberMessage> = {
|
|
|
273
311
|
},
|
|
274
312
|
};
|
|
275
313
|
|
|
314
|
+
/** Each appointment status's message key — a new status is a type error, never a raw key. */
|
|
315
|
+
const APPOINTMENT_STATUS_KEY = {
|
|
316
|
+
pending: "appointments.statuses.pending",
|
|
317
|
+
confirmed: "appointments.statuses.confirmed",
|
|
318
|
+
cancelled: "appointments.statuses.cancelled",
|
|
319
|
+
} satisfies Record<MemberAppointment["status"], string>;
|
|
320
|
+
|
|
321
|
+
const appointment: AccountInboxKind<MemberAppointment> = {
|
|
322
|
+
kind: "appointment",
|
|
323
|
+
label: "appointments.heading",
|
|
324
|
+
section: "upcoming",
|
|
325
|
+
async load({ init, t, user }) {
|
|
326
|
+
const appointments = await read(member.appointments(init), user);
|
|
327
|
+
if (appointments === null) return [];
|
|
328
|
+
// The read's own split decides upcoming — a meeting in progress is upcoming.
|
|
329
|
+
const toItem = (a: MemberAppointment, upcoming: boolean) => ({
|
|
330
|
+
id: a.id,
|
|
331
|
+
title: a.title ?? t("appointments.untitled"),
|
|
332
|
+
at: a.startTime,
|
|
333
|
+
upcoming,
|
|
334
|
+
data: a,
|
|
335
|
+
});
|
|
336
|
+
return [
|
|
337
|
+
...appointments.upcoming.map((a) => toItem(a, true)),
|
|
338
|
+
...appointments.past.map((a) => toItem(a, false)),
|
|
339
|
+
];
|
|
340
|
+
},
|
|
341
|
+
renderDetail({ data }, { locale, t }) {
|
|
342
|
+
return {
|
|
343
|
+
status: t(APPOINTMENT_STATUS_KEY[data.status]),
|
|
344
|
+
fields: {
|
|
345
|
+
heading: t("appointments.heading"),
|
|
346
|
+
items: [
|
|
347
|
+
{ label: t("appointments.starts"), value: formatWhen(data.startTime, locale) },
|
|
348
|
+
{ label: t("appointments.ends"), value: formatWhen(data.endTime, locale) },
|
|
349
|
+
],
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
|
|
276
355
|
// ── Base For-you sources (D9 as amended, D8a) ────────────────────────────────
|
|
277
356
|
|
|
278
357
|
const emailUnverified: AccountForYouSource = {
|
|
@@ -285,7 +364,7 @@ const emailUnverified: AccountForYouSource = {
|
|
|
285
364
|
type: { id: "email", label: t("profile.email"), tone: "needs-you" },
|
|
286
365
|
title: t("profile.unverified"),
|
|
287
366
|
time: { kind: "at", at: new Date().toISOString() },
|
|
288
|
-
action: { kind: "open", href: localeHref(locale, "/account/
|
|
367
|
+
action: { kind: "open", href: localeHref(locale, "/account/general") },
|
|
289
368
|
dismissible: true,
|
|
290
369
|
},
|
|
291
370
|
];
|
|
@@ -304,7 +383,7 @@ const paymentDue: AccountForYouSource = {
|
|
|
304
383
|
type: { id: "payment", label: t("subscription.action"), tone: "needs-you" },
|
|
305
384
|
title: t("subscription.headline"),
|
|
306
385
|
time: { kind: "at", at: new Date().toISOString() },
|
|
307
|
-
action: { kind: "open", href: localeHref(locale, "/account/
|
|
386
|
+
action: { kind: "open", href: localeHref(locale, "/account/billing") },
|
|
308
387
|
dismissible: true,
|
|
309
388
|
},
|
|
310
389
|
];
|
|
@@ -334,14 +413,71 @@ const pieceNeedsClient: AccountForYouSource = {
|
|
|
334
413
|
},
|
|
335
414
|
};
|
|
336
415
|
|
|
416
|
+
// ── New actions (#8233) ──────────────────────────────────────────────────────
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* A New action's server half — everything but the popup it opens, which
|
|
420
|
+
* `account/new-popups.tsx` binds (see the header: this file imports nothing
|
|
421
|
+
* under `(authed)`).
|
|
422
|
+
*/
|
|
423
|
+
export type AccountNewRead = Omit<AccountNewAction, "renderPopup">;
|
|
424
|
+
|
|
425
|
+
const scheduleService = {
|
|
426
|
+
id: "schedule-service",
|
|
427
|
+
label: "new.actions.scheduleService",
|
|
428
|
+
async available({ init, user }) {
|
|
429
|
+
return (await read(member.meetingTypes(init), user)) !== null;
|
|
430
|
+
},
|
|
431
|
+
} as const satisfies AccountNewRead;
|
|
432
|
+
|
|
433
|
+
const requestQuote = {
|
|
434
|
+
id: "request-quote",
|
|
435
|
+
label: "new.actions.requestQuote",
|
|
436
|
+
async available({ init, user }) {
|
|
437
|
+
// `form: null` is Request quote switched off — a state, never a 404.
|
|
438
|
+
return ((await read(member.requestForm(init), user))?.form ?? null) !== null;
|
|
439
|
+
},
|
|
440
|
+
} as const satisfies AccountNewRead;
|
|
441
|
+
|
|
442
|
+
const sendMessage = {
|
|
443
|
+
id: "send-message",
|
|
444
|
+
label: "new.actions.sendMessage",
|
|
445
|
+
async available({ init, user }) {
|
|
446
|
+
return (await read(memberMessages(init), user)) !== null;
|
|
447
|
+
},
|
|
448
|
+
} as const satisfies AccountNewRead;
|
|
449
|
+
|
|
337
450
|
// ── Contributions ─────────────────────────────────────────────────────────────
|
|
338
451
|
|
|
339
452
|
/** Inbox item kinds, in load order. A module appends its own. */
|
|
340
|
-
export const accountInboxKinds: AccountInboxKind[] = [
|
|
453
|
+
export const accountInboxKinds: AccountInboxKind[] = [
|
|
454
|
+
result,
|
|
455
|
+
purchase,
|
|
456
|
+
piece,
|
|
457
|
+
message,
|
|
458
|
+
appointment,
|
|
459
|
+
];
|
|
341
460
|
|
|
342
461
|
/** User-settings sections a module adds after the base ones. None in the base area. */
|
|
343
462
|
export const accountSettingsSections: AccountSettingsSection[] = [];
|
|
344
463
|
|
|
464
|
+
/**
|
|
465
|
+
* New-menu actions' server halves, in menu order (#8233 D2). A module appends
|
|
466
|
+
* its own read here and binds its popup in `account/new-popups.tsx`, keyed on
|
|
467
|
+
* the id — `AccountNewActionId` makes a read with no popup a type error.
|
|
468
|
+
*/
|
|
469
|
+
export const accountNewActions = [
|
|
470
|
+
scheduleService,
|
|
471
|
+
requestQuote,
|
|
472
|
+
sendMessage,
|
|
473
|
+
] as const satisfies readonly AccountNewRead[];
|
|
474
|
+
|
|
475
|
+
/** The ids `accountNewActions` carries — the keys `new-popups.tsx` must bind. */
|
|
476
|
+
export type AccountNewActionId = (typeof accountNewActions)[number]["id"];
|
|
477
|
+
|
|
478
|
+
/** Billing-page sections a module adds after the base ones. `member-settings` (#8232) renders it. */
|
|
479
|
+
export const accountBillingSections: AccountBillingSection[] = [];
|
|
480
|
+
|
|
345
481
|
/** For-you sources, in load order. A module appends its own. */
|
|
346
482
|
export const accountForYouSources: AccountForYouSource[] = [
|
|
347
483
|
emailUnverified,
|
|
@@ -200,6 +200,12 @@ async function dispatch(
|
|
|
200
200
|
|
|
201
201
|
export interface ResetPasswordDeps extends DispatchDeps {
|
|
202
202
|
prisma: AuthEmailPrisma;
|
|
203
|
+
/**
|
|
204
|
+
* The id of the person signed in on `request`, or `null` (#8431 D4). Set,
|
|
205
|
+
* it lets a signed-in person ask for their OWN first password — the
|
|
206
|
+
* Security tab's Set password. Unset, the § D14 guard is unchanged.
|
|
207
|
+
*/
|
|
208
|
+
sessionUserId?: (request: Request | undefined) => Promise<string | null>;
|
|
203
209
|
}
|
|
204
210
|
|
|
205
211
|
/**
|
|
@@ -217,17 +223,29 @@ export interface ResetPasswordDeps extends DispatchDeps {
|
|
|
217
223
|
* address does not exist": the second is the enumeration AC-1 forbids and the
|
|
218
224
|
* first is nearly as useful to an attacker, since it maps a site's users to
|
|
219
225
|
* their identity provider.
|
|
226
|
+
*
|
|
227
|
+
* THE ONE CARVE-OUT (#8431 D4). A person with no password who is SIGNED IN
|
|
228
|
+
* may set one from their Security tab, by the same emailed link: the mail
|
|
229
|
+
* goes out when `deps.sessionUserId(request)` is that same person's id. The
|
|
230
|
+
* concern above is an ANONYMOUS request adding a password path around
|
|
231
|
+
* someone's identity provider; a request carrying that person's own live
|
|
232
|
+
* session is that person. The anonymous path is unchanged and silent, the
|
|
233
|
+
* link still goes only to the account's own address, and a failed session
|
|
234
|
+
* lookup counts as no session.
|
|
220
235
|
*/
|
|
221
236
|
export function makeSendResetPassword(deps: ResetPasswordDeps) {
|
|
222
237
|
const { prisma, send } = deps;
|
|
223
238
|
const log = deps.log ?? defaultLog;
|
|
224
|
-
return async function sendResetPassword(
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
239
|
+
return async function sendResetPassword(
|
|
240
|
+
{
|
|
241
|
+
user,
|
|
242
|
+
url,
|
|
243
|
+
}: {
|
|
244
|
+
user: { id: string; email: string };
|
|
245
|
+
url: string;
|
|
246
|
+
},
|
|
247
|
+
request?: Request,
|
|
248
|
+
): Promise<void> {
|
|
231
249
|
let hasPassword: { id: string } | null;
|
|
232
250
|
try {
|
|
233
251
|
hasPassword = await prisma.account.findFirst({
|
|
@@ -245,12 +263,29 @@ export function makeSendResetPassword(deps: ResetPasswordDeps) {
|
|
|
245
263
|
}
|
|
246
264
|
|
|
247
265
|
if (!hasPassword) {
|
|
248
|
-
|
|
266
|
+
let sessionUserId: string | null = null;
|
|
267
|
+
let lookupFailed = false;
|
|
268
|
+
if (deps.sessionUserId) {
|
|
269
|
+
try {
|
|
270
|
+
sessionUserId = await deps.sessionUserId(request);
|
|
271
|
+
} catch {
|
|
272
|
+
lookupFailed = true;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (sessionUserId !== user.id) {
|
|
276
|
+
log.warn({
|
|
277
|
+
userId: user.id,
|
|
278
|
+
kind: "password-reset",
|
|
279
|
+
reason: "no_credential_account",
|
|
280
|
+
...(lookupFailed ? { errorKind: "session_lookup_failed" } : {}),
|
|
281
|
+
});
|
|
282
|
+
return; // no send; the endpoint's 200 body is unchanged
|
|
283
|
+
}
|
|
284
|
+
log.info({
|
|
249
285
|
userId: user.id,
|
|
250
286
|
kind: "password-reset",
|
|
251
|
-
reason: "
|
|
287
|
+
reason: "self_service_set",
|
|
252
288
|
});
|
|
253
|
-
return; // no send; the endpoint's 200 body is unchanged
|
|
254
289
|
}
|
|
255
290
|
|
|
256
291
|
await dispatch(
|
|
@@ -34,8 +34,11 @@ jest.mock("../../topology.generated", () => ({
|
|
|
34
34
|
SIGN_IN_PROVIDER: "hub",
|
|
35
35
|
}));
|
|
36
36
|
|
|
37
|
+
import { cookies } from "next/headers";
|
|
38
|
+
|
|
37
39
|
import { forwardedCookieInit } from "../../server-api";
|
|
38
40
|
import { hubSessionConfig } from "../config";
|
|
41
|
+
import { resealHubSession } from "../reseal";
|
|
39
42
|
import { seal, unseal } from "../seal";
|
|
40
43
|
import {
|
|
41
44
|
type HubSessionPayload,
|
|
@@ -115,6 +118,29 @@ describe("seal", () => {
|
|
|
115
118
|
expect(unseal("", SESSION_SECRET, "session", COOKIE)).toBeNull();
|
|
116
119
|
});
|
|
117
120
|
|
|
121
|
+
// #8146 AC-4: the `continue` purpose was added to seal.ts. These two
|
|
122
|
+
// ciphertexts were sealed on `main` BEFORE that edit and committed as
|
|
123
|
+
// literals, so any change to INFO.session, INFO.flow or deriveKey fails
|
|
124
|
+
// here — the round trips above cannot see a key change (a changed key still
|
|
125
|
+
// round-trips with itself, and would silently log out every live spoke).
|
|
126
|
+
it("still opens a `session` value sealed before the `continue` purpose existed", () => {
|
|
127
|
+
const sealed =
|
|
128
|
+
"T0OSblxM1tRcJ4f5OP4VDvQA5TvWQdW3K_GvZlFzETSgVCw1tzEEaDWfAhHz0HZgY1sg8vA2fy0au73t";
|
|
129
|
+
expect(unseal(sealed, SESSION_SECRET, "session", COOKIE)).toEqual({
|
|
130
|
+
v: 1,
|
|
131
|
+
fixed: "session-vector",
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("still opens a `flow` value sealed before the `continue` purpose existed", () => {
|
|
136
|
+
const sealed =
|
|
137
|
+
"TI60LmvzNlRIp6a4BexFT9zmNoOWn8ZYBhW2VtOklk6lwUtrEWwtJ15XSJeAfgeNpSc322z-PWlZ";
|
|
138
|
+
expect(unseal(sealed, SESSION_SECRET, "flow", "ailk-hub-flow")).toEqual({
|
|
139
|
+
v: 1,
|
|
140
|
+
fixed: "flow-vector",
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
118
144
|
it("uses a fresh IV per seal", () => {
|
|
119
145
|
expect(seal({ a: 1 }, SESSION_SECRET, "session", COOKIE)).not.toBe(
|
|
120
146
|
seal({ a: 1 }, SESSION_SECRET, "session", COOKIE),
|
|
@@ -260,3 +286,120 @@ describe("hubNextPath", () => {
|
|
|
260
286
|
expect(hubNextPath(raw)).toBe(expected);
|
|
261
287
|
});
|
|
262
288
|
});
|
|
289
|
+
|
|
290
|
+
// #8232 D11 (spec docs/specs/8232-member-settings-account-general-and-spec.md
|
|
291
|
+
// AC-5) — after a member's profile save, the spoke re-seals its session from
|
|
292
|
+
// the hub's /me. Only `user` changes; the credential and its expiry are kept,
|
|
293
|
+
// so a re-seal never extends a session (SEC-8232-1), and the cookie is
|
|
294
|
+
// written with the attributes the callback writes at sign-in (SEC-8232-2).
|
|
295
|
+
describe("#8232 AC-5 — resealHubSession", () => {
|
|
296
|
+
const mockSet = jest.fn();
|
|
297
|
+
const RENAMED = { ...USER, name: "Ada King" };
|
|
298
|
+
|
|
299
|
+
beforeEach(() => {
|
|
300
|
+
mockSet.mockReset();
|
|
301
|
+
jest.mocked(cookies).mockResolvedValue({ set: mockSet } as never);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
function signedIn(overrides: Partial<HubSessionPayload> = {}, name = COOKIE): void {
|
|
305
|
+
mockHeaders.mockResolvedValue(headersWith(seal(payload(overrides), SESSION_SECRET, "session", name), name));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
it("re-seals the session with /me's person, keeping the credential and expiry, once, with the sign-in attributes", async () => {
|
|
309
|
+
const expiresAt = Date.now() + 60_000;
|
|
310
|
+
signedIn({ expiresAt });
|
|
311
|
+
mockMe.mockResolvedValue({ ok: true, body: { user: RENAMED, billingCustomerId: null } });
|
|
312
|
+
|
|
313
|
+
await expect(resealHubSession()).resolves.toBe("resealed");
|
|
314
|
+
|
|
315
|
+
expect(mockMe).toHaveBeenCalledTimes(1);
|
|
316
|
+
expect(mockMe).toHaveBeenCalledWith({ headers: { authorization: `Bearer ${CREDENTIAL}` } });
|
|
317
|
+
expect(mockSet).toHaveBeenCalledTimes(1);
|
|
318
|
+
const [name, value, options] = mockSet.mock.calls[0] as [string, string, Record<string, unknown>];
|
|
319
|
+
expect(name).toBe(COOKIE);
|
|
320
|
+
expect(options).toEqual({
|
|
321
|
+
httpOnly: true,
|
|
322
|
+
sameSite: "lax",
|
|
323
|
+
path: "/",
|
|
324
|
+
secure: false,
|
|
325
|
+
maxAge: expect.any(Number),
|
|
326
|
+
});
|
|
327
|
+
expect(options["maxAge"]).toBeGreaterThan(0);
|
|
328
|
+
expect(options["maxAge"]).toBeLessThanOrEqual(60);
|
|
329
|
+
expect(unseal(value, SESSION_SECRET, "session", COOKIE)).toEqual({
|
|
330
|
+
v: 1,
|
|
331
|
+
accessToken: CREDENTIAL,
|
|
332
|
+
expiresAt,
|
|
333
|
+
user: RENAMED,
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("writes the __Host- cookie with Secure on an https site", async () => {
|
|
338
|
+
configure({ NEXT_PUBLIC_BASE_URL: "https://spoke.example.com" });
|
|
339
|
+
signedIn({}, "__Host-ailk-hub-session");
|
|
340
|
+
mockMe.mockResolvedValue({ ok: true, body: { user: RENAMED, billingCustomerId: null } });
|
|
341
|
+
|
|
342
|
+
await expect(resealHubSession()).resolves.toBe("resealed");
|
|
343
|
+
const [name, , options] = mockSet.mock.calls[0] as [string, string, Record<string, unknown>];
|
|
344
|
+
expect(name).toBe("__Host-ailk-hub-session");
|
|
345
|
+
expect(options).toMatchObject({ secure: true, httpOnly: true });
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("is skipped with no configuration (a single site), calling neither /me nor set", async () => {
|
|
349
|
+
signedIn();
|
|
350
|
+
configure({ AILK_HUB_SESSION_SECRET: undefined });
|
|
351
|
+
await expect(resealHubSession()).resolves.toBe("skipped");
|
|
352
|
+
expect(mockMe).not.toHaveBeenCalled();
|
|
353
|
+
expect(mockSet).not.toHaveBeenCalled();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("is skipped with no session cookie, calling neither /me nor set", async () => {
|
|
357
|
+
mockHeaders.mockResolvedValue(new Headers());
|
|
358
|
+
await expect(resealHubSession()).resolves.toBe("skipped");
|
|
359
|
+
expect(mockMe).not.toHaveBeenCalled();
|
|
360
|
+
expect(mockSet).not.toHaveBeenCalled();
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it.each([
|
|
364
|
+
["a non-2xx", () => mockMe.mockResolvedValue({ ok: false, status: 401, body: null })],
|
|
365
|
+
["a throw", () => mockMe.mockRejectedValue(new Error("network"))],
|
|
366
|
+
[
|
|
367
|
+
"a user with a non-string email",
|
|
368
|
+
() => mockMe.mockResolvedValue({ ok: true, body: { user: { ...RENAMED, email: 42 }, billingCustomerId: null } }),
|
|
369
|
+
],
|
|
370
|
+
[
|
|
371
|
+
"a user with a non-boolean emailVerified",
|
|
372
|
+
() =>
|
|
373
|
+
mockMe.mockResolvedValue({ ok: true, body: { user: { ...RENAMED, emailVerified: "yes" }, billingCustomerId: null } }),
|
|
374
|
+
],
|
|
375
|
+
[
|
|
376
|
+
"a user with a non-string createdAt",
|
|
377
|
+
() => mockMe.mockResolvedValue({ ok: true, body: { user: { ...RENAMED, createdAt: 1 }, billingCustomerId: null } }),
|
|
378
|
+
],
|
|
379
|
+
[
|
|
380
|
+
"a user whose name is neither a string nor null",
|
|
381
|
+
() => mockMe.mockResolvedValue({ ok: true, body: { user: { ...RENAMED, name: 7 }, billingCustomerId: null } }),
|
|
382
|
+
],
|
|
383
|
+
])("fails on %s from /me and leaves the cookie as it is", async (_label, arrange) => {
|
|
384
|
+
signedIn();
|
|
385
|
+
arrange();
|
|
386
|
+
await expect(resealHubSession()).resolves.toBe("failed");
|
|
387
|
+
expect(mockSet).not.toHaveBeenCalled();
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("is skipped when the session has under a second left, with no call and no write", async () => {
|
|
391
|
+
signedIn({ expiresAt: Date.now() + 500 });
|
|
392
|
+
await expect(resealHubSession()).resolves.toBe("skipped");
|
|
393
|
+
expect(mockMe).not.toHaveBeenCalled();
|
|
394
|
+
expect(mockSet).not.toHaveBeenCalled();
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("fails, never throws, when the cookie write is refused", async () => {
|
|
398
|
+
signedIn();
|
|
399
|
+
mockMe.mockResolvedValue({ ok: true, body: { user: RENAMED, billingCustomerId: null } });
|
|
400
|
+
mockSet.mockImplementation(() => {
|
|
401
|
+
throw new Error("Cookies can only be modified in a Server Action or Route Handler");
|
|
402
|
+
});
|
|
403
|
+
await expect(resealHubSession()).resolves.toBe("failed");
|
|
404
|
+
});
|
|
405
|
+
});
|