chore(template): clean-slate template snapshot from bb4a0c7
Curated, product-agnostic snapshot of the post-story-04 tree: demo content deleted, auth-only reference feature, web-next shell, all gates green. Product-specific docs, ADRs 027-029, PRDs/epics/archive, editor library traces, and product naming are curated out; generic template repairs (coverage provider devDeps, root test:coverage script, live lint fixes, root-only release-please) are kept. See TEMPLATE.md for provenance, curation list, and usage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
89
apps/cms/AGENTS.md
Normal file
89
apps/cms/AGENTS.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# AGENTS.md — apps/cms
|
||||
|
||||
**Thin shell** hosting the Payload CMS admin panel via Next.js. All CMS configuration (collections, globals, hooks, access control, `payload.config.ts`) lives in `@repo/core-cms`, which aggregates collections from feature packages.
|
||||
|
||||
## Purpose
|
||||
|
||||
This app exists solely to serve the Payload Admin UI. It contains no custom CMS code beyond Next.js routing boilerplate. All business knowledge lives in feature packages (`@repo/auth`, etc.), which export their collections/globals via subpath exports (`.../cms`). `@repo/core-cms` composes them into a single Payload config.
|
||||
|
||||
## Port: 3001
|
||||
|
||||
```bash
|
||||
docker compose up -d postgres # Start PostgreSQL on port 5432
|
||||
pnpm dev --filter @repo/cms # http://localhost:3001/admin
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
| -------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| `next.config.mjs` | Minimal config wrapped with `withPayload()` from `@payloadcms/next` |
|
||||
| `tsconfig.json` | TypeScript config with `@payload-config` path alias |
|
||||
| `src/app/(payload)/layout.tsx` | Auto-generated Payload root layout (DO NOT MODIFY) |
|
||||
| `src/app/(payload)/admin/[[...segments]]/page.tsx` | Auto-generated catch-all admin page (DO NOT MODIFY) |
|
||||
| `src/app/(payload)/importMap.js` | Auto-generated Payload import map (DO NOT MODIFY) |
|
||||
|
||||
## Hard Rules
|
||||
|
||||
- **NEVER** add collections, globals, or hooks in this app — put them in feature packages
|
||||
- **NEVER** create custom CMS logic here — use `@repo/core-cms`
|
||||
- **NEVER** modify auto-generated files under `src/app/(payload)/`
|
||||
- All Payload config changes go in `packages/core-cms/src/payload.config.ts`
|
||||
|
||||
## @payload-config Alias
|
||||
|
||||
The `tsconfig.json` points to `@repo/core-cms`:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@payload-config": ["../../packages/core-cms/src/payload.config.ts"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When Payload imports `@payload-config`, it resolves to the composed config from `@repo/core-cms`, which in turn imports feature collections.
|
||||
|
||||
## Composition flow
|
||||
|
||||
```
|
||||
Feature (@repo/auth)
|
||||
└─ src/integrations/cms/collections/users.ts
|
||||
└─ exported as ./cms
|
||||
|
||||
Core CMS (@repo/core-cms)
|
||||
└─ src/payload.config.ts
|
||||
imports all feature /cms exports
|
||||
calls buildConfig({ collections, globals })
|
||||
|
||||
This app (@repo/cms)
|
||||
└─ src/app/(payload)/layout.tsx
|
||||
loads config from @payload-config
|
||||
Payload CLI auto-generates admin routes
|
||||
```
|
||||
|
||||
## Type Generation
|
||||
|
||||
After adding/modifying collections in any feature's `/cms` folder:
|
||||
|
||||
```bash
|
||||
cd apps/cms && pnpm generate:types
|
||||
# Regenerates packages/core-cms/src/generated-types.ts
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Purpose |
|
||||
| ------------------ | ------------------------------- |
|
||||
| `@repo/core-cms` | Payload config + buildConfig |
|
||||
| `@payloadcms/next` | Next.js integration for Payload |
|
||||
| `payload` | Payload CMS core |
|
||||
| `next` | Next.js 15 framework |
|
||||
| `sharp` | Image processing |
|
||||
|
||||
## Cross-References
|
||||
|
||||
- **Feature collections:** each feature's `src/integrations/cms/` folder
|
||||
- **CMS composition:** `packages/core-cms/AGENTS.md`
|
||||
11
apps/cms/eslint.config.js
Normal file
11
apps/cms/eslint.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import baseConfig from "@repo/core-eslint/base";
|
||||
|
||||
export default [
|
||||
...baseConfig,
|
||||
{
|
||||
files: ["next-env.d.ts"],
|
||||
rules: {
|
||||
"@typescript-eslint/triple-slash-reference": "off",
|
||||
},
|
||||
},
|
||||
];
|
||||
22
apps/cms/instrumentation.ts
Normal file
22
apps/cms/instrumentation.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// apps/cms/instrumentation.ts
|
||||
// CMS is server-only (Payload admin UI). No instrumentation-client.ts here —
|
||||
// Payload admin UI bundling is opinionated and the public DSN flow is
|
||||
// out-of-scope per spec §8.
|
||||
//
|
||||
// Initializes the OTel SDK here so PII scrub processors are active from the
|
||||
// very first request — before bindAll() fires (C1 fix).
|
||||
|
||||
export async function register() {
|
||||
if (
|
||||
process.env["NEXT_RUNTIME"] === "nodejs" ||
|
||||
process.env["NEXT_RUNTIME"] === "edge"
|
||||
) {
|
||||
const { initOtelServerNode } =
|
||||
await import("@repo/core-shared/instrumentation/otel/init-server-node");
|
||||
initOtelServerNode({
|
||||
dsn: process.env["CMS_SENTRY_DSN"] ?? "",
|
||||
serviceName: "cms",
|
||||
environment: process.env["NODE_ENV"] ?? "development",
|
||||
});
|
||||
}
|
||||
}
|
||||
18
apps/cms/middleware.ts
Normal file
18
apps/cms/middleware.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { buildSecurityHeaders } from "@repo/core-shared/security";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export function middleware(_request: NextRequest): NextResponse {
|
||||
const mode = process.env.NODE_ENV === "production" ? "prod" : "dev";
|
||||
const secHeaders = buildSecurityHeaders({ mode });
|
||||
|
||||
const response = NextResponse.next();
|
||||
for (const [name, value] of Object.entries(secHeaders)) {
|
||||
response.headers.set(name, value);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||
};
|
||||
6
apps/cms/next-env.d.ts
vendored
Normal file
6
apps/cms/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
14
apps/cms/next.config.mjs
Normal file
14
apps/cms/next.config.mjs
Normal file
@@ -0,0 +1,14 @@
|
||||
import { withPayload } from "@payloadcms/next/withPayload";
|
||||
import { withSentryConfig } from "@sentry/nextjs";
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {};
|
||||
|
||||
export default withSentryConfig(withPayload(nextConfig), {
|
||||
silent: process.env.CI !== "true",
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||
org: process.env.SENTRY_ORG,
|
||||
project: process.env.SENTRY_PROJECT_CMS,
|
||||
hideSourceMaps: true,
|
||||
disableLogger: true,
|
||||
});
|
||||
38
apps/cms/package.json
Normal file
38
apps/cms/package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@repo/cms",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "echo 'CMS build requires database — use docker compose or pnpm dev'",
|
||||
"dev": "next dev --port 3001",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"generate:types": "payload generate:types"
|
||||
},
|
||||
"dependencies": {
|
||||
"@payloadcms/next": "^3.14.0",
|
||||
"@payloadcms/richtext-lexical": "^3.14.0",
|
||||
"@payloadcms/ui": "^3.14.0",
|
||||
"@repo/core-cms": "workspace:*",
|
||||
"@repo/core-shared": "workspace:*",
|
||||
"@sentry/nextjs": "^10.51.0",
|
||||
"next": "^15.3.0",
|
||||
"payload": "^3.14.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"sass": "^1.99.0",
|
||||
"sharp": "^0.33.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/core-eslint": "workspace:*",
|
||||
"@repo/core-testing": "workspace:*",
|
||||
"@repo/core-typescript": "workspace:*",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import config from "@payload-config";
|
||||
import { NotFoundPage, generatePageMetadata } from "@payloadcms/next/views";
|
||||
import { importMap } from "../importMap";
|
||||
|
||||
type Args = {
|
||||
params: Promise<{ segments: string[] }>;
|
||||
searchParams: Promise<Record<string, string | string[]>>;
|
||||
};
|
||||
|
||||
export const generateMetadata = ({
|
||||
params,
|
||||
searchParams,
|
||||
}: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config, params, searchParams });
|
||||
|
||||
const NotFound = ({ params, searchParams }: Args) =>
|
||||
NotFoundPage({ config, importMap, params, searchParams });
|
||||
|
||||
export default NotFound;
|
||||
23
apps/cms/src/app/(payload)/admin/[[...segments]]/page.tsx
Normal file
23
apps/cms/src/app/(payload)/admin/[[...segments]]/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import config from "@payload-config";
|
||||
import { RootPage, generatePageMetadata } from "@payloadcms/next/views";
|
||||
import { importMap } from "../importMap";
|
||||
|
||||
type Args = {
|
||||
params: Promise<{ segments: string[] }>;
|
||||
searchParams: Promise<Record<string, string | string[]>>;
|
||||
};
|
||||
|
||||
export const generateMetadata = ({
|
||||
params,
|
||||
searchParams,
|
||||
}: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config, params, searchParams });
|
||||
|
||||
const Page = ({ params, searchParams }: Args) =>
|
||||
RootPage({ config, importMap, params, searchParams });
|
||||
|
||||
export default Page;
|
||||
76
apps/cms/src/app/(payload)/admin/importMap.js
Normal file
76
apps/cms/src/app/(payload)/admin/importMap.js
Normal file
@@ -0,0 +1,76 @@
|
||||
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from "@payloadcms/richtext-lexical/rsc";
|
||||
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from "@payloadcms/richtext-lexical/rsc";
|
||||
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from "@payloadcms/richtext-lexical/rsc";
|
||||
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from "@payloadcms/next/rsc";
|
||||
|
||||
/** @type {Record<string, any>} */
|
||||
export const importMap = {
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell":
|
||||
RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField":
|
||||
RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent":
|
||||
LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient":
|
||||
InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient":
|
||||
HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UploadFeatureClient":
|
||||
UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient":
|
||||
BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient":
|
||||
RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#LinkFeatureClient":
|
||||
LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient":
|
||||
ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient":
|
||||
OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient":
|
||||
UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#IndentFeatureClient":
|
||||
IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#AlignFeatureClient":
|
||||
AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#HeadingFeatureClient":
|
||||
HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient":
|
||||
ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient":
|
||||
InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient":
|
||||
SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient":
|
||||
SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient":
|
||||
StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient":
|
||||
UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BoldFeatureClient":
|
||||
BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ItalicFeatureClient":
|
||||
ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/next/rsc#CollectionCards":
|
||||
CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1,
|
||||
};
|
||||
19
apps/cms/src/app/(payload)/api/[...slug]/route.ts
Normal file
19
apps/cms/src/app/(payload)/api/[...slug]/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from "@payload-config";
|
||||
import "@payloadcms/next/css";
|
||||
import {
|
||||
REST_DELETE,
|
||||
REST_GET,
|
||||
REST_OPTIONS,
|
||||
REST_PATCH,
|
||||
REST_POST,
|
||||
REST_PUT,
|
||||
} from "@payloadcms/next/routes";
|
||||
|
||||
export const GET = REST_GET(config);
|
||||
export const POST = REST_POST(config);
|
||||
export const DELETE = REST_DELETE(config);
|
||||
export const PATCH = REST_PATCH(config);
|
||||
export const PUT = REST_PUT(config);
|
||||
export const OPTIONS = REST_OPTIONS(config);
|
||||
6
apps/cms/src/app/(payload)/api/graphql/route.ts
Normal file
6
apps/cms/src/app/(payload)/api/graphql/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from "@payload-config";
|
||||
import { GRAPHQL_POST } from "@payloadcms/next/routes";
|
||||
|
||||
export const POST = GRAPHQL_POST(config);
|
||||
1
apps/cms/src/app/(payload)/custom.scss
Normal file
1
apps/cms/src/app/(payload)/custom.scss
Normal file
@@ -0,0 +1 @@
|
||||
// Custom admin panel styles
|
||||
35
apps/cms/src/app/(payload)/layout.tsx
Normal file
35
apps/cms/src/app/(payload)/layout.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from "@payload-config";
|
||||
import "@payloadcms/next/css";
|
||||
import type { ServerFunctionClient } from "payload";
|
||||
import { handleServerFunctions, RootLayout } from "@payloadcms/next/layouts";
|
||||
import React from "react";
|
||||
|
||||
import { importMap } from "./admin/importMap.js";
|
||||
import "./custom.scss";
|
||||
|
||||
type Args = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const serverFunction: ServerFunctionClient = async function (args) {
|
||||
"use server";
|
||||
return handleServerFunctions({
|
||||
...args,
|
||||
config,
|
||||
importMap,
|
||||
});
|
||||
};
|
||||
|
||||
const Layout = ({ children }: Args) => (
|
||||
<RootLayout
|
||||
config={config}
|
||||
importMap={importMap}
|
||||
serverFunction={serverFunction}
|
||||
>
|
||||
{children}
|
||||
</RootLayout>
|
||||
);
|
||||
|
||||
export default Layout;
|
||||
81
apps/cms/src/middleware.test.ts
Normal file
81
apps/cms/src/middleware.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const responseMock = vi.hoisted(() => {
|
||||
function makeResponseMock() {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
_store: store,
|
||||
headers: {
|
||||
set: vi.fn((k: string, v: string) => store.set(k, v)),
|
||||
get: vi.fn((k: string) => store.get(k) ?? null),
|
||||
},
|
||||
};
|
||||
}
|
||||
return { makeResponseMock };
|
||||
});
|
||||
|
||||
vi.mock("next/server", () => ({
|
||||
NextResponse: {
|
||||
next: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { middleware } from "../middleware";
|
||||
|
||||
const ALL_SIX_HEADERS = [
|
||||
"Strict-Transport-Security",
|
||||
"X-Frame-Options",
|
||||
"X-Content-Type-Options",
|
||||
"Referrer-Policy",
|
||||
"Permissions-Policy",
|
||||
"Content-Security-Policy",
|
||||
] as const;
|
||||
|
||||
function makeRequest(): NextRequest {
|
||||
return { headers: new Headers() } as unknown as NextRequest;
|
||||
}
|
||||
|
||||
describe("cms middleware", () => {
|
||||
let mock: ReturnType<typeof responseMock.makeResponseMock>;
|
||||
|
||||
beforeEach(() => {
|
||||
mock = responseMock.makeResponseMock();
|
||||
vi.mocked(NextResponse.next).mockReturnValue(
|
||||
mock as unknown as ReturnType<typeof NextResponse.next>,
|
||||
);
|
||||
});
|
||||
|
||||
it("sets all six security headers on the response", () => {
|
||||
middleware(makeRequest());
|
||||
|
||||
for (const header of ALL_SIX_HEADERS) {
|
||||
expect(mock._store.has(header)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not set a nonce header", () => {
|
||||
middleware(makeRequest());
|
||||
|
||||
expect(mock._store.has("x-nonce")).toBe(false);
|
||||
});
|
||||
|
||||
it("CSP is permissive in development mode", () => {
|
||||
vi.stubEnv("NODE_ENV", "development");
|
||||
|
||||
middleware(makeRequest());
|
||||
|
||||
const csp = mock._store.get("Content-Security-Policy");
|
||||
expect(csp).toContain("'unsafe-inline'");
|
||||
});
|
||||
|
||||
it("CSP uses strict-dynamic in production mode", () => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
|
||||
middleware(makeRequest());
|
||||
|
||||
const csp = mock._store.get("Content-Security-Policy");
|
||||
expect(csp).toContain("'strict-dynamic'");
|
||||
});
|
||||
});
|
||||
16
apps/cms/src/payload.config.test.ts
Normal file
16
apps/cms/src/payload.config.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import config from "./payload.config";
|
||||
|
||||
describe("CMS app payload.config", () => {
|
||||
it("registers all feature collections", async () => {
|
||||
const resolved = await config;
|
||||
const slugs = resolved.collections?.map((c) => c.slug) ?? [];
|
||||
expect(slugs).toEqual(expect.arrayContaining(["users"]));
|
||||
});
|
||||
|
||||
it("registers no feature globals (none remain)", async () => {
|
||||
const resolved = await config;
|
||||
const slugs = resolved.globals?.map((g) => g.slug) ?? [];
|
||||
expect(slugs).toEqual([]);
|
||||
});
|
||||
});
|
||||
3
apps/cms/src/payload.config.ts
Normal file
3
apps/cms/src/payload.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Re-export Payload config from @repo/core-cms.
|
||||
// This file exists so @payload-config resolves correctly in the CMS app.
|
||||
export { default } from "@repo/core-cms";
|
||||
18
apps/cms/tsconfig.json
Normal file
18
apps/cms/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "@repo/core-typescript/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@payload-config": ["./src/payload.config.ts"]
|
||||
},
|
||||
"allowJs": true,
|
||||
"types": ["vitest/globals"]
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
1
apps/cms/tsconfig.tsbuildinfo
Normal file
1
apps/cms/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
4
apps/cms/turbo.json
Normal file
4
apps/cms/turbo.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tags": ["app"]
|
||||
}
|
||||
21
apps/cms/vitest.config.ts
Normal file
21
apps/cms/vitest.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import path from "node:path";
|
||||
import { mergeConfig } from "vitest/config";
|
||||
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
|
||||
|
||||
// Coverage excludes mirror the feature-package pattern (see
|
||||
// packages/auth/vitest.config.ts): framework glue is excluded, thresholds
|
||||
// stay inherited from the shared base — never lowered here.
|
||||
export default mergeConfig(nodeVitestConfig, {
|
||||
test: {
|
||||
coverage: {
|
||||
exclude: [
|
||||
// Payload-generated admin UI + API mount points: importMap.js is
|
||||
// stamped "GENERATED AUTOMATICALLY BY PAYLOAD"; the layout/page/
|
||||
// route files are one-line re-exports of @payloadcms/next handlers.
|
||||
// Exercised through the running CMS, not unit-testable.
|
||||
"src/app/**",
|
||||
],
|
||||
},
|
||||
},
|
||||
resolve: { alias: { "@": path.resolve(__dirname, "./src") } },
|
||||
});
|
||||
2
apps/storybook/.eslintignore
Normal file
2
apps/storybook/.eslintignore
Normal file
@@ -0,0 +1,2 @@
|
||||
storybook-static
|
||||
.storybook/storybook-static
|
||||
1
apps/storybook/.storybook/css.d.ts
vendored
Normal file
1
apps/storybook/.storybook/css.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module "*.css";
|
||||
17
apps/storybook/.storybook/main.ts
Normal file
17
apps/storybook/.storybook/main.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { StorybookConfig } from "@storybook/react-vite";
|
||||
|
||||
const config: StorybookConfig = {
|
||||
framework: "@storybook/react-vite",
|
||||
stories: ["../../../packages/core-ui/src/**/*.stories.@(ts|tsx)"],
|
||||
addons: ["@storybook/addon-essentials"],
|
||||
docs: {
|
||||
autodocs: "tag",
|
||||
},
|
||||
async viteFinal(config) {
|
||||
const tailwindPlugin = await import("@tailwindcss/vite");
|
||||
config.plugins = [tailwindPlugin.default(), ...(config.plugins || [])];
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
15
apps/storybook/.storybook/preview.ts
Normal file
15
apps/storybook/.storybook/preview.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import "./storybook.css";
|
||||
import type { Preview } from "@storybook/react";
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
4
apps/storybook/.storybook/storybook.css
Normal file
4
apps/storybook/.storybook/storybook.css
Normal file
@@ -0,0 +1,4 @@
|
||||
@import "tailwindcss";
|
||||
@source "../../../packages/core-ui/src";
|
||||
|
||||
@import "../../../packages/core-ui/src/styles/theme.css";
|
||||
137
apps/storybook/AGENTS.md
Normal file
137
apps/storybook/AGENTS.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# AGENTS.md — apps/storybook
|
||||
|
||||
Centralized Storybook instance for visual component development, documentation, and MCP integration for AI agents. Currently ships with an empty stories list — scaffold `@repo/core-ui` first to populate it.
|
||||
|
||||
## Purpose
|
||||
|
||||
Visual testing and documentation hub for the design system. When `@repo/core-ui` is scaffolded, stories live colocated with their components there. Storybook serves as the single source of truth for component usage.
|
||||
|
||||
> **core-ui is optional.** Scaffold it with `pnpm turbo gen core-package ui`, then add the stories glob and CSS import (see next-steps printed by the generator).
|
||||
|
||||
## Port: 6006
|
||||
|
||||
```bash
|
||||
pnpm dev --filter @repo/storybook # http://localhost:6006
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### `.storybook/main.ts`
|
||||
|
||||
Stories are empty by default. After scaffolding `@repo/core-ui`, add the glob:
|
||||
|
||||
```typescript
|
||||
const config: StorybookConfig = {
|
||||
framework: "@storybook/react-vite",
|
||||
stories: ["../../../packages/core-ui/src/**/*.stories.@(ts|tsx)"],
|
||||
addons: ["@storybook/addon-essentials"],
|
||||
docs: { autodocs: "tag" },
|
||||
async viteFinal(config) {
|
||||
const { mergeConfig } = await import("vite");
|
||||
const tailwindPlugin = await import("@tailwindcss/vite");
|
||||
return mergeConfig(config, {
|
||||
plugins: [tailwindPlugin.default()],
|
||||
});
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Key settings:
|
||||
|
||||
- **`stories` glob** — empty by default; add `"../../../packages/core-ui/src/**/*.stories.@(ts|tsx)"` after scaffolding core-ui
|
||||
- **`viteFinal`** — adds Tailwind v4 plugin so classes render in Storybook
|
||||
- **`autodocs: "tag"`** — auto-generates docs for tagged stories
|
||||
|
||||
### `.storybook/preview.ts`
|
||||
|
||||
After scaffolding `@repo/core-ui`, import global styles here:
|
||||
|
||||
```typescript
|
||||
import type { Preview } from "@storybook/react";
|
||||
import "@repo/core-ui/styles/globals.css";
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Story Organization
|
||||
|
||||
Stories are organized by Atomic Design level via the `title` field:
|
||||
|
||||
| Level | Title format | Sidebar path |
|
||||
| -------- | ----------------------------- | ------------------------- |
|
||||
| Atom | `"Atoms/{ComponentName}"` | Atoms > ComponentName |
|
||||
| Molecule | `"Molecules/{ComponentName}"` | Molecules > ComponentName |
|
||||
| Organism | `"Organisms/{ComponentName}"` | Organisms > ComponentName |
|
||||
| Template | `"Templates/{ComponentName}"` | Templates > ComponentName |
|
||||
|
||||
Example story file (after scaffolding core-ui at `packages/core-ui/src/atoms/button/button.stories.tsx`):
|
||||
|
||||
```typescript
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Button } from "./button";
|
||||
|
||||
const meta = {
|
||||
title: "Atoms/Button",
|
||||
component: Button,
|
||||
tags: ["autodocs"],
|
||||
} satisfies Meta<typeof Button>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: { children: "Click me" },
|
||||
};
|
||||
|
||||
export const Variant: Story = {
|
||||
args: { children: "Secondary", variant: "secondary" },
|
||||
};
|
||||
```
|
||||
|
||||
## MCP Integration
|
||||
|
||||
When Storybook runs, the MCP endpoint is available at:
|
||||
|
||||
```
|
||||
http://localhost:6006/mcp
|
||||
```
|
||||
|
||||
### Available tools:
|
||||
|
||||
- **`list-all-documentation`** — Lists all component stories and their properties
|
||||
- **`get-documentation`** — Gets detailed component info (props, variants, usage examples)
|
||||
- **`run-story-tests`** — Validates story rendering
|
||||
|
||||
### Before building new components:
|
||||
|
||||
1. Query `list-all-documentation` to check if a similar component exists
|
||||
2. Query `get-documentation` to understand existing props and variants
|
||||
3. After creating: `run-story-tests` to validate
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Purpose |
|
||||
| ----------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| `@repo/core-ui` | Component source + stories (optional — scaffold with `pnpm turbo gen core-package ui`) |
|
||||
| `@storybook/react-vite` | Storybook with Vite bundler |
|
||||
| `@storybook/addon-essentials` | Controls, Actions, Docs, Backgrounds |
|
||||
| `@tailwindcss/vite` | Vite plugin for Tailwind v4 |
|
||||
| `storybook` | Storybook CLI + dev server |
|
||||
| `tailwindcss` | Tailwind CSS v4 |
|
||||
| `vite` | Build tool |
|
||||
| `react` / `react-dom` | React 19 |
|
||||
|
||||
## Cross-References
|
||||
|
||||
- **Component source (when scaffolded):** `packages/core-ui/AGENTS.md`
|
||||
- **Scaffold core-ui:** `pnpm turbo gen core-package ui`
|
||||
- **Storybook docs:** `.storybook/` folder
|
||||
3
apps/storybook/eslint.config.js
Normal file
3
apps/storybook/eslint.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "@repo/core-eslint/base";
|
||||
|
||||
export default baseConfig;
|
||||
35
apps/storybook/package.json
Normal file
35
apps/storybook/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@repo/storybook",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "echo 'Storybook build — use pnpm dev for development'",
|
||||
"build:storybook": "storybook build",
|
||||
"build-storybook": "storybook build",
|
||||
"dev": "storybook dev -p 6006",
|
||||
"lint": "eslint .",
|
||||
"test-storybook": "test-storybook --url http://localhost:6006",
|
||||
"test:stories": "concurrently -k -s first -n 'SB,TEST' -c 'magenta,blue' 'pnpm exec http-server storybook-static --port 6006 --silent' 'pnpm exec wait-on tcp:6006 && pnpm test-storybook'"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.0",
|
||||
"@repo/core-eslint": "workspace:*",
|
||||
"@repo/core-typescript": "workspace:*",
|
||||
"@storybook/addon-essentials": "^8.6.0",
|
||||
"@storybook/react": "^8.6.0",
|
||||
"@storybook/react-vite": "^8.6.0",
|
||||
"@storybook/test-runner": "^0.19.1",
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"concurrently": "^9.0.0",
|
||||
"http-server": "^14.1.0",
|
||||
"playwright": "^1.52.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"storybook": "^8.6.0",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"vite": "^6.3.0",
|
||||
"wait-on": "^8.0.0"
|
||||
}
|
||||
}
|
||||
13
apps/storybook/test-runner.config.ts
Normal file
13
apps/storybook/test-runner.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { TestRunnerConfig } from "@storybook/test-runner";
|
||||
|
||||
const config: TestRunnerConfig = {
|
||||
async preVisit(page) {
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() === "error") {
|
||||
throw new Error(`Console error in story: ${msg.text()}`);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
51
apps/storybook/tests/visual.spec.ts
Normal file
51
apps/storybook/tests/visual.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Iterates every story registered in Storybook and takes a screenshot.
|
||||
*
|
||||
* Storybook exposes its story manifest at /index.json (Storybook 7+). For
|
||||
* each entry where `type === "story"`, we navigate to the iframe URL and
|
||||
* snapshot.
|
||||
*
|
||||
* Today the index is empty (no components in the repo). The harness still
|
||||
* runs — it just finds zero stories. The moment a story lands, the
|
||||
* baseline is captured on first run and subsequent runs diff against it.
|
||||
*/
|
||||
type StoryEntry = {
|
||||
id: string;
|
||||
title: string;
|
||||
name: string;
|
||||
type: "story" | "docs";
|
||||
};
|
||||
|
||||
async function fetchStoryIndex(baseURL: string): Promise<StoryEntry[]> {
|
||||
const res = await fetch(`${baseURL}/index.json`);
|
||||
if (!res.ok) return [];
|
||||
const json = (await res.json()) as {
|
||||
entries?: Record<string, StoryEntry>;
|
||||
};
|
||||
return Object.values(json.entries ?? {}).filter((e) => e.type === "story");
|
||||
}
|
||||
|
||||
test.describe("Storybook visual regression", () => {
|
||||
test("captures a screenshot for every registered story", async ({
|
||||
page,
|
||||
baseURL,
|
||||
}) => {
|
||||
const stories = await fetchStoryIndex(baseURL!);
|
||||
if (stories.length === 0) {
|
||||
test.skip(
|
||||
true,
|
||||
"No stories registered yet — visual regression harness is inactive until the first story lands.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const story of stories) {
|
||||
await test.step(`${story.title} — ${story.name}`, async () => {
|
||||
await page.goto(`/iframe.html?id=${story.id}&viewMode=story`);
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(page).toHaveScreenshot(`${story.id}.png`);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
16
apps/storybook/tsconfig.json
Normal file
16
apps/storybook/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "@repo/core-typescript/react-library.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
".storybook/**/*.ts",
|
||||
"*.ts",
|
||||
"*.tsx"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
4
apps/storybook/turbo.json
Normal file
4
apps/storybook/turbo.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tags": ["app"]
|
||||
}
|
||||
110
apps/web-next/AGENTS.md
Normal file
110
apps/web-next/AGENTS.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# AGENTS.md — apps/web-next
|
||||
|
||||
Next.js 15 reference application using App Router. Demonstrates consuming feature packages via tRPC and importing UI components from `@repo/core-ui`. Both `@repo/core-trpc` and `@repo/core-ui` are optional packages — scaffold them with `pnpm turbo gen core-package trpc` / `ui` if needed.
|
||||
|
||||
## Purpose
|
||||
|
||||
Thin app showcasing how features work end-to-end. Business logic lives in feature packages (`@repo/auth`, etc.); UI primitives live in `@repo/core-ui`; this app is mostly routes, layouts, and component composition.
|
||||
|
||||
## Port: 3000
|
||||
|
||||
```bash
|
||||
pnpm dev --filter @repo/web-next # http://localhost:3000
|
||||
```
|
||||
|
||||
Requires `@repo/cms` and PostgreSQL running to fetch live data:
|
||||
|
||||
```bash
|
||||
docker compose up -d postgres # PostgreSQL on port 5432
|
||||
pnpm dev --filter @repo/cms # Payload admin on port 3001
|
||||
pnpm dev --filter @repo/web-next # Next.js on port 3000
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
| ----------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| `src/app/layout.tsx` | Root layout — wraps app with `<Providers>` |
|
||||
| `src/app/providers.tsx` | Client component wrapper (add tRPC/React Query here after scaffolding `@repo/core-trpc`) |
|
||||
| `src/app/page.tsx` | Home page |
|
||||
| `e2e/` | Playwright end-to-end tests |
|
||||
|
||||
## tRPC Setup (optional)
|
||||
|
||||
`@repo/core-trpc` is not installed by default. After scaffolding with `pnpm turbo gen core-package trpc`:
|
||||
|
||||
1. Create `src/app/api/trpc/[trpc]/route.ts`:
|
||||
|
||||
```typescript
|
||||
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||
import { appRouter } from "@repo/core-api";
|
||||
import { bindAll } from "../../../../server/bind-production";
|
||||
|
||||
const handler = async (req: Request) => {
|
||||
await bindAll();
|
||||
return fetchRequestHandler({
|
||||
endpoint: "/api/trpc",
|
||||
req,
|
||||
router: appRouter,
|
||||
createContext: () => ({}),
|
||||
});
|
||||
};
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
```
|
||||
|
||||
2. Update `src/app/providers.tsx`:
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
import { NextTrpcProvider } from "@repo/core-trpc/next";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return <NextTrpcProvider trpcUrl="/api/trpc">{children}</NextTrpcProvider>;
|
||||
}
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Purpose |
|
||||
| ---------------------- | ---------------------------------------------------------- |
|
||||
| `@repo/core-api` | `appRouter` for tRPC endpoint |
|
||||
| `@repo/core-trpc/next` | Next.js tRPC client + provider (optional — scaffold first) |
|
||||
| `@repo/core-ui` | Design system components (optional — scaffold first) |
|
||||
| `@repo/auth`, etc. | Feature packages (indirectly via core-api) |
|
||||
| `next` | Next.js 15 framework |
|
||||
| `@trpc/server` | tRPC server (fetch adapter) |
|
||||
|
||||
## Test conventions
|
||||
|
||||
- Unit tests colocated: `src/app/providers.test.tsx`
|
||||
- Vitest environment: `jsdom`
|
||||
- e2e tests in `e2e/` folder: `*.spec.ts`
|
||||
- Run: `pnpm test --filter @repo/web-next` (units) or `pnpm test:e2e` (Playwright)
|
||||
|
||||
## E2E Test Setup
|
||||
|
||||
Playwright config in `e2e/playwright.config.ts`:
|
||||
|
||||
```typescript
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
webServer: {
|
||||
command: "pnpm dev",
|
||||
port: 3000,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
use: { ...devices["Desktop Chrome"].use },
|
||||
});
|
||||
```
|
||||
|
||||
Run: `pnpm test:e2e` starts the dev server and runs all `.spec.ts` files.
|
||||
|
||||
## Cross-References
|
||||
|
||||
- **Feature packages:** `packages/auth/`
|
||||
- **tRPC composition:** `packages/core-api/AGENTS.md`
|
||||
- **tRPC client + provider (optional):** scaffold `@repo/core-trpc` first, then see `turbo/generators/templates/core-package/trpc/AGENTS.md.hbs`
|
||||
- **UI components (optional):** scaffold with `pnpm turbo gen core-package ui`, then see `turbo/generators/templates/core-package/ui/AGENTS.md.hbs`
|
||||
34
apps/web-next/e2e/auth-sign-in.spec.ts
Normal file
34
apps/web-next/e2e/auth-sign-in.spec.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// Surviving e2e baseline (platform-retrofit PRD): the dev-seed shell must
|
||||
// sign in with seeded credentials (see packages/auth/src/__seeds__/dev.ts)
|
||||
// and sign back out. Runs against `pnpm dev` in dev-seed mode — no Payload.
|
||||
test.describe("auth sign-in", () => {
|
||||
test("signs in with dev-seed credentials and reaches the signed-in shell", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByLabel("Username").fill("alice");
|
||||
await page.getByLabel("Password").fill("secret_alice");
|
||||
await page.getByRole("button", { name: "Sign in" }).click();
|
||||
|
||||
await expect(page.getByText("You are signed in.")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Sign out" }).click();
|
||||
await expect(page.getByRole("button", { name: "Sign in" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("rejects invalid credentials and keeps the sign-in form", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByLabel("Username").fill("alice");
|
||||
await page.getByLabel("Password").fill("not-the-password");
|
||||
await page.getByRole("button", { name: "Sign in" }).click();
|
||||
|
||||
await expect(page.getByText("Invalid username or password.")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Sign in" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
7
apps/web-next/e2e/home.spec.ts
Normal file
7
apps/web-next/e2e/home.spec.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("home page renders heading", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
// Page renders and shows the heading
|
||||
await expect(page.locator("h1").first()).toBeVisible();
|
||||
});
|
||||
11
apps/web-next/eslint.config.js
Normal file
11
apps/web-next/eslint.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import baseConfig from "@repo/core-eslint/base";
|
||||
|
||||
export default [
|
||||
...baseConfig,
|
||||
{
|
||||
files: ["next-env.d.ts"],
|
||||
rules: {
|
||||
"@typescript-eslint/triple-slash-reference": "off",
|
||||
},
|
||||
},
|
||||
];
|
||||
19
apps/web-next/instrumentation-client.ts
Normal file
19
apps/web-next/instrumentation-client.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// apps/web-next/instrumentation-client.ts
|
||||
// Next.js 15+ browser hook: runs in the client bundle on app start.
|
||||
|
||||
import { initSentryClient } from "@repo/core-shared/instrumentation/sentry/init-client";
|
||||
|
||||
function getNonce(): string {
|
||||
if (typeof document === "undefined") return "";
|
||||
return (
|
||||
document.querySelector('meta[name="csp-nonce"]')?.getAttribute("content") ??
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
initSentryClient({
|
||||
dsn: process.env["NEXT_PUBLIC_WEB_NEXT_SENTRY_DSN"],
|
||||
app: "web-next",
|
||||
release: process.env["NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA"],
|
||||
nonce: getNonce(),
|
||||
});
|
||||
21
apps/web-next/instrumentation.ts
Normal file
21
apps/web-next/instrumentation.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// apps/web-next/instrumentation.ts
|
||||
// Next.js convention: this module runs once on server boot (before any request handler).
|
||||
// Initializes the OTel SDK here so PII scrub processors are active from the very first
|
||||
// request — before bindAll() fires. Calling initOtelServerNode here (not inside bindAll)
|
||||
// closes the startup window where @sentry/nextjs auto-instrumentation could send
|
||||
// unscrubbed errors (C1 fix).
|
||||
|
||||
export async function register() {
|
||||
if (
|
||||
process.env["NEXT_RUNTIME"] === "nodejs" ||
|
||||
process.env["NEXT_RUNTIME"] === "edge"
|
||||
) {
|
||||
const { initOtelServerNode } =
|
||||
await import("@repo/core-shared/instrumentation/otel/init-server-node");
|
||||
initOtelServerNode({
|
||||
dsn: process.env["WEB_NEXT_SENTRY_DSN"] ?? "",
|
||||
serviceName: "web-next",
|
||||
environment: process.env["NODE_ENV"] ?? "development",
|
||||
});
|
||||
}
|
||||
}
|
||||
10
apps/web-next/middleware.ts
Normal file
10
apps/web-next/middleware.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { withSecurityHeaders } from "@repo/core-shared/security/next";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
return withSecurityHeaders(request);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||
};
|
||||
6
apps/web-next/next-env.d.ts
vendored
Normal file
6
apps/web-next/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
27
apps/web-next/next.config.mjs
Normal file
27
apps/web-next/next.config.mjs
Normal file
@@ -0,0 +1,27 @@
|
||||
import { withSentryConfig } from "@sentry/nextjs";
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
transpilePackages: [
|
||||
"@repo/auth",
|
||||
"@repo/core-analytics",
|
||||
"@repo/core-api",
|
||||
"@repo/core-audit",
|
||||
"@repo/core-cms",
|
||||
"@repo/core-consent",
|
||||
"@repo/core-dsr",
|
||||
"@repo/core-shared",
|
||||
"@repo/core-ui",
|
||||
"@repo/core-trpc",
|
||||
],
|
||||
};
|
||||
|
||||
export default withSentryConfig(nextConfig, {
|
||||
// Token is build-time only; CI sets SENTRY_AUTH_TOKEN.
|
||||
silent: process.env.CI !== "true",
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||
org: process.env.SENTRY_ORG,
|
||||
project: process.env.SENTRY_PROJECT_WEB_NEXT,
|
||||
hideSourceMaps: true,
|
||||
disableLogger: true,
|
||||
});
|
||||
51
apps/web-next/package.json
Normal file
51
apps/web-next/package.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@repo/web-next",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "echo 'Next.js build requires full environment — use pnpm dev or docker'",
|
||||
"dev": "TSX_TSCONFIG_PATH=../../tsconfig.json tsx server.ts",
|
||||
"start": "node --import tsx server.ts",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:install": "playwright install --with-deps chromium",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/auth": "workspace:*",
|
||||
"@repo/core-api": "workspace:*",
|
||||
"@repo/core-cms": "workspace:*",
|
||||
"@repo/core-shared": "workspace:*",
|
||||
"@repo/core-trpc": "workspace:^",
|
||||
"@sentry/nextjs": "^10.51.0",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@tanstack/react-query": "^5.96.2",
|
||||
"@trpc/server": "^11.17.0",
|
||||
"inversify": "^6.2.0",
|
||||
"next": "^15.3.0",
|
||||
"payload": "^3.14.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"superjson": "^2.2.1",
|
||||
"tailwindcss": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.0",
|
||||
"@repo/core-eslint": "workspace:*",
|
||||
"@repo/core-testing": "workspace:*",
|
||||
"@repo/core-typescript": "workspace:*",
|
||||
"@testing-library/jest-dom": "^6.5.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@testing-library/user-event": "^14.5.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"jsdom": "^25.0.0",
|
||||
"tsx": "^4.0.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
26
apps/web-next/playwright.config.ts
Normal file
26
apps/web-next/playwright.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: "list",
|
||||
use: {
|
||||
baseURL: "http://localhost:3000",
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: "pnpm dev",
|
||||
url: "http://localhost:3000",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 60_000,
|
||||
},
|
||||
});
|
||||
5
apps/web-next/postcss.config.mjs
Normal file
5
apps/web-next/postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
23
apps/web-next/server.ts
Normal file
23
apps/web-next/server.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// apps/web-next/server.ts
|
||||
// SERVER-ONLY entry. Custom Next.js server for local development.
|
||||
// When @repo/core-realtime is scaffolded, this file is extended to boot
|
||||
// Socket.IO alongside Next (see pnpm turbo gen core-package realtime).
|
||||
import "reflect-metadata";
|
||||
import { createServer } from "node:http";
|
||||
import next from "next";
|
||||
import { bindAll } from "./src/server/bind-production.js";
|
||||
|
||||
const dev = process.env.NODE_ENV !== "production";
|
||||
const port = Number(process.env.PORT ?? 3000);
|
||||
|
||||
const app = next({ dev });
|
||||
const handle = app.getRequestHandler();
|
||||
|
||||
await app.prepare();
|
||||
|
||||
await bindAll();
|
||||
|
||||
const httpServer = createServer((req, res) => handle(req, res));
|
||||
httpServer.listen(port, () => {
|
||||
console.log(`> Ready on http://localhost:${port}`);
|
||||
});
|
||||
94
apps/web-next/src/__tests__/middleware.test.ts
Normal file
94
apps/web-next/src/__tests__/middleware.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const responseMock = vi.hoisted(() => {
|
||||
function makeResponseMock() {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
_store: store,
|
||||
headers: {
|
||||
set: vi.fn((k: string, v: string) => store.set(k, v)),
|
||||
get: vi.fn((k: string) => store.get(k) ?? null),
|
||||
},
|
||||
};
|
||||
}
|
||||
return { makeResponseMock };
|
||||
});
|
||||
|
||||
vi.mock("next/server", () => ({
|
||||
NextResponse: {
|
||||
next: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { middleware } from "../../middleware";
|
||||
|
||||
const ALL_SIX_HEADERS = [
|
||||
"Strict-Transport-Security",
|
||||
"X-Frame-Options",
|
||||
"X-Content-Type-Options",
|
||||
"Referrer-Policy",
|
||||
"Permissions-Policy",
|
||||
"Content-Security-Policy",
|
||||
] as const;
|
||||
|
||||
function makeRequest(): NextRequest {
|
||||
return { headers: new Headers() } as unknown as NextRequest;
|
||||
}
|
||||
|
||||
describe("web-next middleware", () => {
|
||||
let mock: ReturnType<typeof responseMock.makeResponseMock>;
|
||||
|
||||
beforeEach(() => {
|
||||
mock = responseMock.makeResponseMock();
|
||||
vi.mocked(NextResponse.next).mockReturnValue(
|
||||
mock as unknown as ReturnType<typeof NextResponse.next>,
|
||||
);
|
||||
});
|
||||
|
||||
it("sets all six security headers on the response", () => {
|
||||
middleware(makeRequest());
|
||||
|
||||
for (const header of ALL_SIX_HEADERS) {
|
||||
expect(mock._store.has(header)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("sets x-nonce header on the response", () => {
|
||||
middleware(makeRequest());
|
||||
|
||||
const nonce = mock._store.get("x-nonce");
|
||||
expect(nonce).toBeDefined();
|
||||
expect(typeof nonce).toBe("string");
|
||||
expect((nonce as string).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("CSP contains nonce in production mode", () => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
|
||||
middleware(makeRequest());
|
||||
|
||||
const nonce = mock._store.get("x-nonce");
|
||||
const csp = mock._store.get("Content-Security-Policy");
|
||||
expect(csp).toContain(`'nonce-${nonce}'`);
|
||||
});
|
||||
|
||||
it("CSP is permissive (unsafe-inline) in development mode", () => {
|
||||
vi.stubEnv("NODE_ENV", "development");
|
||||
|
||||
middleware(makeRequest());
|
||||
|
||||
const csp = mock._store.get("Content-Security-Policy");
|
||||
expect(csp).toContain("'unsafe-inline'");
|
||||
});
|
||||
|
||||
it("x-nonce is forwarded in request headers passed to NextResponse.next", () => {
|
||||
middleware(makeRequest());
|
||||
|
||||
const call = vi.mocked(NextResponse.next).mock.calls[0] as [
|
||||
{ request?: { headers?: Headers } } | undefined,
|
||||
];
|
||||
expect(call[0]?.request?.headers?.get("x-nonce")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
13
apps/web-next/src/app/api/trpc/[trpc]/route.ts
Normal file
13
apps/web-next/src/app/api/trpc/[trpc]/route.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||
import { appRouter } from "@repo/core-api";
|
||||
|
||||
const handler = async (req: Request) => {
|
||||
return fetchRequestHandler({
|
||||
endpoint: "/api/trpc",
|
||||
req,
|
||||
router: appRouter,
|
||||
createContext: () => ({}),
|
||||
});
|
||||
};
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
31
apps/web-next/src/app/layout.tsx
Normal file
31
apps/web-next/src/app/layout.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Metadata } from "next";
|
||||
import "../styles/app.css";
|
||||
import { getNonce } from "@repo/core-shared/security/next";
|
||||
import { bindAll } from "../server/bind-production";
|
||||
import { Providers } from "./providers";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Template",
|
||||
description: "Template",
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
await bindAll();
|
||||
const nonce = await getNonce();
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
{/* nonce exposed to client so instrumentation-client.ts can read it */}
|
||||
<meta name="csp-nonce" content={nonce} />
|
||||
</head>
|
||||
<body>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
90
apps/web-next/src/app/page.tsx
Normal file
90
apps/web-next/src/app/page.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { SESSION_COOKIE } from "@repo/auth";
|
||||
import { bindAll } from "../server/bind-production";
|
||||
import { signInAction, signOutAction } from "../server/auth-actions";
|
||||
|
||||
/**
|
||||
* Shell home: the sign-in surface plus a minimal authenticated placeholder.
|
||||
* The workspaces UI that replaces the placeholder arrives with the
|
||||
* walking-skeleton PRD. Signed-in state is cookie presence only — session
|
||||
* validation stays inside the auth feature and is exercised on sign-out.
|
||||
*/
|
||||
export default async function Home({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ error?: string }>;
|
||||
}) {
|
||||
await bindAll();
|
||||
const [cookieStore, params] = await Promise.all([cookies(), searchParams]);
|
||||
const signedIn = cookieStore.has(SESSION_COOKIE);
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-sm flex-col justify-center gap-6 px-6 py-8">
|
||||
<h1 className="text-2xl font-bold text-foreground">Template</h1>
|
||||
|
||||
{signedIn ? (
|
||||
<section aria-label="Signed in" className="flex flex-col gap-4">
|
||||
<p className="text-foreground">You are signed in.</p>
|
||||
<form action={signOutAction}>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
) : (
|
||||
<form
|
||||
action={signInAction}
|
||||
aria-label="Sign in"
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="text-sm font-medium text-foreground"
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
required
|
||||
className="rounded-md border border-input bg-background px-3 py-2 text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="text-sm font-medium text-foreground"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="rounded-md border border-input bg-background px-3 py-2 text-foreground"
|
||||
/>
|
||||
</div>
|
||||
{params.error === "invalid-credentials" ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Invalid username or password.
|
||||
</p>
|
||||
) : null}
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
14
apps/web-next/src/app/providers.test.tsx
Normal file
14
apps/web-next/src/app/providers.test.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { Providers } from "./providers";
|
||||
|
||||
describe("Providers", () => {
|
||||
it("renders children", () => {
|
||||
render(
|
||||
<Providers>
|
||||
<div data-testid="child">hi</div>
|
||||
</Providers>,
|
||||
);
|
||||
expect(screen.getByTestId("child")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
7
apps/web-next/src/app/providers.tsx
Normal file
7
apps/web-next/src/app/providers.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { NextTrpcProvider } from "@repo/core-trpc/next";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return <NextTrpcProvider>{children}</NextTrpcProvider>;
|
||||
}
|
||||
1
apps/web-next/src/css.d.ts
vendored
Normal file
1
apps/web-next/src/css.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module "*.css";
|
||||
161
apps/web-next/src/server/auth-actions.test.ts
Normal file
161
apps/web-next/src/server/auth-actions.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// redirect() in Next.js throws a control-flow error; the mock mirrors that so
|
||||
// action code after redirect() is provably unreachable in tests too.
|
||||
const redirectSentinel = vi.hoisted(() => {
|
||||
class RedirectError extends Error {
|
||||
constructor(public readonly url: string) {
|
||||
super(`NEXT_REDIRECT:${url}`);
|
||||
}
|
||||
}
|
||||
return { RedirectError };
|
||||
});
|
||||
|
||||
const cookieMocks = vi.hoisted(() => {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
store,
|
||||
set: vi.fn(
|
||||
(name: string, value: string, _attributes?: Record<string, unknown>) => {
|
||||
store.set(name, value);
|
||||
},
|
||||
),
|
||||
get: vi.fn((name: string) => {
|
||||
const value = store.get(name);
|
||||
return value === undefined ? undefined : { name, value };
|
||||
}),
|
||||
delete: vi.fn((name: string) => {
|
||||
store.delete(name);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const callerMocks = vi.hoisted(() => ({
|
||||
signIn: vi.fn(),
|
||||
signOut: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
cookies: vi.fn(async () => cookieMocks),
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn((url: string): never => {
|
||||
throw new redirectSentinel.RedirectError(url);
|
||||
}),
|
||||
}));
|
||||
vi.mock("./bind-production", () => ({ bindAll: vi.fn(async () => {}) }));
|
||||
vi.mock("@repo/core-api", () => ({
|
||||
appRouter: {
|
||||
createCaller: vi.fn(() => ({
|
||||
auth: { signIn: callerMocks.signIn, signOut: callerMocks.signOut },
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
import { signInAction, signOutAction } from "./auth-actions";
|
||||
import { bindAll } from "./bind-production";
|
||||
|
||||
const { RedirectError } = redirectSentinel;
|
||||
|
||||
function signInForm(username: string, password: string): FormData {
|
||||
const form = new FormData();
|
||||
form.set("username", username);
|
||||
form.set("password", password);
|
||||
return form;
|
||||
}
|
||||
|
||||
async function redirectTargetOf(action: Promise<void>): Promise<string> {
|
||||
try {
|
||||
await action;
|
||||
} catch (err) {
|
||||
if (err instanceof RedirectError) return err.url;
|
||||
throw err;
|
||||
}
|
||||
throw new Error("expected the action to redirect");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
cookieMocks.store.clear();
|
||||
});
|
||||
|
||||
describe("signInAction", () => {
|
||||
it("binds, signs in through the app router, sets the returned cookie, and redirects home", async () => {
|
||||
callerMocks.signIn.mockResolvedValue({
|
||||
name: "session",
|
||||
value: "session_alice",
|
||||
attributes: { httpOnly: true },
|
||||
});
|
||||
|
||||
const target = await redirectTargetOf(
|
||||
signInAction(signInForm("alice", "secret_alice")),
|
||||
);
|
||||
|
||||
expect(target).toBe("/");
|
||||
expect(bindAll).toHaveBeenCalled();
|
||||
expect(callerMocks.signIn).toHaveBeenCalledExactlyOnceWith({
|
||||
username: "alice",
|
||||
password: "secret_alice",
|
||||
});
|
||||
expect(cookieMocks.set).toHaveBeenCalledExactlyOnceWith(
|
||||
"session",
|
||||
"session_alice",
|
||||
{ httpOnly: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("redirects to the error state without setting a cookie when sign-in fails", async () => {
|
||||
callerMocks.signIn.mockRejectedValue(new Error("UNAUTHORIZED"));
|
||||
|
||||
const target = await redirectTargetOf(
|
||||
signInAction(signInForm("alice", "wrong-password")),
|
||||
);
|
||||
|
||||
expect(target).toBe("/?error=invalid-credentials");
|
||||
expect(cookieMocks.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("submits missing form fields as empty strings (rejected by the input schema server-side)", async () => {
|
||||
callerMocks.signIn.mockRejectedValue(new Error("BAD_REQUEST"));
|
||||
|
||||
const target = await redirectTargetOf(signInAction(new FormData()));
|
||||
|
||||
expect(target).toBe("/?error=invalid-credentials");
|
||||
expect(callerMocks.signIn).toHaveBeenCalledExactlyOnceWith({
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("signOutAction", () => {
|
||||
it("invalidates the session from the cookie, clears it, and redirects home", async () => {
|
||||
cookieMocks.store.set("session", "session_alice");
|
||||
callerMocks.signOut.mockResolvedValue(undefined);
|
||||
|
||||
const target = await redirectTargetOf(signOutAction());
|
||||
|
||||
expect(target).toBe("/");
|
||||
expect(callerMocks.signOut).toHaveBeenCalledExactlyOnceWith({
|
||||
sessionId: "session_alice",
|
||||
});
|
||||
expect(cookieMocks.delete).toHaveBeenCalledExactlyOnceWith("session");
|
||||
});
|
||||
|
||||
it("still clears a stale cookie when the server-side session is already gone", async () => {
|
||||
cookieMocks.store.set("session", "session_stale");
|
||||
callerMocks.signOut.mockRejectedValue(new Error("UNAUTHORIZED"));
|
||||
|
||||
const target = await redirectTargetOf(signOutAction());
|
||||
|
||||
expect(target).toBe("/");
|
||||
expect(cookieMocks.delete).toHaveBeenCalledExactlyOnceWith("session");
|
||||
});
|
||||
|
||||
it("is a no-op redirect when no session cookie is present", async () => {
|
||||
const target = await redirectTargetOf(signOutAction());
|
||||
|
||||
expect(target).toBe("/");
|
||||
expect(callerMocks.signOut).not.toHaveBeenCalled();
|
||||
expect(cookieMocks.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
64
apps/web-next/src/server/auth-actions.ts
Normal file
64
apps/web-next/src/server/auth-actions.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
// apps/web-next/src/server/auth-actions.ts
|
||||
// SERVER-ONLY: Next.js server actions for the auth shell. Both actions go
|
||||
// through the composed tRPC appRouter (createCaller — the sanctioned server
|
||||
// entry point) so the auth feature's controllers, error mapping, and
|
||||
// conformance wrappers all run. The app owns the session cookie: the sign-in
|
||||
// controller *returns* the cookie and the action writes it via next/headers,
|
||||
// which keeps httpOnly-capable attributes server-side.
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { appRouter } from "@repo/core-api";
|
||||
import { SESSION_COOKIE } from "@repo/auth";
|
||||
import { bindAll } from "./bind-production";
|
||||
|
||||
/**
|
||||
* Sign in with username + password from the shell home form.
|
||||
*
|
||||
* Success: sets the session cookie and redirects to `/`.
|
||||
* Failure (bad input or wrong credentials): redirects to
|
||||
* `/?error=invalid-credentials` — the shell home renders the error inline.
|
||||
*/
|
||||
export async function signInAction(formData: FormData): Promise<void> {
|
||||
await bindAll();
|
||||
const caller = appRouter.createCaller({});
|
||||
|
||||
let cookie;
|
||||
try {
|
||||
cookie = await caller.auth.signIn({
|
||||
username: String(formData.get("username") ?? ""),
|
||||
password: String(formData.get("password") ?? ""),
|
||||
});
|
||||
} catch {
|
||||
// BAD_REQUEST (input) and UNAUTHORIZED (credentials) collapse into one
|
||||
// user-facing error on the placeholder shell.
|
||||
redirect("/?error=invalid-credentials");
|
||||
}
|
||||
|
||||
const store = await cookies();
|
||||
store.set(cookie.name, cookie.value, cookie.attributes);
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign out from the shell home. Invalidates the server-side session, then
|
||||
* clears the cookie even when the session is already gone (e.g. the dev
|
||||
* server restarted and the in-memory session store was lost).
|
||||
*/
|
||||
export async function signOutAction(): Promise<void> {
|
||||
await bindAll();
|
||||
const store = await cookies();
|
||||
const sessionId = store.get(SESSION_COOKIE)?.value;
|
||||
|
||||
if (sessionId) {
|
||||
try {
|
||||
await appRouter.createCaller({}).auth.signOut({ sessionId });
|
||||
} catch {
|
||||
// Stale session id — still clear the cookie below.
|
||||
}
|
||||
store.delete(SESSION_COOKIE);
|
||||
}
|
||||
|
||||
redirect("/");
|
||||
}
|
||||
99
apps/web-next/src/server/bind-production.smoke.test.ts
Normal file
99
apps/web-next/src/server/bind-production.smoke.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
// Dev-seed boot smoke (platform-retrofit story 04): prove `bindAll()` boots
|
||||
// the app with exactly the auth feature bound — no dangling DI symbols from
|
||||
// the deleted demo features.
|
||||
//
|
||||
// Unlike bind-production.test.ts (which mocks the per-feature binders to test
|
||||
// dispatcher routing), this file runs the REAL auth dev-seed binder, so the
|
||||
// `assertFeatureConformance` call at its tail executes — the same boot
|
||||
// assertion `pnpm dev` runs. The bind-dev-seed docstring's "tests must not
|
||||
// call this" rule targets auth's own unit tests; this app-level smoke exists
|
||||
// precisely to exercise the real boot path.
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Production-path imports only — the dev-seed path never touches Payload, but
|
||||
// bind-production.ts imports these at module top for bindAllProduction.
|
||||
vi.mock("@repo/core-cms", () => ({ default: Promise.resolve({}) }));
|
||||
vi.mock("payload", () => ({
|
||||
getPayload: vi.fn(async () => ({ jobs: { queue: vi.fn() } })),
|
||||
}));
|
||||
|
||||
describe("dev-seed boot smoke — bindAll() binds exactly the auth feature", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllEnvs();
|
||||
vi.stubEnv("USE_DEV_SEED", "true");
|
||||
vi.stubEnv("WEB_NEXT_SENTRY_DSN", "");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("boots through the real auth dev-seed binder, passing its boot conformance assertion", async () => {
|
||||
const { bindAll } = await import("./bind-production");
|
||||
|
||||
await expect(bindAll()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves every auth manifest use case and controller after boot", async () => {
|
||||
const { bindAll } = await import("./bind-production");
|
||||
await bindAll();
|
||||
|
||||
const { authContainer } = await import("@repo/auth/di/container");
|
||||
const { AUTH_SYMBOLS } = await import("@repo/auth/di/symbols");
|
||||
const { authManifest } = await import("@repo/auth");
|
||||
|
||||
const useCases = Object.keys(authManifest.useCases);
|
||||
expect(useCases.sort()).toEqual(["signIn", "signOut", "signUp"]);
|
||||
|
||||
for (const useCase of useCases) {
|
||||
const cap = useCase[0]!.toUpperCase() + useCase.slice(1);
|
||||
for (const suffix of ["UseCase", "Controller"] as const) {
|
||||
const symbol =
|
||||
AUTH_SYMBOLS[`I${cap}${suffix}` as keyof typeof AUTH_SYMBOLS];
|
||||
expect(symbol, `AUTH_SYMBOLS.I${cap}${suffix} exists`).toBeDefined();
|
||||
expect(
|
||||
typeof authContainer.get(symbol),
|
||||
`authContainer resolves I${cap}${suffix}`,
|
||||
).toBe("function");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("signs in a dev-seed user through the bound controller (server-side smoke)", async () => {
|
||||
const { bindAll } = await import("./bind-production");
|
||||
await bindAll();
|
||||
|
||||
const { authContainer } = await import("@repo/auth/di/container");
|
||||
const { AUTH_SYMBOLS } = await import("@repo/auth/di/symbols");
|
||||
const { SESSION_COOKIE } = await import("@repo/auth");
|
||||
|
||||
const signIn = authContainer.get<import("@repo/auth").ISignInController>(
|
||||
AUTH_SYMBOLS.ISignInController,
|
||||
);
|
||||
const cookie = await signIn({
|
||||
username: "alice",
|
||||
password: "secret_alice",
|
||||
});
|
||||
|
||||
expect(cookie.name).toBe(SESSION_COOKIE);
|
||||
expect(cookie.value).not.toBe("");
|
||||
});
|
||||
|
||||
it("wires auth and nothing else — the dispatcher imports exactly one feature's binders", () => {
|
||||
// vitest runs with cwd at the package root (jsdom rewrites
|
||||
// import.meta.url to an http: URL, so resolve from cwd instead).
|
||||
const source = readFileSync(
|
||||
path.resolve(process.cwd(), "src/server/bind-production.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const features = [...source.matchAll(/@repo\/([\w-]+)\/di\/bind-/g)].map(
|
||||
(match) => match[1],
|
||||
);
|
||||
|
||||
expect(features.length).toBeGreaterThan(0);
|
||||
expect([...new Set(features)].sort()).toEqual(["auth"]);
|
||||
});
|
||||
});
|
||||
218
apps/web-next/src/server/bind-production.test.ts
Normal file
218
apps/web-next/src/server/bind-production.test.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("@repo/core-cms", () => ({ default: Promise.resolve({}) }));
|
||||
vi.mock("payload", () => ({
|
||||
getPayload: vi.fn(async () => ({ jobs: { queue: vi.fn() } })),
|
||||
}));
|
||||
vi.mock("@repo/auth/di/bind-production", () => ({
|
||||
bindProductionAuth: vi.fn(),
|
||||
}));
|
||||
vi.mock("@repo/auth/di/bind-dev-seed", () => ({ bindDevSeedAuth: vi.fn() }));
|
||||
vi.mock("@repo/core-shared/instrumentation", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@repo/core-shared/instrumentation")>();
|
||||
const mockedOtel = vi.fn(actual.bindOtelInstrumentation);
|
||||
return {
|
||||
...actual,
|
||||
bindOtelInstrumentation: mockedOtel,
|
||||
// Deprecated alias — points to same spy so existing assertions still work.
|
||||
bindSentryInstrumentation: mockedOtel,
|
||||
bindNoopInstrumentation: vi.fn(actual.bindNoopInstrumentation),
|
||||
};
|
||||
});
|
||||
|
||||
describe("bindAllProduction", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("binds the auth feature production repos", async () => {
|
||||
const { bindAllProduction } = await import("./bind-production");
|
||||
const { bindProductionAuth } =
|
||||
await import("@repo/auth/di/bind-production");
|
||||
|
||||
await bindAllProduction();
|
||||
|
||||
expect(bindProductionAuth).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("is idempotent via bindAll — second call does not re-bind", async () => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
const { bindAll } = await import("./bind-production");
|
||||
const { bindProductionAuth } =
|
||||
await import("@repo/auth/di/bind-production");
|
||||
await bindAll();
|
||||
await bindAll();
|
||||
expect(bindProductionAuth).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("passes a Payload-backed queue to each per-feature binder", async () => {
|
||||
const { bindAllProduction } = await import("./bind-production");
|
||||
const { bindProductionAuth } =
|
||||
await import("@repo/auth/di/bind-production");
|
||||
const { PayloadJobQueue } = await import("@repo/core-shared/jobs");
|
||||
|
||||
await bindAllProduction();
|
||||
|
||||
const ctx = vi.mocked(bindProductionAuth).mock.calls[0]![0];
|
||||
expect(ctx.bus).toBeUndefined();
|
||||
expect(ctx.queue).toBeInstanceOf(PayloadJobQueue);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bindAllDevSeed", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("passes an in-memory queue (no bus) to each per-feature dev-seed binder", async () => {
|
||||
const { bindAllDevSeed } = await import("./bind-production");
|
||||
const { bindDevSeedAuth } = await import("@repo/auth/di/bind-dev-seed");
|
||||
const { InMemoryJobQueue } = await import("@repo/core-shared/jobs");
|
||||
|
||||
await bindAllDevSeed();
|
||||
|
||||
const ctx = vi.mocked(bindDevSeedAuth).mock.calls[0]![0];
|
||||
expect(ctx.bus).toBeUndefined();
|
||||
expect(ctx.queue).toBeInstanceOf(InMemoryJobQueue);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bindAll dispatcher", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("USE_DEV_SEED='true' wins → dispatches to bindAllDevSeed", async () => {
|
||||
vi.stubEnv("USE_DEV_SEED", "true");
|
||||
vi.stubEnv("NODE_ENV", "production"); // even in production, dev seed wins
|
||||
const { bindAll } = await import("./bind-production");
|
||||
const { bindDevSeedAuth } = await import("@repo/auth/di/bind-dev-seed");
|
||||
const { bindProductionAuth } =
|
||||
await import("@repo/auth/di/bind-production");
|
||||
|
||||
await bindAll();
|
||||
|
||||
expect(bindDevSeedAuth).toHaveBeenCalledOnce();
|
||||
expect(bindProductionAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("NODE_ENV='production' (no override) → dispatches to bindAllProduction", async () => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
const { bindAll } = await import("./bind-production");
|
||||
const { bindProductionAuth } =
|
||||
await import("@repo/auth/di/bind-production");
|
||||
const { bindDevSeedAuth } = await import("@repo/auth/di/bind-dev-seed");
|
||||
|
||||
await bindAll();
|
||||
|
||||
expect(bindProductionAuth).toHaveBeenCalledOnce();
|
||||
expect(bindDevSeedAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("NODE_ENV='development' → dispatches to bindAllDevSeed (developer default)", async () => {
|
||||
vi.stubEnv("NODE_ENV", "development");
|
||||
const { bindAll } = await import("./bind-production");
|
||||
const { bindDevSeedAuth } = await import("@repo/auth/di/bind-dev-seed");
|
||||
const { bindProductionAuth } =
|
||||
await import("@repo/auth/di/bind-production");
|
||||
|
||||
await bindAll();
|
||||
|
||||
expect(bindDevSeedAuth).toHaveBeenCalledOnce();
|
||||
expect(bindProductionAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("USE_DEV_SEED='false' is treated as not-set (only 'true' triggers dev seed)", async () => {
|
||||
vi.stubEnv("USE_DEV_SEED", "false");
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
const { bindAll } = await import("./bind-production");
|
||||
const { bindProductionAuth } =
|
||||
await import("@repo/auth/di/bind-production");
|
||||
const { bindDevSeedAuth } = await import("@repo/auth/di/bind-dev-seed");
|
||||
|
||||
await bindAll();
|
||||
|
||||
expect(bindProductionAuth).toHaveBeenCalledOnce();
|
||||
expect(bindDevSeedAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("bindAll instrumentation orthogonality", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
// In the mock setup above, bindSentryInstrumentation is an alias that points
|
||||
// to the same spy as bindOtelInstrumentation. Assertions against either name
|
||||
// verify the same call, which also validates the deprecation alias is wired.
|
||||
|
||||
it("DSN absent → bindNoopInstrumentation regardless of NODE_ENV", async () => {
|
||||
vi.stubEnv("WEB_NEXT_SENTRY_DSN", "");
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
const { bindAll } = await import("./bind-production");
|
||||
const { bindNoopInstrumentation, bindOtelInstrumentation } =
|
||||
await import("@repo/core-shared/instrumentation");
|
||||
|
||||
await bindAll();
|
||||
|
||||
expect(bindNoopInstrumentation).toHaveBeenCalledOnce();
|
||||
expect(bindOtelInstrumentation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("DSN set → bindOtelInstrumentation regardless of NODE_ENV", async () => {
|
||||
vi.stubEnv("WEB_NEXT_SENTRY_DSN", "https://x@y/1");
|
||||
vi.stubEnv("NODE_ENV", "development");
|
||||
const { bindAll } = await import("./bind-production");
|
||||
const { bindNoopInstrumentation, bindOtelInstrumentation } =
|
||||
await import("@repo/core-shared/instrumentation");
|
||||
|
||||
await bindAll();
|
||||
|
||||
expect(bindOtelInstrumentation).toHaveBeenCalledOnce();
|
||||
expect(bindNoopInstrumentation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("OTel instrumentation works alongside dev seed (USE_DEV_SEED=true)", async () => {
|
||||
vi.stubEnv("USE_DEV_SEED", "true");
|
||||
vi.stubEnv("WEB_NEXT_SENTRY_DSN", "https://x@y/1");
|
||||
const { bindAll } = await import("./bind-production");
|
||||
const { bindOtelInstrumentation } =
|
||||
await import("@repo/core-shared/instrumentation");
|
||||
const { bindDevSeedAuth } = await import("@repo/auth/di/bind-dev-seed");
|
||||
|
||||
await bindAll();
|
||||
|
||||
expect(bindOtelInstrumentation).toHaveBeenCalledOnce();
|
||||
expect(bindDevSeedAuth).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("Noop instrumentation works alongside production binding (DSN unset, NODE_ENV=production)", async () => {
|
||||
vi.stubEnv("WEB_NEXT_SENTRY_DSN", "");
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
const { bindAll } = await import("./bind-production");
|
||||
const { bindNoopInstrumentation } =
|
||||
await import("@repo/core-shared/instrumentation");
|
||||
const { bindProductionAuth } =
|
||||
await import("@repo/auth/di/bind-production");
|
||||
|
||||
await bindAll();
|
||||
|
||||
expect(bindNoopInstrumentation).toHaveBeenCalledOnce();
|
||||
expect(bindProductionAuth).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
147
apps/web-next/src/server/bind-production.ts
Normal file
147
apps/web-next/src/server/bind-production.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
// apps/web-next/src/server/bind-production.ts
|
||||
// SERVER-ONLY: this module imports Payload config and must never be bundled into the browser.
|
||||
import "reflect-metadata";
|
||||
import { Container } from "inversify";
|
||||
import { getPayload } from "payload";
|
||||
import config from "@repo/core-cms";
|
||||
import {
|
||||
bindNoopInstrumentation,
|
||||
bindOtelInstrumentation,
|
||||
type ITracer,
|
||||
type ILogger,
|
||||
} from "@repo/core-shared/instrumentation";
|
||||
import type { BindProductionContext, BindContext } from "@repo/core-shared/di";
|
||||
import {
|
||||
InMemoryJobQueue,
|
||||
PayloadJobQueue,
|
||||
type IJobQueue,
|
||||
} from "@repo/core-shared/jobs";
|
||||
import { NoopRateLimit } from "@repo/core-shared/rate-limit";
|
||||
import { bindProductionAuth } from "@repo/auth/di/bind-production";
|
||||
import { bindDevSeedAuth } from "@repo/auth/di/bind-dev-seed";
|
||||
|
||||
let bindPromise: Promise<void> | null = null;
|
||||
|
||||
// Shared container holds TRACER + LOGGER bindings; per-feature containers
|
||||
// receive references via parameter passing. This separates the instrumentation
|
||||
// container (one) from feature containers (per-feature, ADR-008).
|
||||
const sharedContainer = new Container();
|
||||
|
||||
let resolvedTracer: ITracer | null = null;
|
||||
let resolvedLogger: ILogger | null = null;
|
||||
let resolvedQueue: IJobQueue | null = null;
|
||||
|
||||
/** Rule 0: pick instrumentation backend from DSN env (orthogonal to repo mode). */
|
||||
function resolveInstrumentation(): { tracer: ITracer; logger: ILogger } {
|
||||
if (resolvedTracer && resolvedLogger) {
|
||||
return { tracer: resolvedTracer, logger: resolvedLogger };
|
||||
}
|
||||
const dsn = process.env.WEB_NEXT_SENTRY_DSN;
|
||||
const result = dsn
|
||||
? bindOtelInstrumentation(sharedContainer, { dsn, app: "web-next" })
|
||||
: bindNoopInstrumentation(sharedContainer);
|
||||
resolvedTracer = result.tracer;
|
||||
resolvedLogger = result.logger;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Production-mode job queue: backed by Payload's job system so ad-hoc jobs go
|
||||
* through `PayloadJobQueue.enqueue`. Cached after first resolution.
|
||||
*
|
||||
* Note: @repo/core-events (IEventBus) is optional — scaffold via
|
||||
* `pnpm turbo gen core-package events` to re-enable cross-feature event fanout.
|
||||
*/
|
||||
async function resolveJobsProduction(): Promise<{ queue: IJobQueue }> {
|
||||
if (resolvedQueue) return { queue: resolvedQueue };
|
||||
const resolvedConfig = await config;
|
||||
const payload = await getPayload({ config: resolvedConfig });
|
||||
const queue = new PayloadJobQueue(payload);
|
||||
resolvedQueue = queue;
|
||||
return { queue };
|
||||
}
|
||||
|
||||
/**
|
||||
* Dev-seed mode: in-process job queue. Per-feature binders register their job
|
||||
* handlers via `queue.register(slug, handler)` at bind time so dev/test
|
||||
* exercises the enqueue path without booting Payload.
|
||||
*/
|
||||
function resolveJobsDevSeed(): { queue: IJobQueue } {
|
||||
if (resolvedQueue) return { queue: resolvedQueue };
|
||||
const queue = new InMemoryJobQueue();
|
||||
resolvedQueue = queue;
|
||||
return { queue };
|
||||
}
|
||||
|
||||
/**
|
||||
* Production path: swap each feature's mock repository binding for the real
|
||||
* Payload-backed one. Constructs `new XRepository(config, tracer, logger)` per
|
||||
* feature via `bindProductionX` exports.
|
||||
*/
|
||||
export async function bindAllProduction(): Promise<void> {
|
||||
const { tracer, logger } = resolveInstrumentation(); // Rule 0
|
||||
const { queue } = await resolveJobsProduction();
|
||||
const resolvedConfig = await config;
|
||||
|
||||
const ctx: BindProductionContext = {
|
||||
config: resolvedConfig,
|
||||
tracer,
|
||||
logger,
|
||||
queue,
|
||||
rateLimit: new NoopRateLimit(),
|
||||
};
|
||||
|
||||
bindProductionAuth(ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dev-seed path: keep each feature's MockXRepository in place but populate it
|
||||
* with realistic seed data so the running app shows non-empty UI without
|
||||
* Payload booted. Mutually exclusive with `bindAllProduction()`.
|
||||
*/
|
||||
export async function bindAllDevSeed(): Promise<void> {
|
||||
const { tracer, logger } = resolveInstrumentation(); // Rule 0
|
||||
const { queue } = resolveJobsDevSeed();
|
||||
|
||||
const ctx: BindContext = {
|
||||
tracer,
|
||||
logger,
|
||||
queue,
|
||||
rateLimit: new NoopRateLimit(),
|
||||
};
|
||||
|
||||
await bindDevSeedAuth(ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot dispatcher: pick the binder based on the environment.
|
||||
*
|
||||
* Resolution order (first match wins):
|
||||
*
|
||||
* Rule 0 (always): instrumentation (Noop vs Sentry) from WEB_NEXT_SENTRY_DSN
|
||||
* presence — runs inside both bindAllProduction and
|
||||
* bindAllDevSeed via resolveInstrumentation().
|
||||
* Rule 1: USE_DEV_SEED === "true" → dev seed (explicit override)
|
||||
* Rule 2: NODE_ENV === "production" → real Payload via bindAllProduction
|
||||
* Rule 3: otherwise → dev seed (developer-friendly default)
|
||||
*
|
||||
* When @repo/core-events is scaffolded via `pnpm turbo gen core-package events`,
|
||||
* extend to construct IEventBus and pass it via ctx.bus to per-feature binders.
|
||||
* When @repo/core-realtime is scaffolded, extend to accept realtime deps
|
||||
* (IRealtimeBroadcaster, IRealtimeHandlerRegistry) and pass them through.
|
||||
*/
|
||||
export function bindAll(): Promise<void> {
|
||||
if (bindPromise) return bindPromise;
|
||||
|
||||
if (process.env.USE_DEV_SEED === "false") {
|
||||
bindPromise = bindAllProduction();
|
||||
} else if (process.env.USE_DEV_SEED === "true") {
|
||||
bindPromise = bindAllDevSeed();
|
||||
} else if (process.env.NODE_ENV === "production") {
|
||||
bindPromise = bindAllProduction();
|
||||
} else {
|
||||
bindPromise = bindAllDevSeed();
|
||||
}
|
||||
|
||||
return bindPromise;
|
||||
}
|
||||
6
apps/web-next/src/styles/app.css
Normal file
6
apps/web-next/src/styles/app.css
Normal file
@@ -0,0 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@source "../../../../packages/core-ui/src";
|
||||
@source "../../../../packages/auth/src";
|
||||
@source "../";
|
||||
|
||||
@import "../../../../packages/core-ui/src/styles/theme.css";
|
||||
4
apps/web-next/test-results/.last-run.json
Normal file
4
apps/web-next/test-results/.last-run.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
17
apps/web-next/tsconfig.json
Normal file
17
apps/web-next/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "@repo/core-typescript/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"allowJs": true,
|
||||
"types": ["vitest/globals", "@testing-library/jest-dom"]
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
1
apps/web-next/tsconfig.tsbuildinfo
Normal file
1
apps/web-next/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
4
apps/web-next/turbo.json
Normal file
4
apps/web-next/turbo.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tags": ["app"]
|
||||
}
|
||||
26
apps/web-next/vitest.config.ts
Normal file
26
apps/web-next/vitest.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import path from "node:path";
|
||||
import { mergeConfig } from "vitest/config";
|
||||
import { jsdomVitestConfig } from "@repo/core-typescript/vitest.base.jsdom";
|
||||
|
||||
// Coverage excludes mirror the feature-package pattern (see
|
||||
// packages/auth/vitest.config.ts): framework glue is excluded, thresholds
|
||||
// stay inherited from the shared base — never lowered here.
|
||||
export default mergeConfig(jsdomVitestConfig, {
|
||||
esbuild: { jsx: "automatic" },
|
||||
test: {
|
||||
coverage: {
|
||||
exclude: [
|
||||
// Next.js App Router entry points — async server components and the
|
||||
// tRPC route-handler mount, invoked by the framework and covered by
|
||||
// Playwright e2e; not unit-testable in jsdom. providers.tsx and
|
||||
// src/server/** stay counted (they have unit tests).
|
||||
"src/app/**/layout.tsx",
|
||||
"src/app/**/page.tsx",
|
||||
"src/app/**/route.ts",
|
||||
// Ambient type declarations — no executable code
|
||||
"src/**/*.d.ts",
|
||||
],
|
||||
},
|
||||
},
|
||||
resolve: { alias: { "@": path.resolve(__dirname, "./src") } },
|
||||
});
|
||||
Reference in New Issue
Block a user