@tumbaland/backend-core 1.43.0 → 1.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/package.json +5 -1
  2. package/.versionrc.json +0 -7
  3. package/__mocks__/uuid.js +0 -8
  4. package/jest.config.js +0 -24
  5. package/src/apiKeys/ApiKey.test.ts +0 -142
  6. package/src/apiKeys/ApiKey.ts +0 -102
  7. package/src/apiKeys/crypto.test.ts +0 -161
  8. package/src/apiKeys/crypto.ts +0 -163
  9. package/src/apiKeys/index.test.ts +0 -72
  10. package/src/apiKeys/index.ts +0 -35
  11. package/src/apiKeys/middleware.test.ts +0 -651
  12. package/src/apiKeys/middleware.ts +0 -341
  13. package/src/apiKeys/service.test.ts +0 -401
  14. package/src/apiKeys/service.ts +0 -168
  15. package/src/apiKeys/types.test.ts +0 -41
  16. package/src/apiKeys/types.ts +0 -124
  17. package/src/app/createBaseApp.test.ts +0 -109
  18. package/src/app/createBaseApp.ts +0 -102
  19. package/src/app/shutdown.test.ts +0 -129
  20. package/src/app/shutdown.ts +0 -81
  21. package/src/audit/AuditEvent.ts +0 -123
  22. package/src/audit/actor.test.ts +0 -95
  23. package/src/audit/actor.ts +0 -68
  24. package/src/audit/context.test.ts +0 -91
  25. package/src/audit/context.ts +0 -83
  26. package/src/audit/index.ts +0 -11
  27. package/src/audit/plugin.test.ts +0 -258
  28. package/src/audit/plugin.ts +0 -254
  29. package/src/audit/reads.test.ts +0 -164
  30. package/src/audit/reads.ts +0 -88
  31. package/src/audit/service.test.ts +0 -115
  32. package/src/audit/service.ts +0 -95
  33. package/src/auth/session.test.ts +0 -89
  34. package/src/auth/session.ts +0 -75
  35. package/src/config/env.test.ts +0 -28
  36. package/src/config/env.ts +0 -13
  37. package/src/database/connection.test.ts +0 -188
  38. package/src/database/connection.ts +0 -90
  39. package/src/entitlements/UsageMeter.ts +0 -49
  40. package/src/entitlements/client.test.ts +0 -200
  41. package/src/entitlements/client.ts +0 -179
  42. package/src/entitlements/definitions.test.ts +0 -161
  43. package/src/entitlements/definitions.ts +0 -268
  44. package/src/entitlements/index.ts +0 -42
  45. package/src/entitlements/middleware.test.ts +0 -196
  46. package/src/entitlements/middleware.ts +0 -150
  47. package/src/entitlements/reconcile.test.ts +0 -333
  48. package/src/entitlements/reconcile.ts +0 -384
  49. package/src/entitlements/types.ts +0 -21
  50. package/src/entitlements/usage.test.ts +0 -314
  51. package/src/entitlements/usage.ts +0 -223
  52. package/src/errors/HttpError.test.ts +0 -76
  53. package/src/errors/HttpError.ts +0 -91
  54. package/src/groups/client.test.ts +0 -215
  55. package/src/groups/client.ts +0 -182
  56. package/src/groups/index.ts +0 -7
  57. package/src/groups/membership.test.ts +0 -84
  58. package/src/groups/membership.ts +0 -133
  59. package/src/groups/subject.test.ts +0 -85
  60. package/src/groups/subject.ts +0 -50
  61. package/src/health/createHealthCheck.test.ts +0 -89
  62. package/src/health/createHealthCheck.ts +0 -67
  63. package/src/health/healthController.test.ts +0 -113
  64. package/src/health/healthController.ts +0 -56
  65. package/src/index.ts +0 -88
  66. package/src/logging/logger.test.ts +0 -91
  67. package/src/logging/logger.ts +0 -103
  68. package/src/metrics/index.test.ts +0 -116
  69. package/src/metrics/index.ts +0 -111
  70. package/src/middleware/authMiddleware.test.ts +0 -275
  71. package/src/middleware/authMiddleware.ts +0 -91
  72. package/src/middleware/corsMiddleware.test.ts +0 -135
  73. package/src/middleware/corsMiddleware.ts +0 -65
  74. package/src/middleware/errorHandler.test.ts +0 -188
  75. package/src/middleware/errorHandler.ts +0 -103
  76. package/src/middleware/internalServiceAuth.test.ts +0 -173
  77. package/src/middleware/internalServiceAuth.ts +0 -96
  78. package/src/middleware/requestLogger.test.ts +0 -81
  79. package/src/middleware/requestLogger.ts +0 -48
  80. package/src/middleware/security.test.ts +0 -45
  81. package/src/middleware/security.ts +0 -43
  82. package/src/middleware/validate.test.ts +0 -72
  83. package/src/middleware/validate.ts +0 -23
  84. package/src/oauth/index.ts +0 -29
  85. package/src/oauth/models.ts +0 -164
  86. package/src/oauth/service.test.ts +0 -432
  87. package/src/oauth/service.ts +0 -299
  88. package/src/oauth/tokens.test.ts +0 -146
  89. package/src/oauth/tokens.ts +0 -186
  90. package/src/testing/serviceTestSetup.ts +0 -17
  91. package/src/tracing/index.test.ts +0 -272
  92. package/src/tracing/index.ts +0 -110
  93. package/src/types/auth.ts +0 -45
  94. package/src/utils/correlation.test.ts +0 -47
  95. package/src/utils/correlation.ts +0 -22
  96. package/src/utils/permissionUtils.test.ts +0 -47
  97. package/src/utils/permissionUtils.ts +0 -68
  98. package/src/utils/response.test.ts +0 -64
  99. package/src/utils/response.ts +0 -60
  100. package/tsconfig.build.json +0 -7
  101. package/tsconfig.json +0 -23
@@ -1,124 +0,0 @@
1
- /**
2
- * API keys let a non-browser client — an MCP server driving Claude or ChatGPT,
3
- * a script, a cron job — act as a user without a Google sign-in flow.
4
- *
5
- * They are deliberately narrower than a session: a session is a person at a
6
- * keyboard who can see what they are doing, while a key is handed to software
7
- * that acts on its own. So a key carries explicit scopes, and it pins the tenant
8
- * it writes to rather than choosing one per request the way the UI does.
9
- */
10
-
11
- /** Everything a key may be granted. Read and write are separate on purpose. */
12
- export const API_KEY_SCOPES = [
13
- 'relationship:read',
14
- 'relationship:write',
15
- 'album:read',
16
- 'album:write',
17
- 'finance:read',
18
- 'finance:write'
19
- ] as const;
20
-
21
- export type ApiKeyScope = (typeof API_KEY_SCOPES)[number];
22
-
23
- export const isApiKeyScope = (value: unknown): value is ApiKeyScope =>
24
- typeof value === 'string' && (API_KEY_SCOPES as readonly string[]).includes(value);
25
-
26
- /**
27
- * A tenant a credential may act in: a group id, or the user's own data.
28
- *
29
- * Personal is a named sentinel rather than `null` so that a tenant is always a
30
- * plain string — storable in an array, sendable in a JWT claim, comparable
31
- * without a special case at every layer. It cannot collide with a real tenant:
32
- * group ids are 24-character hex.
33
- */
34
- export const PERSONAL_TENANT = 'personal';
35
-
36
- export type Tenant = string;
37
-
38
- /** The `groupId` a tenant corresponds to, as handlers have always read it. */
39
- export const groupIdOf = (tenant: Tenant): string | null =>
40
- tenant === PERSONAL_TENANT ? null : tenant;
41
-
42
- /**
43
- * Which tenants a credential may act in, and which one it acts in by default.
44
- *
45
- * The web app picks a tenant per request from a selector; an agent has no such
46
- * UI. So the *set* is chosen once, by a person, and the credential cannot escape
47
- * it — but within that set the caller may name one per request, which is what
48
- * lets a single connection reach both a shared journal and a private photo
49
- * library without reconnecting.
50
- *
51
- * `allowed` is never empty and always contains `default`. A credential granted
52
- * exactly one tenant behaves precisely as a pinned one did: nothing to name,
53
- * nothing to get wrong.
54
- */
55
- export interface ApiKeyTenant {
56
- allowed: Tenant[];
57
- default: Tenant;
58
- }
59
-
60
- /** Just the groups, for the `req.userGroups` every handler already reads. */
61
- export const groupIdsOf = (tenants: Tenant[]): string[] =>
62
- tenants.filter((tenant) => tenant !== PERSONAL_TENANT);
63
-
64
- /**
65
- * Read a stored tenant grant, tolerating one written before tenants were a set.
66
- *
67
- * Keys and grants issued under the old single-tenant model carry `groupId`
68
- * alone. Falling back to it here means they keep working across the deploy
69
- * rather than every connected assistant breaking at once.
70
- */
71
- export const readTenants = (stored: {
72
- tenants?: Tenant[] | null;
73
- defaultTenant?: Tenant | null;
74
- groupId?: string | null;
75
- }): ApiKeyTenant => {
76
- const allowed =
77
- stored.tenants && stored.tenants.length > 0
78
- ? stored.tenants
79
- : [stored.groupId ?? PERSONAL_TENANT];
80
-
81
- const fallbackDefault = allowed[0] as Tenant;
82
- const preferred = stored.defaultTenant ?? fallbackDefault;
83
-
84
- return {
85
- allowed,
86
- default: allowed.includes(preferred) ? preferred : fallbackDefault
87
- };
88
- };
89
-
90
- /** A key as the API hands it back — never including the secret. */
91
- export interface ApiKeySummary {
92
- id: string;
93
- name: string;
94
- /** the public half, shown in listings so a key is identifiable at a glance */
95
- prefix: string;
96
- scopes: ApiKeyScope[];
97
- tenants: Tenant[];
98
- defaultTenant: Tenant;
99
- createdAt: string;
100
- lastUsedAt: string | null;
101
- expiresAt: string | null;
102
- revokedAt: string | null;
103
- }
104
-
105
- /** Why a presented key was refused. Kept out of the HTTP response — see the middleware. */
106
- export type ApiKeyRejection =
107
- | 'malformed'
108
- | 'unknown'
109
- | 'revoked'
110
- | 'expired'
111
- | 'bad-secret';
112
-
113
- export interface ApiKeyVerification {
114
- ok: boolean;
115
- rejection?: ApiKeyRejection;
116
- userId?: string;
117
- userEmail?: string;
118
- userName?: string;
119
- keyId?: string;
120
- /** what the owner called this key, for an audit trail a person can read */
121
- label?: string;
122
- scopes?: ApiKeyScope[];
123
- tenants?: ApiKeyTenant;
124
- }
@@ -1,109 +0,0 @@
1
- import request from 'supertest';
2
- import { createBaseApp } from './createBaseApp';
3
-
4
- jest.mock('../logging/logger', () => ({
5
- __esModule: true,
6
- default: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn(), http: jest.fn() }
7
- }));
8
-
9
- describe('createBaseApp', () => {
10
- it('parses JSON bodies and cookies by default', async () => {
11
- const app = createBaseApp();
12
- app.get('/echo-cookie', (req, res) => res.json({ cookies: req.cookies }));
13
- app.post('/echo-body', (req, res) => res.json({ body: req.body }));
14
-
15
- const cookieRes = await request(app).get('/echo-cookie').set('Cookie', 'foo=bar');
16
- expect(cookieRes.body).toEqual({ cookies: { foo: 'bar' } });
17
-
18
- const bodyRes = await request(app).post('/echo-body').send({ hello: 'world' });
19
- expect(bodyRes.body).toEqual({ body: { hello: 'world' } });
20
- });
21
-
22
- it('applies security headers and rate limiting', async () => {
23
- const app = createBaseApp();
24
- app.get('/ping', (_req, res) => res.json({ ok: true }));
25
-
26
- const res = await request(app).get('/ping');
27
- expect(res.headers['x-content-type-options']).toBe('nosniff');
28
- });
29
-
30
- it('skips JSON parsing when parseJson is false', async () => {
31
- const app = createBaseApp({ parseJson: false });
32
- app.post('/echo-body', (req, res) => res.json({ body: req.body ?? null }));
33
-
34
- const res = await request(app).post('/echo-body').set('Content-Type', 'application/json').send('{"hello":"world"}');
35
- expect(res.body).toEqual({ body: null });
36
- });
37
-
38
- it('skips cookie parsing when parseCookies is false', async () => {
39
- const app = createBaseApp({ parseCookies: false });
40
- app.get('/echo-cookie', (req, res) => res.json({ cookies: req.cookies ?? null }));
41
-
42
- const res = await request(app).get('/echo-cookie').set('Cookie', 'foo=bar');
43
- expect(res.body).toEqual({ cookies: null });
44
- });
45
-
46
- it('honors a custom rate limiter', async () => {
47
- const app = createBaseApp({ rateLimiter: (await import('../middleware/security')).createRateLimiter({ windowMs: 60_000, max: 1 }) });
48
- app.get('/ping', (_req, res) => res.json({ ok: true }));
49
-
50
- const first = await request(app).get('/ping');
51
- const second = await request(app).get('/ping');
52
-
53
- expect(first.status).toBe(200);
54
- expect(second.status).toBe(429);
55
- });
56
-
57
- it('uses the plain request logger (no metrics histogram) when metrics is false', async () => {
58
- const app = createBaseApp({ metrics: false });
59
- app.get('/ping', (_req, res) => res.json({ ok: true }));
60
-
61
- const res = await request(app).get('/ping');
62
-
63
- expect(res.status).toBe(200);
64
- });
65
-
66
- it('trusts the proxy, so X-Forwarded-For identifies the client rather than being ignored', async () => {
67
- const app = createBaseApp();
68
- app.get('/whoami', (req, res) => res.json({ ip: req.ip }));
69
-
70
- const res = await request(app).get('/whoami').set('X-Forwarded-For', '203.0.113.7');
71
-
72
- expect(res.body.ip).toBe('203.0.113.7');
73
- });
74
-
75
- it('keeps rate-limit buckets per client, not one shared bucket behind the proxy', async () => {
76
- const app = createBaseApp({ rateLimiter: (await import('../middleware/security')).createRateLimiter({ windowMs: 60_000, max: 1 }) });
77
- app.get('/ping', (_req, res) => res.json({ ok: true }));
78
-
79
- const clientA = await request(app).get('/ping').set('X-Forwarded-For', '203.0.113.7');
80
- const clientB = await request(app).get('/ping').set('X-Forwarded-For', '198.51.100.4');
81
- const clientAAgain = await request(app).get('/ping').set('X-Forwarded-For', '203.0.113.7');
82
-
83
- expect(clientA.status).toBe(200);
84
- expect(clientB.status).toBe(200); // a different client is unaffected by A exhausting its window
85
- expect(clientAAgain.status).toBe(429);
86
- });
87
-
88
- it('ignores X-Forwarded-For when trustProxy is disabled', async () => {
89
- const app = createBaseApp({ trustProxy: false });
90
- app.get('/whoami', (req, res) => res.json({ ip: req.ip }));
91
-
92
- const res = await request(app).get('/whoami').set('X-Forwarded-For', '203.0.113.7');
93
-
94
- expect(res.body.ip).not.toBe('203.0.113.7');
95
- });
96
-
97
- it('mounts /health/live ahead of the rate limiter, so it never gets 429s a busy service would', async () => {
98
- const app = createBaseApp({ rateLimiter: (await import('../middleware/security')).createRateLimiter({ windowMs: 60_000, max: 1 }) });
99
- app.get('/ping', (_req, res) => res.json({ ok: true }));
100
-
101
- await request(app).get('/ping'); // exhaust the max: 1 budget
102
- const live1 = await request(app).get('/health/live');
103
- const live2 = await request(app).get('/health/live');
104
-
105
- expect(live1.status).toBe(200);
106
- expect(live1.body).toEqual({ status: 'ok' });
107
- expect(live2.status).toBe(200);
108
- });
109
- });
@@ -1,102 +0,0 @@
1
- import express, { Express, RequestHandler } from 'express';
2
- import cookieParser from 'cookie-parser';
3
- import { correlationMiddleware } from '../utils/correlation';
4
- import { tracingMiddleware } from '../tracing';
5
- import { requestLogger, requestLoggerWithMetrics } from '../middleware/requestLogger';
6
- import { createCorsMiddleware, CorsMiddlewareOptions } from '../middleware/corsMiddleware';
7
- import { securityHeaders, standardRateLimiter } from '../middleware/security';
8
- import { healthLive } from '../health/healthController';
9
-
10
- export interface CreateBaseAppOptions {
11
- /** Passed through to createCorsMiddleware. */
12
- corsOptions?: CorsMiddlewareOptions;
13
- /**
14
- * Defaults to standardRateLimiter; pass strictRateLimiter or a custom one
15
- * to override. Pass `false` to skip mounting one here entirely — e.g. when
16
- * a route (like a webhook) must be registered, and therefore exempted,
17
- * before the limiter, and the service applies it itself afterward.
18
- */
19
- rateLimiter?: RequestHandler | false;
20
- /** Mount express.json() here. Set false when a route needs the raw body first (e.g. a webhook) or handles its own parsing. Default true. */
21
- parseJson?: boolean;
22
- /** Mount cookie-parser here. Set false for services that don't use cookies. Default true. */
23
- parseCookies?: boolean;
24
- /** Mount tracingMiddleware. Default true. */
25
- tracing?: boolean;
26
- /** Use requestLoggerWithMetrics (adds Prometheus histograms) instead of the plain requestLogger. Default true. */
27
- metrics?: boolean;
28
- /**
29
- * Value for Express's `trust proxy`. Defaults to trusting private/loopback
30
- * addresses — see the note on tumbaland-proxy below. Pass `false` for a
31
- * service exposed directly to clients with no proxy in front of it.
32
- */
33
- trustProxy?: Parameters<Express['set']>[1];
34
- }
35
-
36
- /**
37
- * Assembles the middleware stack every service was hand-rolling in its own
38
- * index.ts (correlation → tracing → request logging → security → rate
39
- * limiting → CORS → cookies → JSON body). Services still mount their own
40
- * routes, `/health` (DB-aware readiness), `/metrics`, and
41
- * `app.use(errorHandler)` last — this only owns the common prefix, not the
42
- * whole app lifecycle.
43
- *
44
- * `/health/live` is the one exception: it's mounted here, first, ahead of
45
- * every other middleware. It has zero per-service variation (it never
46
- * touches a dependency, just confirms the process is up), so — unlike
47
- * `/health` — there's nothing for a service to customize. Mounting it
48
- * before the rate limiter also matters operationally: a service under
49
- * heavy legitimate traffic shouldn't have its own liveness probe start
50
- * getting 429s and get killed for being "unhealthy" at the exact moment
51
- * it's just busy.
52
- *
53
- * Services with non-standard body parsing (a webhook needing the raw body,
54
- * or conditional parsing per-route) should pass `parseJson`/`parseCookies:
55
- * false` and mount those themselves at the exact point they need to.
56
- *
57
- * `trust proxy` is on by default because nothing here is reached directly:
58
- * every service sits behind tumbaland-proxy, which sets `X-Forwarded-For`.
59
- * Left at Express's default (`false`) that header is ignored, which
60
- * express-rate-limit reports as `ERR_ERL_UNEXPECTED_X_FORWARDED_FOR` — and
61
- * it is right to: the limiter would then key every request in the cluster on
62
- * the proxy's own container IP, so one busy client could exhaust the window
63
- * for everyone.
64
- *
65
- * The value is `loopback, linklocal, uniquelocal` (private ranges) rather
66
- * than a hop count, because the chain length differs per ingress path: a
67
- * direct hit arrives as `client` + the proxy's own socket address, while
68
- * traffic through the Cloudflare tunnel arrives as `client, 127.0.0.1` (the
69
- * local cloudflared) + that same socket address. Express walks the chain
70
- * right-to-left and stops at the first address it does not trust, so both
71
- * paths land on the real client; a fixed number would be wrong for one of
72
- * them. All the intermediaries are private addresses and the client is not,
73
- * which is exactly the split this preset encodes.
74
- */
75
- export function createBaseApp(options: CreateBaseAppOptions = {}): Express {
76
- const {
77
- corsOptions,
78
- rateLimiter = standardRateLimiter,
79
- parseJson = true,
80
- parseCookies = true,
81
- tracing = true,
82
- metrics = true,
83
- trustProxy = 'loopback, linklocal, uniquelocal'
84
- } = options;
85
-
86
- const app = express();
87
-
88
- app.set('trust proxy', trustProxy);
89
-
90
- app.get('/health/live', healthLive);
91
-
92
- app.use(securityHeaders);
93
- if (rateLimiter) app.use(rateLimiter);
94
- app.use(correlationMiddleware);
95
- if (tracing) app.use(tracingMiddleware);
96
- app.use(metrics ? requestLoggerWithMetrics : requestLogger);
97
- app.use(createCorsMiddleware(corsOptions));
98
- if (parseCookies) app.use(cookieParser());
99
- if (parseJson) app.use(express.json());
100
-
101
- return app;
102
- }
@@ -1,129 +0,0 @@
1
- jest.mock('../logging/logger', () => ({
2
- __esModule: true,
3
- default: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn(), http: jest.fn() }
4
- }));
5
-
6
- jest.mock('../database/connection', () => ({
7
- __esModule: true,
8
- disconnectDB: jest.fn().mockResolvedValue(undefined)
9
- }));
10
-
11
- import type { Server } from 'http';
12
- import logger from '../logging/logger';
13
- import { disconnectDB } from '../database/connection';
14
- import { registerShutdown } from './shutdown';
15
-
16
- /** Minimal fake http.Server whose close() invokes its callback. */
17
- function fakeServer(closeErr?: Error): Server {
18
- return {
19
- close: jest.fn((cb?: (err?: Error) => void) => {
20
- cb?.(closeErr);
21
- return undefined as unknown as Server;
22
- })
23
- } as unknown as Server;
24
- }
25
-
26
- describe('registerShutdown', () => {
27
- const listeners: Record<string, (...args: unknown[]) => void> = {};
28
- let processOnSpy: jest.SpyInstance;
29
- let exitSpy: jest.SpyInstance;
30
-
31
- beforeEach(() => {
32
- jest.clearAllMocks();
33
- for (const key of Object.keys(listeners)) delete listeners[key];
34
-
35
- processOnSpy = jest
36
- .spyOn(process, 'on')
37
- .mockImplementation((event: string | symbol, handler: (...args: unknown[]) => void) => {
38
- listeners[event as string] = handler;
39
- return process;
40
- });
41
- exitSpy = jest.spyOn(process, 'exit').mockImplementation(((): never => undefined as never));
42
- });
43
-
44
- afterEach(() => {
45
- processOnSpy.mockRestore();
46
- exitSpy.mockRestore();
47
- });
48
-
49
- const flush = () => new Promise((resolve) => setImmediate(resolve));
50
-
51
- it('registers SIGINT and SIGTERM handlers', () => {
52
- registerShutdown({ serviceName: 'svc' });
53
- expect(Object.keys(listeners).sort()).toEqual(['SIGINT', 'SIGTERM']);
54
- });
55
-
56
- it('closes the server, disconnects the DB, and exits 0 on SIGTERM', async () => {
57
- const server = fakeServer();
58
- registerShutdown({ serviceName: 'svc', server });
59
-
60
- listeners.SIGTERM();
61
- await flush();
62
-
63
- expect(server.close).toHaveBeenCalled();
64
- expect(disconnectDB).toHaveBeenCalledWith('svc');
65
- expect(exitSpy).toHaveBeenCalledWith(0);
66
- expect(logger.info).toHaveBeenCalledWith('Received shutdown signal, closing gracefully', {
67
- service: 'svc',
68
- signal: 'SIGTERM'
69
- });
70
- });
71
-
72
- it('runs the onShutdown hook before closing the server', async () => {
73
- const order: string[] = [];
74
- const server = {
75
- close: jest.fn((cb?: (err?: Error) => void) => {
76
- order.push('server');
77
- cb?.();
78
- return undefined as unknown as Server;
79
- })
80
- } as unknown as Server;
81
- const onShutdown = jest.fn(async () => {
82
- order.push('hook');
83
- });
84
-
85
- registerShutdown({ serviceName: 'svc', server, onShutdown });
86
- listeners.SIGINT();
87
- await flush();
88
-
89
- expect(order).toEqual(['hook', 'server']);
90
- expect(exitSpy).toHaveBeenCalledWith(0);
91
- });
92
-
93
- it('skips the DB disconnect when disconnectDatabase is false', async () => {
94
- registerShutdown({ serviceName: 'svc', disconnectDatabase: false });
95
-
96
- listeners.SIGTERM();
97
- await flush();
98
-
99
- expect(disconnectDB).not.toHaveBeenCalled();
100
- expect(exitSpy).toHaveBeenCalledWith(0);
101
- });
102
-
103
- it('is idempotent — a second signal while shutting down is ignored', async () => {
104
- const server = fakeServer();
105
- registerShutdown({ serviceName: 'svc', server });
106
-
107
- listeners.SIGTERM();
108
- listeners.SIGINT();
109
- await flush();
110
-
111
- expect(server.close).toHaveBeenCalledTimes(1);
112
- expect(exitSpy).toHaveBeenCalledTimes(1);
113
- });
114
-
115
- it('exits 1 when the server fails to close', async () => {
116
- const server = fakeServer(new Error('close boom'));
117
- registerShutdown({ serviceName: 'svc', server });
118
-
119
- listeners.SIGTERM();
120
- await flush();
121
-
122
- expect(disconnectDB).not.toHaveBeenCalled();
123
- expect(exitSpy).toHaveBeenCalledWith(1);
124
- expect(logger.error).toHaveBeenCalledWith('Error during graceful shutdown', {
125
- service: 'svc',
126
- error: 'close boom'
127
- });
128
- });
129
- });
@@ -1,81 +0,0 @@
1
- import type { Server } from 'http';
2
- import logger from '../logging/logger';
3
- import { disconnectDB } from '../database/connection';
4
-
5
- export interface RegisterShutdownOptions {
6
- /** Service name, used for log context and the DB disconnect. */
7
- serviceName: string;
8
- /** The HTTP server returned by `app.listen(...)`, closed before the DB. */
9
- server?: Server;
10
- /**
11
- * Whether to close the MongoDB connection on shutdown. Defaults to `true`;
12
- * set `false` for services that never call `connectDB` (e.g. file, public).
13
- */
14
- disconnectDatabase?: boolean;
15
- /** Optional extra cleanup run before the server/DB are closed. */
16
- onShutdown?: () => Promise<void> | void;
17
- /**
18
- * Force `process.exit` after this many ms if a graceful close hangs, so a
19
- * stuck connection can't block a container rollout. Defaults to 10s.
20
- */
21
- forceExitAfterMs?: number;
22
- }
23
-
24
- /**
25
- * Registers `SIGINT` and `SIGTERM` handlers that drain the service before the
26
- * process exits: run the optional cleanup hook, stop accepting new HTTP
27
- * connections, then close the MongoDB connection. `SIGTERM` matters most —
28
- * it's the signal Docker/Kubernetes send on stop and rollout, and without a
29
- * handler the process is force-killed with its DB connection still open.
30
- *
31
- * The handler is idempotent (a second signal while shutting down is ignored)
32
- * and self-arms a force-exit timer so a hung close still terminates.
33
- */
34
- export const registerShutdown = ({
35
- serviceName,
36
- server,
37
- disconnectDatabase = true,
38
- onShutdown,
39
- forceExitAfterMs = 10_000
40
- }: RegisterShutdownOptions): void => {
41
- let shuttingDown = false;
42
-
43
- const shutdown = async (signal: string): Promise<void> => {
44
- if (shuttingDown) return;
45
- shuttingDown = true;
46
-
47
- logger.info('Received shutdown signal, closing gracefully', { service: serviceName, signal });
48
-
49
- const forceExit = setTimeout(() => {
50
- logger.error('Graceful shutdown timed out, forcing exit', { service: serviceName });
51
- process.exit(1);
52
- }, forceExitAfterMs);
53
- // Don't let the timer itself keep the event loop alive.
54
- forceExit.unref?.();
55
-
56
- try {
57
- if (onShutdown) await onShutdown();
58
-
59
- if (server) {
60
- await new Promise<void>((resolve, reject) => {
61
- server.close((err) => (err ? reject(err) : resolve()));
62
- });
63
- }
64
-
65
- if (disconnectDatabase) await disconnectDB(serviceName);
66
-
67
- clearTimeout(forceExit);
68
- process.exit(0);
69
- } catch (error) {
70
- clearTimeout(forceExit);
71
- logger.error('Error during graceful shutdown', {
72
- service: serviceName,
73
- error: (error as Error)?.message
74
- });
75
- process.exit(1);
76
- }
77
- };
78
-
79
- process.on('SIGINT', () => void shutdown('SIGINT'));
80
- process.on('SIGTERM', () => void shutdown('SIGTERM'));
81
- };
@@ -1,123 +0,0 @@
1
- import mongoose, { Document, Schema } from 'mongoose';
2
- import type { ActorKind } from './actor';
3
-
4
- /**
5
- * One thing that happened, and who did it.
6
- *
7
- * Lives in backend-core rather than any one service because the question it
8
- * answers spans them: "what did Claude do yesterday" is not a relationship
9
- * question or an album question. Every service writes into the same collection,
10
- * and `service` says which one.
11
- */
12
- /**
13
- * What one MCP tool call cost.
14
- *
15
- * Kept beside the trail rather than in a metrics system because the question it
16
- * answers is the same one the trail answers — what did the assistant do — with
17
- * the part that turned out to matter added: how much text came back. A tool
18
- * result is not a page a person closes. It stays in the conversation and is
19
- * resent with every message after it, so a single answer of ten thousand tokens
20
- * is paid for again on every turn that follows. Nothing in the product made
21
- * that visible until a bill did.
22
- */
23
- export interface ToolCall {
24
- /** the tool as the model called it — 'get_net_worth', 'log_day' */
25
- name: string;
26
- /** how long the handler took, end to end, including the calls it made */
27
- ms: number;
28
- /** characters of text handed back to the model */
29
- chars: number;
30
- /**
31
- * Roughly what those characters cost, at four to a token.
32
- *
33
- * An estimate on purpose: the real count depends on a tokeniser this server
34
- * has no reason to carry, and the decision it informs — which tool is the
35
- * expensive one — is nowhere near close enough for the difference to matter.
36
- */
37
- tokens: number;
38
- /** the call came back as an error, so the size is a message rather than data */
39
- failed?: boolean;
40
- }
41
-
42
- export interface IAuditEvent extends Document {
43
- at: Date;
44
- /** which service handled it — 'relationship-service', 'album-service' */
45
- service: string;
46
- action: 'create' | 'update' | 'delete' | 'read' | 'call';
47
- /** what kind of thing — 'activity', 'activity_type', 'photo' */
48
- resource: string;
49
- resourceId?: string;
50
- actorKind: ActorKind;
51
- /** the account acted for; every listing is scoped to this */
52
- userId: string;
53
- actorLabel: string;
54
- /** a key's id or an OAuth client id; absent when a person acted directly */
55
- actorCredentialId?: string;
56
- /** the journal it happened in */
57
- tenant: string;
58
- /**
59
- * What changed, field by field.
60
- *
61
- * Present on updates. This is the difference between an audit log and a
62
- * request log: "Claude edited an entry" is barely worth storing, "Claude
63
- * changed hours from 3 to 2" is the thing someone actually wants to see.
64
- */
65
- changes?: Record<string, { from: unknown; to: unknown }>;
66
- /** what a delete removed, so it can be read back or restored by hand */
67
- snapshot?: Record<string, unknown>;
68
- /** how many records a read returned — the size of what left the server */
69
- count?: number;
70
- /** present on 'call': the tool, and what it cost */
71
- tool?: ToolCall;
72
- createdAt: Date;
73
- }
74
-
75
- const ToolCallSchema = new Schema<ToolCall>(
76
- {
77
- name: { type: String, required: true },
78
- ms: { type: Number, required: true },
79
- chars: { type: Number, required: true },
80
- tokens: { type: Number, required: true },
81
- failed: { type: Boolean }
82
- },
83
- { _id: false }
84
- );
85
-
86
- const AuditEventSchema = new Schema<IAuditEvent>(
87
- {
88
- at: { type: Date, required: true, default: Date.now },
89
- service: { type: String, required: true },
90
- action: { type: String, required: true, enum: ['create', 'update', 'delete', 'read', 'call'] },
91
- resource: { type: String, required: true },
92
- resourceId: { type: String },
93
- actorKind: { type: String, required: true, enum: ['person', 'key', 'assistant'] },
94
- userId: { type: String, required: true, index: true },
95
- actorLabel: { type: String, required: true },
96
- actorCredentialId: { type: String },
97
- tenant: { type: String, required: true },
98
- changes: { type: Schema.Types.Mixed },
99
- snapshot: { type: Schema.Types.Mixed },
100
- count: { type: Number },
101
- tool: { type: ToolCallSchema }
102
- },
103
- { timestamps: { createdAt: true, updatedAt: false }, collection: 'audit_events' }
104
- );
105
-
106
- // Every listing is one account's trail, newest first.
107
- AuditEventSchema.index({ userId: 1, at: -1 });
108
-
109
- /**
110
- * Rows expire on their own.
111
- *
112
- * They carry journal content — the old value of a description, a deleted
113
- * entry — so keeping them indefinitely would quietly build a second copy of the
114
- * journal that nothing in the product ever deletes from. A year is long enough
115
- * to answer "what happened" and short enough that the copy does not outlive the
116
- * question.
117
- */
118
- const RETENTION_DAYS = 365;
119
- AuditEventSchema.index({ at: 1 }, { expireAfterSeconds: RETENTION_DAYS * 24 * 60 * 60 });
120
-
121
- export const AuditEvent = mongoose.models.AuditEvent
122
- ? (mongoose.models.AuditEvent as mongoose.Model<IAuditEvent>)
123
- : mongoose.model<IAuditEvent>('AuditEvent', AuditEventSchema);