@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
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.RefreshToken = exports.AuthorizationCode = exports.OAuthClient = void 0;
37
37
  const mongoose_1 = __importStar(require("mongoose"));
38
+ const plugin_1 = require("../audit/plugin");
38
39
  const types_1 = require("../apiKeys/types");
39
40
  const OAuthClientSchema = new mongoose_1.Schema({
40
41
  clientId: { type: String, required: true, unique: true, index: true },
@@ -84,6 +85,15 @@ const RefreshTokenSchema = new mongoose_1.Schema({
84
85
  lastUsedAt: { type: Date }
85
86
  }, { timestamps: true, collection: 'oauth_refresh_tokens' });
86
87
  RefreshTokenSchema.index({ userId: 1, createdAt: -1 });
88
+ /**
89
+ * A grant appearing and being revoked is the shape of "who connected Claude,
90
+ * and when did it stop" — the question the connections page answers for the
91
+ * present and this answers for the past.
92
+ *
93
+ * `tokenHash` is redacted: it is the only thing standing between a copy of this
94
+ * collection and a working refresh token.
95
+ */
96
+ RefreshTokenSchema.plugin(plugin_1.auditPlugin, { resource: 'connection', redact: ['tokenHash'] });
87
97
  exports.RefreshToken = mongoose_1.default.models.RefreshToken
88
98
  ? mongoose_1.default.models.RefreshToken
89
99
  : mongoose_1.default.model('RefreshToken', RefreshTokenSchema);
@@ -1 +1 @@
1
- {"version":3,"file":"models.js","sourceRoot":"","sources":["../../src/oauth/models.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,qDAAsD;AACtD,4CAAgE;AAkBhE,MAAM,iBAAiB,GAAG,IAAI,iBAAM,CAClC;IACE,QAAQ,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE;IACrE,UAAU,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,SAAS,EAAE,GAAG,EAAE;IAC5D,YAAY,EAAE,EAAE,IAAI,EAAE,CAAC,MAAM,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE;CACjD,EACD,EAAE,UAAU,EAAE,IAAI,EAAE,UAAU,EAAE,eAAe,EAAE,CAClD,CAAC;AAEW,QAAA,WAAW,GAAG,kBAAQ,CAAC,MAAM,CAAC,WAAW;IACpD,CAAC,CAAE,kBAAQ,CAAC,MAAM,CAAC,WAA4C;IAC/D,CAAC,CAAC,kBAAQ,CAAC,KAAK,CAAe,aAAa,EAAE,iBAAiB,CAAC,CAAC;AAoCnE,MAAM,uBAAuB,GAAG,IAAI,iBAAM,CACxC;IACE,IAAI,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE;IACjE,QAAQ,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IAC1C,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IACxC,SAAS,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IAC3C,QAAQ,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE,EAAE;IACvC,WAAW,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IAC7C,MAAM,EAAE,EAAE,IAAI,EAAE,CAAC,MAAM,CAAC,EAAE,OAAO,EAAE,EAAE,EAAE;IACvC,OAAO,EAAE,EAAE,IAAI,EAAE,CAAC,MAAM,CAAC,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,CAAC,uBAAe,CAAC,EAAE;IAC7D,aAAa,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,uBAAe,EAAE;IACzD,WAAW,EAAE,EAAE,IAAI,EAAE,iBAAM,CAAC,KAAK,CAAC,KAAK,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE;IAC9D,mEAAmE;IACnE,OAAO,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE;IACxC,QAAQ,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IAC1C,aAAa,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IAC/C,MAAM,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE;IACtB,SAAS,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE;CAC1C,EACD,EAAE,UAAU,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,EAAE,UAAU,EAAE,2BAA2B,EAAE,CAC/F,CAAC;AAEF,+EAA+E;AAC/E,qEAAqE;AACrE,uBAAuB,CAAC,KAAK,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE,EAAE,EAAE,kBAAkB,EAAE,CAAC,EAAE,CAAC,CAAC;AAE9D,QAAA,iBAAiB,GAAG,kBAAQ,CAAC,MAAM,CAAC,iBAAiB;IAChE,CAAC,CAAE,kBAAQ,CAAC,MAAM,CAAC,iBAAwD;IAC3E,CAAC,CAAC,kBAAQ,CAAC,KAAK,CAAqB,mBAAmB,EAAE,uBAAuB,CAAC,CAAC;AAmCrF,MAAM,kBAAkB,GAAG,IAAI,iBAAM,CACnC;IACE,SAAS,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE;IACtE,QAAQ,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IAC1C,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE;IACrD,MAAM,EAAE,EAAE,IAAI,EAAE,CAAC,MAAM,CAAC,EAAE,OAAO,EAAE,EAAE,EAAE;IACvC,OAAO,EAAE,EAAE,IAAI,EAAE,CAAC,MAAM,CAAC,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,CAAC,uBAAe,CAAC,EAAE;IAC7D,aAAa,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,uBAAe,EAAE;IACzD,WAAW,EAAE,EAAE,IAAI,EAAE,iBAAM,CAAC,KAAK,CAAC,KAAK,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE;IAC9D,mEAAmE;IACnE,OAAO,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE;IACxC,QAAQ,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE;IAC1C,SAAS,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,CAAC,GAAG,EAAE;IAC5C,SAAS,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE;IACzB,UAAU,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE;CAC3B,EACD,EAAE,UAAU,EAAE,IAAI,EAAE,UAAU,EAAE,sBAAsB,EAAE,CACzD,CAAC;AAEF,kBAAkB,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC;AAE1C,QAAA,YAAY,GAAG,kBAAQ,CAAC,MAAM,CAAC,YAAY;IACtD,CAAC,CAAE,kBAAQ,CAAC,MAAM,CAAC,YAA8C;IACjE,CAAC,CAAC,kBAAQ,CAAC,KAAK,CAAgB,cAAc,EAAE,kBAAkB,CAAC,CAAC"}
1
+ {"version":3,"file":"models.js","sourceRoot":"","sources":["../../src/oauth/models.ts"],"names":[],"mappings":"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"}
@@ -34,6 +34,17 @@ export interface AccessTokenClaims {
34
34
  tenantNames: Record<Tenant, string>;
35
35
  /** the single tenant an older token was pinned to; read by `readTenants` */
36
36
  groupId?: string | null;
37
+ /**
38
+ * Which app this token was issued to.
39
+ *
40
+ * `jti` identifies the token and rotates every hour, so it cannot stand in for
41
+ * the connection — an audit trail built on it would show a different actor
42
+ * each time the assistant refreshed. The client id is stable for the life of
43
+ * the grant, and the name is snapshotted alongside it for the same reason the
44
+ * tenant names are: nothing downstream can look it up.
45
+ */
46
+ clientId: string;
47
+ clientName: string;
37
48
  email: string;
38
49
  name: string;
39
50
  typ: typeof ACCESS_TOKEN_TYPE;
@@ -52,6 +63,8 @@ export interface MintAccessTokenInput {
52
63
  tenants: Tenant[];
53
64
  defaultTenant: Tenant;
54
65
  tenantNames: Record<Tenant, string>;
66
+ clientId: string;
67
+ clientName: string;
55
68
  }
56
69
  export interface MintedAccessToken {
57
70
  accessToken: string;
@@ -68,6 +81,8 @@ export interface AccessTokenVerification {
68
81
  scopes?: ApiKeyScope[];
69
82
  tenants?: ApiKeyTenant;
70
83
  tenantNames?: Record<Tenant, string>;
84
+ clientId?: string;
85
+ clientName?: string;
71
86
  }
72
87
  /**
73
88
  * Check a bearer token presented to the MCP server.
@@ -1 +1 @@
1
- {"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../../src/oauth/tokens.ts"],"names":[],"mappings":"AAGA,OAAO,EAGL,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,KAAK,MAAM,EACZ,MAAM,kBAAkB,CAAC;AAE1B;;;;;;;;;GASG;AAEH,oFAAoF;AACpF,eAAO,MAAM,iBAAiB,eAAe,CAAC;AAK9C,MAAM,WAAW,iBAAiB;IAChC,2CAA2C;IAC3C,GAAG,EAAE,MAAM,CAAC;IACZ,gFAAgF;IAChF,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,yCAAyC;IACzC,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,gDAAgD;IAChD,aAAa,EAAE,MAAM,CAAC;IACtB;;;;;;;;OAQG;IACH,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,4EAA4E;IAC5E,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,OAAO,iBAAiB,CAAC;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,2DAA2D;IAC3D,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,WAAW,EAAE,CAAC;IACtB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACrC;AAED,MAAM,WAAW,iBAAiB;IAChC,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,eAAO,MAAM,eAAe,GAAI,OAAO,oBAAoB,KAAG,iBAsB7D,CAAC;AAEF,MAAM,WAAW,uBAAuB;IACtC,EAAE,EAAE,OAAO,CAAC;IACZ,SAAS,CAAC,EAAE,WAAW,GAAG,SAAS,GAAG,gBAAgB,GAAG,YAAY,GAAG,eAAe,CAAC;IACxF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,WAAW,EAAE,CAAC;IACvB,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACtC;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,iBAAiB,GAC5B,OAAO,MAAM,EACb,kBAAkB,MAAM,KACvB,uBAsBF,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,GAAI,QAAQ,OAAO,KAAG,MAAM,IAAI,iBAGP,CAAC;AAE1D,qFAAqF;AACrF,eAAO,MAAM,WAAW,GAAI,OAAO,OAAO,KAAG,WAAW,EACmB,CAAC"}
1
+ {"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../../src/oauth/tokens.ts"],"names":[],"mappings":"AAGA,OAAO,EAGL,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,KAAK,MAAM,EACZ,MAAM,kBAAkB,CAAC;AAE1B;;;;;;;;;GASG;AAEH,oFAAoF;AACpF,eAAO,MAAM,iBAAiB,eAAe,CAAC;AAK9C,MAAM,WAAW,iBAAiB;IAChC,2CAA2C;IAC3C,GAAG,EAAE,MAAM,CAAC;IACZ,gFAAgF;IAChF,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,yCAAyC;IACzC,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,gDAAgD;IAChD,aAAa,EAAE,MAAM,CAAC;IACtB;;;;;;;;OAQG;IACH,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,4EAA4E;IAC5E,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB;;;;;;;;OAQG;IACH,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,OAAO,iBAAiB,CAAC;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,2DAA2D;IAC3D,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,WAAW,EAAE,CAAC;IACtB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,iBAAiB;IAChC,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,eAAO,MAAM,eAAe,GAAI,OAAO,oBAAoB,KAAG,iBAwB7D,CAAC;AAEF,MAAM,WAAW,uBAAuB;IACtC,EAAE,EAAE,OAAO,CAAC;IACZ,SAAS,CAAC,EAAE,WAAW,GAAG,SAAS,GAAG,gBAAgB,GAAG,YAAY,GAAG,eAAe,CAAC;IACxF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,WAAW,EAAE,CAAC;IACvB,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACrC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,iBAAiB,GAC5B,OAAO,MAAM,EACb,kBAAkB,MAAM,KACvB,uBAwBF,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,GAAI,QAAQ,OAAO,KAAG,MAAM,IAAI,iBAGP,CAAC;AAE1D,qFAAqF;AACrF,eAAO,MAAM,WAAW,GAAI,OAAO,OAAO,KAAG,WAAW,EACmB,CAAC"}
@@ -32,6 +32,8 @@ const mintAccessToken = (input) => {
32
32
  tenants: input.tenants,
33
33
  defaultTenant: input.defaultTenant,
34
34
  tenantNames: input.tenantNames,
35
+ clientId: input.clientId,
36
+ clientName: input.clientName,
35
37
  email: input.email,
36
38
  name: input.name,
37
39
  typ: exports.ACCESS_TOKEN_TYPE,
@@ -72,7 +74,9 @@ const verifyAccessToken = (token, expectedAudience) => {
72
74
  name: claims.name ?? '',
73
75
  scopes: (claims.scope ?? '').split(' ').filter(types_1.isApiKeyScope),
74
76
  tenants: (0, types_1.readTenants)(claims),
75
- tenantNames: claims.tenantNames ?? {}
77
+ tenantNames: claims.tenantNames ?? {},
78
+ clientId: claims.clientId,
79
+ clientName: claims.clientName
76
80
  };
77
81
  };
78
82
  exports.verifyAccessToken = verifyAccessToken;
@@ -1 +1 @@
1
- {"version":3,"file":"tokens.js","sourceRoot":"","sources":["../../src/oauth/tokens.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"tokens.js","sourceRoot":"","sources":["../../src/oauth/tokens.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tumbaland/backend-core",
3
- "version": "1.38.0",
3
+ "version": "1.40.0",
4
4
  "description": "Core shared functionality for Tumbaland backend services",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -1,4 +1,4 @@
1
- import { ApiKey } from './ApiKey';
1
+ import { ApiKey, API_KEY_SECRET_FIELDS } from './ApiKey';
2
2
 
3
3
  /** The fields the schema insists on, so each test only varies what it is about. */
4
4
  const complete = {
@@ -116,3 +116,27 @@ describe('model registration', () => {
116
116
  expect(again).toBe(ApiKey);
117
117
  });
118
118
  });
119
+
120
+ describe('what a key’s audit trail may carry', () => {
121
+ /**
122
+ * The trail is readable by the account owner and kept for a year. A snapshot
123
+ * carrying the hash or the sealed ciphertext would copy credential material
124
+ * into a second collection with a different lifetime — which is exactly what
125
+ * the encryption exists to prevent.
126
+ */
127
+ it('redacts every field on the schema that holds a secret', () => {
128
+ const secretish = Object.keys(ApiKey.schema.paths).filter((path) =>
129
+ /hash|sealed|secret/i.test(path)
130
+ );
131
+
132
+ // Compared against the schema rather than a hand-written list, so a secret
133
+ // field added later fails here instead of quietly reaching the trail.
134
+ expect(secretish.sort()).toEqual([...API_KEY_SECRET_FIELDS].sort());
135
+ });
136
+
137
+ it('keeps the fields that make a row worth reading', () => {
138
+ for (const field of ['name', 'scopes', 'tenants', 'revokedAt']) {
139
+ expect(API_KEY_SECRET_FIELDS).not.toContain(field);
140
+ }
141
+ });
142
+ });
@@ -1,4 +1,5 @@
1
1
  import mongoose, { Document, Schema } from 'mongoose';
2
+ import { auditPlugin } from '../audit/plugin';
2
3
  import { PERSONAL_TENANT, type ApiKeyScope, type Tenant } from './types';
3
4
 
4
5
  /**
@@ -82,6 +83,20 @@ const ApiKeySchema = new Schema<IApiKey>(
82
83
  // Every listing is one user's keys, newest first.
83
84
  ApiKeySchema.index({ userId: 1, createdAt: -1 });
84
85
 
86
+ /**
87
+ * Issuing and revoking a key is worth a record — arguably more than anything a
88
+ * key goes on to do, since a credential nobody remembers creating is the one
89
+ * that matters.
90
+ *
91
+ * Every secret-bearing field is redacted. The trail is readable by the account
92
+ * owner and kept for a year; a snapshot carrying `hash` or the sealed
93
+ * ciphertext would put credential material into a second collection with a
94
+ * different lifetime, which is precisely the thing the encryption is for.
95
+ */
96
+ export const API_KEY_SECRET_FIELDS = ['hash', 'sealedCiphertext', 'sealedIv', 'sealedTag'];
97
+
98
+ ApiKeySchema.plugin(auditPlugin, { resource: 'api_key', redact: API_KEY_SECRET_FIELDS });
99
+
85
100
  export const ApiKey = mongoose.models.ApiKey
86
101
  ? (mongoose.models.ApiKey as mongoose.Model<IApiKey>)
87
102
  : mongoose.model<IApiKey>('ApiKey', ApiKeySchema);
@@ -39,6 +39,7 @@ const okVerification = (over: Record<string, unknown> = {}) => ({
39
39
  keyId: 'k1',
40
40
  scopes: ['relationship:read', 'relationship:write'],
41
41
  tenants: grant(PERSONAL_TENANT),
42
+ label: 'Claude Code',
42
43
  ...over
43
44
  });
44
45
 
@@ -116,7 +117,11 @@ describe('authenticateAgent — API keys', () => {
116
117
  keyId: 'k1',
117
118
  scopes: ['relationship:read', 'relationship:write'],
118
119
  tenants: grant(PERSONAL_TENANT),
119
- actingAs: PERSONAL_TENANT
120
+ actingAs: PERSONAL_TENANT,
121
+ kind: 'api_key',
122
+ // What the owner called it, so a trail reads "Claude Code" not an id.
123
+ label: 'Claude Code',
124
+ credentialId: 'k1'
120
125
  });
121
126
  });
122
127
 
@@ -341,7 +346,15 @@ describe('authenticateAgent — a multi-tenant grant', () => {
341
346
  describe('requireScope', () => {
342
347
  const keyReq = (scopes: string[]) =>
343
348
  req({
344
- apiKey: { keyId: 'k1', scopes, tenants: grant(PERSONAL_TENANT), actingAs: PERSONAL_TENANT }
349
+ apiKey: {
350
+ keyId: 'k1',
351
+ scopes,
352
+ tenants: grant(PERSONAL_TENANT),
353
+ actingAs: PERSONAL_TENANT,
354
+ kind: 'api_key' as const,
355
+ label: 'A key',
356
+ credentialId: 'k1'
357
+ }
345
358
  } as Partial<Request>);
346
359
 
347
360
  it('lets a session through unconditionally', () => {
@@ -421,7 +434,15 @@ describe('denyApiKeys', () => {
421
434
 
422
435
  denyApiKeys(
423
436
  req({
424
- apiKey: { keyId: 'k1', scopes: [], tenants: grant(PERSONAL_TENANT), actingAs: PERSONAL_TENANT }
437
+ apiKey: {
438
+ keyId: 'k1',
439
+ scopes: [],
440
+ tenants: grant(PERSONAL_TENANT),
441
+ actingAs: PERSONAL_TENANT,
442
+ kind: 'api_key' as const,
443
+ label: 'A key',
444
+ credentialId: 'k1'
445
+ }
425
446
  }) as Request,
426
447
  res,
427
448
  next
@@ -451,6 +472,8 @@ describe('authenticateAgent — OAuth access tokens', () => {
451
472
  tenants: [PERSONAL_TENANT],
452
473
  defaultTenant: PERSONAL_TENANT,
453
474
  tenantNames: { [PERSONAL_TENANT]: 'My own data' },
475
+ clientId: 'client-1',
476
+ clientName: 'Claude',
454
477
  ...over
455
478
  }).accessToken;
456
479
 
@@ -2,6 +2,7 @@ import { NextFunction, Request, RequestHandler, Response } from 'express';
2
2
  import jwt from 'jsonwebtoken';
3
3
  import { requireEnv } from '../config/env';
4
4
  import logger from '../logging/logger';
5
+ import { beginAudit } from '../audit/context';
5
6
  import { isAccessTokenClaims, type AccessTokenClaims } from '../oauth/tokens';
6
7
  import type { UserPayload } from '../types/auth';
7
8
  import { looksLikeApiKey } from './crypto';
@@ -28,6 +29,18 @@ export interface ApiKeyContext {
28
29
  tenants: ApiKeyTenant;
29
30
  /** the tenant this particular request resolved to */
30
31
  actingAs: Tenant;
32
+ /** which credential this is; they are revoked and audited differently */
33
+ kind: 'api_key' | 'oauth';
34
+ /** what the owner called it — a key's name, or the connected app's */
35
+ label: string;
36
+ /**
37
+ * The stable identity of the credential.
38
+ *
39
+ * A key's document id, or an OAuth client id — never the access token's
40
+ * `jti`, which rotates hourly and would make one connection look like a new
41
+ * actor every time it refreshed.
42
+ */
43
+ credentialId: string;
31
44
  }
32
45
 
33
46
  declare global {
@@ -113,8 +126,11 @@ const applyTenantGrant = (req: Request, tenants: ApiKeyTenant): Tenant | null =>
113
126
 
114
127
  /** A verified non-session credential, whichever kind it arrived as. */
115
128
  interface AgentCredential {
116
- /** the key id or token id, for the request-scoped context */
129
+ /** the key's document id, or the OAuth client id */
117
130
  credentialId: string;
131
+ kind: 'api_key' | 'oauth';
132
+ /** what the owner called it, for a trail a person can read */
133
+ label: string;
118
134
  userId: string;
119
135
  email: string;
120
136
  name: string;
@@ -165,10 +181,16 @@ function admitAgent(
165
181
  keyId: credential.credentialId,
166
182
  scopes: credential.scopes,
167
183
  tenants: credential.tenants,
168
- actingAs
184
+ actingAs,
185
+ kind: credential.kind,
186
+ label: credential.label,
187
+ credentialId: credential.credentialId
169
188
  };
170
189
 
171
- next();
190
+ // Attribution comes from authentication, so it is established here rather
191
+ // than mounted separately — a router that authenticates per route would
192
+ // otherwise have no actor in scope when a router-level middleware ran.
193
+ beginAudit(req, res, next);
172
194
  }
173
195
 
174
196
  /**
@@ -204,7 +226,7 @@ export const authenticateAgent = (...requiredScopes: ApiKeyScope[]): RequestHand
204
226
  const user = claims as UserPayload;
205
227
  req.user = user;
206
228
  req.userGroups = user.groups ?? [];
207
- next();
229
+ beginAudit(req, res, next);
208
230
  return;
209
231
  }
210
232
 
@@ -219,7 +241,10 @@ export const authenticateAgent = (...requiredScopes: ApiKeyScope[]): RequestHand
219
241
  // whose scope collapsed to nothing fell through to "no user context" and
220
242
  // answered from the whole collection.
221
243
  admitAgent(req, res, next, requiredScopes, {
222
- credentialId: claims.jti,
244
+ // The client, not the token: `jti` is a new value every hour.
245
+ credentialId: claims.clientId ?? 'unknown-client',
246
+ kind: 'oauth',
247
+ label: claims.clientName || 'A connected app',
223
248
  userId: claims.sub,
224
249
  email: claims.email ?? '',
225
250
  name: claims.name ?? '',
@@ -240,6 +265,8 @@ export const authenticateAgent = (...requiredScopes: ApiKeyScope[]): RequestHand
240
265
 
241
266
  admitAgent(req, res, next, requiredScopes, {
242
267
  credentialId: result.keyId!,
268
+ kind: 'api_key',
269
+ label: result.label || 'An API key',
243
270
  userId: result.userId!,
244
271
  email: result.userEmail ?? '',
245
272
  name: result.userName ?? '',
@@ -346,6 +346,8 @@ describe('verifyApiKey', () => {
346
346
  userName: 'Tester',
347
347
  keyId: 'k1',
348
348
  scopes: ['relationship:read', 'album:write'],
349
+ // The key's own name, so an audit trail reads "Claude" and not "k1".
350
+ label: 'Claude',
349
351
  tenants: { allowed: [PERSONAL_TENANT, 'g1'], default: 'g1' }
350
352
  });
351
353
  });
@@ -161,6 +161,7 @@ export const verifyApiKey = async (token: unknown): Promise<ApiKeyVerification>
161
161
  userEmail: key.userEmail,
162
162
  userName: key.userName,
163
163
  keyId: String(key._id),
164
+ label: key.name,
164
165
  scopes: key.scopes,
165
166
  tenants: readTenants(key)
166
167
  };
@@ -117,6 +117,8 @@ export interface ApiKeyVerification {
117
117
  userEmail?: string;
118
118
  userName?: string;
119
119
  keyId?: string;
120
+ /** what the owner called this key, for an audit trail a person can read */
121
+ label?: string;
120
122
  scopes?: ApiKeyScope[];
121
123
  tenants?: ApiKeyTenant;
122
124
  }
@@ -0,0 +1,123 @@
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);
@@ -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})`;