feat(cards-server): Phase α — service skeleton + 16-table schema

Lays the foundation for the Cards marketplace + community backend per
apps/cards/docs/MARKETPLACE_PLAN.md. Phase α scope: skeleton, schema,
JWT auth wiring, health endpoint. Routes follow in Phase β.

Stack: Hono + Bun + Drizzle + Postgres + jose-JWKS — mirrors the
mana-credits service template.

Schema: pgSchema('cards') inside mana_platform, 16 tables across six
groups in src/db/schema/:
  - authors.ts: authors, author_follows
  - decks.ts: decks, deck_versions, deck_cards (with cards_card_type
    enum mirroring @mana/cards-core; per-card content_hash for
    smart-merge; CHECK constraint that paid decks must use
    Cards-Pro-Only-1.0 license)
  - tags.ts: tag_definitions (hierarchical), deck_tags
  - engagement.ts: deck_stars, deck_subscriptions, deck_forks
  - discussions.ts: deck_pull_requests (with diff jsonb +
    pr_status enum), card_discussions (bound to card_content_hash
    so threads survive version bumps)
  - moderation.ts: deck_reports (with category/status enums),
    ai_moderation_log
  - credits.ts: deck_purchases (snapshot price + author/mana split),
    author_payouts

Phase λ's co_learn_sessions intentionally not yet here.

Service plumbing:
  - src/index.ts: Hono entry on :3072, /health unauth, /v1 stub
  - src/config.ts: env loader with author-payout BPS knobs
    (defaults 80/20 standard, 90/10 verified-mana) and
    community-verified thresholds
  - src/middleware/jwt-auth.ts + service-auth.ts: JWKS validation
    + X-Service-Key check (mirrors mana-credits)
  - src/lib/errors.ts: HttpError + named subclasses
  - drizzle.config.ts pointing at mana_platform with schemaFilter:cards
  - drizzle/0000_*.sql committed so other devs / prod migration path
    has a reproducible starting point

Validated: tsc --noEmit clean, drizzle-kit generate produces
233-line SQL with all 16 tables + 5 enums + indexes.

Next (Phase α.4): Dockerfile + docker-compose + cloudflare tunnel
route cards-api.mana.how → :3072.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-07 16:01:08 +02:00
parent 33bc654238
commit a7b62ea8ae
26 changed files with 3407 additions and 140 deletions

377
pnpm-lock.yaml generated
View file

@ -141,14 +141,14 @@ importers:
version: link:../../../../packages/shared-landing-ui
astro:
specifier: ^5.16.0
version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
typescript:
specifier: ^5.9.2
version: 5.9.3
devDependencies:
'@astrojs/tailwind':
specifier: ^6.0.2
version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))
version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))
'@tailwindcss/typography':
specifier: ^0.5.18
version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))
@ -157,13 +157,13 @@ importers:
version: 20.19.39
eslint:
specifier: ^9.0.0
version: 9.39.4(jiti@2.6.1)
version: 9.39.4(jiti@1.21.7)
eslint-config-prettier:
specifier: ^9.1.0
version: 9.1.2(eslint@9.39.4(jiti@2.6.1))
version: 9.1.2(eslint@9.39.4(jiti@1.21.7))
eslint-plugin-astro:
specifier: ^1.0.0
version: 1.6.0(eslint@9.39.4(jiti@2.6.1))
version: 1.6.0(eslint@9.39.4(jiti@1.21.7))
prettier:
specifier: ^3.6.2
version: 3.8.1
@ -321,10 +321,10 @@ importers:
version: 3.7.2
'@astrojs/tailwind':
specifier: ^6.0.0
version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))
version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))
astro:
specifier: ^5.16.11
version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
tailwindcss:
specifier: ^3.4.17
version: 3.4.19(tsx@4.21.0)(yaml@2.8.3)
@ -2578,6 +2578,34 @@ importers:
specifier: ^2.0.0
version: 2.1.9(@types/node@24.12.2)(jsdom@29.0.2(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.1)
services/cards-server:
dependencies:
'@mana/shared-hono':
specifier: workspace:*
version: link:../../packages/shared-hono
drizzle-orm:
specifier: ^0.38.3
version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(@types/sql.js@1.4.11)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)(sql.js@1.14.1)
hono:
specifier: ^4.7.0
version: 4.12.12
jose:
specifier: ^6.1.2
version: 6.2.2
postgres:
specifier: ^3.4.5
version: 3.4.9
zod:
specifier: ^3.24.0
version: 3.25.76
devDependencies:
drizzle-kit:
specifier: ^0.30.4
version: 0.30.6
typescript:
specifier: ^5.9.3
version: 5.9.3
services/mana-ai:
dependencies:
'@mana/shared-ai':
@ -17341,6 +17369,16 @@ snapshots:
transitivePeerDependencies:
- ts-node
'@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
autoprefixer: 10.4.27(postcss@8.5.8)
postcss: 8.5.8
postcss-load-config: 4.0.2(postcss@8.5.8)
tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- ts-node
'@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
@ -17361,16 +17399,6 @@ snapshots:
transitivePeerDependencies:
- ts-node
'@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
autoprefixer: 10.4.27(postcss@8.5.8)
postcss: 8.5.8
postcss-load-config: 4.0.2(postcss@8.5.8)
tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- ts-node
'@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
@ -19542,6 +19570,11 @@ snapshots:
'@esbuild/win32-x64@0.27.7':
optional: true
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@1.21.7))':
dependencies:
eslint: 9.39.4(jiti@1.21.7)
eslint-visitor-keys: 3.4.3
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))':
dependencies:
eslint: 9.39.4(jiti@2.6.1)
@ -24650,6 +24683,108 @@ snapshots:
transitivePeerDependencies:
- supports-color
astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3):
dependencies:
'@astrojs/compiler': 2.13.1
'@astrojs/internal-helpers': 0.7.6
'@astrojs/markdown-remark': 6.3.11
'@astrojs/telemetry': 3.3.0
'@capsizecss/unpack': 4.0.0
'@oslojs/encoding': 1.1.0
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
acorn: 8.16.0
aria-query: 5.3.2
axobject-query: 4.1.0
boxen: 8.0.1
ci-info: 4.4.0
clsx: 2.1.1
common-ancestor-path: 1.0.1
cookie: 1.1.1
cssesc: 3.0.0
debug: 4.4.3
deterministic-object-hash: 2.0.2
devalue: 5.7.0
diff: 8.0.4
dlv: 1.1.3
dset: 3.1.4
es-module-lexer: 1.7.0
esbuild: 0.27.7
estree-walker: 3.0.3
flattie: 1.1.1
fontace: 0.4.1
github-slugger: 2.0.0
html-escaper: 3.0.3
http-cache-semantics: 4.2.0
import-meta-resolve: 4.2.0
js-yaml: 4.1.1
magic-string: 0.30.21
magicast: 0.5.2
mrmime: 2.0.1
neotraverse: 0.6.18
p-limit: 6.2.0
p-queue: 8.1.1
package-manager-detector: 1.6.0
piccolore: 0.1.3
picomatch: 4.0.4
prompts: 2.4.2
rehype: 13.0.2
semver: 7.7.4
shiki: 3.23.0
smol-toml: 1.6.1
svgo: 4.0.1
tinyexec: 1.0.4
tinyglobby: 0.2.15
tsconfck: 3.1.6(typescript@5.9.3)
ultrahtml: 1.6.0
unifont: 0.7.4
unist-util-visit: 5.1.0
unstorage: 1.17.5(@azure/storage-blob@12.31.0)(ioredis@5.10.1)
vfile: 6.0.3
vite: 6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitefu: 1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
xxhash-wasm: 1.1.0
yargs-parser: 21.1.1
yocto-spinner: 0.2.3
zod: 3.25.76
zod-to-json-schema: 3.25.2(zod@3.25.76)
zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76)
optionalDependencies:
sharp: 0.34.5
transitivePeerDependencies:
- '@azure/app-configuration'
- '@azure/cosmos'
- '@azure/data-tables'
- '@azure/identity'
- '@azure/keyvault-secrets'
- '@azure/storage-blob'
- '@capacitor/preferences'
- '@deno/kv'
- '@netlify/blobs'
- '@planetscale/database'
- '@types/node'
- '@upstash/redis'
- '@vercel/blob'
- '@vercel/functions'
- '@vercel/kv'
- aws4fetch
- db0
- idb-keyval
- ioredis
- jiti
- less
- lightningcss
- rollup
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- typescript
- uploadthing
- yaml
astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3):
dependencies:
'@astrojs/compiler': 2.13.1
@ -24854,108 +24989,6 @@ snapshots:
- uploadthing
- yaml
astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3):
dependencies:
'@astrojs/compiler': 2.13.1
'@astrojs/internal-helpers': 0.7.6
'@astrojs/markdown-remark': 6.3.11
'@astrojs/telemetry': 3.3.0
'@capsizecss/unpack': 4.0.0
'@oslojs/encoding': 1.1.0
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
acorn: 8.16.0
aria-query: 5.3.2
axobject-query: 4.1.0
boxen: 8.0.1
ci-info: 4.4.0
clsx: 2.1.1
common-ancestor-path: 1.0.1
cookie: 1.1.1
cssesc: 3.0.0
debug: 4.4.3
deterministic-object-hash: 2.0.2
devalue: 5.7.0
diff: 8.0.4
dlv: 1.1.3
dset: 3.1.4
es-module-lexer: 1.7.0
esbuild: 0.27.7
estree-walker: 3.0.3
flattie: 1.1.1
fontace: 0.4.1
github-slugger: 2.0.0
html-escaper: 3.0.3
http-cache-semantics: 4.2.0
import-meta-resolve: 4.2.0
js-yaml: 4.1.1
magic-string: 0.30.21
magicast: 0.5.2
mrmime: 2.0.1
neotraverse: 0.6.18
p-limit: 6.2.0
p-queue: 8.1.1
package-manager-detector: 1.6.0
piccolore: 0.1.3
picomatch: 4.0.4
prompts: 2.4.2
rehype: 13.0.2
semver: 7.7.4
shiki: 3.23.0
smol-toml: 1.6.1
svgo: 4.0.1
tinyexec: 1.0.4
tinyglobby: 0.2.15
tsconfck: 3.1.6(typescript@5.9.3)
ultrahtml: 1.6.0
unifont: 0.7.4
unist-util-visit: 5.1.0
unstorage: 1.17.5(@azure/storage-blob@12.31.0)(ioredis@5.10.1)
vfile: 6.0.3
vite: 6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitefu: 1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
xxhash-wasm: 1.1.0
yargs-parser: 21.1.1
yocto-spinner: 0.2.3
zod: 3.25.76
zod-to-json-schema: 3.25.2(zod@3.25.76)
zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76)
optionalDependencies:
sharp: 0.34.5
transitivePeerDependencies:
- '@azure/app-configuration'
- '@azure/cosmos'
- '@azure/data-tables'
- '@azure/identity'
- '@azure/keyvault-secrets'
- '@azure/storage-blob'
- '@capacitor/preferences'
- '@deno/kv'
- '@netlify/blobs'
- '@planetscale/database'
- '@types/node'
- '@upstash/redis'
- '@vercel/blob'
- '@vercel/functions'
- '@vercel/kv'
- aws4fetch
- db0
- idb-keyval
- ioredis
- jiti
- less
- lightningcss
- rollup
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- typescript
- uploadthing
- yaml
astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3):
dependencies:
'@astrojs/compiler': 2.13.1
@ -26783,6 +26816,11 @@ snapshots:
eslint: 9.39.4(jiti@2.6.1)
semver: 7.7.4
eslint-compat-utils@0.6.5(eslint@9.39.4(jiti@1.21.7)):
dependencies:
eslint: 9.39.4(jiti@1.21.7)
semver: 7.7.4
eslint-compat-utils@0.6.5(eslint@9.39.4(jiti@2.6.1)):
dependencies:
eslint: 9.39.4(jiti@2.6.1)
@ -26792,6 +26830,10 @@ snapshots:
dependencies:
eslint: 9.39.4(jiti@2.6.1)
eslint-config-prettier@9.1.2(eslint@9.39.4(jiti@1.21.7)):
dependencies:
eslint: 9.39.4(jiti@1.21.7)
eslint-config-prettier@9.1.2(eslint@9.39.4(jiti@2.6.1)):
dependencies:
eslint: 9.39.4(jiti@2.6.1)
@ -26836,6 +26878,20 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-plugin-astro@1.6.0(eslint@9.39.4(jiti@1.21.7)):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7))
'@jridgewell/sourcemap-codec': 1.5.5
'@typescript-eslint/types': 8.58.0
astro-eslint-parser: 1.4.0
eslint: 9.39.4(jiti@1.21.7)
eslint-compat-utils: 0.6.5(eslint@9.39.4(jiti@1.21.7))
globals: 16.5.0
postcss: 8.5.8
postcss-selector-parser: 7.1.1
transitivePeerDependencies:
- supports-color
eslint-plugin-astro@1.6.0(eslint@9.39.4(jiti@2.6.1)):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
@ -27009,6 +27065,47 @@ snapshots:
eslint-visitor-keys@5.0.1: {}
eslint@9.39.4(jiti@1.21.7):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7))
'@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.21.2
'@eslint/config-helpers': 0.4.2
'@eslint/core': 0.17.0
'@eslint/eslintrc': 3.3.5
'@eslint/js': 9.39.4
'@eslint/plugin-kit': 0.4.1
'@humanfs/node': 0.16.7
'@humanwhocodes/module-importer': 1.0.1
'@humanwhocodes/retry': 0.4.3
'@types/estree': 1.0.8
ajv: 6.14.0
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.3
escape-string-regexp: 4.0.0
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1
espree: 10.4.0
esquery: 1.7.0
esutils: 2.0.3
fast-deep-equal: 3.1.3
file-entry-cache: 8.0.0
find-up: 5.0.0
glob-parent: 6.0.2
ignore: 5.3.2
imurmurhash: 0.1.4
is-glob: 4.0.3
json-stable-stringify-without-jsonify: 1.0.1
lodash.merge: 4.6.2
minimatch: 3.1.5
natural-compare: 1.4.0
optionator: 0.9.4
optionalDependencies:
jiti: 1.21.7
transitivePeerDependencies:
- supports-color
eslint@9.39.4(jiti@2.6.1):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
@ -34087,6 +34184,23 @@ snapshots:
lightningcss: 1.32.0
terser: 5.46.1
vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
postcss: 8.5.8
rollup: 4.60.1
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 20.19.39
fsevents: 2.3.3
jiti: 1.21.7
lightningcss: 1.32.0
terser: 5.46.1
tsx: 4.21.0
yaml: 2.8.3
vite@6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
esbuild: 0.25.12
@ -34121,23 +34235,6 @@ snapshots:
tsx: 4.21.0
yaml: 2.8.3
vite@6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
postcss: 8.5.8
rollup: 4.60.1
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 24.12.2
fsevents: 2.3.3
jiti: 1.21.7
lightningcss: 1.32.0
terser: 5.46.1
tsx: 4.21.0
yaml: 2.8.3
vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
esbuild: 0.25.12
@ -34155,6 +34252,10 @@ snapshots:
tsx: 4.21.0
yaml: 2.8.3
vitefu@1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
optionalDependencies:
vite: 6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitefu@1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
optionalDependencies:
vite: 6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
@ -34163,10 +34264,6 @@ snapshots:
optionalDependencies:
vite: 6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitefu@1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
optionalDependencies:
vite: 6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitefu@1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
optionalDependencies:
vite: 6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)

5
services/cards-server/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules
.env
.env.local
.DS_Store
drizzle/meta/_journal.json.bak

View file

@ -0,0 +1,110 @@
# cards-server
Cards Marketplace + Community backend. Owns the published-deck side of
the Cards product (the standalone app at `cards.mana.how` is the
client). Phase α is the data skeleton — schema + bootstrap + JWT auth
in place; routes land progressively in Phase β onwards.
For the full design rationale, phasing, and contract decisions see
**[`apps/cards/docs/MARKETPLACE_PLAN.md`](../../apps/cards/docs/MARKETPLACE_PLAN.md)**.
## Tech Stack
| Layer | Tech |
|-------|------|
| Runtime | Bun |
| Framework | Hono |
| Database | PostgreSQL (`mana_platform.cards.*` schema) + Drizzle ORM |
| Auth | JWT via JWKS from mana-auth (EdDSA, jose) |
| Money | mana-credits — never Stripe directly |
## Port: 3072
## Quick Start
```bash
# Schema push (writes to local mana_platform DB)
bun run db:push
# Dev server with watch
bun run dev
# Type check
bun run type-check
```
## Database
Schema: **`cards`** inside the shared `mana_platform` DB. 17 tables across
six logical groups (matching the source files in `src/db/schema/`):
| File | Tables |
|------|--------|
| `authors.ts` | `cards.authors`, `cards.author_follows` |
| `decks.ts` | `cards.decks`, `cards.deck_versions`, `cards.deck_cards` |
| `tags.ts` | `cards.tag_definitions`, `cards.deck_tags` |
| `engagement.ts` | `cards.deck_stars`, `cards.deck_subscriptions`, `cards.deck_forks` |
| `discussions.ts` | `cards.deck_pull_requests`, `cards.card_discussions` |
| `moderation.ts` | `cards.deck_reports`, `cards.ai_moderation_log` |
| `credits.ts` | `cards.deck_purchases`, `cards.author_payouts` |
`co_learn_sessions` (Phase λ) is intentionally not yet in the schema.
Every table is created via `pgSchema('cards')` per the Mana convention.
## Auth model
Three middleware:
- `jwtAuth(authUrl)` — validates Bearer tokens via JWKS. Sets
`c.set('user', { userId, email, role })`. Used on every user-facing
`/v1/*` route.
- `serviceAuth(serviceKey)``X-Service-Key` check for service-to-
service calls (e.g. mana-credits-webhook → cards-server).
- (planned) `optionalAuth` — for routes that should respond
differently when the caller is signed-in but never reject anonymous.
## Phasing (per MARKETPLACE_PLAN §11)
| Phase | What lands | Where |
|-------|-----------|-------|
| **α** | Skeleton + schema + JWT + health | now |
| β | Author publish flow + AI-mod-first-pass | next |
| γ | Discovery (browse, search, tags, follow) | |
| δ | Subscribe + smart-merge | |
| ε | Pull-requests + discussions | |
| ζ | mana-credits marketplace | |
| η | Moderation + trust | |
| θ | Deep AI (auto-tags, embeddings, audio) | |
| ι | Optimisation + scale | |
## Environment Variables
```env
PORT=3072
DATABASE_URL=postgresql://mana:devpassword@localhost:5432/mana_platform
MANA_AUTH_URL=http://localhost:3001
MANA_CREDITS_URL=http://localhost:3061
MANA_LLM_URL=http://localhost:3025
MANA_MEDIA_URL=http://localhost:3015
MANA_NOTIFY_URL=http://localhost:3040
MANA_SERVICE_KEY=dev-service-key
CORS_ORIGINS=http://localhost:5173,http://localhost:5180
# Author payout splits (basis points). Defaults: 80/20 standard,
# 90/10 verified-mana.
AUTHOR_PAYOUT_STANDARD_BPS=8000
AUTHOR_PAYOUT_VERIFIED_BPS=9000
# Community-verified auto-thresholds.
COMMUNITY_VERIFY_STARS=500
COMMUNITY_VERIFY_FEATURED=3
COMMUNITY_VERIFY_SUBSCRIBERS=200
```
## Critical Rules
- **Never call Stripe directly.** All money flows through mana-credits.
- **`/v1` is the public contract** — additive-only changes within v1, breaking changes go to `/v2`.
- **Content-hash everything.** Per-card and per-version SHA-256s drive smart-merge, cache invalidation, and trust.
- **Subscribed Decks are unidirectional.** Author → Subscriber. Forks for the bidirectional case.
- **Verification is binary, not numeric.** Two flags (`verified_mana`, `verified_community`), the UI shows badges. Never invent a "trust score".

View file

@ -0,0 +1,6 @@
# cards-server
Backend for the Cards marketplace. See
[`CLAUDE.md`](./CLAUDE.md) for the technical overview and
[`apps/cards/docs/MARKETPLACE_PLAN.md`](../../apps/cards/docs/MARKETPLACE_PLAN.md)
for the full product plan.

View file

@ -0,0 +1,14 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema/*.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL || 'postgresql://mana:devpassword@localhost:5432/mana_platform',
},
// All Cards-marketplace tables live under the `cards` schema in the
// shared mana_platform DB — keeps the marketplace state next to the
// rest of the per-app data instead of creating yet another DB.
schemaFilter: ['cards'],
});

View file

@ -0,0 +1,234 @@
CREATE SCHEMA "cards";
--> statement-breakpoint
CREATE TYPE "public"."cards_card_type" AS ENUM('basic', 'basic-reverse', 'cloze', 'type-in', 'image-occlusion', 'audio', 'multiple-choice');--> statement-breakpoint
CREATE TYPE "public"."cards_pr_status" AS ENUM('open', 'merged', 'closed', 'rejected');--> statement-breakpoint
CREATE TYPE "public"."cards_ai_mod_verdict" AS ENUM('pass', 'flag', 'block');--> statement-breakpoint
CREATE TYPE "public"."cards_report_category" AS ENUM('spam', 'copyright', 'nsfw', 'misinformation', 'hate', 'other');--> statement-breakpoint
CREATE TYPE "public"."cards_report_status" AS ENUM('open', 'dismissed', 'actioned');--> statement-breakpoint
CREATE TABLE "cards"."author_follows" (
"follower_user_id" text NOT NULL,
"author_user_id" text NOT NULL,
"since" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "cards"."authors" (
"user_id" text PRIMARY KEY NOT NULL,
"slug" text NOT NULL,
"display_name" text NOT NULL,
"bio" text,
"avatar_url" text,
"joined_at" timestamp with time zone DEFAULT now() NOT NULL,
"pseudonym" boolean DEFAULT false NOT NULL,
"verified_mana" boolean DEFAULT false NOT NULL,
"verified_community" boolean DEFAULT false NOT NULL,
"banned_at" timestamp with time zone,
"banned_reason" text
);
--> statement-breakpoint
CREATE TABLE "cards"."author_payouts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"author_user_id" text NOT NULL,
"source_purchase_id" uuid NOT NULL,
"credits_granted" integer NOT NULL,
"credits_grant_id" text,
"granted_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "cards"."deck_purchases" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"buyer_user_id" text NOT NULL,
"deck_id" uuid NOT NULL,
"version_id" uuid NOT NULL,
"price_credits" integer NOT NULL,
"author_share" integer NOT NULL,
"mana_share" integer NOT NULL,
"credits_transaction" text,
"purchased_at" timestamp with time zone DEFAULT now() NOT NULL,
"refunded_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "cards"."deck_cards" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"version_id" uuid NOT NULL,
"type" "cards_card_type" NOT NULL,
"fields" jsonb NOT NULL,
"ord" integer NOT NULL,
"content_hash" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "cards"."deck_versions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"deck_id" uuid NOT NULL,
"semver" text NOT NULL,
"changelog" text,
"content_hash" text NOT NULL,
"card_count" integer NOT NULL,
"published_at" timestamp with time zone DEFAULT now() NOT NULL,
"deprecated_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "cards"."decks" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"slug" text NOT NULL,
"title" text NOT NULL,
"description" text,
"language" text,
"license" text DEFAULT 'Cards-Personal-Use-1.0' NOT NULL,
"price_credits" integer DEFAULT 0 NOT NULL,
"owner_user_id" text NOT NULL,
"latest_version_id" uuid,
"is_featured" boolean DEFAULT false NOT NULL,
"is_takedown" boolean DEFAULT false NOT NULL,
"takedown_at" timestamp with time zone,
"takedown_reason" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "decks_price_requires_license" CHECK (price_credits = 0 OR license = 'Cards-Pro-Only-1.0')
);
--> statement-breakpoint
CREATE TABLE "cards"."card_discussions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"card_content_hash" text NOT NULL,
"deck_id" uuid NOT NULL,
"author_user_id" text NOT NULL,
"parent_id" uuid,
"body" text NOT NULL,
"hidden" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "cards"."deck_pull_requests" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"deck_id" uuid NOT NULL,
"author_user_id" text NOT NULL,
"status" "cards_pr_status" DEFAULT 'open' NOT NULL,
"title" text NOT NULL,
"body" text,
"diff" jsonb NOT NULL,
"merged_into_version_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"resolved_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "cards"."deck_forks" (
"user_id" text NOT NULL,
"source_deck_id" uuid NOT NULL,
"source_version_id" uuid NOT NULL,
"forked_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "cards"."deck_stars" (
"user_id" text NOT NULL,
"deck_id" uuid NOT NULL,
"starred_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "cards"."deck_subscriptions" (
"user_id" text NOT NULL,
"deck_id" uuid NOT NULL,
"current_version_id" uuid,
"subscribed_at" timestamp with time zone DEFAULT now() NOT NULL,
"notify_updates" boolean DEFAULT true NOT NULL
);
--> statement-breakpoint
CREATE TABLE "cards"."deck_tags" (
"deck_id" uuid NOT NULL,
"tag_id" uuid NOT NULL
);
--> statement-breakpoint
CREATE TABLE "cards"."tag_definitions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"slug" text NOT NULL,
"name" text NOT NULL,
"parent_id" uuid,
"description" text,
"curated" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "cards"."ai_moderation_log" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"version_id" uuid NOT NULL,
"verdict" "cards_ai_mod_verdict" NOT NULL,
"categories" text[],
"model" text,
"rationale" text,
"human_reviewed" boolean DEFAULT false NOT NULL,
"human_overrode" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "cards"."deck_reports" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"deck_id" uuid NOT NULL,
"version_id" uuid,
"card_content_hash" text,
"reporter_user_id" text NOT NULL,
"category" "cards_report_category" NOT NULL,
"body" text,
"status" "cards_report_status" DEFAULT 'open' NOT NULL,
"resolved_by" text,
"resolved_at" timestamp with time zone,
"resolution_notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "cards"."author_follows" ADD CONSTRAINT "author_follows_author_user_id_authors_user_id_fk" FOREIGN KEY ("author_user_id") REFERENCES "cards"."authors"("user_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cards"."author_payouts" ADD CONSTRAINT "author_payouts_author_user_id_authors_user_id_fk" FOREIGN KEY ("author_user_id") REFERENCES "cards"."authors"("user_id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cards"."author_payouts" ADD CONSTRAINT "author_payouts_source_purchase_id_deck_purchases_id_fk" FOREIGN KEY ("source_purchase_id") REFERENCES "cards"."deck_purchases"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cards"."deck_purchases" ADD CONSTRAINT "deck_purchases_deck_id_decks_id_fk" FOREIGN KEY ("deck_id") REFERENCES "cards"."decks"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cards"."deck_purchases" ADD CONSTRAINT "deck_purchases_version_id_deck_versions_id_fk" FOREIGN KEY ("version_id") REFERENCES "cards"."deck_versions"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cards"."deck_cards" ADD CONSTRAINT "deck_cards_version_id_deck_versions_id_fk" FOREIGN KEY ("version_id") REFERENCES "cards"."deck_versions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cards"."deck_versions" ADD CONSTRAINT "deck_versions_deck_id_decks_id_fk" FOREIGN KEY ("deck_id") REFERENCES "cards"."decks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cards"."decks" ADD CONSTRAINT "decks_owner_user_id_authors_user_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "cards"."authors"("user_id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cards"."card_discussions" ADD CONSTRAINT "card_discussions_deck_id_decks_id_fk" FOREIGN KEY ("deck_id") REFERENCES "cards"."decks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cards"."deck_pull_requests" ADD CONSTRAINT "deck_pull_requests_deck_id_decks_id_fk" FOREIGN KEY ("deck_id") REFERENCES "cards"."decks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cards"."deck_pull_requests" ADD CONSTRAINT "deck_pull_requests_merged_into_version_id_deck_versions_id_fk" FOREIGN KEY ("merged_into_version_id") REFERENCES "cards"."deck_versions"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cards"."deck_forks" ADD CONSTRAINT "deck_forks_source_deck_id_decks_id_fk" FOREIGN KEY ("source_deck_id") REFERENCES "cards"."decks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cards"."deck_forks" ADD CONSTRAINT "deck_forks_source_version_id_deck_versions_id_fk" FOREIGN KEY ("source_version_id") REFERENCES "cards"."deck_versions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cards"."deck_stars" ADD CONSTRAINT "deck_stars_deck_id_decks_id_fk" FOREIGN KEY ("deck_id") REFERENCES "cards"."decks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cards"."deck_subscriptions" ADD CONSTRAINT "deck_subscriptions_deck_id_decks_id_fk" FOREIGN KEY ("deck_id") REFERENCES "cards"."decks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cards"."deck_subscriptions" ADD CONSTRAINT "deck_subscriptions_current_version_id_deck_versions_id_fk" FOREIGN KEY ("current_version_id") REFERENCES "cards"."deck_versions"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cards"."deck_tags" ADD CONSTRAINT "deck_tags_deck_id_decks_id_fk" FOREIGN KEY ("deck_id") REFERENCES "cards"."decks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cards"."deck_tags" ADD CONSTRAINT "deck_tags_tag_id_tag_definitions_id_fk" FOREIGN KEY ("tag_id") REFERENCES "cards"."tag_definitions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cards"."ai_moderation_log" ADD CONSTRAINT "ai_moderation_log_version_id_deck_versions_id_fk" FOREIGN KEY ("version_id") REFERENCES "cards"."deck_versions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cards"."deck_reports" ADD CONSTRAINT "deck_reports_deck_id_decks_id_fk" FOREIGN KEY ("deck_id") REFERENCES "cards"."decks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cards"."deck_reports" ADD CONSTRAINT "deck_reports_version_id_deck_versions_id_fk" FOREIGN KEY ("version_id") REFERENCES "cards"."deck_versions"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "author_follows_pk" ON "cards"."author_follows" USING btree ("follower_user_id","author_user_id");--> statement-breakpoint
CREATE INDEX "author_follows_author_idx" ON "cards"."author_follows" USING btree ("author_user_id");--> statement-breakpoint
CREATE INDEX "author_follows_follower_idx" ON "cards"."author_follows" USING btree ("follower_user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "authors_slug_idx" ON "cards"."authors" USING btree ("slug");--> statement-breakpoint
CREATE INDEX "authors_verified_idx" ON "cards"."authors" USING btree ("verified_mana","verified_community");--> statement-breakpoint
CREATE INDEX "author_payouts_author_idx" ON "cards"."author_payouts" USING btree ("author_user_id");--> statement-breakpoint
CREATE INDEX "author_payouts_purchase_idx" ON "cards"."author_payouts" USING btree ("source_purchase_id");--> statement-breakpoint
CREATE UNIQUE INDEX "deck_purchases_buyer_deck_idx" ON "cards"."deck_purchases" USING btree ("buyer_user_id","deck_id");--> statement-breakpoint
CREATE INDEX "deck_purchases_buyer_idx" ON "cards"."deck_purchases" USING btree ("buyer_user_id");--> statement-breakpoint
CREATE INDEX "deck_purchases_deck_idx" ON "cards"."deck_purchases" USING btree ("deck_id");--> statement-breakpoint
CREATE UNIQUE INDEX "deck_cards_version_ord_idx" ON "cards"."deck_cards" USING btree ("version_id","ord");--> statement-breakpoint
CREATE INDEX "deck_cards_hash_idx" ON "cards"."deck_cards" USING btree ("content_hash");--> statement-breakpoint
CREATE UNIQUE INDEX "deck_versions_deck_semver_idx" ON "cards"."deck_versions" USING btree ("deck_id","semver");--> statement-breakpoint
CREATE INDEX "deck_versions_deck_idx" ON "cards"."deck_versions" USING btree ("deck_id");--> statement-breakpoint
CREATE INDEX "deck_versions_hash_idx" ON "cards"."deck_versions" USING btree ("content_hash");--> statement-breakpoint
CREATE UNIQUE INDEX "decks_slug_idx" ON "cards"."decks" USING btree ("slug");--> statement-breakpoint
CREATE INDEX "decks_owner_idx" ON "cards"."decks" USING btree ("owner_user_id");--> statement-breakpoint
CREATE INDEX "decks_featured_idx" ON "cards"."decks" USING btree ("is_featured");--> statement-breakpoint
CREATE INDEX "card_discussions_hash_idx" ON "cards"."card_discussions" USING btree ("card_content_hash");--> statement-breakpoint
CREATE INDEX "card_discussions_deck_idx" ON "cards"."card_discussions" USING btree ("deck_id");--> statement-breakpoint
CREATE INDEX "card_discussions_parent_idx" ON "cards"."card_discussions" USING btree ("parent_id");--> statement-breakpoint
CREATE INDEX "deck_pull_requests_deck_idx" ON "cards"."deck_pull_requests" USING btree ("deck_id");--> statement-breakpoint
CREATE INDEX "deck_pull_requests_status_idx" ON "cards"."deck_pull_requests" USING btree ("deck_id","status");--> statement-breakpoint
CREATE INDEX "deck_pull_requests_author_idx" ON "cards"."deck_pull_requests" USING btree ("author_user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "deck_forks_pk" ON "cards"."deck_forks" USING btree ("user_id","source_deck_id","source_version_id");--> statement-breakpoint
CREATE INDEX "deck_forks_source_idx" ON "cards"."deck_forks" USING btree ("source_deck_id");--> statement-breakpoint
CREATE UNIQUE INDEX "deck_stars_pk" ON "cards"."deck_stars" USING btree ("user_id","deck_id");--> statement-breakpoint
CREATE INDEX "deck_stars_deck_idx" ON "cards"."deck_stars" USING btree ("deck_id");--> statement-breakpoint
CREATE UNIQUE INDEX "deck_subscriptions_pk" ON "cards"."deck_subscriptions" USING btree ("user_id","deck_id");--> statement-breakpoint
CREATE INDEX "deck_subscriptions_deck_idx" ON "cards"."deck_subscriptions" USING btree ("deck_id");--> statement-breakpoint
CREATE INDEX "deck_subscriptions_user_idx" ON "cards"."deck_subscriptions" USING btree ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "deck_tags_pk" ON "cards"."deck_tags" USING btree ("deck_id","tag_id");--> statement-breakpoint
CREATE INDEX "deck_tags_tag_idx" ON "cards"."deck_tags" USING btree ("tag_id");--> statement-breakpoint
CREATE UNIQUE INDEX "tag_definitions_slug_idx" ON "cards"."tag_definitions" USING btree ("slug");--> statement-breakpoint
CREATE INDEX "tag_definitions_parent_idx" ON "cards"."tag_definitions" USING btree ("parent_id");--> statement-breakpoint
CREATE INDEX "ai_moderation_log_version_idx" ON "cards"."ai_moderation_log" USING btree ("version_id");--> statement-breakpoint
CREATE INDEX "ai_moderation_log_verdict_idx" ON "cards"."ai_moderation_log" USING btree ("verdict");--> statement-breakpoint
CREATE INDEX "deck_reports_deck_idx" ON "cards"."deck_reports" USING btree ("deck_id");--> statement-breakpoint
CREATE INDEX "deck_reports_status_idx" ON "cards"."deck_reports" USING btree ("status");

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1778162392774,
"tag": "0000_condemned_wrecking_crew",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,27 @@
{
"name": "@mana/cards-server",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Cards marketplace + community backend. Owns published decks, versions, subscriptions, forks, pull-requests, discussions, moderation, and the credits-based author payout pipeline.",
"scripts": {
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts",
"type-check": "tsc --noEmit",
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@mana/shared-hono": "workspace:*",
"drizzle-orm": "^0.38.3",
"hono": "^4.7.0",
"jose": "^6.1.2",
"postgres": "^3.4.5",
"zod": "^3.24.0"
},
"devDependencies": {
"drizzle-kit": "^0.30.4",
"typescript": "^5.9.3"
}
}

View file

@ -0,0 +1,72 @@
/**
* Runtime config read once at startup, validated with sensible
* dev-friendly defaults but loud in prod when secrets are missing.
*/
export interface Config {
port: number;
databaseUrl: string;
manaAuthUrl: string;
manaCreditsUrl: string;
manaLlmUrl: string;
manaMediaUrl: string;
manaNotifyUrl: string;
serviceKey: string;
cors: { origins: string[] };
authorPayout: {
standardAuthorBps: number;
verifiedAuthorBps: number;
};
communityVerifiedThresholds: {
stars: number;
featuredDecks: number;
activeSubscribers: number;
};
}
function getEnv(key: string, fallback?: string): string {
const v = process.env[key];
if (v && v.length > 0) return v;
if (fallback !== undefined) return fallback;
throw new Error(`Missing required env var: ${key}`);
}
function getEnvNumber(key: string, fallback: number): number {
const v = process.env[key];
if (!v) return fallback;
const n = Number(v);
if (Number.isNaN(n)) throw new Error(`${key} is not a number: ${v}`);
return n;
}
export function loadConfig(): Config {
const inProd = process.env.NODE_ENV === 'production';
return {
port: getEnvNumber('PORT', 3072),
databaseUrl: getEnv(
'DATABASE_URL',
inProd ? undefined : 'postgresql://mana:devpassword@localhost:5432/mana_platform'
),
manaAuthUrl: getEnv('MANA_AUTH_URL', 'http://localhost:3001'),
manaCreditsUrl: getEnv('MANA_CREDITS_URL', 'http://localhost:3061'),
manaLlmUrl: getEnv('MANA_LLM_URL', 'http://localhost:3025'),
manaMediaUrl: getEnv('MANA_MEDIA_URL', 'http://localhost:3015'),
manaNotifyUrl: getEnv('MANA_NOTIFY_URL', 'http://localhost:3040'),
serviceKey: getEnv('MANA_SERVICE_KEY', inProd ? undefined : 'dev-service-key'),
cors: {
origins: getEnv('CORS_ORIGINS', 'http://localhost:5173,http://localhost:5180').split(','),
},
authorPayout: {
// 80/20 standard, 90/10 for verified-mana authors. Stored in
// basis-points so we can tune later without code change.
standardAuthorBps: getEnvNumber('AUTHOR_PAYOUT_STANDARD_BPS', 8000),
verifiedAuthorBps: getEnvNumber('AUTHOR_PAYOUT_VERIFIED_BPS', 9000),
},
communityVerifiedThresholds: {
stars: getEnvNumber('COMMUNITY_VERIFY_STARS', 500),
featuredDecks: getEnvNumber('COMMUNITY_VERIFY_FEATURED', 3),
activeSubscribers: getEnvNumber('COMMUNITY_VERIFY_SUBSCRIBERS', 200),
},
};
}

View file

@ -0,0 +1,23 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
let _db: ReturnType<typeof drizzle<typeof schema>> | null = null;
/**
* Lazy singleton caller passes the url, but reuses the pool across
* the lifetime of the process. drizzle-kit cli skips this and opens
* its own connection from drizzle.config.ts.
*/
export function getDb(url: string) {
if (_db) return _db;
const client = postgres(url, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
});
_db = drizzle(client, { schema });
return _db;
}
export type Database = ReturnType<typeof getDb>;

View file

@ -0,0 +1,10 @@
import { pgSchema } from 'drizzle-orm/pg-core';
/**
* All Cards-marketplace tables live under the `cards` Postgres schema
* inside `mana_platform`. This keeps the marketplace next to the rest
* of the per-app data (Mana convention: one schema per product) and
* lets the per-table FKs reference shared tables (e.g. `auth.users`)
* via plain text columns without cross-DB JOINs.
*/
export const cardsSchema = pgSchema('cards');

View file

@ -0,0 +1,66 @@
/**
* Authors public-facing identity layer for users who publish decks.
*
* One author row per user that has ever opted into being an author.
* `userId` is a plain text reference to `auth.users.id` (cross-DB,
* no FK at the DB level the consumer service validates JWTs from
* mana-auth and uses the `sub` claim verbatim).
*
* Verification has two orthogonal axes:
* - `verified_mana`: manually granted by Mana-Verein (teachers,
* professional educators, doctors, etc.). Not earnable.
* - `verified_community`: automatically calculated from engagement
* ( X stars across decks, Y featured decks, Z subscribers).
* Periodically re-evaluated.
*
* Both axes can be true at once the UI shows both badges.
*/
import { boolean, index, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { cardsSchema } from './_schema';
export const authors = cardsSchema.table(
'authors',
{
userId: text('user_id').primaryKey(),
slug: text('slug').notNull(),
displayName: text('display_name').notNull(),
bio: text('bio'),
avatarUrl: text('avatar_url'),
joinedAt: timestamp('joined_at', { withTimezone: true }).defaultNow().notNull(),
// Pseudonym mode: legal name stays hidden, only displayName visible.
pseudonym: boolean('pseudonym').default(false).notNull(),
// Verification flags (see header).
verifiedMana: boolean('verified_mana').default(false).notNull(),
verifiedCommunity: boolean('verified_community').default(false).notNull(),
// Soft-ban: blocked author can no longer publish, existing decks
// stay readable but get a "deactivated" badge.
bannedAt: timestamp('banned_at', { withTimezone: true }),
bannedReason: text('banned_reason'),
},
(t) => ({
slugIdx: uniqueIndex('authors_slug_idx').on(t.slug),
verifiedIdx: index('authors_verified_idx').on(t.verifiedMana, t.verifiedCommunity),
})
);
/**
* Following relationship between users (followers) and authors.
* Drives the personal activity feed.
*/
export const authorFollows = cardsSchema.table(
'author_follows',
{
followerUserId: text('follower_user_id').notNull(),
authorUserId: text('author_user_id')
.notNull()
.references(() => authors.userId, { onDelete: 'cascade' }),
since: timestamp('since', { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
// Composite primary key (user, author).
pk: uniqueIndex('author_follows_pk').on(t.followerUserId, t.authorUserId),
authorIdx: index('author_follows_author_idx').on(t.authorUserId),
followerIdx: index('author_follows_follower_idx').on(t.followerUserId),
})
);

View file

@ -0,0 +1,63 @@
/**
* Two-sided marketplace bookkeeping. The actual money lives in
* mana-credits we just record the deck-purchase event and the
* derived author payout so we can show buyer history, author
* dashboards, and reconcile against the mana-credits ledger.
*/
import { index, integer, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
import { cardsSchema } from './_schema';
import { authors } from './authors';
import { publicDecks, publicDeckVersions } from './decks';
export const deckPurchases = cardsSchema.table(
'deck_purchases',
{
id: uuid('id').primaryKey().defaultRandom(),
buyerUserId: text('buyer_user_id').notNull(),
deckId: uuid('deck_id')
.notNull()
.references(() => publicDecks.id, { onDelete: 'restrict' }),
// Snapshot the version at time of purchase — buyer keeps lifetime
// access to all subsequent versions.
versionId: uuid('version_id')
.notNull()
.references(() => publicDeckVersions.id, { onDelete: 'restrict' }),
// Snapshot of the price at the time of purchase.
priceCredits: integer('price_credits').notNull(),
// Pre-computed split (sum equals priceCredits modulo rounding).
authorShare: integer('author_share').notNull(),
manaShare: integer('mana_share').notNull(),
// Reference into mana-credits ledger.
creditsTransaction: text('credits_transaction'),
purchasedAt: timestamp('purchased_at', { withTimezone: true }).defaultNow().notNull(),
refundedAt: timestamp('refunded_at', { withTimezone: true }),
},
(t) => ({
// One purchase per buyer per deck — covers lifetime access.
uniqueBuyerDeck: uniqueIndex('deck_purchases_buyer_deck_idx').on(t.buyerUserId, t.deckId),
buyerIdx: index('deck_purchases_buyer_idx').on(t.buyerUserId),
deckIdx: index('deck_purchases_deck_idx').on(t.deckId),
})
);
export const authorPayouts = cardsSchema.table(
'author_payouts',
{
id: uuid('id').primaryKey().defaultRandom(),
authorUserId: text('author_user_id')
.notNull()
.references(() => authors.userId, { onDelete: 'restrict' }),
sourcePurchaseId: uuid('source_purchase_id')
.notNull()
.references(() => deckPurchases.id, { onDelete: 'restrict' }),
creditsGranted: integer('credits_granted').notNull(),
// Reference into mana-credits grant ledger.
creditsGrantId: text('credits_grant_id'),
grantedAt: timestamp('granted_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
authorIdx: index('author_payouts_author_idx').on(t.authorUserId),
purchaseIdx: index('author_payouts_purchase_idx').on(t.sourcePurchaseId),
})
);

View file

@ -0,0 +1,126 @@
/**
* Decks, Versions, Cards.
*
* A deck is the long-lived thing the user identifies with ("Spanish
* A2 Vocab"). It always points at a `latest_version_id`. Versions are
* immutable snapshots once published, they never change. Cards are
* scoped to a version and carry a per-card content-hash so subscriber
* smart-merge can preserve FSRS state for unchanged cards across
* version bumps.
*
* `price_credits` of 0 means free. Anything > 0 forces the
* Cards-Pro-Only-1.0 license (CHECK constraint enforced at DB level).
*/
import {
boolean,
check,
index,
integer,
jsonb,
pgEnum,
text,
timestamp,
uniqueIndex,
uuid,
} from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { cardsSchema } from './_schema';
import { authors } from './authors';
/** Mirrors `CardType` in @mana/cards-core. Phase-1 ships basic / basic-reverse / cloze / type-in. */
export const cardTypeEnum = pgEnum('cards_card_type', [
'basic',
'basic-reverse',
'cloze',
'type-in',
'image-occlusion',
'audio',
'multiple-choice',
]);
export const publicDecks = cardsSchema.table(
'decks',
{
id: uuid('id').primaryKey().defaultRandom(),
slug: text('slug').notNull(),
title: text('title').notNull(),
description: text('description'),
// ISO-639-1 (e.g. 'de', 'en', 'es'). Nullable for mixed-language decks.
language: text('language'),
// SPDX-style ID. CC0-1.0, CC-BY-4.0, CC-BY-SA-4.0,
// Cards-Personal-Use-1.0 (default for free), Cards-Pro-Only-1.0 (paid).
license: text('license').notNull().default('Cards-Personal-Use-1.0'),
priceCredits: integer('price_credits').notNull().default(0),
ownerUserId: text('owner_user_id')
.notNull()
.references(() => authors.userId, { onDelete: 'restrict' }),
// Updated each time a new version is published.
latestVersionId: uuid('latest_version_id'),
isFeatured: boolean('is_featured').notNull().default(false),
isTakedown: boolean('is_takedown').notNull().default(false),
takedownAt: timestamp('takedown_at', { withTimezone: true }),
takedownReason: text('takedown_reason'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
slugIdx: uniqueIndex('decks_slug_idx').on(t.slug),
ownerIdx: index('decks_owner_idx').on(t.ownerUserId),
featuredIdx: index('decks_featured_idx').on(t.isFeatured),
// Paid decks must carry the Pro-Only license.
priceLicense: check(
'decks_price_requires_license',
sql`price_credits = 0 OR license = 'Cards-Pro-Only-1.0'`
),
})
);
export const publicDeckVersions = cardsSchema.table(
'deck_versions',
{
id: uuid('id').primaryKey().defaultRandom(),
deckId: uuid('deck_id')
.notNull()
.references(() => publicDecks.id, { onDelete: 'cascade' }),
// SemVer string — ordering done in app code, not DB.
semver: text('semver').notNull(),
changelog: text('changelog'),
// SHA-256 over the canonicalized card list — clients use this to
// detect "did anything change" without diffing payloads.
contentHash: text('content_hash').notNull(),
cardCount: integer('card_count').notNull(),
publishedAt: timestamp('published_at', { withTimezone: true }).defaultNow().notNull(),
// Older versions stay readable but new subscribers go to latest.
deprecatedAt: timestamp('deprecated_at', { withTimezone: true }),
},
(t) => ({
uniqueSemver: uniqueIndex('deck_versions_deck_semver_idx').on(t.deckId, t.semver),
deckIdx: index('deck_versions_deck_idx').on(t.deckId),
hashIdx: index('deck_versions_hash_idx').on(t.contentHash),
})
);
export const publicDeckCards = cardsSchema.table(
'deck_cards',
{
id: uuid('id').primaryKey().defaultRandom(),
versionId: uuid('version_id')
.notNull()
.references(() => publicDeckVersions.id, { onDelete: 'cascade' }),
// Mirrors @mana/cards-core CardType.
type: cardTypeEnum('type').notNull(),
// Free-form key/value bag of user content.
// basic / basic-reverse / type-in: { front, back }
// cloze: { text, extra? }
fields: jsonb('fields').$type<Record<string, string>>().notNull(),
ord: integer('ord').notNull(),
// SHA-256 over canonical(type, fields). Subscribers use this to
// detect per-card changes during smart-merge — unchanged cards
// keep their FSRS state across version pulls.
contentHash: text('content_hash').notNull(),
},
(t) => ({
uniqueOrd: uniqueIndex('deck_cards_version_ord_idx').on(t.versionId, t.ord),
hashIdx: index('deck_cards_hash_idx').on(t.contentHash),
})
);

View file

@ -0,0 +1,79 @@
/**
* Pull-Requests + Card-Discussions.
*
* Pull-requests propose card-level changes to a deck; the author can
* merge cards-server creates a new version automatically. The diff
* is stored as a JSON blob ({ add, modify, remove }) so we can render
* a GitHub-style review UI without re-deriving from version diffs.
*
* Card discussions are bound to `card_content_hash` (not `card_id`)
* so threads survive version bumps as long as the card itself stays
* unchanged.
*/
import { index, jsonb, text, timestamp, uuid, boolean, pgEnum } from 'drizzle-orm/pg-core';
import { cardsSchema } from './_schema';
import { publicDecks, publicDeckVersions } from './decks';
export const pullRequestStatusEnum = pgEnum('cards_pr_status', [
'open',
'merged',
'closed',
'rejected',
]);
export interface PullRequestDiff {
add: { type: string; fields: Record<string, string> }[];
modify: { contentHash: string; fields: Record<string, string> }[];
remove: { contentHash: string }[];
}
export const deckPullRequests = cardsSchema.table(
'deck_pull_requests',
{
id: uuid('id').primaryKey().defaultRandom(),
deckId: uuid('deck_id')
.notNull()
.references(() => publicDecks.id, { onDelete: 'cascade' }),
authorUserId: text('author_user_id').notNull(),
status: pullRequestStatusEnum('status').notNull().default('open'),
title: text('title').notNull(),
body: text('body'),
diff: jsonb('diff').$type<PullRequestDiff>().notNull(),
mergedIntoVersionId: uuid('merged_into_version_id').references(() => publicDeckVersions.id, {
onDelete: 'set null',
}),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
resolvedAt: timestamp('resolved_at', { withTimezone: true }),
},
(t) => ({
deckIdx: index('deck_pull_requests_deck_idx').on(t.deckId),
statusIdx: index('deck_pull_requests_status_idx').on(t.deckId, t.status),
authorIdx: index('deck_pull_requests_author_idx').on(t.authorUserId),
})
);
export const cardDiscussions = cardsSchema.table(
'card_discussions',
{
id: uuid('id').primaryKey().defaultRandom(),
// Bound to the card's content_hash, not its row id, so the thread
// follows the card across version bumps as long as content stays.
cardContentHash: text('card_content_hash').notNull(),
deckId: uuid('deck_id')
.notNull()
.references(() => publicDecks.id, { onDelete: 'cascade' }),
authorUserId: text('author_user_id').notNull(),
// Threading: parent_id NULL = root post, NOT NULL = reply.
parentId: uuid('parent_id'),
body: text('body').notNull(),
// Hidden by author or moderator. Not deleted — preserves audit trail.
hidden: boolean('hidden').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
hashIdx: index('card_discussions_hash_idx').on(t.cardContentHash),
deckIdx: index('card_discussions_deck_idx').on(t.deckId),
parentIdx: index('card_discussions_parent_idx').on(t.parentId),
})
);

View file

@ -0,0 +1,66 @@
/**
* Engagement primitives Stars (bookmarks), Subscriptions (live
* updates), Forks (own copy with lineage).
*
* All keyed by `user_id` text a plain reference to auth.users.id
* with no cross-DB FK.
*/
import { boolean, index, timestamp, uniqueIndex, uuid, text } from 'drizzle-orm/pg-core';
import { cardsSchema } from './_schema';
import { publicDecks, publicDeckVersions } from './decks';
export const deckStars = cardsSchema.table(
'deck_stars',
{
userId: text('user_id').notNull(),
deckId: uuid('deck_id')
.notNull()
.references(() => publicDecks.id, { onDelete: 'cascade' }),
starredAt: timestamp('starred_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
pk: uniqueIndex('deck_stars_pk').on(t.userId, t.deckId),
deckIdx: index('deck_stars_deck_idx').on(t.deckId),
})
);
export const deckSubscriptions = cardsSchema.table(
'deck_subscriptions',
{
userId: text('user_id').notNull(),
deckId: uuid('deck_id')
.notNull()
.references(() => publicDecks.id, { onDelete: 'cascade' }),
// Latest version the user has pulled. Smart-merge compares this to
// the deck's `latest_version_id` to compute the diff.
currentVersionId: uuid('current_version_id').references(() => publicDeckVersions.id, {
onDelete: 'set null',
}),
subscribedAt: timestamp('subscribed_at', { withTimezone: true }).defaultNow().notNull(),
notifyUpdates: boolean('notify_updates').notNull().default(true),
},
(t) => ({
pk: uniqueIndex('deck_subscriptions_pk').on(t.userId, t.deckId),
deckIdx: index('deck_subscriptions_deck_idx').on(t.deckId),
userIdx: index('deck_subscriptions_user_idx').on(t.userId),
})
);
export const deckForks = cardsSchema.table(
'deck_forks',
{
userId: text('user_id').notNull(),
sourceDeckId: uuid('source_deck_id')
.notNull()
.references(() => publicDecks.id, { onDelete: 'cascade' }),
sourceVersionId: uuid('source_version_id')
.notNull()
.references(() => publicDeckVersions.id, { onDelete: 'cascade' }),
forkedAt: timestamp('forked_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
pk: uniqueIndex('deck_forks_pk').on(t.userId, t.sourceDeckId, t.sourceVersionId),
sourceIdx: index('deck_forks_source_idx').on(t.sourceDeckId),
})
);

View file

@ -0,0 +1,13 @@
/**
* Re-exports for the entire cards-server schema. Keep imports flat
* downstream code does `import { authors, publicDecks } from '../db/schema'`.
*/
export { cardsSchema } from './_schema';
export * from './authors';
export * from './decks';
export * from './tags';
export * from './engagement';
export * from './discussions';
export * from './moderation';
export * from './credits';

View file

@ -0,0 +1,74 @@
/**
* Moderation user-submitted reports + AI-first-pass log.
*
* Reports flow into a Mana-admin inbox; AI-mod-log is a record of every
* automated check we ran on a version so we can audit / re-train if a
* bad outcome shipped.
*/
import { boolean, index, pgEnum, text, timestamp, uuid } from 'drizzle-orm/pg-core';
import { cardsSchema } from './_schema';
import { publicDecks, publicDeckVersions } from './decks';
export const reportCategoryEnum = pgEnum('cards_report_category', [
'spam',
'copyright',
'nsfw',
'misinformation',
'hate',
'other',
]);
export const reportStatusEnum = pgEnum('cards_report_status', ['open', 'dismissed', 'actioned']);
export const deckReports = cardsSchema.table(
'deck_reports',
{
id: uuid('id').primaryKey().defaultRandom(),
deckId: uuid('deck_id')
.notNull()
.references(() => publicDecks.id, { onDelete: 'cascade' }),
versionId: uuid('version_id').references(() => publicDeckVersions.id, {
onDelete: 'set null',
}),
// Optional: report scoped to one specific card by content-hash.
cardContentHash: text('card_content_hash'),
reporterUserId: text('reporter_user_id').notNull(),
category: reportCategoryEnum('category').notNull(),
body: text('body'),
status: reportStatusEnum('status').notNull().default('open'),
resolvedBy: text('resolved_by'),
resolvedAt: timestamp('resolved_at', { withTimezone: true }),
resolutionNotes: text('resolution_notes'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
deckIdx: index('deck_reports_deck_idx').on(t.deckId),
statusIdx: index('deck_reports_status_idx').on(t.status),
})
);
export const aiModerationVerdictEnum = pgEnum('cards_ai_mod_verdict', ['pass', 'flag', 'block']);
export const aiModerationLog = cardsSchema.table(
'ai_moderation_log',
{
id: uuid('id').primaryKey().defaultRandom(),
versionId: uuid('version_id')
.notNull()
.references(() => publicDeckVersions.id, { onDelete: 'cascade' }),
verdict: aiModerationVerdictEnum('verdict').notNull(),
// Categories the model flagged — array because one verdict can hit
// multiple categories (e.g. "spam" + "misinformation").
categories: text('categories').array(),
model: text('model'),
rationale: text('rationale'),
humanReviewed: boolean('human_reviewed').notNull().default(false),
humanOverrode: boolean('human_overrode').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
versionIdx: index('ai_moderation_log_version_idx').on(t.versionId),
verdictIdx: index('ai_moderation_log_verdict_idx').on(t.verdict),
})
);

View file

@ -0,0 +1,43 @@
/**
* Tag taxonomy. Hierarchical (parent_id), curated by the Mana-Verein
* for the canonical tree (medizin > anatomie > kardiologie). Authors
* pick from existing tags and can suggest new ones via a moderated
* flow (`curated = false` admin reviews before promoting).
*/
import { boolean, index, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
import { cardsSchema } from './_schema';
import { publicDecks } from './decks';
export const tagDefinitions = cardsSchema.table(
'tag_definitions',
{
id: uuid('id').primaryKey().defaultRandom(),
slug: text('slug').notNull(),
name: text('name').notNull(),
parentId: uuid('parent_id'),
description: text('description'),
curated: boolean('curated').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
slugIdx: uniqueIndex('tag_definitions_slug_idx').on(t.slug),
parentIdx: index('tag_definitions_parent_idx').on(t.parentId),
})
);
export const deckTags = cardsSchema.table(
'deck_tags',
{
deckId: uuid('deck_id')
.notNull()
.references(() => publicDecks.id, { onDelete: 'cascade' }),
tagId: uuid('tag_id')
.notNull()
.references(() => tagDefinitions.id, { onDelete: 'cascade' }),
},
(t) => ({
pk: uniqueIndex('deck_tags_pk').on(t.deckId, t.tagId),
tagIdx: index('deck_tags_tag_idx').on(t.tagId),
})
);

View file

@ -0,0 +1,60 @@
/**
* cards-server Cards Marketplace + Community backend.
*
* Hono + Bun. Owns published decks, versions, subscriptions, forks,
* pull-requests, discussions, moderation, and the credits-based
* author payout pipeline.
*
* See apps/cards/docs/MARKETPLACE_PLAN.md for the full design.
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { serviceErrorHandler as errorHandler } from '@mana/shared-hono';
import { loadConfig } from './config';
import { getDb } from './db/connection';
import { healthRoutes } from './routes/health';
// ─── Bootstrap ──────────────────────────────────────────────
const config = loadConfig();
// Eager-init the pool so a misconfigured DATABASE_URL fails at boot
// (instead of on the first user request).
getDb(config.databaseUrl);
// ─── App ────────────────────────────────────────────────────
const app = new Hono();
app.onError(errorHandler);
app.use(
'*',
cors({
origin: config.cors.origins,
credentials: true,
})
);
// Health (no auth)
app.route('/health', healthRoutes);
// Versioned API surface — routes will land here in Phase α.3 onwards.
// The /v1 prefix is the public contract from day one (see
// MARKETPLACE_PLAN §3 architecture principle 1).
const v1 = new Hono();
v1.get('/', (c) =>
c.json({
service: 'cards-server',
version: 1,
message: 'See apps/cards/docs/MARKETPLACE_PLAN.md for the full plan.',
})
);
app.route('/v1', v1);
// ─── Listen ────────────────────────────────────────────────
console.log(`[cards-server] listening on :${config.port}`);
export default {
port: config.port,
fetch: app.fetch,
};

View file

@ -0,0 +1,51 @@
/**
* Domain errors caught by serviceErrorHandler from @mana/shared-hono
* and translated to JSON responses with the right status code.
*/
export class HttpError extends Error {
constructor(
public status: number,
message: string,
public code?: string,
public details?: unknown
) {
super(message);
this.name = 'HttpError';
}
}
export class UnauthorizedError extends HttpError {
constructor(message = 'Unauthorized') {
super(401, message, 'unauthorized');
this.name = 'UnauthorizedError';
}
}
export class ForbiddenError extends HttpError {
constructor(message = 'Forbidden') {
super(403, message, 'forbidden');
this.name = 'ForbiddenError';
}
}
export class NotFoundError extends HttpError {
constructor(message = 'Not found') {
super(404, message, 'not_found');
this.name = 'NotFoundError';
}
}
export class ConflictError extends HttpError {
constructor(message = 'Conflict') {
super(409, message, 'conflict');
this.name = 'ConflictError';
}
}
export class BadRequestError extends HttpError {
constructor(message = 'Bad request', details?: unknown) {
super(400, message, 'bad_request', details);
this.name = 'BadRequestError';
}
}

View file

@ -0,0 +1,56 @@
/**
* JWT authentication middleware validates Bearer tokens via JWKS from
* mana-auth (EdDSA, jose). Sets `c.set('user', { userId, email, role })`
* on success.
*
* Mirrors the mana-credits middleware almost verbatim. Kept in-tree
* rather than shared so we can evolve auth-related concerns (e.g.
* audience claims) per service without coordination overhead.
*/
import type { MiddlewareHandler } from 'hono';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { UnauthorizedError } from '../lib/errors';
let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
function getJwks(authUrl: string) {
if (!jwks) {
jwks = createRemoteJWKSet(new URL('/api/auth/jwks', authUrl));
}
return jwks;
}
export interface AuthUser {
userId: string;
email: string;
role: string;
}
export function jwtAuth(authUrl: string): MiddlewareHandler {
return async (c, next) => {
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
throw new UnauthorizedError('Missing or invalid Authorization header');
}
const token = authHeader.slice(7);
try {
const { payload } = await jwtVerify(token, getJwks(authUrl), {
issuer: authUrl,
audience: 'mana',
});
const user: AuthUser = {
userId: payload.sub || '',
email: (payload.email as string) || '',
role: (payload.role as string) || 'user',
};
c.set('user', user);
await next();
} catch {
throw new UnauthorizedError('Invalid or expired token');
}
};
}

View file

@ -0,0 +1,18 @@
/**
* Service-to-service authentication. Used by `/api/v1/internal/*`
* routes that other Mana services call (e.g. mana-credits-webhook
* pinging us about a confirmed payment).
*/
import type { MiddlewareHandler } from 'hono';
import { UnauthorizedError } from '../lib/errors';
export function serviceAuth(expectedKey: string): MiddlewareHandler {
return async (c, next) => {
const key = c.req.header('X-Service-Key');
if (!key || key !== expectedKey) {
throw new UnauthorizedError('Invalid X-Service-Key');
}
await next();
};
}

View file

@ -0,0 +1,11 @@
import { Hono } from 'hono';
export const healthRoutes = new Hono();
healthRoutes.get('/', (c) => {
return c.json({
status: 'ok',
service: 'cards-server',
timestamp: new Date().toISOString(),
});
});

View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["bun"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true
},
"include": ["src/**/*", "drizzle.config.ts"],
"exclude": ["node_modules", "drizzle"]
}