@dreamtree-org/integrations
v0.4.1
Published
Shared third-party integrations for every Dreamtree product — OAuth, sealed per-company connections, Google Drive and Calendar, Zoho Books, Slack, SMTP and IMAP.
Maintainers
Readme
@dreamtree-org/integrations
Replaces the third-party integration code that was copied into each Pro product
(infrastructure/googleDriveClient.js in taskpro, utils/googleDrive.js in
venturepro, lib/zoho.js in procurementpro, …). One package, one subpath per
provider. Each provider's SDK is an optional peer dependency, so an app
installs only the SDKs for the providers it imports.
| Import | What | Needs |
|---|---|---|
| @dreamtree-org/integrations | IntegrationError, OAuth 2.0 client over fetch, OAuth state store, sealed connection store, memory/knex adapters | — |
| @dreamtree-org/integrations/google | Google OAuth (createGoogleAuth), Drive (createDriveClient) and Calendar + Meet (createCalendarClient) | googleapis |
| @dreamtree-org/integrations/zoho | Zoho OAuth, region-aware (createZohoAuth), and Zoho Books v3 (createZohoBooksClient) | — |
| @dreamtree-org/integrations/mail | SMTP sending (createSmtpMailer) and IMAP reading (createImapReader), with host, header and password guards | nodemailer; imapflow + mailparser for IMAP |
| @dreamtree-org/integrations/slack | Slack OAuth v2 bot install (createSlackAuth) and Web API (createSlackClient) | — |
Design rules
- Provider clients don't store anything. They take credentials as input
(
drive.forToken(refreshToken)) and never read a database, so the app's registry / test-double pattern keeps working. - One error type. Every client throws
IntegrationErrorwith acode:AUTH_REQUIRED,FORBIDDEN,NOT_FOUND,RATE_LIMITED,RETRYABLE,NOT_CONFIGUREDorUNKNOWN. Check it withIntegrationError.is(err), notinstanceof(each subpath is bundled on its own). - Credentials are sealed with tenancy. The connection store takes your
@dreamtree-org/tenancysecret service (tenancy.secretsorcreateSecretService()). The AAD binds environment, company, row, provider and field, so a sealed value copied to another company or field won't open. - Config is lazy. Pass config as a function; the app boots without the
provider's credentials, and
status()names what is missing. - No hard-coded host. Redirect URIs are passed per call, built from the request's origin. Google only accepts URIs registered on the OAuth client, so that list is what limits which hosts can complete a sign-in.
Google Drive
const { createGoogleAuth, createDriveClient, BASE_SCOPES, DRIVE_FILE_SCOPE } = require('@dreamtree-org/integrations/google');
const auth = createGoogleAuth({
provider: 'google-drive',
scopes: [...BASE_SCOPES, DRIVE_FILE_SCOPE],
config: () => ({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
});
const drive = createDriveClient({ auth, appProperties: { app: 'taskpro' } });
// The redirect URI comes from the request, so one build serves every host
// (localhost, stage, prod). Behind a proxy, set Express `trust proxy`.
const redirectUri = `${req.protocol}://${req.get('host')}/api/integrations/google-drive/callback`;
auth.status(); // { ok, hint }
const url = auth.buildAuthUrl({ state, redirectUri }); // offline access, prompt=consent
const tokens = await auth.exchangeCode(code, { redirectUri }); // same URI; { refreshToken, idToken, scopes, ... }
const who = await auth.verifyIdToken(tokens.idToken); // { sub, email, name }
const d = drive.forToken(refreshToken); // a fresh OAuth client per operation
const leaf = await d.ensureFolder({ rootId, segments: ['PRO-4', 'BRIT-1'] });
const file = await d.upload({ name, parentId: leaf.id, mimeType, buffer });
const bytes = await d.download(file.id); // Buffer; downloadWithType() adds mimeType
await d.rename(file.id, 'new name');
await d.remove(file.id);Connections and OAuth state
const { createConnectionStore, createStateStore, knexAdapters, createIntegrationTables } = require('@dreamtree-org/integrations');
await createIntegrationTables(knex); // integration_connections + integration_oauth_states
const adapters = knexAdapters(knex); // or knexAdapters(() => currentCompanyKnex())
const connections = createConnectionStore({ adapter: adapters.connections, secrets: tenancy.secrets });
const states = createStateStore({ adapter: adapters.states });
// connect
const state = await states.issue({ companyId, provider: 'google-drive', userId });
res.redirect(auth.buildAuthUrl({ state, redirectUri }));
// callback
const { companyId } = await states.consume(req.query.state, { provider: 'google-drive' }); // one-time, 10 min
const tokens = await auth.exchangeCode(req.query.code, { redirectUri });
await connections.save({
companyId, provider: 'google-drive',
account: await auth.verifyIdToken(tokens.idToken),
scopes: tokens.scopes,
credentials: { refreshToken: tokens.refreshToken }, // undefined keeps, null clears
});
// use
const conn = await connections.open({ companyId, provider: 'google-drive' });
try {
await drive.forToken(conn.credentials.refreshToken).upload({ ... });
} catch (err) {
await connections.markError({ companyId, provider: 'google-drive' }, err); // AUTH_REQUIRED → status "revoked"
throw err;
}connections.get() never returns credentials, only
secrets: { refreshToken: { configured: true, keyId } }, so it is safe to
send to a client.
Apps that already have their own connection tables can keep them and pass an
adapter of their own ({ find, insert, update, remove }), or use only the
provider clients.
Google Calendar
const { createGoogleAuth, createCalendarClient, BASE_SCOPES, CALENDAR_APP_CREATED_SCOPE } = require('@dreamtree-org/integrations/google');
const auth = createGoogleAuth({ provider: 'google-calendar', scopes: [...BASE_SCOPES, CALENDAR_APP_CREATED_SCOPE], config });
const calendar = createCalendarClient({ auth });
const c = calendar.forToken(refreshToken);
const cal = (await c.getCalendar(storedCalendarId)) || (await c.createCalendar({ name: 'TaskPro' }));
const ev = await c.createEvent({ calendarId: cal.id, event: { summary, start, end, attendees }, withMeet: true });
ev.meetUrl; // the Meet link
await c.patchEvent({ calendarId: cal.id, eventId: ev.id, event: { summary: 'Moved' } }); // keeps the conference
await c.deleteEvent({ calendarId: cal.id, eventId: ev.id }); // 404/410 = already gone, fine
const busy = await c.listEvents({ timeMin, timeMax }); // recurring expanded, all pages- Scopes.
calendar.app.createdis the least privilege: the app creates one calendar and manages events on it. It can't read the calendar list, so look the calendar up by its stored id. Usecalendar.eventsonly to work with the user's own calendars. - Meet links. Each event with a Meet link gets a fresh random request id, because a reused id would put the same meeting on two events.
Slack
const { createSlackAuth, createSlackClient } = require('@dreamtree-org/integrations/slack');
const auth = createSlackAuth({ config: () => ({ clientId, clientSecret }) }); // bot scopes: chat:write, channels:read, …
res.redirect(auth.buildAuthUrl({ state, redirectUri }));
const install = await auth.exchangeCode(code, { redirectUri }); // { botToken, teamId, teamName, botUserId, scopes }
const slack = createSlackClient().forToken(install.botToken);
const channels = await slack.listChannels(); // public + private the bot is in, all pages
const who = await slack.lookupUserByEmail('[email protected]'); // null when not a member
await slack.postMessage({ channel, text: who ? `<@${who.id}> done` : 'Ann: done', blocks });- Bot token only. Only the bot token is returned. The installing user's token is dropped, so nothing acts as a person.
- Errors. Slack answers HTTP 200 with
{ ok: false }for most failures, so the client checksok. Failures throwIntegrationErrorwith Slack's own string onerr.slackError:token_revoked/invalid_auth→AUTH_REQUIREDmissing_scope→FORBIDDENchannel_not_found→NOT_FOUNDratelimited→RATE_LIMITED, witherr.retryAfterMs
- Retries. Calls are not retried by default, because callers usually
retry from a job. Pass
retries: 2to wait out a shortRetry-Afterinline instead.
Zoho Books
const { createZohoAuth, createZohoBooksClient } = require('@dreamtree-org/integrations/zoho');
const auth = createZohoAuth({ config: () => ({ clientId, clientSecret }) }); // scope ZohoBooks.fullaccess.all
const books = createZohoBooksClient({ auth });
// connect: redirect to the company's data center
res.redirect(auth.buildAuthUrl({ state, redirectUri, dataCenter: 'in' }));
// callback: Zoho appends ?code&accounts-server — the server is validated against
// the known Zoho hosts before the client secret is sent anywhere.
const t = await auth.exchangeCode(req.query.code, { redirectUri, accountsServer: req.query['accounts-server'] });
// store t.refreshToken (sealed), t.accountsServer, t.apiDomain
// use
const b = books.forConnection({
refreshToken, accountsServer, apiDomain, organizationId,
accessToken, expiresAt, // optional: a stored, still-valid token
onTokens: (fresh) => saveAccessToken(fresh), // Zoho caps token minting — persist each one
});
const orgs = await b.organizations();
const { contact } = await b.post('contacts', { contact_name: 'Acme' });
const items = await b.listAll('items'); // follows page_context.has_more_page- The access token is cached and refreshed 60 s before expiry. A 401 forces one refresh and one retry. Concurrent calls share one refresh.
- 429 (with
Retry-After), Books code 43, 5xx and connection resets are retried. Every request has a 30 s timeout. - Errors are
IntegrationError, with Books' own code onerr.zohoCode.invalid_code,invalid_grantand a second 401 →AUTH_REQUIRED; code 57 →FORBIDDEN; 43 / 429 →RATE_LIMITED.
Mail: SMTP and IMAP
const { createSmtpMailer, createImapReader, smtpAccountFromSettings } = require('@dreamtree-org/integrations/mail');
const mailer = createSmtpMailer();
const account = smtpAccountFromSettings(resolvedSettings); // tenancy's smtp.* family → { host, port, username, password, from }
await mailer.verify(account); // logs in, sends nothing
const r = await mailer.send(account, { from: account.from, to: '[email protected]', subject: 'Hi', text: '…', html: '…' });
// r.ok, r.errorCode ('AUTH_FAILED' | 'RECIPIENT_REFUSED' | …), r.code (IntegrationError code), r.response (scrubbed)
const reader = createImapReader();
const res = await reader.fetch(
{ host: 'imap.gmail.com', port: 993, username, password, allowedHosts: ['imap.gmail.com'] },
{ sinceUid: mailbox.lastUid, uidValidity: mailbox.uidValidity, maxMessages: 50 },
);
// res.messages (parsed), res.highestUid (store it), res.more, res.errorCode === 'UIDVALIDITY_CHANGED' → reset the mark- Never throws. Mail calls return outcomes (
{ ok, errorCode, code, message };sendBatchgives one per message), so one dead relay, mailbox or refused recipient cannot abort a worker tick. - Host guard. IP literals, single-label names and non-mail ports are always
refused. Pass
allowedHoststo narrow further. - TLS policy, no escape hatch. Port 465 uses TLS from the start; every other port must upgrade with STARTTLS, so the password never travels in cleartext. Certificates are verified, and TLS 1.2 is the minimum.
- Secrets. Server responses are scrubbed of the password: as typed, without spaces, and in base64 as used by AUTH LOGIN and AUTH PLAIN. imapflow logging is off, because the IMAP conversation contains the LOGIN password.
- Injection. Every address, subject and header is checked for CR/LF and folding before sending.
- IMAP bounds. Reads are limited by count, size and a deadline. A message that
is too large or will not parse comes back as its envelope (
oversized: true), so the watermark still advances.AUTH_FAILEDandAPP_PASSWORD_REQUIREDare separate codes, because Google reports both as AUTHENTICATIONFAILED and they need different fixes.
OAuth for providers without an SDK
const { createOAuthClient } = require('@dreamtree-org/integrations');
const zoho = createOAuthClient({
provider: 'zoho-books',
config: () => ({
authorizeUrl: 'https://accounts.zoho.in/oauth/v2/auth',
tokenUrl: 'https://accounts.zoho.in/oauth/v2/token',
clientId, clientSecret, redirectUri,
scopes: ['ZohoBooks.fullaccess.all'], scopeSeparator: ',',
authorizeParams: { access_type: 'offline', prompt: 'consent' },
}),
});
const tokens = await zoho.exchangeCode(code); // tokens.raw.api_domain kept
const fresh = await zoho.refresh(refreshToken); // invalid_grant → AUTH_REQUIREDDevelopment
npm install
npm test # node --test
npm run typecheck
npm run build # dist/{index,google,zoho,mail,slack}.{js,cjs}