Ulabase

Build

Your own code

Run your own code on a function platform such as Cloudflare Workers or AWS Lambda, and connect it to your service through the API, webhooks and change streams.

Ulabase gives your app its core APIs and features that are ready to use. The code that belongs only to your app, such as parsing a file, calling a third-party API or running a job at night, runs on a function platform such as Cloudflare Workers or AWS Lambda. Running functions is their business, and Ulabase leaves that job to them by design.

Use this page when your app needs code of its own on the server. It shows the pattern to follow, with an example.

What you get:

  • One place where access is decided. Your code calls the service with a credential of its own, and the permissions of the service say what it may do.

  • Code you can move. The function talks to Ulabase over HTTPS, so it runs on any platform.

  • Nothing new to learn. The function uses the same API as your app.

Three ways to connect

Mechanism What it does

The API

Your code calls the service over HTTPS, with an API key.

Webhooks

The service calls a URL of yours when a request matches a condition.

Change streams

Your code subscribes to the changes of a collection, over a WebSocket or Server-Sent Events.

The pattern

The example loads a file. A client uploads it, and a function parses it and writes the records.

  1. A client writes the file to a file bucket.

  2. A webhook calls your function.

  3. The function reads the file through the API and writes the records.

  4. The function writes the outcome to a collection, where the app reads it.

The client never calls the function. Only the webhook does. A function called by the browser would have to check the user’s token and repeat rules the service already enforces. Here the permissions of the service decide who may upload, and that is the only check.

The file goes to a bucket

A collection whose name ends in .files is a file bucket. The client uploads the file with an ordinary authenticated request. The id is the SHA-256 hash of the content, so the same file sent twice is stored once.

curl -X PUT "https://c0ffee.ulabase.app/inbox.files/$(shasum -a 256 statement.txt | cut -d' ' -f1)" \
  -H "Authorization: Bearer <token>" \
  -F "file=@statement.txt" \
  -F 'metadata={"filename":"statement.txt"}'

A permission on /inbox.files decides who may upload.

A webhook calls the function

Add an HTTP webhook that fires when a file is written to the bucket:

Field Value

Name

Inbox to function

URL

https://app.example.com/inbox/webhook

Condition

path-prefix('/inbox.files/') and (method('PUT') or method('PATCH'))

Headers

{ "Authorization": "Bearer <a secret the function checks>" }

Timeout

30000

Transform

Enabled

Transformation template:

{ "id": "{{{request.path}}}" }

The template sends one thing: the path of the upload, which ends with the file’s id.

The function does the work

A Cloudflare Worker, shortened:

export default {
  async fetch(request, env) {
    // only the webhook knows the secret
    if (request.headers.get('Authorization') !== `Bearer ${env.WEBHOOK_SECRET}`) {
      return new Response(null, { status: 401 });
    }

    // the body is { "id": "/inbox.files/<id>" }: keep the id
    const { id: path } = await request.json();
    const id = /([0-9a-f]{64})$/.exec(path)?.[1];
    if (!id) return new Response(null, { status: 400 });

    // read the file and write the records, with the function's own API key
    const api = ulabase(env.ULABASE_URL, env.ULABASE_API_KEY);
    const file = await api.binary(`inbox.files/${id}/binary`);
    const outcome = await load(api, id, file);

    // write the outcome where the app reads it
    await api.put(`loads/${id}`, { _id: id, ...outcome });
    return Response.json(outcome);
  },
};

ulabase() is a small wrapper around fetch that sends the API key. load() is your own code: it parses the file and writes the records through the API.

One credential for each caller

Caller Credential What it may do

The app

The user’s session

Upload files, read outcomes

The function

An API key with a role of its own, such as loader

Read the bucket, write records and outcomes

The service, calling the function

The secret in the webhook’s header

Call the function

The function gets a user of its own. That user issues the key once, with POST /keys, and the key goes into the function’s secrets:

curl -u loader:<password> -X POST https://c0ffee.ulabase.app/keys \
  -H 'Content-Type: application/json' \
  -d '{ "name": "inbox function", "roles": ["loader"], "expiresIn": 365 }'

If the key leaks, you revoke that key alone. The app and its users keep working.

What to plan for

  • No synchronous answer. A webhook runs after the response has gone back to the client. The app reads the outcome from the loads collection.

  • The answer has a deadline. A webhook waits 5 seconds for the function’s answer, or the timeout_ms you set, up to 30 seconds on Free and 60 on Starter and Pro. A function with longer work answers first and finishes after, or hands the job to a queue of its platform.

  • Write the outcome to another collection. A function that writes back to the file fires the webhook again, and loads the file again, without end.

Do it from the command line

You can keep this setup in git and apply it with ulabase setup. The steps install the two features, create the function’s user with its permissions, and add the webhook:

import { defineSetup, step, fromEnv } from '@ulabase/cli';

// the webhook that calls the function when a file is written to the bucket;
// the secret the function checks comes from the environment
const inboxToFunction = {
  id: 'inbox-to-function',
  name: 'Inbox to function',
  condition: "path-prefix('/inbox.files/') and (method('PUT') or method('PATCH'))",
  url: 'https://app.example.com/inbox/webhook',
  headers: { Authorization: `Bearer ${process.env.WEBHOOK_SECRET}` },
  // the function loads a file inside the call: give it more than the default 5 seconds
  timeout_ms: 30000,
  transform: { enabled: true, template: '{ "id": "{{{request.path}}}" }' },
};

export default defineSetup('Custom code', [
  // step 1: install the two features, if they are not installed yet
  step('webhooks feature installed', {
    check: ({ admin, srvId }) => admin.isFeatureInstalled(srvId, 'webhook-integration'),
    apply: ({ admin, srvId }) => admin.installFeature(srvId, 'webhook-integration'),
  }),
  step('api-keys feature installed', {
    check: ({ admin, srvId }) => admin.isFeatureInstalled(srvId, 'api-keys'),
    apply: ({ admin, srvId }) => admin.installFeature(srvId, 'api-keys'),
  }),

  // step 2: the user the function works as, with a role of its own
  step('function user created', {
    check: ({ service }) => service.userExists('loader'),
    apply: ({ service }) =>
      service.createUser('loader', { password: fromEnv('LOADER_PASSWORD'), roles: ['loader'] }),
  }),

  // step 3: what the loader role may do: read the bucket, write records and outcomes
  step('loader reads the bucket and writes records', {
    check: ({ service }) => service.permissionExists('loaderWorks'),
    apply: ({ service }) =>
      service.putPermission('loaderWorks', {
        roles: ['loader'],
        predicate: "(path-prefix('/inbox.files') and method('GET')) or path-prefix('/movements') or path-prefix('/loads')",
        priority: 10,
      }),
  }),

  // step 4: the loader user may issue a key that carries the loader role
  step('loader issues its key', {
    check: ({ service }) => service.permissionExists('loaderIssuesKeys'),
    apply: ({ service }) =>
      service.putPermission('loaderIssuesKeys', {
        roles: ['loader'],
        predicate: "path-prefix('/keys')",
        priority: 10,
        apiKeys: { roles: ['loader'], 'max-expires-in-days': 365 },
      }),
  }),

  // step 5: save the webhook, unless one with this id and condition is already saved
  step('webhook to the function configured', {
    async check({ admin, srvId }) {
      const c = await admin.getFeatureConfig(srvId, 'webhook-integration');
      const hooks = (c.webhooks ?? []) as Array<{ id: string; condition: string }>;
      return hooks.some(h => h.id === inboxToFunction.id && h.condition === inboxToFunction.condition);
    },
    async apply({ admin, srvId }) {
      const current = await admin.getFeatureConfig(srvId, 'webhook-integration');
      // keep the other webhooks, such as those added from the console, and replace this one
      const others = ((current.webhooks ?? []) as Array<{ id: string }>).filter(h => h.id !== inboxToFunction.id);
      await admin.updateFeatureConfig(srvId, 'webhook-integration', { ...current, webhooks: [...others, inboxToFunction] });
    },
  }),
]);

The key itself is not part of the setup. The loader user issues it once with POST /keys, and sees it once.

  • Webhooks: conditions, templates, recording and logs.

  • API keys: who may issue which keys, and how to revoke one.

  • Change streams: the third way to connect, for code that listens to changes.

  • Permissions: what each role may do.