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
|
## Shared Libraries
|
||||||
|
|
||||||
| Package | Description | Peer Dependencies |
|
| Package | Description | Peer Dependencies |
|
||||||
| ------------------------- | --------------------------------------------------------- | ------------------ |
|
| ------------------------- | --------------------------------------------------------- | ---------------------- |
|
||||||
| `@bytelyst/errors` | Typed HTTP service errors (400–429) | — |
|
| `@bytelyst/errors` | Typed HTTP service errors (400–429) | — |
|
||||||
| `@bytelyst/cosmos` | Azure Cosmos DB client singleton + container registry | `@azure/cosmos` |
|
| `@bytelyst/logger` | Structured logger factory for services/dashboards | — |
|
||||||
| `@bytelyst/config` | Zod-based env config loader + product identity | `zod` |
|
| `@bytelyst/cosmos` | Azure Cosmos DB client singleton + container registry | `@azure/cosmos` |
|
||||||
| `@bytelyst/auth` | JWT utilities, auth middleware, password hashing | `jose`, `bcryptjs` |
|
| `@bytelyst/blob` | Azure Blob Storage helpers + SAS URL generation | `@azure/storage-blob` |
|
||||||
| `@bytelyst/api-client` | Configurable fetch wrapper with auth token injection | — |
|
| `@bytelyst/config` | Zod-based env config loader + product identity | `zod` |
|
||||||
| `@bytelyst/react-auth` | React auth context factory (typed provider + hook) | `react` |
|
| `@bytelyst/auth` | JWT utilities, auth middleware, password hashing | `jose`, `bcryptjs` |
|
||||||
| `@bytelyst/design-tokens` | Cross-platform design tokens (JSON → CSS/TS/Kotlin/Swift) | — |
|
| `@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
|
## 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.1** Publish `@bytelyst/*` packages to GitHub Packages (private npm registry)
|
||||||
- [ ] **7.2** Add Changesets for automated version management and changelogs
|
- [ ] **7.2** Add Changesets for automated version management and changelogs
|
||||||
- [ ] **7.3** Create reusable GitHub Actions workflow templates for service CI
|
- [ ] **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.5** Add `@bytelyst/monitoring` package (health check aggregator)
|
||||||
- [x] **7.6** Add `@bytelyst/testing` package (shared test utilities, mock factories) — created with 10 tests
|
- [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
|
- [ ] **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 |
|
| **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 |
|
| **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 |
|
| **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** |
|
| **Total** | **10 packages (+1 bonus: logger)** | **278** | **251** | **~90% complete** |
|
||||||
|
|
||||||
### Bonus Package (not in original roadmap)
|
### Bonus Package (not in original roadmap)
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
- 2026-02-14: Checklist created (`docs/workstreams/*`) in commit `5113b56`
|
- 2026-02-14: Checklist created (`docs/workstreams/*`) in commit `5113b56`
|
||||||
- 2026-02-14: Added Dependabot config (`.github/dependabot.yml`)
|
- 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 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)
|
## Prereqs (Local)
|
||||||
|
|
||||||
@ -71,7 +72,7 @@ Publishing + repo hygiene
|
|||||||
- [ ] **7.1** Publish `@bytelyst/*` packages to GitHub Packages (private npm registry)
|
- [ ] **7.1** Publish `@bytelyst/*` packages to GitHub Packages (private npm registry)
|
||||||
- [ ] **7.2** Add Changesets for automated version management and changelogs
|
- [ ] **7.2** Add Changesets for automated version management and changelogs
|
||||||
- [ ] **7.3** Create reusable GitHub Actions workflow templates for service CI
|
- [ ] **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.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.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)
|
- [ ] **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'
|
specifier: '>=5.0.0'
|
||||||
version: 6.1.3
|
version: 6.1.3
|
||||||
|
|
||||||
|
packages/blob:
|
||||||
|
dependencies:
|
||||||
|
'@azure/storage-blob':
|
||||||
|
specifier: '>=12.0.0'
|
||||||
|
version: 12.31.0
|
||||||
|
|
||||||
packages/config:
|
packages/config:
|
||||||
dependencies:
|
dependencies:
|
||||||
zod:
|
zod:
|
||||||
@ -335,6 +341,9 @@ importers:
|
|||||||
'@azure/storage-blob':
|
'@azure/storage-blob':
|
||||||
specifier: ^12.31.0
|
specifier: ^12.31.0
|
||||||
version: 12.31.0
|
version: 12.31.0
|
||||||
|
'@bytelyst/blob':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/blob
|
||||||
'@bytelyst/config':
|
'@bytelyst/config':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/config
|
version: link:../../packages/config
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
"lint": "eslint src/"
|
"lint": "eslint src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@bytelyst/blob": "workspace:*",
|
||||||
"@bytelyst/config": "workspace:*",
|
"@bytelyst/config": "workspace:*",
|
||||||
"@bytelyst/cosmos": "workspace:*",
|
"@bytelyst/cosmos": "workspace:*",
|
||||||
"@bytelyst/errors": "workspace:*",
|
"@bytelyst/errors": "workspace:*",
|
||||||
|
|||||||
@ -1,150 +1,8 @@
|
|||||||
/**
|
export {
|
||||||
* Shared Azure Blob Storage client for the Platform Service.
|
BLOB_CONTAINERS,
|
||||||
*
|
type BlobContainerName,
|
||||||
* Provides singleton BlobServiceClient, container helpers, and SAS token generation.
|
getBlobServiceClient,
|
||||||
* Containers are lazily created on first access (createIfNotExists).
|
getContainerClient,
|
||||||
*
|
generateSasUrl,
|
||||||
* Expected env vars:
|
isBlobStorageConfigured,
|
||||||
* AZURE_BLOB_CONNECTION_STRING — full connection string (preferred)
|
} from '@bytelyst/blob';
|
||||||
* — 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)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user