Skip to content

Programmatic SDK ​

Atlas ships as two npm packages:

PackageInstallUse when
@wisecom/atlas-clinpm install -g @wisecom/atlas-cliDay-to-day operations from a shell: cron jobs, one-off backups, operator workflows. Reads .env and config files.
@wisecom/atlas-sdknpm add @wisecom/atlas-sdkEmbedding Atlas in your own Node.js app: multi-tenant SaaS, custom schedulers, portals, or automation that needs typed programmatic control.

This page documents @wisecom/atlas-sdk. For shell commands and flags, see CLI Commands.

The SDK is a standalone package with every internal module bundled in. One install, no peer @wisecom/atlas-* packages to add. The API is organized by workload namespace (atlas.outlook, atlas.onedrive, atlas.sharepoint) plus cross-cutting methods on the root instance (replicateSnapshot, getBucketStats, etc.).

Installation ​

bash
npm add @wisecom/atlas-sdk

Creating an Instance ​

typescript
import { createAtlasInstance } from '@wisecom/atlas-sdk';

const atlas = createAtlasInstance({
  tenantId: 'your-azure-tenant-id',
  clientId: 'app-client-id',
  clientSecret: 'app-client-secret',
  s3Endpoint: 'http://localhost:9000',
  s3AccessKey: 'minioadmin',
  s3SecretKey: 'minioadmin',
  encryptionPassphrase: 'my-secret-passphrase',
});

All credentials and tenant configuration are explicit. The SDK does not discover them in environment variables, .env, or config files, so a stale file or another tenant's inherited configuration cannot select credentials. The environment-loading container factory is not exported. Standard runtime controls such as TLS certificate validation still apply.

The tenant is bound at creation time, so every method operates within that tenant scope. Methods are async and return Promises.

Everything on the public surface is camelCase: methods, config, option fields and result fields alike. Before v5.0.0 the methods were camelCase and every option and result field was snake_case, because the internal model leaked through as the public API. Atlas is still snake_case internally, and the conversion happens at the SDK boundary, so atlas.outlook.backup(id, { forceFull: true }) returns { summary: { attachmentsStored } } and never a mixture of the two. See Migrating to v5 for the full list of renamed fields.

Two kinds of key stay verbatim, because they are data rather than field names: the keys of a map Atlas did not choose (deltaLinks keyed by Graph folder id, requestsByType keyed by request label, byService keyed by pool name), and the raw Graph payload in readMessage().message, which has to keep matching what Graph returned and what the stored blob contains.

Configuration validation ​

typescript
import { AuthError, StorageError, WrongPassphraseError } from '@wisecom/atlas-sdk';

// Optional, after creating the instance and provisioning its tenant bucket.
try {
  await atlas.validate();
} catch (error) {
  if (error instanceof StorageError) {
    console.error('Check S3 endpoint, credentials and tenant-bucket access.');
  } else if (error instanceof WrongPassphraseError) {
    console.error("The passphrase does not open this tenant's existing backup key.");
  } else if (error instanceof AuthError) {
    console.error('Check Microsoft Entra credentials and connectivity.');
  }
  throw error;
}

createAtlasInstance(config) validates locally and synchronously, before creating clients. Missing or blank required fields, a passphrase shorter than 14 UTF-8 bytes, or an invalid s3Endpoint throw ConfigError (ATLAS_CONFIG_INVALID). The endpoint must be an absolute HTTP or HTTPS URL with a hostname and no embedded credentials, query or fragment. HTTP supports local S3-compatible storage; use HTTPS across untrusted networks. Construction makes no network requests.

atlas.validate(): Promise<void> is opt-in. It checks three stages, in order: HeadBucket for atlas-{tenantId}, the existing wrapped tenant key when one is present, then a Graph token from the instance's shared authentication provider with https://graph.microsoft.com/.default scope. A cached valid token may be reused. Success resolves without returning a token, key material or other data.

Validation stageFailureOperator action
S3 HeadBucketStorageError, ATLAS_STORAGE_FAILURECheck endpoint, region, credentials, bucket existence and s3:ListBucket access. A missing bucket fails; provision it separately.
_meta/dek.enc unwrap, when the object existsWrongPassphraseError, ATLAS_WRONG_PASSPHRASEUse the passphrase that created this tenant's backups. In a multi-tenant service, verify that the tenant row and passphrase row match.
Graph token acquisitionAuthError, ATLAS_AUTH_DENIEDCheck tenant ID, client ID, client secret and connectivity to Microsoft Entra ID.

These codes identify the failed validation stage, not necessarily bad credentials: DNS, TLS and transport failures can also cause rejection. Stages stop on the first failure, so Graph is not probed after an S3 or passphrase failure. Each error retains the original failure as cause. Do not publish raw provider diagnostics without redacting tenant and credential details.

The key stage performs at most one additional storage GET, then a local AES-256-GCM open. It never creates a missing _meta/dek.enc, writes an object, logs key material or retains the temporary data encryption key (DEK). A fresh tenant with no wrapped key passes and continues to Graph validation; the first backup still creates the key through the normal race-safe path.

The probe does not verify write permissions, Object Lock readiness or workload-specific Graph consent. Use checkStorage() for Object Lock readiness. See Security for passphrase guidance and v5 migration before upgrading an existing tenant.

Logging ​

The SDK is silent by default: no Atlas output reaches the host's stdout or stderr. Pass a logger to receive it.

typescript
import pino from 'pino';

const log = pino();

const atlas = createAtlasInstance({
  /* ...credentials... */
  logger: {
    debug: (message, fields) => log.debug(fields, message),
    info: (message, fields) => log.info(fields, message),
    warn: (message, fields) => log.warn(fields, message),
    error: (message, fields) => log.error(fields, message),
  },
});

The LogSink interface is those four methods and nothing else, so pino, winston, an OpenTelemetry exporter, or console all satisfy it with an adapter of a few lines.

Every line carries fields identifying where it came from:

json
{ "tenantId": "00000000-0000-0000-0000-000000000000", "operation": "backup" }

operation is the SDK method that produced the line, so one process serving many tenants can attribute output without correlating by timestamp. The tag is applied per call, and concurrent operations on separate instances never share a sink.

There is no success level: those lines arrive as info. Progress output is dropped rather than logged, because it is terminal cursor control rather than a record. Use the progress events for that.

debug is passed to the sink regardless of the DEBUG environment variable. The host asked for the lines, so the host's logger decides its own level.

Logs are not the only channel

Anything operationally significant is also in the typed result: summary.warnings, summary.errors, summary.excludedFolders, integrityFailures, and the failed-item ledger. Never parse log text to find out what a run did.

Behaviour change in 4.1.0

Earlier SDK versions wrote chalk-coloured [*], [!] and [x] lines straight to the console, including raw ANSI cursor control on a TTY. An embedder that relied on that output has to pass a logger to keep seeing it. The CLI is unaffected and its output is unchanged.

Instance lifecycle ​

An instance owns an S3 client with keep-alive socket pools and a cache of what it has already asked of the bucket. Create one per tenant, dispose it when done.

typescript
const atlas = createAtlasInstance({/* ...config... */});
try {
  await atlas.outlook.backup('user@company.com');
} finally {
  await atlas.dispose();
}

On Node 20 and later, await using does it for you:

typescript
await using atlas = createAtlasInstance({/* ...config... */});
await atlas.outlook.backup('user@company.com');
// disposed at the end of the block, including on a throw

dispose() closes the S3 client's sockets, clears the instance's bucket cache, and drops the container's bindings. It is idempotent, so a finally block and an await using scope can both fire safely, and it never throws: a step that fails is logged and the remaining steps still run. The instance must not be used afterwards.

A long-lived service that creates an instance per request and never disposes it accumulates socket pools for the lifetime of the process.

Passphrases cannot be zeroed

dispose() drops the instance's reference to your encryptionPassphrase, and TenantContext.destroy() already zeroes the derived key buffers. The passphrase string cannot be wiped: JavaScript strings are immutable, so the value stays in the heap until the garbage collector reclaims it, and no library can change that. Where that matters, keep the passphrase in a Buffer on your side and treat the process boundary, not dispose(), as the security boundary.

Bucket caches are per instance. Two instances pointing at different S3 endpoints with same-named buckets no longer answer each other's questions about whether a bucket exists or supports Object Lock, which before 4.1.0 could skip creating a bucket that was not there.

Available Methods ​

createAtlasInstance returns an AtlasInstance with three workload sub-APIs and cross-cutting tenant methods:

typescript
// --- Outlook (mailboxes) ---
const result = await atlas.outlook.backup('user@company.com', { forceFull: true });
const mailboxes = await atlas.outlook.listMailboxes();
const snapshots = await atlas.outlook.listSnapshots('user@company.com');
const verification = await atlas.outlook.verify('snapshot-id');
const restore = await atlas.outlook.restore('snapshot-id', { folderName: 'Inbox' });
const fullRestore = await atlas.outlook.restoreMailbox('user@company.com');
const save = await atlas.outlook.save('snapshot-id', {
  folderName: 'Inbox',
  outputPath: 'backup.zip',
});
const message = await atlas.outlook.readMessage('snapshot-id', '42');
const status = await atlas.outlook.checkMailboxStatus('user@company.com');

// --- OneDrive (owner: email or Entra object id) ---
const od = await atlas.onedrive.backup('john.doe@example.com');
await atlas.onedrive.verify('john.doe@example.com', 'od-snap-123');
await atlas.onedrive.checkStatus('john.doe@example.com');
const odStats = await atlas.onedrive.getStats('john.doe@example.com'); // omit the owner for every drive

// --- SharePoint (site: URL or composite site id; one result per backed-up site) ---
const site = 'https://contoso.sharepoint.com/sites/Example';
const [sp] = await atlas.sharepoint.backup(site);
const tree = await atlas.sharepoint.backup(site, { includeSubsites: true });
await atlas.sharepoint.verify(site, 'sp-snap-123');
const sites = await atlas.sharepoint.listSites();
const spStats = await atlas.sharepoint.getStats(site); // omit the site for every site

// --- Cross-cutting (tenant scope) ---
const check = await atlas.checkStorage({ mode: 'GOVERNANCE', retentionDays: 30 });
const stats = await atlas.getBucketStats();
await atlas.replicateSnapshot('snapshot-id', [offsite]);

Method names mirror the CLI structure: atlas outlook backup maps to atlas.outlook.backup(), atlas onedrive backup to atlas.onedrive.backup(), and so on. Every capability the CLI can reach is reachable from the SDK; the SDK exposes some the CLI does not. See SDK Examples for production-ready patterns.

A drive or site backup that creates no snapshot says why in summary.noSnapshotReason: 'no_changes' when content is there and nothing moved, 'no_content' when there is nothing to protect and no snapshot exists. Branch on it rather than on the counters, which are zero in both cases:

typescript
const [sp] = await atlas.sharepoint.backup(site);
if (!sp.snapshot && sp.summary.noSnapshotReason === 'no_content') {
  // No recovery point exists for this site. Reporting it as backed up would be wrong.
}

The field is absent when a snapshot was created, and absent when the run was interrupted or unhealthy, where interrupted, errors and healthy are the answer. See SharePoint Backup and OneDrive Backup for the full table.

Identifiers ​

Drive methods take the same identifiers the CLI takes, and normalise them the same way.

NamespaceAcceptedNormalisation
atlas.onedrive.*An email or UPN, or an Entra object idAn argument containing @ is resolved through Graph; anything else is used as is
atlas.sharepoint.*A site URL or hostname, or a composite host,siteGuid,webGuid idAn argument without commas is resolved through Graph; anything else is used as is

Resolution failures throw, so a mistyped address fails the call instead of quietly addressing a scope that does not exist. Resolved identities are cached per instance, and atlas.onedrive.backup records the resolved email and display name with the snapshot, which is what makes owners readable in later listings. resolveUser and resolveSite remain available when you want the lookup on its own.

Progress and Cancellation ​

Every Outlook, OneDrive, and SharePoint backup, restore, save, and verify method accepts two common options:

typescript
const controller = new AbortController();

const result = await atlas.outlook.backup('user@company.com', {
  signal: controller.signal,
  onProgress(event) {
    console.log(event.phase, event.processed, event.total, event.current);
    if (event.processed >= 1000) controller.abort();
  },
});

if (result.interrupted) {
  console.log('Stopped safely; rerun to continue from the last committed delta');
}
OptionTypeDescription
onProgress(event: OperationProgressEvent) => voidReceives discovery, per-item processing, finalization, and terminal progress events.
signalAbortSignalRequests cancellation. The transfer in flight is ended and the run stops at a safe boundary.

atlas.outlook.backup accepts a third option, hardStopSignal, for the case where graceful is not fast enough. This is the escalation the CLI wires to a second Ctrl+C:

typescript
const graceful = new AbortController();
const immediate = new AbortController();

process.on('SIGTERM', () => graceful.abort());
setTimeout(() => immediate.abort(), 30_000); // shutdown deadline

const result = await atlas.outlook.backup('user@company.com', {
  signal: graceful.signal,
  hardStopSignal: immediate.signal,
});
SignalEffect
signalFinishes the page in flight, stores its attachments, and persists the delta link for every completed folder. The next run resumes from there.
hardStopSignalDrops the page in flight and its pending attachments. The affected folder keeps its previous delta link and is re-enumerated on the next run.

Both return a result with interrupted: true rather than throwing, and both keep the snapshot manifest that was written for the work already done. hardStopSignal trades re-enumeration of one folder for a faster exit, so use it when a deadline matters more than the wasted work.

OneDrive and SharePoint backups accept signal only, and it reaches the transfer itself rather than only the next item: a cancelled run aborts the chunk request in flight, skips the retry backoff, and closes a historical version's stream instead of reading it to the end. Their long unit of work is that one file transfer, so there is nothing left for an escalation signal to shorten.

What it does not cover: anything that goes through the Graph SDK client rather than a direct request. That is small-file content under 64 MiB, which is bounded by its own per-request timeout, and every mail operation. Cancelling those still waits for the item in flight. Cancellation is also an SDK feature: the CLI has no equivalent for drive backups, so Ctrl+C there behaves as it always did.

OperationProgressEvent is stable across workloads:

typescript
interface OperationProgressEvent {
  operation: 'backup' | 'restore' | 'save' | 'verify';
  workload: 'outlook' | 'onedrive' | 'sharepoint';
  phase: 'discovering' | 'processing' | 'finalizing' | 'completed' | 'interrupted';
  processed: number;
  total?: number;
  current?: string;
  rate?: number;
}

Cancellation returns normally with interrupted: true; it does not throw an abort error. Restore and save results contain partial counts, and save finalizes a valid zip with the completed files. A partially processed backup does not advance that folder, drive, or library's delta cursor, so the next run safely replays it. Completed units remain committed.

If the signal is already aborted when the operation is called, no discovering event is emitted — the stream contains only finalizing followed by interrupted. The event stream never claims work that did not happen, so a progress bar driven by discovering will not paint a "starting..." state for a run that is already over.

The callback is optional and runs inline with the operation. Keep it fast; move network writes or database updates to your own queue.

Outlook API Reference ​

MethodCLI equivalentDescription
backup(mailboxId, options?)atlas outlook backup -mBackup a single mailbox
verify(snapshotId, options?)atlas outlook verifyVerify full restorable state (chain-aware, incl. attachments); { fast: true } for existence-only
restore(snapshotId, options?)atlas outlook restore -sRestore from a snapshot
restoreMailbox(mailboxId, options?)atlas outlook restore -mRestore all snapshots for a mailbox
save(snapshotId, options?)atlas outlook save -sExport snapshot as EML zip
saveMailbox(mailboxId, options?)atlas outlook save -mExport all snapshots as EML zip
listMailboxes()atlas outlook listList backed-up mailboxes
listSnapshots(mailboxId)atlas outlook list -mList snapshots for a mailbox
readMessage(snapshotId, messageRef)atlas outlook readRead a single message
checkMailboxStatus(mailboxId)atlas outlook statusFast delta peek (pending changes)
listAvailableMailboxes(options?)(discovery)List all tenant mailboxes via Graph
deleteMailboxData(mailboxId)atlas outlook delete -mDelete all data for a mailbox
deleteSnapshot(snapshotId)atlas outlook delete -sDelete a single snapshot manifest
purgeTenantData()atlas delete --purgePurge entire tenant bucket
getMailboxStats(mailboxId)atlas stats -mMailbox-level statistics

OneDrive and SharePoint expose parallel methods on atlas.onedrive and atlas.sharepoint (including workload-specific replication). See OneDrive Backup and SharePoint Backup for full SDK examples per workload.

The drive restore methods take destination, inPlace and renameTo alongside conflictBehavior. As of 4.0.0 they default to a generated Restore-<timestamp> root rather than writing back over the original paths, so an embedder that relied on the old behaviour has to pass inPlace: true:

typescript
const snapshotId = 'od-snap-123';
await atlas.onedrive.restore('owner-id', { snapshotId }); // /Restore-2026-08-27T10-15-30/...
await atlas.onedrive.restore('owner-id', { snapshotId, destination: '/DR-drill' });
await atlas.onedrive.restore('owner-id', { snapshotId, inPlace: true }); // pre-4.0.0 behaviour

restore and save on both drive workloads require an options object containing snapshotId. TypeScript enforces that at compile time; a JavaScript caller that omits it now gets a TypeError naming the method, for example onedrive.restore() requires an options object with a snapshotId, instead of a crash from inside the service about an internal property. verify continues to accept options optionally.

Deletion methods erase every version of the objects they match. purgeTenantData() sweeps the whole bucket, every workload and not only Outlook. The returned DeletionResult separates retained* (blocked by Object Lock, deletable once retention expires) from failed* (everything else, which will not clear on its own). See Erasure.

Version restore ​

restoreVersion() pushes the file version bytes Atlas holds back into a live drive or library. Available on both atlas.onedrive and atlas.sharepoint.

typescript
// One exact version, listed first so you know what exists.
const versions = await atlas.onedrive.listFileVersions('owner-id', '/Documents/report.docx');
const previous = versions.at(-2);
if (!previous) throw new Error('No older version is stored for this file');

const result = await atlas.onedrive.restoreVersion('owner-id', {
  fileRef: '/Documents/report.docx',
  versionId: previous.versionId,
});

// A rollback of one folder to the state before a known instant.
await atlas.sharepoint.restoreVersion('site-id', {
  before: new Date('2026-03-10T00:00:00Z'),
  pathPrefix: '/Shared Documents/Projects',
  placement: 'in-place',
});
OptionDescription
fileRefGraph item ID, rooted path, or bare filename; required with versionId
versionIdExact stored version, from listFileVersions()
beforeDate; restores each file's newest version at or before this instant
pathPrefixLimits a before rollback to one folder and below
placement'copy' (default) writes a sibling file; 'in-place' uploads over the original

Pass either fileRef with versionId, or before. The result reports filesRestored, filesSkipped, the placement that was applied, and a restored array of { fileId, versionId, lastModifiedAt, sizeBytes, restoredTo }. A file with no version stored at or before the cutoff appears in errors and counts as skipped, so a caller can tell a complete rollback from a partial one:

typescript
if (result.filesSkipped > 0) {
  for (const reason of result.errors) console.warn(reason);
}

Nothing is destroyed by either placement. 'copy' never touches the live file. 'in-place' uploads over it, and Microsoft 365 records that as a new version while keeping the content it replaced in the file's own version history. Restored files carry the modification time the version had, not the restore time.

Atlas uploads its own checksum-verified bytes rather than calling Graph's restoreVersion, which only works on a version the service still holds and cannot be verified against the manifest. See the CLI reference for the full reasoning.

Folder coverage ​

backup() walks every mail folder at any depth, including Drafts, Outbox, Junk Email, and folders Exchange marks hidden. Pass excludeJunk to skip Junk Email and its subfolders:

typescript
const result = await atlas.outlook.backup('user@company.com', { excludeJunk: true });

for (const folder of result.summary.excludedFolders) {
  console.log(`${folder.folderPath} not captured: ${folder.reason}`);
}

reason is 'junk-excluded', 'hidden-system-folder', 'recoverable-items-not-mail' or 'recoverable-items-unrecognised'. The same list is on the manifest as excludedFolders, so an embedder can answer "was this folder captured?" from a stored snapshot rather than from the options whoever ran the backup happened to pass. MailFolder.isHidden marks folders Exchange hides.

Drafts and Outbox are new in 4.1.0. Earlier versions skipped them, so the first backup after upgrading is larger for mailboxes holding unsent mail.

Recoverable Items ​

includeRecoverableItems also captures hard-deleted and hold-retained mail from the Exchange dumpster, which no delta page ever reports. Off by default:

typescript
await atlas.outlook.backup('user@company.com', { includeRecoverableItems: true });

Deletions, Purges, DiscoveryHolds and SubstrateHolds are captured; Versions, Calendar Logging and Audits are reported through excludedFolders instead. Captured entries carry recoverableItems: true on the manifest entry, and restore() and save() drop them unless the same option is passed there:

typescript
const restorable = manifest.entries.filter((entry) => entry.recoverableItems !== true);

await atlas.outlook.restore('snapshot-id', { includeRecoverableItems: true });

With the option off, request volume is identical to a run before it existed. Storing purged mail has compliance consequences: see Recoverable Items and legal hold.

Snapshot folders ​

Outlook manifests carry folders, the folders the run selected for capture, so a client can build a folder pane from a snapshot alone:

typescript
const snapshot = await atlas.outlook.getSnapshotDetail('snapshot-id');

const inbox = snapshot?.folders?.find((folder) => folder.wellKnownName === 'inbox');
const inboxEntries = snapshot?.entries.filter((entry) => entry.folderId === inbox?.folderId);

Each record has folderId, displayName, folderPath, parentFolderId, totalItemCount, and, when set, isHidden, isRecoverableItems, and wellKnownName.

wellKnownName is the Graph well-known name of the folder (inbox, drafts, sentitems, deleteditems, junkemail, archive, outbox, conversationhistory, searchfolders, or recoverableitemsdeletions) and is absent on every other folder. Display names are localized (a Finnish Inbox is Saapuneet), and a user folder may reuse any name, so match on wellKnownName, never on displayName. Graph v1.0 has no wellKnownName property, so Atlas resolves each name to a folder ID with one GET /users/{id}/mailFolders/{wellKnownName} per backup: at most nine requests per mailbox whatever its folder count, in the outlook pool. A name the mailbox does not have (no archive folder, for example) answers 404 and leaves no folder tagged. Any other failure is logged and ends the lookups for that run, leaving the remaining roles untagged rather than failing the backup or spending a retry budget per name on the same outage. The Recoverable Items Deletions folder is tagged from the lookup Atlas already makes to find that subtree.

An incremental manifest lists only the folders its own run selected, and with a folder filter only the matching ones. Entries carried in from earlier snapshots can name a folder that only an older manifest lists, and manifests written before Atlas 5.2.3 have no folders at all.

Message sender ​

Outlook manifest entries carry from, the sender Outlook displays, including for delegated and send-as mail. It comes from the delta page Atlas already fetches, so it costs no extra Graph request:

typescript
const snapshot = await atlas.outlook.getSnapshotDetail('snapshot-id');

for (const entry of snapshot?.entries ?? []) {
  entry.from?.address; // 'ada@example.com'
  entry.from?.name; // 'Ada Example', absent when Graph reports no display name
}

from is absent on drafts and system items that have no sender, and on entries written before Atlas 5.2.3. Treat a missing field as an unknown sender. The value is personal data in the same class as subject and is encrypted inside the manifest with it, so a snapshot can be listed by sender without decrypting any message body.

Shared mailbox identity ​

Three result types carry an optional mailboxPurpose field ('user' | 'linked' | 'shared' | 'room' | 'equipment' | 'others'), sourced from the Graph mailboxSettings.userPurpose property. A value of 'shared' identifies a shared mailbox:

  • TenantMailbox.mailboxPurpose (from listAvailableMailboxes(); resolved only for unlicensed mailboxes during discovery)
  • MailboxSummary.mailboxPurpose (from listMailboxes(); taken from the newest manifest that recorded one, so a transient lookup failure in the latest backup does not blank the field)
  • Manifest.mailboxPurpose (from backup(), listSnapshots(), getSnapshotDetail(); recorded at backup time)

The field is absent when the purpose was never resolved (pre-feature manifests, lookup failures):

typescript
const mailboxes = await atlas.outlook.listAvailableMailboxes();
const shared = mailboxes.filter((mb) => mb.mailboxPurpose === 'shared');

In-Place Archive coverage ​

TenantMailbox.hasInPlaceArchive (from listAvailableMailboxes()) reports whether a mailbox has an In-Place Archive (Online Archive). That store is not backed up: Graph cannot read archive mailboxes at all, so a successful backup of such a mailbox is not a backup of all its mail. See In-Place Archive is out of scope.

The field is tri-state, and the third state matters:

typescript
const mailboxes = await atlas.outlook.listAvailableMailboxes();

const uncovered = mailboxes.filter((mb) => mb.hasInPlaceArchive === true);
const unknown = mailboxes.filter((mb) => mb.hasInPlaceArchive === undefined);

undefined means unknown, not "no archive". The signal is the Has Archive column of the mailbox usage report, which needs the optional Reports.Read.All permission, so an embedder that treats absence as "covered" will report coverage Atlas never confirmed. No per-mailbox Graph property exposes archive state on v1.0 or beta.

Drive item metadata ​

Drive manifest entries carry the metadata a restore cannot rebuild from bytes alone:

typescript
const snapshot = await atlas.onedrive.getSnapshotDetail('owner-id', { snapshotId });

for (const entry of snapshot.entries) {
  entry.fileSystemInfo?.createdAt; // original client timestamp, restored
  entry.createdBy?.displayName; // author, recorded for audit only
  entry.lastModifiedBy?.email;
}

fileSystemInfo holds the client-reported timestamps from the Graph fileSystemInfo facet, which is the pair Atlas reapplies on restore. lastModifiedAt remains the service-side value, which after a restore reflects the restore. Authors and version authors are captured but never reapplied, and sharing permissions are not captured. See What a drive restore rebuilds, and what it cannot.

All four fields are absent on manifests written before 4.1.0.

Identifier case ​

Every method taking a mailbox address, an Entra object ID, or a SharePoint site ID lowercases it before it becomes a storage key segment, so two spellings of one identifier address one tree.

The SDK is where this used to bite. Graph hands back these identifiers lowercase, so the CLI never saw the problem; an embedder holding an object ID in application state or reading one from a portal could. Two spellings meant two prefixes, so the same drive was backed up twice. Worse:

typescript
// Before 2.1.0-beta: swept an empty prefix, reported what it deleted there,
// and left the real data behind -- a successful-looking erasure of nothing.
await atlas.onedrive.deleteOwnerData('75A21B57-4D82-4F42-9CCC-7C231C30F78C');

Graph item IDs (fileId, itemId) are case-sensitive and never folded. fileFilter compares them case-insensitively, so an ID copied from listFileVersions() matches whatever case you send it in.

Save Options ​

atlas.outlook.save and atlas.outlook.saveMailbox accept the following options:

OptionTypeDescription
folderNamestringSave only this folder and its subfolders (name or path)
messageRefstringSave a single message by index or ID
startDateDateInclude snapshots on or after this date
endDateDateInclude snapshots on or before this date
outputPathstringOutput zip file path (default: Restore-<timestamp>.zip)
skipIntegrityCheckbooleanSkip SHA-256 verification (default: false)
outputWritableStream the archive to this destination instead of a file

Both methods return a SaveResult:

typescript
interface SaveResult {
  snapshotId: string;
  savedCount: number;
  attachmentCount: number;
  errorCount: number;
  errors: string[];
  outputPath: string;
  totalBytes: number;
  integrityFailures: string[];
  interrupted: boolean;
}

Exporting to a Stream ​

save() and saveMailbox() take an output stream instead of a path, so an export reaches its consumer without staging on the exporting machine's disk. That matters for a read-only or ephemeral filesystem, and for exports large enough that writing them twice costs real time.

typescript
import express from 'express';
import { createAtlasInstance } from '@wisecom/atlas-sdk';

const app = express();
const atlas = createAtlasInstance({ /* ...credentials... */ });

app.get('/export/:snapshotId', async (req, res) => {
  res.setHeader('Content-Type', 'application/zip');
  res.setHeader('Content-Disposition', 'attachment; filename="export.zip"');

  try {
    const result = await atlas.outlook.save(req.params.snapshotId, { output: res });
    log.info({ saved: result.savedCount, failures: result.integrityFailures }, 'export served');
  } catch (err) {
    // Atlas has already destroyed the response, so the client sees a failed transfer.
    log.error({ err }, 'export failed');
  }
});

The same option works for the drive workloads:

typescript
await atlas.onedrive.save('user@company.com', { snapshotId, output: res });
await atlas.sharepoint.save('https://contoso.sharepoint.com/sites/Engineering', {
  snapshotId,
  output: res,
});

Anything implementing Node's Writable works: an HTTP response, an upload stream, a compressor.

What changes compared with a file export:

BehaviourStream export
outputPath in the resultEmpty string. There is no file, so Atlas reports no path.
Counts, errors, integrityFailuresReported exactly as for a file export.
A failed runThe stream is destroyed rather than ended, including when setup fails before any bytes are written.
An interrupted runThe stream is destroyed without finalizing the archive. The result reports interrupted: true.
output with outputPathRejected with ConfigError. Atlas writes one archive, and silently dropping the other value is how an export goes missing.

A destroyed stream is the point. Ending a failed or interrupted stream would hand the consumer a short archive that opens like a complete one, which is the failure mode file exports avoid by staging. Over HTTP the client sees the transfer break, so treat a body that arrived without a completed request as a failed export rather than a partial one. Headers are already sent by then, so the status code cannot report the failure: check the result, or the absence of one, on the server.

Memory is bounded to one message or file at a time in either mode. Each entry is fetched, decrypted, compressed and flushed before the next one starts, so a slow consumer applies backpressure rather than accumulating the archive in the heap.

When the consumer disconnects ​

A client that hangs up mid-export destroys the response, and Atlas treats that as the end of the run rather than as one failed entry:

  • The promise rejects with the destination failure. It does not sit pending waiting for a stream that will never emit finish.
  • Nothing further is downloaded. The next entry would be fetched, decrypted and dropped, so the run stops at the entry it could not write.
  • The archive is never finalised, so the bytes the client already received are not a valid zip.

Over HTTP the disconnect is usually the client's doing, which means nobody is listening for the rejection. Handle it on the server anyway: the rejection is what tells the run to stop, and it is the only record that an export ended early.

Restore Options ​

atlas.outlook.restore and atlas.outlook.restoreMailbox accept the following options:

OptionTypeDescription
folderNamestringRestore only this folder and its subfolders (name or path)
messageRefstringRestore a single message by index or ID
targetMailboxstringTarget mailbox for cross-mailbox restore
startDateDateInclude snapshots on or after this date
endDateDateInclude snapshots on or before this date

Both methods return a RestoreResult:

typescript
interface RestoreResult {
  snapshotId: string;
  restoredCount: number;
  attachmentCount: number;
  errorCount: number;
  attachmentErrorCount: number;
  errors: string[];
  verificationWarnings: string[];
  restoreFolderName: string;
  graphCost?: OperationCost; // SDK only
  interrupted: boolean;
}
FieldDescription
errorCountMessage-level failures. Matches errors.length.
attachmentErrorCountAttachment-level failures (count only; details are logged during restore).
errorsHuman-readable detail for each message-level failure.
verificationWarningsPer-folder verification warnings, including API failures that prevented count confirmation.

Object Lock ​

Pass objectLockRequest to any backup method to apply WORM retention. Atlas derives the rest of the policy from it, so the SDK and the CLI produce the same result for the same retention period:

typescript
await atlas.outlook.backup('user@company.com', {
  objectLockRequest: { mode: 'COMPLIANCE', retentionDays: 30 },
});

await atlas.onedrive.backup('owner-id', {
  objectLockRequest: { mode: 'GOVERNANCE', retentionDays: 30 },
});
FieldDerived behavior
modeGOVERNANCE (privileged users can shorten retention) or COMPLIANCE (nobody can, including root). Defaults to GOVERNANCE.
retentionDaysConverted to an absolute retainUntil timestamp in UTC at the moment the run starts.

Outlook applies the policy to each stored object; OneDrive and SharePoint set the bucket default retention so every new object version inherits it. Writes are fail-closed: when a lock policy is present and the bucket has versioning or Object Lock disabled, or does not support the requested mode, the write throws instead of storing unprotected data. Immutability is therefore never silently downgraded.

Batch Processing ​

For backing up multiple mailboxes from a shell, enumerate them with atlas outlook mailboxes and loop over atlas outlook backup -m <id> in your scheduler. The CLI backs up one mailbox per invocation; fan-out is scheduling and belongs to the caller.

In the SDK, create one instance and iterate sequentially. Each backup, restore, or save makes hundreds or thousands of Graph requests internally, so running mailboxes through Promise.all multiplies the request rate and triggers aggressive throttling (HTTP 429). Atlas retries throttled requests with exponential backoff up to 12 times, but a sequential loop finishes sooner and more predictably:

typescript
const mailboxIds = ['alice@company.com', 'bob@company.com', 'carol@company.com'];

for (const mailboxId of mailboxIds) {
  const result = await atlas.outlook.backup(mailboxId);
  console.log(`${mailboxId}: snapshot ${result.snapshot.id}`);
}

Replication ​

The SDK supports snapshot-level replication and disaster recovery rehydration. A StorageTarget represents a secondary S3 endpoint and needs only S3 credentials plus the shared passphrase, no M365 credentials.

typescript
import { createAtlasInstance, createStorageTarget } from '@wisecom/atlas-sdk';

const atlas = createAtlasInstance({/* primary config */});

const offsite = createStorageTarget({
  targetId: 'offsite-dr',
  s3Endpoint: 'http://offsite:9000',
  s3AccessKey: 'offsite-key',
  s3SecretKey: 'offsite-secret',
  encryptionPassphrase: 'same-passphrase-as-primary',
});

// Replicate a snapshot to one or more targets
const results = await atlas.replicateSnapshot('snapshot-id', [offsite]);

// Replicate all unreplicated snapshots for a mailbox
const mailboxResults = await atlas.replicateMailbox('user@company.com', [offsite]);

// Query replication status: by snapshot, or every snapshot for one owner
const status = await atlas.getReplicationStatus('snapshot-id');
const ownerStatus = await atlas.getReplicationStatusByOwner('user@company.com');

// Disaster recovery: recover from a replica
await atlas.rehydrateSnapshot('snapshot-id', offsite);
await atlas.rehydrateMailbox('user@company.com', offsite);

// Full tenant DR: every workload, reported per workload
const recovery = await atlas.rehydrateTenant(offsite);
for (const { workload, result } of recovery.workloads) {
  console.log(workload, result.objectsCopied, result.objectsFailed);
}
console.log(recovery.total.status);

createStorageTarget accepts a StorageTargetConfig:

OptionTypeDescription
targetIdstringStable human-readable ID (auto-derived from endpoint if omitted)
s3EndpointstringS3 endpoint URL
s3AccessKeystringS3 access key
s3SecretKeystringS3 secret key
s3RegionstringS3 region (default: us-east-1)
encryptionPassphrasestringMust match the primary passphrase (shared encryption model)

Key Re-wrap ​

typescript
const result = await atlas.rewrapDataKey(newPassphrase);

console.log(result.passphraseChanged, result.previousKdfId, result.kdfId);

Re-wraps the tenant's stored data key. Called with no argument it re-wraps under the configured passphrase with current KDF parameters, which is how a tenant bootstrapped with weaker scrypt parameters is brought forward.

FieldTypeDescription
tenantIdstringTenant whose key was re-wrapped
passphraseChangedbooleanFalse when the call re-wrapped under the configured passphrase
previousKdfIdnumberKDF the wrapper used before the call
kdfIdnumberKDF the new wrapper uses

The data key is unchanged, so nothing in the bucket is re-encrypted and every snapshot stays readable. This rotates the wrapper, not the key: an attacker who already holds the data key or the plaintext is unaffected by it. Use it for a leaked passphrase, not for a compromised bucket.

Buckets are versioned and noncurrent versions expire after 30 days, so the wrapper this replaces stays readable until then. Someone with the old passphrase and permission to read object versions can still unwrap the same data key from it. Treat the rotation as complete only once that version is gone, or once the leaked reader's s3:GetObjectVersion is revoked.

A wrong current passphrase rejects before anything is written. After the write the blob is read back and unwrapped before the promise resolves, so a resolved call means the tenant opens with the new passphrase, and a verification failure restores the previous wrapper before rejecting. Update the instance configuration afterwards: nothing reads the new value until you do, and an instance constructed with the old passphrase will fail its next operation.

Graph API Cost Tracking ​

The four operations that report cost, backup, restore, restoreMailbox and checkMailboxStatus, return how many Graph API requests they made, broken down by service pool, as a graphCost field on the result. Other Graph-backed calls such as listAvailableMailboxes() do consume quota but do not carry the field, so a scheduler budgeting against graphCost should account for them separately:

typescript
const result = await atlas.outlook.backup('user@company.com');

console.log(result.graphCost);
// {
//   requestsTotal: 852,
//   byService: {
//     outlook: { requests: 847, resourceUnits: 847, uploadBytes: 0 },
//     identity: { requests: 5, resourceUnits: 5, uploadBytes: 0 },
//   },
//   requestsByType: {
//     // Keys are request-type labels, not fields: they stay exactly as Atlas records them.
//     delta_sync: 312, fetch_attachments: 530,
//     list_folders: 5, mailbox_exists: 2, list_users: 3,
//   },
//   elapsedMs: 45200,
// }

Methods that report graphCost: atlas.outlook.backup, atlas.outlook.restore, atlas.outlook.restoreMailbox, atlas.outlook.checkMailboxStatus.

What counts as a request ​

One request sent through the Graph client is one recorded request. Counting happens in the transport, immediately before the request goes out, so the number matches what the tenant is actually charged:

  • Every page. A delta sync that follows @odata.nextLink across 40 pages counts 40, not 1. Same for folder trees, drive listings and version history.
  • Every attempt. A call throttled twice and succeeding on the third attempt counts 3. Retries made by Atlas and retries made internally by the Graph SDK are both visible here, and a throttled tenant is exactly when the count matters most.
  • Every redirect followed to a new location.
  • Upload bytes per attempt. A resumable chunk re-sent after a failure is charged twice against the Outlook 150 MB / 5-minute window, because it was.

Pre-authenticated transfers are deliberately excluded: file downloads from @microsoft.graph.downloadUrl and OneDrive/SharePoint resumable chunk uploads go straight to storage rather than through Graph, and consume no Graph quota.

requestsByType labels each request with the connector operation that issued it, so a paginated delta_sync shows the page count under one label. Those labels are map keys rather than fields and are recorded verbatim, so they keep the spelling shown in the example above.

Recorded costs are higher than in 2.1.0-beta and earlier

Earlier releases recorded one request per connector method call, so pagination and retries were invisible and reported cost was a floor. Cooldowns derived from it were correspondingly too short. Numbers from this release are larger for the same work. That is the undercount being removed, not a change in what Atlas does. Expect a step change in any dashboard built on the old values.

Cost when an operation fails ​

Failures are the most expensive runs a tenant pays for: a delta sync that dies on page 400, or a request that spent its whole retry budget against a 429, has burned more quota than any successful run in the job. That cost is attached to the thrown error and read with getGraphCost:

typescript
import { createAtlasInstance, getGraphCost } from '@wisecom/atlas-sdk';

try {
  const result = await atlas.outlook.backup('user@company.com');
  recordCost(result.graphCost);
} catch (err) {
  // Requests already burned before the failure -- undefined if the error came
  // from somewhere other than a tracked SDK operation.
  const cost = getGraphCost(err);
  if (cost) recordCost(cost);
  throw err;
}

The error itself is rethrown unchanged, so instanceof checks and existing catch filters keep working, and the cost is a non-enumerable property, so error logging and serialisation are unaffected. A failure that happened before any Graph call reports requestsTotal: 0, which is a fact worth recording rather than a missing value.

Ignoring this skews your scheduling in the wrong direction

A scheduler that reads cost only on the success path treats the most expensive runs as free, and re-queues the next mailbox into a tenant that is already throttled, producing another 429 and raising the throttle fence again.

OperationCost Type ​

typescript
interface OperationCost {
  requestsTotal: number;
  byService: Partial<Record<GraphServicePool, ServicePoolCost>>;
  requestsByType: Record<string, number>;
  elapsedMs: number;
}

interface ServicePoolCost {
  requests: number; // API calls made against this pool
  resourceUnits: number; // RU consumed (equals requests for flat-cost Outlook pool)
  uploadBytes: number; // Request body bytes (relevant for Outlook 150 MB/5min limit)
}

type GraphServicePool = 'outlook' | 'sharepoint_onedrive' | 'identity';

Only pools that were actually used during the operation appear as keys in byService. A mail backup typically has outlook and identity entries.

GRAPH_SERVICE_LIMITS ​

The officially-sourced throttling limits are exported as a frozen constant so your scheduler can use the same numbers Atlas uses internally:

typescript
import { GRAPH_SERVICE_LIMITS } from '@wisecom/atlas-sdk';

const outlook = GRAPH_SERVICE_LIMITS.outlook;
// outlook.requestsPerWindow      => 10,000
// outlook.windowDurationMs       => 600,000 (10 min)
// outlook.maxConcurrentRequests  => 4

const sp = GRAPH_SERVICE_LIMITS.sharepoint_onedrive;
// sp.resourceUnitsPerMinute['0-1000'] => 1,250
// sp.deltaWithTokenCost               => 1

const identity = GRAPH_SERVICE_LIMITS.identity;
// identity.resourceUnitsPer10s['L']   => 8,000
// identity.usersListCost               => 2

See the Graph API Rate Limits page for the full reference including all pool limits, cost models, and official Microsoft documentation links.

Scheduling with pg-boss ​

A common pattern for SaaS products is to queue one job per mailbox using pg-boss and use graphCost to compute a cooldown before scheduling the next job:

typescript
import { createAtlasInstance, getGraphCost, GRAPH_SERVICE_LIMITS } from '@wisecom/atlas-sdk';
import type { OperationCost } from '@wisecom/atlas-sdk';
import PgBoss from 'pg-boss';

const boss = new PgBoss(DATABASE_URL);

boss.work('backup-mailbox', async (job) => {
  const { tenantConfig, mailboxId } = job.data;
  const atlas = createAtlasInstance(tenantConfig);

  let cost: OperationCost | undefined;
  try {
    const result = await atlas.outlook.backup(mailboxId);
    cost = result.graphCost;
  } catch (err) {
    // A failed run has usually burned MORE quota than a successful one. Bill it
    // and cool down on it, then let pg-boss see the failure.
    cost = getGraphCost(err);
    throw err;
  } finally {
    if (cost) {
      // Store per-pool costs for trend analysis
      await db.query(
        `INSERT INTO backupCosts
           (mailboxId, outlookRequests, identityRequests, elapsedMs, completedAt)
         VALUES ($1, $2, $3, $4, NOW())`,
        [
          mailboxId,
          cost.byService.outlook?.requests ?? 0,
          cost.byService.identity?.requests ?? 0,
          cost.elapsedMs,
        ],
      );

      // Compute cooldown from the Outlook pool limit (bottleneck for mail backup)
      const outlookLimits = GRAPH_SERVICE_LIMITS.outlook;
      const outlookUsed = cost.byService.outlook?.requests ?? 0;
      const usageRatio = outlookUsed / outlookLimits.requestsPerWindow;
      const cooldownMs = Math.ceil(usageRatio * outlookLimits.windowDurationMs);

      // Re-enqueue after cooldown -- on the failure path this is what stops the
      // retry from landing straight back on a throttled tenant.
      // `retryLimit: 0` because this is the retry: pg-boss would otherwise schedule its own
      // automatic retry alongside this cooldown job, and the tenant would be hit twice.
      await boss.send('backup-mailbox', job.data, {
        startAfter: new Date(Date.now() + cooldownMs),
        retryLimit: 0,
      });
    }
  }
});

Because the Outlook pool limit is per-mailbox, each mailbox's cooldown is independent. Running 50 parallel pg-boss workers for 50 different mailboxes is safe, because they do not share quota.

For OneDrive backup jobs, the sharepoint_onedrive pool is per-tenant instead. Aggregate resourceUnits across all users of a tenant and compare against GRAPH_SERVICE_LIMITS.sharepoint_onedrive.resourceUnitsPerMinute['<tier>'] before scheduling the next OneDrive job.

Errors ​

Every failure Atlas raises on purpose is an AtlasError with a stable code. Branch on the class or on the code, never on the message: message wording changes between releases and is written for the operator reading a terminal, not for a caller.

typescript
import { MailboxNotLicensedError, WrongPassphraseError, AtlasError } from '@wisecom/atlas-sdk';

try {
  await atlas.outlook.backup('user@company.com');
} catch (err) {
  if (err instanceof MailboxNotLicensedError) {
    await reassign_license('user@company.com'); // retryable once a license is back
  } else if (err instanceof WrongPassphraseError) {
    throw err; // never retry: the data is fine, the key is wrong
  } else if (err instanceof AtlasError) {
    logger.error({ code: err.code, cause: err.cause }, 'Atlas backup failed');
  }
}
ClasscodeMeaning
AtlasErrorany of the belowBase class; catch this to separate deliberate failures from bugs
AuthErrorATLAS_AUTH_DENIEDGraph or storage refused the call for lack of permission or admin consent
MailboxNotLicensedErrorATLAS_MAILBOX_NOT_LICENSEDNo Exchange Online license, so Graph will not serve the mailbox
NotFoundErrorATLAS_NOT_FOUNDSnapshot, mailbox, drive, site or object does not exist
ThrottledErrorATLAS_THROTTLEDService throttled the call and the retry budget was spent; see retryAfterMs
WrongPassphraseErrorATLAS_WRONG_PASSPHRASEThe passphrase could not unwrap the data key
ObjectLockRetainedErrorATLAS_OBJECT_LOCK_RETAINEDObject is under retention or a legal hold; key names it
StorageErrorATLAS_STORAGE_FAILUREStorage failed for a reason that is not permission, retention or absence
ConfigErrorATLAS_CONFIG_INVALIDCredentials, endpoint, passphrase or tenant is missing or unusable
ObjectLockVersioningDisabledErrorATLAS_CONFIG_INVALIDImmutability requested but bucket versioning is off
ObjectLockUnsupportedErrorATLAS_CONFIG_INVALIDImmutability requested but the bucket has no Object Lock
ObjectLockModeRejectedErrorATLAS_CONFIG_INVALIDBackend rejected the requested retention mode
PreconditionFailedErrorATLAS_STORAGE_FAILUREConditional write lost a race (HTTP 412)
UnreadableContentErrorATLAS_CONTENT_UNREADABLEStored content was fetched and verified but cannot be parsed back

Every error carries the underlying failure as cause, so the Graph or AWS SDK error is still available for logging without being what you branch on.

WrongPassphraseError deserves a note. AES-GCM cannot tell a wrong key from damaged ciphertext: both are one authentication failure. Atlas names the passphrase because that is the likelier cause and the only one an operator can act on, and keeps the raw crypto error as cause. If the passphrase is definitely correct for that tenant, treat it as a possible integrity problem and run atlas verify against the snapshot.

Exports ​

@wisecom/atlas-sdk exports an explicit public list, not all internal domain types or ports. Workload options and results use the camelCase types below; infer other method results from AtlasInstance rather than importing internal models. See v5 exports.

  • Instance types: AtlasInstance, AtlasInstanceConfig
  • Sub-API types: OutlookApi, OneDriveApi, SharePointApi
  • Workload options and results: the named Outlook*, OneDriveSdk* and SharePointSdk* types used by each API
  • Storage targets: StorageTarget, StorageTargetConfig
  • Factory functions: createAtlasInstance, createStorageTarget
  • Operation control types: SdkOperationOptions, OperationProgressEvent, OperationProgressCallback, OperationProgressPhase
  • Cost helpers: getGraphCost
  • Error classes: AtlasError, AtlasErrorCode, AuthError, MailboxNotLicensedError, NotFoundError, ThrottledError, WrongPassphraseError, ObjectLockRetainedError, StorageError, ConfigError, ObjectLockVersioningDisabledError, ObjectLockUnsupportedError, ObjectLockModeRejectedError, PreconditionFailedError, UnreadableContentError (see Errors)

Graph cost types:

ExportKindDescription
OperationCosttypePer-operation cost breakdown
ServicePoolCosttypeCost for a single service pool
GraphServicePooltypePool identifier union type
GraphServiceLimitstypeType for the full limits constant
GRAPH_SERVICE_LIMITSvalueFrozen official limits constant
getGraphCostvalueReads the cost burned before a failed operation threw
OutlookBackupResulttypeResult of atlas.outlook.backup (includes graphCost)
OutlookRestoreResulttypeResult of atlas.outlook.restore / restoreMailbox (includes graphCost)

Released under the Apache-2.0 License.