@yosr-ai/template-next-fullstack 0.0.0-stage → 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +15 -2
  3. package/features/api-trpc/files/src/app/api/trpc/[trpc]/route.ts +15 -0
  4. package/features/api-trpc/files/src/app/trpc-provider.tsx +27 -0
  5. package/features/api-trpc/files/src/features/notes/components/note-form.tsx +48 -0
  6. package/features/api-trpc/files/src/server/api/context.ts +8 -0
  7. package/features/api-trpc/files/src/server/api/notes.test.ts +30 -0
  8. package/features/api-trpc/files/src/server/api/notes.ts +42 -0
  9. package/features/api-trpc/files/src/server/api/root.ts +8 -0
  10. package/features/api-trpc/files/src/server/api/trpc.ts +8 -0
  11. package/features/api-trpc/snippets/providers-imports.tsx +2 -0
  12. package/features/api-trpc/snippets/providers.tsx +1 -0
  13. package/features/auth-authjs/files/e2e/sign-in.spec.ts +10 -0
  14. package/features/auth-authjs/files/src/app/api/auth/[...nextauth]/route.ts +13 -0
  15. package/features/auth-authjs/files/src/app/auth-session-provider.tsx +8 -0
  16. package/features/auth-authjs/files/src/app/sign-in/page.tsx +19 -0
  17. package/features/auth-authjs/files/src/server/auth/config.ts +28 -0
  18. package/features/auth-authjs/files/src/server/auth/index.ts +15 -0
  19. package/features/auth-authjs/snippets/providers-imports.tsx +2 -0
  20. package/features/auth-authjs/snippets/providers.tsx +1 -0
  21. package/features/auth-authjs-drizzle/files/src/server/db/auth-tables.ts +86 -0
  22. package/features/auth-authjs-drizzle/snippets/adapter-db-imports.ts +8 -0
  23. package/features/auth-authjs-drizzle/snippets/adapter-imports.ts +1 -0
  24. package/features/auth-authjs-drizzle/snippets/adapter.ts +7 -0
  25. package/features/auth-authjs-drizzle/snippets/auth-tables.ts +7 -0
  26. package/features/auth-authjs-prisma/snippets/adapter-db-imports.ts +1 -0
  27. package/features/auth-authjs-prisma/snippets/adapter-imports.ts +1 -0
  28. package/features/auth-authjs-prisma/snippets/adapter.ts +1 -0
  29. package/features/auth-authjs-prisma/snippets/auth-models.prisma +58 -0
  30. package/features/auth-clerk/files/e2e/sign-in.spec.ts +15 -0
  31. package/features/auth-clerk/files/src/app/clerk-app-provider.tsx +13 -0
  32. package/features/auth-clerk/files/src/app/sign-in/[[...sign-in]]/page.tsx +18 -0
  33. package/features/auth-clerk/files/src/app/sign-up/[[...sign-up]]/page.tsx +18 -0
  34. package/features/auth-clerk/files/src/proxy.ts +24 -0
  35. package/features/auth-clerk/files/src/server/auth/index.ts +11 -0
  36. package/features/auth-clerk/snippets/providers-imports.tsx +2 -0
  37. package/features/auth-clerk/snippets/providers.tsx +1 -0
  38. package/features/deploy-docker/files/.dockerignore +6 -0
  39. package/features/deploy-docker/files/Dockerfile +26 -0
  40. package/features/deploy-docker/files/compose.yaml +6 -0
  41. package/features/deploy-docker-npm/files/Dockerfile +26 -0
  42. package/features/deploy-docker-postgres/snippets/postgres-service.yaml +4 -0
  43. package/features/deploy-vercel/snippets/readme-deploy.md +3 -0
  44. package/features/orm-drizzle/files/drizzle.config.ts +8 -0
  45. package/features/orm-drizzle/files/src/features/notes/notes-store.ts +21 -0
  46. package/features/orm-drizzle/files/src/server/db/index.ts +20 -0
  47. package/features/orm-drizzle/files/src/server/db/schema.ts +11 -0
  48. package/features/orm-drizzle/snippets/ci-e2e-setup.yaml +1 -0
  49. package/features/orm-drizzle/snippets/ci-job.yaml +11 -0
  50. package/features/orm-drizzle/snippets/readme-db.md +4 -0
  51. package/features/orm-prisma/files/prisma/schema.prisma +18 -0
  52. package/features/orm-prisma/files/prisma.config.ts +13 -0
  53. package/features/orm-prisma/files/src/features/notes/notes-store.ts +13 -0
  54. package/features/orm-prisma/files/src/server/db/index.ts +20 -0
  55. package/features/orm-prisma/snippets/ci-e2e-setup.yaml +1 -0
  56. package/features/orm-prisma/snippets/ci-job.yaml +11 -0
  57. package/features/orm-prisma/snippets/readme-db.md +4 -0
  58. package/features/payments-stripe/files/src/app/api/checkout/route.ts +27 -0
  59. package/features/payments-stripe/files/src/app/api/webhooks/stripe/route.ts +18 -0
  60. package/features/payments-stripe/files/src/server/billing/billing.test.ts +113 -0
  61. package/features/payments-stripe/files/src/server/billing/index.ts +99 -0
  62. package/features/payments-stripe/files/src/server/billing/stripe.ts +10 -0
  63. package/features/payments-stripe-drizzle/files/src/server/billing/store.ts +39 -0
  64. package/features/payments-stripe-drizzle/snippets/billing-tables.ts +13 -0
  65. package/features/payments-stripe-prisma/files/src/server/billing/store.ts +30 -0
  66. package/features/payments-stripe-prisma/snippets/billing-models.prisma +17 -0
  67. package/files/.env.example +1 -0
  68. package/files/.github/workflows/ci.yml +25 -0
  69. package/files/.prettierignore +7 -0
  70. package/files/.prettierrc.json +1 -0
  71. package/files/README.md +18 -0
  72. package/files/components.json +20 -0
  73. package/files/e2e/smoke.spec.ts +16 -0
  74. package/files/eslint.config.mjs +21 -0
  75. package/files/gitignore +25 -0
  76. package/files/next.config.ts +7 -0
  77. package/files/package.json +42 -0
  78. package/files/playwright.config.ts +20 -0
  79. package/files/pnpm-workspace.yaml +16 -0
  80. package/files/postcss.config.mjs +7 -0
  81. package/files/src/app/globals.css +68 -0
  82. package/files/src/app/layout.tsx +22 -0
  83. package/files/src/app/page.tsx +21 -0
  84. package/files/src/app/providers.tsx +15 -0
  85. package/files/src/components/ui/button.tsx +59 -0
  86. package/files/src/components/ui/input.tsx +21 -0
  87. package/files/src/features/notes/actions.ts +22 -0
  88. package/files/src/features/notes/components/note-form.tsx +32 -0
  89. package/files/src/features/notes/components/note-list.tsx +23 -0
  90. package/files/src/features/notes/create-note.test.ts +27 -0
  91. package/files/src/features/notes/create-note.ts +23 -0
  92. package/files/src/features/notes/note-schema.ts +13 -0
  93. package/files/src/features/notes/notes-store.ts +17 -0
  94. package/files/src/shared/utils.test.ts +13 -0
  95. package/files/src/shared/utils.ts +6 -0
  96. package/files/tsconfig.json +35 -0
  97. package/files/vitest.config.mts +9 -0
  98. package/package.json +26 -4
  99. package/rules/api-server-actions.md +1 -0
  100. package/rules/api-trpc.md +1 -0
  101. package/rules/architecture.md +11 -0
  102. package/rules/auth-authjs.md +1 -0
  103. package/rules/auth-clerk.md +1 -0
  104. package/rules/commands.md +5 -0
  105. package/rules/data-drizzle.md +1 -0
  106. package/rules/data-none.md +1 -0
  107. package/rules/data-prisma.md +1 -0
  108. package/rules/data-server-first.md +1 -0
  109. package/rules/errors-result.md +1 -0
  110. package/rules/errors-throw.md +1 -0
  111. package/rules/forms-zod.md +1 -0
  112. package/rules/layout-feature.md +5 -0
  113. package/rules/layout-flat.md +5 -0
  114. package/rules/layout-layer.md +6 -0
  115. package/rules/payments-stripe.md +1 -0
  116. package/rules/project-overview.md +5 -0
  117. package/rules/tests-colocated.md +1 -0
  118. package/rules/tests-folder.md +1 -0
  119. package/rules/tests-separate.md +1 -0
  120. package/rules/ui-shadcn.md +1 -0
  121. package/seed.template.json +1721 -0
  122. package/style/errors-result/files/src/features/notes/actions.ts +18 -0
  123. package/style/errors-result/files/src/features/notes/create-note.test.ts +30 -0
  124. package/style/errors-result/files/src/features/notes/create-note.ts +16 -0
  125. package/style/errors-result/files/src/shared/result.ts +12 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # @yosr-ai/template-next-fullstack
2
+
3
+ ## 0.1.0-alpha.1
4
+
5
+ ### Patch Changes
6
+
7
+ - An interactive Yosr Seed run closes with the next command when the gate is green, and points at `.seed/report.md` when the project was still written. On a terminal, the written folder, git, and the gate stay inside the prompt rail. The published Yosr Seed package rewrites every bundled `@yosr-ai/engine` import.
8
+ - Generated Yosr Seed projects record the choices made at setup and ask before departing from them. Flat aliases and unit-test paths match the layout, the home page follows the database choice, and standalone output is only for the Docker image build.
9
+ - Generated Yosr Seed projects pass every `ts.typeDefinitions` setting. Flat layout names `src/lib/server/` as the server-infrastructure area, with one level for `db/` and `auth/`.
10
+
11
+ ## 0.1.0-alpha.0
12
+
13
+ ### Major Changes
14
+
15
+ - First alpha of the Yosr Seed next-fullstack template.
package/README.md CHANGED
@@ -1,3 +1,16 @@
1
- # Temporary Holding Version
1
+ # `@yosr-ai/template-next-fullstack`
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The Yosr Seed next-fullstack template. License: MIT. Node.js: `>=24`. CLI usage is in the [repository README](https://github.com/getyosr/yosr-seed#readme).
4
+
5
+ For template authors; not rendered. Layout follows the template format spec.
6
+
7
+ - `seed.template.json`: manifest. Started from `examples/next-fullstack/seed.template.json`; dependency versions pinned from the npm registry on 2026-10-19. `x-prototype.stubbedFeatures` is empty. The selftest stub is fixture-only.
8
+ - `files/`: the base. It is a real Next.js app that installs, lints, type-checks, builds and tests on its own (checked). Files sit at the **default** layout's concrete paths (`src/features`, `src/shared`, `src/server`); the renderer relocates them for other layouts. `gitignore` is renamed to `.gitignore` on render (npm strips `.gitignore` from published packages).
9
+ - `features/orm-drizzle/`: Postgres + Drizzle overlay (replaces the in-memory notes store). `database: none` is the base without this overlay.
10
+ - `features/orm-prisma/`: Postgres + Prisma overlay (same role as Drizzle: schema, client, notes store via `replaces`). `prisma generate` runs on install. The generated client is not committed.
11
+ - `features/deploy-vercel/`: README section only.
12
+ - `features/deploy-docker/`: Dockerfile, `.dockerignore`, and `compose.yaml` for `deploy: docker`. The `next-config` anchor turns on `output: "standalone"` only when `YOSR_STANDALONE=1`, which the Dockerfile sets for the image build. Local `next start` does not use standalone output. `features/deploy-docker-postgres/` adds a Postgres service to that compose file when `database` is `postgres`. The image build is a non-required gate step (`--image`).
13
+ - `style/errors-result/`: variant tree for `errors.pattern = result`.
14
+ - `rules/`: agent-rule fragments and the architecture note source.
15
+
16
+ Anchors (`@seed:anchor <name>`) must be alone on their line; inserted content takes the anchor line's indentation; anchors are removed from output.
@@ -0,0 +1,15 @@
1
+ import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
2
+
3
+ import { createContext } from "@server/api/context";
4
+ import { appRouter } from "@server/api/root";
5
+
6
+ function handler(req: Request) {
7
+ return fetchRequestHandler({
8
+ endpoint: "/api/trpc",
9
+ req,
10
+ router: appRouter,
11
+ createContext: () => createContext(),
12
+ });
13
+ }
14
+
15
+ export { handler as GET, handler as POST };
@@ -0,0 +1,27 @@
1
+ "use client";
2
+
3
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
+ import { httpBatchLink } from "@trpc/client";
5
+ import { createTRPCReact } from "@trpc/react-query";
6
+ import type { ReactNode } from "react";
7
+ import { useState } from "react";
8
+
9
+ import type { AppRouter } from "@server/api/root";
10
+
11
+ export const trpc = createTRPCReact<AppRouter>();
12
+
13
+ /** One registry entry: tRPC client inside React Query. */
14
+ export function TrpcProvider({ children }: { children: ReactNode }) {
15
+ const [queryClient] = useState(() => new QueryClient());
16
+ const [trpcClient] = useState(() =>
17
+ trpc.createClient({
18
+ links: [httpBatchLink({ url: "/api/trpc" })],
19
+ }),
20
+ );
21
+
22
+ return (
23
+ <trpc.Provider client={trpcClient} queryClient={queryClient}>
24
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
25
+ </trpc.Provider>
26
+ );
27
+ }
@@ -0,0 +1,48 @@
1
+ "use client";
2
+
3
+ import { useRouter } from "next/navigation";
4
+ import { useState } from "react";
5
+
6
+ import { trpc } from "@/app/trpc-provider";
7
+ import { Button } from "@ui/button";
8
+ import { Input } from "@ui/input";
9
+
10
+ export function NoteForm() {
11
+ const router = useRouter();
12
+ const [title, setTitle] = useState("");
13
+ const create = trpc.notes.create.useMutation({
14
+ onSuccess: () => {
15
+ setTitle("");
16
+ router.refresh();
17
+ },
18
+ });
19
+
20
+ return (
21
+ <form
22
+ className="flex flex-col gap-2"
23
+ onSubmit={(event) => {
24
+ event.preventDefault();
25
+ create.mutate({ title });
26
+ }}
27
+ >
28
+ <div className="flex gap-2">
29
+ <Input
30
+ name="title"
31
+ value={title}
32
+ onChange={(event) => setTitle(event.target.value)}
33
+ placeholder="Write a note"
34
+ aria-label="Note title"
35
+ maxLength={120}
36
+ />
37
+ <Button type="submit" disabled={create.isPending}>
38
+ Add
39
+ </Button>
40
+ </div>
41
+ {create.error ? (
42
+ <p role="alert" className="text-sm text-destructive">
43
+ {create.error.message}
44
+ </p>
45
+ ) : null}
46
+ </form>
47
+ );
48
+ }
@@ -0,0 +1,8 @@
1
+ import { insertNote } from "@domain/notes/notes-store";
2
+
3
+ /** Request context. Importing the store does not open a database. */
4
+ export function createContext() {
5
+ return { insertNote };
6
+ }
7
+
8
+ export type ApiContext = ReturnType<typeof createContext>;
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import type { ApiContext } from "./context";
4
+ import { appRouter } from "./root";
5
+
6
+ describe("notes.create", () => {
7
+ it("stores a trimmed title through createNote", async () => {
8
+ const insertNote: ApiContext["insertNote"] = vi.fn(async (input) => ({
9
+ id: "1",
10
+ title: input.title,
11
+ createdAt: new Date(0),
12
+ }));
13
+ const caller = appRouter.createCaller({ insertNote });
14
+
15
+ const note = await caller.notes.create({ title: " Buy milk " });
16
+
17
+ expect(insertNote).toHaveBeenCalledWith({ title: "Buy milk" });
18
+ expect(note.title).toBe("Buy milk");
19
+ });
20
+
21
+ it("rejects an empty title without storing", async () => {
22
+ const insertNote: ApiContext["insertNote"] = vi.fn();
23
+ const caller = appRouter.createCaller({ insertNote });
24
+
25
+ await expect(caller.notes.create({ title: " " })).rejects.toThrow(
26
+ "Title is required",
27
+ );
28
+ expect(insertNote).not.toHaveBeenCalled();
29
+ });
30
+ });
@@ -0,0 +1,42 @@
1
+ import { TRPCError } from "@trpc/server";
2
+ import { z } from "zod";
3
+
4
+ import { createNote } from "@domain/notes/create-note";
5
+ import type { Note } from "@domain/notes/note-schema";
6
+
7
+ import { publicProcedure, router } from "./trpc";
8
+
9
+ function asNote(value: unknown): Note {
10
+ if (
11
+ typeof value === "object" &&
12
+ value !== null &&
13
+ "id" in value &&
14
+ typeof value.id === "string" &&
15
+ "title" in value &&
16
+ typeof value.title === "string" &&
17
+ "createdAt" in value &&
18
+ value.createdAt instanceof Date
19
+ ) {
20
+ return { id: value.id, title: value.title, createdAt: value.createdAt };
21
+ }
22
+ throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Invalid note" });
23
+ }
24
+
25
+ export const notesRouter = router({
26
+ create: publicProcedure.input(z.unknown()).mutation(async ({ ctx, input }) => {
27
+ const created = await createNote(input, ctx.insertNote);
28
+ if (typeof created === "object" && created !== null && "ok" in created) {
29
+ if (created.ok === false) {
30
+ const message =
31
+ "error" in created && typeof created.error === "string"
32
+ ? created.error
33
+ : "Invalid note";
34
+ throw new TRPCError({ code: "BAD_REQUEST", message });
35
+ }
36
+ if ("value" in created) {
37
+ return asNote(created.value);
38
+ }
39
+ }
40
+ return asNote(created);
41
+ }),
42
+ });
@@ -0,0 +1,8 @@
1
+ import { notesRouter } from "./notes";
2
+ import { router } from "./trpc";
3
+
4
+ export const appRouter = router({
5
+ notes: notesRouter,
6
+ });
7
+
8
+ export type AppRouter = typeof appRouter;
@@ -0,0 +1,8 @@
1
+ import { initTRPC } from "@trpc/server";
2
+
3
+ import type { ApiContext } from "./context";
4
+
5
+ const t = initTRPC.context<ApiContext>().create();
6
+
7
+ export const router = t.router;
8
+ export const publicProcedure = t.procedure;
@@ -0,0 +1,2 @@
1
+
2
+ import { TrpcProvider } from "./trpc-provider";
@@ -0,0 +1 @@
1
+ TrpcProvider,
@@ -0,0 +1,10 @@
1
+ import { expect, test } from "@playwright/test";
2
+
3
+ test("sign-in page lists GitHub and Google only", async ({ page }) => {
4
+ await page.goto("/sign-in");
5
+ await expect(page.getByRole("button", { name: "GitHub" })).toBeVisible();
6
+ await expect(page.getByRole("button", { name: "Google" })).toBeVisible();
7
+ await expect(page.getByLabel(/email/i)).toHaveCount(0);
8
+ await expect(page.getByRole("button", { name: /magic link/i })).toHaveCount(0);
9
+ await expect(page.getByText(/magic link/i)).toHaveCount(0);
10
+ });
@@ -0,0 +1,13 @@
1
+ import type { NextRequest } from "next/server";
2
+ import NextAuth from "next-auth";
3
+
4
+ import { authOptions } from "@server/auth/config";
5
+
6
+ async function handler(
7
+ req: NextRequest,
8
+ context: { params: Promise<{ nextauth: string[] }> },
9
+ ) {
10
+ return NextAuth(req, context, authOptions());
11
+ }
12
+
13
+ export { handler as GET, handler as POST };
@@ -0,0 +1,8 @@
1
+ "use client";
2
+
3
+ import { SessionProvider } from "next-auth/react";
4
+ import type { ReactNode } from "react";
5
+
6
+ export function AuthSessionProvider({ children }: { children: ReactNode }) {
7
+ return <SessionProvider>{children}</SessionProvider>;
8
+ }
@@ -0,0 +1,19 @@
1
+ "use client";
2
+
3
+ import { signIn } from "next-auth/react";
4
+
5
+ import { Button } from "@ui/button";
6
+
7
+ export default function SignInPage() {
8
+ return (
9
+ <main className="mx-auto flex w-full max-w-sm flex-col gap-3 p-6">
10
+ <h1 className="text-xl font-semibold">Sign in</h1>
11
+ <Button type="button" onClick={() => void signIn("github")}>
12
+ GitHub
13
+ </Button>
14
+ <Button type="button" onClick={() => void signIn("google")}>
15
+ Google
16
+ </Button>
17
+ </main>
18
+ );
19
+ }
@@ -0,0 +1,28 @@
1
+ // @seed:anchor adapter-imports
2
+ import type { NextAuthOptions } from "next-auth";
3
+ import GitHubProvider from "next-auth/providers/github";
4
+ import GoogleProvider from "next-auth/providers/google";
5
+
6
+ // @seed:anchor adapter-db-imports
7
+
8
+ /** Auth.js options. The adapter calls `db()`, so this runs per request, not at import. */
9
+ export function authOptions(): NextAuthOptions {
10
+ return {
11
+ // @seed:anchor adapter
12
+ ...(process.env.AUTH_SECRET !== undefined
13
+ ? { secret: process.env.AUTH_SECRET }
14
+ : {}),
15
+ session: { strategy: "database" },
16
+ pages: { signIn: "/sign-in" },
17
+ providers: [
18
+ GitHubProvider({
19
+ clientId: process.env.GITHUB_ID ?? "",
20
+ clientSecret: process.env.GITHUB_SECRET ?? "",
21
+ }),
22
+ GoogleProvider({
23
+ clientId: process.env.GOOGLE_CLIENT_ID ?? "",
24
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? "",
25
+ }),
26
+ ],
27
+ };
28
+ }
@@ -0,0 +1,15 @@
1
+ import { getServerSession } from "next-auth";
2
+
3
+ import { authOptions } from "./config";
4
+
5
+ /** Server session. Opens the database only when called. */
6
+ export function auth() {
7
+ return getServerSession(authOptions());
8
+ }
9
+
10
+ /** Signed-in email. Opens the database only when called. */
11
+ export async function sessionEmail(): Promise<string | null> {
12
+ const session = await auth();
13
+ const email = session?.user?.email;
14
+ return typeof email === "string" && email.length > 0 ? email : null;
15
+ }
@@ -0,0 +1,2 @@
1
+
2
+ import { AuthSessionProvider } from "./auth-session-provider";
@@ -0,0 +1 @@
1
+ AuthSessionProvider,
@@ -0,0 +1,86 @@
1
+ import {
2
+ boolean,
3
+ integer,
4
+ pgTable,
5
+ primaryKey,
6
+ text,
7
+ timestamp,
8
+ } from "drizzle-orm/pg-core";
9
+
10
+ /** Columns from `@auth/drizzle-adapter` 1.11.3 `defineTables` (`src/lib/pg.ts`). */
11
+ export const users = pgTable("user", {
12
+ id: text("id")
13
+ .primaryKey()
14
+ .$defaultFn(() => crypto.randomUUID()),
15
+ name: text("name"),
16
+ email: text("email").unique(),
17
+ emailVerified: timestamp("emailVerified", { mode: "date" }),
18
+ image: text("image"),
19
+ });
20
+
21
+ export const accounts = pgTable(
22
+ "account",
23
+ {
24
+ userId: text("userId")
25
+ .notNull()
26
+ .references(() => users.id, { onDelete: "cascade" }),
27
+ type: text("type").notNull(),
28
+ provider: text("provider").notNull(),
29
+ providerAccountId: text("providerAccountId").notNull(),
30
+ refresh_token: text("refresh_token"),
31
+ access_token: text("access_token"),
32
+ expires_at: integer("expires_at"),
33
+ token_type: text("token_type"),
34
+ scope: text("scope"),
35
+ id_token: text("id_token"),
36
+ session_state: text("session_state"),
37
+ },
38
+ (account) => ({
39
+ compositePk: primaryKey({
40
+ columns: [account.provider, account.providerAccountId],
41
+ }),
42
+ }),
43
+ );
44
+
45
+ export const sessions = pgTable("session", {
46
+ sessionToken: text("sessionToken").primaryKey(),
47
+ userId: text("userId")
48
+ .notNull()
49
+ .references(() => users.id, { onDelete: "cascade" }),
50
+ expires: timestamp("expires", { mode: "date" }).notNull(),
51
+ });
52
+
53
+ export const verificationTokens = pgTable(
54
+ "verificationToken",
55
+ {
56
+ identifier: text("identifier").notNull(),
57
+ token: text("token").notNull(),
58
+ expires: timestamp("expires", { mode: "date" }).notNull(),
59
+ },
60
+ (verificationToken) => ({
61
+ compositePk: primaryKey({
62
+ columns: [verificationToken.identifier, verificationToken.token],
63
+ }),
64
+ }),
65
+ );
66
+
67
+ export const authenticators = pgTable(
68
+ "authenticator",
69
+ {
70
+ credentialID: text("credentialID").notNull().unique(),
71
+ userId: text("userId")
72
+ .notNull()
73
+ .references(() => users.id, { onDelete: "cascade" }),
74
+ providerAccountId: text("providerAccountId").notNull(),
75
+ credentialPublicKey: text("credentialPublicKey").notNull(),
76
+ counter: integer("counter").notNull(),
77
+ credentialDeviceType: text("credentialDeviceType").notNull(),
78
+ credentialBackedUp: boolean("credentialBackedUp").notNull(),
79
+ transports: text("transports"),
80
+ },
81
+ (authenticator) => ({
82
+ compositePK: primaryKey({
83
+ columns: [authenticator.userId, authenticator.credentialID],
84
+ }),
85
+ }),
86
+ );
@@ -0,0 +1,8 @@
1
+ import { db } from "@server/db";
2
+ import {
3
+ accounts,
4
+ authenticators,
5
+ sessions,
6
+ users,
7
+ verificationTokens,
8
+ } from "@server/db/schema";
@@ -0,0 +1 @@
1
+ import { DrizzleAdapter } from "@auth/drizzle-adapter";
@@ -0,0 +1,7 @@
1
+ adapter: DrizzleAdapter(db(), {
2
+ usersTable: users,
3
+ accountsTable: accounts,
4
+ sessionsTable: sessions,
5
+ verificationTokensTable: verificationTokens,
6
+ authenticatorsTable: authenticators,
7
+ }),
@@ -0,0 +1,7 @@
1
+ export {
2
+ accounts,
3
+ authenticators,
4
+ sessions,
5
+ users,
6
+ verificationTokens,
7
+ } from "./auth-tables";
@@ -0,0 +1 @@
1
+ import { db } from "@server/db";
@@ -0,0 +1 @@
1
+ import { PrismaAdapter } from "@auth/prisma-adapter";
@@ -0,0 +1 @@
1
+ adapter: PrismaAdapter(db()),
@@ -0,0 +1,58 @@
1
+ model User {
2
+ id String @id @default(cuid())
3
+ name String?
4
+ email String @unique
5
+ emailVerified DateTime?
6
+ image String?
7
+ accounts Account[]
8
+ sessions Session[]
9
+ Authenticator Authenticator[]
10
+ }
11
+
12
+ model Account {
13
+ userId String
14
+ type String
15
+ provider String
16
+ providerAccountId String
17
+ refresh_token String?
18
+ access_token String?
19
+ expires_at Int?
20
+ token_type String?
21
+ scope String?
22
+ id_token String?
23
+ session_state String?
24
+
25
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
26
+
27
+ @@id([provider, providerAccountId])
28
+ }
29
+
30
+ model Session {
31
+ sessionToken String @unique
32
+ userId String
33
+ expires DateTime
34
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
35
+ }
36
+
37
+ model VerificationToken {
38
+ identifier String
39
+ token String
40
+ expires DateTime
41
+
42
+ @@id([identifier, token])
43
+ }
44
+
45
+ model Authenticator {
46
+ credentialID String @unique
47
+ userId String
48
+ providerAccountId String
49
+ credentialPublicKey String
50
+ counter Int
51
+ credentialDeviceType String
52
+ credentialBackedUp Boolean
53
+ transports String?
54
+
55
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
56
+
57
+ @@id([userId, credentialID])
58
+ }
@@ -0,0 +1,15 @@
1
+ import { expect, test } from "@playwright/test";
2
+
3
+ test("sign-in page renders offline", async ({ page }) => {
4
+ const clerkCalls: string[] = [];
5
+ page.on("request", (request) => {
6
+ const url = request.url();
7
+ if (/clerk\.(com|dev)|accounts\.dev|clerk\.accounts|clerk-telemetry/i.test(url)) {
8
+ clerkCalls.push(url);
9
+ }
10
+ });
11
+
12
+ await page.goto("/sign-in");
13
+ await expect(page.getByRole("heading", { name: "Sign in" })).toBeVisible();
14
+ expect(clerkCalls).toEqual([]);
15
+ });
@@ -0,0 +1,13 @@
1
+ "use client";
2
+
3
+ import { ClerkProvider } from "@clerk/nextjs";
4
+ import type { ReactNode } from "react";
5
+
6
+ /** Mounts Clerk only when a publishable key is set, so the gate does not call Clerk. */
7
+ export function ClerkAppProvider({ children }: { children: ReactNode }) {
8
+ if (!process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY) {
9
+ return children;
10
+ }
11
+
12
+ return <ClerkProvider>{children}</ClerkProvider>;
13
+ }
@@ -0,0 +1,18 @@
1
+ export default async function SignInPage() {
2
+ if (!process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY) {
3
+ return (
4
+ <main className="mx-auto flex w-full max-w-sm flex-col gap-3 p-6">
5
+ <h1 className="text-xl font-semibold">Sign in</h1>
6
+ </main>
7
+ );
8
+ }
9
+
10
+ const { SignIn } = await import("@clerk/nextjs");
11
+
12
+ return (
13
+ <main className="mx-auto flex w-full max-w-sm flex-col gap-3 p-6">
14
+ <h1 className="text-xl font-semibold">Sign in</h1>
15
+ <SignIn />
16
+ </main>
17
+ );
18
+ }
@@ -0,0 +1,18 @@
1
+ export default async function SignUpPage() {
2
+ if (!process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY) {
3
+ return (
4
+ <main className="mx-auto flex w-full max-w-sm flex-col gap-3 p-6">
5
+ <h1 className="text-xl font-semibold">Sign up</h1>
6
+ </main>
7
+ );
8
+ }
9
+
10
+ const { SignUp } = await import("@clerk/nextjs");
11
+
12
+ return (
13
+ <main className="mx-auto flex w-full max-w-sm flex-col gap-3 p-6">
14
+ <h1 className="text-xl font-semibold">Sign up</h1>
15
+ <SignUp />
16
+ </main>
17
+ );
18
+ }
@@ -0,0 +1,24 @@
1
+ import { clerkMiddleware } from "@clerk/nextjs/server";
2
+ import type { NextFetchEvent, NextRequest } from "next/server";
3
+ import { NextResponse } from "next/server";
4
+
5
+ const gate = clerkMiddleware();
6
+
7
+ /** Next.js 16 proxy. `clerkMiddleware` runs only when both Clerk keys are set. */
8
+ export function proxy(request: NextRequest, event: NextFetchEvent) {
9
+ if (
10
+ !process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY ||
11
+ !process.env.CLERK_SECRET_KEY
12
+ ) {
13
+ return NextResponse.next();
14
+ }
15
+
16
+ return gate(request, event);
17
+ }
18
+
19
+ export const config = {
20
+ matcher: [
21
+ "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
22
+ "/(api|trpc)(.*)",
23
+ ],
24
+ };
@@ -0,0 +1,11 @@
1
+ import { currentUser } from "@clerk/nextjs/server";
2
+
3
+ /** Signed-in email. Calls Clerk only when this function runs. */
4
+ export async function sessionEmail(): Promise<string | null> {
5
+ const user = await currentUser();
6
+ if (user === null) return null;
7
+ const fromPrimary = user.primaryEmailAddress?.emailAddress;
8
+ if (typeof fromPrimary === "string" && fromPrimary.length > 0) return fromPrimary;
9
+ const first = user.emailAddresses[0]?.emailAddress;
10
+ return typeof first === "string" && first.length > 0 ? first : null;
11
+ }
@@ -0,0 +1,2 @@
1
+
2
+ import { ClerkAppProvider } from "./clerk-app-provider";
@@ -0,0 +1 @@
1
+ ClerkAppProvider,
@@ -0,0 +1,6 @@
1
+ node_modules
2
+ .next
3
+ out
4
+ .env
5
+ .env.*
6
+ !.env.example