The Atlas MikeOSS.Azure's docs, bound to the code — and to the migration that built it
108 documents

The migration, PRD by PRD

The trail of changes since the upstream fork: the milestone azure-migration issues in order, each paired with the code it produced. How Mike went from hosted-Supabase SaaS to a single-tenant Azure deployment.

Parent docs

docs/storage/001-blob-storage-platform.md docs/infra/001-network-topology.md

What to build

Two things in one vertical slice: provision the Azure Blob Storage account (infra) and wire the AzureBlobProvider into the backend (code). They are bundled because neither is verifiable end-to-end without the other.

Infra: infra/modules/storage.bicep

  • Microsoft.Storage/storageAccounts: name stmike<env>, SKU Standard_LRS, kind StorageV2.
  • allowBlobPublicAccess: false, minimumTlsVersion: 'TLS1_2', networkAcls.defaultAction: 'Deny', bypass: 'None'.
  • Blob container documents (private, no anonymous access).
  • Private Endpoint in subnet-pe; DNS zone group linked to privatelink.blob.core.windows.net (zone from slice 001).
  • Role assignment: backend Container App system-assigned MI → Storage Blob Data Contributor scoped to the documents container (actual MI exists after slice 006; define the role assignment as a conditional or wire it in slice 006).
  • Blob soft-delete: 7-day retention.

Code: backend/src/lib/storage.ts

Implement the StorageProvider interface and factory documented in docs/storage/001-blob-storage-platform.md:

export interface StorageProvider {
  upload(key: string, content: ArrayBuffer, contentType: string): Promise<void>;
  download(key: string): Promise<ArrayBuffer | null>;
  remove(key: string): Promise<void>;
  signedUrl(key: string, expiresIn: number, downloadFilename?: string): Promise<string | null>;
}
  • R2Provider — wraps existing AWS SDK v3 behaviour; selected when R2_ENDPOINT_URL + R2_ACCESS_KEY_ID + R2_SECRET_ACCESS_KEY are set.
  • AzureBlobProvider — uses @azure/storage-blob + @azure/identity (DefaultAzureCredential); selected when AZURE_STORAGE_ACCOUNT_NAME or AZURE_STORAGE_CONNECTION_STRING is set. Azure takes priority over R2.
  • AzureBlobProvider.signedUrl() returns null (no SAS URLs issued to clients; /download/:token proxy handles downloads).
  • createProvider() factory at module load selects the implementation; the four module-level functions (uploadFile, downloadFile, deleteFile, getSignedUrl) delegate to the singleton. No call-site changes outside storage.ts.

Update backend/src/routes/documents.ts: when getSignedUrl() returns null, fall back to buildDownloadUrl() (this pattern may already be present; confirm and keep).

Local dev: AZURE_STORAGE_CONNECTION_STRING=UseDevelopmentStorage=true (Azurite).

Packages already in backend/package.json: @azure/storage-blob ^12.26.0, @azure/identity ^4.5.0. AWS SDK packages are retained for R2 compatibility.

Acceptance criteria

  • [ ] AZURE_STORAGE_CONNECTION_STRING=UseDevelopmentStorage=true + Azurite: upload, download, delete all succeed locally.
  • [ ] AZURE_STORAGE_ACCOUNT_NAME=stmikedev + az login credentials: upload, download, delete succeed against the real Azure storage account.
  • [ ] getSignedUrl() returns null from AzureBlobProvider; /download/:token route returns the file correctly (no change to call sites in routes).
  • [ ] R2_ENDPOINT_URL set: R2Provider is selected; existing R2 behaviour is unchanged.
  • [ ] Neither env block set: server starts but logs a clear configuration warning.
  • [ ] Storage account has no public endpoint reachable from outside the VNet (verify via az storage account show --query networkRuleSet).
  • [ ] Blob soft-delete retention is 7 days.

Blocked by

  • 001-bicep-infra-foundation.md (VNet, private DNS zones must exist for the storage private endpoint)

User stories addressed

  • Document upload and download work against Azure Blob Storage with no storage credentials in application config (Managed Identity in Azure, az login locally).
  • Existing R2/Cloudflare deployments continue to work with no code changes.
  • Browser never receives storage credentials or SAS URLs.