Inicial commit

This commit is contained in:
Julio Ruiz 2026-07-31 14:03:42 -05:00
commit e1f55c8411
65 changed files with 14338 additions and 0 deletions

View File

@ -0,0 +1,35 @@
{
"permissions": {
"allow": [
"WebFetch(domain:portless.sh)",
"WebFetch(domain:github.com)",
"Bash(pnpm -v)",
"Bash(npm -v)",
"Bash(docker -v)",
"Bash(docker compose *)",
"Bash(pnpm config *)",
"Bash(pnpm install *)",
"Bash(echo \"EXIT=$?\")",
"Bash(pnpm --filter cms generate:importmap)",
"Bash(pnpm --filter cms generate:types)",
"Bash(pnpm --filter web typecheck)",
"Bash(pnpm --filter web build)",
"Bash(pnpm --filter cms build)",
"Bash(PORT=3000 pnpm --filter cms dev)",
"Bash(curl -s --retry-connrefused --retry 40 --retry-delay 2 -o /dev/null -w \"connect ok, http=%{http_code}\\\\n\" \"http://localhost:3000/api/posts\")",
"Bash(curl -s \"http://localhost:3000/api/posts\")",
"Bash(docker exec *)",
"Bash(python3 -c \"import sys,json;d=json.load\\(sys.stdin\\);print\\('totalDocs',d['totalDocs']\\);[print\\('-',p['title'],'/',p['slug']\\) for p in d['docs']]\")",
"Bash(PORT=4321 pnpm --filter web dev:plain)",
"Bash(echo \"started web dev \\(pid $!\\)\")",
"Bash(curl -s --retry-connrefused --retry 30 --retry-delay 1 -o /dev/null -w \"home http=%{http_code}\\\\n\" \"http://localhost:4321/\")",
"Bash(curl -s \"http://localhost:4321/\")",
"Bash(curl -s \"http://localhost:4321/posts/hello-from-payload\")",
"Bash(pkill -f \"next dev\")",
"Bash(pkill -f \"astro dev\")",
"Bash(pkill -f \"next-server\")",
"Bash(pnpm exec *)",
"Bash(python3 -c \"import sys,json;d=json.load\\(sys.stdin\\);[print\\('-',t['taskId']\\) for t in d['tasks']]\")"
]
}
}

116
.env.example Normal file
View File

@ -0,0 +1,116 @@
############################################################
# Single source of truth for the whole stack.
# cp .env.example .env
#
# Used by BOTH docker-compose.yml (Supabase backend) and the
# apps (Payload CMS + Astro).
#
# ⚠️ These are INSECURE DEV DEFAULTS. Regenerate every secret
# before deploying anywhere real. See README "Production".
############################################################
########################
# Postgres (Supabase DB)
########################
POSTGRES_HOST=db
POSTGRES_DB=postgres
POSTGRES_PORT=5432
POSTGRES_PASSWORD=postgres
########################
# JWT / API keys
# The ANON_KEY and SERVICE_ROLE_KEY below are signed with this
# exact JWT_SECRET. If you change JWT_SECRET you MUST regenerate
# both keys (see README) or Auth/Kong will reject them.
########################
JWT_SECRET=your-super-secret-jwt-token-with-at-least-32-characters-long
JWT_EXPIRY=3600
ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE
SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q
# New-style opaque API keys (optional; leave blank to disable)
SUPABASE_PUBLISHABLE_KEY=
SUPABASE_SECRET_KEY=
ANON_KEY_ASYMMETRIC=
SERVICE_ROLE_KEY_ASYMMETRIC=
########################
# API gateway (Kong) + public URLs
########################
KONG_HTTP_PORT=8000
KONG_HTTPS_PORT=8443
SUPABASE_PUBLIC_URL=http://localhost:8000
API_EXTERNAL_URL=http://localhost:8000/auth/v1
########################
# Studio dashboard
########################
DASHBOARD_USERNAME=supabase
DASHBOARD_PASSWORD=this_password_is_insecure_and_should_be_updated
STUDIO_DEFAULT_ORGANIZATION=Default Organization
STUDIO_DEFAULT_PROJECT=cdrdpyj
PG_META_CRYPTO_KEY=your-encryption-key-32-chars-min
OPENAI_API_KEY=
########################
# Auth (GoTrue)
########################
SITE_URL=https://web.localhost
ADDITIONAL_REDIRECT_URLS=
DISABLE_SIGNUP=false
ENABLE_EMAIL_SIGNUP=true
ENABLE_EMAIL_AUTOCONFIRM=true
ENABLE_ANONYMOUS_USERS=false
ENABLE_PHONE_SIGNUP=false
ENABLE_PHONE_AUTOCONFIRM=false
MAILER_URLPATHS_CONFIRMATION="/auth/v1/verify"
MAILER_URLPATHS_INVITE="/auth/v1/verify"
MAILER_URLPATHS_RECOVERY="/auth/v1/verify"
MAILER_URLPATHS_EMAIL_CHANGE="/auth/v1/verify"
SMTP_ADMIN_EMAIL=admin@example.com
SMTP_HOST=supabase-mail
SMTP_PORT=2500
SMTP_USER=fake_mail_user
SMTP_PASS=fake_mail_password
SMTP_SENDER_NAME=fake_sender
########################
# REST (PostgREST)
########################
PGRST_DB_SCHEMAS=public,graphql_public
PGRST_DB_MAX_ROWS=1000
PGRST_DB_EXTRA_SEARCH_PATH=public
########################
# Storage + imgproxy
########################
GLOBAL_S3_BUCKET=stub
REGION=stub
STORAGE_TENANT_ID=stub
S3_PROTOCOL_ACCESS_KEY_ID=625729a08b95bf1b7ff351a663f3a23c
S3_PROTOCOL_ACCESS_KEY_SECRET=850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907
IMGPROXY_AUTO_WEBP=true
############################################################
# Applications
############################################################
########################
# Payload CMS (apps/cms)
# Runs on plain http://localhost:3000 in host dev so Astro's
# server-side fetch stays plain HTTP (portless HTTPS certs are
# not trusted by Node). DATABASE_URI connects via the published
# Postgres port; the compose `apps` profile overrides host->db.
########################
DATABASE_URI=postgresql://postgres:postgres@localhost:5432/postgres
PAYLOAD_SECRET=dev-only-change-me-6f1c2b8a9e4d
PAYLOAD_PUBLIC_SERVER_URL=http://localhost:3000
########################
# Astro (apps/web) — runs behind portless at https://web.localhost
# Where the frontend reads content from Payload's REST API.
# Vars prefixed PUBLIC_ are exposed to the browser by Astro.
########################
PUBLIC_PAYLOAD_API_URL=http://localhost:3000/api
PUBLIC_SUPABASE_URL=http://localhost:8000
PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE

35
.gitignore vendored Normal file
View File

@ -0,0 +1,35 @@
# Dependencies
node_modules
.pnp
.pnp.js
# Turborepo
.turbo
# Builds
dist
build
.next
out
*.tsbuildinfo
# Env
.env
.env.*
!.env.example
# Supabase persisted data & generated content
supabase/volumes/db/data
supabase/volumes/storage/*
!supabase/volumes/storage/.gitkeep
# Payload generated
apps/cms/media
# Logs
*.log
npm-debug.log*
pnpm-debug.log*
# OS
.DS_Store

2
.npmrc Normal file
View File

@ -0,0 +1,2 @@
auto-install-peers=true
strict-peer-dependencies=false

184
README.md Normal file
View File

@ -0,0 +1,184 @@
# cdrdpyj
A TypeScript **Turborepo** wiring an **Astro** frontend to a **Payload CMS**
backend, both running against a **self-hosted Supabase** stack (Postgres),
orchestrated from a single **docker-compose** file — with **[portless]** for
stable `*.localhost` dev URLs.
```
┌──────────────┐ REST /api ┌──────────────┐ SQL ┌───────────────────────────┐
│ Astro (web) │ ──────────────────────▶ │ Payload (cms)│ ─────────────────▶ │ Supabase (Postgres 17) │
│ SSR, :4321 │ │ Next, :3000 │ schema: payload │ db · studio · kong · │
│ web.localhost│ │ │ │ auth · rest · storage │
└──────────────┘ └──────────────┘ └───────────────────────────┘
▲ portless Studio dashboard :8000
```
- **apps/web** — Astro 5 (SSR via `@astrojs/node`). Reads posts from Payload's REST API.
- **apps/cms** — Payload 3 (on Next.js 16). Stores content in a dedicated `payload` schema in Postgres.
- **Supabase** — trimmed self-hosted stack: `db`, `studio`, `meta`, `kong`, `auth`, `rest`, `storage`, `imgproxy`.
- **packages/typescript-config** — shared `tsconfig` bases.
[portless]: https://portless.sh
---
## Prerequisites
| Tool | Version | Notes |
| ------- | ------- | ----- |
| Node | ≥ 24 | portless and Payload both require Node 24+ |
| pnpm | ≥ 9 | `corepack enable` or `npm i -g pnpm` |
| Docker | + Compose v2 | for the Supabase backend |
| portless| latest | `pnpm add -g portless` (optional but recommended) |
---
## Quick start
```bash
# 1. Configure — one env file drives the whole stack
cp .env.example .env
# 2. Install workspace deps
pnpm install
# 3. Start the Supabase backend (Postgres + Studio + Auth + REST + Storage)
docker compose up -d # first run pulls images; give it a minute
# 4. Run the apps (Astro + Payload) with hot reload
pnpm dev
```
Then open:
| What | URL |
| --------------- | ------------------------------------- |
| Astro frontend | http://localhost:4321 (or the portless URL below) |
| Payload admin | http://localhost:3000/admin |
| Supabase Studio | http://localhost:8000 (login: `supabase` / `this_password_is_insecure_and_should_be_updated`) |
| Postgres | `postgresql://postgres:postgres@localhost:5432/postgres` |
First time in Payload admin you'll create the initial admin user. Then add a
**Post** (give it a `slug`) and it appears on the Astro homepage.
---
## portless — stable `*.localhost` URLs
[portless] gives the frontend a memorable HTTPS URL instead of a port number,
and injects `$PORT` automatically.
```bash
pnpm add -g portless # once
pnpm dev # Astro is started via `portless web astro dev`
```
The web app is served at **https://web.localhost**. On first run portless
generates and trusts a local CA and binds to `:443` (may prompt for sudo).
> **Why only the frontend?** Payload stays on plain `http://localhost:3000` so
> Astro's server-side `fetch` to it uses plain HTTP — Node doesn't trust
> portless's local CA for server-to-server HTTPS. To also route Payload through
> portless (https://cms.localhost), run `pnpm --filter cms dev:portless` and set
> `NODE_EXTRA_CA_CERTS` to portless's CA so Astro can reach it over HTTPS.
Prefer no portless? Use `pnpm --filter web dev:plain` (plain `astro dev`).
---
## Everything in Docker (optional)
The apps also ship with Dockerfiles behind a compose `apps` profile:
```bash
docker compose --profile apps up -d --build
```
This runs Astro (`:4321`) and Payload (`:3000`) as containers alongside
Supabase. `DATABASE_URI` and the Astro→Payload URL are rewired to the internal
Docker network automatically. The recommended day-to-day loop is still
`docker compose up -d` (backend) + `pnpm dev` (apps) for hot reload.
---
## Scripts
Run from the repo root:
| Command | Does |
| ------------------------ | ---- |
| `pnpm dev` | Turbo runs both apps (Astro via portless, Payload plain) |
| `pnpm build` | Build all apps |
| `pnpm typecheck` | Type-check all apps |
| `pnpm supabase:up` / `:down` | Start / stop the Supabase backend |
| `pnpm supabase:logs` | Tail backend logs |
| `pnpm supabase:reset` | ⚠️ Stop and **delete** all Postgres data + volumes |
| `pnpm stack:up` / `:down`| Backend **and** apps in Docker (`apps` profile) |
Per-app: `pnpm --filter cms generate:types`, `pnpm --filter cms generate:importmap`.
---
## How configuration works
There is **one** `.env` at the repo root, shared by everything:
- **docker-compose** reads it natively.
- **Payload** loads it from the root in `apps/cms/src/payload.config.ts`.
- **Astro** loads it via `vite.envDir` pointed at the root; `PUBLIC_*` vars are
exposed to the browser.
The Supabase `ANON_KEY` / `SERVICE_ROLE_KEY` in `.env.example` are the standard
Supabase demo keys, signed with the matching demo `JWT_SECRET`. They work out of
the box for local dev. **Changing `JWT_SECRET` invalidates them** — regenerate
with Supabase's `generate-keys` steps if you do.
---
## Project structure
```
.
├── docker-compose.yml # single-file Supabase stack (+ apps profile)
├── .env.example # one source of truth for all config
├── turbo.json # task pipeline
├── apps/
│ ├── web/ # Astro SSR frontend
│ └── cms/ # Payload 3 (Next.js) CMS
├── packages/
│ └── typescript-config/ # shared tsconfig bases
└── supabase/
└── volumes/ # kong config + Postgres init SQL (roles, jwt, payload schema)
```
---
## Production notes
This scaffold is tuned for local development. Before deploying anywhere real:
- Regenerate **every** secret in `.env` (`POSTGRES_PASSWORD`, `JWT_SECRET` +
matching keys, `PAYLOAD_SECRET`, `DASHBOARD_PASSWORD`, `PG_META_CRYPTO_KEY`,
the S3 protocol keys).
- Put Kong / the apps behind TLS and real domains; set `SITE_URL`,
`API_EXTERNAL_URL`, `SUPABASE_PUBLIC_URL`, `PAYLOAD_PUBLIC_SERVER_URL`,
`PUBLIC_PAYLOAD_API_URL` accordingly.
- Switch Payload from dev "push" to committed migrations
(`pnpm --filter cms payload migrate:create`).
- Consider adding back the Supabase services this scaffold trims (Realtime,
Edge Functions, Supavisor pooler) if you need them — see
[Supabase self-hosting docs](https://supabase.com/docs/guides/self-hosting/docker).
---
## Troubleshooting
- **Astro homepage shows "Couldn't reach the CMS"** — Payload isn't running or
the DB is down. Ensure `docker compose up -d` is healthy and `pnpm dev` shows
the `cms` server on `:3000`.
- **Payload can't connect to Postgres** — the DB exposes `:5432`; confirm
`docker compose ps` shows `cdrdpyj-db` healthy and `DATABASE_URI` matches.
- **Ports already in use**`5432`, `8000`, `3000`, `4321` must be free (or
change them in `.env` / the app dev scripts).
```

33
apps/cms/Dockerfile Normal file
View File

@ -0,0 +1,33 @@
# Payload CMS (apps/cms) — built from the monorepo root context.
# docker compose --profile apps build cms
FROM node:24-slim AS base
ENV PNPM_HOME=/pnpm
ENV PATH=$PNPM_HOME:$PATH
RUN corepack enable
# sharp / libvips runtime deps
RUN apt-get update && apt-get install -y --no-install-recommends \
openssl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /repo
# ---- Install dependencies (whole workspace, respecting the lockfile) ----
FROM base AS deps
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml* .npmrc* ./
COPY packages/typescript-config/package.json packages/typescript-config/
COPY apps/cms/package.json apps/cms/
COPY apps/web/package.json apps/web/
RUN pnpm install --frozen-lockfile || pnpm install
# ---- Build the CMS ----
FROM base AS build
COPY --from=deps /repo/node_modules ./node_modules
COPY . .
RUN pnpm --filter cms build
# ---- Runtime ----
FROM base AS runtime
ENV NODE_ENV=production
COPY --from=build /repo ./
WORKDIR /repo/apps/cms
EXPOSE 3000
CMD ["pnpm", "start"]

View File

@ -0,0 +1,38 @@
import { dirname } from 'path'
import { fileURLToPath } from 'url'
import { FlatCompat } from '@eslint/eslintrc'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const compat = new FlatCompat({
baseDirectory: __dirname,
})
const eslintConfig = [
...compat.extends('next/core-web-vitals', 'next/typescript'),
{
rules: {
'@typescript-eslint/ban-ts-comment': 'warn',
'@typescript-eslint/no-empty-object-type': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': [
'warn',
{
vars: 'all',
args: 'after-used',
ignoreRestSiblings: false,
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^(_|ignore)',
},
],
},
},
{
ignores: ['.next/', 'src/payload-types.ts', 'src/payload-generated-schema.ts'],
},
]
export default eslintConfig

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" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

32
apps/cms/next.config.ts Normal file
View File

@ -0,0 +1,32 @@
import { withPayload } from '@payloadcms/next/withPayload'
import type { NextConfig } from 'next'
import path from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(__filename)
const nextConfig: NextConfig = {
images: {
localPatterns: [
{
pathname: '/api/media/file/**',
},
],
},
webpack: (webpackConfig) => {
webpackConfig.resolve.extensionAlias = {
'.cjs': ['.cts', '.cjs'],
'.js': ['.ts', '.tsx', '.js', '.jsx'],
'.mjs': ['.mts', '.mjs'],
}
return webpackConfig
},
turbopack: {
// Monorepo root so Turbopack resolves pnpm-hoisted packages correctly.
root: path.resolve(dirname, '../../'),
},
}
export default withPayload(nextConfig, { devBundleServerPackages: false })

47
apps/cms/package.json Normal file
View File

@ -0,0 +1,47 @@
{
"name": "cms",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "cross-env NODE_OPTIONS=--no-deprecation next dev",
"dev:portless": "cross-env NODE_OPTIONS=--no-deprecation portless cms next dev",
"devsafe": "rm -rf .next && cross-env NODE_OPTIONS=--no-deprecation next dev",
"build": "cross-env NODE_OPTIONS=--no-deprecation next build",
"start": "cross-env NODE_OPTIONS=--no-deprecation next start",
"typecheck": "tsc --noEmit",
"lint": "cross-env NODE_OPTIONS=--no-deprecation eslint .",
"generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types",
"generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap",
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
"clean": "rm -rf .next node_modules"
},
"dependencies": {
"@payloadcms/db-postgres": "3.87.0",
"@payloadcms/next": "3.87.0",
"@payloadcms/richtext-lexical": "3.87.0",
"@payloadcms/ui": "3.87.0",
"cross-env": "10.1.0",
"dotenv": "16.4.7",
"graphql": "^16.8.1",
"next": "16.2.7",
"payload": "3.87.0",
"react": "19.2.6",
"react-dom": "19.2.6",
"sharp": "0.34.2"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",
"@types/node": "^24.12.3",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"eslint": "^9.16.0",
"eslint-config-next": "16.2.7",
"portless": "^0.15.5",
"sass": "^1.83.0",
"typescript": "^5.7.3"
},
"engines": {
"node": ">=24.15.0"
}
}

View File

@ -0,0 +1,19 @@
import React from 'react'
import './styles.css'
export const metadata = {
description: 'A blank template using Payload in a Next.js app.',
title: 'Payload Blank Template',
}
export default async function RootLayout(props: { children: React.ReactNode }) {
const { children } = props
return (
<html lang="en">
<body>
<main>{children}</main>
</body>
</html>
)
}

View File

@ -0,0 +1,59 @@
import { headers as getHeaders } from 'next/headers.js'
import Image from 'next/image'
import { getPayload } from 'payload'
import React from 'react'
import { fileURLToPath } from 'url'
import config from '@/payload.config'
import './styles.css'
export default async function HomePage() {
const headers = await getHeaders()
const payloadConfig = await config
const payload = await getPayload({ config: payloadConfig })
const { user } = await payload.auth({ headers })
const fileURL = `vscode://file/${fileURLToPath(import.meta.url)}`
return (
<div className="home">
<div className="content">
<picture>
<source srcSet="https://raw.githubusercontent.com/payloadcms/payload/3.x/packages/ui/src/assets/payload-favicon.svg" />
<Image
alt="Payload Logo"
height={65}
src="https://raw.githubusercontent.com/payloadcms/payload/3.x/packages/ui/src/assets/payload-favicon.svg"
width={65}
/>
</picture>
{!user && <h1>Welcome to your new project.</h1>}
{user && <h1>Welcome back, {user.email}</h1>}
<div className="links">
<a
className="admin"
href={payloadConfig.routes.admin}
rel="noopener noreferrer"
target="_blank"
>
Go to admin panel
</a>
<a
className="docs"
href="https://payloadcms.com/docs"
rel="noopener noreferrer"
target="_blank"
>
Documentation
</a>
</div>
</div>
<div className="footer">
<p>Update this page by editing</p>
<a className="codeLink" href={fileURL}>
<code>app/(frontend)/page.tsx</code>
</a>
</div>
</div>
)
}

View File

@ -0,0 +1,164 @@
:root {
--font-mono: 'Roboto Mono', monospace;
}
* {
box-sizing: border-box;
}
html {
font-size: 18px;
line-height: 32px;
background: rgb(0, 0, 0);
-webkit-font-smoothing: antialiased;
}
html,
body,
#app {
height: 100%;
}
body {
font-family: system-ui;
font-size: 18px;
line-height: 32px;
margin: 0;
color: rgb(1000, 1000, 1000);
@media (max-width: 1024px) {
font-size: 15px;
line-height: 24px;
}
}
img {
max-width: 100%;
height: auto;
display: block;
}
h1 {
margin: 40px 0;
font-size: 64px;
line-height: 70px;
font-weight: bold;
@media (max-width: 1024px) {
margin: 24px 0;
font-size: 42px;
line-height: 42px;
}
@media (max-width: 768px) {
font-size: 38px;
line-height: 38px;
}
@media (max-width: 400px) {
font-size: 32px;
line-height: 32px;
}
}
p {
margin: 24px 0;
@media (max-width: 1024px) {
margin: calc(var(--base) * 0.75) 0;
}
}
a {
color: currentColor;
&:focus {
opacity: 0.8;
outline: none;
}
&:active {
opacity: 0.7;
outline: none;
}
}
svg {
vertical-align: middle;
}
.home {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
height: 100vh;
padding: 45px;
max-width: 1024px;
margin: 0 auto;
overflow: hidden;
@media (max-width: 400px) {
padding: 24px;
}
.content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-grow: 1;
h1 {
text-align: center;
}
}
.links {
display: flex;
align-items: center;
gap: 12px;
a {
text-decoration: none;
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.admin {
color: rgb(0, 0, 0);
background: rgb(1000, 1000, 1000);
border: 1px solid rgb(0, 0, 0);
}
.docs {
color: rgb(1000, 1000, 1000);
background: rgb(0, 0, 0);
border: 1px solid rgb(1000, 1000, 1000);
}
}
.footer {
display: flex;
align-items: center;
gap: 8px;
@media (max-width: 1024px) {
flex-direction: column;
gap: 6px;
}
p {
margin: 0;
}
.codeLink {
text-decoration: none;
padding: 0 0.5rem;
background: rgb(60, 60, 60);
border-radius: 4px;
}
}
}

View File

@ -0,0 +1,24 @@
/* 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<{
[key: string]: string | string[]
}>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const NotFound = ({ params, searchParams }: Args) =>
NotFoundPage({ config, params, searchParams, importMap })
export default NotFound

View File

@ -0,0 +1,24 @@
/* 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<{
[key: string]: string | string[]
}>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const Page = ({ params, searchParams }: Args) =>
RootPage({ config, params, searchParams, importMap })
export default Page

View File

@ -0,0 +1,52 @@
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 import('payload').ImportMap */
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,7 @@
/* 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 { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes'
export const GET = GRAPHQL_PLAYGROUND_GET(config)

View File

@ -0,0 +1,8 @@
/* 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, REST_OPTIONS } from '@payloadcms/next/routes'
export const POST = GRAPHQL_POST(config)
export const OPTIONS = REST_OPTIONS(config)

View File

@ -0,0 +1 @@
// Add your custom admin panel styles here.

View File

@ -0,0 +1,31 @@
/* 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,16 @@
import type { CollectionConfig } from 'payload'
export const Media: CollectionConfig = {
slug: 'media',
access: {
read: () => true,
},
fields: [
{
name: 'alt',
type: 'text',
required: true,
},
],
upload: true,
}

View File

@ -0,0 +1,51 @@
import type { CollectionConfig } from 'payload'
export const Posts: CollectionConfig = {
slug: 'posts',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'slug', 'publishedAt', 'updatedAt'],
},
access: {
// Public read so the Astro frontend can fetch content via the REST API.
read: () => true,
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'slug',
type: 'text',
required: true,
unique: true,
index: true,
admin: {
description: 'URL-friendly identifier used by the frontend.',
},
},
{
name: 'excerpt',
type: 'textarea',
},
{
name: 'content',
type: 'richText',
},
{
name: 'coverImage',
type: 'upload',
relationTo: 'media',
},
{
name: 'publishedAt',
type: 'date',
admin: {
position: 'sidebar',
date: { pickerAppearance: 'dayAndTime' },
},
},
],
}

View File

@ -0,0 +1,14 @@
import type { CollectionConfig } from 'payload'
export const Users: CollectionConfig = {
slug: 'users',
admin: {
useAsTitle: 'email',
},
auth: true,
fields: [
// Email added by default
// Add more fields as needed
],
versions: false,
}

View File

@ -0,0 +1,391 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
/**
* Supported timezones in IANA format.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "supportedTimezones".
*/
export type SupportedTimezones =
| 'Pacific/Midway'
| 'Pacific/Niue'
| 'Pacific/Honolulu'
| 'Pacific/Rarotonga'
| 'America/Anchorage'
| 'Pacific/Gambier'
| 'America/Los_Angeles'
| 'America/Tijuana'
| 'America/Denver'
| 'America/Phoenix'
| 'America/Chicago'
| 'America/Guatemala'
| 'America/New_York'
| 'America/Bogota'
| 'America/Caracas'
| 'America/Santiago'
| 'America/Buenos_Aires'
| 'America/Sao_Paulo'
| 'Atlantic/South_Georgia'
| 'Atlantic/Azores'
| 'Atlantic/Cape_Verde'
| 'Europe/London'
| 'Europe/Berlin'
| 'Africa/Lagos'
| 'Europe/Athens'
| 'Africa/Cairo'
| 'Europe/Moscow'
| 'Asia/Riyadh'
| 'Asia/Dubai'
| 'Asia/Baku'
| 'Asia/Karachi'
| 'Asia/Tashkent'
| 'Asia/Calcutta'
| 'Asia/Dhaka'
| 'Asia/Almaty'
| 'Asia/Jakarta'
| 'Asia/Bangkok'
| 'Asia/Shanghai'
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
| 'Pacific/Auckland'
| 'Pacific/Fiji';
export interface Config {
auth: {
users: UserAuthOperations;
};
blocks: {};
collections: {
users: User;
media: Media;
posts: Post;
'payload-kv': PayloadKv;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
collectionsJoins: {};
collectionsSelect: {
users: UsersSelect<false> | UsersSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
posts: PostsSelect<false> | PostsSelect<true>;
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: number;
};
fallbackLocale: null;
globals: {};
globalsSelect: {};
locale: null;
widgets: {
collections: CollectionsWidget;
};
user: User;
jobs: {
tasks: unknown;
workflows: unknown;
};
}
export interface UserAuthOperations {
forgotPassword: {
email: string;
password: string;
};
login: {
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: number;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
sessions?:
| {
id: string;
createdAt?: string | null;
expiresAt: string;
}[]
| null;
password?: string | null;
collection: 'users';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media".
*/
export interface Media {
id: number;
alt: string;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts".
*/
export interface Post {
id: number;
title: string;
/**
* URL-friendly identifier used by the frontend.
*/
slug: string;
excerpt?: string | null;
content?: {
root: {
type: string;
children: {
type: any;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
coverImage?: (number | null) | Media;
publishedAt?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv".
*/
export interface PayloadKv {
id: number;
key: string;
data:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: number;
document?:
| ({
relationTo: 'users';
value: number | User;
} | null)
| ({
relationTo: 'media';
value: number | Media;
} | null)
| ({
relationTo: 'posts';
value: number | Post;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: number | User;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: number;
user: {
relationTo: 'users';
value: number | User;
};
key?: string | null;
value?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: number;
name?: string | null;
batch?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".
*/
export interface UsersSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
email?: T;
resetPasswordToken?: T;
resetPasswordExpiration?: T;
salt?: T;
hash?: T;
loginAttempts?: T;
lockUntil?: T;
sessions?:
| T
| {
id?: T;
createdAt?: T;
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media_select".
*/
export interface MediaSelect<T extends boolean = true> {
alt?: T;
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts_select".
*/
export interface PostsSelect<T extends boolean = true> {
title?: T;
slug?: T;
excerpt?: T;
content?: T;
coverImage?: T;
publishedAt?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv_select".
*/
export interface PayloadKvSelect<T extends boolean = true> {
key?: T;
data?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".
*/
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
document?: T;
globalSlug?: T;
user?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences_select".
*/
export interface PayloadPreferencesSelect<T extends boolean = true> {
user?: T;
key?: T;
value?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations_select".
*/
export interface PayloadMigrationsSelect<T extends boolean = true> {
name?: T;
batch?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "collections_widget".
*/
export interface CollectionsWidget {
data?: {
[k: string]: unknown;
};
width: 'full';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".
*/
export interface Auth {
[k: string]: unknown;
}
declare module 'payload' {
export interface GeneratedTypes extends Config {}
}

View File

@ -0,0 +1,44 @@
import { postgresAdapter } from '@payloadcms/db-postgres'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { config as loadEnv } from 'dotenv'
import path from 'path'
import { buildConfig } from 'payload'
import { fileURLToPath } from 'url'
import sharp from 'sharp'
import { Users } from './collections/Users'
import { Media } from './collections/Media'
import { Posts } from './collections/Posts'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
// Load the single source-of-truth env from the monorepo root (../../../.env),
// then let a local apps/cms/.env (if present) override it.
loadEnv({ path: path.resolve(dirname, '../../../.env') })
loadEnv({ path: path.resolve(dirname, '../.env'), override: true })
export default buildConfig({
admin: {
user: Users.slug,
importMap: {
baseDir: path.resolve(dirname),
},
},
collections: [Users, Media, Posts],
editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET || '',
serverURL: process.env.PAYLOAD_PUBLIC_SERVER_URL || '',
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
// Payload stores its tables in a dedicated `payload` schema so it stays
// cleanly separated from Supabase's own objects in `public`.
db: postgresAdapter({
pool: {
connectionString: process.env.DATABASE_URI || '',
},
schemaName: 'payload',
}),
sharp,
})

45
apps/cms/tsconfig.json Normal file
View File

@ -0,0 +1,45 @@
{
"compilerOptions": {
"baseUrl": ".",
"lib": [
"DOM",
"DOM.Iterable",
"ES2022"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
],
"@payload-config": [
"./src/payload.config.ts"
]
},
"target": "ES2022"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

View File

@ -0,0 +1 @@
export default new Map();

View File

@ -0,0 +1 @@
export default new Map();

199
apps/web/.astro/content.d.ts vendored Normal file
View File

@ -0,0 +1,199 @@
declare module 'astro:content' {
export interface RenderResult {
Content: import('astro/runtime/server/index.js').AstroComponentFactory;
headings: import('astro').MarkdownHeading[];
remarkPluginFrontmatter: Record<string, any>;
}
interface Render {
'.md': Promise<RenderResult>;
}
export interface RenderedContent {
html: string;
metadata?: {
imagePaths: Array<string>;
[key: string]: unknown;
};
}
}
declare module 'astro:content' {
type Flatten<T> = T extends { [K: string]: infer U } ? U : never;
export type CollectionKey = keyof AnyEntryMap;
export type CollectionEntry<C extends CollectionKey> = Flatten<AnyEntryMap[C]>;
export type ContentCollectionKey = keyof ContentEntryMap;
export type DataCollectionKey = keyof DataEntryMap;
type AllValuesOf<T> = T extends any ? T[keyof T] : never;
type ValidContentEntrySlug<C extends keyof ContentEntryMap> = AllValuesOf<
ContentEntryMap[C]
>['slug'];
export type ReferenceDataEntry<
C extends CollectionKey,
E extends keyof DataEntryMap[C] = string,
> = {
collection: C;
id: E;
};
export type ReferenceContentEntry<
C extends keyof ContentEntryMap,
E extends ValidContentEntrySlug<C> | (string & {}) = string,
> = {
collection: C;
slug: E;
};
export type ReferenceLiveEntry<C extends keyof LiveContentConfig['collections']> = {
collection: C;
id: string;
};
/** @deprecated Use `getEntry` instead. */
export function getEntryBySlug<
C extends keyof ContentEntryMap,
E extends ValidContentEntrySlug<C> | (string & {}),
>(
collection: C,
// Note that this has to accept a regular string too, for SSR
entrySlug: E,
): E extends ValidContentEntrySlug<C>
? Promise<CollectionEntry<C>>
: Promise<CollectionEntry<C> | undefined>;
/** @deprecated Use `getEntry` instead. */
export function getDataEntryById<C extends keyof DataEntryMap, E extends keyof DataEntryMap[C]>(
collection: C,
entryId: E,
): Promise<CollectionEntry<C>>;
export function getCollection<C extends keyof AnyEntryMap, E extends CollectionEntry<C>>(
collection: C,
filter?: (entry: CollectionEntry<C>) => entry is E,
): Promise<E[]>;
export function getCollection<C extends keyof AnyEntryMap>(
collection: C,
filter?: (entry: CollectionEntry<C>) => unknown,
): Promise<CollectionEntry<C>[]>;
export function getLiveCollection<C extends keyof LiveContentConfig['collections']>(
collection: C,
filter?: LiveLoaderCollectionFilterType<C>,
): Promise<
import('astro').LiveDataCollectionResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>
>;
export function getEntry<
C extends keyof ContentEntryMap,
E extends ValidContentEntrySlug<C> | (string & {}),
>(
entry: ReferenceContentEntry<C, E>,
): E extends ValidContentEntrySlug<C>
? Promise<CollectionEntry<C>>
: Promise<CollectionEntry<C> | undefined>;
export function getEntry<
C extends keyof DataEntryMap,
E extends keyof DataEntryMap[C] | (string & {}),
>(
entry: ReferenceDataEntry<C, E>,
): E extends keyof DataEntryMap[C]
? Promise<DataEntryMap[C][E]>
: Promise<CollectionEntry<C> | undefined>;
export function getEntry<
C extends keyof ContentEntryMap,
E extends ValidContentEntrySlug<C> | (string & {}),
>(
collection: C,
slug: E,
): E extends ValidContentEntrySlug<C>
? Promise<CollectionEntry<C>>
: Promise<CollectionEntry<C> | undefined>;
export function getEntry<
C extends keyof DataEntryMap,
E extends keyof DataEntryMap[C] | (string & {}),
>(
collection: C,
id: E,
): E extends keyof DataEntryMap[C]
? string extends keyof DataEntryMap[C]
? Promise<DataEntryMap[C][E]> | undefined
: Promise<DataEntryMap[C][E]>
: Promise<CollectionEntry<C> | undefined>;
export function getLiveEntry<C extends keyof LiveContentConfig['collections']>(
collection: C,
filter: string | LiveLoaderEntryFilterType<C>,
): Promise<import('astro').LiveDataEntryResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>>;
/** Resolve an array of entry references from the same collection */
export function getEntries<C extends keyof ContentEntryMap>(
entries: ReferenceContentEntry<C, ValidContentEntrySlug<C>>[],
): Promise<CollectionEntry<C>[]>;
export function getEntries<C extends keyof DataEntryMap>(
entries: ReferenceDataEntry<C, keyof DataEntryMap[C]>[],
): Promise<CollectionEntry<C>[]>;
export function render<C extends keyof AnyEntryMap>(
entry: AnyEntryMap[C][string],
): Promise<RenderResult>;
export function reference<C extends keyof AnyEntryMap>(
collection: C,
): import('astro/zod').ZodEffects<
import('astro/zod').ZodString,
C extends keyof ContentEntryMap
? ReferenceContentEntry<C, ValidContentEntrySlug<C>>
: ReferenceDataEntry<C, keyof DataEntryMap[C]>
>;
// Allow generic `string` to avoid excessive type errors in the config
// if `dev` is not running to update as you edit.
// Invalid collection names will be caught at build time.
export function reference<C extends string>(
collection: C,
): import('astro/zod').ZodEffects<import('astro/zod').ZodString, never>;
type ReturnTypeOrOriginal<T> = T extends (...args: any[]) => infer R ? R : T;
type InferEntrySchema<C extends keyof AnyEntryMap> = import('astro/zod').infer<
ReturnTypeOrOriginal<Required<ContentConfig['collections'][C]>['schema']>
>;
type ContentEntryMap = {
};
type DataEntryMap = {
};
type AnyEntryMap = ContentEntryMap & DataEntryMap;
type ExtractLoaderTypes<T> = T extends import('astro/loaders').LiveLoader<
infer TData,
infer TEntryFilter,
infer TCollectionFilter,
infer TError
>
? { data: TData; entryFilter: TEntryFilter; collectionFilter: TCollectionFilter; error: TError }
: { data: never; entryFilter: never; collectionFilter: never; error: never };
type ExtractDataType<T> = ExtractLoaderTypes<T>['data'];
type ExtractEntryFilterType<T> = ExtractLoaderTypes<T>['entryFilter'];
type ExtractCollectionFilterType<T> = ExtractLoaderTypes<T>['collectionFilter'];
type ExtractErrorType<T> = ExtractLoaderTypes<T>['error'];
type LiveLoaderDataType<C extends keyof LiveContentConfig['collections']> =
LiveContentConfig['collections'][C]['schema'] extends undefined
? ExtractDataType<LiveContentConfig['collections'][C]['loader']>
: import('astro/zod').infer<
Exclude<LiveContentConfig['collections'][C]['schema'], undefined>
>;
type LiveLoaderEntryFilterType<C extends keyof LiveContentConfig['collections']> =
ExtractEntryFilterType<LiveContentConfig['collections'][C]['loader']>;
type LiveLoaderCollectionFilterType<C extends keyof LiveContentConfig['collections']> =
ExtractCollectionFilterType<LiveContentConfig['collections'][C]['loader']>;
type LiveLoaderErrorType<C extends keyof LiveContentConfig['collections']> = ExtractErrorType<
LiveContentConfig['collections'][C]['loader']
>;
export type ContentConfig = typeof import("../src/content.config.mjs");
export type LiveContentConfig = never;
}

View File

@ -0,0 +1 @@
[["Map",1,2],"meta::meta",["Map",3,4,5,6],"astro-version","5.18.2","astro-config-digest","{\"root\":{},\"srcDir\":{},\"publicDir\":{},\"outDir\":{},\"cacheDir\":{},\"compressHTML\":true,\"base\":\"/\",\"trailingSlash\":\"ignore\",\"output\":\"server\",\"scopedStyleStrategy\":\"attribute\",\"build\":{\"format\":\"directory\",\"client\":{},\"server\":{},\"assets\":\"_astro\",\"serverEntry\":\"entry.mjs\",\"redirects\":false,\"inlineStylesheets\":\"auto\",\"concurrency\":1},\"server\":{\"open\":false,\"host\":\"127.0.0.1\",\"port\":4610,\"streaming\":true,\"allowedHosts\":[]},\"redirects\":{},\"image\":{\"endpoint\":{\"route\":\"/_image\",\"entrypoint\":\"astro/assets/endpoint/dev\"},\"service\":{\"entrypoint\":\"astro/assets/services/sharp\",\"config\":{}},\"domains\":[],\"remotePatterns\":[],\"responsiveStyles\":false},\"devToolbar\":{\"enabled\":true},\"markdown\":{\"syntaxHighlight\":{\"type\":\"shiki\",\"excludeLangs\":[\"math\"]},\"shikiConfig\":{\"langs\":[],\"langAlias\":{},\"theme\":\"github-dark\",\"themes\":{},\"wrap\":false,\"transformers\":[]},\"remarkPlugins\":[],\"rehypePlugins\":[],\"remarkRehype\":{},\"gfm\":true,\"smartypants\":true},\"security\":{\"checkOrigin\":true,\"allowedDomains\":[],\"actionBodySizeLimit\":1048576},\"env\":{\"schema\":{},\"validateSecrets\":false},\"experimental\":{\"clientPrerender\":false,\"contentIntellisense\":false,\"headingIdCompat\":false,\"preserveScriptOrder\":false,\"liveContentCollections\":false,\"csp\":false,\"staticImportMetaEnv\":false,\"chromeDevtoolsWorkspace\":false,\"failOnPrerenderConflict\":false,\"svgo\":false},\"legacy\":{\"collections\":false},\"session\":{\"driver\":\"fs-lite\",\"options\":{\"base\":\"/Volumes/Data/Work/LGCC/cdrdpyj-turborepo/apps/web/node_modules/.astro/sessions\"}}}"]

View File

@ -0,0 +1,5 @@
{
"_variables": {
"lastUpdateCheck": 1785519132161
}
}

1
apps/web/.astro/types.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="astro/client" />

29
apps/web/Dockerfile Normal file
View File

@ -0,0 +1,29 @@
# Astro frontend (apps/web) — built from the monorepo root context.
# docker compose --profile apps build web
FROM node:24-slim AS base
ENV PNPM_HOME=/pnpm
ENV PATH=$PNPM_HOME:$PATH
RUN corepack enable
WORKDIR /repo
FROM base AS deps
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml* .npmrc* ./
COPY packages/typescript-config/package.json packages/typescript-config/
COPY apps/cms/package.json apps/cms/
COPY apps/web/package.json apps/web/
RUN pnpm install --frozen-lockfile || pnpm install
FROM base AS build
COPY --from=deps /repo/node_modules ./node_modules
COPY . .
RUN pnpm --filter web build
FROM base AS runtime
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=4321
COPY --from=build /repo/apps/web/dist ./dist
COPY --from=build /repo/apps/web/package.json ./package.json
COPY --from=build /repo/node_modules ./node_modules
EXPOSE 4321
CMD ["node", "./dist/server/entry.mjs"]

19
apps/web/astro.config.mjs Normal file
View File

@ -0,0 +1,19 @@
// @ts-check
import { defineConfig } from 'astro/config'
import node from '@astrojs/node'
import { fileURLToPath } from 'node:url'
// SSR so pages can fetch live content from Payload's REST API on each request.
export default defineConfig({
output: 'server',
adapter: node({ mode: 'standalone' }),
server: {
host: true,
port: Number(process.env.PORT) || 4321,
},
vite: {
// Read the single root .env (shared with docker-compose) instead of a
// per-app copy. PUBLIC_* vars are exposed to the client from here.
envDir: fileURLToPath(new URL('../../', import.meta.url)),
},
})

26
apps/web/package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "web",
"type": "module",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "portless web -- astro dev",
"dev:portless": "portless web -- astro dev",
"dev:plain": "astro dev",
"build": "astro build",
"preview": "astro preview",
"start": "node ./dist/server/entry.mjs",
"typecheck": "astro check",
"clean": "rm -rf dist .astro node_modules"
},
"dependencies": {
"@astrojs/node": "^9.1.3",
"@supabase/supabase-js": "^2.48.1",
"astro": "^5.4.2"
},
"devDependencies": {
"@astrojs/check": "^0.9.4",
"@repo/typescript-config": "workspace:*",
"typescript": "^5.7.3"
}
}

11
apps/web/src/env.d.ts vendored Normal file
View File

@ -0,0 +1,11 @@
/// <reference types="astro/client" />
interface ImportMetaEnv {
readonly PUBLIC_PAYLOAD_API_URL?: string
readonly PUBLIC_SUPABASE_URL?: string
readonly PUBLIC_SUPABASE_ANON_KEY?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

@ -0,0 +1,99 @@
---
interface Props {
title?: string
description?: string
}
const { title = 'cdrdpyj', description = 'Astro + Payload + Supabase' } = Astro.props
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content={description} />
<title>{title}</title>
</head>
<body>
<header class="site-header">
<a class="brand" href="/">cdrdpyj</a>
<nav>
<a href="/">Home</a>
<a href={(import.meta.env.PUBLIC_PAYLOAD_API_URL ?? '').replace(/\/api$/, '/admin')}>
Payload admin
</a>
</nav>
</header>
<main>
<slot />
</main>
<footer class="site-footer">
<span>Astro frontend · content from Payload CMS · backed by Supabase</span>
</footer>
<style is:global>
:root {
--bg: #0b0c10;
--panel: #15171e;
--text: #e8eaf0;
--muted: #9aa2b1;
--accent: #6ee7b7;
--border: #262a35;
}
* { box-sizing: border-box; }
html { color-scheme: dark; }
body {
margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
}
.site-header, .site-footer {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 1rem clamp(1rem, 5vw, 4rem);
border-bottom: 1px solid var(--border);
}
.site-footer {
border-top: 1px solid var(--border);
border-bottom: none;
color: var(--muted);
font-size: 0.875rem;
margin-top: 3rem;
}
.brand { font-weight: 700; letter-spacing: -0.01em; }
nav { display: flex; gap: 1rem; margin-left: auto; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
main { max-width: 760px; margin: 0 auto; padding: 2rem clamp(1rem, 5vw, 2rem); }
h1 { font-size: clamp(1.8rem, 4vw, 2.6rem); letter-spacing: -0.02em; }
.card {
display: block;
padding: 1.25rem 1.5rem;
margin: 0 0 1rem;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
color: var(--text);
}
.card:hover { border-color: var(--accent); text-decoration: none; }
.card h2 { margin: 0 0 0.25rem; font-size: 1.25rem; }
.muted { color: var(--muted); }
.empty {
padding: 2rem;
border: 1px dashed var(--border);
border-radius: 12px;
text-align: center;
color: var(--muted);
}
article img { max-width: 100%; border-radius: 12px; }
blockquote {
margin: 1rem 0; padding: 0.5rem 1rem;
border-left: 3px solid var(--accent); color: var(--muted);
}
code { background: #0006; padding: 0.1em 0.35em; border-radius: 4px; }
</style>
</body>
</html>

View File

@ -0,0 +1,78 @@
/**
* Minimal serializer for Payload's Lexical rich-text JSON -> HTML.
* Handles the common node types; unknown nodes fall back to their children.
*/
interface LexicalNode {
type?: string
tag?: string
text?: string
format?: number | string
listType?: string
fields?: { url?: string; newTab?: boolean }
children?: LexicalNode[]
[key: string]: unknown
}
const FORMAT = { bold: 1, italic: 2, strikethrough: 4, underline: 8, code: 16 }
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function renderText(node: LexicalNode): string {
let out = escapeHtml(node.text ?? '')
const f = typeof node.format === 'number' ? node.format : 0
if (f & FORMAT.code) out = `<code>${out}</code>`
if (f & FORMAT.bold) out = `<strong>${out}</strong>`
if (f & FORMAT.italic) out = `<em>${out}</em>`
if (f & FORMAT.underline) out = `<u>${out}</u>`
if (f & FORMAT.strikethrough) out = `<s>${out}</s>`
return out
}
function renderChildren(children?: LexicalNode[]): string {
if (!children) return ''
return children.map(renderNode).join('')
}
function renderNode(node: LexicalNode): string {
switch (node.type) {
case 'text':
return renderText(node)
case 'linebreak':
return '<br />'
case 'paragraph':
return `<p>${renderChildren(node.children)}</p>`
case 'heading': {
const tag = /^h[1-6]$/.test(node.tag ?? '') ? node.tag : 'h2'
return `<${tag}>${renderChildren(node.children)}</${tag}>`
}
case 'quote':
return `<blockquote>${renderChildren(node.children)}</blockquote>`
case 'list': {
const tag = node.listType === 'number' ? 'ol' : 'ul'
return `<${tag}>${renderChildren(node.children)}</${tag}>`
}
case 'listitem':
return `<li>${renderChildren(node.children)}</li>`
case 'link': {
const url = escapeHtml(node.fields?.url ?? '#')
const target = node.fields?.newTab ? ' target="_blank" rel="noopener noreferrer"' : ''
return `<a href="${url}"${target}>${renderChildren(node.children)}</a>`
}
default:
return renderChildren(node.children)
}
}
export function renderLexical(content: unknown): string {
if (!content || typeof content !== 'object') return ''
const root = (content as { root?: LexicalNode }).root
if (!root?.children) return ''
return renderChildren(root.children)
}

View File

@ -0,0 +1,72 @@
/**
* Tiny typed client for Payload's REST API.
*
* Server-side (SSR) requests prefer PAYLOAD_API_URL so that, inside Docker,
* the web container can reach the cms container directly (http://cms:3000/api).
* Falls back to the public URL used by the browser.
*/
const runtimeEnv = (typeof process !== 'undefined' && process.env) || {}
const API_URL =
runtimeEnv.PAYLOAD_API_URL || // container-to-container in Docker
runtimeEnv.PUBLIC_PAYLOAD_API_URL ||
import.meta.env.PUBLIC_PAYLOAD_API_URL || // dev (loaded from root .env by Vite)
'http://localhost:3000/api'
export interface Media {
id: string
url?: string
alt?: string
width?: number
height?: number
}
export interface Post {
id: string
title: string
slug: string
excerpt?: string
content?: unknown
coverImage?: Media | string | null
publishedAt?: string
updatedAt: string
createdAt: string
}
interface PaginatedDocs<T> {
docs: T[]
totalDocs: number
page: number
totalPages: number
}
async function apiGet<T>(path: string): Promise<T> {
const res = await fetch(`${API_URL}${path}`, {
headers: { 'content-type': 'application/json' },
})
if (!res.ok) {
throw new Error(`Payload API ${res.status} ${res.statusText} for ${path}`)
}
return (await res.json()) as T
}
export async function getPosts(limit = 100): Promise<Post[]> {
const data = await apiGet<PaginatedDocs<Post>>(
`/posts?depth=1&limit=${limit}&sort=-publishedAt`,
)
return data.docs
}
export async function getPostBySlug(slug: string): Promise<Post | null> {
const query = `/posts?depth=1&limit=1&where[slug][equals]=${encodeURIComponent(slug)}`
const data = await apiGet<PaginatedDocs<Post>>(query)
return data.docs[0] ?? null
}
export function mediaUrl(media: Post['coverImage']): string | null {
if (!media || typeof media === 'string') return null
if (!media.url) return null
// Payload returns root-relative URLs; make them absolute against the API host.
if (media.url.startsWith('http')) return media.url
const base = API_URL.replace(/\/api$/, '')
return `${base}${media.url}`
}

View File

@ -0,0 +1,44 @@
---
import Layout from '../layouts/Layout.astro'
import { getPosts, type Post } from '../lib/payload'
let posts: Post[] = []
let error: string | null = null
try {
posts = await getPosts()
} catch (e) {
error = e instanceof Error ? e.message : String(e)
}
const fmt = (d?: string) =>
d ? new Date(d).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : ''
---
<Layout title="cdrdpyj — Posts">
<h1>Posts</h1>
<p class="muted">Fetched live from the Payload CMS REST API.</p>
{error && (
<div class="empty">
<p><strong>Couldn't reach the CMS.</strong></p>
<p class="muted">{error}</p>
<p class="muted">Is Payload running? Try <code>pnpm dev</code> and check the CMS URL.</p>
</div>
)}
{!error && posts.length === 0 && (
<div class="empty">
<p>No posts yet.</p>
<p class="muted">Create one in the Payload admin, then refresh.</p>
</div>
)}
{posts.map((post) => (
<a class="card" href={`/posts/${post.slug}`}>
<h2>{post.title}</h2>
{post.publishedAt && <div class="muted">{fmt(post.publishedAt)}</div>}
{post.excerpt && <p class="muted">{post.excerpt}</p>}
</a>
))}
</Layout>

View File

@ -0,0 +1,29 @@
---
import Layout from '../../layouts/Layout.astro'
import { getPostBySlug, mediaUrl } from '../../lib/payload'
import { renderLexical } from '../../lib/lexical'
const { slug } = Astro.params
const post = slug ? await getPostBySlug(slug) : null
if (!post) {
return new Response('Not found', { status: 404 })
}
const cover = mediaUrl(post.coverImage)
const html = renderLexical(post.content)
const fmt = (d?: string) =>
d ? new Date(d).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : ''
---
<Layout title={`${post.title} — cdrdpyj`} description={post.excerpt}>
<p><a href="/">← All posts</a></p>
<article>
<h1>{post.title}</h1>
{post.publishedAt && <p class="muted">{fmt(post.publishedAt)}</p>}
{cover && <img src={cover} alt={post.title} />}
{post.excerpt && <p><em>{post.excerpt}</em></p>}
<Fragment set:html={html} />
</article>
</Layout>

5
apps/web/tsconfig.json Normal file
View File

@ -0,0 +1,5 @@
{
"extends": ["astro/tsconfigs/strict", "@repo/typescript-config/astro.json"],
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}

354
docker-compose.yml Normal file
View File

@ -0,0 +1,354 @@
# cdrdpyj — single-file dev stack
#
# Backend only (recommended, pair with `pnpm dev` + portless on the host):
# docker compose up -d
#
# Everything in Docker (backend + Astro + Payload):
# docker compose --profile apps up -d --build
#
# Studio dashboard: http://localhost:8000 (user/pass from .env)
# Postgres: postgresql://postgres:<POSTGRES_PASSWORD>@localhost:5432/postgres
#
# The Supabase services are a trimmed self-hosted stack (db, meta, studio,
# kong, auth, rest, storage, imgproxy). Realtime / Edge Functions / Supavisor
# pooler are intentionally omitted to keep local dev light; the DB is still
# initialised with the full official role/JWT setup so they can be added later.
name: cdrdpyj
services:
#####################################################################
# Postgres — the Supabase database (also used directly by Payload CMS)
#####################################################################
db:
container_name: cdrdpyj-db
image: supabase/postgres:17.6.1.136
restart: unless-stopped
ports:
# Exposed so host-run apps (Payload via portless) can connect.
- ${POSTGRES_PORT}:5432
volumes:
- ./supabase/volumes/db/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:Z
- ./supabase/volumes/db/webhooks.sql:/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql:Z
- ./supabase/volumes/db/roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:Z
- ./supabase/volumes/db/jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql:Z
# Pre-create the dedicated schema Payload CMS uses
- ./supabase/volumes/db/payload.sql:/docker-entrypoint-initdb.d/init-scripts/99-payload.sql:Z
- ./supabase/volumes/db/_supabase.sql:/docker-entrypoint-initdb.d/migrations/97-_supabase.sql:Z
- ./supabase/volumes/db/logs.sql:/docker-entrypoint-initdb.d/migrations/99-logs.sql:Z
- ./supabase/volumes/db/pooler.sql:/docker-entrypoint-initdb.d/migrations/99-pooler.sql:Z
- ./supabase/volumes/db/data:/var/lib/postgresql/data:Z
- db-config:/etc/postgresql-custom
healthcheck:
test: ["CMD", "pg_isready", "-U", "postgres", "-h", "localhost"]
interval: 5s
timeout: 5s
retries: 10
environment:
POSTGRES_HOST: /var/run/postgresql
PGPORT: ${POSTGRES_PORT}
POSTGRES_PORT: ${POSTGRES_PORT}
PGPASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
PGDATABASE: ${POSTGRES_DB}
POSTGRES_DB: ${POSTGRES_DB}
JWT_SECRET: ${JWT_SECRET}
JWT_EXP: ${JWT_EXPIRY}
command:
- postgres
- -c
- config_file=/etc/postgresql/postgresql.conf
- -c
- log_min_messages=fatal
#####################################################################
# postgres-meta — powers Studio's table/SQL editor
#####################################################################
meta:
container_name: cdrdpyj-meta
image: supabase/postgres-meta:v0.96.6
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
PG_META_PORT: 8080
PG_META_DB_HOST: ${POSTGRES_HOST}
PG_META_DB_PORT: ${POSTGRES_PORT}
PG_META_DB_NAME: ${POSTGRES_DB}
PG_META_DB_USER: postgres
PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD}
CRYPTO_KEY: ${PG_META_CRYPTO_KEY}
#####################################################################
# Studio — the Supabase dashboard
#####################################################################
studio:
container_name: cdrdpyj-studio
image: supabase/studio:2026.07.07-sha-a6a04f2
restart: unless-stopped
depends_on:
meta:
condition: service_started
healthcheck:
test:
[
"CMD-SHELL",
"node -e \"fetch('http://localhost:3000/api/platform/profile').then((r) => {if (r.status !== 200) throw new Error(r.status)})\""
]
timeout: 10s
interval: 5s
retries: 3
start_period: 20s
environment:
HOSTNAME: "0.0.0.0"
STUDIO_PG_META_URL: http://meta:8080
POSTGRES_PORT: ${POSTGRES_PORT}
POSTGRES_HOST: ${POSTGRES_HOST}
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_USER_READ_WRITE: postgres
PG_META_CRYPTO_KEY: ${PG_META_CRYPTO_KEY}
PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS}
DEFAULT_ORGANIZATION_NAME: ${STUDIO_DEFAULT_ORGANIZATION}
DEFAULT_PROJECT_NAME: ${STUDIO_DEFAULT_PROJECT}
OPENAI_API_KEY: ${OPENAI_API_KEY}
SUPABASE_URL: http://kong:8000
SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL}
SUPABASE_ANON_KEY: ${ANON_KEY}
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
AUTH_JWT_SECRET: ${JWT_SECRET}
SUPABASE_PUBLISHABLE_KEY: ${SUPABASE_PUBLISHABLE_KEY}
SUPABASE_SECRET_KEY: ${SUPABASE_SECRET_KEY}
ENABLED_FEATURES_LOGS_ALL: "false"
SNIPPETS_MANAGEMENT_FOLDER: /app/snippets
EDGE_FUNCTIONS_MANAGEMENT_FOLDER: /app/edge-functions
volumes:
- ./supabase/volumes/snippets:/app/snippets:z
- ./supabase/volumes/functions:/app/edge-functions:ro,z
#####################################################################
# Kong — API gateway (Auth / REST / Storage all sit behind :8000)
#####################################################################
kong:
container_name: cdrdpyj-kong
image: kong/kong:3.9.1
restart: unless-stopped
networks:
default:
aliases:
- api-gw
healthcheck:
test: ["CMD", "kong", "health"]
interval: 5s
timeout: 5s
retries: 5
depends_on:
studio:
condition: service_healthy
ports:
- ${KONG_HTTP_PORT}:8000/tcp
- ${KONG_HTTPS_PORT}:8443/tcp
volumes:
- ./supabase/volumes/api/kong.yml:/home/kong/temp.yml:ro,z
- ./supabase/volumes/api/kong-entrypoint.sh:/home/kong/kong-entrypoint.sh:ro,z
environment:
KONG_DATABASE: "off"
KONG_DECLARATIVE_CONFIG: /usr/local/kong/kong.yml
KONG_ROUTER_FLAVOR: expressions
KONG_DNS_ORDER: LAST,A,CNAME
KONG_DNS_NOT_FOUND_TTL: 1
KONG_DNS_VALID_TTL: 5
KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth,request-termination,ip-restriction,post-function
KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
KONG_PROXY_ACCESS_LOG: /dev/stdout combined
SUPABASE_ANON_KEY: ${ANON_KEY}
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
SUPABASE_PUBLISHABLE_KEY: ${SUPABASE_PUBLISHABLE_KEY:-}
SUPABASE_SECRET_KEY: ${SUPABASE_SECRET_KEY:-}
ANON_KEY_ASYMMETRIC: ${ANON_KEY_ASYMMETRIC:-}
SERVICE_ROLE_KEY_ASYMMETRIC: ${SERVICE_ROLE_KEY_ASYMMETRIC:-}
DASHBOARD_USERNAME: ${DASHBOARD_USERNAME}
DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD}
entrypoint: ["/bin/sh", "/home/kong/kong-entrypoint.sh"]
#####################################################################
# Auth (GoTrue)
#####################################################################
auth:
container_name: cdrdpyj-auth
image: supabase/gotrue:v2.189.0
restart: unless-stopped
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9999/health"]
timeout: 5s
interval: 5s
retries: 3
environment:
GOTRUE_API_HOST: 0.0.0.0
GOTRUE_API_PORT: 9999
API_EXTERNAL_URL: ${API_EXTERNAL_URL}
GOTRUE_DB_DRIVER: postgres
GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
GOTRUE_SITE_URL: ${SITE_URL}
GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS}
GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP}
GOTRUE_JWT_ADMIN_ROLES: service_role
GOTRUE_JWT_AUD: authenticated
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
GOTRUE_JWT_EXP: ${JWT_EXPIRY}
GOTRUE_JWT_SECRET: ${JWT_SECRET}
GOTRUE_JWT_ISSUER: ${API_EXTERNAL_URL}
GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP}
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${ENABLE_ANONYMOUS_USERS}
GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM}
GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL}
GOTRUE_SMTP_HOST: ${SMTP_HOST}
GOTRUE_SMTP_PORT: ${SMTP_PORT}
GOTRUE_SMTP_USER: ${SMTP_USER}
GOTRUE_SMTP_PASS: ${SMTP_PASS}
GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME}
GOTRUE_MAILER_URLPATHS_INVITE: ${MAILER_URLPATHS_INVITE}
GOTRUE_MAILER_URLPATHS_CONFIRMATION: ${MAILER_URLPATHS_CONFIRMATION}
GOTRUE_MAILER_URLPATHS_RECOVERY: ${MAILER_URLPATHS_RECOVERY}
GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: ${MAILER_URLPATHS_EMAIL_CHANGE}
GOTRUE_EXTERNAL_PHONE_ENABLED: ${ENABLE_PHONE_SIGNUP}
GOTRUE_SMS_AUTOCONFIRM: ${ENABLE_PHONE_AUTOCONFIRM}
#####################################################################
# REST (PostgREST) — auto REST API over the public schema
#####################################################################
rest:
container_name: cdrdpyj-rest
image: postgrest/postgrest:v14.12
restart: unless-stopped
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "postgrest", "--ready"]
interval: 5s
timeout: 5s
retries: 3
environment:
PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS}
PGRST_DB_MAX_ROWS: ${PGRST_DB_MAX_ROWS:-1000}
PGRST_DB_EXTRA_SEARCH_PATH: ${PGRST_DB_EXTRA_SEARCH_PATH:-public}
PGRST_DB_ANON_ROLE: anon
PGRST_ADMIN_SERVER_PORT: 3001
PGRST_ADMIN_SERVER_HOST: localhost
PGRST_JWT_SECRET: ${JWT_SECRET}
PGRST_DB_USE_LEGACY_GUCS: "false"
PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET}
PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY}
command: ["postgrest"]
#####################################################################
# Storage + imgproxy — file storage with on-the-fly image transforms
#####################################################################
storage:
container_name: cdrdpyj-storage
image: supabase/storage-api:v1.60.4
restart: unless-stopped
depends_on:
db:
condition: service_healthy
rest:
condition: service_started
imgproxy:
condition: service_started
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://storage:5000/status"]
timeout: 5s
interval: 5s
retries: 3
environment:
ANON_KEY: ${ANON_KEY}
SERVICE_KEY: ${SERVICE_ROLE_KEY}
POSTGREST_URL: http://rest:3000
AUTH_JWT_SECRET: ${JWT_SECRET}
DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
STORAGE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL}
REQUEST_ALLOW_X_FORWARDED_PATH: "true"
FILE_SIZE_LIMIT: 52428800
STORAGE_BACKEND: file
GLOBAL_S3_BUCKET: ${GLOBAL_S3_BUCKET}
FILE_STORAGE_BACKEND_PATH: /var/lib/storage
TENANT_ID: ${STORAGE_TENANT_ID}
REGION: ${REGION}
ENABLE_IMAGE_TRANSFORMATION: "true"
IMGPROXY_URL: http://imgproxy:5001
S3_PROTOCOL_ACCESS_KEY_ID: ${S3_PROTOCOL_ACCESS_KEY_ID}
S3_PROTOCOL_ACCESS_KEY_SECRET: ${S3_PROTOCOL_ACCESS_KEY_SECRET}
volumes:
- ./supabase/volumes/storage:/var/lib/storage:z
imgproxy:
container_name: cdrdpyj-imgproxy
image: darthsim/imgproxy:v3.30.1
restart: unless-stopped
volumes:
- ./supabase/volumes/storage:/var/lib/storage:z
healthcheck:
test: ["CMD", "imgproxy", "health"]
timeout: 5s
interval: 5s
retries: 3
environment:
IMGPROXY_BIND: ":5001"
IMGPROXY_LOCAL_FILESYSTEM_ROOT: /
IMGPROXY_USE_ETAG: "true"
IMGPROXY_AUTO_WEBP: ${IMGPROXY_AUTO_WEBP}
IMGPROXY_MAX_SRC_RESOLUTION: 16.8
#####################################################################
# Applications (opt-in: `docker compose --profile apps up`)
# The recommended dev loop runs these on the host via portless
# (`pnpm dev`) instead, for hot-reload + *.localhost URLs.
#####################################################################
cms:
container_name: cdrdpyj-cms
profiles: ["apps"]
build:
context: .
dockerfile: apps/cms/Dockerfile
restart: unless-stopped
depends_on:
db:
condition: service_healthy
ports:
- "3000:3000"
environment:
NODE_ENV: production
DATABASE_URI: postgresql://postgres:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
PAYLOAD_SECRET: ${PAYLOAD_SECRET}
PAYLOAD_PUBLIC_SERVER_URL: http://localhost:3000
web:
container_name: cdrdpyj-web
profiles: ["apps"]
build:
context: .
dockerfile: apps/web/Dockerfile
restart: unless-stopped
depends_on:
- cms
ports:
- "4321:4321"
environment:
NODE_ENV: production
HOST: 0.0.0.0
PORT: 4321
# Server-side fetches go container-to-container; browser links use localhost.
PAYLOAD_API_URL: http://cms:3000/api
PUBLIC_PAYLOAD_API_URL: http://localhost:3000/api
PUBLIC_SUPABASE_URL: ${SUPABASE_PUBLIC_URL}
PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY}
volumes:
db-config:

29
package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "cdrdpyj-turborepo",
"version": "0.0.0",
"private": true,
"packageManager": "pnpm@11.9.0",
"engines": {
"node": ">=24"
},
"scripts": {
"dev": "turbo run dev",
"dev:portless": "turbo run dev:portless",
"build": "turbo run build",
"start": "turbo run start",
"lint": "turbo run lint",
"typecheck": "turbo run typecheck",
"clean": "turbo run clean && rm -rf node_modules",
"supabase:up": "docker compose up -d",
"supabase:down": "docker compose down",
"supabase:logs": "docker compose logs -f",
"supabase:reset": "docker compose down -v && rm -rf supabase/volumes/db/data",
"stack:up": "docker compose --profile apps up -d --build",
"stack:down": "docker compose --profile apps down"
},
"devDependencies": {
"portless": "^0.15.5",
"turbo": "^2.5.4",
"typescript": "^5.7.3"
}
}

View File

@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Astro",
"extends": "./base.json",
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"jsx": "preserve",
"jsxImportSource": "astro",
"noEmit": true,
"allowJs": true
}
}

View File

@ -0,0 +1,20 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Base",
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"incremental": true
}
}

View File

@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Next.js",
"extends": "./base.json",
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "preserve",
"noEmit": true,
"allowJs": true,
"plugins": [{ "name": "next" }]
}
}

View File

@ -0,0 +1,10 @@
{
"name": "@repo/typescript-config",
"version": "0.0.0",
"private": true,
"files": [
"base.json",
"nextjs.json",
"astro.json"
]
}

10959
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

22
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,22 @@
packages:
- "apps/*"
- "packages/*"
# Native/postinstall build scripts we trust to run.
allowBuilds:
"@parcel/watcher": true
esbuild: true
sharp: true
unrs-resolver: true
# pnpm's minimum-release-age policy gates very recently published packages;
# these pins are allowed through so the Payload 3.87.0 set installs.
minimumReleaseAgeExclude:
- "@payloadcms/db-postgres@3.87.0"
- "@payloadcms/drizzle@3.87.0"
- "@payloadcms/graphql@3.87.0"
- "@payloadcms/next@3.87.0"
- "@payloadcms/richtext-lexical@3.87.0"
- "@payloadcms/translations@3.87.0"
- "@payloadcms/ui@3.87.0"
- payload@3.87.0

View File

@ -0,0 +1,49 @@
#!/bin/sh
# Custom entrypoint for Kong that builds Lua expressions for request-transformer
# and performs environment variable substitution in the declarative config.
# Build Lua expressions for translating opaque API keys to asymmetric JWTs.
# When opaque keys are not configured (empty env vars), expressions fall through
# to legacy-only behavior - just passing apikey as-is.
#
# Full expression logic (when opaque keys are configured):
# 1. If Authorization header exists and is NOT an sb_ key -> pass through (user session JWT)
# 2. If apikey matches secret key -> set service_role asymmetric JWT internal "API key"
# 3. If apikey matches publishable key -> set anon asymmetric JWT internal "API key"
# 4. Fallback: pass apikey as-is (legacy HS256 JWT)
if [ -n "$SUPABASE_SECRET_KEY" ] && [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
# Opaque keys configured -> full translation expressions
export LUA_AUTH_EXPR="\$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == '$SUPABASE_SECRET_KEY' and 'Bearer $SERVICE_ROLE_KEY_ASYMMETRIC') or (headers.apikey == '$SUPABASE_PUBLISHABLE_KEY' and 'Bearer $ANON_KEY_ASYMMETRIC') or headers.apikey)"
# Realtime WebSocket: reads from query_params.apikey (supabase-js sends apikey
# via query string), outputs to x-api-key header which Realtime checks first.
export LUA_RT_WS_EXPR="\$((query_params.apikey == '$SUPABASE_SECRET_KEY' and '$SERVICE_ROLE_KEY_ASYMMETRIC') or (query_params.apikey == '$SUPABASE_PUBLISHABLE_KEY' and '$ANON_KEY_ASYMMETRIC') or query_params.apikey)"
else
# Legacy API keys, not sb_ API keys -> pass apikey through unchanged
export LUA_AUTH_EXPR="\$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or headers.apikey)"
export LUA_RT_WS_EXPR="\$(query_params.apikey)"
fi
# Substitute environment variables in the Kong declarative config.
# Uses awk instead of eval/echo to preserve YAML quoting (eval strips double
# quotes, breaking "Header: value" patterns that YAML parses as mappings).
awk '{
result = ""
rest = $0
while (match(rest, /\$[A-Za-z_][A-Za-z_0-9]*/)) {
varname = substr(rest, RSTART + 1, RLENGTH - 1)
if (varname in ENVIRON) {
result = result substr(rest, 1, RSTART - 1) ENVIRON[varname]
} else {
result = result substr(rest, 1, RSTART + RLENGTH - 1)
}
rest = substr(rest, RSTART + RLENGTH)
}
print result rest
}' /home/kong/temp.yml > "$KONG_DECLARATIVE_CONFIG"
# Remove empty key-auth credentials (unconfigured opaque keys)
sed -i '/^[[:space:]]*- key:[[:space:]]*$/d' "$KONG_DECLARATIVE_CONFIG"
exec /entrypoint.sh kong docker-start

View File

@ -0,0 +1,467 @@
_format_version: '2.1'
_transform: true
###
### Consumers / Users
###
consumers:
- username: DASHBOARD
- username: anon
keyauth_credentials:
- key: $SUPABASE_ANON_KEY
- key: $SUPABASE_PUBLISHABLE_KEY
- username: service_role
keyauth_credentials:
- key: $SUPABASE_SERVICE_KEY
- key: $SUPABASE_SECRET_KEY
###
### Access Control List
###
acls:
- consumer: anon
group: anon
- consumer: service_role
group: admin
###
### Dashboard credentials
###
basicauth_credentials:
- consumer: DASHBOARD
username: '$DASHBOARD_USERNAME'
password: '$DASHBOARD_PASSWORD'
###
### API Routes
###
services:
## Open Auth routes
- name: auth-v1-open
_comment: 'Auth: /auth/v1/verify* -> http://auth:9999/verify*'
url: http://auth:9999/verify
routes:
- name: auth-v1-open
strip_path: true
paths:
- /auth/v1/verify
plugins:
- name: cors
- name: auth-v1-open-callback
_comment: 'Auth: /auth/v1/callback* -> http://auth:9999/callback*'
url: http://auth:9999/callback
routes:
- name: auth-v1-open-callback
strip_path: true
paths:
- /auth/v1/callback
plugins:
- name: cors
- name: auth-v1-open-authorize
_comment: 'Auth: /auth/v1/authorize* -> http://auth:9999/authorize*'
url: http://auth:9999/authorize
routes:
- name: auth-v1-open-authorize
strip_path: true
paths:
- /auth/v1/authorize
plugins:
- name: cors
- name: auth-v1-open-jwks
_comment: 'Auth: /auth/v1/.well-known/jwks.json -> http://auth:9999/.well-known/jwks.json'
url: http://auth:9999/.well-known/jwks.json
routes:
- name: auth-v1-open-jwks
strip_path: true
paths:
- /auth/v1/.well-known/jwks.json
plugins:
- name: cors
- name: auth-v1-open-sso-acs
url: "http://auth:9999/sso/saml/acs"
routes:
- name: auth-v1-open-sso-acs
strip_path: true
paths:
- /auth/v1/sso/saml/acs
plugins:
- name: cors
- name: auth-v1-open-sso-metadata
url: "http://auth:9999/sso/saml/metadata"
routes:
- name: auth-v1-open-sso-metadata
strip_path: true
paths:
- /auth/v1/sso/saml/metadata
plugins:
- name: cors
## Secure Auth routes
- name: auth-v1
_comment: 'Auth: /auth/v1/* -> http://auth:9999/*'
url: http://auth:9999/
routes:
- name: auth-v1-all
strip_path: true
paths:
- /auth/v1/
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
- name: request-transformer
config:
add:
headers:
- "Authorization: $LUA_AUTH_EXPR"
replace:
headers:
- "Authorization: $LUA_AUTH_EXPR"
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
## OpenAPI root - admin only
- name: rest-v1-openapi
_comment: 'PostgREST OpenAPI root: /rest/v1/ -> <http://rest:3000/> (admin only). See <https://github.com/orgs/supabase/discussions/42949>'
url: http://rest:3000/
routes:
- name: rest-v1-openapi-root
strip_path: true
expression: 'http.path == "/rest/v1/"'
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
- name: request-transformer
config:
add:
headers:
- "Authorization: $LUA_AUTH_EXPR"
replace:
headers:
- "Authorization: $LUA_AUTH_EXPR"
- name: acl
config:
hide_groups_header: true
allow:
- admin
## Secure PostgREST routes
- name: rest-v1
_comment: 'PostgREST: /rest/v1/* -> http://rest:3000/*'
url: http://rest:3000/
routes:
- name: rest-v1-all
strip_path: true
paths:
- /rest/v1/
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
- name: request-transformer
config:
add:
headers:
- "Authorization: $LUA_AUTH_EXPR"
replace:
headers:
- "Authorization: $LUA_AUTH_EXPR"
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
## Secure GraphQL routes
- name: graphql-v1
_comment: 'PostgREST: /graphql/v1/* -> http://rest:3000/rpc/graphql'
url: http://rest:3000/rpc/graphql
routes:
- name: graphql-v1-all
strip_path: true
paths:
- /graphql/v1
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
- name: request-transformer
config:
add:
headers:
- "Content-Profile: graphql_public"
- "Authorization: $LUA_AUTH_EXPR"
replace:
headers:
- "Authorization: $LUA_AUTH_EXPR"
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
## Secure Realtime routes
- name: realtime-v1-ws
_comment: 'Realtime: /realtime/v1/* -> ws://realtime:4000/socket/*'
url: http://realtime-dev.supabase-realtime:4000/socket
protocol: ws
routes:
- name: realtime-v1-ws
strip_path: true
paths:
- /realtime/v1/
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
- name: request-transformer
config:
add:
headers:
- "x-api-key:$LUA_RT_WS_EXPR"
replace:
querystring:
- "apikey:$LUA_RT_WS_EXPR"
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
# Block access to /realtime/v1/api/openapi
- name: realtime-v1-rest-openapi
_comment: 'Realtime: /realtime/v1/api/openapi/* -> http://realtime:4000/api/openapi/* (blocked)'
url: http://realtime-dev.supabase-realtime:4000/api/openapi
protocol: http
routes:
- name: realtime-v1-rest-openapi
strip_path: true
paths:
- /realtime/v1/api/openapi
plugins:
- name: request-termination
config:
status_code: 403
message: "Access is forbidden."
# Block access to /realtime/v1/api/tenants
- name: realtime-v1-rest-tenants
_comment: 'Realtime: /realtime/v1/api/tenants/* -> http://realtime:4000/api/tenants/* (blocked)'
url: http://realtime-dev.supabase-realtime:4000/api/tenants
protocol: http
routes:
- name: realtime-v1-rest-tenants
strip_path: true
paths:
- /realtime/v1/api/tenants
plugins:
- name: request-termination
config:
status_code: 403
message: "Access is forbidden."
- name: realtime-v1-rest
_comment: 'Realtime: /realtime/v1/api/* -> http://realtime:4000/api/*'
url: http://realtime-dev.supabase-realtime:4000/api
protocol: http
routes:
- name: realtime-v1-rest
strip_path: true
paths:
- /realtime/v1/api
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
- name: request-transformer
config:
add:
headers:
- "Authorization: $LUA_AUTH_EXPR"
replace:
headers:
- "Authorization: $LUA_AUTH_EXPR"
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
## Storage API endpoint (with Authorization header transformation).
## No key-auth - S3 protocol requests don't carry an apikey header.
##
## The request-transformer translates opaque API keys to asymmetric JWTs
## and passes through existing Authorization headers (user JWTs, AWS SigV4).
## When no Authorization or apikey header is present (S3 presigned URLs),
## the Lua expression evaluates to nil which Kong renders as empty string.
## The post-function strips this empty header so Storage's S3 signature
## verification falls through to query-parameter parsing.
- name: storage-v1
_comment: 'Storage: /storage/v1/* -> http://storage:5000/*'
url: http://storage:5000/
routes:
- name: storage-v1-all
strip_path: true
paths:
- /storage/v1/
plugins:
- name: cors
- name: request-transformer
config:
add:
headers:
- "Authorization: $LUA_AUTH_EXPR"
replace:
headers:
- "Authorization: $LUA_AUTH_EXPR"
- name: post-function
config:
access:
- |
local auth = kong.request.get_header("authorization")
if auth == nil or auth == "" or auth:find("^%s*$") then
kong.service.request.clear_header("authorization")
end
## Edge Functions routes
- name: functions-v1
_comment: 'Edge Functions: /functions/v1/* -> http://functions:9000/*'
url: http://functions:9000/
read_timeout: 150000
routes:
- name: functions-v1-all
strip_path: true
paths:
- /functions/v1/
plugins:
- name: cors
## OAuth 2.0 Authorization Server Metadata (RFC 8414)
- name: well-known-oauth
_comment: 'Auth: /.well-known/oauth-authorization-server -> http://auth:9999/.well-known/oauth-authorization-server'
url: http://auth:9999/.well-known/oauth-authorization-server
routes:
- name: well-known-oauth
strip_path: true
paths:
- /.well-known/oauth-authorization-server
plugins:
- name: cors
## Analytics routes
## Not used - Studio and Vector talk directly to analytics via Docker networking.
## If external access is needed, add routes with key-auth matching Logflare's x-api-key auth.
# - name: analytics-v1-api
# _comment: 'Analytics: /analytics/v1/api/endpoints/* -> http://logflare:4000/api/endpoints/*'
# url: http://analytics:4000/api/endpoints
# routes:
# - name: analytics-v1-api
# strip_path: true
# paths:
# - /analytics/v1/api/endpoints/
# - name: analytics-v1
# _comment: 'Analytics: /analytics/v1/* -> http://logflare:4000/*'
# url: http://analytics:4000/
# routes:
# - name: dashboard-v1-all
# strip_path: true
# paths:
# - /analytics/v1
# plugins:
# - name: cors
# - name: basic-auth
# config:
# hide_credentials: true
## Secure Database routes
- name: meta
_comment: 'pg-meta: /pg/* -> http://pg-meta:8080/*'
url: http://meta:8080/
routes:
- name: meta-all
strip_path: true
paths:
- /pg/
plugins:
- name: key-auth
config:
hide_credentials: false
- name: acl
config:
hide_groups_header: true
allow:
- admin
## Block access to /api/mcp
- name: mcp-blocker
_comment: 'Block direct access to /api/mcp'
url: http://studio:3000/api/mcp
routes:
- name: mcp-blocker-route
strip_path: true
paths:
- /api/mcp
plugins:
- name: request-termination
config:
status_code: 403
message: "Access is forbidden."
## MCP endpoint - local access
- name: mcp
_comment: 'MCP: /mcp -> http://studio:3000/api/mcp (local access)'
url: http://studio:3000/api/mcp
routes:
- name: mcp
strip_path: true
paths:
- /mcp
plugins:
# Block access to /mcp by default
- name: request-termination
config:
status_code: 403
message: "Access is forbidden."
# Enable local access (danger zone!)
# 1. Comment out the 'request-termination' section above
# 2. Uncomment the entire section below, including 'deny'
# 3. Add your local IPs to the 'allow' list
#- name: cors
#- name: ip-restriction
# config:
# allow:
# - 127.0.0.1
# - ::1
# deny: []
## Protected Dashboard - catch all remaining routes
- name: dashboard
_comment: 'Studio: /* -> http://studio:3000/*'
url: http://studio:3000/
routes:
- name: dashboard-all
strip_path: true
paths:
- /
plugins:
- name: cors
- name: basic-auth
config:
hide_credentials: true

View File

@ -0,0 +1,3 @@
\set pguser `echo "$POSTGRES_USER"`
CREATE DATABASE _supabase WITH OWNER :pguser;

View File

@ -0,0 +1,5 @@
\set jwt_secret `echo "$JWT_SECRET"`
\set jwt_exp `echo "$JWT_EXP"`
ALTER DATABASE postgres SET "app.settings.jwt_secret" TO :'jwt_secret';
ALTER DATABASE postgres SET "app.settings.jwt_exp" TO :'jwt_exp';

View File

@ -0,0 +1,6 @@
\set pguser `echo "$POSTGRES_USER"`
\c _supabase
create schema if not exists _analytics;
alter schema _analytics owner to :pguser;
\c postgres

View File

@ -0,0 +1,4 @@
-- Dedicated schema for Payload CMS tables, kept separate from Supabase's
-- own objects in `public`. Payload connects as `postgres` and will also
-- create this on first boot; pre-creating it here avoids any edge cases.
CREATE SCHEMA IF NOT EXISTS payload AUTHORIZATION postgres;

View File

@ -0,0 +1,6 @@
\set pguser `echo "$POSTGRES_USER"`
\c _supabase
create schema if not exists _supavisor;
alter schema _supavisor owner to :pguser;
\c postgres

View File

@ -0,0 +1,4 @@
\set pguser `echo "$POSTGRES_USER"`
create schema if not exists _realtime;
alter schema _realtime owner to :pguser;

View File

@ -0,0 +1,8 @@
-- NOTE: change to your own passwords for production environments
\set pgpass `echo "$POSTGRES_PASSWORD"`
ALTER USER authenticator WITH PASSWORD :'pgpass';
ALTER USER pgbouncer WITH PASSWORD :'pgpass';
ALTER USER supabase_auth_admin WITH PASSWORD :'pgpass';
ALTER USER supabase_functions_admin WITH PASSWORD :'pgpass';
ALTER USER supabase_storage_admin WITH PASSWORD :'pgpass';

View File

@ -0,0 +1,208 @@
BEGIN;
-- Create pg_net extension
CREATE EXTENSION IF NOT EXISTS pg_net SCHEMA extensions;
-- Create supabase_functions schema
CREATE SCHEMA supabase_functions AUTHORIZATION supabase_admin;
GRANT USAGE ON SCHEMA supabase_functions TO postgres, anon, authenticated, service_role;
ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON TABLES TO postgres, anon, authenticated, service_role;
ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON FUNCTIONS TO postgres, anon, authenticated, service_role;
ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON SEQUENCES TO postgres, anon, authenticated, service_role;
-- supabase_functions.migrations definition
CREATE TABLE supabase_functions.migrations (
version text PRIMARY KEY,
inserted_at timestamptz NOT NULL DEFAULT NOW()
);
-- Initial supabase_functions migration
INSERT INTO supabase_functions.migrations (version) VALUES ('initial');
-- supabase_functions.hooks definition
CREATE TABLE supabase_functions.hooks (
id bigserial PRIMARY KEY,
hook_table_id integer NOT NULL,
hook_name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT NOW(),
request_id bigint
);
CREATE INDEX supabase_functions_hooks_request_id_idx ON supabase_functions.hooks USING btree (request_id);
CREATE INDEX supabase_functions_hooks_h_table_id_h_name_idx ON supabase_functions.hooks USING btree (hook_table_id, hook_name);
COMMENT ON TABLE supabase_functions.hooks IS 'Supabase Functions Hooks: Audit trail for triggered hooks.';
CREATE FUNCTION supabase_functions.http_request()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
DECLARE
request_id bigint;
payload jsonb;
url text := TG_ARGV[0]::text;
method text := TG_ARGV[1]::text;
headers jsonb DEFAULT '{}'::jsonb;
params jsonb DEFAULT '{}'::jsonb;
timeout_ms integer DEFAULT 1000;
BEGIN
IF url IS NULL OR url = 'null' THEN
RAISE EXCEPTION 'url argument is missing';
END IF;
IF method IS NULL OR method = 'null' THEN
RAISE EXCEPTION 'method argument is missing';
END IF;
IF TG_ARGV[2] IS NULL OR TG_ARGV[2] = 'null' THEN
headers = '{"Content-Type": "application/json"}'::jsonb;
ELSE
headers = TG_ARGV[2]::jsonb;
END IF;
IF TG_ARGV[3] IS NULL OR TG_ARGV[3] = 'null' THEN
params = '{}'::jsonb;
ELSE
params = TG_ARGV[3]::jsonb;
END IF;
IF TG_ARGV[4] IS NULL OR TG_ARGV[4] = 'null' THEN
timeout_ms = 1000;
ELSE
timeout_ms = TG_ARGV[4]::integer;
END IF;
CASE
WHEN method = 'GET' THEN
SELECT http_get INTO request_id FROM net.http_get(
url,
params,
headers,
timeout_ms
);
WHEN method = 'POST' THEN
payload = jsonb_build_object(
'old_record', OLD,
'record', NEW,
'type', TG_OP,
'table', TG_TABLE_NAME,
'schema', TG_TABLE_SCHEMA
);
SELECT http_post INTO request_id FROM net.http_post(
url,
payload,
params,
headers,
timeout_ms
);
ELSE
RAISE EXCEPTION 'method argument % is invalid', method;
END CASE;
INSERT INTO supabase_functions.hooks
(hook_table_id, hook_name, request_id)
VALUES
(TG_RELID, TG_NAME, request_id);
RETURN NEW;
END
$function$;
-- Supabase super admin
DO
$$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_roles
WHERE rolname = 'supabase_functions_admin'
)
THEN
CREATE USER supabase_functions_admin NOINHERIT CREATEROLE LOGIN NOREPLICATION;
END IF;
END
$$;
GRANT ALL PRIVILEGES ON SCHEMA supabase_functions TO supabase_functions_admin;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA supabase_functions TO supabase_functions_admin;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA supabase_functions TO supabase_functions_admin;
ALTER USER supabase_functions_admin SET search_path = "supabase_functions";
ALTER table "supabase_functions".migrations OWNER TO supabase_functions_admin;
ALTER table "supabase_functions".hooks OWNER TO supabase_functions_admin;
ALTER function "supabase_functions".http_request() OWNER TO supabase_functions_admin;
GRANT supabase_functions_admin TO postgres;
-- Remove unused supabase_pg_net_admin role
DO
$$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_roles
WHERE rolname = 'supabase_pg_net_admin'
)
THEN
REASSIGN OWNED BY supabase_pg_net_admin TO supabase_admin;
DROP OWNED BY supabase_pg_net_admin;
DROP ROLE supabase_pg_net_admin;
END IF;
END
$$;
-- pg_net grants when extension is already enabled
DO
$$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_extension
WHERE extname = 'pg_net'
)
THEN
GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role;
ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER;
ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER;
ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net;
ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net;
REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC;
REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role;
GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role;
END IF;
END
$$;
-- Event trigger for pg_net
CREATE OR REPLACE FUNCTION extensions.grant_pg_net_access()
RETURNS event_trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_event_trigger_ddl_commands() AS ev
JOIN pg_extension AS ext
ON ev.objid = ext.oid
WHERE ext.extname = 'pg_net'
)
THEN
GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role;
ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER;
ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER;
ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net;
ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net;
REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC;
REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role;
GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role;
END IF;
END;
$$;
COMMENT ON FUNCTION extensions.grant_pg_net_access IS 'Grants access to pg_net';
DO
$$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_event_trigger
WHERE evtname = 'issue_pg_net_access'
) THEN
CREATE EVENT TRIGGER issue_pg_net_access ON ddl_command_end WHEN TAG IN ('CREATE EXTENSION')
EXECUTE PROCEDURE extensions.grant_pg_net_access();
END IF;
END
$$;
INSERT INTO supabase_functions.migrations (version) VALUES ('20210809183423_update_grants');
ALTER function supabase_functions.http_request() SECURITY DEFINER;
ALTER function supabase_functions.http_request() SET search_path = supabase_functions;
REVOKE ALL ON FUNCTION supabase_functions.http_request() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION supabase_functions.http_request() TO postgres, anon, authenticated, service_role;
COMMIT;

View File

View File

View File

41
turbo.json Normal file
View File

@ -0,0 +1,41 @@
{
"$schema": "https://turbo.build/schema.json",
"ui": "tui",
"globalDependencies": [".env"],
"globalEnv": [
"NODE_ENV",
"DATABASE_URI",
"PAYLOAD_SECRET",
"PAYLOAD_PUBLIC_SERVER_URL",
"PUBLIC_PAYLOAD_API_URL",
"SUPABASE_URL",
"SUPABASE_ANON_KEY",
"PORT"
],
"tasks": {
"dev": {
"cache": false,
"persistent": true
},
"dev:portless": {
"cache": false,
"persistent": true
},
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"start": {
"dependsOn": ["build"],
"cache": false,
"persistent": true
},
"typecheck": {
"dependsOn": ["^build"]
},
"lint": {},
"clean": {
"cache": false
}
}
}