@tumbaland/backend-core 1.38.0 → 1.40.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 (77) hide show
  1. package/dist/apiKeys/ApiKey.d.ts +11 -0
  2. package/dist/apiKeys/ApiKey.d.ts.map +1 -1
  3. package/dist/apiKeys/ApiKey.js +14 -1
  4. package/dist/apiKeys/ApiKey.js.map +1 -1
  5. package/dist/apiKeys/middleware.d.ts +12 -0
  6. package/dist/apiKeys/middleware.d.ts.map +1 -1
  7. package/dist/apiKeys/middleware.js +16 -4
  8. package/dist/apiKeys/middleware.js.map +1 -1
  9. package/dist/apiKeys/service.d.ts.map +1 -1
  10. package/dist/apiKeys/service.js +1 -0
  11. package/dist/apiKeys/service.js.map +1 -1
  12. package/dist/apiKeys/types.d.ts +2 -0
  13. package/dist/apiKeys/types.d.ts.map +1 -1
  14. package/dist/audit/AuditEvent.d.ts +82 -0
  15. package/dist/audit/AuditEvent.d.ts.map +1 -0
  16. package/dist/audit/AuditEvent.js +77 -0
  17. package/dist/audit/AuditEvent.js.map +1 -0
  18. package/dist/audit/actor.d.ts +41 -0
  19. package/dist/audit/actor.d.ts.map +1 -0
  20. package/dist/audit/actor.js +39 -0
  21. package/dist/audit/actor.js.map +1 -0
  22. package/dist/audit/context.d.ts +40 -0
  23. package/dist/audit/context.d.ts.map +1 -0
  24. package/dist/audit/context.js +60 -0
  25. package/dist/audit/context.js.map +1 -0
  26. package/dist/audit/index.d.ts +12 -0
  27. package/dist/audit/index.d.ts.map +1 -0
  28. package/dist/audit/index.js +22 -0
  29. package/dist/audit/index.js.map +1 -0
  30. package/dist/audit/plugin.d.ts +23 -0
  31. package/dist/audit/plugin.d.ts.map +1 -0
  32. package/dist/audit/plugin.js +226 -0
  33. package/dist/audit/plugin.js.map +1 -0
  34. package/dist/audit/reads.d.ts +47 -0
  35. package/dist/audit/reads.d.ts.map +1 -0
  36. package/dist/audit/reads.js +94 -0
  37. package/dist/audit/reads.js.map +1 -0
  38. package/dist/audit/service.d.ts +51 -0
  39. package/dist/audit/service.d.ts.map +1 -0
  40. package/dist/audit/service.js +66 -0
  41. package/dist/audit/service.js.map +1 -0
  42. package/dist/index.d.ts +1 -0
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +1 -0
  45. package/dist/index.js.map +1 -1
  46. package/dist/oauth/models.d.ts.map +1 -1
  47. package/dist/oauth/models.js +10 -0
  48. package/dist/oauth/models.js.map +1 -1
  49. package/dist/oauth/tokens.d.ts +15 -0
  50. package/dist/oauth/tokens.d.ts.map +1 -1
  51. package/dist/oauth/tokens.js +5 -1
  52. package/dist/oauth/tokens.js.map +1 -1
  53. package/package.json +1 -1
  54. package/src/apiKeys/ApiKey.test.ts +25 -1
  55. package/src/apiKeys/ApiKey.ts +15 -0
  56. package/src/apiKeys/middleware.test.ts +26 -3
  57. package/src/apiKeys/middleware.ts +32 -5
  58. package/src/apiKeys/service.test.ts +2 -0
  59. package/src/apiKeys/service.ts +1 -0
  60. package/src/apiKeys/types.ts +2 -0
  61. package/src/audit/AuditEvent.ts +123 -0
  62. package/src/audit/actor.test.ts +95 -0
  63. package/src/audit/actor.ts +68 -0
  64. package/src/audit/context.test.ts +91 -0
  65. package/src/audit/context.ts +83 -0
  66. package/src/audit/index.ts +11 -0
  67. package/src/audit/plugin.test.ts +258 -0
  68. package/src/audit/plugin.ts +254 -0
  69. package/src/audit/reads.test.ts +164 -0
  70. package/src/audit/reads.ts +88 -0
  71. package/src/audit/service.test.ts +115 -0
  72. package/src/audit/service.ts +95 -0
  73. package/src/index.ts +1 -0
  74. package/src/middleware/authMiddleware.test.ts +3 -1
  75. package/src/oauth/models.ts +11 -0
  76. package/src/oauth/tokens.test.ts +2 -0
  77. package/src/oauth/tokens.ts +20 -1
@@ -0,0 +1,95 @@
1
+ import { AuditEvent, type IAuditEvent, type ToolCall } from './AuditEvent';
2
+ import type { ActorKind } from './actor';
3
+
4
+ export interface AuditQuery {
5
+ userId: string;
6
+ /** narrow to one actor — a key's id or an OAuth client id */
7
+ credentialId?: string;
8
+ actorKind?: ActorKind;
9
+ action?: IAuditEvent['action'];
10
+ resource?: string;
11
+ /** narrow to one area — 'relationship-service', 'album-service' */
12
+ service?: string;
13
+ from?: Date;
14
+ to?: Date;
15
+ limit?: number;
16
+ page?: number;
17
+ }
18
+
19
+ export interface AuditEntry {
20
+ id: string;
21
+ at: string;
22
+ service: string;
23
+ action: IAuditEvent['action'];
24
+ resource: string;
25
+ resourceId?: string;
26
+ actorKind: ActorKind;
27
+ actorLabel: string;
28
+ actorCredentialId?: string;
29
+ tenant: string;
30
+ changes?: Record<string, { from: unknown; to: unknown }>;
31
+ snapshot?: Record<string, unknown>;
32
+ count?: number;
33
+ /** present on 'call': which tool ran, how long it took, and what it cost */
34
+ tool?: ToolCall;
35
+ }
36
+
37
+ const toEntry = (event: IAuditEvent): AuditEntry => ({
38
+ id: String(event._id),
39
+ at: event.at.toISOString(),
40
+ service: event.service,
41
+ action: event.action,
42
+ resource: event.resource,
43
+ resourceId: event.resourceId,
44
+ actorKind: event.actorKind,
45
+ actorLabel: event.actorLabel,
46
+ actorCredentialId: event.actorCredentialId,
47
+ tenant: event.tenant,
48
+ changes: event.changes,
49
+ snapshot: event.snapshot,
50
+ count: event.count,
51
+ tool: event.tool
52
+ });
53
+
54
+ /**
55
+ * One account's trail, newest first.
56
+ *
57
+ * Always scoped to `userId` by the query rather than filtered afterwards, so
58
+ * there is no path where a wider read is one forgotten condition away. The trail
59
+ * carries old values of journal entries, which makes it as sensitive as the
60
+ * journal itself.
61
+ */
62
+ export const listAuditEvents = async (
63
+ query: AuditQuery
64
+ ): Promise<{ events: AuditEntry[]; total: number; page: number; totalPages: number }> => {
65
+ const limit = Math.min(query.limit ?? 50, 200);
66
+ const page = Math.max(query.page ?? 1, 1);
67
+
68
+ const filter: Record<string, unknown> = { userId: query.userId };
69
+ if (query.credentialId) filter.actorCredentialId = query.credentialId;
70
+ if (query.actorKind) filter.actorKind = query.actorKind;
71
+ if (query.action) filter.action = query.action;
72
+ if (query.resource) filter.resource = query.resource;
73
+ if (query.service) filter.service = query.service;
74
+ if (query.from || query.to) {
75
+ const at: Record<string, Date> = {};
76
+ if (query.from) at.$gte = query.from;
77
+ if (query.to) at.$lte = query.to;
78
+ filter.at = at;
79
+ }
80
+
81
+ const [events, total] = await Promise.all([
82
+ AuditEvent.find(filter)
83
+ .sort({ at: -1 })
84
+ .skip((page - 1) * limit)
85
+ .limit(limit),
86
+ AuditEvent.countDocuments(filter)
87
+ ]);
88
+
89
+ return {
90
+ events: events.map(toEntry),
91
+ total,
92
+ page,
93
+ totalPages: Math.ceil(total / limit)
94
+ };
95
+ };
package/src/index.ts CHANGED
@@ -35,6 +35,7 @@ export * from './apiKeys';
35
35
 
36
36
  // OAuth 2.1 authorization server, for assistants connecting over MCP
37
37
  export * from './oauth';
38
+ export * from './audit';
38
39
 
39
40
  // Middleware
40
41
  export { authenticateToken, optionalAuth } from './middleware/authMiddleware';
@@ -195,7 +195,9 @@ describe('OAuth access tokens are not sessions', () => {
195
195
  scopes: ['relationship:read'],
196
196
  tenants: [PERSONAL_TENANT],
197
197
  defaultTenant: PERSONAL_TENANT,
198
- tenantNames: {}
198
+ tenantNames: {},
199
+ clientId: 'client-1',
200
+ clientName: 'Claude'
199
201
  }).accessToken;
200
202
 
201
203
  it('refuses one on a session-only route', () => {
@@ -1,4 +1,5 @@
1
1
  import mongoose, { Document, Schema } from 'mongoose';
2
+ import { auditPlugin } from '../audit/plugin';
2
3
  import { PERSONAL_TENANT, type Tenant } from '../apiKeys/types';
3
4
 
4
5
  /**
@@ -148,6 +149,16 @@ const RefreshTokenSchema = new Schema<IRefreshToken>(
148
149
 
149
150
  RefreshTokenSchema.index({ userId: 1, createdAt: -1 });
150
151
 
152
+ /**
153
+ * A grant appearing and being revoked is the shape of "who connected Claude,
154
+ * and when did it stop" — the question the connections page answers for the
155
+ * present and this answers for the past.
156
+ *
157
+ * `tokenHash` is redacted: it is the only thing standing between a copy of this
158
+ * collection and a working refresh token.
159
+ */
160
+ RefreshTokenSchema.plugin(auditPlugin, { resource: 'connection', redact: ['tokenHash'] });
161
+
151
162
  export const RefreshToken = mongoose.models.RefreshToken
152
163
  ? (mongoose.models.RefreshToken as mongoose.Model<IRefreshToken>)
153
164
  : mongoose.model<IRefreshToken>('RefreshToken', RefreshTokenSchema);
@@ -18,6 +18,8 @@ const mint = (over: Partial<Parameters<typeof mintAccessToken>[0]> = {}) =>
18
18
  tenants: [PERSONAL_TENANT],
19
19
  defaultTenant: PERSONAL_TENANT,
20
20
  tenantNames: { [PERSONAL_TENANT]: 'My own data' },
21
+ clientId: 'client-1',
22
+ clientName: 'Claude',
21
23
  ...over
22
24
  });
23
25
 
@@ -49,6 +49,17 @@ export interface AccessTokenClaims {
49
49
  tenantNames: Record<Tenant, string>;
50
50
  /** the single tenant an older token was pinned to; read by `readTenants` */
51
51
  groupId?: string | null;
52
+ /**
53
+ * Which app this token was issued to.
54
+ *
55
+ * `jti` identifies the token and rotates every hour, so it cannot stand in for
56
+ * the connection — an audit trail built on it would show a different actor
57
+ * each time the assistant refreshed. The client id is stable for the life of
58
+ * the grant, and the name is snapshotted alongside it for the same reason the
59
+ * tenant names are: nothing downstream can look it up.
60
+ */
61
+ clientId: string;
62
+ clientName: string;
52
63
  email: string;
53
64
  name: string;
54
65
  typ: typeof ACCESS_TOKEN_TYPE;
@@ -68,6 +79,8 @@ export interface MintAccessTokenInput {
68
79
  tenants: Tenant[];
69
80
  defaultTenant: Tenant;
70
81
  tenantNames: Record<Tenant, string>;
82
+ clientId: string;
83
+ clientName: string;
71
84
  }
72
85
 
73
86
  export interface MintedAccessToken {
@@ -88,6 +101,8 @@ export const mintAccessToken = (input: MintAccessTokenInput): MintedAccessToken
88
101
  tenants: input.tenants,
89
102
  defaultTenant: input.defaultTenant,
90
103
  tenantNames: input.tenantNames,
104
+ clientId: input.clientId,
105
+ clientName: input.clientName,
91
106
  email: input.email,
92
107
  name: input.name,
93
108
  typ: ACCESS_TOKEN_TYPE,
@@ -109,6 +124,8 @@ export interface AccessTokenVerification {
109
124
  scopes?: ApiKeyScope[];
110
125
  tenants?: ApiKeyTenant;
111
126
  tenantNames?: Record<Tenant, string>;
127
+ clientId?: string;
128
+ clientName?: string;
112
129
  }
113
130
 
114
131
  /**
@@ -144,7 +161,9 @@ export const verifyAccessToken = (
144
161
  name: claims.name ?? '',
145
162
  scopes: (claims.scope ?? '').split(' ').filter(isApiKeyScope),
146
163
  tenants: readTenants(claims),
147
- tenantNames: claims.tenantNames ?? {}
164
+ tenantNames: claims.tenantNames ?? {},
165
+ clientId: claims.clientId,
166
+ clientName: claims.clientName
148
167
  };
149
168
  };
150
169