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-s3Configuration
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-r2Configuration
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;
});