Curated vetted freelanceJob Board
A curated, both-sides-vetted freelance platform — think Upwork/Fiverr, but gated and quality-first. Sanctioned members post gigs, others take them up. Game development is the launch vertical, not the ceiling: the data model, taxonomy, and matching are vertical-agnostic by design.
Curation is the wedge
Both posters and takers are vetted, so quality is the product. Every gig is legit; every applicant is screened. The underlying platform stays general so we can expand without re-platforming.
- Vertical-first — game dev first, horizontal data model.
- Portfolio-first — talent is hired by seeing work.
What it gives you
Features
Curation is the wedge
Both posters and takers are vetted, so quality is the product — every gig is legit, every applicant screened.
Vertical-first, horizontal model
Win one vertical at a time (game dev first) while the underlying platform stays general, so expansion needs no re-platforming.
Portfolio-first
Creative/technical talent is hired by seeing work — the portfolio system is a first-class feature across every vertical.
Decoupled Rust/Axum API
A standalone REST API with Supabase auth and jedi PgCluster over Postgres, reusable across web, future mobile, and integrations.
Upstream
README
jobboard
Decoupled freelance job board. Rust/Axum REST API on :5400 that also serves
an embedded React + Vite SPA (the same @kbve/rn components the mobile app
uses, via react-native-web). One binary serves UI + /api. Layout, ports, and
domains live in the frontmatter above.
Data Flow
flowchart LR
User([Browser])
Dev([Vite dev :5401])
User --> Axum[axum jobboard :5400<br/>SPA + /api]
Dev -. proxies /api .-> Axum
Axum --> Embed[rust-embed web/dist<br/>or JOBBOARD_WEB_DIR]
Axum --> API{/api/*, /health, /ready}
API --> PG[(kilobase<br/>Postgres)]
Axum -. verify JWT .-> Supa[(supabase.kbve.com)]
SPA[React SPA] -. sign-in .-> Supa
Embed --> SPACommands
Run targets either through the repo wrapper (sources .env.local, avoids OOM)
or pnpm nx directly:
# full local stack (Postgres + migrations + seed + the Axum binary)
./kbve.sh -nx jobboard:dev
pnpm nx run jobboard:dev
# tear the stack down (wipes the db volume)
./kbve.sh -nx jobboard:down
pnpm nx run jobboard:down
# SPA dev server with hot reload (vite :5401, proxies /api -> :5400)
./kbve.sh -nx jobboard:web-dev
pnpm nx run jobboard:web-dev
# build the SPA to web/dist
./kbve.sh -nx jobboard:web-build
pnpm nx run jobboard:web-build
# run the Axum binary on the host, serving web/dist from disk (no re-embed)
./kbve.sh -nx jobboard:serve-static
pnpm nx run jobboard:serve-static
# cargo run / build / test / lint the Rust service
./kbve.sh -nx jobboard:run
./kbve.sh -nx jobboard:build
./kbve.sh -nx jobboard:test
./kbve.sh -nx jobboard:lint
pnpm nx run jobboard:run
# build the production container image
./kbve.sh -nx jobboard:container
pnpm nx run jobboard:container
Quick start
./kbve.sh -nx jobboard:dev # first run builds the image (slow, emulated)
open http://localhost:5400
dev streams full compose output and captures only FAILURE lines into
jobboards.txt (gitignored), wiped each run — cat apps/jobboard/jobboards.txt
to see just what broke.
Fast SPA loop (no Rust rebuilds)
./kbve.sh -nx jobboard:dev # leave the DB + API up, or:
./kbve.sh -nx jobboard:serve-static # API + disk-served SPA on :5400
./kbve.sh -nx jobboard:web-dev # vite :5401 with HMR
Auth
Identity comes from a Supabase JWT (sub = auth.users.id, kbve_username
claim). The SPA signs in against supabase.kbve.com via the shared
@kbve/rn LoginScreen. The Axum service verifies the bearer token with
SUPABASE_JWT_SECRET.
Local note: sign-in yields a real Supabase token, but the compose default
SUPABASE_JWT_SECRETis a placeholder, so/api/auth/merejects it locally unless you supply the real kbve secret./api/verticals+ the SPA need no auth.
How the SPA is served
- Release / container →
web/distis baked into the binary withrust-embed; one static binary serves everything. JOBBOARD_WEB_DIR=<path>→ Axum serves that directory from disk at runtime instead (rebuild the SPA without recompiling Rust).serve-staticsets it toweb/dist.- SPA deep links fall back to
index.html;/api/*,/health,/readyare real routes.
Status & positioning
Job Board
Status: Phase 0 scaffolded —
apps/jobboardAxum service boots,jobboard.protowired into the build. Auth = Supabase (JWTsub=auth.users.id); the Phase-0 first-party auth/sessions in the Rust code is a dead end to be removed. Schema migration shipped (20260615120000_jobboard_tables.sql): 23 tables, ULID-as-uuid PKs, junction tables (no array refs), state/money/lifecycle CHECK constraints, RLS lockdown,updated_attriggers; all user refs →auth.users(id). No seed yet. Phases 1–10 below remain spec.
A curated, both-sides-vetted job board for freelance work — think Upwork/Fiverr, but gated and quality-first. Sanctioned members post gigs, other sanctioned members take them up, and the platform handles discovery, matching, and messaging. Money moves off-platform in v1 (lead-gen model); monetization is built behind abstraction hooks to switch on later.
Game development is the launch vertical, not the ceiling. We pioneer in game dev (art, code, audio, design, QA for games) because it’s an underserved, portfolio-driven niche where curation wins — but the data model, taxonomy, and matching are vertical-agnostic by design. Adding a new vertical (e.g. video production, music, dev/IT, design, writing) is a data operation (seed a Vertical + its taxonomy), not a schema change or code change.
Architecture is decoupled: a standalone Rust/Axum REST API (own DB access, own first-party auth — no third-party auth SaaS) serving a separate frontend client (framework TBD) over HTTP. This keeps the API reusable across web, future mobile, and integrations.
Positioning / moat
Section titled “Positioning / moat”- Curation is the wedge. Both posters and takers are vetted, so quality is the product. Every gig is legit; every applicant is screened.
- Vertical-first go-to-market, horizontal data model. We win one vertical at a time (game dev first) by speaking its language, while the underlying platform stays general so we can expand without re-platforming.
- Portfolio-first. Talent in creative/technical verticals is hired by seeing work (characters, gameplay clips, playable builds, reels, repos), not reading résumés. The portfolio system is a first-class feature across every vertical.
Who's on it
Personas & roles
| Persona | What they do | Vetting |
|---|---|---|
| Poster | Has work (indie dev, studio, publisher, agency, business). Posts gigs, reviews applicants, selects talent. | Vetted (org/identity + intent) |
| Taker | Does work (artist, programmer, audio, designer, QA, and every other vertical’s specialists). Builds a portfolio, applies to gigs. | Vetted (portfolio review) |
| Admin | Platform staff. Reviews membership applications, moderates gigs, manages verticals + taxonomy, handles reports. | Internal |
- A single account can hold both capabilities (
posterandtaker), granted independently during vetting. - Capabilities are the authorization primitive — encoded as a bitmask on the existing
User.rolefield (see protobuf section). - A member can operate in one or more verticals; their granted disciplines/skills are scoped per vertical.
Vocabulary
Verticals & taxonomy
The platform is organized around verticals (top-level service areas) and a controlled, admin-managed vocabulary scoped to each vertical — so filtering and matching work without free text, and so a new vertical can be launched without touching code.
- Vertical — the top-level service area. Game Dev is vertical #1. Others (Video, Audio/Music, Software/IT, Design, Writing, Marketing…) are added later as data.
- Discipline — a category of work within a vertical (data-driven taxonomy, not a hardcoded enum).
- Tool/Platform — engines, software, frameworks relevant to the vertical.
- Skill tag — finer-grained, controlled-but-extensible.
Why no hardcoded discipline enum? An earlier draft baked game-dev disciplines into a protobuf bitmask. That ties the schema to one vertical. Instead, disciplines/tools/skills all live in the
Taxonomytable, each row scoped byvertical_id. Gigs and profiles reference taxonomy ids, so a new vertical is purely a seed-data operation. (If a vertical later needs ultra-fast bitmask filtering, a per-vertical cached bitmask can be derived from taxonomy ids without changing the contract.)
Game-dev vertical — seed vocabulary (example)
Section titled “Game-dev vertical — seed vocabulary (example)”This is the first vertical’s seed data, illustrating the pattern — not a fixed part of the schema:
- Disciplines: 2D Art, 3D Art, Animation/Rigging, Programming, Technical Art/Shaders, Audio (Music/SFX/VO), Game Design, Level Design, Narrative/Writing, UI/UX, QA/Testing, Porting, Localization, Production/PM, Community.
- Tools/Engines: Unity, Unreal, Godot, GameMaker, Bevy, Custom; tools — Blender, Maya, ZBrush, Substance, Photoshop, Spine, FMOD, Wwise.
- Skill tags:
netcode,humanoid-rigging,pixel-art,procgen,shader-graph,vertical-slice.
Admin-managed vocabulary so new verticals, disciplines, tools, and skills can be added without code changes.
How it works
Core flows
A. Membership / vetting (both sides)
Section titled “A. Membership / vetting (both sides)”- Sign up (first-party auth — email/password or OAuth).
- Choose intended capabilities: post work, take work, or both; and the vertical(s) of interest.
- Complete application — statement + portfolio links; talent pick vertical(s) + disciplines/tools, posters add org info.
- Submit → application status
pending. - Admin reviews in the vetting queue → approve/reject per capability.
- On approval → capability bit set on
User.role; user unlocks profile + the granted actions.
B. Poster posts a gig
Section titled “B. Poster posts a gig”- Approved poster creates a gig (title, vertical, discipline(s), scope, budget, tools, skills, deadline).
- Publish →
OPEN(optionallyPENDING_REVIEWif gig moderation is enabled). - Gig enters discovery.
C. Taker discovers & applies
Section titled “C. Taker discovers & applies”- Browse / search / filter gigs (vertical, discipline, tool, budget…).
- View gig detail.
- Apply — cover message, proposed rate, attach relevant portfolio items (gated on
taker). - Poster notified.
D. Selection & engagement
Section titled “D. Selection & engagement”- Poster reviews applicants, shortlists, messages candidates.
- Poster accepts one →
Engagementcreated, gig →FILLED, others auto-declined/notified. - Work + payment happen off-platform (v1).
- Either party marks the engagement complete.
- Both leave reviews → reputation accrues (aggregated into
User.reputation).
E. Admin / moderation (cross-cutting)
Section titled “E. Admin / moderation (cross-cutting)”- Vetting queue, gig takedowns, report handling, vertical + taxonomy management, audit log.
Postgres
Data model
Accessed via sqlx (async, compile-time-checked queries; migrations via sqlx migrate). Each table is mirrored by a protobuf message (see below). Reused entities map to existing protos; new entities to the jobboard package.
| Table | Source proto |
|---|---|
auth.users (identity) | Supabase — referenced by user_id FKs, not recreated |
profiles (base) | reuse Profile (kbveproto) |
verticals | new jobboard.Vertical |
talent_profiles | new jobboard.TalentProfile |
client_profiles | new jobboard.ClientProfile |
member_applications | new jobboard.MembershipApplication |
portfolio_items | new jobboard.PortfolioItem (+ reuse MediaAttachment) |
taxonomy (disciplines/tools/skills, scoped per vertical) | new jobboard.Taxonomy |
gigs | new jobboard.Gig |
applications | new jobboard.Application |
engagements | new jobboard.Engagement |
reviews | new jobboard.Review |
conversations, messages | new jobboard.Conversation / Message (+ reuse MediaAttachment) |
notifications | new jobboard.Notification |
reports, audit_log | new (admin) |
invoices, settings, globals (monetization hooks) | reuse Invoice, Setting, Global (kbveproto) |
Decoupled
Tech architecture
Backend — Rust API service
Section titled “Backend — Rust API service”| Concern | Choice | Why |
|---|---|---|
| Framework | Axum (Tokio) | Fast, ergonomic, tower middleware ecosystem |
| Auth | Supabase — verify the Supabase JWT (sub = auth.users.id, kbve_username claim), no first-party user/session store | Reuses existing KBVE identity; same model as apps/rows |
| DB access | jedi PgCluster (tokio-postgres + bb8, rw/ro/any split, RLS caller-scoped reads, valkey cache) | House pattern (matches axum-kbve); free read/write split + RLS + caching |
| Migrations | dbmate (packages/data/sql/dbmate/migrations) — schema deferred, not committed yet | Repo-standard migration tool; one system across the monorepo |
| DB | Postgres — KBVE CNPG/Supabase cluster via KBVE_PG_RW_URL | Shared cluster; jobboard schema isolates the tables |
| Wire format | prost/tonic protobuf over jedi.JediEnvelope | Reuses the KBVE proto/envelope transport |
| Media | S3-compatible (Cloudflare R2) | Rust-friendly; private deliverables |
| Search | Postgres FTS (v1) → Meilisearch later | Meilisearch is Rust-native, easy upgrade |
| Notifications | In-app (DB) v1; email later | Keep v1 lean |
Frontend — TBD (decided in a later phase)
Section titled “Frontend — TBD (decided in a later phase)”- Pure HTTP client of the API. Candidates: Astro + React (KBVE-native), Next.js (frontend-only), or React SPA.
- Auth carried as httpOnly session cookie (browser) — already supported by the API design.
Authorization model: Axum middleware/extractors authenticate the session/token; route guards check the Capability bitmask on User.role (POSTER/TAKER/ADMIN); ownership checks (e.g. gig owner) enforced per-handler.
Ship it
Deployment
The deployable surface lives in apps/jobboard: Dockerfile (chiseled Ubuntu axum base), version.toml, and nx project.json (build / test / lint / run / container / containerx). The container build context is the repo root.
Container image
Section titled “Container image”- Built via
./kbve.sh -nx jobboard:container→kbve/jobboard:latest(+ version tag); production config also tagsghcr.io/kbve/jobboard. - Multi-stage cargo-chef build (planner → cook → builder → runtime) on
ghcr.io/kbve/chisel-ubuntu-axum. Runtime image runs/app/jobboardas uid 10001, exposes5400. - Protos are vendored (
apps/jobboard/src/proto/jobboard.rs); the image build does not runprotoc. Regenerate only when the.protochanges:BUILD_PROTO=1 cargo build -p jobboard(and revert the incidental jedi proto regen before committing).
Runtime environment (env var contract)
Section titled “Runtime environment (env var contract)”| Var | Required | Default | Notes |
|---|---|---|---|
KBVE_PG_RW_URL | yes | — | jedi PgCluster primary (rw). DATABASE_URL is mapped to it if unset (dev convenience). |
KBVE_PG_RO_URL | no | = rw | Read replica; falls back to rw. |
KBVE_PG_ANY_URL | no | = rw | Any-instance read fallback. |
HTTP_HOST | no | 0.0.0.0 | Bind host. |
HTTP_PORT | no | 5400 | Bind port. |
SECURE_COOKIES | no | false | Set true behind TLS (sets Secure on the session cookie). |
RUST_LOG | no | jobboard=info,tower_http=info | Tracing filter. |
jedi PgCluster also honors its standard KBVE_PG_* tuning knobs (pool sizes, timeouts, search_path, pooler rewrite, CA bundle) — see packages/rust/jedi/src/state/pg.rs. TLS bypasses transparently when the URL carries sslmode=disable (in-cluster), engages for sslmode=require (external).
Probes
Section titled “Probes”- Liveness:
GET /health(always 200 when the process is up). - Readiness:
GET /ready(200 when the DB cluster health probe passes, 503 otherwise) — gate traffic on this.
Kubernetes / ArgoCD (shipped)
Section titled “Kubernetes / ArgoCD (shipped)”Deployed under apps/kube/jobboard/ into the kbve namespace (own ArgoCD app):
jobboard-deployment.yaml— Deployment (ghcr.io/kbve/jobboard:<ver>, port 5400, liveness/health, readiness/ready, Reloader) +jobboard-service(ClusterIP 5400). Reuses the kbve-nssupabase-sharedsecret forKBVE_PG_RW_URL/_RO/_ANY(no new ExternalSecret).httproute.yaml—jobs.kbve.com→jobboard-service:5400, parented tokbve-gateway. No cert/ReferenceGrant/gateway-listener needed:jobs.kbve.comis covered by the existinghttps-kbvewildcard listener (*.kbve.com, certkbve-wt-tls).jobboard-serviceaccount.yaml—jobboard-sa,automountServiceAccountToken: false. No RBAC Role/RoleBinding — the pod never calls the k8s API.application.yamlregistered inapps/kube/kustomization.yaml(kube-root app-of-apps).- ArgoCD apps track
main; PRs land ondev, so the manifest reaches the cluster only after the periodic dev→main release.
MDX-driven image-tag automation
Section titled “MDX-driven image-tag automation”The frontmatter above (version, image, version_toml, version_target, deployment_yaml) registers jobboard in .github/ci-dispatch-manifest.json (regenerate via astro-kbve:build → astro-kbve:sync:ci-manifest; registry entry in @kbve/devops registry.ts). On a version: bump here, ci-docker.yml’s version gate (MDX version > version.toml) builds + pushes ghcr.io/kbve/jobboard:<ver>, then utils-post-publish.yml opens an atom PR that sed-updates version.toml, Cargo.toml, and the deployment image tag in jobboard-deployment.yaml. First publish uses the version.toml = 0.0.1 sentinel so the gate fires.
Database schema
Section titled “Database schema”Source of truth: packages/data/sql/schema/jobboard/jobboard.sql — the canonical desired-state DDL (same convention as schema/meme, schema/ows, etc.). The dbmate migration packages/data/sql/dbmate/migrations/20260615120000_jobboard_tables.sql is the applied delta that brings prod to that state, generated from / kept identical to the schema file. The proto (packages/data/proto/jobboard/jobboard.proto) is the third leg and tracks the same shape. 23 tables, hardened with constraints, junction tables, RLS lockdown, and updated_at triggers. No seed (separate follow-up migration).
- Identity is Supabase. No
jobboard.users/jobboard.sessions— every owner column isuser_id uuid references auth.users(id). The service trusts the Supabase JWT (sub); reputation/capabilities live on jobboard rows or are derived. - Primary keys: lookup tables (
verticals,taxonomy) usebigint generated always as identity; entity tables use ULID-as-uuid —uuid default public.gen_ulid()::uuid(the house generator’s 32-hex output casts to a 16-byte binary uuid whose time prefix makes it chronologically sortable → non-enumerable + cheap keyset feeds).user_idisuuid→auth.users. - Junction tables replace all
*_idsarrays (real FKs + uniqueness):talent_verticals,talent_taxonomy,client_verticals,member_application_verticals,portfolio_tags,gig_taxonomy,application_portfolio_items. Single-vertical entities (gig_taxonomy,portfolio_tags) carryvertical_idand use composite FKs togigs(id, vertical_id)/portfolio_items(id, vertical_id)+taxonomy(id, vertical_id), so a tag must belong to the entity’s own vertical (cross-vertical tagging is rejected).talent_taxonomyis also vertical-aligned — composite FKs totalent_verticals(user_id, vertical_id)+taxonomy(id, vertical_id), so a talent can only tag taxonomy within a vertical they’ve joined. - State integrity: CHECK constraints on every enum/bitmask column (ranges from the proto enums —
budget_type0–3; gig/notificationstatus/kind in (0,1,2,4,8,16)treated as single-value enums;reports.target_kind in (1..4)=ReportTarget,reports.status in (0..3)=ReportStatus), money invariants (min ≤ max,≥ 0,currency ~ '^[A-Z]{3}$', minor units), identifier/text rules (slug/name^[a-z0-9]+(-[a-z0-9]+)*$— no leading/trailing/double hyphens; non-empty boundedlabel/title/reason/action), lifecycle invariants (member-app review state, one-pending-per-user partial unique, gig publication state — OPEN/closed states requirepublished_at, engagementstatus↔completed_atlifecycle [COMPLETED⇒completed_at ≥ started_at, else null] + one-per-gig, null-safe distinct-parties / distinct-reviewer-reviewee so double account deletion can’t trip the check, participantleft_at ≥ joined_at), unique review-per-(engagement, reviewer), message content non-empty + size caps (body ≤ 20k, ≤ 10 attachments),jsonb_typeofchecks. - Messaging:
conversation_participantshas a surrogateidPK; messages reference the participant via composite FK(conversation_id, sender_participant_id) → conversation_participants(conversation_id, id)withON DELETE NO ACTION— a participant row can’t be hard-deleted while it has messages (participants leave by settingleft_at, never by row deletion), yet a full conversation delete still cascades cleanly (NO ACTION defers to statement end; RESTRICT would break that). On account deletion the participant’suser_idisSET NULL(anonymized) and messages are preserved (they key off the immutable participant id, not the auth uuid). Per-participant read cursor (last_read_message_id, conversation-scoped composite FK with column-listSET NULL), not a singlemessages.read_at. - Indexes are feed/keyset-oriented: partial gig feeds
where status = 2(vertical_id, published_at desc, id desc+ global), applicant/poster application queues, message cursor, partial unread-notifications, reports queue, reviewee history, portfolio ordering (user_id, sort_order, created_at desc, id desc), member-application history + moderation queue, active-participant partial (user_id, conversation_id where left_at is null), and reverse indexes on every junction’s non-leading column. - Bounds + self-documentation: length caps on all free text (
headline/org_name≤200,about≤2000, descriptions≤5–20k,cover_message/body≤10k,portfolio_links≤20) andcomment on columnon every enum/bitmask column (GigStatus,EngagementStatus,ReportTarget, …) so the integer↔meaning mapping lives in the catalog and can’t silently drift from the proto/Rust enums. - Delete policy (middle ground): personal data
CASCADE(profiles, portfolio, notifications, member applications, conversation participants); commercial/moderation historySET NULL+ nullable (gigs.poster_id, applications.applicant_id, engagements, reviews, reports, audit_log, and conversation_participants.user_id) so the marketplace + chat record survives account deletion. Snapshot/display-name capture deferred. - RLS / privileges locked to a service-role-only model (the Rust service reaches the schema via its privileged jedi pool, NOT via PostgREST/
authenticated):create schema jobboard authorization postgres; RLS enabled on all 23 tables;forceon the strictly server-mediated tables (engagements,reviews,member_applications,reports,audit_log);revoke all on all tables/sequences from anon, authenticated; role-specificalter default privileges for role postgres … revoke(the generic form only covers the running role); schemarevoke from publicwith noauthenticatedusage grant — soauthenticatedgetspermission denied for schema jobboard(verified). Theset_updated_at()fn isrevoke … from public+owner to postgres. No plpgsql/PostgREST RPC layer — the jobboard Axum service is the single writer and reaches the schema via its jedi pool connecting as a BYPASSRLS role (service_roleorpostgres), which is required becauseforce row level securitybinds even the table owner (superusers/BYPASSRLS bypass it). RLS +forcetherefore act purely as defense-in-depth against any future direct/PostgREST path; the real authorization + multi-step invariants live in the service. (If direct client reads are ever wanted, add PostgREST grants + SELECT policies, orpublic.*proxy wrappers, per feature phase.) - Verified up + down on Postgres 17 (stub
auth.users/gen_ulid): 23 tables, 60 indexes, schema ownerpostgres; negative battery confirms cross-vertical tag, non-joined talent taxonomy, bad slug, empty label, over-length text, >20 portfolio links, unpublished-OPEN gig, COMPLETED-without-completed_at, dup review, empty message, cross-conversation cursor, badtarget_kind/target_id, andauthenticatedschema access all reject — while a full conversation delete cascades and a direct participant delete (with messages) is blocked, preserving history.
Enforced in the jobboard Axum service, not DDL (nullable historical FKs + temporal/identity rules can’t be plain CHECKs). The service is the single writer; each multi-step invariant is a Rust transaction (conn.transaction() + SELECT … FOR UPDATE on the gig/application rows), with the DB constraints as the safety net underneath — no plpgsql RPCs: portfolio-item ownership on application attach (portfolio.user_id = application.applicant_id); engagement creation (lock gig + accepted application, verify caller, reject competing applications, flip gig status, audit — one tx); reviews must have reviewer/reviewee ∈ engagement parties, reviewer ≠ reviewee, engagement COMPLETED; non-null identities on insert (the nullable id columns exist only for ON DELETE SET NULL, never for fresh rows); message send must reject left_at is not null participants (and optionally cursor-before-join); member-application review transitions; reports moderation; capability/role grants.
Other deferred follow-ups: seed migration; per-phase RLS SELECT policies; conversation inbox denormalization (conversations.last_message_at/id, maintained in the send RPC); attachment element-type validation (array-of-objects, RPC/helper); proto entity id fields uint64 → bytes (uuid) + Message.sender → participant id. The down (drop schema … cascade) is bootstrap-only — mark irreversible once prod data exists.
Axum
REST API surface
All mutations go through the API; the frontend is a pure client. Auth via session cookie or Authorization: Bearer. Versioned under /api/v1.
# AuthPOST /auth/register email/password signupPOST /auth/login -> session cookie / tokenPOST /auth/logoutGET /auth/me current user + capabilitiesGET /auth/oauth/:provider start Discord/GitHub/Steam flowGET /auth/oauth/:provider/cb callback
# Verticals & taxonomyGET /verticals list active verticalsGET /verticals/:id/taxonomy disciplines/tools/skills for a verticalPOST /admin/verticals [admin] create verticalPOST /admin/taxonomy [admin] add discipline/tool/skill
# Membership / vettingPOST /applications submit membership applicationGET /admin/applications [admin] vetting queuePOST /admin/applications/:id/decision [admin] approve/reject per capability
# Profiles & portfolioGET /talent browse/filter talent (vertical, discipline, tool…)GET /talent/:usernamePATCH /me/profileGET/POST/PATCH/DELETE /me/portfolio[/:id]
# GigsGET /gigs browse/filter (vertical, discipline, tool, budget…)POST /gigs [poster] createGET /gigs/:idPATCH /gigs/:id [poster, owner]POST /gigs/:id/close [poster, owner]
# Applications to gigsPOST /gigs/:id/applications [taker] applyGET /gigs/:id/applications [poster, owner] list applicantsPATCH /applications/:id shortlist/decline/withdrawPOST /applications/:id/accept [poster] -> creates engagement, fills gig
# MessagingGET /conversationsGET /conversations/:id/messagesPOST /conversations/:id/messages
# Engagements & reviewsPOST /engagements/:id/completePOST /engagements/:id/reviews
# ModerationPOST /reportsGET /admin/reports [admin]Wire contract
Protocol Buffers
The platform’s wire/storage contract is protobuf. The proto root is packages/data/proto/ (the shared protoc include root — cross-package imports already work there). We add one new file — packages/data/proto/jobboard/jobboard.proto (package jobboard).
Phase 0 reality check. The reuse table below was aspirational; the actual repo differs, and Phase 0 ships
jobboard.protoas standalone (it defines its ownMediaAttachmentrather than importing one) to sidestep cross-package include friction. Corrections to the table: identity protos live inpackages/data/proto/kbve/kbveproto.protowith nopackagedeclaration (so they are awkward toimportinto a packaged file — reuse happens at the DB/service layer, not via proto import); there is nostatus.proto(MediaAttachmentwas invented locally; the closest existing iskbve.forum.Attachment); the Discord listing template iskbve/discordsh.proto(DiscordServer+ServerTag), notdisoxide.proto; there is nostore.proto/StoreService(onlyjedi/redis.proto’sRedisService).JediEnvelope/MessageKind/PayloadFormat(jedi/jedi.proto) andRedisServiceare real and reusable. As later phases need true cross-file reuse, givekbveproto.protoa package and import from the shared root.
What we reuse (do not recreate)
Section titled “What we reuse (do not recreate)”| Need | Reused proto | Where it lives | Notes |
|---|---|---|---|
| Auth / credentials | Auth | kbveproto.proto | Already has email, hash, salt, reset token+expiry, verification token+expiry, status, last_login, failed_login_attempts, lockout_until, 2FA secret, recovery codes. This is our first-party auth model — use as-is. |
| User identity | User | kbveproto.proto | userid, username, role, reputation, exp, created_at. We encode capabilities into role (bitmask) and aggregate review scores into reputation. |
| Base social profile | Profile | kbveproto.proto | name, bio, github, instagram, discord, unsplash, userid. Vertical-specific fields go in a new TalentProfile that references the same userid — we extend, not duplicate. |
| Bearer API tokens | Apikey | kbveproto.proto | For non-browser clients (Authorization: Bearer). |
| Monetization hooks | Invoice, Setting, Global | kbveproto.proto | Wired only when monetization switches on. |
| Media (portfolio/attachments) | MediaAttachment | status.proto | url, media_type (bitmask), caption. Embedded by PortfolioItem and Message. |
| Listing pattern | DiscordServer, DiscordTag | disoxide.proto | Template (not copied): bitmask status/categories, inline SQL CHECK comments, URL regex, updated_at. Gig, Vertical, and Taxonomy follow this convention. |
| Transport envelope | JediEnvelope, MessageKind, PayloadFormat | jedi.proto | Job board messages travel as payload bytes inside JediEnvelope. Existing MessageKind bits (AUTH, HTTP_API, WEBSOCKET, REDIS, SUPABASE) already cover routing — no new kinds needed for v1. |
| KV / cache / realtime | StoreService, RedisService | store.proto, redis.proto | Caching, presence, live message/notification fan-out. Reuse as-is. |
What we add — jobboard.proto
Section titled “What we add — jobboard.proto”Canonical source: packages/data/proto/jobboard/jobboard.proto (package jobboard, standalone, built via prost). It is not inlined here — that drifts; the proto is kept in sync with the SQL schema package (packages/data/sql/schema/jobboard/).
- Id conventions: entity ids are
bytes= the 16-byte ULID-as-uuid (public.gen_ulid()::uuid); user refs arebytes=auth.users(id); lookup ids (Vertical,Taxonomy) and the taxonomy/vertical id lists areuint64=bigintidentity; timestamps are ISOstring. - Messages mirror the tables: added
ConversationParticipant,Report,AuditLog;Messagecarriessender_participant_id(not a raw user id) and droppedread_at(read state is the participant cursor);Conversationdroppedparticipant_ids;ByIdkeys onbytes,ByLookuponuint64. - Taxonomy/vertical id lists (
discipline_ids/tool_ids/skill_ids/tag_ids/vertical_ids) stayrepeated uint64— the API view of the junction tables; relational integrity (same-vertical, ownership) lives in SQL.
Reuse summary
Section titled “Reuse summary”- New file:
jobboard.proto(1 file, 1 package). - Reused as-is:
JediEnvelope/MessageKind/PayloadFormat(jedi/jedi.proto),RedisService(jedi/redis.proto). (Identity reuse —Auth/User/Profileetc. — happens at the DB/service layer, not via proto import, sincekbveproto.protohas no package; see the reality-check note above.) - Reused as pattern (not copied):
kbve.discordsh.DiscordServer→Gig,ServerTag→Vertical/Taxonomy. - No new
MessageKindvalues — existing routing bits suffice for v1. - No hardcoded discipline enum — disciplines/tools/skills are data-driven taxonomy scoped by vertical, so new verticals require no proto change.
Safety
Trust & safety
- Vetting on both sides is the primary trust mechanism.
- Reporting + admin takedown for gigs, profiles, messages.
- Off-platform payment disclaimer + safety guidance (no escrow yet — set expectations clearly).
- Audit log for admin actions.
Open questions
Key risks / open questions
- Disintermediation / leakage — with payments off-platform, members can match once and transact off-platform forever. Mitigations: keep reviews/reputation valuable enough to bring people back; consider gating contact details until a stage; plan the monetization switch before leakage becomes habit.
- Vetting throughput — manual approval is the quality bar and a growth bottleneck. Needs a written rubric (per vertical) + a fast admin UI.
- Cold-start liquidity — curated + both-vetted = slowest to fill. Likely need to hand-seed one side first, in one vertical first. Game dev launches first; which side to seed?
- “Sanctioned” criteria undefined — what objectively qualifies a poster vs a taker, per vertical?
- Vertical expansion sequencing — the model supports many verticals, but each needs taxonomy seeding, a vetting rubric, and liquidity. Which vertical follows game dev, and on what trigger?
- Proto include paths — sharing
MediaAttachment/identity across packages needs a common protoc include root; decide whether to relocate shared protos or vendor.
Plan
Phased build plan
| Phase | Scope | Outcome |
|---|---|---|
| 0 — Foundations | Axum + sqlx + Postgres; migrations; verticals + taxonomy seed (game dev first); first-party auth (register/login/session/logout) + CORS; wire jobboard.proto into the build | API boots, auth works, DB ready |
| 1 — Identity & vetting | Membership application endpoints, admin vetting queue, capability grants (per vertical) | Members can be sanctioned |
| 2 — Profiles & portfolio | Talent/client profiles, portfolio CRUD + R2 media upload | Public profiles exist |
| 3 — Gigs | Post gig, gig moderation, browse + filter (vertical/discipline/tool), gig detail | Work is discoverable |
| 4 — Applications & selection | Apply, applicant management, accept → engagement | Matching works end-to-end |
| 5 — Messaging | Conversations + threads + attachments | Parties can talk |
| 6 — Engagements & reviews | Mark complete, two-sided reviews, reputation display | Trust accrues |
| 7 — Frontend client | Choose framework; build the UI against the API | Usable product |
| 8 — Discovery & notifications | Search polish, saved items, in-app notifications | Retention loops |
| 9 — Monetization hooks | Wire entitlements to a chosen model (sub/featured/credits) via reused Invoice/Setting | Revenue switch ready |
| 10 — Second vertical | Seed a new vertical (taxonomy + rubric); validate the model expands without schema change | Multi-vertical proven |
Locked & open
Decisions
Locked: curated, both-sides vetted · gig→apply mechanic · lead-gen v1 (off-platform payments) · monetization-agnostic hooks · game dev as the launch vertical, vertical-agnostic data model (verticals + data-driven taxonomy, no hardcoded discipline enum) · decoupled architecture · Rust/Axum API · Supabase auth (verify JWT, sub = auth.users.id; all jobboard rows FK auth.users) — first-party auth was a wrong turn, removing it · jedi PgCluster (tokio-postgres + bb8) + dbmate migrations against the KBVE cluster · standalone jobboard.proto (one package, standalone for Phase 0).
Still open:
- Frontend framework (Astro+React / Next.js frontend-only / React SPA) — deferred to Phase 7.
- Sanctioning rubric for posters and takers (per vertical).
- Which side to seed first for liquidity (in the game-dev vertical).
- Which vertical follows game dev, and on what trigger.
- When/whether to gate contact info to reduce leakage.
- Eventual monetization model (subscription / featured / credits).
- cookie-only vs cookie+bearer; OAuth providers (Discord/GitHub/Steam) for v1.
- Proto include-path strategy (shared root vs vendor).
Questions
Frequently asked
What is the KBVE Job Board?
It is a curated, both-sides-vetted freelance platform where sanctioned members post gigs and other sanctioned members take them up. It launches in the game-dev vertical but is architected so additional service verticals can be added as data, without schema or code changes.
What is the Job Board's technical architecture?
It is a decoupled Rust/Axum REST API served to a separate frontend client. Auth is Supabase (verifying the JWT sub as auth.users.id), DB access is via jedi PgCluster over Postgres, migrations use dbmate, and the wire format is protobuf over the jedi envelope.
How does the Job Board add new verticals without schema changes?
Disciplines, tools, and skills all live in a taxonomy table scoped by vertical_id rather than a hardcoded enum, and gigs and profiles reference taxonomy ids. Launching a new vertical is a seed-data operation, not a schema or code change.
