🍊 is a permissionful api access layer for jelly
- 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".
2026-08-29.11-28-41.mp4
on your own instance or an instance where you have access, open the docs and play around with different endpoints.
- 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
First, install the dependencies:
pnpm installThis project uses PostgreSQL with Drizzle ORM.
-
Make sure you have a PostgreSQL database set up.
-
Set
DATABASE_URLin your environment or update your localapps/web/.envfile with your PostgreSQL connection details. -
Apply the schema to your database:
pnpm run db:pushdb: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 NULLcolumn with no default makesdb:pushstop 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.resyncrepairingexists_in_jellyis the pattern to copy.
Then, run the development server:
pnpm run devOpen http://localhost:3001 in your browser to see the fullstack application. Use the Expo Go app to run the mobile application.
- Target: web + server
- Config:
docker-compose.yml(app Dockerfiles live inapps/*/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 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 32A 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.
pnpm test # everything, through turbo
pnpm test:watch # from a package directoryThey 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 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.
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.
key:
- 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
- admin actions
- 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
- perm presets
-
- member actions
- request own/other member access to be granted
- admin actions
- 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
- see all better auth users
- 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
- admin actions
- 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
- permissions with keys
- 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 /revokechecks the whole key, not just its printed prefix
-
- revokability)
- 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
-
dryRunon 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
-
mailboxandjelly_contactuniqueness fit for more than one team -
jelly_team_idon 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 tunnelshost 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
-
swaggerscalar api docs- documented output schemas
-