@ema-unlimited/frontier-apps-sdk
v0.2.1
Published
SDK for Frontier Apps — database, storage, and Ema API access via frontier-apps-gateway
Readme
@ema-unlimited/frontier-apps-sdk
SDK for Frontier Apps — provides database, storage, and Ema API access via the
frontier-apps-gateway. Each capability is a separate subpath import so apps
only pull in what they use.
Status: v0.x — DB routes (
/v1/db/*) deployed. Storage routes (/v1/storage/*) added in 0.2.0 (currently inrcchannel; see thercdist-tag). Ema API routes are planned for a future release.
Why
Every deployed Frontier App previously talked to MongoDB directly. A single runaway query could saturate the replica set for every app. This SDK forces all traffic through the gateway where a safety blocklist, per-app connection pool, and audit trail live.
App code only changes its import line — the call surface (Mongoose Schema,
model(), query chaining) is preserved.
Install
pnpm add @ema-unlimited/frontier-apps-sdk mongoosemongoose is a required peer dependency for the DB subpath. The SDK re-exports
mongoose.Schema and InferSchemaType so the migration from a direct
Mongoose connection is a one-line import swap. The SDK never opens a
MongoDB connection itself — Schema construction is pure JS path-building.
The package is published to the public npm registry — no auth required:
npm install @ema-unlimited/frontier-apps-sdkSubpaths
| Import | Status | What it does |
| -------------------------------------------- | ----------- | ------------------------------------------------------------- |
| @ema-unlimited/frontier-apps-sdk | Available | Gateway init (initGatewayClient) + base error class |
| @ema-unlimited/frontier-apps-sdk/mongodb | Available | MongoDB-style DB access (full Mongoose surface) |
| @ema-unlimited/frontier-apps-sdk/storage | Available | File storage via V4 signed URLs (put / get / list / …) |
@ema-unlimited/frontier-apps-sdk/ema (Ema API proxy) is planned but not yet added.
Root (@ema-unlimited/frontier-apps-sdk)
The root export is gateway infrastructure only — not a capability.
import { initGatewayClient, FrontierError } from '@ema-unlimited/frontier-apps-sdk'
import type { GatewayClientConfig } from '@ema-unlimited/frontier-apps-sdk'One-time client init
initGatewayClient({
gatewayUrl: process.env.FRONTIER_GATEWAY_URL!, // injected by publisher
appId: process.env.APP_ID!, // injected by publisher
})APP_ID is the publisher-provided value: a full UUID for published scope,
preview-{shortId} for preview scope. Call this once at app boot — it wires
the shared transport used by all subpaths.
MongoDB (@ema-unlimited/frontier-apps-sdk/mongodb)
Define a schema (mechanical swap from Mongoose)
// Before
import mongoose, { Schema, type InferSchemaType } from 'mongoose'
// After — only the import line changes
import { Schema, model, type InferSchemaType } from '@ema-unlimited/frontier-apps-sdk/mongodb'
const noteSchema = new Schema(
{
userId: { type: String, required: true, index: true },
title: { type: String, required: true },
content: { type: String, default: '' },
},
{ timestamps: true },
)
export type NoteDocument = InferSchemaType<typeof noteSchema>
export const Note = model<NoteDocument>('Note', noteSchema)Query
const notes = await Note.find({ userId }).sort({ createdAt: -1 }).limit(20)
const note = await Note.findOneAndUpdate(
{ _id: id, userId },
{ $set: { title: 'New' } },
{ new: true },
)
await Note.deleteOne({ _id: id, userId })Supported operations
| Method | Returns |
| ------------------------------------- | ----------------------------- |
| find(filter?, options?) | QueryBuilder<T[]> (thenable) |
| findOne(filter) | QueryBuilder<T \| null> |
| findById(id) | QueryBuilder<T \| null> |
| findOneAndUpdate(filter, update, ?) | QueryBuilder<T \| null> |
| findByIdAndUpdate(id, update, ?) | QueryBuilder<T \| null> |
| findOneAndDelete(filter) | QueryBuilder<T \| null> |
| findByIdAndDelete(id) | QueryBuilder<T \| null> |
| create(doc \| docs[]) | Promise<T \| T[]> |
| insertOne(doc) | Promise<T> |
| insertMany(docs[]) | Promise<T[]> |
| updateOne(filter, update) | Promise<UpdateResult> |
| updateMany(filter, update) | Promise<UpdateResult> |
| deleteOne(filter) | Promise<DeleteResult> |
| deleteMany(filter) | Promise<DeleteResult> |
| countDocuments(filter?) | Promise<number> |
| distinct(field, filter?) | Promise<unknown[]> |
| aggregate(pipeline[]) | Promise<unknown[]> |
The query chain is lazy — .sort(), .limit(), .skip(), .lean() return
the same QueryBuilder and the HTTP request fires only when awaited.
.lean() is a no-op (the gateway always returns plain JSON).
Blocked at the facade level
These throw synchronously before any network call:
.populate()→ useaggregatewith$lookup.drop()/dropCollection()→ schema management is not exposedcreateIndex()/dropIndex()→ index management is not exposed
The gateway additionally blocks $where, $function, $accumulator, $out,
mapReduce, and unbounded find({}) calls. These return HTTP 400, which the
SDK surfaces as a FrontierDbError.
Error handling
import { FrontierError } from '@ema-unlimited/frontier-apps-sdk'
import { FrontierDbError } from '@ema-unlimited/frontier-apps-sdk/mongodb'
try {
await Note.find({})
} catch (err) {
if (err instanceof FrontierDbError) {
console.error(err.status, err.code, err.message)
}
// Or catch any SDK error across subpaths:
if (err instanceof FrontierError) { ... }
}Storage (@ema-unlimited/frontier-apps-sdk/storage)
Per-app file storage backed by GCS. The SDK never touches GCS credentials —
the gateway mints short-lived V4 signed URLs (5-min PUT, 15-min GET) and
the browser/Node client does the actual byte transfer direct to
storage.googleapis.com. The control plane (auth, MIME allow-list, quota,
audit) runs at the gateway; the data plane skips it.
Key layout
Every key the SDK sends is sanitised and prefixed server-side:
tenants/<tenantId>/frontier-apps/<appId>/uploads/<your-key>Tenant + app are resolved from the gateway (per-app K8s secret) — the SDK
cannot influence them. User-supplied keys must match [A-Za-z0-9._/-] and
cannot start with / or contain .. / NUL.
High-level upload (handles signing for you)
import { put } from '@ema-unlimited/frontier-apps-sdk/storage'
const blob = new Blob([fileBytes], { type: 'image/png' })
const meta = await put(`users/${userId}/avatar.png`, blob, {
contentType: 'image/png',
})
// → { key, size, contentType }Under the hood put() does sign → PUT (direct to GCS) → confirm.
If the PUT fails, confirm-put is not called — no orphan records.
Browser-direct upload (large files, no Node proxy hop)
When the upload comes from a browser, mint the URL server-side and let the
browser do the PUT. The same Content-Type + size are pinned into the
signature, so the caller can't enlarge or retype the upload after the
gateway authorizes.
import { signPut, confirmPut } from '@ema-unlimited/frontier-apps-sdk/storage'
// 1. Server side — mint the signed URL.
const signed = await signPut(`users/${userId}/big.zip`, {
contentType: 'application/zip',
size: file.size,
})
// 2. Browser side — PUT bytes direct to GCS.
await fetch(signed.url, {
method: 'PUT',
headers: signed.requiredHeaders, // includes Content-Type + X-Goog-Content-Length-Range
body: file,
})
// 3. Server side — record the upload.
const meta = await confirmPut(`users/${userId}/big.zip`)Read back / list / delete
import { get, getMeta, list, del } from '@ema-unlimited/frontier-apps-sdk/storage'
// Signed GET URL — usable directly in <img src>, <video src>, <a download>.
const { url, expiresAt, size, contentType } = await get(`users/${userId}/avatar.png`)
// Metadata only (no signing, no transfer).
const meta = await getMeta(`users/${userId}/avatar.png`)
// Paginated list scoped to the app's prefix.
const { items, nextCursor } = await list(`users/${userId}/`, { limit: 50 })
// Idempotent delete.
await del(`users/${userId}/avatar.png`)Constraints (server-enforced)
| Limit | Default |
| ---------------- | -------------------------------------------------------------------------------------- |
| Per-object size | 1 GiB |
| Signed PUT TTL | 5 minutes |
| Signed GET TTL | 15 minutes |
| MIME allow-list | image/*, application/pdf, text/*, application/json, application/zip, video/{mp4,webm,quicktime}, audio/{mpeg,wav,webm}, Office docs |
| Key charset | [A-Za-z0-9._/-] — no .., no leading /, no NUL |
Error handling
import { FrontierStorageError } from '@ema-unlimited/frontier-apps-sdk/storage'
try {
await put('uploads/x.pdf', blob, { contentType: 'application/pdf' })
} catch (err) {
if (err instanceof FrontierStorageError) {
console.error(err.status, err.code, err.message)
// err.status: HTTP status (e.g. 415 for blocked MIME, 413 for oversize)
// err.code: "validation" | "network_error" | "gcs_put_failed" | …
}
}Where things land in GCS
gs://<project-id>-frontier-apps-uploads/
tenants/
<tenantId>/
frontier-apps/
<appId>/
uploads/
users/<userId>/avatar.png ← written by the storage SDK
users/<userId>/reports/2026-Q1.pdfThe bucket is dedicated to per-app user uploads — separate from the
shared <env>-emu-blobs bucket where frontier-apps-builder keeps its
git snapshots. Per-tenant offboarding is a single
gsutil -m rm -r gs://<bucket>/tenants/<tenantId>/ against this bucket.
Development
pnpm install
pnpm typecheck
pnpm test
pnpm build