create-ailk 0.17.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +6 -5
- package/NOTICE +2 -0
- package/dist/add-module.js +4 -1
- package/dist/category-profile.d.ts +10 -0
- package/dist/category-profile.js +21 -0
- package/dist/derive-architecture.d.ts +5 -1
- package/dist/derive-architecture.js +8 -3
- package/dist/lib/env-catalog.js +5 -9
- package/dist/lib/license-key-files.d.ts +6 -3
- package/dist/lib/license-key-files.js +8 -2
- package/dist/lib/scaffold-render-sweep.d.ts +9 -0
- package/dist/lib/scaffold-render-sweep.js +24 -3
- package/dist/license-set.d.ts +1 -1
- package/dist/license-set.js +13 -8
- package/dist/module-architecture.js +119 -0
- package/dist/programmatic.js +3 -0
- package/dist/spoke-checkout.d.ts +11 -3
- package/dist/spoke-checkout.js +62 -12
- package/dist/starter-grid.d.ts +1 -1
- package/dist/surfaces.js +140 -1
- package/dist/template-manifest.d.ts +4 -3
- package/dist/topology.d.ts +15 -2
- package/dist/topology.js +83 -4
- package/package.json +5 -4
- package/templates/.env.example +3 -3
- package/templates/.github/workflows/ci.yml +5 -0
- package/templates/apps/api/CLAUDE.md +25 -7
- package/templates/apps/api/LICENSE +6 -5
- package/templates/apps/api/NOTICE +2 -0
- package/templates/apps/api/jest.config.cjs +4 -29
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +9 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
- package/templates/apps/api/src/lib/stripe.ts +81 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +266 -39
- package/templates/apps/api/src/middleware/auth.ts +206 -46
- package/templates/apps/api/src/routes/leads/index.ts +4 -0
- package/templates/apps/api/src/server.ts +107 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +58 -0
- package/templates/apps/api/src/services/lead-routing.ts +30 -2
- package/templates/apps/api/vercel.json +2 -2
- package/templates/apps/mcp/.env.example +10 -0
- package/templates/apps/mcp/CLAUDE.md +58 -7
- package/templates/apps/mcp/LICENSE +6 -5
- package/templates/apps/mcp/NOTICE +2 -0
- package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
- package/templates/apps/mcp/__tests__/app-entry.test.ts +325 -0
- package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
- package/templates/apps/mcp/__tests__/boot.test.ts +280 -0
- package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
- package/templates/apps/mcp/__tests__/index.test.ts +86 -47
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +5 -6
- package/templates/apps/mcp/__tests__/lib/content-root.test.ts +93 -0
- package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
- package/templates/apps/mcp/__tests__/server.test.ts +63 -6
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
- package/templates/apps/mcp/jest.config.cjs +3 -44
- package/templates/apps/mcp/src/abuse/index.ts +117 -21
- package/templates/apps/mcp/src/app.ts +32 -0
- package/templates/apps/mcp/src/boot.ts +167 -0
- package/templates/apps/mcp/src/http-transport.ts +132 -88
- package/templates/apps/mcp/src/index.ts +19 -126
- package/templates/apps/mcp/src/lib/audit-single-page.ts +7 -6
- package/templates/apps/mcp/src/lib/content-adapter.ts +34 -5
- package/templates/apps/mcp/src/lib/topology.generated.ts +3 -0
- package/templates/apps/mcp/src/request-context.ts +52 -0
- package/templates/apps/mcp/src/server.ts +10 -2
- package/templates/apps/mcp/src/serverless-app.ts +88 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
- package/templates/apps/mcp/src/tools/get_business_hours.ts +6 -5
- package/templates/apps/mcp/src/tools/get_faq.ts +6 -5
- package/templates/apps/mcp/src/tools/get_locations.ts +6 -5
- package/templates/apps/mcp/src/tools/get_offerings.ts +6 -5
- package/templates/apps/mcp/src/tools/get_pricing.ts +6 -5
- package/templates/apps/mcp/src/tools/get_site_info.ts +6 -5
- package/templates/apps/mcp/src/tools/list_pages.ts +8 -7
- package/templates/apps/mcp/src/tools/read_content.ts +7 -7
- package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
- package/templates/apps/mcp/src/tools/search_content.ts +6 -5
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
- package/templates/apps/mcp/tsconfig.json +2 -0
- package/templates/apps/web/LICENSE +6 -5
- package/templates/apps/web/NOTICE +2 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +16 -4
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +3 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +568 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +272 -42
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +115 -16
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +126 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +139 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +20 -390
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +114 -50
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +25 -32
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +177 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +131 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +4 -1
- package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
- package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +80 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +108 -0
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
- package/templates/apps/web/app/[locale]/layout.tsx +23 -4
- package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
- package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
- package/templates/apps/web/app/robots.txt/route.ts +10 -4
- package/templates/apps/web/auth.ts +5 -1
- package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +42 -0
- package/templates/apps/web/lib/account-config.ts +188 -52
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
- package/templates/apps/web/lib/hub-session/index.ts +1 -0
- package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
- package/templates/apps/web/lib/hub-session/seal.ts +13 -9
- package/templates/apps/web/lib/site-brand.tsx +22 -6
- package/templates/apps/web/lib/topology.generated.ts +27 -0
- package/templates/apps/web/messages/en.json +156 -35
- package/templates/database/CHANGELOG.md +21 -0
- package/templates/database/LICENSE +6 -5
- package/templates/database/NOTICE +2 -0
- package/templates/database/README.md +1 -1
- package/templates/database/__tests__/member-area.db.test.ts +15 -3
- package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
- package/templates/database/__tests__/member-contract.db.test.ts +413 -0
- package/templates/database/__tests__/seed.test.ts +54 -2
- package/templates/database/inbox/schema.prisma +90 -15
- package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
- package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
- package/templates/database/package.json +3 -2
- package/templates/database/seed.ts +34 -2
- package/versions.json +36 -36
- package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
- package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
- package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
- package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
- package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
- package/templates/apps/api/src/__tests__/server.test.ts +0 -591
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
- package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
- package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
- package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
- package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
- package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
- package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
- package/templates/database/__tests__/build.test.ts +0 -131
|
@@ -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,
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The spoke's session re-seal (#8232, spec
|
|
3
|
+
* docs/specs/8232-member-settings-account-general-and-spec.md D11; roadmap
|
|
4
|
+
* D9).
|
|
5
|
+
*
|
|
6
|
+
* After a member saves their name through the hub (`PATCH /v1/member/profile`),
|
|
7
|
+
* the sealed session cookie still names the person as they were at sign-in.
|
|
8
|
+
* `resealHubSession()` re-reads the person from the hub's own record,
|
|
9
|
+
* `GET /v1/hub-sign-in/me`, with the session's credential, and writes the
|
|
10
|
+
* cookie again so the next render shows the new name:
|
|
11
|
+
*
|
|
12
|
+
* - no hub-session configuration (a single site) or no session cookie ⇒
|
|
13
|
+
* `"skipped"`, with no call;
|
|
14
|
+
* - `/me` refusing, throwing, or answering a person of the wrong shape ⇒
|
|
15
|
+
* `"failed"`, and the cookie is left as it is (sign-out is the sign-out
|
|
16
|
+
* route's job — a transient hub error never signs the member out);
|
|
17
|
+
* - otherwise only `user` changes. `v`, `accessToken` and `expiresAt` are
|
|
18
|
+
* kept exactly and `Max-Age` is the remaining lifetime, so a re-seal
|
|
19
|
+
* never extends a session (SEC-8232-1). The cookie's name, key and
|
|
20
|
+
* attributes are the ones the callback route writes at sign-in
|
|
21
|
+
* (`cookieHeader`: `HttpOnly`, `SameSite=Lax`, `Path=/`, `Secure` on an
|
|
22
|
+
* https site — SEC-8232-2).
|
|
23
|
+
*
|
|
24
|
+
* It tests no topology: a single site has no configuration and no cookie.
|
|
25
|
+
* Nothing here logs a credential, a cookie value, a name or an email.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { hubSignIn } from "@working-theory/api-client";
|
|
29
|
+
import { cookies, headers } from "next/headers";
|
|
30
|
+
|
|
31
|
+
import { hubSessionConfig } from "./config";
|
|
32
|
+
import { seal } from "./seal";
|
|
33
|
+
import { type HubSessionUser, readHubSessionPayload, sessionCookieName } from "./session";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The person `/me` names, when every field is the type the session holds.
|
|
37
|
+
* It repeats `session.ts`'s private shape check on purpose: `sync-routes`
|
|
38
|
+
* brings this file to a scaffold whose own `session.ts` it never updates,
|
|
39
|
+
* so it cannot rely on a new export there.
|
|
40
|
+
*/
|
|
41
|
+
function sessionUser(value: unknown): HubSessionUser | null {
|
|
42
|
+
if (typeof value !== "object" || value === null) return null;
|
|
43
|
+
const user = value as Record<string, unknown>;
|
|
44
|
+
const { name, email, emailVerified, createdAt } = user;
|
|
45
|
+
if (
|
|
46
|
+
typeof email !== "string" ||
|
|
47
|
+
typeof emailVerified !== "boolean" ||
|
|
48
|
+
typeof createdAt !== "string" ||
|
|
49
|
+
(name !== null && typeof name !== "string")
|
|
50
|
+
) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
return { name, email, emailVerified, createdAt };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Re-seals the hub session cookie from the hub's `/me`. Never throws. */
|
|
57
|
+
export async function resealHubSession(): Promise<"resealed" | "skipped" | "failed"> {
|
|
58
|
+
const config = hubSessionConfig();
|
|
59
|
+
if (config === null) return "skipped";
|
|
60
|
+
const payload = readHubSessionPayload(await headers());
|
|
61
|
+
if (payload === null) return "skipped";
|
|
62
|
+
const maxAge = Math.floor((payload.expiresAt - Date.now()) / 1000);
|
|
63
|
+
if (maxAge <= 0) return "skipped";
|
|
64
|
+
|
|
65
|
+
let user: HubSessionUser | null;
|
|
66
|
+
try {
|
|
67
|
+
const res = await hubSignIn.me({
|
|
68
|
+
headers: { authorization: `Bearer ${payload.accessToken}` },
|
|
69
|
+
});
|
|
70
|
+
user = res.ok ? sessionUser(res.body.user) : null;
|
|
71
|
+
} catch {
|
|
72
|
+
user = null;
|
|
73
|
+
}
|
|
74
|
+
if (user === null) return "failed";
|
|
75
|
+
|
|
76
|
+
const name = sessionCookieName(config);
|
|
77
|
+
try {
|
|
78
|
+
(await cookies()).set(name, seal({ ...payload, user }, config.sessionSecret, "session", name), {
|
|
79
|
+
httpOnly: true,
|
|
80
|
+
sameSite: "lax",
|
|
81
|
+
path: "/",
|
|
82
|
+
maxAge,
|
|
83
|
+
secure: config.secure,
|
|
84
|
+
});
|
|
85
|
+
} catch {
|
|
86
|
+
// Outside a Server Action or route handler Next refuses a cookie write.
|
|
87
|
+
return "failed";
|
|
88
|
+
}
|
|
89
|
+
return "resealed";
|
|
90
|
+
}
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Sealed
|
|
2
|
+
* Sealed values for the hub sign-in (#7894 D7, #8146 D3).
|
|
3
3
|
*
|
|
4
|
-
* AES-256-GCM with a fresh 12-byte IV per seal. The key is HKDF-SHA256 of
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* AES-256-GCM with a fresh 12-byte IV per seal. The key is HKDF-SHA256 of the
|
|
5
|
+
* caller's secret with one info string per purpose, so the three purposes
|
|
6
|
+
* never share a key, and the caller-supplied name is the additional
|
|
7
|
+
* authenticated data, so a value sealed for one name never opens as another.
|
|
8
|
+
* Two secrets feed this module: `AILK_HUB_SESSION_SECRET` for the spoke's
|
|
9
|
+
* `session` and `flow` cookies, and `BETTER_AUTH_SECRET` for the hub's
|
|
10
|
+
* `continue` ref (`app/api/auth/hub/continuation.ts`, hub and standalone
|
|
11
|
+
* sites only; the entry is unused on a spoke). The key never derives from the
|
|
12
|
+
* client secret: a leaked client secret forges no spoke session.
|
|
10
13
|
*
|
|
11
14
|
* Wire form: base64url(iv ‖ tag ‖ ciphertext). Anything that does not open —
|
|
12
15
|
* a wrong key, a wrong name, a truncated or altered value — reads as `null`,
|
|
@@ -20,12 +23,13 @@ import {
|
|
|
20
23
|
randomBytes,
|
|
21
24
|
} from "node:crypto";
|
|
22
25
|
|
|
23
|
-
/** The
|
|
24
|
-
export type SealPurpose = "session" | "flow";
|
|
26
|
+
/** The things this module seals, each under its own derived key. */
|
|
27
|
+
export type SealPurpose = "session" | "flow" | "continue";
|
|
25
28
|
|
|
26
29
|
const INFO: Record<SealPurpose, string> = {
|
|
27
30
|
session: "ailk-hub-session/v1",
|
|
28
31
|
flow: "ailk-hub-flow/v1",
|
|
32
|
+
continue: "ailk-hub-continue/v1",
|
|
29
33
|
};
|
|
30
34
|
|
|
31
35
|
const IV_BYTES = 12;
|