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:
2026-07-12 20:40:54 +02:00
commit f77e6ea881
1062 changed files with 105156 additions and 0 deletions

89
apps/cms/AGENTS.md Normal file
View 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
View 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",
},
},
];

View 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
View 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
View 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
View 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
View 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"
}
}

View 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 { 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;

View 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;

View 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,
};

View 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);

View 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);

View File

@@ -0,0 +1 @@
// Custom admin panel styles

View 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;

View 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'");
});
});

View 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([]);
});
});

View 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
View 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"]
}

File diff suppressed because one or more lines are too long

4
apps/cms/turbo.json Normal file
View File

@@ -0,0 +1,4 @@
{
"extends": ["//"],
"tags": ["app"]
}

21
apps/cms/vitest.config.ts Normal file
View 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") } },
});

View File

@@ -0,0 +1,2 @@
storybook-static
.storybook/storybook-static

1
apps/storybook/.storybook/css.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module "*.css";

View 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;

View 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;

View 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
View 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

View File

@@ -0,0 +1,3 @@
import baseConfig from "@repo/core-eslint/base";
export default baseConfig;

View 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"
}
}

View 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;

View 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`);
});
}
});
});

View 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"]
}

View File

@@ -0,0 +1,4 @@
{
"extends": ["//"],
"tags": ["app"]
}

110
apps/web-next/AGENTS.md Normal file
View 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`

View 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();
});
});

View 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();
});

View 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",
},
},
];

View 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(),
});

View 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",
});
}
}

View 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
View 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.

View 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,
});

View 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"
}
}

View 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,
},
});

View File

@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};

23
apps/web-next/server.ts Normal file
View 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}`);
});

View 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();
});
});

View 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 };

View 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>
);
}

View 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>
);
}

View 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();
});
});

View 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
View File

@@ -0,0 +1 @@
declare module "*.css";

View 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();
});
});

View 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("/");
}

View 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"]);
});
});

View 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();
});
});

View 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;
}

View 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";

View File

@@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}

View 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"]
}

File diff suppressed because one or more lines are too long

4
apps/web-next/turbo.json Normal file
View File

@@ -0,0 +1,4 @@
{
"extends": ["//"],
"tags": ["app"]
}

View 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") } },
});