Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .snyk
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,14 @@ ignore:
reason: 'Requires upgrade of @opentelemetry/sdk-node to 0.217.0, which has type errors that break compilation. Created task to upgrade OTEL service to 2.x and resolve vulnerability that way.'
expires: '2026-07-28T00:00:00.000Z'
created: '2026-06-01T10:00:00.000Z'
'SNYK-JS-POSTCSSSELECTORPARSER-16873882':
- '* > postcss-selector-parser':
reason: 'Transitive dependency in Docusaurus CSS optimization/build tooling; Snyk reports no fixed version for postcss-selector-parser yet. Not exploitable at runtime because docs CSS is repository-controlled and processed at build time.'
expires: '2026-07-28T00:00:00.000Z'
created: '2026-05-27T00:00:00.000Z'
sast-ignore:
'packages/cellix/service-blob-storage/src/test-support/azurite.ts':
- 'Hardcoded-Non-Cryptographic-Secret @ line 10':
reason: 'This is the standard well-known Azurite/Azure Storage Emulator test account key from official Microsoft documentation (https://learn.microsoft.com/en-us/azure/storage/common/storage-use-azurite). Used only for local testing and not a real credential.'
expires: '2027-05-14T00:00:00.000Z'
created: '2026-05-14T16:00:00.000Z'

3 changes: 2 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"prestart": "pnpm run prepare:deploy && pnpm run sync-local-settings",
"start": "func start --typescript --script-root deploy/",
"sync-local-settings": "node -e \"const fs=require('node:fs'); fs.mkdirSync('deploy',{recursive:true}); if (fs.existsSync('local.settings.json')) fs.copyFileSync('local.settings.json','deploy/local.settings.json');\"",
"azurite": "azurite-blob --silent --skipApiVersionCheck --location ../../__blobstorage__ & azurite-queue --silent --location ../../__queuestorage__ & azurite-table --silent --location ../../__tablestorage__"
"azurite": "azurite-blob --silent --skipApiVersionCheck --location ../../__blobstorage__ & azurite-queue --silent --skipApiVersionCheck --location ../../__queuestorage__ & azurite-table --silent --location ../../__tablestorage__"
},
"dependencies": {
"@azure/functions": "catalog:",
Expand All @@ -38,6 +38,7 @@
"@ocom/service-apollo-server": "workspace:*",
"@ocom/service-blob-storage": "workspace:*",
"@ocom/service-mongoose": "workspace:*",
"@ocom/service-queue-storage": "workspace:*",
"@ocom/service-otel": "workspace:*",
"@ocom/service-token-validation": "workspace:*",
"@opentelemetry/api": "1.9.0"
Expand Down
34 changes: 28 additions & 6 deletions apps/api/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const {
registerEventHandlers,
MockServiceApolloServer,
MockServiceBlobStorage,
SpyServiceBlobStorage,
MockServiceMongoose,
MockServiceTokenValidation,
} = vi.hoisted(() => {
Expand Down Expand Up @@ -45,6 +46,10 @@ const {
}
}

const HoistedSpyServiceBlobStorage = vi.fn(function MockedBlobStorage(options: unknown) {
return new HoistedServiceBlobStorage(options);
});

return {
registerInfrastructureService: vi.fn(),
setContext: vi.fn(),
Expand All @@ -55,6 +60,7 @@ const {
registerEventHandlers: vi.fn(),
MockServiceApolloServer: HoistedServiceApolloServer,
MockServiceBlobStorage: HoistedServiceBlobStorage,
SpyServiceBlobStorage: HoistedSpyServiceBlobStorage,
MockServiceMongoose: HoistedServiceMongoose,
MockServiceTokenValidation: HoistedServiceTokenValidation,
};
Expand All @@ -77,7 +83,7 @@ vi.mock('./cellix.ts', () => ({
},
}));
vi.mock('@ocom/service-blob-storage', () => ({
ServiceBlobStorage: MockServiceBlobStorage,
ServiceBlobStorage: SpyServiceBlobStorage,
}));
vi.mock('@ocom/service-mongoose', () => ({
ServiceMongoose: MockServiceMongoose,
Expand All @@ -100,10 +106,9 @@ vi.mock('./service-config/mongoose/index.ts', () => ({
mongooseContextBuilder: vi.fn(() => dataSourcesFactory),
}));
vi.mock('./service-config/blob-storage/index.ts', () => ({
blobStorageConfig: {
accountName: 'devstoreaccount1',
connectionString: 'UseDevelopmentStorage=true;AccountName=devstoreaccount1;AccountKey=abc123=',
},
accountName: 'devstoreaccount1',
connectionString: 'UseDevelopmentStorage=true;AccountName=devstoreaccount1;AccountKey=abc123=',
provisionContainers: ['public', 'private', 'queue-logs'],
}));
vi.mock('./service-config/token-validation/index.ts', () => ({
portalTokens: new Map([['AccountPortal', 'ACCOUNT_PORTAL']]),
Expand All @@ -117,6 +122,19 @@ vi.mock('@ocom/graphql-handler', () => ({
vi.mock('@ocom/rest', () => ({
restHandlerCreator: vi.fn(),
}));
vi.mock('@ocom/service-queue-storage', () => ({
ServiceQueueStorage: vi.fn(function MockServiceQueueStorage() {
return {
startUp: vi.fn(),
shutDown: vi.fn(),
sendMessageToCommunityCreationQueue: vi.fn(),
receiveFromImportRequestsQueue: vi.fn(),
peekAtImportRequestsQueue: vi.fn(),
};
}),
QUEUE_LOG_CONTAINER: 'queue-logs',
allQueueNames: ['email-notifications', 'audit-events', 'import-requests'],
}));

describe('apps/api bootstrap', () => {
beforeEach(() => {
Expand Down Expand Up @@ -146,7 +164,11 @@ describe('apps/api bootstrap', () => {

registerServices?.(serviceRegistry);

expect(registerInfrastructureService).toHaveBeenCalledTimes(5);
expect(registerInfrastructureService).toHaveBeenCalledTimes(6);
expect(SpyServiceBlobStorage).toHaveBeenNthCalledWith(1, {
connectionString: 'UseDevelopmentStorage=true;AccountName=devstoreaccount1;AccountKey=abc123=',
provisionContainers: ['public', 'private', 'queue-logs'],
});
// Find the registered blob services by the semantic registration name instead of relying on call order.
const registeredBlobService = registerInfrastructureService.mock.calls.find((c) => c?.[1] === 'BlobStorageService')?.[0];
const registeredClientOpsService = registerInfrastructureService.mock.calls.find((c) => c?.[1] === 'ClientOperationsService')?.[0];
Expand Down
26 changes: 20 additions & 6 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { restHandlerCreator } from '@ocom/rest';
import { ServiceApolloServer } from '@ocom/service-apollo-server';
import { ServiceBlobStorage } from '@ocom/service-blob-storage';
import { ServiceMongoose } from '@ocom/service-mongoose';
import { ServiceQueueStorage } from '@ocom/service-queue-storage';
import { ServiceTokenValidation } from '@ocom/service-token-validation';
import { Cellix } from './cellix.ts';
import * as ApolloServerConfig from './service-config/apollo-server/index.ts';
Expand All @@ -21,13 +22,25 @@ Cellix.initializeInfrastructureServices<ApiContextSpec, ApplicationServices>((se
const { NODE_ENV } = process.env;
const isProd = NODE_ENV === 'production';

const mongooseService = new ServiceMongoose(MongooseConfig.mongooseConnectionString, MongooseConfig.mongooseConnectOptions);
const blobStorageService = isProd
? new ServiceBlobStorage({ accountName: BlobStorageConfig.accountName })
: new ServiceBlobStorage({ connectionString: BlobStorageConfig.connectionString, provisionContainers: BlobStorageConfig.provisionContainers });
const clientOperationsService = new ServiceBlobStorage({ connectionString: BlobStorageConfig.connectionString });
const tokenValidationService = new ServiceTokenValidation(TokenValidationConfig.portalTokens);
const apolloService = new ServiceApolloServer<GraphContext>(ApolloServerConfig.apolloServerOptions);
const queueStorageService = isProd
? new ServiceQueueStorage({ accountName: BlobStorageConfig.accountName as string, blobStorage: blobStorageService })
: new ServiceQueueStorage({ connectionString: BlobStorageConfig.connectionString, blobStorage: blobStorageService });

serviceRegistry
.registerInfrastructureService(new ServiceMongoose(MongooseConfig.mongooseConnectionString, MongooseConfig.mongooseConnectOptions))
.registerInfrastructureService(isProd ? new ServiceBlobStorage({ accountName: BlobStorageConfig.accountName }) : new ServiceBlobStorage({ connectionString: BlobStorageConfig.connectionString }), 'BlobStorageService')
.registerInfrastructureService(new ServiceBlobStorage({ connectionString: BlobStorageConfig.connectionString }), 'ClientOperationsService')
.registerInfrastructureService(new ServiceTokenValidation(TokenValidationConfig.portalTokens))
.registerInfrastructureService(new ServiceApolloServer<GraphContext>(ApolloServerConfig.apolloServerOptions));
})
.registerInfrastructureService(mongooseService)
.registerInfrastructureService(blobStorageService, 'BlobStorageService')
.registerInfrastructureService(clientOperationsService, 'ClientOperationsService')
.registerInfrastructureService(queueStorageService)
.registerInfrastructureService(tokenValidationService)
.registerInfrastructureService(apolloService);
})
.setContext((serviceRegistry) => {
const dataSourcesFactory = MongooseConfig.mongooseContextBuilder(serviceRegistry.getInfrastructureService<ServiceMongoose>(ServiceMongoose));

Expand All @@ -40,6 +53,7 @@ Cellix.initializeInfrastructureServices<ApiContextSpec, ApplicationServices>((se
apolloServerService: serviceRegistry.getInfrastructureService<ServiceApolloServer>(ServiceApolloServer),
blobStorageService: serviceRegistry.getInfrastructureService<ServiceBlobStorage>('BlobStorageService'),
clientOperationsService: serviceRegistry.getInfrastructureService<ServiceBlobStorage>('ClientOperationsService'),
queueStorageService: serviceRegistry.getInfrastructureService<ServiceQueueStorage>(ServiceQueueStorage),
};
})
.initializeApplicationServices((context) => buildApplicationServicesFactory(context))
Expand Down
6 changes: 5 additions & 1 deletion apps/api/src/service-config/blob-storage/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
* client uploads. Server-only blob operations require only accountName.
*/

import { QUEUE_LOG_CONTAINER } from '@ocom/service-queue-storage';

const { AZURE_STORAGE_ACCOUNT_NAME: accountName, AZURE_STORAGE_CONNECTION_STRING: connectionString } = process.env;

if (!accountName) {
Expand All @@ -34,4 +36,6 @@ if (!connectionString) {
throw new Error('Missing AZURE_STORAGE_CONNECTION_STRING environment variable. Required for SAS token generation for client uploads. ' + '(Applications that only perform server-side blob operations do not require this.)');
}

export { accountName, connectionString };
const provisionContainers = ['public', 'private', QUEUE_LOG_CONTAINER];

export { accountName, connectionString, provisionContainers };
5 changes: 4 additions & 1 deletion apps/api/start-dev.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ const childEnv = {
NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ''} --use-system-ca`.trim(),
};

const child = spawn('func', ['start', '--typescript', '--script-root', 'deploy/', '--port', envPort], {
// `--cors '*'` matches Host.CORS in local.settings.json but does not depend on
// that file existing — local.settings.json is gitignored, so CI has no CORS
// allowance otherwise and the UI's cross-origin GraphQL requests are blocked.
const child = spawn('func', ['start', '--typescript', '--script-root', 'deploy/', '--port', envPort, '--cors', '*'], {
stdio: 'inherit',
env: childEnv,
});
Expand Down
2 changes: 2 additions & 0 deletions apps/api/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
{ "path": "../../packages/ocom/persistence" },
{ "path": "../../packages/ocom/rest" },
{ "path": "../../packages/ocom/service-blob-storage" },
{ "path": "../../packages/ocom/service-queue-storage" },
{ "path": "../../packages/cellix/service-queue-storage" },
{ "path": "../../packages/ocom/service-mongoose" },
{ "path": "../../packages/ocom/service-otel" },
{ "path": "../../packages/ocom/service-token-validation" }
Expand Down
131 changes: 131 additions & 0 deletions apps/docs/docs/decisions/0033-azure-queue-storage-typed-services.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
---
sidebar_position: 33
sidebar_label: 0033 Azure Queue Storage Typed Services
description: "Architecture decision for exposing Azure Queue Storage through typed registered services in Cellix"
status: accepted
contact: nnoce14
date: 2026-05-29
deciders: nnoce14
consulted:
informed:
---

# Azure Queue Storage via Typed Registered Services

## Problem Statement

Cellix needs a consistent way to make Azure Queue Storage available to applications without leaking a broad low-level transport API into application code. The framework also needs to enforce queue contracts, logging expectations, and local-versus-production infrastructure boundaries consistently.

## Decision Drivers

1. Narrow consumer API for applications
2. Strong compile-time and runtime queue contract enforcement
3. Automatic logging on the normal queue paths
4. Clear separation between framework internals and application-specific queues
5. Practical Azurite support without weakening production infrastructure discipline

## Considered Options

- Expose a broad raw Azure Queue Storage service directly to consumers
- Hide Azure Queue Storage behind typed registered queue services
- Use Azure Queue Storage only through Azure Functions triggers with no framework service abstraction

## Decision Outcome

Chosen option: **Hide Azure Queue Storage behind typed registered queue services**, because it best aligns with the Cellix service model and gives applications a narrow, typed, intention-revealing API.

### What This Means

- Azure Queue Storage is exposed through **application-specific typed services**
- Application packages define concrete queues; the framework package stays queue-agnostic
- Application consumers use only the generated typed queue methods for the queues registered by their application
- Raw queue transport operations remain framework-internal
- Queue validation and queue logging are enforced by the framework on the normal typed send and receive paths

### Boundary Decision

#### Public to application consumers

- Queue-definition authoring helpers
- Queue registration
- Typed send, receive, and peek methods for registered queues

#### Internal to the framework

- Raw Azure Queue Storage transport operations
- Method binding and naming mechanics
- Logging enforcement mechanics
- Local provisioning mechanics

### Environment Decision

Cellix distinguishes between local development convenience and production ownership:

- Local/Azurite workflows may provision known queue-related resources during startup
- Production environments are expected to provision queues and related storage resources explicitly through infrastructure-as-code

This avoids turning normal runtime queue or logging operations into hidden infrastructure mutation.

## Consequences

### Positive

1. Applications get a narrow and intention-revealing queue API
2. Queue payload contracts are explicit and consistently enforced
3. Logging becomes a framework guarantee on typed queue paths
4. The framework remains reusable across multiple application packages
5. Local development remains practical without redefining production standards

### Neutral

1. Queue usage is opinionated around a registration pattern instead of ad hoc transport access
2. Application queue definitions follow framework-provided conventions

### Negative

1. The framework must maintain the typed registered-service abstraction over Azure Queue Storage
2. Consumers who need to bypass the typed queue model would require explicit framework changes

## Validation

Compliance with this decision is validated through:

- public contract tests in `@cellix/service-queue-storage`
- downstream package build and typecheck validation
- alignment between framework package docs and developer guidance in the docs site

## Pros and Cons of the Options

### Expose a broad raw Azure Queue Storage service directly to consumers

- Good, because it is simple to expose
- Good, because it mirrors the Azure SDK closely
- Bad, because it leaks framework internals into application code
- Bad, because it weakens consistency around validation and logging
- Bad, because it creates multiple competing usage styles

### Hide Azure Queue Storage behind typed registered queue services

- Good, because it gives applications a narrow and typed service contract
- Good, because it centralizes framework policies for validation and logging
- Good, because it keeps application queue definitions outside the framework package
- Neutral, because it introduces a registration pattern
- Bad, because it adds abstraction over the raw SDK

### Use Azure Queue Storage only through Azure Functions triggers with no framework service abstraction

- Good, because it follows an Azure-native trigger model
- Good, because it can simplify some event-driven integrations
- Bad, because it does not fit the Cellix application-service consumption model
- Bad, because it makes typed queue APIs less consistent across applications
- Bad, because it pushes queue policy into host-specific trigger implementations

## More Information

This decision complements:

- [0011 Bicep](/docs/decisions/0011-bicep.md)
- [0014 Azure Infrastructure Deployments](/docs/decisions/0014-azure-infrastructure-deployments.md)
- [0032 Azure Blob Storage with Managed Identity & Client Uploads](/docs/decisions/0032-azure-blob-storage-client-uploads.md)

Detailed developer and agent guidance belongs in the queue-storage technical overview and framework package docs rather than in this ADR.
Loading