Skip to main content

Integrations

The Integration Registry provides a plugin system for registering and using external services like S3, email providers, payment gateways, and more.

Overview

Integrations allow you to extend Sentro with third-party services. Once registered, integrations can be accessed throughout your customizers, hooks, and actions.

Key Features

  • Register any service or object as an integration
  • Access integrations by ID from anywhere in your code
  • Type-safe integration access with TypeScript
  • Manage integration lifecycle (register, get, remove)

Integration Registry API

Registering an Integration

connector.use<T>(
  id: string,        // Unique identifier
  integration: T     // Integration instance
): this

// Example
connector.use('s3', new S3Integration(config));
connector.use('email', emailService);
connector.use('stripe', stripeClient);

Getting an Integration

connector.using<T>(id: string): T | undefined

// Example
const s3 = connector.using<S3Integration>('s3');
if (s3) {
  await s3.prepareUpload({...});
}

Built-in Integrations

Sentro currently ships with object storage integrations for Amazon S3 and Cloudflare R2. Both support direct server-side uploads plus presigned client-side upload flows.

S3 Integration

The @sentrodb/integration-s3 package provides S3 file upload capabilities for direct server-side uploads and presigned client-side uploads.

Installation

npm install @sentrodb/integration-s3

Configuration

import { S3Integration } from "@sentrodb/integration-s3";

const s3Integration = new S3Integration({
  region: 'us-east-1',
  bucket: 'my-bucket',
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!
  },
  // Optional
  endpoint: process.env.S3_ENDPOINT,
  forcePathStyle: false
});

connector.use('s3', s3Integration);

S3 Integration Methods

class S3Integration {
  // Upload directly from the server
  async uploadObject(input: {
    key: string;
    body: Buffer | Uint8Array | string | Readable;
    contentType?: string;
    ContentLength?: number;
  }): Promise<PutObjectCommandOutput>

  // Prepare single or multipart upload
  async prepareUpload(input: {
    key: string;
    fileSize: number;
    contentType?: string;
    expiresIn?: number;
  }): Promise<S3UploadPlan>

  // Complete multipart upload
  async completeMultipartUpload(
    key: string,
    uploadId: string,
    parts: { PartNumber: number; ETag: string }[]
  ): Promise<void>

  // Abort multipart upload
  async abortMultipartUpload(
    key: string,
    uploadId: string
  ): Promise<void>

  // List files in bucket
  async listFiles(prefix?: string): Promise<any>

  // Get file info
  async getFileInfo(key: string): Promise<any>

  // Get upload progress
  getUpload(uploadId: string): any

  // List active uploads
  listUploads(): any[]
}

Using S3 in Field Writer

A common pattern is to accept a JSON file payload from the admin UI, decode the base64 buffer, upload it to S3, and store only the S3 metadata in your table.

import { randomUUID } from "crypto";
import { S3Integration } from "@sentrodb/integration-s3";
import { ModelCustomizer } from "@sentrodb/connector-node/dist/inc/customizers/modelCustomizer";

type UploadedFileInput = {
  filename: string;
  size: number;
  type: string;
  buffer: string;
};

const bucket = process.env.S3_BUCKET!;
const region = process.env.AWS_REGION!;

connector.use("s3", new S3Integration({
  bucket,
  region,
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
  },
}));

connector.customize(() => {
  const model = new ModelCustomizer("staff");
  const s3 = model.using<S3Integration>("s3");

  if (!s3) {
    throw new Error("S3 integration not configured");
  }

  model.replaceFieldWriting("imageUrl", async (value) => {
    const file = value as UploadedFileInput;

    if (!file?.buffer || !file.filename || !file.type) {
      throw new Error("imageUrl must include filename, type, and buffer");
    }

    const base64 = file.buffer.includes(",")
      ? file.buffer.split(",").pop()!
      : file.buffer;
    const buffer = Buffer.from(base64, "base64");

    const extension = file.filename.includes(".")
      ? file.filename.split(".").pop()
      : file.type.split("/").pop();
    const key = `staff/${randomUUID()}.${extension}`;

    await s3.uploadObject({
      key,
      body: buffer,
      contentType: file.type,
      ContentLength: buffer.length,
    });

    return {
      imageUrl: {
        filename: file.filename,
        size: buffer.length,
        type: file.type,
        url: `https://${bucket}.s3.${region}.amazonaws.com/${key}`,
        key,
      },
    };
  });

  return model;
});

Expected Upload Payload

{
  "imageUrl": {
    "filename": "photo.jpg",
    "size": 123,
    "type": "image/jpeg",
    "buffer": "base64string"
  }
}

Client-side Presigned Uploads

Use prepareUpload() when the browser or another client should upload the file directly to S3. Small files return one signed PUT URL; large files return multipart part URLs that the client uploads and then completes with completeMultipartUpload().

const plan = await s3.prepareUpload({
  key: "staff/photo.jpg",
  fileSize: file.size,
  contentType: file.type,
  expiresIn: 900,
});

if (plan.type === "single") {
  await fetch(plan.url, {
    method: "PUT",
    headers: { "Content-Type": file.type },
    body: file,
  });
}

Using S3 in Action

connector.customize(() => {
  const c = new ModelCustomizer("documents");

  c.addAction({
    type: "detail",
    id: "upload-to-s3",
    label: "Upload to S3",
    callback: async (request, record, db) => {
      const s3 = connector.using<S3Integration>('s3');

      if (!s3) {
        return { success: false, error: 'S3 not configured' };
      }

      const result = await s3.prepareUpload({
        key: `documents/${record.id}/${record.filename}`,
        fileSize: record.fileSize,
        contentType: record.mimeType
      });

      // Send this plan to a client that can upload the file directly to S3.
      await db.update({
        table: 'documents',
        data: { s3Key: `documents/${record.id}/${record.filename}` },
        where: { id: record.id }
      });

      return {
        success: true,
        upload: result
      };
    }
  });

  return c;
});

Cloudflare R2 Integration

The @sentrodb/integration-cloudflare-r2 package provides the same upload workflow for Cloudflare R2 using its S3-compatible API.

Installation

npm install @sentrodb/integration-cloudflare-r2

Configuration

import { R2Integration } from "@sentrodb/integration-cloudflare-r2";

const r2Integration = new R2Integration({
  accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
  bucket: process.env.R2_BUCKET!,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID!,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
  },
  // Optional
  region: "auto",
  jurisdiction: "auto",
  // endpoint: "https://<account-id>.r2.cloudflarestorage.com",
});

connector.use("r2", r2Integration);

R2 Integration Methods

class R2Integration {
  // Upload directly from the server
  async uploadObject(input: {
    key: string;
    body: Buffer | Uint8Array | string | Readable;
    contentType?: string;
    ContentLength?: number;
  }): Promise<PutObjectCommandOutput>

  // Prepare single or multipart upload
  async prepareUpload(input: {
    key: string;
    fileSize: number;
    contentType?: string;
    expiresIn?: number;
  }): Promise<R2UploadPlan>

  // Complete multipart upload
  async completeMultipartUpload(
    key: string,
    uploadId: string,
    parts: { PartNumber: number; ETag: string }[]
  ): Promise<void>

  // Abort multipart upload
  async abortMultipartUpload(
    key: string,
    uploadId: string
  ): Promise<void>

  // List files in bucket
  async listFiles(prefix?: string): Promise<any>

  // Get file info
  async getFileInfo(key: string): Promise<any>

  // Get upload progress
  getUpload(uploadId: string): any

  // List active uploads
  listUploads(): any[]
}

Using R2 in Field Writer

The field writer pattern is the same as S3. The main difference is the returned public URL you store for the uploaded object.

In most projects, it is best to keep your public R2 domain in an environment variable so the stored URL matches your public bucket URL or custom domain.

import { randomUUID } from "crypto";
import { R2Integration } from "@sentrodb/integration-cloudflare-r2";
import { ModelCustomizer } from "@sentrodb/connector-node/dist/inc/customizers/modelCustomizer";

type UploadedFileInput = {
  filename: string;
  size: number;
  type: string;
  buffer: string;
};

const accountId = process.env.CLOUDFLARE_ACCOUNT_ID!;
const bucket = process.env.R2_BUCKET!;
const publicBaseUrl = process.env.R2_PUBLIC_BASE_URL!;

connector.use("r2", new R2Integration({
  accountId,
  bucket,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID!,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
  },
}));

connector.customize(() => {
  const model = new ModelCustomizer("assets");
  const r2 = model.using<R2Integration>("r2");

  if (!r2) {
    throw new Error("R2 integration not configured");
  }

  model.replaceFieldWriting("file", async (value) => {
    const file = value as UploadedFileInput;

    if (!file?.buffer || !file.filename || !file.type) {
      throw new Error("file must include filename, type, and buffer");
    }

    const base64 = file.buffer.includes(",")
      ? file.buffer.split(",").pop()!
      : file.buffer;
    const buffer = Buffer.from(base64, "base64");

    const extension = file.filename.includes(".")
      ? file.filename.split(".").pop()
      : file.type.split("/").pop();
    const key = `assets/${randomUUID()}.${extension}`;

    await r2.uploadObject({
      key,
      body: buffer,
      contentType: file.type,
      ContentLength: buffer.length,
    });

    return {
      file: {
        filename: file.filename,
        size: buffer.length,
        type: file.type,
        url: `${publicBaseUrl}/${key}`,
        key,
      },
    };
  });

  return model;
});

Client-side Presigned Uploads

const r2 = connector.using<R2Integration>("r2");

if (!r2) {
  throw new Error("R2 integration not configured");
}

const plan = await r2.prepareUpload({
  key: "assets/photo.jpg",
  fileSize: file.size,
  contentType: file.type,
  expiresIn: 900,
});

if (plan.type === "single") {
  await fetch(plan.url, {
    method: "PUT",
    headers: { "Content-Type": file.type },
    body: file,
  });
}

Custom Integrations

You can create and register any service as an integration:

Email Service

// email.service.ts
import nodemailer from 'nodemailer';

export class EmailService {
  private transporter;

  constructor(config: {
    host: string;
    port: number;
    auth: { user: string; pass: string };
  }) {
    this.transporter = nodemailer.createTransport(config);
  }

  async send(params: {
    to: string;
    subject: string;
    html: string;
  }) {
    return await this.transporter.sendMail({
      from: 'noreply@example.com',
      ...params
    });
  }

  async sendTemplate(
    to: string,
    template: string,
    data: Record<string, any>
  ) {
    const html = this.renderTemplate(template, data);
    return this.send({ to, subject: template, html });
  }

  private renderTemplate(template: string, data: any): string {
    // Template rendering logic
    return template;
  }
}

// Register
const emailService = new EmailService({
  host: process.env.SMTP_HOST!,
  port: parseInt(process.env.SMTP_PORT!),
  auth: {
    user: process.env.SMTP_USER!,
    pass: process.env.SMTP_PASS!
  }
});

connector.use('email', emailService);

Using Email Service

connector.customize(() => {
  const c = new ModelCustomizer("users");

  c.onAfter("CREATE", async (result) => {
    const user = result.rows[0];
    const email = connector.using<EmailService>('email');

    if (email) {
      await email.sendTemplate(
        user.email,
        'welcome',
        { name: user.name }
      );
    }

    return result;
  });

  return c;
});

Stripe Integration

// stripe.integration.ts
import Stripe from 'stripe';

export class StripeIntegration {
  private stripe: Stripe;

  constructor(apiKey: string) {
    this.stripe = new Stripe(apiKey, {
      apiVersion: '2023-10-16'
    });
  }

  async createCustomer(email: string, name: string) {
    return await this.stripe.customers.create({
      email,
      name
    });
  }

  async createPaymentIntent(amount: number, currency: string) {
    return await this.stripe.paymentIntents.create({
      amount,
      currency
    });
  }

  async createSubscription(customerId: string, priceId: string) {
    return await this.stripe.subscriptions.create({
      customer: customerId,
      items: [{ price: priceId }]
    });
  }
}

// Register
const stripe = new StripeIntegration(process.env.STRIPE_SECRET_KEY!);
connector.use('stripe', stripe);

Using Stripe

connector.customize(() => {
  const c = new ModelCustomizer("users");

  c.onAfter("CREATE", async (result) => {
    const user = result.rows[0];
    const stripe = connector.using<StripeIntegration>('stripe');

    if (stripe) {
      const customer = await stripe.createCustomer(
        user.email,
        user.name
      );

      // Update user with Stripe customer ID
      await db.update({
        table: 'users',
        data: { stripeCustomerId: customer.id },
        where: { id: user.id }
      });
    }

    return result;
  });

  return c;
});

Redis Cache Integration

import { createClient } from 'redis';

export class RedisIntegration {
  private client;

  constructor() {
    this.client = createClient({
      url: process.env.REDIS_URL
    });
  }

  async connect() {
    await this.client.connect();
  }

  async get<T>(key: string): Promise<T | null> {
    const data = await this.client.get(key);
    return data ? JSON.parse(data) : null;
  }

  async set(key: string, value: any, ttl?: number) {
    const options = ttl ? { EX: ttl } : undefined;
    await this.client.set(key, JSON.stringify(value), options);
  }

  async del(key: string) {
    await this.client.del(key);
  }
}

// Register
const redis = new RedisIntegration();
await redis.connect();
connector.use('cache', redis);

Using Redis for Caching

connector.customize(() => {
  const c = new ModelCustomizer("products");

  c.onAfter("READ", async (result) => {
    const cache = connector.using<RedisIntegration>('cache');

    if (cache) {
      // Cache the results
      await cache.set(`products:list`, result, 300); // 5 min TTL
    }

    return result;
  });

  c.onBefore("READ", async (payload) => {
    const cache = connector.using<RedisIntegration>('cache');

    if (cache) {
      const cached = await cache.get('products:list');
      if (cached) {
        return cached as any; // Return cached data
      }
    }

    return payload;
  });

  return c;
});

Best Practices

  • Use descriptive integration IDs (e.g., 's3', 'email', 'stripe')
  • Initialize integrations before starting the connector
  • Always check if integration exists before using
  • Use TypeScript generics for type-safe integration access
  • Handle integration errors gracefully
  • Keep integration configuration in environment variables
  • Create wrapper classes for complex third-party libraries
  • Document integration methods and usage

Integration Lifecycle

// Register multiple integrations
connector.use('s3', new S3Integration(s3Config));
connector.use('email', new EmailService(emailConfig));
connector.use('stripe', new StripeIntegration(stripeKey));
connector.use('cache', redisClient);

// Start connector (integrations are now available)
await connector.start();

// Use in customizers
connector.customize(() => {
  const c = new ModelCustomizer("users");

  c.onAfter("CREATE", async (result) => {
    const s3 = connector.using<S3Integration>('s3');
    const email = connector.using<EmailService>('email');

    // Both integrations are available here
  });

  return c;
});
Next Steps: Learn about the Type System for type-safe database operations.