@tumbaland/backend-core 1.37.0 → 1.39.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 (92) hide show
  1. package/dist/apiKeys/ApiKey.d.ts +23 -3
  2. package/dist/apiKeys/ApiKey.d.ts.map +1 -1
  3. package/dist/apiKeys/ApiKey.js +19 -2
  4. package/dist/apiKeys/ApiKey.js.map +1 -1
  5. package/dist/apiKeys/index.d.ts +2 -2
  6. package/dist/apiKeys/index.d.ts.map +1 -1
  7. package/dist/apiKeys/index.js +5 -1
  8. package/dist/apiKeys/index.js.map +1 -1
  9. package/dist/apiKeys/middleware.d.ts +17 -3
  10. package/dist/apiKeys/middleware.d.ts.map +1 -1
  11. package/dist/apiKeys/middleware.js +71 -34
  12. package/dist/apiKeys/middleware.js.map +1 -1
  13. package/dist/apiKeys/service.d.ts +5 -3
  14. package/dist/apiKeys/service.d.ts.map +1 -1
  15. package/dist/apiKeys/service.js +11 -3
  16. package/dist/apiKeys/service.js.map +1 -1
  17. package/dist/apiKeys/types.d.ts +43 -8
  18. package/dist/apiKeys/types.d.ts.map +1 -1
  19. package/dist/apiKeys/types.js +35 -1
  20. package/dist/apiKeys/types.js.map +1 -1
  21. package/dist/audit/AuditEvent.d.ts +51 -0
  22. package/dist/audit/AuditEvent.d.ts.map +1 -0
  23. package/dist/audit/AuditEvent.js +69 -0
  24. package/dist/audit/AuditEvent.js.map +1 -0
  25. package/dist/audit/actor.d.ts +41 -0
  26. package/dist/audit/actor.d.ts.map +1 -0
  27. package/dist/audit/actor.js +39 -0
  28. package/dist/audit/actor.js.map +1 -0
  29. package/dist/audit/context.d.ts +40 -0
  30. package/dist/audit/context.d.ts.map +1 -0
  31. package/dist/audit/context.js +60 -0
  32. package/dist/audit/context.js.map +1 -0
  33. package/dist/audit/index.d.ts +12 -0
  34. package/dist/audit/index.d.ts.map +1 -0
  35. package/dist/audit/index.js +22 -0
  36. package/dist/audit/index.js.map +1 -0
  37. package/dist/audit/plugin.d.ts +23 -0
  38. package/dist/audit/plugin.d.ts.map +1 -0
  39. package/dist/audit/plugin.js +226 -0
  40. package/dist/audit/plugin.js.map +1 -0
  41. package/dist/audit/reads.d.ts +47 -0
  42. package/dist/audit/reads.d.ts.map +1 -0
  43. package/dist/audit/reads.js +94 -0
  44. package/dist/audit/reads.js.map +1 -0
  45. package/dist/audit/service.d.ts +49 -0
  46. package/dist/audit/service.d.ts.map +1 -0
  47. package/dist/audit/service.js +65 -0
  48. package/dist/audit/service.js.map +1 -0
  49. package/dist/index.d.ts +1 -0
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +1 -0
  52. package/dist/index.js.map +1 -1
  53. package/dist/oauth/models.d.ts +13 -2
  54. package/dist/oauth/models.d.ts.map +1 -1
  55. package/dist/oauth/models.js +19 -0
  56. package/dist/oauth/models.js.map +1 -1
  57. package/dist/oauth/service.d.ts +15 -8
  58. package/dist/oauth/service.d.ts.map +1 -1
  59. package/dist/oauth/service.js +10 -3
  60. package/dist/oauth/service.js.map +1 -1
  61. package/dist/oauth/tokens.d.ts +37 -5
  62. package/dist/oauth/tokens.d.ts.map +1 -1
  63. package/dist/oauth/tokens.js +9 -2
  64. package/dist/oauth/tokens.js.map +1 -1
  65. package/package.json +1 -1
  66. package/src/apiKeys/ApiKey.test.ts +25 -1
  67. package/src/apiKeys/ApiKey.ts +31 -4
  68. package/src/apiKeys/index.ts +16 -2
  69. package/src/apiKeys/middleware.test.ts +126 -20
  70. package/src/apiKeys/middleware.ts +99 -39
  71. package/src/apiKeys/service.test.ts +58 -9
  72. package/src/apiKeys/service.ts +25 -6
  73. package/src/apiKeys/types.ts +63 -8
  74. package/src/audit/AuditEvent.ts +79 -0
  75. package/src/audit/actor.test.ts +95 -0
  76. package/src/audit/actor.ts +68 -0
  77. package/src/audit/context.test.ts +91 -0
  78. package/src/audit/context.ts +83 -0
  79. package/src/audit/index.ts +11 -0
  80. package/src/audit/plugin.test.ts +258 -0
  81. package/src/audit/plugin.ts +254 -0
  82. package/src/audit/reads.test.ts +164 -0
  83. package/src/audit/reads.ts +88 -0
  84. package/src/audit/service.test.ts +115 -0
  85. package/src/audit/service.ts +92 -0
  86. package/src/index.ts +1 -0
  87. package/src/middleware/authMiddleware.test.ts +6 -1
  88. package/src/oauth/models.ts +32 -2
  89. package/src/oauth/service.test.ts +58 -6
  90. package/src/oauth/service.ts +25 -7
  91. package/src/oauth/tokens.test.ts +22 -5
  92. package/src/oauth/tokens.ts +52 -7
@@ -24,17 +24,69 @@ export const isApiKeyScope = (value: unknown): value is ApiKeyScope =>
24
24
  typeof value === 'string' && (API_KEY_SCOPES as readonly string[]).includes(value);
25
25
 
26
26
  /**
27
- * The tenant a key acts in, fixed when the key is created.
27
+ * A tenant a credential may act in: a group id, or the user's own data.
28
28
  *
29
- * The web app picks this per request from a tenant selector, but an agent has no
30
- * such UI and no way to know it guessed wrong — so the choice is made once, by a
31
- * person, and the key cannot escape it. `groupId: null` means the user's own
32
- * non-group data.
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.
33
54
  */
34
55
  export interface ApiKeyTenant {
35
- groupId: string | null;
56
+ allowed: Tenant[];
57
+ default: Tenant;
36
58
  }
37
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
+
38
90
  /** A key as the API hands it back — never including the secret. */
39
91
  export interface ApiKeySummary {
40
92
  id: string;
@@ -42,7 +94,8 @@ export interface ApiKeySummary {
42
94
  /** the public half, shown in listings so a key is identifiable at a glance */
43
95
  prefix: string;
44
96
  scopes: ApiKeyScope[];
45
- groupId: string | null;
97
+ tenants: Tenant[];
98
+ defaultTenant: Tenant;
46
99
  createdAt: string;
47
100
  lastUsedAt: string | null;
48
101
  expiresAt: string | null;
@@ -64,6 +117,8 @@ export interface ApiKeyVerification {
64
117
  userEmail?: string;
65
118
  userName?: string;
66
119
  keyId?: string;
120
+ /** what the owner called this key, for an audit trail a person can read */
121
+ label?: string;
67
122
  scopes?: ApiKeyScope[];
68
- groupId?: string | null;
123
+ tenants?: ApiKeyTenant;
69
124
  }
@@ -0,0 +1,79 @@
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
+ export interface IAuditEvent extends Document {
13
+ at: Date;
14
+ /** which service handled it — 'relationship-service', 'album-service' */
15
+ service: string;
16
+ action: 'create' | 'update' | 'delete' | 'read';
17
+ /** what kind of thing — 'activity', 'activity_type', 'photo' */
18
+ resource: string;
19
+ resourceId?: string;
20
+ actorKind: ActorKind;
21
+ /** the account acted for; every listing is scoped to this */
22
+ userId: string;
23
+ actorLabel: string;
24
+ /** a key's id or an OAuth client id; absent when a person acted directly */
25
+ actorCredentialId?: string;
26
+ /** the journal it happened in */
27
+ tenant: string;
28
+ /**
29
+ * What changed, field by field.
30
+ *
31
+ * Present on updates. This is the difference between an audit log and a
32
+ * request log: "Claude edited an entry" is barely worth storing, "Claude
33
+ * changed hours from 3 to 2" is the thing someone actually wants to see.
34
+ */
35
+ changes?: Record<string, { from: unknown; to: unknown }>;
36
+ /** what a delete removed, so it can be read back or restored by hand */
37
+ snapshot?: Record<string, unknown>;
38
+ /** how many records a read returned — the size of what left the server */
39
+ count?: number;
40
+ createdAt: Date;
41
+ }
42
+
43
+ const AuditEventSchema = new Schema<IAuditEvent>(
44
+ {
45
+ at: { type: Date, required: true, default: Date.now },
46
+ service: { type: String, required: true },
47
+ action: { type: String, required: true, enum: ['create', 'update', 'delete', 'read'] },
48
+ resource: { type: String, required: true },
49
+ resourceId: { type: String },
50
+ actorKind: { type: String, required: true, enum: ['person', 'key', 'assistant'] },
51
+ userId: { type: String, required: true, index: true },
52
+ actorLabel: { type: String, required: true },
53
+ actorCredentialId: { type: String },
54
+ tenant: { type: String, required: true },
55
+ changes: { type: Schema.Types.Mixed },
56
+ snapshot: { type: Schema.Types.Mixed },
57
+ count: { type: Number }
58
+ },
59
+ { timestamps: { createdAt: true, updatedAt: false }, collection: 'audit_events' }
60
+ );
61
+
62
+ // Every listing is one account's trail, newest first.
63
+ AuditEventSchema.index({ userId: 1, at: -1 });
64
+
65
+ /**
66
+ * Rows expire on their own.
67
+ *
68
+ * They carry journal content — the old value of a description, a deleted
69
+ * entry — so keeping them indefinitely would quietly build a second copy of the
70
+ * journal that nothing in the product ever deletes from. A year is long enough
71
+ * to answer "what happened" and short enough that the copy does not outlive the
72
+ * question.
73
+ */
74
+ const RETENTION_DAYS = 365;
75
+ AuditEventSchema.index({ at: 1 }, { expireAfterSeconds: RETENTION_DAYS * 24 * 60 * 60 });
76
+
77
+ export const AuditEvent = mongoose.models.AuditEvent
78
+ ? (mongoose.models.AuditEvent as mongoose.Model<IAuditEvent>)
79
+ : mongoose.model<IAuditEvent>('AuditEvent', AuditEventSchema);
@@ -0,0 +1,95 @@
1
+ import type { Request } from 'express';
2
+ import { actorOf, describeActor } from './actor';
3
+ import { PERSONAL_TENANT } from '../apiKeys/types';
4
+
5
+ const req = (over: Partial<Request> = {}): Request =>
6
+ ({ query: {}, body: {}, headers: {}, ...over }) as unknown as Request;
7
+
8
+ const grant = { allowed: [PERSONAL_TENANT], default: PERSONAL_TENANT };
9
+
10
+ describe('actorOf', () => {
11
+ it('reads a session as the person themselves', () => {
12
+ const actor = actorOf(req({ user: { id: 'u1', email: 'tom@example.com', name: 'Tom' } }));
13
+
14
+ expect(actor).toMatchObject({ kind: 'person', userId: 'u1', label: 'Tom' });
15
+ // Nothing to revoke and nothing that rotates: a person is not a credential.
16
+ expect(actor?.credentialId).toBeUndefined();
17
+ });
18
+
19
+ it('falls back to the email when a session carries no name', () => {
20
+ // An unlabelled row is the one nobody can act on.
21
+ const actor = actorOf(req({ user: { id: 'u1', email: 'tom@example.com', name: '' } }));
22
+ expect(actor?.label).toBe('tom@example.com');
23
+ });
24
+
25
+ it('names an API key by what its owner called it', () => {
26
+ const actor = actorOf(
27
+ req({
28
+ user: { id: 'u1', email: 'tom@example.com', name: 'Tom' },
29
+ apiKey: {
30
+ keyId: 'k1',
31
+ scopes: [],
32
+ tenants: grant,
33
+ actingAs: PERSONAL_TENANT,
34
+ kind: 'api_key',
35
+ label: 'Claude Code',
36
+ credentialId: 'k1'
37
+ }
38
+ })
39
+ );
40
+
41
+ expect(actor).toMatchObject({ kind: 'key', label: 'Claude Code', credentialId: 'k1' });
42
+ });
43
+
44
+ it('names a connected assistant by its client, not its token', () => {
45
+ const actor = actorOf(
46
+ req({
47
+ user: { id: 'u1', email: 'tom@example.com', name: 'Tom' },
48
+ apiKey: {
49
+ keyId: 'client-1',
50
+ scopes: [],
51
+ tenants: { allowed: ['g1'], default: 'g1' },
52
+ actingAs: 'g1',
53
+ kind: 'oauth',
54
+ label: 'Claude',
55
+ credentialId: 'client-1'
56
+ }
57
+ })
58
+ );
59
+
60
+ // An access token's `jti` rotates hourly; a trail built on it would show a
61
+ // different actor after every refresh.
62
+ expect(actor).toMatchObject({ kind: 'assistant', label: 'Claude', credentialId: 'client-1' });
63
+ });
64
+
65
+ it('records the journal the request is acting in', () => {
66
+ const actor = actorOf(
67
+ req({
68
+ user: { id: 'u1', email: 'a@b.c', name: 'Tom' },
69
+ apiKey: {
70
+ keyId: 'c1',
71
+ scopes: [],
72
+ tenants: { allowed: [PERSONAL_TENANT, 'g1'], default: 'g1' },
73
+ actingAs: 'g1',
74
+ kind: 'oauth',
75
+ label: 'Claude',
76
+ credentialId: 'c1'
77
+ }
78
+ })
79
+ );
80
+
81
+ expect(actor?.tenant).toBe('g1');
82
+ });
83
+
84
+ it('has nothing to attribute when nobody is authenticated', () => {
85
+ // A row about the transport is not a row about anyone.
86
+ expect(actorOf(req())).toBeNull();
87
+ });
88
+
89
+ it('describes software so it is not mistaken for a person', () => {
90
+ expect(describeActor({ kind: 'person', userId: 'u1', label: 'Tom', tenant: PERSONAL_TENANT })).toBe('Tom');
91
+ expect(
92
+ describeActor({ kind: 'assistant', userId: 'u1', label: 'Claude', tenant: 'g1' })
93
+ ).toBe('Claude (assistant)');
94
+ });
95
+ });
@@ -0,0 +1,68 @@
1
+ import type { Request } from 'express';
2
+ import { PERSONAL_TENANT, type Tenant } from '../apiKeys/types';
3
+
4
+ /**
5
+ * Who is acting, in the only three ways anything reaches this system.
6
+ *
7
+ * The distinction that matters is `person` against everything else. A person is
8
+ * at a keyboard and can see what they are doing; a key or an assistant is
9
+ * software acting on their behalf, and is the thing an audit trail exists to
10
+ * make legible. Splitting `key` from `assistant` is worth the extra case because
11
+ * they fail differently: a key is a secret someone pasted somewhere and may have
12
+ * lost, an assistant is a consent screen someone approved and can withdraw.
13
+ */
14
+ export type ActorKind = 'person' | 'key' | 'assistant';
15
+
16
+ export interface Actor {
17
+ kind: ActorKind;
18
+ /** the account being acted for — the same whichever kind of actor it is */
19
+ userId: string;
20
+ /** what to show in a trail: the person's name, the key's name, the app's name */
21
+ label: string;
22
+ /**
23
+ * The credential, for software.
24
+ *
25
+ * A key's document id, or an OAuth client id. Deliberately not the access
26
+ * token's `jti`, which rotates hourly and would show a different actor after
27
+ * every refresh.
28
+ */
29
+ credentialId?: string;
30
+ /** the journal this request is acting in */
31
+ tenant: Tenant;
32
+ }
33
+
34
+ /**
35
+ * Read the actor off a request that has already been authenticated.
36
+ *
37
+ * Returns null for an unauthenticated request rather than inventing an
38
+ * anonymous actor: there is nothing to attribute, and a row saying so would be
39
+ * a row about the transport, not about anyone.
40
+ */
41
+ export function actorOf(req: Request): Actor | null {
42
+ const user = req.user;
43
+ if (!user?.id) return null;
44
+
45
+ const credential = req.apiKey;
46
+ if (!credential) {
47
+ return {
48
+ kind: 'person',
49
+ userId: user.id,
50
+ // Falling back to the email because a name is optional on a session and an
51
+ // unlabelled row is the one nobody can act on.
52
+ label: user.name || user.email || user.id,
53
+ tenant: (req.query?.groupId as string) || PERSONAL_TENANT
54
+ };
55
+ }
56
+
57
+ return {
58
+ kind: credential.kind === 'oauth' ? 'assistant' : 'key',
59
+ userId: user.id,
60
+ label: credential.label,
61
+ credentialId: credential.credentialId,
62
+ tenant: credential.actingAs
63
+ };
64
+ }
65
+
66
+ /** One line naming an actor, for a log message or an error. */
67
+ export const describeActor = (actor: Actor): string =>
68
+ actor.kind === 'person' ? actor.label : `${actor.label} (${actor.kind})`;
@@ -0,0 +1,91 @@
1
+ import type { Request, Response } from 'express';
2
+
3
+ jest.mock('./reads', () => ({ recordRead: jest.fn() }));
4
+
5
+ import { recordRead } from './reads';
6
+ import { beginAudit, currentAuditContext, withAuditContext } from './context';
7
+ import { PERSONAL_TENANT } from '../apiKeys/types';
8
+
9
+ const ORIGINAL_ENV = process.env;
10
+
11
+ const req = (over: Partial<Request> = {}): Request =>
12
+ ({ method: 'GET', query: {}, body: {}, baseUrl: '', path: '/', ...over }) as unknown as Request;
13
+
14
+ const res = () => ({ on: jest.fn(), locals: {} }) as unknown as Response;
15
+ const user = { id: 'u1', email: 'tom@example.com', name: 'Tom' };
16
+
17
+ beforeEach(() => {
18
+ jest.clearAllMocks();
19
+ process.env = { ...ORIGINAL_ENV };
20
+ });
21
+
22
+ afterEach(() => {
23
+ process.env = ORIGINAL_ENV;
24
+ });
25
+
26
+ describe('beginAudit', () => {
27
+ it('puts the actor in scope for everything after it', () => {
28
+ let seen: string | undefined;
29
+ beginAudit(req({ user }), res(), () => {
30
+ seen = currentAuditContext()?.actor.label;
31
+ });
32
+
33
+ expect(seen).toBe('Tom');
34
+ });
35
+
36
+ it('leaves no context behind once the request is done', () => {
37
+ beginAudit(req({ user }), res(), () => undefined);
38
+ // A leaked context would attribute the next request — or a background job —
39
+ // to whoever happened to come before it.
40
+ expect(currentAuditContext()).toBeUndefined();
41
+ });
42
+
43
+ it('continues without a context when nobody is authenticated', () => {
44
+ const next = jest.fn();
45
+ beginAudit(req(), res(), next);
46
+
47
+ expect(next).toHaveBeenCalled();
48
+ expect(recordRead).not.toHaveBeenCalled();
49
+ });
50
+
51
+ it('arms the read trail for the same request', () => {
52
+ beginAudit(req({ user }), res(), () => undefined);
53
+ expect(recordRead).toHaveBeenCalled();
54
+ });
55
+ });
56
+
57
+ describe('naming the service', () => {
58
+ it('uses SERVICE_NAME, as metrics and health already do', () => {
59
+ process.env.SERVICE_NAME = 'album-service';
60
+ let seen: string | undefined;
61
+
62
+ beginAudit(req({ user }), res(), () => {
63
+ seen = currentAuditContext()?.service;
64
+ });
65
+
66
+ expect(seen).toBe('album-service');
67
+ });
68
+
69
+ it('falls back to the package name, so local development is still labelled', () => {
70
+ // Nothing sets SERVICE_NAME in dev, and every row landing under one
71
+ // meaningless label makes the UI's area filter look broken.
72
+ delete process.env.SERVICE_NAME;
73
+ process.env.npm_package_name = 'finance-service';
74
+ let seen: string | undefined;
75
+
76
+ beginAudit(req({ user }), res(), () => {
77
+ seen = currentAuditContext()?.service;
78
+ });
79
+
80
+ expect(seen).toBe('finance-service');
81
+ });
82
+ });
83
+
84
+ describe('withAuditContext', () => {
85
+ it('runs work as an explicit actor, for jobs and tests', () => {
86
+ const actor = { kind: 'person' as const, userId: 'u1', label: 'Tom', tenant: PERSONAL_TENANT };
87
+ const seen = withAuditContext({ actor, service: 'test' }, () => currentAuditContext());
88
+
89
+ expect(seen?.actor.label).toBe('Tom');
90
+ });
91
+ });
@@ -0,0 +1,83 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import type { NextFunction, Request, RequestHandler, Response } from 'express';
3
+ import { actorOf, type Actor } from './actor';
4
+ import { recordRead } from './reads';
5
+
6
+ /**
7
+ * The actor, reachable from wherever the write actually happens.
8
+ *
9
+ * Attribution has to come from the request and be recorded at the data layer,
10
+ * and those are far apart: a Mongoose hook knows exactly which fields changed
11
+ * and nothing about who asked. Threading an actor through every service and
12
+ * controller signature to bridge that would touch every function between the
13
+ * two and be silently wrong the first time someone forgot.
14
+ *
15
+ * `AsyncLocalStorage` carries it instead, so the hook reads the actor without
16
+ * anything in between knowing it exists. A write outside a request — a script,
17
+ * a migration — simply finds no actor, which is the truth about it.
18
+ */
19
+ export interface AuditContext {
20
+ actor: Actor;
21
+ service: string;
22
+ }
23
+
24
+ const storage = new AsyncLocalStorage<AuditContext>();
25
+
26
+ export const currentAuditContext = (): AuditContext | undefined => storage.getStore();
27
+
28
+ /**
29
+ * Run the rest of a request with its actor in scope.
30
+ *
31
+ * Mounted after authentication, since there is no actor before it.
32
+ */
33
+ export const auditContext =
34
+ (service: string): RequestHandler =>
35
+ (req: Request, _res: Response, next: NextFunction): void => {
36
+ const actor = actorOf(req);
37
+ if (!actor) {
38
+ next();
39
+ return;
40
+ }
41
+
42
+ storage.run({ actor, service }, next);
43
+ };
44
+
45
+ /** Run something with an explicit actor — for jobs and tests. */
46
+ export const withAuditContext = <T>(context: AuditContext, fn: () => T): T =>
47
+ storage.run(context, fn);
48
+
49
+ /**
50
+ * The service this process is, for a trail that spans several of them.
51
+ *
52
+ * `SERVICE_NAME` is already how metrics, health and the internal-service clients
53
+ * identify a process, so auditing uses the same one rather than introducing a
54
+ * second name for the same thing.
55
+ *
56
+ * The fallback to the package name matters more here than elsewhere: the trail
57
+ * is filtered by service in the UI, and in local development nothing sets
58
+ * `SERVICE_NAME`, so every row would land under one meaningless label and the
59
+ * filter would look broken. Every service's package is named after it.
60
+ */
61
+ const serviceName = (): string =>
62
+ process.env.SERVICE_NAME || process.env.npm_package_name || 'unknown-service';
63
+
64
+ /**
65
+ * Put a request's actor in scope, and arm the read trail, for everything after.
66
+ *
67
+ * Called from `authenticateAgent` rather than mounted as its own middleware. A
68
+ * separate mount only works on a router that authenticates with
69
+ * `router.use(...)`; routers that authenticate per route — album's photos,
70
+ * finance's tickers — have no actor yet when a router-level middleware runs, so
71
+ * the context would be silently empty for exactly the endpoints most worth
72
+ * auditing. Doing it where authentication happens makes the two inseparable.
73
+ */
74
+ export function beginAudit(req: Request, res: Response, next: NextFunction): void {
75
+ const actor = actorOf(req);
76
+ if (!actor) {
77
+ next();
78
+ return;
79
+ }
80
+
81
+ recordRead(req, res, actor, serviceName());
82
+ storage.run({ actor, service: serviceName() }, next);
83
+ }
@@ -0,0 +1,11 @@
1
+ export { actorOf, describeActor } from './actor';
2
+ export type { Actor, ActorKind } from './actor';
3
+ export { AuditEvent } from './AuditEvent';
4
+ export type { IAuditEvent } from './AuditEvent';
5
+ export { auditContext, beginAudit, currentAuditContext, withAuditContext } from './context';
6
+ export type { AuditContext } from './context';
7
+ export { auditPlugin } from './plugin';
8
+ export type { AuditPluginOptions } from './plugin';
9
+ export { auditReads, countRead, resourceOf } from './reads';
10
+ export { listAuditEvents } from './service';
11
+ export type { AuditQuery, AuditEntry } from './service';