duneforge
v3.0.0
Published
Universal backend: introspects your MySQL/Postgres schema and serves an access-controlled REST API, auth, file upload, generated docs and a master console.
Maintainers
Readme
DuneForge
A universal backend. Point it at a MySQL, MariaDB or Postgres database and it serves a complete, access-controlled REST API — CRUD for every table, authentication, file upload, generated documentation and a master console — with no code generation and nothing written to disk.
Node 20.19+ · PostgreSQL 12+, MySQL 8.0.16+ or MariaDB 10.5+ · TypeScript · ESM + CommonJS
import { init } from 'duneforge'
const api = await init({
db: { client: 'postgres', host: 'localhost', database: 'shop', user: 'app', password: '…' },
jwt: { secret: process.env.JWT_SECRET }, // 32+ chars, stable across restarts
console: { masterPassword: process.env.DUNEFORGE_MASTER },
bootstrapAdmin: { identifier: '[email protected]', password: process.env.ADMIN_PASSWORD },
})
await api.listen(3000)That is the whole integration. DuneForge introspects the schema on boot, creates its own
duneforge_* tables, and serves a full API over your existing tables.
Contents
Install · Quick start · Routes ·
Querying · Auth · Files · Security ·
Row scoping · Custom routes ·
Adapters · init() options ·
MySQL and MariaDB · Browser frontends ·
Before production · Limitations
Install
npm install duneforge # or: pnpm add duneforge · yarn add duneforgeThat is all you need. Both database drivers come with it — pg and mysql2 are
optional dependencies, so every package manager installs them for you, and DuneForge loads
only the one your db.client names. MariaDB uses mysql2, so it needs nothing extra.
Nothing native is compiled: password hashing uses node:crypto scrypt.
If you want only the driver you actually use, skip the optional dependencies and install that one yourself:
npm install duneforge --omit=optional && npm install pg
yarn add duneforge --ignore-optional && yarn add pg
pnpm add duneforge --no-optional && pnpm add mysql2Two integrations stay opt-in, because they are large and only matter if you configure them. These are optional peer dependencies, so install them yourself when you want the feature:
npm install nodemailer # email OTP, password reset
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner # S3 / R2 storageConfigure one of those without installing it and DuneForge throws a clear error naming the package and the command for every manager.
Requirements
| | |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Node | 20.19 or newer. Kysely is ESM-only and 20.19 is the first Node whose require() can load an ES module, which the CommonJS build needs. |
| Database | PostgreSQL 12+, MySQL 8.0.16+ or MariaDB 10.5+. Below those, recursive CTEs and CHECK-derived enums do not work and row scoping would misbehave silently, so DuneForge asserts the version at boot and refuses to start. |
| Module format | Ships both ESM and CommonJS with correct types for each. import and require both work. |
Tested on Node 20.19+ with npm, pnpm and yarn (including Yarn Plug'n'Play — every lazy import resolves a dependency this package declares, so PnP's strictness has nothing to refuse). Bun and Deno are not tested.
Quick start
From an empty database to a working, scoped API.
1. Design your schema first
DuneForge reads your schema; it never writes one for you. Create your tables however you like:
create table products (
id bigserial primary key,
sku varchar(32) not null unique,
name varchar(120) not null,
price numeric(10,2) not null,
cost_price numeric(10,2),
status varchar(16) not null default 'draft'
check (status in ('draft','live','archived'))
);Two things DuneForge picks up automatically:
check (status in (...))becomes a real enum, in validation and in the generated docs.- A foreign key to
duneforge_users(id)is what makes a table ownable, and therefore scopable per user.duneforge_usersonly exists after the first boot, so add that foreign key in a second migration:
create table orders (
id bigserial primary key,
user_id bigint not null references duneforge_users(id),
total numeric(10,2) not null,
placed_at timestamptz not null default now()
);2. Boot
import { init } from 'duneforge'
const api = await init({
db: { client: 'postgres', host: 'localhost', database: 'shop', user: 'app', password: '…' },
jwt: { secret: process.env.JWT_SECRET },
console: { masterPassword: process.env.DUNEFORGE_MASTER },
bootstrapAdmin: { identifier: '[email protected]', password: process.env.ADMIN_PASSWORD },
})
await api.listen(3000)On first boot DuneForge creates its own duneforge_* tables, seeds your admin as a
superuser, and seeds an empty non-superuser role named user for self-registration.
Everything answers
404right now, except to the admin. That is deliberate, not a misconfiguration — nothing is ever accidentally public. Granting is step 4.
3. Log in
TOKEN=$(curl -s localhost:3000/api/auth/login \
-H 'content-type: application/json' \
-d '{"identifier":"[email protected]","password":"…"}' \
| sed 's/.*"token":"\([^"]*\)".*/\1/')
curl -s localhost:3000/api/products -H "Authorization: Bearer $TOKEN"Every route needs a token. There is no anonymous access and no public role.
4. Grant access
Open localhost:3000/admin, sign in with the master password, and use the grant
grid. Or drive the same JSON API the UI uses:
MASTER=$(curl -s localhost:3000/admin/login \
-H 'content-type: application/json' \
-d '{"password":"…"}' | sed 's/.*"masterToken":"\([^"]*\)".*/\1/')
# Customers read live products — but not cost_price, which is simply absent for them.
curl -s -X PATCH "localhost:3000/admin/permissions/$ROLE_ID/products" \
-H "Authorization: Bearer $MASTER" -H 'content-type: application/json' \
-d '{"canRead":true,"readColumns":["id","sku","name","price","status"]}'Grants are per role, per table, per operation, per column. A column you do not name is not readable — and a column added by a later migration is denied until you grant it. Fields you leave out keep their current value, so flipping one flag is safe.
Policy is read from the database on every request. No cache, no reload: the next request sees the change.
5. Scope the rows
Grants say which columns. The duneforge_ownership table says which rows:
# Each customer sees only their own orders.
curl -s -X PATCH localhost:3000/admin/ownership/orders \
-H "Authorization: Bearer $MASTER" -H 'content-type: application/json' \
-d '{"scope":"self","ownerColumn":"user_id"}'That is the whole setup. Details of both steps are below.
What you get
GET /api/:table ?filters &sort &select &include &search &page &limit
GET /api/:table/:id
POST /api/:table POST /api/:table/bulk
PATCH /api/:table/:id PUT /api/:table/:id DELETE /api/:table/:id
POST /api/_tx several operations across tables, atomically
GET /api/_health
POST /api/auth/login /register /otp/request /otp/verify /otp/widget-verify
GET /api/auth/oauth/:provider /api/auth/me
POST /api/auth/logout /link /password/forgot /password/reset
POST /api/files/presign /api/files/:id/confirm GET /api/files/:id
GET /admin the master console (UI + JSON API + API docs)Responses are always { data, meta }. Errors are { error: { code, message, details } }
with these codes:
| code | status | | code | status |
| ---------------------- | ------ | --- | ------------------------ | ------ |
| bad_request | 400 | | payload_too_large | 413 |
| auth_required | 401 | | unsupported_media_type | 415 |
| auth_forbidden | 403 | | validation_failed | 422 |
| not_found | 404 | | rate_limited | 429 |
| conflict_unique | 409 | | server_error | 500 |
| conflict_foreign_key | 409 | | | |
The idea
The database is the source of truth for schema and policy. Tables, columns, foreign
keys and enums come from information_schema; who may see and change what comes from a
small set of fixed duneforge_* tables in the same database. So init() stays tiny, and
access rules change with an UPDATE rather than a redeploy.
Querying
GET /api/products?status=live&price=gte.10&sort=-price&select=id,name,price&page=2&limit=50| what | how |
| --------- | ---------------------------------------------------------------- |
| filter | ?status=live or ?status=eq.live |
| operators | eq ne neq lt lte gt gte in nin like nlike ilike is |
| list | ?status=in.(live,archived) |
| null | ?image_file_id=is.null |
| text | ?name=ilike.*shirt* |
| sort | ?sort=-placed_at,name (- is descending) |
| columns | ?select=id,name |
| relations | ?include=user (one level, named — no *, no dotted paths) |
| search | ?search=blue (needs searchColumns and a full-text index) |
| paging | ?page=2&limit=50 (not offset — that is a 422) |
| total | counted by default; ?count=0 skips it on a hot list |
A filter naming a column you cannot read is a 422, not a silent drop — dropping it would return more rows than you asked for.
Writes:
POST /api/orders PATCH /api/orders/:id DELETE /api/orders/:id
POST /api/orders/bulk best-effort per row, reports both outcomes
POST /api/_tx several tables, one transaction, all-or-nothingAuth
POST /api/auth/register always on; lands in the seeded `user` role
POST /api/auth/login { identifier, password }
POST /api/auth/otp/request emailed 6-digit code
POST /api/auth/otp/verify a code for an unknown address creates the account
GET /api/auth/oauth/:provider google · github · facebook · apple
POST /api/auth/password/forgot /reset — a reset ends every existing session
GET /api/auth/me who am I, and which identities
POST /api/auth/logout effective on the next requestA registered account can authenticate and see nothing until its role is granted columns.
/auth/link adds a second login (say, a password) to an account — but only for an address
that account has already verified.
Phone OTP uses the MSG91 OTP Widget: DuneForge verifies the widget's token and never issues a code itself.
Files
POST /api/files/presign -> { fileId, uploadUrl } then PUT your bytes to uploadUrl
POST /api/files/:id/confirm -> marks it usable
GET /api/files/:id -> a signed, time-limited URLGive a table a ..._file_id column referencing duneforge_files and reads expand it into a
sibling object with a ready-to-use url. Use the s3 driver in production; the
filesystem driver refuses to start when NODE_ENV=production.
Security posture
Deny by default. A role sees a table only if a permission row grants it, sees a column only if that column is named in the grant, and reaches any generated route only with a valid session. There is no anonymous access.
A fresh install therefore answers 404 for every table except to the bootstrap admin, who is a superuser. Nothing can be left accidentally open. The console's diagnostics panel lists every ungranted table and column with a one-click path to the grant grid.
Registration is always on. POST /api/auth/register always exists, and a verified email
or phone OTP for an unknown identifier creates an account too. New users land in a
non-superuser role DuneForge seeds for the purpose (user, renameable via
auth.defaultRoleName), and that role starts with no grants — so a registered account can
authenticate and see nothing at all. DuneForge refuses to boot if the registration role is a
superuser role.
A refusal is a 404, byte-identical to the response for a table that does not exist —
so the API cannot be used to enumerate your schema.
Two floors cannot be configured away, for any role including a superuser:
| floor | columns | effect |
| ---------- | ------------------------------------------------------------------------------------------ | --------------------------------------- |
| secret | duneforge_identities.secret, duneforge_otps.code_hash, duneforge_sessions.token_hash | never read or written through any route |
| escalation | duneforge_users.role_id, all of duneforge_user_managers | writable only by a superuser role |
Sessions are validated against the database on every request, so a logout, a deactivation or a role change takes effect on the next one.
Every query is built with parameters and dialect-quoted identifiers that come only from introspection, never from a request.
Row scoping
duneforge_ownership says how each table's rows are owned:
table_name owner_column owner_via scope bypass_roles
orders user_id NULL hierarchy [1]
user_notes user_id NULL self [1]
order_items NULL order_id->orders.user_id hierarchy [1]
products NULL NULL none -| scope | who sees a row |
| ----------- | ---------------------------------------------------------- |
| none | everyone with a grant on the table |
| self | the owner only |
| hierarchy | the owner, plus everyone below them in the reporting graph |
For a table with no owner column of its own, point at its parent:
{ "scope": "hierarchy", "ownerVia": "order_id->orders.user_id" }hierarchy uses duneforge_user_managers, which is a multi-parent directed graph, not a
tree: a user may report to several managers, and cycles are legal — a cycle is a
mutual-visibility grant, and the console flags every one it finds.
Scoping applies to reads, updates and deletes alike, so an out-of-scope row is a 404 and never a partial write.
Custom routes
No hook system — DuneForge hands you primitives and you write routes:
import { init, DuneForgeError } from 'duneforge'
await init({
// …
routes: (r) => {
r.get('/me/summary', r.auth(), async (ctx) => {
const team = await ctx.visibleUsers()
return { data: { userId: ctx.user!.id, teamSize: team.length } }
})
r.post('/checkout', r.auth(), async (ctx) => {
const { total } = (await ctx.body()) as { total: string }
return ctx.tx(async (t) => {
const order = await t.insert('orders', { total, user_id: ctx.user!.id })
await ctx.mail?.send({ to: ctx.user!.email!, subject: 'Order placed', html: '…' })
return { data: order }
})
})
r.post('/webhook/razorpay', async (ctx) => {
if (!verify(await ctx.rawBody(), ctx.headers)) {
throw new DuneForgeError('bad_request', 'Bad signature')
}
if (!(await ctx.webhooks.once('razorpay', eventId))) return { data: { ok: true } }
return { data: { ok: true } }
})
},
})ctx carries user, role, visibleUsers(), db, tx(), files, mail,
webhooks.once(), body(), rawBody(), params, query, headers, ip and log.
Note that body() and rawBody() are async methods, not properties. Return { data },
or throw a DuneForgeError. Custom routes are yours — the grant system does not apply to
them.
ctx.visibleUsers() returns every user id below you in the reporting graph, as an array.
The generated /api routes never build that list — their scope predicate embeds the
recursive CTE as a subquery — so for a user near the top of a large organisation this is the
one call that materialises it. Prefer filtering in SQL (where owner_id in (subquery)) over
pulling the ids into JavaScript and filtering there.
Payment gateways are deliberately not in DuneForge: every integration differs enough to
belong in your own route, which is what rawBody() and webhooks.once() are for.
Framework adapters
Skip the framework entirely with api.listen(3000), or mount DuneForge inside an app you
already have. Mount on a path, not at the root — the adapters answer every request that
reaches them and never fall through:
import { expressAdapter } from 'duneforge/express'
// BEFORE express.json(): a body parser re-serializes the bytes, and ctx.rawBody() is what
// an HMAC signature check has to run over.
app.use('/backend', expressAdapter(api.handler))
import { fastifyPlugin } from 'duneforge/fastify'
await app.register(fastifyPlugin(api.handler), { prefix: '/backend' })
import { honoHandler } from 'duneforge/hono'
app.all('/backend/*', honoHandler(api.handler))Each one strips its own mount prefix, so the routes below are always relative to it:
/backend/auth/login, not /auth/login.
Rate limiting needs the caller's address, and a Request does not carry one. The
bundled adapters supply it; if you wire api.handler or api.fetch into a runtime
directly, tell DuneForge where the connection came from or every caller shares one
rate-limit bucket:
import { setPeerAddress } from 'duneforge'
export default {
fetch(request: Request, env: Env, ctx: ExecutionContext) {
setPeerAddress(request, request.headers.get('cf-connecting-ip'))
return api.fetch(request)
},
}It is deliberately not a header — a header could be sent by the caller, and the rate-limit key is what stands between a guesser and the console's master password.
init() options
Only db and jwt are required.
| option | type | default | what it does |
| ---------------------------------------------------------------------------- | ----------------------------------------------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| db.client | 'postgres' \| 'mysql' \| 'mariadb' | — | Required. Which server. 'mariadb' is its own value, not an alias for 'mysql' — see MySQL and MariaDB. |
| db.database | string | — | Required. |
| db.host / db.port / db.user / db.password | | | Connection. |
| db.schema | string | 'public' | Postgres only, and only 'public' is supported — boot refuses anything else. |
| db.ssl | unknown | — | Passed to the driver. |
| db.poolMax | number | 10 | Pool ceiling. Minimum 4 — boot pins one connection for its migration lock and needs others while holding it. |
| db.acquireTimeoutMs | number | 10000 | How long to wait for a connection before failing, instead of waiting for ever. |
| db.statementTimeoutMs | number | off | Kill a query that runs longer than this. Off by default; see Before production. |
| jwt.secret | string | — | Required, 32+ characters, and stable across restarts — rotating it logs everyone out. |
| timezone | string | UTC | IANA zone that timestamps render in. Storage is always UTC. |
| bootstrapAdmin | { identifier, password, roleName? } | — | Seeded once, as a superuser, on first boot. |
| auth.defaultRoleId | string \| number | — | Role self-registered users land in. May not be a superuser role — boot fails if it is. |
| auth.defaultRoleName | string | 'user' | Name of the role seeded when defaultRoleId is absent. |
| auth.sessionTtl | string \| number | | How long a session lives. |
| auth.password | boolean | | Enable password login. |
| auth.emailOtp | { enabled?, ttl?, maxAttempts?, length? } | — | Needs mail. |
| auth.phoneOtp.msg91 | { widgetId, authKey } | — | MSG91 OTP Widget; both fields required together. |
| auth.oauth | { google?, github?, facebook?, apple?, successRedirect? } | — | Each provider takes { clientId, clientSecret, scope?, redirectUri? }. Needs publicUrl unless every provider sets redirectUri. |
| auth.passwordReset.ttl | string \| number | | Reset-link lifetime. |
| mail.smtp | { host, port?, secure?, user?, pass? } | — | Your own SMTP. Requires nodemailer. |
| mail.from | string | — | Envelope sender. |
| storage.driver | 's3' \| 'r2' \| 'filesystem' | — | filesystem refuses to start under NODE_ENV=production. |
| storage.bucket / region / accessKeyId / secretAccessKey / endpoint | | | S3 / R2 credentials. |
| storage.directory | string | — | filesystem driver only. |
| storage.signedUrlTtl | string \| number | | Signed-URL lifetime. |
| storage.maxUploadBytes | number | | Upload ceiling. |
| console.masterPassword | string | — | 12+ characters. Omit the whole console block and no console route is registered at all. |
| console.mountPath | string | /admin | Where the console lives. Outside api.prefix by default — /api is the API, /admin is the console. Set it back to ${prefix}/_duneforge for the pre-2.1.0 path. |
| console.tokenTtl | string \| number | | Master-token lifetime. |
| api.prefix | string | '/api' | Must start with /. |
| api.defaultLimit / api.maxLimit | number | | Page size, and its ceiling. |
| api.includeRowCap | number | 25 | Rows embedded per ?include= relation. |
| api.maxBodyBytes | number | 1 MiB | Largest JSON body accepted. |
| publicUrl | string | — | Public origin, used to build OAuth redirect URIs. |
| trustProxy | boolean | false | Honour x-forwarded-for for the client IP. |
| rateLimit | { enabled?, api?, auth?, console? } | on | Each bucket takes { windowSeconds, max }; console also takes lockoutSeconds. |
| logger / logLevel | | | Bring your own logger. |
| routes | (r: RouteBuilder) => void | — | Your custom routes. |
What init() returns
const api = await init({ … })
api.handler // (Request) => Promise<Response>
api.fetch // the same, named so the instance can be passed to anything expecting `fetch`
api.listen(port, host?) // start a standalone Node server
api.close()
api.reload() // re-introspect, reconcile, rebuild routes and docs in place
api.schema() // the current schema snapshot
api.diagnostics() // ungranted tables/columns, missing indexes, scoping problems
api.routes() // every route, with its auth and master flags
api.db // the underlying Kysely instance
api.config // the resolved configurationThe console
GET /admin is a bundled, zero-build UI: plain HTML, CSS and vanilla JavaScript,
no bundler and no CDN. It covers the grant grid (roles × columns × R/C/U with bulk toggles
and a diff before saving), ownership and scoping, roles, users, identities, the reporting
graph, live sessions, a permission explainer that shows the exact predicate a role will get,
a full data browser, boot diagnostics, and the generated API documentation.
It teaches itself. The first sign-in from a browser lands on Start here, which
explains the model in five rules — deny by default, roles, column grants, row scoping,
floors — then shows a checklist read live from your database (how many roles exist, how
many tables answer to anyone, whether anything is scoped) with a link straight to the
screen that fixes each gap, an ordered walkthrough of turning the API on, and a glossary.
Every other screen carries a "How this works" panel, open by default and collapsible once
you know it; the grant grid adds a legend for every cell state. A fresh install that
answers 404 to everybody says so, in those words, rather than looking broken. Collapsed
panels are remembered in localStorage — still never a cookie.
It holds its token in sessionStorage and sends it as a Bearer header. It sets no
cookies — that is load-bearing, because CORS on the data routes is *, which is only safe
while authentication is a header rather than a cookie.
Omit console from init() and none of it is registered at all.
Documentation for whoever builds the frontend
Under the console: OpenAPI 3.1, generated guide pages that reflect the running
configuration, a permission and scoping matrix, and downloadable types.ts and client.ts.
Download the two TypeScript files once and commit them to the frontend repo — they need a
master token, so a build step cannot fetch them.
MySQL and MariaDB
Both are supported, and db.client must name the one you actually have:
await init({ db: { client: 'mysql' /* … */ } })
await init({ db: { client: 'mariadb' /* … */ } })'mariadb' is not an alias for 'mysql'. MariaDB uses the same mysql2 driver and the
same SQL, but it reports four things about your schema differently, and every one of them
is silent — the schema reads back successfully and means something else. DuneForge asks the
server what it is at boot and refuses to start if the answer does not match db.client,
because guessing wrong is not something you would find out until the wrong data came back.
What differs, and what DuneForge does about it — none of which you have to think about, listed so you can tell it is handled:
| MariaDB behaviour | Why it matters | Handled by |
| ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| json is LONGTEXT + an automatic CHECK (json_valid(col)), not a type | A JSON column would read as plain text, silently turning off JSON validation on write, parsing on read, and the refusal of JSON-path filters | Introspection reads that constraint back and restores the JSON type |
| A column-level CHECK is named after its column, so two tables both have one called status | The MySQL-shaped lookup would hand one table's allowed values to another table's column, and then validate writes against them | MariaDB's information_schema carries the table name directly, so no name-matching is used |
| A nullable column with no default reports the string NULL; a literal default comes back quoted | Every nullable column would look defaulted, and so optional in the generated types, the OpenAPI document and the console editor | Both are normalised to what MySQL reports |
| A CHECK violation is errno 4025, where MySQL uses 3819 | The violation would surface as a 500 instead of a 422 | Matched on errno and SQLSTATE, since 4025 means something unrelated on MySQL |
Versions. MariaDB 10.5 or newer. The suite is executed against 10.5, 10.11, 11.4 and 13.0; the floor is 10.5 because that is the oldest release actually tested, not the oldest that would technically work.
Two differences DuneForge does not paper over, because they are MariaDB's behaviour rather than a reporting quirk:
- Full-text search tokenises differently. MariaDB has its own stopword list and no
ngramparser, so?search=returns a different set of rows than MySQL would for the same query. Relevance is never exposed, so only which rows match changes. - There is no
cte_max_recursion_depth. MariaDB'smax_recursive_iterationsdefaults to effectively unlimited, so on a cyclic reporting graph theUNIONde-duplication in the scoping query is the only thing that ends the recursion. It is deliberate, and documented in the source so nobody turns it intoUNION ALL.
Talking to DuneForge from a browser
Every generated route needs a token (§60), and DuneForge sets no cookies. So a browser app has three options, and the recommendation is the middle one:
browser ──→ your server ──→ DuneForge ← do this
(holds the token)
browser ──→ DuneForge ← only for a trusted internal tool
(holds a long-lived token)Proxy through your own server. Your frontend talks to your own backend — a Next.js route handler, an Express endpoint, whatever you already have — and that server holds the DuneForge token and forwards the request. The credential never reaches the browser, you can add your own per-route rules on top, and nothing about DuneForge has to change. The cost is that you run a server; if you already have one, this is close to free.
A token in the browser is a readable credential. Anyone with the page has it, it cannot be scoped below its role, and revoking it means revoking that session. It is fine for an internal admin tool behind SSO. It is not fine for a public storefront.
A custom route (see below) is the middle path when you only need a handful of public reads: write them as unauthenticated routes on the DuneForge instance and keep the generated CRUD behind auth.
Whichever you pick, if you hand api.handler or api.fetch to a runtime yourself, call
setPeerAddress() — see Framework adapters.
Before production
jwt.secretstable and shared across instances. Rotating it logs every user out, immediately and everywhere — every session token is signed with it, and there is no staged rotation. Plan it like a forced logout, not like a config change.- Set
db.statementTimeoutMs. It is off by default because the right value is per query, but without it one slow scan holds its connection until the database gives up.30000is a reasonable starting point for an API. Note it means different things per server: PostgreSQL and MariaDB apply it to any statement, MySQL only to read-onlySELECTs. console.masterPasswordis one password in front of a full data editor, and the console has no audit log. Treat it like a database password, or omitconsoleentirely.- Storage driver
s3, notfilesystem. - Rate limiting is in-memory, so limits are per instance (see below).
- CORS on data routes is
*by design — safe only because authentication is a Bearer header and DuneForge sets no cookies. Do not add cookie auth without revisiting it. - Check the console's diagnostics panel: ungranted tables, missing indexes, scoping problems.
Limitations and what is not yet verified
Stated plainly, because a generic API layer over a whole database is worth being honest about:
- Rate limiting is in-memory only. Limits are per process, so a multi-instance deployment multiplies them. There is no Redis store.
- The console has no audit log. Who changed what through it is not recorded.
- Postgres
db.schemasupports only'public'. Boot refuses anything else rather than silently reading one schema and writing another. - Not yet exercised against live services: OAuth against real providers, S3/R2 storage, and the console UI in a browser. The code paths are covered by tests with stubs, but they have not been run against the real thing — treat them as unproven, not as known-broken.
The automated suite runs against a real PostgreSQL, a real MySQL and a real MariaDB, and is green on all three.
Notes
Renamed. This package was published as @punitagarwal4/dunes before 1.0.0. The name,
the system tables (duneforge_*), the console mount (/admin), the x-duneforge-*
headers and the DUNEFORGE_* environment variables all changed with it, so upgrading from
the old package is a breaking change rather than a rename you can ignore.
License: MIT. See LICENSE. Versions before 1.0.1 were published as
UNLICENSED; 1.0.1 is the first release anyone is licensed to use.
