diff --git a/services/cowork-service/package.json b/services/cowork-service/package.json index f43b0396..b0dab8fa 100644 --- a/services/cowork-service/package.json +++ b/services/cowork-service/package.json @@ -23,6 +23,7 @@ "@bytelyst/fastify-core": "workspace:*", "@bytelyst/llm-router": "workspace:*", "@bytelyst/logger": "workspace:*", + "@bytelyst/push": "workspace:*", "@fastify/cors": "^10.0.2", "fastify": "^5.2.1", "jose": "^6.0.11", diff --git a/services/cowork-service/src/modules/push/routes.test.ts b/services/cowork-service/src/modules/push/routes.test.ts new file mode 100644 index 00000000..1daefc19 --- /dev/null +++ b/services/cowork-service/src/modules/push/routes.test.ts @@ -0,0 +1,94 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createServiceApp, type FastifyApp } from '@bytelyst/fastify-core'; + +vi.mock('../../lib/config.js', () => ({ + config: { + SERVICE_NAME: 'cowork-service', + PLATFORM_SERVICE_URL: 'http://mock-platform:4003', + }, +})); +vi.mock('../../lib/product-config.js', () => ({ + PRODUCT_ID: 'clawcowork', +})); +vi.mock('../../lib/request-context.js', () => ({ + getUserId: vi.fn(() => 'demo-user'), +})); + +import { pushRoutes } from './routes.js'; + +let app: FastifyApp; +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +beforeAll(async () => { + app = await createServiceApp({ + name: 'push-routes-test', + version: '0.0.1', + logger: false, + }); + await app.register(pushRoutes); +}); + +afterAll(async () => { + await app.close(); +}); + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe('push proxy routes', () => { + it('POST /api/push/register proxies push device registration', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ registered: true }), + }); + + const res = await app.inject({ + method: 'POST', + url: '/api/push/register', + headers: { authorization: 'Bearer push-token' }, + payload: { deviceToken: 'device-token', platform: 'ios' }, + }); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.payload).registered).toBe(true); + expect(mockFetch.mock.calls[0][0]).toBe( + 'http://mock-platform:4003/notifications/push/register' + ); + expect(JSON.parse(mockFetch.mock.calls[0][1].body)).toMatchObject({ + userId: 'demo-user', + productId: 'clawcowork', + deviceToken: 'device-token', + }); + }); + + it('DELETE /api/push/register proxies push unregistration', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ removed: true }), + }); + + const res = await app.inject({ + method: 'DELETE', + url: '/api/push/register', + payload: { deviceToken: 'device-token' }, + }); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.payload).removed).toBe(true); + expect(mockFetch.mock.calls[0][1].method).toBe('DELETE'); + }); + + it('returns 502 when platform-service is unavailable', async () => { + mockFetch.mockRejectedValue(new Error('ECONNREFUSED')); + + const res = await app.inject({ + method: 'POST', + url: '/api/push/register', + payload: { deviceToken: 'device-token' }, + }); + + expect(res.statusCode).toBe(502); + }); +}); diff --git a/services/cowork-service/src/modules/push/routes.ts b/services/cowork-service/src/modules/push/routes.ts new file mode 100644 index 00000000..d4d33798 --- /dev/null +++ b/services/cowork-service/src/modules/push/routes.ts @@ -0,0 +1,69 @@ +import type { FastifyInstance } from 'fastify'; +import { config } from '../../lib/config.js'; +import { PRODUCT_ID } from '../../lib/product-config.js'; +import { getUserId } from '../../lib/request-context.js'; + +function buildHeaders(req: import('fastify').FastifyRequest): Record { + const headers: Record = { + 'Content-Type': 'application/json', + 'x-product-id': PRODUCT_ID, + 'x-request-id': req.id, + }; + const auth = req.headers.authorization; + if (typeof auth === 'string') headers.authorization = auth; + return headers; +} + +export async function pushRoutes(app: FastifyInstance) { + const platformUrl = config.PLATFORM_SERVICE_URL; + + app.post('/api/push/register', async (req, reply) => { + const userId = getUserId(req); + + try { + const res = await fetch(`${platformUrl}/notifications/push/register`, { + method: 'POST', + headers: buildHeaders(req), + body: JSON.stringify({ + userId, + productId: PRODUCT_ID, + ...(req.body as Record), + }), + }); + if (!res.ok) { + reply.code(res.status); + return { error: `Platform returned ${res.status}` }; + } + return res.json(); + } catch (err) { + req.log.warn({ err }, 'Failed to proxy push registration'); + reply.code(502); + return { error: 'Platform-service unavailable' }; + } + }); + + app.delete('/api/push/register', async (req, reply) => { + const userId = getUserId(req); + + try { + const res = await fetch(`${platformUrl}/notifications/push/register`, { + method: 'DELETE', + headers: buildHeaders(req), + body: JSON.stringify({ + userId, + productId: PRODUCT_ID, + ...(req.body as Record), + }), + }); + if (!res.ok) { + reply.code(res.status); + return { error: `Platform returned ${res.status}` }; + } + return res.json(); + } catch (err) { + req.log.warn({ err }, 'Failed to proxy push unregistration'); + reply.code(502); + return { error: 'Platform-service unavailable' }; + } + }); +} diff --git a/services/cowork-service/src/server.test.ts b/services/cowork-service/src/server.test.ts index 03f64057..54b2c55a 100644 --- a/services/cowork-service/src/server.test.ts +++ b/services/cowork-service/src/server.test.ts @@ -82,6 +82,7 @@ vi.mock('./modules/marketplace/routes.js', () => ({ marketplaceRoutes: vi.fn() } vi.mock('./modules/sessions/routes.js', () => ({ sessionRoutes: vi.fn() })); vi.mock('./modules/plugins/routes.js', () => ({ pluginRoutes: vi.fn() })); vi.mock('./modules/schedule/routes.js', () => ({ scheduleRoutes: vi.fn() })); +vi.mock('./modules/push/routes.js', () => ({ pushRoutes: vi.fn() })); describe('cowork-service bootstrap', () => { beforeEach(() => { @@ -101,9 +102,9 @@ describe('cowork-service bootstrap', () => { expect(opts.version).toBe('0.1.0'); expect(opts.readiness).toBe(true); - // health + task + llm + audit + usage + notifications + extraction + marketplace + sessions + plugins + schedule = 11 register calls + 1 JWT + // health + task + llm + audit + usage + notifications + extraction + marketplace + sessions + plugins + schedule + push = 12 register calls + 1 JWT expect(registerOptionalJwtContextMock).toHaveBeenCalledOnce(); - expect(appMock.register).toHaveBeenCalledTimes(11); + expect(appMock.register).toHaveBeenCalledTimes(12); expect(startServiceMock).toHaveBeenCalledWith(appMock, { port: 4009, host: '0.0.0.0' }); }); }); diff --git a/services/cowork-service/src/server.ts b/services/cowork-service/src/server.ts index 21d7aad7..c51ea609 100644 --- a/services/cowork-service/src/server.ts +++ b/services/cowork-service/src/server.ts @@ -33,6 +33,7 @@ import { marketplaceRoutes } from './modules/marketplace/routes.js'; import { sessionRoutes } from './modules/sessions/routes.js'; import { pluginRoutes } from './modules/plugins/routes.js'; import { scheduleRoutes } from './modules/schedule/routes.js'; +import { pushRoutes } from './modules/push/routes.js'; import type { JwtPayload } from './lib/request-context.js'; const jwtSecret = new TextEncoder().encode(config.JWT_SECRET); @@ -70,6 +71,7 @@ await app.register(marketplaceRoutes); await app.register(sessionRoutes); await app.register(pluginRoutes); await app.register(scheduleRoutes); +await app.register(pushRoutes); // Bootstrap endpoint (same pattern as FlowMonk, ActionTrail, etc.) app.get('/api/bootstrap', async () => ({