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 ustenant_id+client_id+client_secret. Credentials are KMS-sealed persharepoint_integrationsrow. 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 POSTinggrant_type=client_credentialstohttps://login.microsoftonline.com/{tenantId}/oauth2/v2.0/tokenwithscope=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.tsimplements a small in-process + Supabasegraph_token_cachelayer 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,GraphIngestJobenvelope, capsworkers/api/src/services/graph-auth.tsβ rawfetchclient-credentials token acquisition + in-process/Supabase cache (no MSAL)workers/api/src/services/graph-poll-worker.tsβ delta query loop, idempotency, SQS enqueueworkers/api/src/services/graph-ingest-consumer.tsβ download item, upload to R2, start conversionworkers/api/src/services/graph-integration-writeback.tsβ write artifacts back to SharePointworkers/api/src/routes/graph-integrations.tsβ HTTP routes mounted in both entry pointsworkers/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 ofworkers/r2-event-receiver/) that receives Graph notifications, echoesvalidationToken, validatesclientStatesecret, enqueuesgraph_ingestSQS 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_expirycolumns (nullable, added as ALTER in a Phase 2 migration)detection_modetoggle 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/testendpoint (call/sites/{siteId}/driveswith 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);
-- RLSALTER 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-onlyCREATE 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.
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(plusclient_id+client_secret) tohttps://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token, and parseaccess_token/expires_infrom the JSON. Identical code path in the Lambda poller and the Node server route path β no Dockerfile edits (contrastreference_workers_api_dockerfile_deps.md). - Cache: a module-level
Map<integrationId, { token; expiresAt }>(warm fast path) backed by thegraph_token_cacheSupabase 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)callsdecryptGraphClientSecret(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
deltaLinkURL verbatim (it already encodes the token and params) - Page-to-page: follow
@odata.nextLinkuntil@odata.deltaLinkappears or page budget exhausted - On
410 Gone(delta token expired): log adelta_resetevent, setdelta_token = NULL, continue with a full resync
Filtering rules (same logic as S3 poller):
- Skip items where
deletedproperty is present (tombstones from delta) - Skip items that are folders (no
fileproperty) - Skip items whose
parentReference.pathfalls underoutput_folder_path(anti-feedback-loop) - Skip items exceeding
GRAPH_POLL_MAX_ITEM_BYTES - Dedup: check
sharepoint_processed_itemsfor(integration_id, drive_id, item_id, ctag)in 200-key chunks usinginclause (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:
- Idempotency check: select
(integration_id, drive_id, item_id, ctag)fromsharepoint_processed_itemsβ if found, returnskipped_duplicate - Load integration row, check status !== βpausedβ
- 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) - MIME allowlist check (same list as S3 consumer β PDF, images, DOCX, audio)
- Terminal skip marker: if MIME rejected or size exceeded, insert
job_id = NULLintosharepoint_processed_itemsand return - Download:
GET /drives/{driveId}/items/{itemId}/contentstreaming to our R2 atusers/{userId}/uploads/{fileId}/original/{fileName}with metadatasourceIntegration={integrationId}andsourceItemId={itemId} - Set
files.source_url = buildGraphSourceUrl(driveId, itemId)β this is the writeback routing signal - Insert
sharepoint_processed_itemswithjob_id = fileId,bytes_in - 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 getdriveIdanditemId - Lookup: select from
sharepoint_integrationsbydrive_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: stripinput_folder_pathfrom 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}:/contentfor artifacts under 4 MB; usePOST /drives/{driveId}/root:/{outputPath}:/createUploadSession+ chunked upload for larger artifacts (the accessible HTML is unlikely to be large, but the accessible.pdf can be)
- Get the source itemβs parent folder path:
- Accumulate
bytes_outonsharepoint_processed_items(same read-then-update pattern as S3) - Insert
sharepoint_integration_eventswith kind=βusedβ andaction: '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):
| Method | Path | Purpose |
|---|---|---|
| GET | / | List userβs integrations |
| POST | / | Create integration (wizard final step) |
| GET | /:id | Detail with recent events summary |
| PATCH | /:id | Update displayName, status, outputFolderPath |
| DELETE | /:id | Soft-delete (sets status=βpausedβ, emits βdeletedβ event; hard-delete deferred) |
| POST | /:id/credentials | Rotate client secret |
| GET | /:id/events | Paginated event log |
| GET | /:id/processed-items | Paginated processed items |
| GET | /:id/usage-summary | 30-day RPC call |
| POST | /:id/test | Verify credential by calling GET /drives/{driveId} and GET /drives/{driveId}/root:/ + {inputFolderPath} |
| POST | /resolve-site | Given a SharePoint site URL, return siteId and available drives (used by wizard step 2) |
| GET | /:id/consent-url | Build 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 integrationsexport 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 stepgetSharePointConsentUrl(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:
- Load both
api.listS3Integrations()andapi.listSharePointIntegrations()in parallel viaPromise.all - Render a unified list (or two sections) with a type badge (βS3β, βSharePointβ)
- Add an βAdd SharePoint integrationβ button linking to
/account/integrations/sharepoint/new - 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(orSites.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.Allis redundant once Sites.ReadWrite.All is granted). Phase 3 offersSites.Selectedfor 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
- 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). - On miss, read the
graph_token_cacheSupabase row β if present and >120 s remaining, hydrate the in-process map and return (covers Lambda cold starts / Node restarts). - On miss, POST the client-credentials request (raw
fetch; see graph-auth.ts), then write both the in-process map andgraph_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 timestamp20260801000000) - Create migration 233:
sharepoint_integration_usage_summaryfunction (timestamp20260801000100) - Create migration 234:
graph_token_cachetable (timestamp20260801000200) - 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.tswithGraphIngestJob, enums, URL helpers, caps - Export from
packages/shared/src/index.ts - (No new dependency β token acquisition is raw
fetchingraph-auth.ts, so noworkers/api/package.jsonor 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.tsand theserver.tsSQS consumer: add thegraph_ingestβingestOneGraphItemdispatch branch (unknown types are dead-lettered today) -
workers/api/src/types/env.ts: no change needed (Model A reusesINTEGRATIONS_KMS_KEY_ARN; noGRAPH_APP_*vars) - Register
graph-pollerLambda in CDK integrations stack (5-minute EventBridge schedule) - Modify
workers/batch/src/pipeline-executor.ts: addmaybeWritebackToSharePointcall - Modify
workers/batch/src/accessible-pdf-export-executor.ts: addgraph://branch inmaybeWritebackPdf - Add
apps/web/src/lib/api/integrations.tstypes + 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(injectGraphPollDeps/GraphIngestDepstest 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 typecheckfromapps/webafter 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 ofworkers/r2-event-receiver/)- Receives
POST /notifyfrom Graph notification service - Validates
validationTokenecho (Graph subscription validation handshake) - Validates
clientStateheader via timing-safe compare againstwebhook_client_state_secret - Enqueues
graph_ingestSQS message
- Receives
- 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 viaPATCH /subscriptions/{subscriptionId}, updateswebhook_subscription_expiry - Add
detection_modetoggle to detail page UI
Phase 3
- Sites.Selected documentation in wizard Step 1 (alternate permission path for tenants that require least-privilege)
-
POST /:id/testendpoint 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:
- 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. - 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 fakeGraphItemmetadataGraphIngestDeps.fetchGraphContentβ returns a fakeReadableStreamorBufferGraphWritebackDeps.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_tokenpersisted - Budget exhaustion mid-page: intermediate
next_tokenpersisted, NOT the finaldeltaLink - 410 Gone response:
delta_resetevent written,delta_tokencleared, 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:
pollAllActiveGraphIntegrationsdoes 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_urlisnullβskipped_not_integration -
source_urlstarts withs3://βskipped_not_integration -
source_urlstarts withgraph://but no matching integration row βskipped_not_integration - Integration paused β
skipped_paused - Happy path: 3 artifacts (html, report.json, pdf) β 3
uploadGraphItemcalls,bytes_outaccumulated, 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:
- Check for 429 responses on every Graph call
- Extract
Retry-Afterheader (value is seconds as an integer string) await sleep(retryAfter * 1000)then retry once- On second 429, return partial enqueue count (do not flip status=error β throttling is transient)
- Log the throttle event at
warnlevel 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:
POST /drives/{driveId}/root:/{path}:/createUploadSessionβ getuploadUrl- PUT to
uploadUrlin 5β10 MB chunks withContent-Rangeheader - The session URL is pre-authenticated (no bearer token needed on chunk PUTs)
- 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 β addgraph_ingestdispatch)/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