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: namestmike<env>, SKUStandard_LRS, kindStorageV2.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 toprivatelink.blob.core.windows.net(zone from slice 001). - Role assignment: backend Container App system-assigned MI →
Storage Blob Data Contributorscoped to thedocumentscontainer (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 whenR2_ENDPOINT_URL+R2_ACCESS_KEY_ID+R2_SECRET_ACCESS_KEYare set.AzureBlobProvider— uses@azure/storage-blob+@azure/identity(DefaultAzureCredential); selected whenAZURE_STORAGE_ACCOUNT_NAMEorAZURE_STORAGE_CONNECTION_STRINGis set. Azure takes priority over R2.AzureBlobProvider.signedUrl()returnsnull(no SAS URLs issued to clients;/download/:tokenproxy 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 outsidestorage.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 logincredentials: upload, download, delete succeed against the real Azure storage account. - [ ]
getSignedUrl()returnsnullfromAzureBlobProvider;/download/:tokenroute returns the file correctly (no change to call sites in routes). - [ ]
R2_ENDPOINT_URLset:R2Provideris 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 loginlocally). - Existing R2/Cloudflare deployments continue to work with no code changes.
- Browser never receives storage credentials or SAS URLs.