@zapier/zapier-sdk 0.15.3 → 0.15.4

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.
@@ -6,14 +6,17 @@
6
6
  */
7
7
  import { createTransport } from "./transport";
8
8
  import { generateEventId, getCurrentTimestamp, getReleaseId } from "./utils";
9
+ import { extractUserIdsFromJwt } from "../../api/auth";
9
10
  import { buildApplicationLifecycleEvent, buildErrorEventWithContext, } from "./builders";
10
11
  import { getTrackingBaseUrl } from "../../utils/url-utils";
12
+ // Maximum time to wait for telemetry emission before allowing process to exit
13
+ const TELEMETRY_EMIT_TIMEOUT_MS = 300;
11
14
  const APPLICATION_LIFECYCLE_EVENT_SUBJECT = "platform.sdk.ApplicationLifecycleEvent";
12
15
  const ERROR_OCCURRED_EVENT_SUBJECT = "platform.sdk.ErrorOccurredEvent";
13
16
  // Track transport success/failure so we only log failure once.
14
17
  const transportStates = new WeakMap();
15
18
  // Silent emission wrapper with smart first-failure logging
16
- async function silentEmit(transport, subject, event) {
19
+ async function silentEmit(transport, subject, event, userContextPromise) {
17
20
  try {
18
21
  // Get or initialize state for this transport
19
22
  let state = transportStates.get(transport);
@@ -21,9 +24,21 @@ async function silentEmit(transport, subject, event) {
21
24
  state = { hasWorked: false, hasLoggedFailure: false };
22
25
  transportStates.set(transport, state);
23
26
  }
27
+ // Resolve user context and merge into event
28
+ let enrichedEvent = event;
29
+ if (userContextPromise) {
30
+ try {
31
+ const userContext = await userContextPromise;
32
+ // Use Object.assign to safely merge user context into event
33
+ enrichedEvent = Object.assign({}, event, userContext);
34
+ }
35
+ catch {
36
+ // If user context promise fails, continue with original event
37
+ }
38
+ }
24
39
  // Fire and forget - don't await the transport
25
40
  transport
26
- .emit(subject, event)
41
+ .emit(subject, enrichedEvent)
27
42
  .then(() => {
28
43
  // Mark as working if any emit succeeds
29
44
  state.hasWorked = true;
@@ -76,6 +91,24 @@ export const eventEmissionPlugin = ({ context }) => {
76
91
  : // Otherwise, use option transport or default
77
92
  (context.options.eventEmission?.transport ?? defaultTransport),
78
93
  };
94
+ // Create getUserContext promise for dynamic user context injection
95
+ const getUserContext = (async () => {
96
+ try {
97
+ // Dynamically import the CLI login package if available
98
+ const { getToken } = await import("@zapier/zapier-sdk-cli-login");
99
+ // Pass baseUrl for potential token refresh operations
100
+ const token = await getToken({
101
+ baseUrl: context.options.baseUrl,
102
+ });
103
+ if (token) {
104
+ return extractUserIdsFromJwt(token);
105
+ }
106
+ }
107
+ catch {
108
+ // CLI login package not available or getToken failed, fall back to null context
109
+ }
110
+ return { customuser_id: null, account_id: null };
111
+ })();
79
112
  const startupTime = Date.now();
80
113
  let shutdownStartTime = null;
81
114
  // If disabled, return noop implementations
@@ -86,7 +119,7 @@ export const eventEmissionPlugin = ({ context }) => {
86
119
  transport: createTransport({ type: "noop" }),
87
120
  config,
88
121
  emit: () => { },
89
- createBaseEvent: () => ({
122
+ createBaseEvent: async () => ({
90
123
  event_id: generateEventId(),
91
124
  timestamp_ms: getCurrentTimestamp(),
92
125
  release_id: getReleaseId(),
@@ -109,23 +142,34 @@ export const eventEmissionPlugin = ({ context }) => {
109
142
  transport = createTransport({ type: "noop" });
110
143
  }
111
144
  // Helper to create base event
112
- const createBaseEventHelper = () => ({
113
- event_id: generateEventId(),
114
- timestamp_ms: getCurrentTimestamp(),
115
- release_id: getReleaseId(),
116
- customuser_id: null,
117
- account_id: null,
118
- identity_id: null,
119
- visitor_id: null,
120
- correlation_id: null,
121
- });
145
+ const createBaseEventHelper = async () => {
146
+ const baseEvent = {
147
+ event_id: generateEventId(),
148
+ timestamp_ms: getCurrentTimestamp(),
149
+ release_id: getReleaseId(),
150
+ customuser_id: null,
151
+ account_id: null,
152
+ identity_id: null,
153
+ visitor_id: null,
154
+ correlation_id: null,
155
+ };
156
+ // Enrich with user context if available
157
+ try {
158
+ const userContext = await getUserContext;
159
+ return { ...baseEvent, ...userContext };
160
+ }
161
+ catch {
162
+ // Return base event if user context fails
163
+ return baseEvent;
164
+ }
165
+ };
122
166
  // Register lifecycle event handlers if enabled
123
167
  if (config.enabled) {
124
168
  // Emit startup event
125
169
  const startupEvent = buildApplicationLifecycleEvent({
126
170
  lifecycle_event_type: "startup",
127
171
  });
128
- silentEmit(transport, APPLICATION_LIFECYCLE_EVENT_SUBJECT, startupEvent);
172
+ silentEmit(transport, APPLICATION_LIFECYCLE_EVENT_SUBJECT, startupEvent, getUserContext);
129
173
  // Register process event handlers (Node.js only)
130
174
  if (typeof process?.on === "function") {
131
175
  // Handle normal process exit
@@ -141,11 +185,11 @@ export const eventEmissionPlugin = ({ context }) => {
141
185
  is_graceful_shutdown: code === 0,
142
186
  shutdown_duration_ms: shutdownDuration,
143
187
  });
144
- silentEmit(transport, APPLICATION_LIFECYCLE_EVENT_SUBJECT, exitEvent);
188
+ silentEmit(transport, APPLICATION_LIFECYCLE_EVENT_SUBJECT, exitEvent, getUserContext);
145
189
  });
146
190
  // Handle uncaught exceptions
147
191
  process.on("uncaughtException", async (error) => {
148
- const errorEvent = buildErrorEventWithContext({
192
+ let errorEvent = buildErrorEventWithContext({
149
193
  error_message: error.message || "Unknown error",
150
194
  error_type: "UncaughtException",
151
195
  error_stack_trace: error.stack || null,
@@ -154,11 +198,19 @@ export const eventEmissionPlugin = ({ context }) => {
154
198
  is_recoverable: false,
155
199
  execution_start_time: startupTime,
156
200
  });
201
+ // Enrich with user context if available
202
+ try {
203
+ const userContext = await getUserContext;
204
+ errorEvent = { ...errorEvent, ...userContext };
205
+ }
206
+ catch {
207
+ // Continue with original event if user context fails
208
+ }
157
209
  // Wait up to 300ms for telemetry to send before allowing process to exit
158
210
  try {
159
211
  await Promise.race([
160
212
  transport.emit(ERROR_OCCURRED_EVENT_SUBJECT, errorEvent),
161
- new Promise((resolve) => setTimeout(resolve, 300)),
213
+ new Promise((resolve) => setTimeout(resolve, TELEMETRY_EMIT_TIMEOUT_MS)),
162
214
  ]);
163
215
  }
164
216
  catch {
@@ -174,7 +226,7 @@ export const eventEmissionPlugin = ({ context }) => {
174
226
  ? reason
175
227
  : "Unhandled promise rejection";
176
228
  const errorStack = reason instanceof Error ? reason.stack : null;
177
- const errorEvent = buildErrorEventWithContext({
229
+ let errorEvent = buildErrorEventWithContext({
178
230
  error_message: errorMessage,
179
231
  error_type: "UnhandledRejection",
180
232
  error_stack_trace: errorStack,
@@ -186,11 +238,19 @@ export const eventEmissionPlugin = ({ context }) => {
186
238
  promise: String(promise),
187
239
  },
188
240
  });
241
+ // Enrich with user context if available
242
+ try {
243
+ const userContext = await getUserContext;
244
+ errorEvent = { ...errorEvent, ...userContext };
245
+ }
246
+ catch {
247
+ // Continue with original event if user context fails
248
+ }
189
249
  // Wait up to 300ms for telemetry to send
190
250
  try {
191
251
  await Promise.race([
192
252
  transport.emit(ERROR_OCCURRED_EVENT_SUBJECT, errorEvent),
193
- new Promise((resolve) => setTimeout(resolve, 300)),
253
+ new Promise((resolve) => setTimeout(resolve, TELEMETRY_EMIT_TIMEOUT_MS)),
194
254
  ]);
195
255
  }
196
256
  catch {
@@ -201,17 +261,25 @@ export const eventEmissionPlugin = ({ context }) => {
201
261
  const handleSignal = async (signal) => {
202
262
  shutdownStartTime = Date.now();
203
263
  const uptime = Date.now() - startupTime;
204
- const signalEvent = buildApplicationLifecycleEvent({
264
+ let signalEvent = buildApplicationLifecycleEvent({
205
265
  lifecycle_event_type: "signal_termination",
206
266
  signal_name: signal,
207
267
  uptime_ms: uptime,
208
268
  is_graceful_shutdown: true,
209
269
  });
270
+ // Enrich with user context if available
271
+ try {
272
+ const userContext = await getUserContext;
273
+ signalEvent = { ...signalEvent, ...userContext };
274
+ }
275
+ catch {
276
+ // Continue with original event if user context fails
277
+ }
210
278
  // Wait up to 300ms for telemetry to send
211
279
  try {
212
280
  await Promise.race([
213
281
  transport.emit(APPLICATION_LIFECYCLE_EVENT_SUBJECT, signalEvent),
214
- new Promise((resolve) => setTimeout(resolve, 300)),
282
+ new Promise((resolve) => setTimeout(resolve, TELEMETRY_EMIT_TIMEOUT_MS)),
215
283
  ]);
216
284
  }
217
285
  catch {
@@ -231,7 +299,7 @@ export const eventEmissionPlugin = ({ context }) => {
231
299
  transport,
232
300
  config,
233
301
  emit: (subject, event) => {
234
- silentEmit(transport, subject, event);
302
+ silentEmit(transport, subject, event, getUserContext);
235
303
  },
236
304
  createBaseEvent: createBaseEventHelper,
237
305
  },
@@ -12,9 +12,16 @@ const mockTransport = {
12
12
  vi.mock("./transport", () => ({
13
13
  createTransport: vi.fn(() => mockTransport),
14
14
  }));
15
+ // Mock CLI login package - default to returning null context
16
+ const mockGetToken = vi.fn().mockResolvedValue(undefined);
17
+ vi.mock("@zapier/zapier-sdk-cli-login", () => ({
18
+ getToken: mockGetToken,
19
+ }));
15
20
  describe("eventEmissionPlugin", () => {
16
21
  beforeEach(() => {
17
22
  vi.clearAllMocks();
23
+ // Reset to default behavior - no token available
24
+ mockGetToken.mockResolvedValue(undefined);
18
25
  });
19
26
  it("should create plugin with default configuration", () => {
20
27
  const plugin = eventEmissionPlugin({
@@ -64,8 +71,13 @@ describe("eventEmissionPlugin", () => {
64
71
  const testSubject = "test.event.TestEvent";
65
72
  plugin.context.eventEmission.emit(testSubject, testEvent);
66
73
  // Give async emission time to complete
67
- await new Promise((resolve) => setTimeout(resolve, 0));
68
- expect(mockTransport.emit).toHaveBeenCalledWith(testSubject, testEvent);
74
+ await new Promise((resolve) => setTimeout(resolve, 50));
75
+ // The event will be enriched with user context (null values)
76
+ expect(mockTransport.emit).toHaveBeenCalledWith(testSubject, {
77
+ ...testEvent,
78
+ customuser_id: null,
79
+ account_id: null,
80
+ });
69
81
  });
70
82
  it("should handle transport creation failures silently", () => {
71
83
  // Mock createTransport to throw an error
@@ -220,6 +232,8 @@ describe("eventEmissionPlugin", () => {
220
232
  mockConsoleWarn.mockRestore();
221
233
  });
222
234
  it("should merge options with defaults", () => {
235
+ // Override env var to ensure proper transport is used
236
+ vi.stubEnv("ZAPIER_SDK_TELEMETRY_TRANSPORT", undefined);
223
237
  const plugin = eventEmissionPlugin({
224
238
  sdk: {},
225
239
  context: {
@@ -239,5 +253,168 @@ describe("eventEmissionPlugin", () => {
239
253
  type: "http",
240
254
  endpoint: "https://example.com",
241
255
  });
256
+ vi.unstubAllEnvs();
257
+ });
258
+ it("should extract user IDs from JWT token and include in events", async () => {
259
+ // Create a test JWT token with user data
260
+ // JWT format: header.payload.signature
261
+ const header = { alg: "HS256", typ: "JWT" };
262
+ const payload = {
263
+ "zap:acc": "12345",
264
+ sub: "67890",
265
+ sub_type: "customuser",
266
+ "zap:uname": "test@example.com",
267
+ };
268
+ const encodedHeader = Buffer.from(JSON.stringify(header)).toString("base64url");
269
+ const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url");
270
+ const testJwt = `${encodedHeader}.${encodedPayload}.fake-signature`;
271
+ // Mock getToken to return the JWT
272
+ mockGetToken.mockResolvedValue(testJwt);
273
+ const plugin = eventEmissionPlugin({
274
+ sdk: {},
275
+ context: {
276
+ meta: {},
277
+ options: {
278
+ eventEmission: {
279
+ enabled: true,
280
+ transport: { type: "console" },
281
+ },
282
+ },
283
+ },
284
+ });
285
+ // Test that createBaseEvent includes the extracted user IDs
286
+ const baseEvent = await plugin.context.eventEmission.createBaseEvent();
287
+ expect(baseEvent.customuser_id).toBe(67890);
288
+ expect(baseEvent.account_id).toBe(12345);
289
+ });
290
+ it("should handle service tokens with nested JWT", async () => {
291
+ // Create a nested JWT for service token testing
292
+ const nestedHeader = { alg: "HS256", typ: "JWT" };
293
+ const nestedPayload = {
294
+ "zap:acc": "99999",
295
+ sub: "88888",
296
+ sub_type: "customuser",
297
+ };
298
+ const nestedEncodedHeader = Buffer.from(JSON.stringify(nestedHeader)).toString("base64url");
299
+ const nestedEncodedPayload = Buffer.from(JSON.stringify(nestedPayload)).toString("base64url");
300
+ const nestedJwt = `${nestedEncodedHeader}.${nestedEncodedPayload}.nested-signature`;
301
+ // Create the service token that wraps the nested JWT
302
+ const serviceHeader = { alg: "HS256", typ: "JWT" };
303
+ const servicePayload = {
304
+ "zap:acc": "11111",
305
+ sub: "22222",
306
+ sub_type: "service",
307
+ njwt: nestedJwt,
308
+ };
309
+ const serviceEncodedHeader = Buffer.from(JSON.stringify(serviceHeader)).toString("base64url");
310
+ const serviceEncodedPayload = Buffer.from(JSON.stringify(servicePayload)).toString("base64url");
311
+ const serviceJwt = `${serviceEncodedHeader}.${serviceEncodedPayload}.service-signature`;
312
+ // Mock getToken to return the service JWT
313
+ mockGetToken.mockResolvedValue(serviceJwt);
314
+ const plugin = eventEmissionPlugin({
315
+ sdk: {},
316
+ context: {
317
+ meta: {},
318
+ options: {
319
+ eventEmission: {
320
+ enabled: true,
321
+ transport: { type: "console" },
322
+ },
323
+ },
324
+ },
325
+ });
326
+ const baseEvent = await plugin.context.eventEmission.createBaseEvent();
327
+ // Should extract from nested JWT, not the service token
328
+ expect(baseEvent.customuser_id).toBe(88888);
329
+ expect(baseEvent.account_id).toBe(99999);
330
+ });
331
+ it("should handle invalid JWT tokens gracefully", async () => {
332
+ // Mock getToken to return an invalid JWT
333
+ mockGetToken.mockResolvedValue("not-a-valid-jwt-token");
334
+ const plugin = eventEmissionPlugin({
335
+ sdk: {},
336
+ context: {
337
+ meta: {},
338
+ options: {
339
+ eventEmission: {
340
+ enabled: true,
341
+ transport: { type: "console" },
342
+ },
343
+ },
344
+ },
345
+ });
346
+ const baseEvent = await plugin.context.eventEmission.createBaseEvent();
347
+ // Should default to null when JWT is invalid
348
+ expect(baseEvent.customuser_id).toBe(null);
349
+ expect(baseEvent.account_id).toBe(null);
350
+ });
351
+ it("should handle missing token gracefully", async () => {
352
+ // mockGetToken defaults to returning undefined (no token)
353
+ const plugin = eventEmissionPlugin({
354
+ sdk: {},
355
+ context: {
356
+ meta: {},
357
+ options: {
358
+ eventEmission: {
359
+ enabled: true,
360
+ transport: { type: "console" },
361
+ },
362
+ },
363
+ },
364
+ });
365
+ const baseEvent = await plugin.context.eventEmission.createBaseEvent();
366
+ // Should default to null when no token is provided
367
+ expect(baseEvent.customuser_id).toBe(null);
368
+ expect(baseEvent.account_id).toBe(null);
369
+ });
370
+ it("should extract user IDs when getToken returns valid JWT", async () => {
371
+ // Create a test JWT token
372
+ const header = { alg: "HS256", typ: "JWT" };
373
+ const payload = {
374
+ "zap:acc": "98765",
375
+ sub: "54321",
376
+ sub_type: "customuser",
377
+ };
378
+ const encodedHeader = Buffer.from(JSON.stringify(header)).toString("base64url");
379
+ const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url");
380
+ const testJwt = `${encodedHeader}.${encodedPayload}.test-signature`;
381
+ // Mock getToken to return the JWT
382
+ mockGetToken.mockResolvedValue(testJwt);
383
+ const plugin = eventEmissionPlugin({
384
+ sdk: {},
385
+ context: {
386
+ meta: {},
387
+ options: {
388
+ eventEmission: {
389
+ enabled: true,
390
+ transport: { type: "console" },
391
+ },
392
+ },
393
+ },
394
+ });
395
+ // Test that createBaseEvent includes the extracted user IDs
396
+ const baseEvent = await plugin.context.eventEmission.createBaseEvent();
397
+ expect(baseEvent.customuser_id).toBe(54321);
398
+ expect(baseEvent.account_id).toBe(98765);
399
+ });
400
+ it("should handle getToken failures gracefully", async () => {
401
+ // Mock getToken to reject/throw
402
+ mockGetToken.mockRejectedValue(new Error("Token fetch failed"));
403
+ const plugin = eventEmissionPlugin({
404
+ sdk: {},
405
+ context: {
406
+ meta: {},
407
+ options: {
408
+ eventEmission: {
409
+ enabled: true,
410
+ transport: { type: "console" },
411
+ },
412
+ },
413
+ },
414
+ });
415
+ const baseEvent = await plugin.context.eventEmission.createBaseEvent();
416
+ // Should gracefully fall back to null context
417
+ expect(baseEvent.customuser_id).toBe(null);
418
+ expect(baseEvent.account_id).toBe(null);
242
419
  });
243
420
  });
@@ -8,7 +8,7 @@ export declare const ActionItemSchema: z.ZodObject<Omit<{
8
8
  is_important: z.ZodOptional<z.ZodBoolean>;
9
9
  is_hidden: z.ZodOptional<z.ZodBoolean>;
10
10
  selected_api: z.ZodOptional<z.ZodString>;
11
- }, "type" | "name" | "selected_api"> & {
11
+ }, "type" | "selected_api" | "name"> & {
12
12
  app_key: z.ZodString;
13
13
  app_version: z.ZodOptional<z.ZodString>;
14
14
  action_type: z.ZodEnum<["filter", "read", "read_bulk", "run", "search", "search_and_write", "search_or_write", "write"]>;
@@ -20,7 +20,7 @@ export declare const AuthenticationItemSchema: z.ZodObject<Omit<{
20
20
  groups: z.ZodOptional<z.ZodString>;
21
21
  members: z.ZodOptional<z.ZodString>;
22
22
  permissions: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
23
- }, "selected_api" | "customuser_id"> & {
23
+ }, "customuser_id" | "selected_api"> & {
24
24
  implementation_id: z.ZodOptional<z.ZodString>;
25
25
  is_expired: z.ZodOptional<z.ZodString>;
26
26
  expired_at: z.ZodOptional<z.ZodNullable<z.ZodString>>;
@@ -28,9 +28,9 @@ export declare const AuthenticationItemSchema: z.ZodObject<Omit<{
28
28
  app_version: z.ZodOptional<z.ZodString>;
29
29
  user_id: z.ZodOptional<z.ZodNumber>;
30
30
  }, "strip", z.ZodTypeAny, {
31
+ account_id: number;
31
32
  id: number;
32
33
  date: string;
33
- account_id: number;
34
34
  is_invite_only: boolean;
35
35
  is_private: boolean;
36
36
  shared_with_all: boolean;
@@ -53,9 +53,9 @@ export declare const AuthenticationItemSchema: z.ZodObject<Omit<{
53
53
  app_key?: string | undefined;
54
54
  app_version?: string | undefined;
55
55
  }, {
56
+ account_id: number;
56
57
  id: number;
57
58
  date: string;
58
- account_id: number;
59
59
  is_invite_only: boolean;
60
60
  is_private: boolean;
61
61
  shared_with_all: boolean;
@@ -99,7 +99,7 @@ export declare const AuthItemSchema: z.ZodObject<Omit<{
99
99
  groups: z.ZodOptional<z.ZodString>;
100
100
  members: z.ZodOptional<z.ZodString>;
101
101
  permissions: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
102
- }, "selected_api" | "customuser_id"> & {
102
+ }, "customuser_id" | "selected_api"> & {
103
103
  implementation_id: z.ZodOptional<z.ZodString>;
104
104
  is_expired: z.ZodOptional<z.ZodString>;
105
105
  expired_at: z.ZodOptional<z.ZodNullable<z.ZodString>>;
@@ -107,9 +107,9 @@ export declare const AuthItemSchema: z.ZodObject<Omit<{
107
107
  app_version: z.ZodOptional<z.ZodString>;
108
108
  user_id: z.ZodOptional<z.ZodNumber>;
109
109
  }, "strip", z.ZodTypeAny, {
110
+ account_id: number;
110
111
  id: number;
111
112
  date: string;
112
- account_id: number;
113
113
  is_invite_only: boolean;
114
114
  is_private: boolean;
115
115
  shared_with_all: boolean;
@@ -132,9 +132,9 @@ export declare const AuthItemSchema: z.ZodObject<Omit<{
132
132
  app_key?: string | undefined;
133
133
  app_version?: string | undefined;
134
134
  }, {
135
+ account_id: number;
135
136
  id: number;
136
137
  date: string;
137
- account_id: number;
138
138
  is_invite_only: boolean;
139
139
  is_private: boolean;
140
140
  shared_with_all: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zapier/zapier-sdk",
3
- "version": "0.15.3",
3
+ "version": "0.15.4",
4
4
  "description": "Complete Zapier SDK - combines all Zapier SDK packages",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.mjs",