Skip to content

SharePoint / OneDrive Integration β€” Implementation Blueprint

Decisions Locked (2026-08-01)

  • Entra app model: Model A β€” each customer registers their own Entra app in their own tenant, grants it Sites.ReadWrite.All (Phase 3: Sites.Selected), and gives us tenant_id + client_id + client_secret. Credentials are KMS-sealed per sharepoint_integrations row. No platform-owned multi-tenant app, no public consent-redirect infra. Fits NDSU’s shared NDUS tenant. (Discard β€œModel B” alternatives in the App Registration Model section below.)
  • Token acquisition: raw fetch, no MSAL β€” acquire tokens by POSTing grant_type=client_credentials to https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token with scope=https://graph.microsoft.com/.default. Works in BOTH the CF Worker route path and the Lambda poller with no bundled dependency and no Dockerfile edits. graph-auth.ts implements a small in-process + Supabase graph_token_cache layer itself. Do NOT add @azure/msal-node.
  • Process: GitHub issues filed before coding. Epic + Phase 1/2/3 issues track the work.

Context and Orientation

This blueprint treats the SharePoint integration as a strict structural sibling of the S3 integration. Every seam mirrors its S3 counterpart: same data-model pattern, same KMS-sealed credential store, same SQS ingest queue with a new type discriminator, same writeback hook sites in the batch executors, same frontend wizard pattern. Where Graph API diverges from S3 (delta tokens instead of cursors, OAuth client-credentials instead of access keys, graph:// source URLs instead of s3://), the changes are isolated to the new files rather than spread across existing ones.

The next migration number after 231 is 232. Three migrations are needed (tables, usage fn, token-cache index), so they occupy 232, 233, 234. Use the timestamp convention 20260801NNNNNN_NNN_*.sql.


Phase Map

Phase 1 β€” Poll-only MVP (proves ingest + convert + writeback)

Delivers: a customer can connect a SharePoint document library folder, our poller discovers new PDFs via Graph delta query, converts them, and writes HTML + report.json + accessible.pdf back to a configurable output folder in the same library.

In scope:

  • All DB migrations (232–234) β€” build them once; webhook columns are nullable placeholders
  • packages/shared/src/graph-integrations.ts β€” shared enums, GraphIngestJob envelope, caps
  • workers/api/src/services/graph-auth.ts β€” raw fetch client-credentials token acquisition + in-process/Supabase cache (no MSAL)
  • workers/api/src/services/graph-poll-worker.ts β€” delta query loop, idempotency, SQS enqueue
  • workers/api/src/services/graph-ingest-consumer.ts β€” download item, upload to R2, start conversion
  • workers/api/src/services/graph-integration-writeback.ts β€” write artifacts back to SharePoint
  • workers/api/src/routes/graph-integrations.ts β€” HTTP routes mounted in both entry points
  • workers/api/src/graph-poller.ts β€” Lambda entry point for EventBridge schedule
  • Modifications to both batch executors (writeback guards), both API entry points, and packages/shared/src/index.ts
  • Frontend: /account/integrations/sharepoint/new/ wizard, detail page, and list page update

Out of scope in Phase 1: Graph change notification webhooks (subscription create/renew/delete), subscription renewal cron, Sites.Selected granular permission grant UI.

Phase 2 β€” Webhooks + Subscription Renewal

Delivers: event-mode detection via Graph change notifications, eliminating the ~5-minute poll lag for large deployments.

In scope:

  • workers/graph-event-receiver/ β€” new CF Worker (analog of workers/r2-event-receiver/) that receives Graph notifications, echoes validationToken, validates clientState secret, enqueues graph_ingest SQS messages
  • Subscription create/renew/delete routes added to workers/api/src/routes/graph-integrations.ts
  • Subscription renewal Lambda (EventBridge cron, runs daily, renews subscriptions expiring within 48 h)
  • sharepoint_integrations.webhook_subscription_id + webhook_subscription_expiry columns (nullable, added as ALTER in a Phase 2 migration)
  • detection_mode toggle surfaced in the detail page UI

Phase 3 β€” Sites.Selected Onboarding + Polish

Delivers: least-privilege permission model (Sites.Selected instead of Sites.ReadWrite.All), guided admin-consent flow, usage dashboard on the detail page.

In scope:

  • Per-site permission grant UI walkthrough in the wizard
  • Graph permission validation check in the POST /:id/test endpoint (call /sites/{siteId}/drives with the sealed credential and verify 200)
  • Admin-consent redirect URL generator in the wizard
  • Usage summary fn surfaced in the detail page
  • Retry-backoff logic for 429 Retry-After responses in poll worker and writeback

Database Migrations

Migration 232 β€” sharepoint_integrations tables

File: supabase/migrations/20260801000000_232_sharepoint_integrations.sql

-- SharePoint / OneDrive integration: core tables (Phase 1).
-- Mirrors s3_integrations / s3_integration_events / s3_processed_objects
-- but replaces bucket+key identity with driveId+itemId from Graph API.
CREATE TYPE public.graph_integration_status AS ENUM ('active', 'paused', 'error');
CREATE TYPE public.graph_detection_mode AS ENUM ('poll', 'event');
CREATE TABLE public.sharepoint_integrations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
display_name TEXT NOT NULL CHECK (char_length(display_name) BETWEEN 1 AND 120),
-- Entra / Graph identity
tenant_id TEXT NOT NULL, -- e.g. 'ndus.onmicrosoft.com' or GUID
client_id TEXT NOT NULL, -- App registration client id (GUID)
client_secret_ciphertext TEXT NOT NULL, -- KMS-sealed base64; see integration-creds analog
kms_key_version SMALLINT NOT NULL DEFAULT 1,
client_secret_last4 TEXT NOT NULL CHECK (char_length(client_secret_last4) = 4),
-- SharePoint location
site_id TEXT NOT NULL, -- Graph site id (opaque, resolved during wizard)
drive_id TEXT NOT NULL, -- drive id within the site
input_folder_path TEXT NOT NULL DEFAULT '', -- '' = library root; 'Incoming/PDFs' = subfolder
output_folder_path TEXT NOT NULL, -- DISTINCT from input_folder_path (anti-feedback)
-- Detection
detection_mode public.graph_detection_mode NOT NULL DEFAULT 'poll',
delta_token TEXT, -- Graph delta query cursor; NULL = full resync
-- Phase 2 fields (nullable placeholders so migration runs once)
webhook_subscription_id TEXT,
webhook_subscription_expiry TIMESTAMPTZ,
webhook_client_state_secret UUID DEFAULT gen_random_uuid(),
-- Operational state
status public.graph_integration_status NOT NULL DEFAULT 'active',
last_error TEXT,
last_seen_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Uniqueness: one integration per (user, tenant, drive, input folder)
CONSTRAINT sharepoint_integrations_unique
UNIQUE (user_id, tenant_id, drive_id, input_folder_path),
-- Anti-feedback: output folder must differ from and not sit UNDER the input
-- folder. The reverse (input nested under output) is caught at runtime by the
-- poll-worker path filter, not here.
CONSTRAINT sharepoint_integrations_output_distinct CHECK (
output_folder_path <> input_folder_path
AND NOT (
input_folder_path <> ''
AND output_folder_path LIKE (input_folder_path || '/%')
)
)
-- Per-user cap (GRAPH_MAX_INTEGRATIONS_PER_USER = 25) is enforced by a COUNT
-- in the route handler β€” a DB UNIQUE constraint can't express "N per user".
);
CREATE INDEX sharepoint_integrations_user_id_idx
ON public.sharepoint_integrations (user_id);
CREATE INDEX sharepoint_integrations_status_detection_idx
ON public.sharepoint_integrations (status, detection_mode)
WHERE status = 'active';
CREATE TABLE public.sharepoint_integration_events (
id BIGSERIAL PRIMARY KEY,
integration_id UUID NOT NULL
REFERENCES public.sharepoint_integrations(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN (
'created', 'rotated', 'used', 'error',
'paused', 'resumed', 'deleted', 'detection_mode_changed',
'delta_reset' -- full resync triggered (delta token expired 410)
)),
detail JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX sharepoint_integration_events_integration_id_idx
ON public.sharepoint_integration_events (integration_id, created_at DESC);
-- Idempotency table and usage ledger.
-- PK: (integration_id, drive_id, item_id, ctag)
-- ctag = Graph cTag (content change tag) β€” analog of S3 etag.
-- A NULL job_id is the terminal skip marker (oversize, wrong MIME, etc.)
CREATE TABLE public.sharepoint_processed_items (
integration_id UUID NOT NULL
REFERENCES public.sharepoint_integrations(id) ON DELETE CASCADE,
drive_id TEXT NOT NULL,
item_id TEXT NOT NULL,
ctag TEXT NOT NULL,
job_id UUID, -- NULL = terminal skip; non-null = files.id
bytes_in BIGINT,
bytes_out BIGINT,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (integration_id, drive_id, item_id, ctag)
);
CREATE INDEX sharepoint_processed_items_integration_id_idx
ON public.sharepoint_processed_items (integration_id);
-- RLS
ALTER TABLE public.sharepoint_integrations ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.sharepoint_integration_events ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.sharepoint_processed_items ENABLE ROW LEVEL SECURITY;
-- Users CRUD their own integrations; events and processed items are read-only
CREATE POLICY "users own sharepoint_integrations"
ON public.sharepoint_integrations FOR ALL TO authenticated
USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid());
CREATE POLICY "users read own sharepoint_integration_events"
ON public.sharepoint_integration_events FOR SELECT TO authenticated
USING (EXISTS (
SELECT 1 FROM public.sharepoint_integrations si
WHERE si.id = integration_id AND si.user_id = auth.uid()
));
CREATE POLICY "users read own sharepoint_processed_items"
ON public.sharepoint_processed_items FOR SELECT TO authenticated
USING (EXISTS (
SELECT 1 FROM public.sharepoint_integrations si
WHERE si.id = integration_id AND si.user_id = auth.uid()
));
-- Service role bypasses RLS (enforced by Supabase default; no explicit grant needed
-- because service_role has BYPASSRLS)

Migration 233 β€” sharepoint_integration_usage_summary function

File: supabase/migrations/20260801000100_233_sharepoint_usage_summary.sql

Mirrors migration 203 exactly. The function takes (p_integration_id UUID, p_since TIMESTAMPTZ, p_user_id UUID), checks ownership via the explicit p_user_id parameter (same pattern as migration 203 to avoid auth.uid() null under service role), and returns (object_count BIGINT, bytes_in BIGINT, bytes_out BIGINT) from sharepoint_processed_items. SECURITY DEFINER, granted to service_role only.

Migration 234 β€” Graph token cache Redis key index

File: supabase/migrations/20260801000200_234_graph_token_cache.sql

No new tables. This migration adds a COMMENT on sharepoint_integrations documenting the graph:// source URL scheme, and creates a graph_token_cache table as a fallback for environments without Redis:

-- Durable token cache for Graph client-credentials tokens (see graph-auth.ts).
-- This is the cross-invocation tier (Lambda cold starts / Node restarts); the
-- in-process Map is the warm fast path. Redis is intentionally not used.
CREATE TABLE IF NOT EXISTS public.graph_token_cache (
cache_key TEXT PRIMARY KEY, -- 'graph_token:{integrationId}'
access_token TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Auto-evict expired tokens. pg_cron or the poll worker sweeps these.
CREATE INDEX graph_token_cache_expires_idx ON public.graph_token_cache (expires_at);
-- Service role only; no user-facing RLS needed (tokens are ephemeral)
ALTER TABLE public.graph_token_cache ENABLE ROW LEVEL SECURITY;

The poll worker uses the in-process Map as the warm fast path and this table as the durable cross-invocation tier; no Redis is required.


Shared Contract: packages/shared/src/graph-integrations.ts

New file. Mirrors packages/shared/src/s3-integrations.ts.

packages/shared/src/graph-integrations.ts
export type GraphIntegrationStatus = 'active' | 'paused' | 'error';
export type GraphDetectionMode = 'poll' | 'event';
/**
* SQS message envelope for a single SharePoint item ready to ingest.
* `type: 'graph_ingest'` routes it on the shared queue β€” consumers
* that don't know this type ignore it cleanly.
*/
interface GraphIngestJobBase {
type: 'graph_ingest';
integrationId: string;
userId: string;
/** Graph drive ID containing the item. */
driveId: string;
/** Graph item ID (stable, survives rename/move). */
itemId: string;
/** Graph cTag at enqueue time β€” idempotency tie-breaker. */
ctag: string;
/** File size in bytes from the delta listing. Optional (consumer HEAD-checks). */
size?: number;
/** MIME type hint from delta listing. Optional. */
mimeType?: string;
/** Human-readable filename for the files row display_name. */
fileName: string;
}
export type GraphIngestJob = GraphIngestJobBase &
(
| { source: 'poll'; enqueuedAt: number }
| { source: 'event'; clientState: string } // Phase 2
);
/** Max items returned per delta page request. */
export const GRAPH_POLL_MAX_ITEMS_PER_PAGE = 200; // Graph default page size
/** Max delta pages processed per integration per poll tick. */
export const GRAPH_POLL_MAX_PAGES_PER_RUN = 50; // 10k items per tick
/** Max file size accepted for conversion. */
export const GRAPH_POLL_MAX_ITEM_BYTES = 200 * 1024 * 1024; // 200 MB
/** Max integrations per user. */
export const GRAPH_MAX_INTEGRATIONS_PER_USER = 25;
/** Source URL scheme written to files.source_url for Graph-originated files. */
export const GRAPH_SOURCE_URL_SCHEME = 'graph://';
/** Build the source_url sentinel for a Graph item. */
export function buildGraphSourceUrl(driveId: string, itemId: string): string {
return `${GRAPH_SOURCE_URL_SCHEME}${driveId}/${itemId}`;
}
/** Parse a graph:// source URL. Returns null on malformed input. */
export function parseGraphSourceUrl(
url: string,
): { driveId: string; itemId: string } | null {
if (!url.startsWith(GRAPH_SOURCE_URL_SCHEME)) return null;
const rest = url.slice(GRAPH_SOURCE_URL_SCHEME.length);
const slash = rest.indexOf('/');
if (slash < 1) return null;
const driveId = rest.slice(0, slash);
const itemId = rest.slice(slash + 1);
if (!driveId || !itemId) return null;
return { driveId, itemId };
}

Also add to packages/shared/src/index.ts:

export * from './graph-integrations';

New Files to Create

workers/api/src/services/graph-auth.ts

Responsibility: acquire and cache Microsoft Graph OAuth 2.0 client-credentials tokens. This is the only place token acquisition logic lives β€” all other Graph callers receive a bearer string and call https://graph.microsoft.com/v1.0/... directly or via @microsoft/microsoft-graph-client.

Design (raw fetch, no MSAL β€” see Decisions Locked):

  • No new dependency. Acquire tokens by POSTing grant_type=client_credentials&scope=https://graph.microsoft.com/.default (plus client_id + client_secret) to https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token, and parse access_token/expires_in from the JSON. Identical code path in the Lambda poller and the Node server route path β€” no Dockerfile edits (contrast reference_workers_api_dockerfile_deps.md).
  • Cache: a module-level Map<integrationId, { token; expiresAt }> (warm fast path) backed by the graph_token_cache Supabase table for cold starts. Return a cached token if it has >120 s of remaining life; otherwise fetch a new one and write both tiers (TTL = expires_in - 120s).
  • Client secret is never stored in plaintext past the call β€” getGraphToken(deps, row) calls decryptGraphClientSecret(deps, row), uses the plaintext for the single token POST, then lets it fall out of scope.

Interfaces:

export interface GraphAuthDeps {
env: Env;
supabase: SupabaseClient;
log?: CredsLogger;
}
// Returns a bearer token string ready for Authorization header.
export async function getGraphToken(
deps: GraphAuthDeps,
row: { id: string; tenant_id: string; client_id: string;
client_secret_ciphertext: string; kms_key_version: number },
): Promise<string>
// Seal client secret for storage (mirrors encryptCredentials)
export async function encryptGraphClientSecret(
env: Env,
integrationId: string,
clientSecret: string,
log?: CredsLogger,
): Promise<{ ciphertext: string; last4: string; kmsKeyVersion: number }>
// Unseal client secret (mirrors decryptSealedCredentials)
export async function decryptGraphClientSecret(
env: Env,
integrationId: string,
ciphertextB64: string,
log?: CredsLogger,
): Promise<string>

Encryption context for KMS: { integration_id, field: 'graph_client_secret', version: '1' }. Reuse the same INTEGRATIONS_KMS_KEY_ARN env var β€” the field discriminant prevents cross-type replay.

workers/api/src/services/graph-poll-worker.ts

Responsibility: for each active+poll integration, run the Graph delta query loop, dedup against sharepoint_processed_items, and enqueue graph_ingest SQS messages.

Design mirrors s3-poll-worker.ts exactly:

export interface GraphPollDeps {
env: Env;
supabase: SupabaseClient;
sqs: SQSClient;
queueUrl: string;
log?: CredsLogger;
// Test seam: override Graph HTTP calls
fetchGraphPage?: (token: string, url: string) => Promise<GraphDeltaPage>;
}
export async function pollAllActiveGraphIntegrations(
deps: GraphPollDeps,
): Promise<{ integrationsScanned: number; totalEnqueued: number; totalErrors: number }>
export async function pollOneGraphIntegration(
deps: GraphPollDeps,
row: IntegrationPollRow, // the sharepoint_integrations SELECT result
): Promise<{ enqueued: number }>

Delta query URL construction:

  • First run (no delta_token): GET https://graph.microsoft.com/v1.0/drives/{driveId}/root:/ + {encodedFolderPath} + :/delta?$select=id,name,file,size,parentReference,cTag,deleted
  • Subsequent runs: use the deltaLink URL verbatim (it already encodes the token and params)
  • Page-to-page: follow @odata.nextLink until @odata.deltaLink appears or page budget exhausted
  • On 410 Gone (delta token expired): log a delta_reset event, set delta_token = NULL, continue with a full resync

Filtering rules (same logic as S3 poller):

  1. Skip items where deleted property is present (tombstones from delta)
  2. Skip items that are folders (no file property)
  3. Skip items whose parentReference.path falls under output_folder_path (anti-feedback-loop)
  4. Skip items exceeding GRAPH_POLL_MAX_ITEM_BYTES
  5. Dedup: check sharepoint_processed_items for (integration_id, drive_id, item_id, ctag) in 200-key chunks using in clause (same batch pattern as S3 poller)

Delta token persistence: when the full delta cycle completes within page budget, save the deltaLink URL’s token portion to delta_token. When budget runs out mid-cycle, leave delta_token at the intermediate nextLink token so the next run resumes from the same position. Clear delta_token to NULL only on full-resync after a 410.

workers/api/src/services/graph-ingest-consumer.ts

Responsibility: consume graph_ingest SQS messages; download the item from SharePoint; upload to our R2; write files row; start conversion.

Mirrors s3-ingest-consumer.ts step for step:

  1. Idempotency check: select (integration_id, drive_id, item_id, ctag) from sharepoint_processed_items β€” if found, return skipped_duplicate
  2. Load integration row, check status !== β€˜paused’
  3. Call GET /drives/{driveId}/items/{itemId} to get fresh metadata (size, mimeType, name, cTag drift check β€” if cTag changed since enqueue, the item was modified; insert new processed_items row and continue with fresh download)
  4. MIME allowlist check (same list as S3 consumer β€” PDF, images, DOCX, audio)
  5. Terminal skip marker: if MIME rejected or size exceeded, insert job_id = NULL into sharepoint_processed_items and return
  6. Download: GET /drives/{driveId}/items/{itemId}/content streaming to our R2 at users/{userId}/uploads/{fileId}/original/{fileName} with metadata sourceIntegration={integrationId} and sourceItemId={itemId}
  7. Set files.source_url = buildGraphSourceUrl(driveId, itemId) β€” this is the writeback routing signal
  8. Insert sharepoint_processed_items with job_id = fileId, bytes_in
  9. Call startConversion(deps, fileId) (generic, provider-agnostic β€” same as S3 path)

Interfaces:

export interface GraphIngestDeps {
env: Env;
supabase: SupabaseClient;
log?: CredsLogger;
// Test seams
fetchGraphItem?: (token: string, driveId: string, itemId: string) => Promise<GraphItem>;
fetchGraphContent?: (token: string, driveId: string, itemId: string) => Promise<ReadableStream>;
uploadToR2?: (key: string, body: ReadableStream, meta: Record<string, string>) => Promise<void>;
}
export async function ingestOneGraphItem(
deps: GraphIngestDeps,
job: GraphIngestJob,
): Promise<GraphIngestResult>
type GraphIngestResult =
| { status: 'ok'; fileId: string }
| { status: 'skipped_duplicate' }
| { status: 'skipped_paused' }
| { status: 'skipped_terminal'; reason: string }
| { status: 'error'; error: Error }

workers/api/src/services/graph-integration-writeback.ts

Responsibility: write conversion artifacts back to SharePoint after a successful conversion. Called from the two batch executor hooks.

Mirrors s3-integration-writeback.ts exactly, with Graph API replacements:

  • Guard: if (!sourceUrl?.startsWith('graph://')) return { status: 'skipped_not_integration' }
  • Parse graph:// URL to get driveId and itemId
  • Lookup: select from sharepoint_integrations by drive_id = driveId AND user_id = userId (one drive maps to one integration; no prefix ambiguity unlike S3 bucket/prefix combos)
  • Check status !== β€˜paused’
  • Get token via getGraphToken(deps, row)
  • For each artifact, determine output path:
    • Get the source item’s parent folder path: GET /drives/{driveId}/items/{itemId}?$select=parentReference,name
    • Compute relativeStem: strip input_folder_path from parent path, append / + stem of original filename (sans extension)
    • Output path in SharePoint: {output_folder_path}/{relativeStem}.{artifact.filename}
    • Upload via PUT /drives/{driveId}/root:/{outputPath}:/content for artifacts under 4 MB; use POST /drives/{driveId}/root:/{outputPath}:/createUploadSession + chunked upload for larger artifacts (the accessible HTML is unlikely to be large, but the accessible.pdf can be)
  • Accumulate bytes_out on sharepoint_processed_items (same read-then-update pattern as S3)
  • Insert sharepoint_integration_events with kind=β€˜used’ and action: 'writeback'

Interfaces:

export interface GraphWritebackDeps {
env: Env;
supabase: SupabaseClient;
log?: CredsLogger;
// Test seam
uploadGraphItem?: (
token: string, driveId: string, path: string, body: Buffer, contentType: string
) => Promise<void>;
}
export async function writeArtifactsToSharePoint(
deps: GraphWritebackDeps,
args: {
fileId: string;
userId: string;
sourceUrl: string | null | undefined;
artifacts: WritebackArtifact[]; // same type as s3-integration-writeback
},
): Promise<GraphWritebackResult>
type GraphWritebackResult =
| { status: 'uploaded'; uploadedPaths: string[]; totalBytes: number }
| { status: 'skipped_not_integration' }
| { status: 'skipped_paused' }
| { status: 'error_lookup_failed'; error: { message: string } }

workers/api/src/routes/graph-integrations.ts

Mirrors workers/api/src/routes/s3-integrations.ts. All routes behind requireAuth. Mount path: /api/integrations/sharepoint.

Route table (Phase 1):

MethodPathPurpose
GET/List user’s integrations
POST/Create integration (wizard final step)
GET/:idDetail with recent events summary
PATCH/:idUpdate displayName, status, outputFolderPath
DELETE/:idSoft-delete (sets status=β€˜paused’, emits β€˜deleted’ event; hard-delete deferred)
POST/:id/credentialsRotate client secret
GET/:id/eventsPaginated event log
GET/:id/processed-itemsPaginated processed items
GET/:id/usage-summary30-day RPC call
POST/:id/testVerify credential by calling GET /drives/{driveId} and GET /drives/{driveId}/root:/ + {inputFolderPath}
POST/resolve-siteGiven a SharePoint site URL, return siteId and available drives (used by wizard step 2)
GET/:id/consent-urlBuild the Microsoft admin-consent URL for the customer to visit

The POST /resolve-site endpoint calls GET https://graph.microsoft.com/v1.0/sites?$search="hostname:tenant.sharepoint.com" or the GET /sites/{hostname}:/{serverRelativePath} pattern with caller-supplied credentials (tenant_id + client_id + client_secret passed in the request body, NOT yet stored β€” this is the wizard verification step before creation). The returned siteId/driveId list populates the wizard dropdown.

rowToDto snake-to-camel transformation function follows the same pattern as S3 routes. Never return client_secret_ciphertext in DTO β€” omit it entirely.

workers/api/src/graph-poller.ts

Lambda entry point (EventBridge schedule). Exact analog of workers/api/src/s3-poller.ts:

  • Loads SSM secrets
  • Creates Supabase service-role client
  • Creates SQS client
  • Calls pollAllActiveGraphIntegrations(deps)
  • Returns a PollerSummary

Registered as a separate Lambda function in the CDK integrations stack (same CDK file that already registers the S3 poller). Invoke every 5 minutes.


Existing Files to Modify

workers/batch/src/pipeline-executor.ts

Locate the existing maybeWritebackToS3Integration call site at line ~373. The guard at the call site inside maybeWritebackToS3Integration is sourceUrl.startsWith('s3://'). Add a parallel call:

// Existing S3 writeback (unchanged):
try {
await maybeWritebackToS3Integration(storage, fid, result);
} catch (err) { ... }
// New Graph writeback (add immediately after):
try {
await maybeWritebackToSharePoint(storage, fid, result);
} catch (err) {
log('error', `graph-writeback failed for ${fid}: ${err instanceof Error ? err.message : String(err)}`, { sessionId });
}

Add maybeWritebackToSharePoint function at module level (same shape as the private maybeWritebackToS3Integration function):

async function maybeWritebackToSharePoint(
storage: StorageContext,
fileId: string,
result: { outputR2Key?: string | null },
): Promise<void> {
if (!storage.db) return;
const meta = await getFileMeta(storage, fileId);
if (!meta?.sourceUrl?.startsWith('graph://')) return;
// ... assemble artifacts from R2 (HTML + report.json), call writeArtifactsToSharePoint
}

Import writeArtifactsToSharePoint from ../../api/src/services/graph-integration-writeback.js.

workers/batch/src/accessible-pdf-export-executor.ts

Locate maybeWritebackPdf at line ~266. The current guard is:

if (!storage.db || !metadata.sourceUrl || !metadata.sourceUrl.startsWith('s3://')) return;

Change to:

if (!storage.db || !metadata.sourceUrl) return;
const isS3Source = metadata.sourceUrl.startsWith('s3://');
const isGraphSource = metadata.sourceUrl.startsWith('graph://');
if (!isS3Source && !isGraphSource) return;
if (isS3Source) {
// existing S3 path unchanged
await writeArtifactsToCustomerBucket(...);
}
if (isGraphSource) {
const env = process.env as unknown as Env;
await writeArtifactsToSharePoint(
{ env, supabase: storage.db as SupabaseClient, log: writebackLog },
{ fileId, userId, sourceUrl: metadata.sourceUrl, artifacts: [...] },
);
}

workers/api/src/index.ts

Add import and route mount (after the S3 lines at 106 and 342):

import { graphIntegrationRoutes } from './routes/graph-integrations';
// ...
app.route('/api/integrations/sharepoint', graphIntegrationRoutes);

workers/api/src/index-aws.ts

Same two additions at 184 and 571:

import { graphIntegrationRoutes } from './routes/graph-integrations';
// ...
app.route('/api/integrations/sharepoint', graphIntegrationRoutes);

Critical: do not skip index-aws.ts. The S3 routes went missing from Lambda until this was noticed (reference reference_api_dual_entry.md).

workers/api/src/server.ts β€” THIRD entry point, do NOT skip

server.ts is the Node-server entry (api.theaccessible.org) with its own route list; it mounts s3-integrations at ~line 759. Add the same import + mount:

import { graphIntegrationRoutes } from './routes/graph-integrations';
// ...
app.route('/api/integrations/sharepoint', graphIntegrationRoutes);

This is the exact omission that broke the phone admin routes (#1784 β†’ fix #1794): the route 404’d on the Node host until server.ts was fixed. Three entry points, always: index.ts, index-aws.ts, server.ts.

workers/api/src/s3-ingest-handler.ts β€” SQS dispatch (+ the server.ts consumer)

The shared ingest queue’s Lambda consumer lives here, and at ~line 117 it rejects unknown message types to the failure queue. Add a graph_ingest branch that calls ingestOneGraphItem(deps, job). The Node-side SQS consumer (server.ts ~line 1131, β€œconsumes all message types”) needs the same branch. Without both, every SharePoint message is dead-lettered instead of processed β€” this is a functional blocker, not cosmetic.

workers/api/src/types/env.ts

Under Model A there is no platform-level Entra app, so no GRAPH_APP_* env vars are needed β€” per-customer tenant_id/client_id/client_secret live (sealed) on the sharepoint_integrations rows. The only Graph-related env reuse is the KMS key, which already exists:

// Microsoft Graph integrations reuse the S3 integrations' KMS key to seal
// per-customer client secrets β€” no new key, and no GRAPH_APP_* vars (Model A).
// INTEGRATIONS_KMS_KEY_ARN is already present on Env.

So env.ts needs no change β€” reuse INTEGRATIONS_KMS_KEY_ARN.

packages/shared/src/index.ts

Add: export * from './graph-integrations';

apps/web/src/lib/api/integrations.ts

Add new types and API methods at the bottom of the file (after the S3 block):

// SharePoint integrations
export interface SharePointIntegration {
id: string;
displayName: string;
tenantId: string;
clientId: string;
clientSecretLast4: string;
siteId: string;
driveId: string;
inputFolderPath: string;
outputFolderPath: string;
detectionMode: 'poll' | 'event';
status: 'active' | 'paused' | 'error';
lastError: string | null;
lastSeenAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface SharePointIntegrationEvent {
id: number;
kind: string;
detail: Record<string, unknown>;
createdAt: string;
}
export interface SharePointProcessedItem {
driveId: string;
itemId: string;
ctag: string;
jobId: string | null;
bytesIn: number | null;
bytesOut: number | null;
processedAt: string;
}

API methods to add:

  • listSharePointIntegrations()
  • getSharePointIntegration(id)
  • createSharePointIntegration(input)
  • updateSharePointIntegration(id, input)
  • deleteSharePointIntegration(id)
  • rotateSharePointClientSecret(id, input)
  • testSharePointIntegration(id)
  • listSharePointIntegrationEvents(id, opts?)
  • listSharePointProcessedItems(id, opts?)
  • getSharePointIntegrationUsageSummary(id)
  • resolveSharePointSite(input: { tenantId; clientId; clientSecret; siteUrl }) β€” wizard step
  • getSharePointConsentUrl(id) β€” Phase 3

Also re-export the new types from apps/web/src/lib/api.ts alongside the existing S3 exports.

apps/web/src/app/account/integrations/page.tsx

The current list page hardcodes S3 integrations and the β€œAdd S3 integration” button. Extend it to:

  1. Load both api.listS3Integrations() and api.listSharePointIntegrations() in parallel via Promise.all
  2. Render a unified list (or two sections) with a type badge (β€œS3”, β€œSharePoint”)
  3. Add an β€œAdd SharePoint integration” button linking to /account/integrations/sharepoint/new
  4. SharePoint list items link to /account/integrations/sharepoint/detail?id={id} (static-export query param pattern)

New Frontend Files

apps/web/src/app/account/integrations/sharepoint/new/page.tsx

4-step wizard (matches S3 wizard structure):

Step 1 β€” Register Your App

Display instructions: β€œYou need a Microsoft Entra app registration in your tenant.” Show:

  • Link to Azure Portal app registration creation
  • Required API permissions: Sites.ReadWrite.All (or Sites.Selected β€” explain both)
  • Redirect URI: not needed for client credentials flow
  • Inputs collected: Tenant ID (GUID or tenant.onmicrosoft.com), Client ID (GUID), Client Secret (plaintext, cleared after submission)

Step 2 β€” Choose Site and Library

After the user enters credentials, call api.resolveSharePointSite() with those credentials + the SharePoint site URL they provide. Display a dropdown of available drives (document libraries) returned by the API. Collect:

  • Site URL (e.g. https://ndus.sharepoint.com/sites/Accessibility)
  • Drive selection from resolved list
  • Input folder path (text input, defaults to empty = library root)
  • Output folder path (text input, must differ from input folder path; client-side validation)

Step 3 β€” Admin Consent

Because Model A uses the customer’s own app with application permissions, the tenant admin grants consent in Azure Portal (β€œAPI Permissions β†’ Grant admin consent for [tenant]”) β€” no redirect needed. Show those instructions plus a β€œConsent granted” checkbox. (An /adminconsent URL is optional and only works if the customer registered a redirect URI on their app.) This step is informational β€” the verify call in Step 4 returns a Graph 403 if consent wasn’t actually granted.

Step 4 β€” Verify and Save

Call api.createSharePointIntegration(). On success, redirect to /account/integrations/sharepoint/detail?id={id}. On 403, surface a clear message: β€œConsent not yet granted or propagated β€” wait 60 seconds and retry.”

After successful creation, show a β€œTest Connection” button that calls api.testSharePointIntegration(id).

apps/web/src/app/account/integrations/sharepoint/detail/page.tsx

Standard Next.js static-export page (reads ?id= from useSearchParams).

apps/web/src/app/account/integrations/sharepoint/detail/IntegrationDetailClient.tsx

Display sections:

  • Status badge, last seen, display name (editable inline)
  • Site / drive / folder configuration (read-only; rotation requires delete + recreate)
  • Secret rotation: form accepts new client secret, calls rotateSharePointClientSecret
  • Detection mode toggle: poll / event (Phase 2 makes event meaningful)
  • Events table (paginated)
  • Processed items table (paginated, shows fileName + ctag + bytes_in/out)
  • Usage summary (30-day)
  • Danger zone: pause / resume / delete

Graph Auth Module Design β€” Entra App Registration

App Registration Model β€” Model A (locked)

Each customer registers their own Entra app in their own tenant and provides us three credentials per integration: tenant_id + client_id + client_secret. There is no platform-owned multi-tenant app and no public consent-redirect infrastructure. Every integration row carries its own client_id/client_secret (KMS-sealed), so the customer keeps full control over revocation. This fits NDSU’s shared NDUS tenant.

(Model B β€” one platform-owned multi-tenant app that each customer’s admin consents to β€” was considered and discarded: it would require a public redirect URI registered on our app and would centralize revocation on us.)

Customer-side registration (documented in wizard Step 1):

  • Supported account types: single tenant (their own) β€” never needs to be multi-tenant.
  • API permissions (application permissions, not delegated): Sites.ReadWrite.All (Files.ReadWrite.All is redundant once Sites.ReadWrite.All is granted). Phase 3 offers Sites.Selected for least-privilege.
  • No redirect URIs β€” client-credentials flow has no interactive redirect.
  • Client secret: one per registration; the customer rotates on their own schedule and re-enters it in our detail-page rotation form.

Admin consent: client-credentials apps with application permissions need no interactive consent redirect. The customer’s own tenant admin grants the permission directly in Azure Portal (β€œAPI Permissions β†’ Grant admin consent for [tenant]”). Wizard Step 3 shows these instructions, not a redirect URL.

POST /resolve-site accepts the three credentials in the request body, verifies them without storing, and returns site/drive choices; POST / seals them via KMS on create.

Token Cache Strategy

  1. Check the module-level Map<integrationId, { token: string; expiresAt: number }> β€” if the token has >120 s remaining, return it immediately (fast path for warm Lambda / the long-lived Node process).
  2. On miss, read the graph_token_cache Supabase row β€” if present and >120 s remaining, hydrate the in-process map and return (covers Lambda cold starts / Node restarts).
  3. On miss, POST the client-credentials request (raw fetch; see graph-auth.ts), then write both the in-process map and graph_token_cache (TTL = expires_in - 120s).

Redis is intentionally NOT a dependency here β€” KV/Redis availability is a known gap in this stack (see the self-hosted plan). The Supabase table is the durable tier.

Token scope: https://graph.microsoft.com/.default β€” acquires all permissions granted to the app.


Data Flow: Ingest Path (Phase 1)

EventBridge (every 5 min)
└─ Lambda: graph-poller.ts
└─ pollAllActiveGraphIntegrations(deps)
for each active+poll sharepoint_integration row:
└─ pollOneGraphIntegration(deps, row)
1. getGraphToken(deps, row) β†’ bearer token
2. GET /drives/{driveId}/root:/{inputFolderPath}:/delta
paginate with @odata.nextLink
until @odata.deltaLink or page budget
3. filter: skip tombstones, folders, output_folder items,
oversize, already in sharepoint_processed_items
4. SendMessageBatch to S3_INGEST_QUEUE_URL
messages: GraphIngestJob { type:'graph_ingest', ... }
5. persist delta_token, update last_seen_at
6. on error: set status='error', write event
SQS S3_INGEST_QUEUE_URL (shared with s3_ingest messages)
└─ Lambda consumer (existing s3-ingest handler)
reads type from message:
'graph_ingest' β†’ ingestOneGraphItem(deps, job)
1. idempotency check in sharepoint_processed_items
2. load integration row
3. GET /drives/{driveId}/items/{itemId} β€” fresh metadata + ctag drift check
4. GET /drives/{driveId}/items/{itemId}/content β€” streaming
5. Upload to R2: users/{userId}/uploads/{fileId}/original/{fileName}
6. putFileMeta: status='uploaded', source_url='graph://{driveId}/{itemId}'
7. INSERT sharepoint_processed_items (job_id=fileId, bytes_in)
8. startConversion(deps, fileId) ← generic, unchanged
Conversion pipeline (workers/batch)
└─ pipeline-executor.ts: on completion
maybeWritebackToSharePoint(storage, fileId, result)
source_url.startsWith('graph://') β†’ proceed
GET /drives/{driveId}/items/{itemId}?$select=parentReference,name
compute output path
PUT /drives/{driveId}/root:/{outputPath}:/content (HTML + report.json)
└─ accessible-pdf-export-executor.ts: on PDF completion
maybeWritebackPdf: source_url.startsWith('graph://') branch
β†’ writeArtifactsToSharePoint(..., [ accessible.pdf artifact ])

Build Sequence (Phased Checklist)

Phase 1

  • Create migration 232: sharepoint_integrations + events + processed_items tables (use timestamp 20260801000000)
  • Create migration 233: sharepoint_integration_usage_summary function (timestamp 20260801000100)
  • Create migration 234: graph_token_cache table (timestamp 20260801000200)
  • Apply all three migrations to prod Supabase before deploying any code that references them (see reference_prod_migration_drift.md)
  • Create packages/shared/src/graph-integrations.ts with GraphIngestJob, enums, URL helpers, caps
  • Export from packages/shared/src/index.ts
  • (No new dependency β€” token acquisition is raw fetch in graph-auth.ts, so no workers/api/package.json or Dockerfile changes)
  • Create workers/api/src/services/graph-auth.ts
  • Create workers/api/src/services/graph-poll-worker.ts
  • Create workers/api/src/services/graph-ingest-consumer.ts
  • Create workers/api/src/services/graph-integration-writeback.ts
  • Create workers/api/src/routes/graph-integrations.ts
  • Create workers/api/src/graph-poller.ts
  • Modify workers/api/src/index.ts: import + mount at /api/integrations/sharepoint
  • Modify workers/api/src/index-aws.ts: same import + mount (do NOT skip β€” Lambda 404 otherwise)
  • Modify workers/api/src/server.ts: same import + mount (THIRD entry β€” #1784 proved routes 404 on the Node host without it)
  • Modify workers/api/src/s3-ingest-handler.ts and the server.ts SQS consumer: add the graph_ingest β†’ ingestOneGraphItem dispatch branch (unknown types are dead-lettered today)
  • workers/api/src/types/env.ts: no change needed (Model A reuses INTEGRATIONS_KMS_KEY_ARN; no GRAPH_APP_* vars)
  • Register graph-poller Lambda in CDK integrations stack (5-minute EventBridge schedule)
  • Modify workers/batch/src/pipeline-executor.ts: add maybeWritebackToSharePoint call
  • Modify workers/batch/src/accessible-pdf-export-executor.ts: add graph:// branch in maybeWritebackPdf
  • Add apps/web/src/lib/api/integrations.ts types + methods (SharePoint section)
  • Re-export from apps/web/src/lib/api.ts
  • Create apps/web/src/app/account/integrations/sharepoint/new/page.tsx (4-step wizard)
  • Create apps/web/src/app/account/integrations/sharepoint/detail/page.tsx
  • Create apps/web/src/app/account/integrations/sharepoint/detail/IntegrationDetailClient.tsx
  • Modify apps/web/src/app/account/integrations/page.tsx: add SharePoint integrations section
  • Write unit tests: graph-auth.test.ts, graph-poll-worker.test.ts, graph-ingest-consumer.test.ts, graph-integration-writeback.test.ts (inject GraphPollDeps/GraphIngestDeps test seams)
  • Write route test: workers/api/src/__tests__/routes/graph-integrations.test.ts (CI check-route-test-coverage will block merge otherwise)
  • Run npm run typecheck from apps/web after all frontend changes
  • Deploy: Node rebuild on 10.1.1.4 (API routes), Lambda deploy for graph-poller

Phase 2

  • Create CF Worker: workers/graph-event-receiver/ (analog of workers/r2-event-receiver/)
    • Receives POST /notify from Graph notification service
    • Validates validationToken echo (Graph subscription validation handshake)
    • Validates clientState header via timing-safe compare against webhook_client_state_secret
    • Enqueues graph_ingest SQS message
  • Add Phase 2 routes to graph-integrations.ts: POST /:id/subscription (create), DELETE /:id/subscription (delete)
  • Add subscription renewal Lambda: queries integrations where webhook_subscription_expiry < now() + 48h, renews via PATCH /subscriptions/{subscriptionId}, updates webhook_subscription_expiry
  • Add detection_mode toggle to detail page UI

Phase 3

  • Sites.Selected documentation in wizard Step 1 (alternate permission path for tenants that require least-privilege)
  • POST /:id/test endpoint validates Sites.Selected by calling the site-specific drive endpoint and distinguishing a sites-level 403 from a drive-level 403
  • Usage summary chart on detail page (30-day bytes_in/bytes_out)
  • Retry-after backoff in poll worker and writeback (429 responses from Graph)

Testing Strategy

Getting a Dev SharePoint Tenant

Two options:

  1. Microsoft 365 Developer Program (free, recommended): developer.microsoft.com/microsoft-365/dev-program β€” provisions an E5 sandbox tenant with SharePoint Online, 25 test user accounts, sample data packs. Renewed every 90 days if dev activity is detected. This is the correct tool for local and CI integration tests.
  2. Microsoft 365 Business Basic trial: 30-day commercial trial with SharePoint included. Useful for testing real enterprise tenant behavior (MFA policies, conditional access, etc.).

For NDUS specifically: NDSU IT can provision a test site collection in their non-production SharePoint environment, or you can use the M365 dev sandbox for all development and reserve NDUS credentials for final UAT.

Unit Testing with Injected Deps

Every service follows the PollDeps injection pattern already established in the S3 code. The GraphPollDeps.fetchGraphPage seam replaces the real Graph HTTP call with a function that returns a GraphDeltaPage object. Similarly:

  • GraphIngestDeps.fetchGraphItem β€” returns fake GraphItem metadata
  • GraphIngestDeps.fetchGraphContent β€” returns a fake ReadableStream or Buffer
  • GraphWritebackDeps.uploadGraphItem β€” records calls, returns void

Test matrix for graph-poll-worker.test.ts:

  • Full delta sweep with items: 3 new items β†’ 3 SQS messages enqueued, delta_token persisted
  • Budget exhaustion mid-page: intermediate next_token persisted, NOT the final deltaLink
  • 410 Gone response: delta_reset event written, delta_token cleared, re-poll from scratch
  • Dedup: existing (integration_id, drive_id, item_id, ctag) in processed_items β†’ skipped
  • Anti-feedback filter: item whose parent path falls under output_folder_path β†’ skipped
  • Paused integration: pollAllActiveGraphIntegrations does not call it (filtered at query)
  • 429 Throttle: function backs off and returns partial enqueue count without flipping status=error

Test matrix for graph-ingest-consumer.test.ts:

  • Happy path: fresh item downloaded, uploaded to R2, files row created, conversion started
  • Duplicate: existing processed_items row β†’ skipped_duplicate, no R2 upload
  • cTag drift: item modified between enqueue and consume β†’ fresh download proceeds with new ctag
  • Wrong MIME type: terminal skip marker inserted, conversion not started
  • Oversized: terminal skip marker inserted
  • Paused integration: skipped_paused, no download

Test matrix for graph-integration-writeback.test.ts:

  • source_url is null β†’ skipped_not_integration
  • source_url starts with s3:// β†’ skipped_not_integration
  • source_url starts with graph:// but no matching integration row β†’ skipped_not_integration
  • Integration paused β†’ skipped_paused
  • Happy path: 3 artifacts (html, report.json, pdf) β†’ 3 uploadGraphItem calls, bytes_out accumulated, audit event inserted

Mocking vs Live

Unit tests: mock everything β€” use vi.fn() and injected deps. No network, no Supabase.

Integration tests (against M365 dev sandbox): run manually before Phase 1 merge and on a weekly CI schedule with M365 credentials in GitHub Actions secrets. Test:

  • Token acquisition and cache hit
  • Delta query returns expected items after a manual file upload to SharePoint
  • Writeback creates a file in the output folder with correct name and content

E2E (Playwright): add a test-gap issue for the wizard flow β€” full browser test of the 4-step wizard against the M365 dev sandbox requires real credentials in the E2E environment. File the gap issue and target it for Phase 3.


Risks and Graph-Specific Gotchas

Delta Token Expiration (410 Gone)

Graph delta tokens expire after approximately 7 days of inactivity. If polling is paused, the token will be stale. The poll worker must handle the 410 Gone response code by logging a delta_reset event, clearing delta_token to NULL, and immediately re-polling from scratch (a full delta sweep without a cursor). This is not an error condition β€” it is expected and recoverable. The only consequence is re-scanning the entire drive folder on next run; the sharepoint_processed_items dedup table prevents re-converting files that were already processed.

Implementation note: Graph may also return 410 when the drive structure changes significantly (large rename operations, library recreation). Same handler.

Graph Throttling (429 + Retry-After)

Microsoft throttles Graph API calls per app per tenant. Limits are not publicly documented but commonly encountered at ~4 requests/second sustained and during large delta syncs. The Retry-After response header specifies the backoff period (typically 10–60 s). The poll worker should:

  1. Check for 429 responses on every Graph call
  2. Extract Retry-After header (value is seconds as an integer string)
  3. await sleep(retryAfter * 1000) then retry once
  4. On second 429, return partial enqueue count (do not flip status=error β€” throttling is transient)
  5. Log the throttle event at warn level with the integration ID and retry-after value

For writeback, a 429 during PutObject to SharePoint is a non-fatal error β€” log it, accumulate in a retry queue (Phase 3), and continue. The PDF and HTML are already in R2.

Subscription Expiry and Renewal (Phase 2)

Graph change notification subscriptions expire after a maximum of 4230 minutes (~3 days) for SharePoint driveItem subscriptions. The renewal Lambda (Phase 2) must run daily and renew any subscriptions expiring within 48 hours using PATCH /subscriptions/{subscriptionId} with a new expirationDateTime. If renewal fails (e.g., credentials rotated), flip the integration to detection_mode=poll as a fallback and log an error event.

Race condition: between subscription expiry and renewal, new files may be missed. The daily poll (which runs even in event-mode as a consistency backstop β€” confirm this design decision, not currently in S3 event mode) would catch these. For Phase 1 (poll only), this is irrelevant.

Large File Upload Sessions

Graph has a 4 MB single-PUT limit for file uploads. The accessible PDF can exceed this for large documents. Use the Upload Session pattern:

  1. POST /drives/{driveId}/root:/{path}:/createUploadSession β†’ get uploadUrl
  2. PUT to uploadUrl in 5–10 MB chunks with Content-Range header
  3. The session URL is pre-authenticated (no bearer token needed on chunk PUTs)
  4. Session expires after 24 hours of inactivity

The accessible HTML is text and will almost never exceed 4 MB. The report.json is small. Only the accessible PDF needs the upload session path. Guard with: if (artifact.body.byteLength > 4 * 1024 * 1024) use upload session.

Sites.Selected Permission-Grant Propagation Delay

When a tenant admin grants Sites.Selected permission to a specific site (via the Graph POST /sites/{siteId}/permissions endpoint or via SharePoint PnP PowerShell), the grant can take up to 5 minutes to propagate before the client-credentials token actually reflects the permission. The wizard should show a banner: β€œIf you just granted access, wait up to 5 minutes before clicking Verify.” The /test endpoint should return a clear 403 message distinguishing β€œcredentials wrong” from β€œaccess not yet granted / propagated.”

Clock Skew on Client Credentials

The iat and exp claims on the requested access token are set by Microsoft’s STS. If the Lambda server clock is more than 5 minutes skewed, token acquisition will fail with an AADSTS500011 error. Lambda instances use NTP and are generally accurate; flag this only if the token POST returns AADSTS500011 clock-skew errors in the logs.

Multi-Geo and GCC-High Tenants

NDUS is on commercial cloud (graph.microsoft.com endpoint). No action needed for NDSU specifically. However, future customers on GCC-High (US government cloud) use a different endpoint: graph.microsoft.us. GCC DoD uses dod-graph.microsoft.us. Add a graph_endpoint column (nullable, defaults to https://graph.microsoft.com/v1.0) to sharepoint_integrations when a GCC customer first requests support. The poll worker, ingest consumer, auth module, and writeback service all read this column rather than hardcoding the endpoint. For Phase 1, NDUS is commercial cloud so the column can be omitted and graph.microsoft.com hardcoded.

Anti-Feedback Loop Integrity

The S3 integration uses a prefix-based filter (sourceKey.startsWith(output_prefix)) enforced both at poll time and at ingest time. SharePoint uses a folder path. The delta query returns items with parentReference.path set to something like /drives/{driveId}/root:/{folderPath}. The anti-feedback filter in the poll worker must strip the Graph path prefix (/drives/{driveId}/root:/) and compare the remainder against output_folder_path. Test this with a nested folder case: if input_folder_path = 'Incoming' and output_folder_path = 'Accessible', a file at Accessible/2024/document.html should be skipped.

Token acquisition runtime β€” raw fetch everywhere (no MSAL)

Both production runtimes are Node: the Lambda handler (index-aws.ts) and the Node server (server.ts, behind the api.theaccessible.org Cloudflare tunnel β€” not a Worker). The legacy index.ts CF Worker is dead code (reference_node_server_deploy.md, reference_api_dual_entry.md), so there is no Workers runtime to design around β€” MSAL would in fact run in both. We avoid MSAL anyway, purely to skip a new dependency and its three Dockerfile edits (reference_workers_api_dockerfile_deps.md).

Token acquisition is a single POST to https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token (grant_type=client_credentials, scope=https://graph.microsoft.com/.default) via plain fetch() in graph-auth.ts β€” the same path in the poller (Lambda), the ingest consumer, the writeback service, and the route handlers (/resolve-site, /). No proxy-through-Lambda, no internal token endpoint, no per-runtime branching.


Relevant File Paths for Implementation

Backend services (create):

  • /Users/larryanglin/Projects/accessible/workers/api/src/services/graph-auth.ts
  • /Users/larryanglin/Projects/accessible/workers/api/src/services/graph-poll-worker.ts
  • /Users/larryanglin/Projects/accessible/workers/api/src/services/graph-ingest-consumer.ts
  • /Users/larryanglin/Projects/accessible/workers/api/src/services/graph-integration-writeback.ts
  • /Users/larryanglin/Projects/accessible/workers/api/src/routes/graph-integrations.ts
  • /Users/larryanglin/Projects/accessible/workers/api/src/graph-poller.ts

Backend services (modify):

  • /Users/larryanglin/Projects/accessible/workers/api/src/index.ts (lines 106, 342)
  • /Users/larryanglin/Projects/accessible/workers/api/src/index-aws.ts (lines 184, 571)
  • /Users/larryanglin/Projects/accessible/workers/api/src/server.ts (route mount ~line 759; SQS consumer ~line 1131) β€” THIRD entry point
  • /Users/larryanglin/Projects/accessible/workers/api/src/s3-ingest-handler.ts (~line 117 β€” add graph_ingest dispatch)
  • /Users/larryanglin/Projects/accessible/workers/batch/src/pipeline-executor.ts (~line 373)
  • /Users/larryanglin/Projects/accessible/workers/batch/src/accessible-pdf-export-executor.ts (~line 266, 273)

Shared contract (modify):

  • /Users/larryanglin/Projects/accessible/packages/shared/src/graph-integrations.ts (create)
  • /Users/larryanglin/Projects/accessible/packages/shared/src/index.ts

Frontend (create):

  • /Users/larryanglin/Projects/accessible/apps/web/src/app/account/integrations/sharepoint/new/page.tsx
  • /Users/larryanglin/Projects/accessible/apps/web/src/app/account/integrations/sharepoint/detail/page.tsx
  • /Users/larryanglin/Projects/accessible/apps/web/src/app/account/integrations/sharepoint/detail/IntegrationDetailClient.tsx

Frontend (modify):

  • /Users/larryanglin/Projects/accessible/apps/web/src/app/account/integrations/page.tsx
  • /Users/larryanglin/Projects/accessible/apps/web/src/lib/api/integrations.ts
  • /Users/larryanglin/Projects/accessible/apps/web/src/lib/api.ts

Migrations (create):

  • /Users/larryanglin/Projects/accessible/supabase/migrations/20260801000000_232_sharepoint_integrations.sql
  • /Users/larryanglin/Projects/accessible/supabase/migrations/20260801000100_233_sharepoint_usage_summary.sql
  • /Users/larryanglin/Projects/accessible/supabase/migrations/20260801000200_234_graph_token_cache.sql

Reference (existing, read to ground the new code):

  • /Users/larryanglin/Projects/accessible/workers/api/src/services/integration-creds.ts β€” KMS pattern to replicate
  • /Users/larryanglin/Projects/accessible/workers/api/src/services/s3-integration-writeback.ts β€” writeback pattern
  • /Users/larryanglin/Projects/accessible/workers/api/src/services/s3-poll-worker.ts β€” poll loop pattern
  • /Users/larryanglin/Projects/accessible/packages/shared/src/s3-integrations.ts β€” shared contract pattern