create-ailk 0.3.0 → 0.4.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/dist/cli.js +19 -9
- package/dist/lib/extract-module-bundle.d.ts +39 -0
- package/dist/lib/extract-module-bundle.js +165 -78
- package/dist/lib/fetch-template.d.ts +20 -0
- package/dist/lib/fetch-template.js +129 -0
- package/dist/lib/module-license-gate.d.ts +8 -0
- package/dist/lib/module-license-gate.js +12 -4
- package/dist/module-architecture.d.ts +58 -0
- package/dist/module-architecture.js +156 -4
- package/dist/parse-args.d.ts +8 -1
- package/dist/parse-args.js +43 -0
- package/dist/programmatic.d.ts +26 -1
- package/dist/programmatic.js +100 -4
- package/dist/surfaces.js +42 -0
- package/dist/template-manifest.d.ts +92 -0
- package/dist/template-manifest.js +168 -0
- package/dist/template-names.d.ts +41 -0
- package/dist/template-names.js +44 -0
- package/package.json +2 -1
- package/site-templates/marketing-site-free/ailk-template.json +8 -0
- package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +103 -0
- package/site-templates/marketing-site-free/content/en/PrivacyPage/privacy.mdx +79 -0
- package/site-templates/marketing-site-free/content/en/TermsPage/terms.mdx +80 -0
- package/site-templates/marketing-site-free/provenance.json +4 -0
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +166 -0
- package/templates/apps/api/src/config/modules.ts +1 -1
- package/templates/apps/api/src/server.ts +46 -3
- package/templates/apps/web/app/[locale]/pricing/page.tsx +40 -0
- package/templates/apps/web/app/[locale]/privacy/page.tsx +40 -0
- package/templates/apps/web/app/[locale]/terms/page.tsx +40 -0
- package/templates/apps/web/jest.config.cjs +5 -0
- package/templates/apps/web/lib/__tests__/server-api.test.ts +61 -0
- package/templates/apps/web/lib/server-api.ts +28 -12
- package/templates/apps/web/messages/en.json +58 -0
- package/templates/database/CHANGELOG.md +14 -0
- package/templates/database/package.json +1 -1
- package/templates/package.json +1 -1
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: "TermsPage"
|
|
3
|
+
slug: "terms"
|
|
4
|
+
title: "Terms of Service"
|
|
5
|
+
description: "Placeholder terms of service for Your Company. Replace every section with terms that describe what you actually offer before you launch."
|
|
6
|
+
sections:
|
|
7
|
+
[
|
|
8
|
+
{
|
|
9
|
+
"id": "terms-document",
|
|
10
|
+
"type": "document-chassis",
|
|
11
|
+
"data":
|
|
12
|
+
{
|
|
13
|
+
"eyebrow": "PLACEHOLDER — REPLACE BEFORE LAUNCH",
|
|
14
|
+
"headline": "Terms of Service",
|
|
15
|
+
"backHref": "/",
|
|
16
|
+
"backLabel": "Back home",
|
|
17
|
+
"navGroupLabel": "Contents",
|
|
18
|
+
"sections":
|
|
19
|
+
[
|
|
20
|
+
{
|
|
21
|
+
"id": "placeholder-notice",
|
|
22
|
+
"heading": "This is placeholder text",
|
|
23
|
+
"blocks":
|
|
24
|
+
[
|
|
25
|
+
{
|
|
26
|
+
"kind": "paragraph",
|
|
27
|
+
"text": "This page is starter content that shipped with your site. It is not a contract, it binds nobody, and it is not legal advice. Replace every section below with terms that match what Your Company actually offers, and have someone qualified read them before you publish.",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"kind": "paragraph",
|
|
31
|
+
"text": "Until you do, leave this notice in place so nobody mistakes the page for finished terms.",
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"id": "what-to-write-here",
|
|
37
|
+
"heading": "What belongs on this page",
|
|
38
|
+
"blocks":
|
|
39
|
+
[
|
|
40
|
+
{
|
|
41
|
+
"kind": "paragraph",
|
|
42
|
+
"text": "Terms of service set out who is offering what, on what conditions. Write each part about the thing you actually sell, and keep the sentences short enough that a customer can follow them.",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"kind": "list",
|
|
46
|
+
"listStyle": "number",
|
|
47
|
+
"items":
|
|
48
|
+
[
|
|
49
|
+
"Who provides the service, and which legal entity stands behind it.",
|
|
50
|
+
"What the service is, and what it is not.",
|
|
51
|
+
"What a customer may and may not do with it.",
|
|
52
|
+
"How payment, renewal and cancellation work, if you charge.",
|
|
53
|
+
"Which warranties you give, and which you do not.",
|
|
54
|
+
"Which law governs the agreement, and how you will announce changes.",
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"id": "contact",
|
|
61
|
+
"heading": "Questions about these terms",
|
|
62
|
+
"blocks":
|
|
63
|
+
[
|
|
64
|
+
{
|
|
65
|
+
"kind": "paragraph",
|
|
66
|
+
"text": "Replace this with the address you actually monitor — for example hello@example.com — so a customer with a question about the terms has somewhere to send it.",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"kind": "paragraph",
|
|
70
|
+
"text": "Replace this with the date these terms last changed, and say what happens when they change again.",
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
]
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
{/* Rendered from frontmatter `sections[]`. Placeholder seed content — these are not terms of service and not legal advice. Replace every section before you launch. */}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file workspace-module-registration.test.ts
|
|
3
|
+
* @description The `workspace || multi-tenant` registration matrix (#5371 D2).
|
|
4
|
+
*
|
|
5
|
+
* `workspace` cannot be folded into the sibling `module-exclusion.test.ts`:
|
|
6
|
+
* that suite's shape is "excluding module X 404s every route X owns", and the
|
|
7
|
+
* three routes here register under EITHER module, so excluding `workspace`
|
|
8
|
+
* while `multi-tenant` is on leaves them registered by design. That is the
|
|
9
|
+
* whole point of D2's gate, so it gets its own matrix.
|
|
10
|
+
*
|
|
11
|
+
* The three cases §5.1 names:
|
|
12
|
+
*
|
|
13
|
+
* 1. workspace only — the three workspace-settings routes resolve for a
|
|
14
|
+
* single-tenant session; billing / workspace-session
|
|
15
|
+
* / org routes stay unregistered.
|
|
16
|
+
* 2. multi-tenant only — everything resolves exactly as it did before this
|
|
17
|
+
* change (the regression floor).
|
|
18
|
+
* 3. both enabled — each of workspaceConfigRoutes /
|
|
19
|
+
* workspaceGeneralRoutes / workspaceMembersRoutes
|
|
20
|
+
* registers exactly ONCE; Fastify throws on a
|
|
21
|
+
* duplicate route at build() time, so a successful
|
|
22
|
+
* build IS the assertion, backed by an explicit
|
|
23
|
+
* count over the router tree.
|
|
24
|
+
*
|
|
25
|
+
* "Resolves" is measured as "not Fastify's own 404". An unauthenticated
|
|
26
|
+
* request to a registered tenant route answers 401, never 404, so the two
|
|
27
|
+
* states are unambiguous — the same signal `module-exclusion.test.ts` uses.
|
|
28
|
+
*
|
|
29
|
+
* Mirrors `src/__tests__/server.test.ts`'s external-boundary mocks (CORS,
|
|
30
|
+
* rate-limit, email) — see that file's header for why each is mocked.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
jest.mock('@fastify/cors', () => {
|
|
34
|
+
return jest.fn().mockResolvedValue(undefined);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
jest.mock('@fastify/rate-limit', () => {
|
|
38
|
+
return jest.fn().mockResolvedValue(undefined);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
jest.mock('@working-theory/email', () => ({
|
|
42
|
+
sendLeadNotification: jest.fn().mockResolvedValue({ ok: true }),
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
import { build } from '../server';
|
|
46
|
+
|
|
47
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- server is typed via Fastify generics; explicit any avoids pulling in the full FastifyInstance type here
|
|
48
|
+
type App = any;
|
|
49
|
+
|
|
50
|
+
const WS = 'wksp_00000000-0000-7000-8000-0000000000aa';
|
|
51
|
+
|
|
52
|
+
/** The routes the widened gate must serve under EITHER module. */
|
|
53
|
+
const SHARED_TENANT_ROUTES = [
|
|
54
|
+
{ method: 'GET' as const, url: `/v1/workspaces/${WS}/theme` },
|
|
55
|
+
{ method: 'GET' as const, url: `/v1/workspaces/${WS}/identity` },
|
|
56
|
+
{ method: 'GET' as const, url: `/v1/workspaces/${WS}/seo` },
|
|
57
|
+
{ method: 'GET' as const, url: `/v1/workspaces/${WS}/general` },
|
|
58
|
+
{ method: 'GET' as const, url: `/v1/workspaces/${WS}/members` },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
/** Routes that must stay multi-tenant-only — the gate's other half. */
|
|
62
|
+
const MULTI_TENANT_ONLY_ROUTES = [
|
|
63
|
+
{ method: 'GET' as const, url: '/v1/workspaces' },
|
|
64
|
+
{ method: 'GET' as const, url: '/v1/billing' },
|
|
65
|
+
{ method: 'GET' as const, url: '/v1/waitlist-experiments' },
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
async function withEnabledModules<T>(
|
|
69
|
+
enabled: string,
|
|
70
|
+
fn: () => Promise<T>,
|
|
71
|
+
): Promise<T> {
|
|
72
|
+
const prior = process.env.AILK_ENABLED_MODULES;
|
|
73
|
+
process.env.AILK_ENABLED_MODULES = enabled;
|
|
74
|
+
try {
|
|
75
|
+
return await fn();
|
|
76
|
+
} finally {
|
|
77
|
+
if (prior === undefined) {
|
|
78
|
+
delete process.env.AILK_ENABLED_MODULES;
|
|
79
|
+
} else {
|
|
80
|
+
process.env.AILK_ENABLED_MODULES = prior;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Every (method, path) pair Fastify's router holds, one per line. */
|
|
86
|
+
function routeLines(app: App): string[] {
|
|
87
|
+
return (app.printRoutes({ commonPrefix: false }) as string)
|
|
88
|
+
.split('\n')
|
|
89
|
+
.map((line: string) => line.trim())
|
|
90
|
+
.filter(Boolean);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe('workspace-settings registration matrix (#5371 D2)', () => {
|
|
94
|
+
let app: App;
|
|
95
|
+
|
|
96
|
+
afterEach(async () => {
|
|
97
|
+
if (app) {
|
|
98
|
+
await app.close();
|
|
99
|
+
app = undefined;
|
|
100
|
+
}
|
|
101
|
+
delete process.env.AILK_ENABLED_MODULES;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("with 'workspace' enabled and 'multi-tenant' disabled, the five settings routes resolve", async () => {
|
|
105
|
+
app = await withEnabledModules('workspace', () => build());
|
|
106
|
+
|
|
107
|
+
for (const { method, url } of SHARED_TENANT_ROUTES) {
|
|
108
|
+
const res = await app.inject({ method, url });
|
|
109
|
+
expect({ url, status: res.statusCode }).toEqual({ url, status: 401 });
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("with 'workspace' alone, billing / workspace-session / org routes stay unregistered", async () => {
|
|
114
|
+
app = await withEnabledModules('workspace', () => build());
|
|
115
|
+
|
|
116
|
+
for (const { method, url } of MULTI_TENANT_ONLY_ROUTES) {
|
|
117
|
+
const res = await app.inject({ method, url });
|
|
118
|
+
expect({ url, status: res.statusCode }).toEqual({ url, status: 404 });
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("with 'multi-tenant' enabled and 'workspace' disabled, every route resolves as before", async () => {
|
|
123
|
+
app = await withEnabledModules('multi-tenant', () => build());
|
|
124
|
+
|
|
125
|
+
for (const { method, url } of [
|
|
126
|
+
...SHARED_TENANT_ROUTES,
|
|
127
|
+
...MULTI_TENANT_ONLY_ROUTES,
|
|
128
|
+
]) {
|
|
129
|
+
const res = await app.inject({ method, url });
|
|
130
|
+
expect({ url, status: res.statusCode }).not.toEqual({ url, status: 404 });
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('with BOTH enabled, each shared route registers exactly once', async () => {
|
|
135
|
+
// build() itself is the first assertion: Fastify throws
|
|
136
|
+
// FST_ERR_DUPLICATED_ROUTE at registration time, so a double-registration
|
|
137
|
+
// never reaches the router tree to be counted.
|
|
138
|
+
app = await withEnabledModules('workspace,multi-tenant', () => build());
|
|
139
|
+
|
|
140
|
+
const lines = routeLines(app);
|
|
141
|
+
for (const segment of ['theme', 'identity', 'seo', 'general', 'members']) {
|
|
142
|
+
const matches = lines.filter((line) => line.includes(segment));
|
|
143
|
+
expect({ segment, count: matches.length }).toEqual({
|
|
144
|
+
segment,
|
|
145
|
+
count: 1,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
for (const { method, url } of [
|
|
150
|
+
...SHARED_TENANT_ROUTES,
|
|
151
|
+
...MULTI_TENANT_ONLY_ROUTES,
|
|
152
|
+
]) {
|
|
153
|
+
const res = await app.inject({ method, url });
|
|
154
|
+
expect({ url, status: res.statusCode }).not.toEqual({ url, status: 404 });
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('with neither enabled, none of the five settings routes registers', async () => {
|
|
159
|
+
app = await withEnabledModules('', () => build());
|
|
160
|
+
|
|
161
|
+
for (const { method, url } of SHARED_TENANT_ROUTES) {
|
|
162
|
+
const res = await app.inject({ method, url });
|
|
163
|
+
expect({ url, status: res.statusCode }).toEqual({ url, status: 404 });
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* means the same (the OSS-strip build target); a list enables exactly the
|
|
10
10
|
* modules it names.
|
|
11
11
|
*
|
|
12
|
-
* Recognized module keys: `docs`, `multi-tenant`, `scheduling`,
|
|
12
|
+
* Recognized module keys: `docs`, `multi-tenant`, `workspace`, `scheduling`,
|
|
13
13
|
* `signal-collectors`, `commerce` (the `ModuleName` vocabulary
|
|
14
14
|
* `packages/create-ailk/src/module-architecture.ts` already models via
|
|
15
15
|
* `MODULE_SLICES`) plus `content-write` — the Pro
|
|
@@ -130,6 +130,8 @@ import {
|
|
|
130
130
|
const WORKSPACES_INDEX = "./routes/workspaces/index.js";
|
|
131
131
|
const WORKSPACES_SESSIONS = "./routes/workspaces/sessions.js";
|
|
132
132
|
const WORKSPACES_CONFIG = "./routes/workspaces/config.js";
|
|
133
|
+
const WORKSPACES_GENERAL = "./routes/workspaces/general.js";
|
|
134
|
+
const WORKSPACES_MEMBERS = "./routes/workspaces/members.js";
|
|
133
135
|
const BILLING_INDEX = "./routes/billing/index.js";
|
|
134
136
|
const WAITLIST_EXPERIMENTS_INDEX = "./routes/waitlist-experiments/index.js";
|
|
135
137
|
const CONTENT_CREATE = "./routes/content/create.js";
|
|
@@ -171,6 +173,14 @@ const SLICE_PLUGINS = {
|
|
|
171
173
|
specifier: WORKSPACES_CONFIG,
|
|
172
174
|
load: () => import(WORKSPACES_CONFIG),
|
|
173
175
|
},
|
|
176
|
+
workspaceGeneralRoutes: {
|
|
177
|
+
specifier: WORKSPACES_GENERAL,
|
|
178
|
+
load: () => import(WORKSPACES_GENERAL),
|
|
179
|
+
},
|
|
180
|
+
workspaceMembersRoutes: {
|
|
181
|
+
specifier: WORKSPACES_MEMBERS,
|
|
182
|
+
load: () => import(WORKSPACES_MEMBERS),
|
|
183
|
+
},
|
|
174
184
|
billingRoutes: {
|
|
175
185
|
specifier: BILLING_INDEX,
|
|
176
186
|
load: () => import(BILLING_INDEX),
|
|
@@ -568,9 +578,6 @@ export async function build() {
|
|
|
568
578
|
await sliceRoutePlugin("workspaceSessionRoutes"),
|
|
569
579
|
);
|
|
570
580
|
await tenantScope.register(await sliceRoutePlugin("sessionRoutes"));
|
|
571
|
-
await tenantScope.register(
|
|
572
|
-
await sliceRoutePlugin("workspaceConfigRoutes"),
|
|
573
|
-
);
|
|
574
581
|
// Org-billing routes (#3701) — org-scoped read + Stripe customer-portal.
|
|
575
582
|
await tenantScope.register(await sliceRoutePlugin("billingRoutes"));
|
|
576
583
|
// Waitlist operator-dashboard read API (#4665 D7, B0) — tenant-scoped
|
|
@@ -585,6 +592,42 @@ export async function build() {
|
|
|
585
592
|
await tenantScope.register(projectListingTenantRoutes);
|
|
586
593
|
});
|
|
587
594
|
}
|
|
595
|
+
|
|
596
|
+
// Workspace SETTINGS routes — a sibling tenant scope, gated on EITHER
|
|
597
|
+
// module (#5371 D2). The site-config routes were registered inside the
|
|
598
|
+
// multi-tenant scope above, which was too narrow: nothing in their logic
|
|
599
|
+
// is multi-tenant-specific, and since Phase 2 `resolveActiveTenant`
|
|
600
|
+
// resolves a single-tenant session's TenantContext from the default
|
|
601
|
+
// Organization/Workspace exactly like a multi-tenant one. Widening the
|
|
602
|
+
// gate is the correction; duplicating the route file would encode the
|
|
603
|
+
// original mis-scoping twice.
|
|
604
|
+
//
|
|
605
|
+
// This is a REGISTRATION change, not a tenant-isolation change: the scope
|
|
606
|
+
// registers its own `tenantPreHandlerSlicePlugin()`, the same default-deny
|
|
607
|
+
// guard the scope above uses, with the same `resolveActiveTenant` behind
|
|
608
|
+
// it. Every other route in that scope (workspaceSessionRoutes,
|
|
609
|
+
// sessionRoutes, billingRoutes, waitlistExperimentRoutes, the
|
|
610
|
+
// project-listings tenant routes) stays multi-tenant-only.
|
|
611
|
+
//
|
|
612
|
+
// A build with BOTH modules enabled registers each of the three route
|
|
613
|
+
// plugins exactly once: they live here and nowhere else, so the two
|
|
614
|
+
// scopes cannot collide on a path.
|
|
615
|
+
if (isModuleEnabled("workspace") || isModuleEnabled("multi-tenant")) {
|
|
616
|
+
await authScope.register(async (tenantScope) => {
|
|
617
|
+
await tenantScope.register(await tenantPreHandlerSlicePlugin());
|
|
618
|
+
await tenantScope.register(
|
|
619
|
+
await sliceRoutePlugin("workspaceConfigRoutes"),
|
|
620
|
+
);
|
|
621
|
+
// The Pro workspace module's own two routes (#5371): the workspace
|
|
622
|
+
// name, and the read-only member roster.
|
|
623
|
+
await tenantScope.register(
|
|
624
|
+
await sliceRoutePlugin("workspaceGeneralRoutes"),
|
|
625
|
+
);
|
|
626
|
+
await tenantScope.register(
|
|
627
|
+
await sliceRoutePlugin("workspaceMembersRoutes"),
|
|
628
|
+
);
|
|
629
|
+
});
|
|
630
|
+
}
|
|
588
631
|
});
|
|
589
632
|
|
|
590
633
|
// Public routes — no auth required.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Pricing page. Reads the Pricing MDX frontmatter, renders sections via
|
|
2
|
+
// SectionRenderer, and emits a JSON-LD <script>.
|
|
3
|
+
import type {
|
|
4
|
+
getMdxSource as defaultGetMdxSource,
|
|
5
|
+
getPageSections as defaultGetPageSections} from "@working-theory/page-renderer";
|
|
6
|
+
import {
|
|
7
|
+
renderPage,
|
|
8
|
+
renderPageMetadata
|
|
9
|
+
} from "@working-theory/page-renderer";
|
|
10
|
+
import type { Metadata } from "next";
|
|
11
|
+
import type { ReactElement } from "react";
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
params: Promise<{ locale: string }>;
|
|
15
|
+
getMdxSource?: typeof defaultGetMdxSource;
|
|
16
|
+
getPageSections?: typeof defaultGetPageSections;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function generateMetadata({
|
|
20
|
+
params,
|
|
21
|
+
}: Pick<Props, "params">): Promise<Metadata> {
|
|
22
|
+
const { locale } = await params;
|
|
23
|
+
return renderPageMetadata({ type: "Pricing", slug: "pricing", locale });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default async function Page({
|
|
27
|
+
params,
|
|
28
|
+
getMdxSource,
|
|
29
|
+
getPageSections,
|
|
30
|
+
}: Props): Promise<ReactElement> {
|
|
31
|
+
const { locale } = await params;
|
|
32
|
+
return renderPage({
|
|
33
|
+
type: "Pricing",
|
|
34
|
+
slug: "pricing",
|
|
35
|
+
locale,
|
|
36
|
+
translationsNamespace: "pricing",
|
|
37
|
+
...(getMdxSource !== undefined && { getMdxSource }),
|
|
38
|
+
...(getPageSections !== undefined && { getPageSections }),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Privacy page. Reads the PrivacyPage MDX frontmatter, renders sections via
|
|
2
|
+
// SectionRenderer, and emits a JSON-LD <script>.
|
|
3
|
+
import type {
|
|
4
|
+
getMdxSource as defaultGetMdxSource,
|
|
5
|
+
getPageSections as defaultGetPageSections} from "@working-theory/page-renderer";
|
|
6
|
+
import {
|
|
7
|
+
renderPage,
|
|
8
|
+
renderPageMetadata
|
|
9
|
+
} from "@working-theory/page-renderer";
|
|
10
|
+
import type { Metadata } from "next";
|
|
11
|
+
import type { ReactElement } from "react";
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
params: Promise<{ locale: string }>;
|
|
15
|
+
getMdxSource?: typeof defaultGetMdxSource;
|
|
16
|
+
getPageSections?: typeof defaultGetPageSections;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function generateMetadata({
|
|
20
|
+
params,
|
|
21
|
+
}: Pick<Props, "params">): Promise<Metadata> {
|
|
22
|
+
const { locale } = await params;
|
|
23
|
+
return renderPageMetadata({ type: "PrivacyPage", slug: "privacy", locale });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default async function Page({
|
|
27
|
+
params,
|
|
28
|
+
getMdxSource,
|
|
29
|
+
getPageSections,
|
|
30
|
+
}: Props): Promise<ReactElement> {
|
|
31
|
+
const { locale } = await params;
|
|
32
|
+
return renderPage({
|
|
33
|
+
type: "PrivacyPage",
|
|
34
|
+
slug: "privacy",
|
|
35
|
+
locale,
|
|
36
|
+
translationsNamespace: "privacy",
|
|
37
|
+
...(getMdxSource !== undefined && { getMdxSource }),
|
|
38
|
+
...(getPageSections !== undefined && { getPageSections }),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Terms page. Reads the TermsPage MDX frontmatter, renders sections via
|
|
2
|
+
// SectionRenderer, and emits a JSON-LD <script>.
|
|
3
|
+
import type {
|
|
4
|
+
getMdxSource as defaultGetMdxSource,
|
|
5
|
+
getPageSections as defaultGetPageSections} from "@working-theory/page-renderer";
|
|
6
|
+
import {
|
|
7
|
+
renderPage,
|
|
8
|
+
renderPageMetadata
|
|
9
|
+
} from "@working-theory/page-renderer";
|
|
10
|
+
import type { Metadata } from "next";
|
|
11
|
+
import type { ReactElement } from "react";
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
params: Promise<{ locale: string }>;
|
|
15
|
+
getMdxSource?: typeof defaultGetMdxSource;
|
|
16
|
+
getPageSections?: typeof defaultGetPageSections;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function generateMetadata({
|
|
20
|
+
params,
|
|
21
|
+
}: Pick<Props, "params">): Promise<Metadata> {
|
|
22
|
+
const { locale } = await params;
|
|
23
|
+
return renderPageMetadata({ type: "TermsPage", slug: "terms", locale });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default async function Page({
|
|
27
|
+
params,
|
|
28
|
+
getMdxSource,
|
|
29
|
+
getPageSections,
|
|
30
|
+
}: Props): Promise<ReactElement> {
|
|
31
|
+
const { locale } = await params;
|
|
32
|
+
return renderPage({
|
|
33
|
+
type: "TermsPage",
|
|
34
|
+
slug: "terms",
|
|
35
|
+
locale,
|
|
36
|
+
translationsNamespace: "terms",
|
|
37
|
+
...(getMdxSource !== undefined && { getMdxSource }),
|
|
38
|
+
...(getPageSections !== undefined && { getPageSections }),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -92,6 +92,11 @@ module.exports = {
|
|
|
92
92
|
"<rootDir>/node_modules/@working-theory/site-config/dist/schema/index.js",
|
|
93
93
|
"^@working-theory/validation$": "<rootDir>/node_modules/@working-theory/validation/dist/index.js",
|
|
94
94
|
"^@working-theory/web-i18n$": "<rootDir>/node_modules/@working-theory/web-i18n/dist/index.js",
|
|
95
|
+
// The dependency-free tenancy-constants subpath (database/tenancy.ts —
|
|
96
|
+
// it imports only a type from @working-theory/ids). Mapped to source like
|
|
97
|
+
// every sibling above; `database/dist` is not built in this workspace
|
|
98
|
+
// pre-`pnpm build`.
|
|
99
|
+
"^@working-theory/database/tenancy$": "<rootDir>/node_modules/@working-theory/database/dist/tenancy.js",
|
|
95
100
|
// #5025: pnpm keys next-intl's virtual-store path on its resolved peers,
|
|
96
101
|
// so apps/web and packages/page-renderer can link two PHYSICALLY DISTINCT
|
|
97
102
|
// copies of the same version — and a `jest.mock("next-intl/server")` in
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `forwardedCookieInit` — the RSC cookie/origin-forwarding helper
|
|
3
|
+
* (#5371 shepherd fix-up).
|
|
4
|
+
*
|
|
5
|
+
* Contract: the returned `init.headers` always carries the forwarded
|
|
6
|
+
* `cookie`, and carries `origin` ONLY when the incoming request's `Origin`
|
|
7
|
+
* header is present — never a fabricated value. This is what lets a Server
|
|
8
|
+
* Action `PUT`/`PATCH` through `@working-theory/api-client`'s `workspace.*`
|
|
9
|
+
* namespace pass `apps/api`'s same-origin CSRF check (`isOriginAllowed`),
|
|
10
|
+
* which Node's server-side `fetch` cannot satisfy on its own since it never
|
|
11
|
+
* sets `Origin`.
|
|
12
|
+
*/
|
|
13
|
+
import { forwardedCookieInit } from "../server-api";
|
|
14
|
+
|
|
15
|
+
const mockCookies = jest.fn();
|
|
16
|
+
const mockHeaders = jest.fn();
|
|
17
|
+
|
|
18
|
+
jest.mock("next/headers", () => ({
|
|
19
|
+
__esModule: true,
|
|
20
|
+
cookies: (...args: unknown[]) => mockCookies(...args),
|
|
21
|
+
headers: (...args: unknown[]) => mockHeaders(...args),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
describe("forwardedCookieInit", () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
mockCookies.mockReset();
|
|
27
|
+
mockHeaders.mockReset();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("forwards cookie + origin when the incoming request carries an Origin header", async () => {
|
|
31
|
+
mockCookies.mockResolvedValue({
|
|
32
|
+
toString: () => "better-auth.session_token=abc",
|
|
33
|
+
});
|
|
34
|
+
mockHeaders.mockResolvedValue(
|
|
35
|
+
new Headers({ origin: "https://app.example.com" }),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const init = await forwardedCookieInit();
|
|
39
|
+
|
|
40
|
+
expect(init).toEqual({
|
|
41
|
+
headers: {
|
|
42
|
+
cookie: "better-auth.session_token=abc",
|
|
43
|
+
origin: "https://app.example.com",
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("forwards cookie alone — no origin key at all — when Origin is absent", async () => {
|
|
49
|
+
mockCookies.mockResolvedValue({
|
|
50
|
+
toString: () => "better-auth.session_token=abc",
|
|
51
|
+
});
|
|
52
|
+
mockHeaders.mockResolvedValue(new Headers());
|
|
53
|
+
|
|
54
|
+
const init = await forwardedCookieInit();
|
|
55
|
+
|
|
56
|
+
expect(init).toEqual({
|
|
57
|
+
headers: { cookie: "better-auth.session_token=abc" },
|
|
58
|
+
});
|
|
59
|
+
expect(init.headers).not.toHaveProperty("origin");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -3,22 +3,26 @@
|
|
|
3
3
|
* (#4665 D9).
|
|
4
4
|
*
|
|
5
5
|
* Forwards the incoming request's cookies to `@working-theory/api-client`'s
|
|
6
|
-
* `waitlistExperiments`
|
|
7
|
-
* auth preHandler resolves the SAME Better Auth
|
|
8
|
-
* both apps share the session store.
|
|
9
|
-
* is
|
|
6
|
+
* `waitlistExperiments` / `workspace` namespaces (B0, #4671; #5371) so
|
|
7
|
+
* `apps/api`'s cookie-session auth preHandler resolves the SAME Better Auth
|
|
8
|
+
* session the browser holds — both apps share the session store. Every page
|
|
9
|
+
* is an RSC and every write is a Server Action; there is no client fetch and
|
|
10
|
+
* no `NEXT_PUBLIC_` credential (D9). For a Server Action `PUT`/`PATCH`, the
|
|
11
|
+
* incoming request's `Origin` is forwarded alongside the cookie so
|
|
12
|
+
* `apps/api`'s same-origin CSRF check (`isOriginAllowed`) passes — Node's
|
|
13
|
+
* server-side `fetch` never sets `Origin` on its own.
|
|
10
14
|
*
|
|
11
15
|
* `apps/web` may not import `apps/api` directly (architecture.yaml) — every
|
|
12
|
-
*
|
|
16
|
+
* call goes through `@working-theory/api-client`'s typed namespace, never a
|
|
13
17
|
* hand-rolled `fetch`. This module supplies exactly the two things that
|
|
14
|
-
* namespace doesn't own: the cookie-forwarding `init` (B0's additive
|
|
18
|
+
* namespace doesn't own: the cookie/origin-forwarding `init` (B0's additive
|
|
15
19
|
* `init?: { headers }` on `http.get`) and the envelope→`ApiError` unwrap
|
|
16
20
|
* (mirrors `apps-paid/workspace/lib/api-error.ts`'s `unwrapEnvelope`, adapted to
|
|
17
21
|
* the `@working-theory/api-client` `ApiResult<T>` shape B0 already returns
|
|
18
22
|
* rather than re-parsing the wire envelope by hand).
|
|
19
23
|
*/
|
|
20
24
|
|
|
21
|
-
import { cookies } from "next/headers";
|
|
25
|
+
import { cookies, headers } from "next/headers";
|
|
22
26
|
|
|
23
27
|
/**
|
|
24
28
|
* Structural mirror of `@working-theory/api-client`'s `ApiResult<T>`
|
|
@@ -57,15 +61,27 @@ interface ErrorBody {
|
|
|
57
61
|
}
|
|
58
62
|
|
|
59
63
|
/**
|
|
60
|
-
* Forward the incoming request's cookies
|
|
61
|
-
* `waitlistExperiments.*` call accepts.
|
|
62
|
-
*
|
|
64
|
+
* Forward the incoming request's cookies — and, when present, its `Origin` —
|
|
65
|
+
* as the `init` every `waitlistExperiments.*` / `workspace.*` call accepts.
|
|
66
|
+
* Call once per request — `cookies()` / `headers()` read the current RSC
|
|
67
|
+
* request context.
|
|
68
|
+
*
|
|
69
|
+
* The `Origin` forward matters for every non-GET call: `apps/api`'s
|
|
70
|
+
* `authPreHandler` (`isOriginAllowed`) requires a matching `Origin` header on
|
|
71
|
+
* a cookie-authenticated mutating request, and Node's server-side `fetch`
|
|
72
|
+
* (unlike a browser XHR/fetch) never sets one on its own. A Server Action's
|
|
73
|
+
* incoming request DOES carry the browser's real `Origin`, so re-forwarding
|
|
74
|
+
* it here — rather than fabricating one — lets a genuine same-origin write
|
|
75
|
+
* pass the check exactly as a browser-originated one would. RSC `GET`s are
|
|
76
|
+
* unaffected: `isOriginAllowed` short-circuits `true` for safe methods before
|
|
77
|
+
* it ever inspects the header.
|
|
63
78
|
*/
|
|
64
79
|
export async function forwardedCookieInit(): Promise<{
|
|
65
|
-
headers: { cookie: string };
|
|
80
|
+
headers: { cookie: string; origin?: string };
|
|
66
81
|
}> {
|
|
67
82
|
const cookieHeader = (await cookies()).toString();
|
|
68
|
-
|
|
83
|
+
const origin = (await headers()).get("origin");
|
|
84
|
+
return { headers: { cookie: cookieHeader, ...(origin ? { origin } : {}) } };
|
|
69
85
|
}
|
|
70
86
|
|
|
71
87
|
/**
|