mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
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:
parent
33bc654238
commit
a7b62ea8ae
26 changed files with 3407 additions and 140 deletions
377
pnpm-lock.yaml
generated
377
pnpm-lock.yaml
generated
|
|
@ -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
5
services/cards-server/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
.env
|
||||
.env.local
|
||||
.DS_Store
|
||||
drizzle/meta/_journal.json.bak
|
||||
110
services/cards-server/CLAUDE.md
Normal file
110
services/cards-server/CLAUDE.md
Normal 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".
|
||||
6
services/cards-server/README.md
Normal file
6
services/cards-server/README.md
Normal 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.
|
||||
14
services/cards-server/drizzle.config.ts
Normal file
14
services/cards-server/drizzle.config.ts
Normal 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'],
|
||||
});
|
||||
234
services/cards-server/drizzle/0000_condemned_wrecking_crew.sql
Normal file
234
services/cards-server/drizzle/0000_condemned_wrecking_crew.sql
Normal 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");
|
||||
1910
services/cards-server/drizzle/meta/0000_snapshot.json
Normal file
1910
services/cards-server/drizzle/meta/0000_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
13
services/cards-server/drizzle/meta/_journal.json
Normal file
13
services/cards-server/drizzle/meta/_journal.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1778162392774,
|
||||
"tag": "0000_condemned_wrecking_crew",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
27
services/cards-server/package.json
Normal file
27
services/cards-server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
72
services/cards-server/src/config.ts
Normal file
72
services/cards-server/src/config.ts
Normal 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),
|
||||
},
|
||||
};
|
||||
}
|
||||
23
services/cards-server/src/db/connection.ts
Normal file
23
services/cards-server/src/db/connection.ts
Normal 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>;
|
||||
10
services/cards-server/src/db/schema/_schema.ts
Normal file
10
services/cards-server/src/db/schema/_schema.ts
Normal 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');
|
||||
66
services/cards-server/src/db/schema/authors.ts
Normal file
66
services/cards-server/src/db/schema/authors.ts
Normal 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),
|
||||
})
|
||||
);
|
||||
63
services/cards-server/src/db/schema/credits.ts
Normal file
63
services/cards-server/src/db/schema/credits.ts
Normal 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),
|
||||
})
|
||||
);
|
||||
126
services/cards-server/src/db/schema/decks.ts
Normal file
126
services/cards-server/src/db/schema/decks.ts
Normal 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),
|
||||
})
|
||||
);
|
||||
79
services/cards-server/src/db/schema/discussions.ts
Normal file
79
services/cards-server/src/db/schema/discussions.ts
Normal 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),
|
||||
})
|
||||
);
|
||||
66
services/cards-server/src/db/schema/engagement.ts
Normal file
66
services/cards-server/src/db/schema/engagement.ts
Normal 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),
|
||||
})
|
||||
);
|
||||
13
services/cards-server/src/db/schema/index.ts
Normal file
13
services/cards-server/src/db/schema/index.ts
Normal 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';
|
||||
74
services/cards-server/src/db/schema/moderation.ts
Normal file
74
services/cards-server/src/db/schema/moderation.ts
Normal 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),
|
||||
})
|
||||
);
|
||||
43
services/cards-server/src/db/schema/tags.ts
Normal file
43
services/cards-server/src/db/schema/tags.ts
Normal 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),
|
||||
})
|
||||
);
|
||||
60
services/cards-server/src/index.ts
Normal file
60
services/cards-server/src/index.ts
Normal 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,
|
||||
};
|
||||
51
services/cards-server/src/lib/errors.ts
Normal file
51
services/cards-server/src/lib/errors.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
56
services/cards-server/src/middleware/jwt-auth.ts
Normal file
56
services/cards-server/src/middleware/jwt-auth.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
}
|
||||
18
services/cards-server/src/middleware/service-auth.ts
Normal file
18
services/cards-server/src/middleware/service-auth.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
11
services/cards-server/src/routes/health.ts
Normal file
11
services/cards-server/src/routes/health.ts
Normal 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(),
|
||||
});
|
||||
});
|
||||
20
services/cards-server/tsconfig.json
Normal file
20
services/cards-server/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue