@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.
- package/package.json +5 -1
- package/.versionrc.json +0 -7
- package/__mocks__/uuid.js +0 -8
- package/jest.config.js +0 -24
- package/src/apiKeys/ApiKey.test.ts +0 -142
- package/src/apiKeys/ApiKey.ts +0 -102
- package/src/apiKeys/crypto.test.ts +0 -161
- package/src/apiKeys/crypto.ts +0 -163
- package/src/apiKeys/index.test.ts +0 -72
- package/src/apiKeys/index.ts +0 -35
- package/src/apiKeys/middleware.test.ts +0 -651
- package/src/apiKeys/middleware.ts +0 -341
- package/src/apiKeys/service.test.ts +0 -401
- package/src/apiKeys/service.ts +0 -168
- package/src/apiKeys/types.test.ts +0 -41
- package/src/apiKeys/types.ts +0 -124
- package/src/app/createBaseApp.test.ts +0 -109
- package/src/app/createBaseApp.ts +0 -102
- package/src/app/shutdown.test.ts +0 -129
- package/src/app/shutdown.ts +0 -81
- package/src/audit/AuditEvent.ts +0 -123
- package/src/audit/actor.test.ts +0 -95
- package/src/audit/actor.ts +0 -68
- package/src/audit/context.test.ts +0 -91
- package/src/audit/context.ts +0 -83
- package/src/audit/index.ts +0 -11
- package/src/audit/plugin.test.ts +0 -258
- package/src/audit/plugin.ts +0 -254
- package/src/audit/reads.test.ts +0 -164
- package/src/audit/reads.ts +0 -88
- package/src/audit/service.test.ts +0 -115
- package/src/audit/service.ts +0 -95
- package/src/auth/session.test.ts +0 -89
- package/src/auth/session.ts +0 -75
- package/src/config/env.test.ts +0 -28
- package/src/config/env.ts +0 -13
- package/src/database/connection.test.ts +0 -188
- package/src/database/connection.ts +0 -90
- package/src/entitlements/UsageMeter.ts +0 -49
- package/src/entitlements/client.test.ts +0 -200
- package/src/entitlements/client.ts +0 -179
- package/src/entitlements/definitions.test.ts +0 -161
- package/src/entitlements/definitions.ts +0 -268
- package/src/entitlements/index.ts +0 -42
- package/src/entitlements/middleware.test.ts +0 -196
- package/src/entitlements/middleware.ts +0 -150
- package/src/entitlements/reconcile.test.ts +0 -333
- package/src/entitlements/reconcile.ts +0 -384
- package/src/entitlements/types.ts +0 -21
- package/src/entitlements/usage.test.ts +0 -314
- package/src/entitlements/usage.ts +0 -223
- package/src/errors/HttpError.test.ts +0 -76
- package/src/errors/HttpError.ts +0 -91
- package/src/groups/client.test.ts +0 -215
- package/src/groups/client.ts +0 -182
- package/src/groups/index.ts +0 -7
- package/src/groups/membership.test.ts +0 -84
- package/src/groups/membership.ts +0 -133
- package/src/groups/subject.test.ts +0 -85
- package/src/groups/subject.ts +0 -50
- package/src/health/createHealthCheck.test.ts +0 -89
- package/src/health/createHealthCheck.ts +0 -67
- package/src/health/healthController.test.ts +0 -113
- package/src/health/healthController.ts +0 -56
- package/src/index.ts +0 -88
- package/src/logging/logger.test.ts +0 -91
- package/src/logging/logger.ts +0 -103
- package/src/metrics/index.test.ts +0 -116
- package/src/metrics/index.ts +0 -111
- package/src/middleware/authMiddleware.test.ts +0 -275
- package/src/middleware/authMiddleware.ts +0 -91
- package/src/middleware/corsMiddleware.test.ts +0 -135
- package/src/middleware/corsMiddleware.ts +0 -65
- package/src/middleware/errorHandler.test.ts +0 -188
- package/src/middleware/errorHandler.ts +0 -103
- package/src/middleware/internalServiceAuth.test.ts +0 -173
- package/src/middleware/internalServiceAuth.ts +0 -96
- package/src/middleware/requestLogger.test.ts +0 -81
- package/src/middleware/requestLogger.ts +0 -48
- package/src/middleware/security.test.ts +0 -45
- package/src/middleware/security.ts +0 -43
- package/src/middleware/validate.test.ts +0 -72
- package/src/middleware/validate.ts +0 -23
- package/src/oauth/index.ts +0 -29
- package/src/oauth/models.ts +0 -164
- package/src/oauth/service.test.ts +0 -432
- package/src/oauth/service.ts +0 -299
- package/src/oauth/tokens.test.ts +0 -146
- package/src/oauth/tokens.ts +0 -186
- package/src/testing/serviceTestSetup.ts +0 -17
- package/src/tracing/index.test.ts +0 -272
- package/src/tracing/index.ts +0 -110
- package/src/types/auth.ts +0 -45
- package/src/utils/correlation.test.ts +0 -47
- package/src/utils/correlation.ts +0 -22
- package/src/utils/permissionUtils.test.ts +0 -47
- package/src/utils/permissionUtils.ts +0 -68
- package/src/utils/response.test.ts +0 -64
- package/src/utils/response.ts +0 -60
- package/tsconfig.build.json +0 -7
- package/tsconfig.json +0 -23
package/src/apiKeys/types.ts
DELETED
|
@@ -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
|
-
});
|
package/src/app/createBaseApp.ts
DELETED
|
@@ -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
|
-
}
|
package/src/app/shutdown.test.ts
DELETED
|
@@ -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
|
-
});
|
package/src/app/shutdown.ts
DELETED
|
@@ -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
|
-
};
|
package/src/audit/AuditEvent.ts
DELETED
|
@@ -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);
|