feat(blob): add @bytelyst/blob shared package
This commit is contained in:
parent
dcfb774313
commit
125eb03745
23
README.md
23
README.md
@ -54,15 +54,20 @@ learning_ai_common_plat/
|
||||
|
||||
## Shared Libraries
|
||||
|
||||
| Package | Description | Peer Dependencies |
|
||||
| ------------------------- | --------------------------------------------------------- | ------------------ |
|
||||
| `@bytelyst/errors` | Typed HTTP service errors (400–429) | — |
|
||||
| `@bytelyst/cosmos` | Azure Cosmos DB client singleton + container registry | `@azure/cosmos` |
|
||||
| `@bytelyst/config` | Zod-based env config loader + product identity | `zod` |
|
||||
| `@bytelyst/auth` | JWT utilities, auth middleware, password hashing | `jose`, `bcryptjs` |
|
||||
| `@bytelyst/api-client` | Configurable fetch wrapper with auth token injection | — |
|
||||
| `@bytelyst/react-auth` | React auth context factory (typed provider + hook) | `react` |
|
||||
| `@bytelyst/design-tokens` | Cross-platform design tokens (JSON → CSS/TS/Kotlin/Swift) | — |
|
||||
| Package | Description | Peer Dependencies |
|
||||
| ------------------------- | --------------------------------------------------------- | ---------------------- |
|
||||
| `@bytelyst/errors` | Typed HTTP service errors (400–429) | — |
|
||||
| `@bytelyst/logger` | Structured logger factory for services/dashboards | — |
|
||||
| `@bytelyst/cosmos` | Azure Cosmos DB client singleton + container registry | `@azure/cosmos` |
|
||||
| `@bytelyst/blob` | Azure Blob Storage helpers + SAS URL generation | `@azure/storage-blob` |
|
||||
| `@bytelyst/config` | Zod-based env config loader + product identity | `zod` |
|
||||
| `@bytelyst/auth` | JWT utilities, auth middleware, password hashing | `jose`, `bcryptjs` |
|
||||
| `@bytelyst/fastify-core` | Fastify service bootstrap (request-id, /health, errors) | `fastify` |
|
||||
| `@bytelyst/api-client` | Configurable fetch wrapper with auth token injection | — |
|
||||
| `@bytelyst/react-auth` | React auth context factory (typed provider + hook) | `react` |
|
||||
| `@bytelyst/design-tokens` | Cross-platform design tokens (JSON → CSS/TS/Kotlin/Swift) | — |
|
||||
| `@bytelyst/extraction` | Extraction service client + shared types | `@bytelyst/api-client` |
|
||||
| `@bytelyst/testing` | Shared test helpers (Fastify inject, schema asserts) | `vitest` |
|
||||
|
||||
## Shared Services
|
||||
|
||||
|
||||
@ -532,7 +532,7 @@ The following gaps were identified by scanning every import in the actual codeba
|
||||
- [ ] **7.1** Publish `@bytelyst/*` packages to GitHub Packages (private npm registry)
|
||||
- [ ] **7.2** Add Changesets for automated version management and changelogs
|
||||
- [ ] **7.3** Create reusable GitHub Actions workflow templates for service CI
|
||||
- [ ] **7.4** Add `@bytelyst/blob` package (extract blob storage client + SAS generation)
|
||||
- [x] **7.4** Add `@bytelyst/blob` package (extract blob storage client + SAS generation)
|
||||
- [ ] **7.5** Add `@bytelyst/monitoring` package (health check aggregator)
|
||||
- [x] **7.6** Add `@bytelyst/testing` package (shared test utilities, mock factories) — created with 10 tests
|
||||
- [ ] **7.7** Evaluate Python shared package for `cosmos_client.py` + `blob_client.py` if MindLyst adds Python backend
|
||||
@ -557,7 +557,7 @@ The following gaps were identified by scanning every import in the actual codeba
|
||||
| **4** | `@bytelyst/design-tokens` (4 platforms) | 24 | 23 | ✅ CSS synced to MindLyst; CONTRIBUTING updated; visual verify pending |
|
||||
| **5** | CI/CD + Docker (pre-copy strategy) | 23 | 21 | ⚠️ All Dockerfiles rewritten, CI workflows created; Docker build blocked by proxy |
|
||||
| **6** | Verification + docs + cleanup | 28 | 21 | ✅ Docs updated, depcheck done, git clean; E2E needs services |
|
||||
| **7** | Future enhancements (+testing pkg) | 10 | 1 | 🔲 @bytelyst/testing created (10 tests) |
|
||||
| **7** | Future enhancements (+testing pkg) | 10 | 2 | 🔲 @bytelyst/testing + @bytelyst/blob created |
|
||||
| **Total** | **10 packages (+1 bonus: logger)** | **278** | **251** | **~90% complete** |
|
||||
|
||||
### Bonus Package (not in original roadmap)
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
- 2026-02-14: Checklist created (`docs/workstreams/*`) in commit `5113b56`
|
||||
- 2026-02-14: Added Dependabot config (`.github/dependabot.yml`)
|
||||
- 2026-02-14: Added pre-commit token auto-generation (`lint-staged` for `packages/design-tokens`)
|
||||
- 2026-02-14: Added `@bytelyst/blob` shared package (Blob client helpers + SAS URL generation)
|
||||
|
||||
## Prereqs (Local)
|
||||
|
||||
@ -71,7 +72,7 @@ Publishing + repo hygiene
|
||||
- [ ] **7.1** Publish `@bytelyst/*` packages to GitHub Packages (private npm registry)
|
||||
- [ ] **7.2** Add Changesets for automated version management and changelogs
|
||||
- [ ] **7.3** Create reusable GitHub Actions workflow templates for service CI
|
||||
- [ ] **7.4** Add `@bytelyst/blob` package (extract blob storage client + SAS generation)
|
||||
- [x] **7.4** Add `@bytelyst/blob` package (extract blob storage client + SAS generation)
|
||||
- [ ] **7.5** Add `@bytelyst/monitoring` package (health check aggregator)
|
||||
- [ ] **7.7** Evaluate Python shared package for `cosmos_client.py` + `blob_client.py` if MindLyst adds Python backend
|
||||
- [ ] **7.8** Integrate `@bytelyst/design-tokens` into LysnrAI dashboards (unified design language)
|
||||
|
||||
23
packages/blob/package.json
Normal file
23
packages/blob/package.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@bytelyst/blob",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@azure/storage-blob": ">=12.0.0"
|
||||
}
|
||||
}
|
||||
146
packages/blob/src/__tests__/blob.test.ts
Normal file
146
packages/blob/src/__tests__/blob.test.ts
Normal file
@ -0,0 +1,146 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
MockBlobServiceClient,
|
||||
mockFromConnectionString,
|
||||
mockGetContainerClient,
|
||||
MockStorageSharedKeyCredential,
|
||||
mockGenerateBlobSASQueryParameters,
|
||||
} = vi.hoisted(() => {
|
||||
const mockGetContainerClient = vi.fn(() => ({
|
||||
createIfNotExists: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFromConnectionString = vi.fn(() => ({
|
||||
getContainerClient: mockGetContainerClient,
|
||||
}));
|
||||
|
||||
const MockBlobServiceClient = vi.fn(() => ({
|
||||
getContainerClient: mockGetContainerClient,
|
||||
})) as unknown as {
|
||||
new (...args: any[]): any;
|
||||
fromConnectionString: typeof mockFromConnectionString;
|
||||
};
|
||||
|
||||
(MockBlobServiceClient as any).fromConnectionString = mockFromConnectionString;
|
||||
|
||||
const MockStorageSharedKeyCredential = vi.fn(() => ({ kind: 'shared-key-cred' }));
|
||||
|
||||
const mockGenerateBlobSASQueryParameters = vi.fn(() => ({
|
||||
toString: () => 'sig=fake',
|
||||
}));
|
||||
|
||||
return {
|
||||
MockBlobServiceClient,
|
||||
mockFromConnectionString,
|
||||
mockGetContainerClient,
|
||||
MockStorageSharedKeyCredential,
|
||||
mockGenerateBlobSASQueryParameters,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@azure/storage-blob', () => ({
|
||||
BlobServiceClient: MockBlobServiceClient,
|
||||
ContainerClient: class {},
|
||||
StorageSharedKeyCredential: MockStorageSharedKeyCredential,
|
||||
BlobSASPermissions: class {
|
||||
read = false;
|
||||
write = false;
|
||||
create = false;
|
||||
delete = false;
|
||||
},
|
||||
generateBlobSASQueryParameters: mockGenerateBlobSASQueryParameters,
|
||||
SASProtocol: { Https: 'Https' },
|
||||
}));
|
||||
|
||||
import {
|
||||
_resetBlobClient,
|
||||
generateSasUrl,
|
||||
getBlobServiceClient,
|
||||
getContainerClient,
|
||||
isBlobStorageConfigured,
|
||||
} from '../index.js';
|
||||
|
||||
describe('blob', () => {
|
||||
beforeEach(() => {
|
||||
_resetBlobClient();
|
||||
MockBlobServiceClient.mockClear();
|
||||
mockFromConnectionString.mockClear();
|
||||
mockGetContainerClient.mockClear();
|
||||
MockStorageSharedKeyCredential.mockClear();
|
||||
mockGenerateBlobSASQueryParameters.mockClear();
|
||||
|
||||
delete process.env.AZURE_BLOB_CONNECTION_STRING;
|
||||
delete process.env.AZURE_BLOB_ACCOUNT_NAME;
|
||||
delete process.env.AZURE_BLOB_ACCOUNT_KEY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.AZURE_BLOB_CONNECTION_STRING;
|
||||
delete process.env.AZURE_BLOB_ACCOUNT_NAME;
|
||||
delete process.env.AZURE_BLOB_ACCOUNT_KEY;
|
||||
});
|
||||
|
||||
it('isBlobStorageConfigured is false when unset', () => {
|
||||
expect(isBlobStorageConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
it('isBlobStorageConfigured is true when connection string is set', () => {
|
||||
process.env.AZURE_BLOB_CONNECTION_STRING = 'AccountName=x;AccountKey=y;';
|
||||
expect(isBlobStorageConfigured()).toBe(true);
|
||||
});
|
||||
|
||||
it('getBlobServiceClient uses connection string when present', () => {
|
||||
process.env.AZURE_BLOB_CONNECTION_STRING = 'AccountName=acc;AccountKey=key==;';
|
||||
const c1 = getBlobServiceClient();
|
||||
const c2 = getBlobServiceClient();
|
||||
expect(c1).toBe(c2);
|
||||
expect(mockFromConnectionString).toHaveBeenCalledTimes(1);
|
||||
expect(MockBlobServiceClient).not.toHaveBeenCalled(); // should not call constructor in this path
|
||||
});
|
||||
|
||||
it('getBlobServiceClient uses account name + key when provided', () => {
|
||||
process.env.AZURE_BLOB_ACCOUNT_NAME = 'acc';
|
||||
process.env.AZURE_BLOB_ACCOUNT_KEY = 'key==';
|
||||
|
||||
getBlobServiceClient();
|
||||
|
||||
expect(MockStorageSharedKeyCredential).toHaveBeenCalledWith('acc', 'key==');
|
||||
expect(MockBlobServiceClient).toHaveBeenCalledWith(
|
||||
'https://acc.blob.core.windows.net',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('getBlobServiceClient throws when credentials missing', () => {
|
||||
expect(() => getBlobServiceClient()).toThrow('Azure Blob Storage not configured');
|
||||
});
|
||||
|
||||
it('getContainerClient creates container and caches client', async () => {
|
||||
process.env.AZURE_BLOB_CONNECTION_STRING = 'AccountName=acc;AccountKey=key==;';
|
||||
|
||||
const c1 = await getContainerClient('audio');
|
||||
const c2 = await getContainerClient('audio');
|
||||
expect(c1).toBe(c2);
|
||||
|
||||
expect(mockGetContainerClient).toHaveBeenCalledTimes(1);
|
||||
expect(c1.createIfNotExists).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('generateSasUrl uses parsed connection string when account vars not set', () => {
|
||||
process.env.AZURE_BLOB_CONNECTION_STRING = 'AccountName=acc;AccountKey=key==;';
|
||||
|
||||
const url = generateSasUrl('audio', 'path/file.wav', 'rw', 10);
|
||||
expect(url).toContain('https://acc.blob.core.windows.net/audio/path/file.wav?');
|
||||
expect(mockGenerateBlobSASQueryParameters).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('generateSasUrl prefers explicit account vars when provided', () => {
|
||||
process.env.AZURE_BLOB_CONNECTION_STRING = 'AccountName=fromconn;AccountKey=key==;';
|
||||
process.env.AZURE_BLOB_ACCOUNT_NAME = 'acc';
|
||||
process.env.AZURE_BLOB_ACCOUNT_KEY = 'key==';
|
||||
|
||||
const url = generateSasUrl('audio', 'x', 'r', 1);
|
||||
expect(url).toContain('https://acc.blob.core.windows.net/audio/x?');
|
||||
});
|
||||
});
|
||||
161
packages/blob/src/blob.ts
Normal file
161
packages/blob/src/blob.ts
Normal file
@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Shared Azure Blob Storage utilities.
|
||||
*
|
||||
* Provides singleton BlobServiceClient, container helpers, and SAS token generation.
|
||||
* Containers are lazily created on first access (createIfNotExists).
|
||||
*
|
||||
* Expected env vars:
|
||||
* AZURE_BLOB_CONNECTION_STRING — full connection string (preferred)
|
||||
* — OR —
|
||||
* AZURE_BLOB_ACCOUNT_NAME + AZURE_BLOB_ACCOUNT_KEY
|
||||
*/
|
||||
|
||||
import {
|
||||
BlobServiceClient,
|
||||
ContainerClient,
|
||||
StorageSharedKeyCredential,
|
||||
BlobSASPermissions,
|
||||
generateBlobSASQueryParameters,
|
||||
SASProtocol,
|
||||
} from '@azure/storage-blob';
|
||||
|
||||
let serviceClient: BlobServiceClient | null = null;
|
||||
const containerClients = new Map<string, ContainerClient>();
|
||||
|
||||
/**
|
||||
* Known blob containers and their purposes.
|
||||
*
|
||||
* Note: This is a convenience list (not enforced). Products can add their own
|
||||
* containers as needed.
|
||||
*/
|
||||
export const BLOB_CONTAINERS = {
|
||||
audio: 'audio', // Dictation audio recordings
|
||||
transcripts: 'transcripts', // Exported transcript files (PDF, DOCX, TXT)
|
||||
attachments: 'attachments', // Tracker item attachments (screenshots, docs)
|
||||
avatars: 'avatars', // User profile images
|
||||
releases: 'releases', // Desktop app update binaries
|
||||
backups: 'backups', // Cosmos DB JSON backups
|
||||
} as const;
|
||||
|
||||
export type BlobContainerName = (typeof BLOB_CONTAINERS)[keyof typeof BLOB_CONTAINERS];
|
||||
|
||||
/**
|
||||
* Get or create the BlobServiceClient singleton.
|
||||
*/
|
||||
export function getBlobServiceClient(): BlobServiceClient {
|
||||
if (serviceClient) return serviceClient;
|
||||
|
||||
const connectionString = process.env.AZURE_BLOB_CONNECTION_STRING;
|
||||
if (connectionString) {
|
||||
serviceClient = BlobServiceClient.fromConnectionString(connectionString);
|
||||
return serviceClient;
|
||||
}
|
||||
|
||||
const accountName = process.env.AZURE_BLOB_ACCOUNT_NAME;
|
||||
const accountKey = process.env.AZURE_BLOB_ACCOUNT_KEY;
|
||||
if (accountName && accountKey) {
|
||||
const credential = new StorageSharedKeyCredential(accountName, accountKey);
|
||||
serviceClient = new BlobServiceClient(
|
||||
`https://${accountName}.blob.core.windows.net`,
|
||||
credential
|
||||
);
|
||||
return serviceClient;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Azure Blob Storage not configured. Set AZURE_BLOB_CONNECTION_STRING or AZURE_BLOB_ACCOUNT_NAME + AZURE_BLOB_ACCOUNT_KEY'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a container client, creating the container if it doesn't exist.
|
||||
*/
|
||||
export async function getContainerClient(containerName: string): Promise<ContainerClient> {
|
||||
const cached = containerClients.get(containerName);
|
||||
if (cached) return cached;
|
||||
|
||||
const client = getBlobServiceClient().getContainerClient(containerName);
|
||||
await client.createIfNotExists({ access: undefined }); // private by default
|
||||
containerClients.set(containerName, client);
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a SAS URL for direct browser upload (or download).
|
||||
*
|
||||
* @param containerName - Target container
|
||||
* @param blobName - Full blob path (e.g., "product/user123/audio/recording.wav")
|
||||
* @param permissions - SAS permissions (default: read)
|
||||
* @param expiresInMinutes - Token lifetime (default: 60)
|
||||
* @returns Full SAS URL for the blob
|
||||
*/
|
||||
export function generateSasUrl(
|
||||
containerName: string,
|
||||
blobName: string,
|
||||
permissions: 'r' | 'w' | 'rw' | 'rwc' | 'rwd' = 'r',
|
||||
expiresInMinutes = 60
|
||||
): string {
|
||||
const connectionString = process.env.AZURE_BLOB_CONNECTION_STRING;
|
||||
const accountName = process.env.AZURE_BLOB_ACCOUNT_NAME;
|
||||
const accountKey = process.env.AZURE_BLOB_ACCOUNT_KEY;
|
||||
|
||||
let credAccountName: string;
|
||||
let credential: StorageSharedKeyCredential;
|
||||
|
||||
if (accountName && accountKey) {
|
||||
credAccountName = accountName;
|
||||
credential = new StorageSharedKeyCredential(accountName, accountKey);
|
||||
} else if (connectionString) {
|
||||
// Parse account name and key from connection string
|
||||
const nameMatch = connectionString.match(/AccountName=([^;]+)/);
|
||||
const keyMatch = connectionString.match(/AccountKey=([^;]+)/);
|
||||
if (!nameMatch || !keyMatch) {
|
||||
throw new Error('Cannot parse AccountName/AccountKey from connection string');
|
||||
}
|
||||
credAccountName = nameMatch[1];
|
||||
credential = new StorageSharedKeyCredential(nameMatch[1], keyMatch[1]);
|
||||
} else {
|
||||
throw new Error('Blob storage credentials not configured');
|
||||
}
|
||||
|
||||
const sasPermissions = new BlobSASPermissions();
|
||||
if (permissions.includes('r')) sasPermissions.read = true;
|
||||
if (permissions.includes('w')) sasPermissions.write = true;
|
||||
if (permissions.includes('c')) sasPermissions.create = true;
|
||||
if (permissions.includes('d')) sasPermissions.delete = true;
|
||||
|
||||
const now = new Date();
|
||||
const expiresOn = new Date(now.getTime() + expiresInMinutes * 60 * 1000);
|
||||
|
||||
const sasToken = generateBlobSASQueryParameters(
|
||||
{
|
||||
containerName,
|
||||
blobName,
|
||||
permissions: sasPermissions,
|
||||
startsOn: now,
|
||||
expiresOn,
|
||||
protocol: SASProtocol.Https,
|
||||
},
|
||||
credential
|
||||
).toString();
|
||||
|
||||
return `https://${credAccountName}.blob.core.windows.net/${containerName}/${blobName}?${sasToken}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if blob storage is configured.
|
||||
*/
|
||||
export function isBlobStorageConfigured(): boolean {
|
||||
return !!(
|
||||
process.env.AZURE_BLOB_CONNECTION_STRING ||
|
||||
(process.env.AZURE_BLOB_ACCOUNT_NAME && process.env.AZURE_BLOB_ACCOUNT_KEY)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test helper: reset module singletons/caches.
|
||||
*/
|
||||
export function _resetBlobClient(): void {
|
||||
serviceClient = null;
|
||||
containerClients.clear();
|
||||
}
|
||||
1
packages/blob/src/index.ts
Normal file
1
packages/blob/src/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './blob.js';
|
||||
9
packages/blob/tsconfig.json
Normal file
9
packages/blob/tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@ -77,6 +77,12 @@ importers:
|
||||
specifier: '>=5.0.0'
|
||||
version: 6.1.3
|
||||
|
||||
packages/blob:
|
||||
dependencies:
|
||||
'@azure/storage-blob':
|
||||
specifier: '>=12.0.0'
|
||||
version: 12.31.0
|
||||
|
||||
packages/config:
|
||||
dependencies:
|
||||
zod:
|
||||
@ -335,6 +341,9 @@ importers:
|
||||
'@azure/storage-blob':
|
||||
specifier: ^12.31.0
|
||||
version: 12.31.0
|
||||
'@bytelyst/blob':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/blob
|
||||
'@bytelyst/config':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/config
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bytelyst/blob": "workspace:*",
|
||||
"@bytelyst/config": "workspace:*",
|
||||
"@bytelyst/cosmos": "workspace:*",
|
||||
"@bytelyst/errors": "workspace:*",
|
||||
|
||||
@ -1,150 +1,8 @@
|
||||
/**
|
||||
* Shared Azure Blob Storage client for the Platform Service.
|
||||
*
|
||||
* Provides singleton BlobServiceClient, container helpers, and SAS token generation.
|
||||
* Containers are lazily created on first access (createIfNotExists).
|
||||
*
|
||||
* Expected env vars:
|
||||
* AZURE_BLOB_CONNECTION_STRING — full connection string (preferred)
|
||||
* — OR —
|
||||
* AZURE_BLOB_ACCOUNT_NAME + AZURE_BLOB_ACCOUNT_KEY
|
||||
*/
|
||||
|
||||
import {
|
||||
BlobServiceClient,
|
||||
ContainerClient,
|
||||
StorageSharedKeyCredential,
|
||||
BlobSASPermissions,
|
||||
generateBlobSASQueryParameters,
|
||||
SASProtocol,
|
||||
} from '@azure/storage-blob';
|
||||
|
||||
let serviceClient: BlobServiceClient | null = null;
|
||||
const containerClients = new Map<string, ContainerClient>();
|
||||
|
||||
/**
|
||||
* Known blob containers and their purposes.
|
||||
*/
|
||||
export const BLOB_CONTAINERS = {
|
||||
audio: 'audio', // Dictation audio recordings
|
||||
transcripts: 'transcripts', // Exported transcript files (PDF, DOCX, TXT)
|
||||
attachments: 'attachments', // Tracker item attachments (screenshots, docs)
|
||||
avatars: 'avatars', // User profile images
|
||||
releases: 'releases', // Desktop app update binaries
|
||||
backups: 'backups', // Cosmos DB JSON backups
|
||||
} as const;
|
||||
|
||||
export type BlobContainerName = (typeof BLOB_CONTAINERS)[keyof typeof BLOB_CONTAINERS];
|
||||
|
||||
/**
|
||||
* Get or create the BlobServiceClient singleton.
|
||||
*/
|
||||
export function getBlobServiceClient(): BlobServiceClient {
|
||||
if (serviceClient) return serviceClient;
|
||||
|
||||
const connectionString = process.env.AZURE_BLOB_CONNECTION_STRING;
|
||||
if (connectionString) {
|
||||
serviceClient = BlobServiceClient.fromConnectionString(connectionString);
|
||||
return serviceClient;
|
||||
}
|
||||
|
||||
const accountName = process.env.AZURE_BLOB_ACCOUNT_NAME;
|
||||
const accountKey = process.env.AZURE_BLOB_ACCOUNT_KEY;
|
||||
if (accountName && accountKey) {
|
||||
const credential = new StorageSharedKeyCredential(accountName, accountKey);
|
||||
serviceClient = new BlobServiceClient(
|
||||
`https://${accountName}.blob.core.windows.net`,
|
||||
credential
|
||||
);
|
||||
return serviceClient;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Azure Blob Storage not configured. Set AZURE_BLOB_CONNECTION_STRING or AZURE_BLOB_ACCOUNT_NAME + AZURE_BLOB_ACCOUNT_KEY'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a container client, creating the container if it doesn't exist.
|
||||
*/
|
||||
export async function getContainerClient(containerName: string): Promise<ContainerClient> {
|
||||
const cached = containerClients.get(containerName);
|
||||
if (cached) return cached;
|
||||
|
||||
const client = getBlobServiceClient().getContainerClient(containerName);
|
||||
await client.createIfNotExists({ access: undefined }); // private by default
|
||||
containerClients.set(containerName, client);
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a SAS URL for direct browser upload (or download).
|
||||
*
|
||||
* @param containerName - Target container
|
||||
* @param blobName - Full blob path (e.g., "lysnrai/user123/audio/recording.wav")
|
||||
* @param permissions - SAS permissions (default: read)
|
||||
* @param expiresInMinutes - Token lifetime (default: 60)
|
||||
* @returns Full SAS URL for the blob
|
||||
*/
|
||||
export function generateSasUrl(
|
||||
containerName: string,
|
||||
blobName: string,
|
||||
permissions: 'r' | 'w' | 'rw' | 'rwc' | 'rwd' = 'r',
|
||||
expiresInMinutes = 60
|
||||
): string {
|
||||
const connectionString = process.env.AZURE_BLOB_CONNECTION_STRING;
|
||||
const accountName = process.env.AZURE_BLOB_ACCOUNT_NAME;
|
||||
const accountKey = process.env.AZURE_BLOB_ACCOUNT_KEY;
|
||||
|
||||
let credAccountName: string;
|
||||
let credential: StorageSharedKeyCredential;
|
||||
|
||||
if (accountName && accountKey) {
|
||||
credAccountName = accountName;
|
||||
credential = new StorageSharedKeyCredential(accountName, accountKey);
|
||||
} else if (connectionString) {
|
||||
// Parse account name and key from connection string
|
||||
const nameMatch = connectionString.match(/AccountName=([^;]+)/);
|
||||
const keyMatch = connectionString.match(/AccountKey=([^;]+)/);
|
||||
if (!nameMatch || !keyMatch) {
|
||||
throw new Error('Cannot parse AccountName/AccountKey from connection string');
|
||||
}
|
||||
credAccountName = nameMatch[1];
|
||||
credential = new StorageSharedKeyCredential(nameMatch[1], keyMatch[1]);
|
||||
} else {
|
||||
throw new Error('Blob storage credentials not configured');
|
||||
}
|
||||
|
||||
const sasPermissions = new BlobSASPermissions();
|
||||
if (permissions.includes('r')) sasPermissions.read = true;
|
||||
if (permissions.includes('w')) sasPermissions.write = true;
|
||||
if (permissions.includes('c')) sasPermissions.create = true;
|
||||
if (permissions.includes('d')) sasPermissions.delete = true;
|
||||
|
||||
const now = new Date();
|
||||
const expiresOn = new Date(now.getTime() + expiresInMinutes * 60 * 1000);
|
||||
|
||||
const sasToken = generateBlobSASQueryParameters(
|
||||
{
|
||||
containerName,
|
||||
blobName,
|
||||
permissions: sasPermissions,
|
||||
startsOn: now,
|
||||
expiresOn,
|
||||
protocol: SASProtocol.Https,
|
||||
},
|
||||
credential
|
||||
).toString();
|
||||
|
||||
return `https://${credAccountName}.blob.core.windows.net/${containerName}/${blobName}?${sasToken}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if blob storage is configured.
|
||||
*/
|
||||
export function isBlobStorageConfigured(): boolean {
|
||||
return !!(
|
||||
process.env.AZURE_BLOB_CONNECTION_STRING ||
|
||||
(process.env.AZURE_BLOB_ACCOUNT_NAME && process.env.AZURE_BLOB_ACCOUNT_KEY)
|
||||
);
|
||||
}
|
||||
export {
|
||||
BLOB_CONTAINERS,
|
||||
type BlobContainerName,
|
||||
getBlobServiceClient,
|
||||
getContainerClient,
|
||||
generateSasUrl,
|
||||
isBlobStorageConfigured,
|
||||
} from '@bytelyst/blob';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user