feat(blob): add @bytelyst/blob shared package

This commit is contained in:
Saravana Achu Mac 2026-02-14 15:53:33 -08:00
parent dcfb774313
commit 125eb03745
11 changed files with 376 additions and 162 deletions

View File

@ -54,15 +54,20 @@ learning_ai_common_plat/
## Shared Libraries
| Package | Description | Peer Dependencies |
| ------------------------- | --------------------------------------------------------- | ------------------ |
| `@bytelyst/errors` | Typed HTTP service errors (400429) | — |
| `@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 (400429) | — |
| `@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

View File

@ -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)

View File

@ -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)

View 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"
}
}

View 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
View 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();
}

View File

@ -0,0 +1 @@
export * from './blob.js';

View 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
View File

@ -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

View File

@ -13,6 +13,7 @@
"lint": "eslint src/"
},
"dependencies": {
"@bytelyst/blob": "workspace:*",
"@bytelyst/config": "workspace:*",
"@bytelyst/cosmos": "workspace:*",
"@bytelyst/errors": "workspace:*",

View File

@ -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';