Skip to content
hackclubPublic

About

🍊 is a permissionful api access and administration layer for 🍓

Resources

Code of conduct

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

marmalade

🍊 is a permissionful api access layer for jelly

goals

  • simple to use
  • everything opt-in
  • finely-grained & least-privelidged api access
  • fully audit-trailed

note: write access runs through a durable action queue (jelly_action). every mutation is persisted before dispatch, retried on transient failure, and audited. jelly's api cannot send customer-facing email — api tokens may create drafts and internal comments only — so marmalade's "send" is "compose a draft a teammate finishes in jelly".

demonstration

2026-08-29.11-28-41.mp4

(interactive) api docs!

on your own instance or an instance where you have access, open the docs and play around with different endpoints.

image

stuff that does stuff

  • TypeScript - For type safety and improved developer experience
  • TanStack Start - SSR framework with TanStack Router
  • TailwindCSS - Utility-first CSS for rapid UI development
  • Shared UI package - shadcn/ui primitives live in packages/ui
  • oRPC - End-to-end type-safe APIs with OpenAPI integration
  • Drizzle - TypeScript-first ORM
  • PostgreSQL - Database engine
  • Authentication - Better-Auth
  • Turborepo - Optimized monorepo build system

development

First, install the dependencies:

pnpm install

db

This project uses PostgreSQL with Drizzle ORM.

  1. Make sure you have a PostgreSQL database set up.

  2. Set DATABASE_URL in your environment or update your local apps/web/.env file with your PostgreSQL connection details.

  3. Apply the schema to your database:

pnpm run db:push

db:push is how schema reaches a database here, including production — there is no drizzle.__drizzle_migrations table. The files under packages/db/src/migrations are generated for reference and for anyone who prefers db:migrate; they are not the deploy path.

Two consequences worth knowing before adding schema:

  • A NOT NULL column with no default makes db:push stop and ask for one, which fails a non-interactive deploy. Give it a default or make it nullable.
  • Data changes written into a migration file never run. Put them in application code that is safe to run repeatedly — team.resync repairing exists_in_jelly is the pattern to copy.

Then, run the development server:

pnpm run dev

Open http://localhost:3001 in your browser to see the fullstack application. Use the Expo Go app to run the mobile application.

deployment

  • Target: web + server
  • Config: docker-compose.yml (app Dockerfiles live in apps/*/Dockerfile)
  • Build images: pnpm run docker:build
  • Start: pnpm run docker:up
  • Logs: pnpm run docker:logs
  • Stop: pnpm run docker:down

Environment variables are read from each app's .env file (baked into web builds for public variables) and overridden in docker-compose.yml for container networking.

jelly credentials

Jelly API tokens and webhook signing secrets live in the jelly_team table, encrypted with AES-256-GCM, rather than in the environment. Connecting a team is a row, not a redeploy, and a token can be rotated without one.

MARMALADE_ENCRYPTION_KEY is required and protects those columns at rest:

openssl rand -base64 32

A missing key stops the process at boot. That is deliberate — the alternative is a fallback that quietly stores Jelly tokens in plaintext.

An existing deployment needs no manual migration: if JELLY_API_KEY and JELLY_WEBHOOK_SECRET are still set, they are encrypted into the team row the first time Marmalade talks to Jelly, and both variables can then be removed.

After that, manage credentials through the admin API:

Endpoint Purpose
GET /admin/team whether credentials are present, and the webhook URL to give Jelly
POST /admin/team/api-token store a token (validated against Jelly before it is saved)
POST /admin/team/webhook-secret generate a secret, returned once
PUT /admin/team/webhook-secret store the secret Jelly generated

No endpoint returns a stored secret.

tests

pnpm test          # everything, through turbo
pnpm test:watch    # from a package directory

They need no database and no credentials. The suite runs against PGlite — Postgres compiled to WebAssembly — with the schema applied by drizzle-kit push, which is how production applies it too. A mock would answer whatever it was told to, and these tests exist to catch a where clause that forgot to name a team: that only shows up against an engine that really enforces the constraints.

What is covered: mailbox and router scope checks, instance-operator authority, team resolution from the header and cookie including the membership check, API keys that act in more than one team, the uniqueness that makes two teams representable at all, and the backfill's refusal to guess whose rows are whose.

The two teams in the fixtures deliberately both own a mailbox called general, and one person is staff in both with a different role in each. Most cross-tenant bugs are invisible unless the teams look alike.

jelly rate limits

Jelly publishes two limits, and they do not scale the same way. The difference matters as soon as a deployment serves more than one team.

Limit Scope What that means here
100,000 per day per team each connected team brings its own budget
5,000 per 5 minutes per source IP shared across every team on the deployment, because they all egress from the same address

The daily ceiling lives on jelly_team.daily_ceiling, defaulting to 80,000 so there is headroom for work Marmalade does not drive. Adding a team adds its own 100,000; one team exhausting its budget does not pause another.

The five-minute limit is the one to watch, because it is not per team: ten teams at 4,000 requests per 5 minutes each would each stay under their own ceiling and collectively hit a 429 that no per-team gauge predicted.

Marmalade therefore keeps an instance-wide bucket (scope = 'instance') that every outbound call spends, checked before the per-team ceilings, and a 429 opens the circuit for the whole deployment rather than for whichever team happened to make the call. /admin/health reports the shared window next to the team's own numbers, since it is the ceiling a team cannot explain from its own traffic.

Worth measuring rather than assuming: on a host with rotating egress addresses the per-IP limit is pessimistic rather than wrong, so ceilings tuned around it should be checked against real numbers.

Marmalade is a read-through mirror, which is what keeps it well under both: reads are served from Postgres and only sync and write actions reach Jelly.

choosing a team

A request acts in exactly one Jelly team, resolved at the context boundary.

Caller How the team is chosen
API key the team on the key row; override with X-Marmalade-Team if the key is granted more
Webhook the endpoint, /api/webhook/jelly/{slug}
Session the navbar team switcher, which sets X-Marmalade-Team and a matching cookie

Nothing about an existing integration changes. A key names its team, so a request that sends no header behaves exactly as it did before multi-team.

A key can be granted further teams at creation with teamIds, and then names one per request:

curl https://<host>/api/rpc/mailboxes \
  -H "Authorization: Bearer mrmld_..." \
  -H "X-Marmalade-Team: haven"

A team the key was not granted is refused with 403 rather than silently falling back to its own. A key cannot be granted reach its creator does not have: a session can only grant teams it is a staff contact in, and a key minting another key can only pass on teams it already holds.

Mailbox scopes are resolved against the active team, because a mailbox id is unique within a team and not across teams. Two teams with a mailbox of the same name do not share a scope.

tasks

key: ‼️ = poc-critical

  • jelly sync
    • ‼️ manually sync mailboxes and team members
    • routinely resync mailboxes and team members
    • resync mailboxes and team members on registration
    • ‼️ sync through jelly webhook
      • new_message
      • assigned
      • comment_added
      • conversation_archived
      • conversation_unarchived
  • mailboxes
    • admin actions
      • ‼️ create marmalade mailbox for jelly mailbox
      • de/re-activate marmalade mailboxes
      • view all mailboxes regardless of membership (toggle)
      • immune from member deletions
    • member actions
      • request marmalade mailbox created for jelly mailbox
  • mailbox members
    • admin actions
      • ‼️ create marmalade mailbox member for jelly mailbox member
      • grant/rescind api perms for marmalade mailbox members
        • mailbox-scoped perms
        • data-scoped perms
          • perm presets
            • metadata vs content
    • member actions
      • request own/other member access to be granted
  • admin panel
    • see all better auth users
      • bans
      • impersonation
    • instance admins are separate from jelly team roles
    • admin actions audited (incl. who impersonated whom)
    • see all audit logs (paginated + filterable, rejections included)
    • service health: worker, queue depth, quota gauges, webhook liveness
    • usage by key/user/endpoint from hourly rollups
    • action queue browser with retry/cancel/approve/force-fail
  • keys
    • admin actions
      • view all keys regardless of ownership (toggle)
    • member actions
      • view all apikeys in permissioned mailboxes
      • view all their apikeys
      • ‼️ create api keys
      • rescind api keys
  • convos
  • conversation assignment
  • messages
  • comments
  • attachments
  • labels
  • security/access
    • ‼️ hack club oidc auth
      • + email OTP auth?
    • ‼️ basic audit logging
      • audit changelogs (e.g. deletion archives & update diffs)
    • ‼️ orpc openapi setup!
      • permissions with keys
        • mailbox-scoped keys
        • data-scoped keys
          • metadata
          • content
    • more throroughly audit log request attempts regardless of status
      • evlog for audit logs
    • ensure all admin routes are protected appropriately
    • admins should not be able to mutate owners
    • standardized key prefix
      • revokability)
        • POST /revoke checks the whole key, not just its printed prefix
    • jelly credentials encrypted at rest in jelly_team, not in env
    • rotate the jelly api token and webhook secret without a redeploy
    • enforce mailbox scoping on conversation/message/comment reads
  • write actions
    • durable action outbox with idempotency, retry and backoff
    • inline dispatch with queue fallback (202 + poll)
    • action scopes on api keys (fail closed) + per-mailbox write opt-in
    • feature parity with jelly's documented write surface
    • scheduled actions + cancel before dispatch
    • dryRun on every write
    • per-key and per-mailbox write ceilings (quota_policy)
    • admin queue browser and dead-letter requeue
    • approval mode for keys that are not trusted yet
  • plumbing
    • ensure uniqueness of relational tables and make references "official"
    • multiple jelly teams
      • credentials per team, encrypted
      • mailbox and jelly_contact uniqueness fit for more than one team
      • jelly_team_id on the content tables
      • team resolved from the request instead of JELLY_TEAM_ID
      • per-team webhook endpoints, /api/webhook/jelly/{slug}
      • instance operators, separate from jelly team admins, who onboard teams
      • team switcher for session users who belong to more than one team
      • api keys can be granted more than one team and pick one per request
      • instance-wide 5-minute quota bucket (jelly rate-limits per IP, not per team)
      • per-team worker fairness in the action drain
      • row-level security
    • first time sync on registration and scheduled/manual org teammember resyncs
    • track jelly requests made and add builtin quotas to avoid ratelimits
      • for now, simply track every jelly request in db and monitor success/fail
    • completely jelly-backwards-compatible api? (api v2)
    • indexes
    • host on nest w/ cf tunnels host on vercel
      • move to enterprise vercel
  • qol
    • use better auth's apikey system cause im a dumb dumb
    • custom error pages/toasts
    • "unauthorized" redirects to /login
    • CI
    • store markdown in addition to html content?
  • dx
    • swagger scalar api docs
      • documented output schemas

About

🍊 is a permissionful api access and administration layer for 🍓

Resources

Code of conduct

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages