@zapier/policy-context 1.3.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,5 +1,20 @@
1
1
  // src/types.ts
2
2
  import { z } from "zod";
3
+ function containsUnsafeInteger(value) {
4
+ if (typeof value === "number") {
5
+ return Number.isInteger(value) && !Number.isSafeInteger(value);
6
+ }
7
+ if (Array.isArray(value)) {
8
+ return value.some(containsUnsafeInteger);
9
+ }
10
+ if (value !== null && typeof value === "object") {
11
+ return Object.values(value).some(containsUnsafeInteger);
12
+ }
13
+ return false;
14
+ }
15
+ var SafeJsonValueSchema = z.json().refine((value) => !containsUnsafeInteger(value), {
16
+ message: "JSON integers must be within the IEEE-754 safe range; encode larger identifiers as strings"
17
+ });
3
18
  var ParsedUrlSchema = z.object({
4
19
  scheme: z.string().describe('URL scheme, e.g. "https"'),
5
20
  host: z.string().describe('Hostname, e.g. "api.example.com"'),
@@ -58,7 +73,7 @@ var HttpRequestContextSchema = z.object({
58
73
  * `z.json()` still rejects non-JSON values (`Date`, `undefined`,
59
74
  * functions) at parse time. Same inferred `JsonValue` type either way.
60
75
  */
61
- body_json: z.unknown().pipe(z.json()).nullable().describe("Parsed JSON body (if applicable)"),
76
+ body_json: z.unknown().pipe(SafeJsonValueSchema).nullable().describe("Parsed JSON body (if applicable)"),
62
77
  /** Zapier connection ID used for this request */
63
78
  connection_id: z.string().max(255).optional().describe("Zapier connection ID used for this request"),
64
79
  /** Optional human-readable reason for the request */
@@ -76,6 +91,11 @@ var ActionTypeSchema = z.enum([
76
91
  ]).meta({ id: "action_type", description: "Canonical Zapier action type." });
77
92
  var ACTION_RESOURCE_SEGMENT_PATTERN = /^[A-Za-z0-9._+-]+(?:@[A-Za-z0-9._+-]+)?$/;
78
93
  var ACTION_KEY_SEGMENT_PATTERN = /^[A-Za-z0-9._+:-]+(?:@[A-Za-z0-9._+:-]+)?$/;
94
+ var CONNECTION_ID_CHARS = "[^/*\\x00-\\x20\\x7f-\\xa0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]";
95
+ var CONNECTION_ID_PATTERN = new RegExp(`^${CONNECTION_ID_CHARS}+$`);
96
+ var CONNECTION_RESOURCE_PATTERN = new RegExp(
97
+ `^connection/(?:\\*|${CONNECTION_ID_CHARS}+)$`
98
+ );
79
99
  function parseActionResource(resource) {
80
100
  const segments = resource.split("/");
81
101
  if (segments.length !== 4 || segments[0] !== "action") {
@@ -136,7 +156,7 @@ var ActionRunContextInputSchema = z.object({
136
156
  }),
137
157
  connection_id: z.string().max(255).optional().describe("Zapier connection ID used for this request"),
138
158
  /** Action input parameters. See body_json above for the pipe rationale. */
139
- inputs: z.unknown().pipe(z.json()).optional().describe("Action input parameters"),
159
+ inputs: z.unknown().pipe(SafeJsonValueSchema).optional().describe("Action input parameters"),
140
160
  reason: z.string().max(1024).optional().describe("Optional human-readable reason for the request")
141
161
  }).meta({
142
162
  id: "ActionRunContext",
@@ -167,7 +187,7 @@ var ActionRunContextSchema = ActionRunContextInputSchema.transform(
167
187
  ...input.connection_id !== void 0 && {
168
188
  connection_id: input.connection_id
169
189
  },
170
- ...input.inputs !== void 0 && { inputs: input.inputs },
190
+ ...input.inputs !== void 0 && input.inputs !== null && { inputs: input.inputs },
171
191
  ...input.reason !== void 0 && { reason: input.reason }
172
192
  };
173
193
  }
@@ -211,8 +231,15 @@ var IGNORED_HEADERS = /* @__PURE__ */ new Set([
211
231
  "x-real-ip",
212
232
  // Proxy internal — sdkapi-specific headers
213
233
  "x-is-interactive-session",
214
- "x-relay-callback-url"
234
+ "x-relay-callback-url",
235
+ // Subsumed by isIgnoredHeader's x-zapier- prefix rule; kept for byte-parity
236
+ // with the approvalsapi source and for consumers reading the set directly.
237
+ "x-zapier-approval-token"
215
238
  ]);
239
+ function isIgnoredHeader(name) {
240
+ const normalized = name.toLowerCase();
241
+ return IGNORED_HEADERS.has(normalized) || normalized.startsWith("zapier-") || normalized.startsWith("x-zapier-");
242
+ }
216
243
 
217
244
  // src/builders.ts
218
245
  import { z as z2 } from "zod";
@@ -235,14 +262,14 @@ function buildExactHttpConditions(context) {
235
262
  conditions.push({
236
263
  path: ["url", "query", key],
237
264
  operator: "equals",
238
- value: values
265
+ value: structuredClone(values)
239
266
  });
240
267
  }
241
268
  if (context.body_json !== null) {
242
269
  conditions.push({
243
270
  path: ["body_json"],
244
271
  operator: "equals",
245
- value: context.body_json
272
+ value: structuredClone(context.body_json)
246
273
  });
247
274
  } else if (context.body !== null) {
248
275
  conditions.push({
@@ -252,7 +279,7 @@ function buildExactHttpConditions(context) {
252
279
  });
253
280
  }
254
281
  for (const [key, value] of Object.entries(context.headers)) {
255
- if (!IGNORED_HEADERS.has(key.toLowerCase()) && typeof value === "string") {
282
+ if (!isIgnoredHeader(key) && typeof value === "string") {
256
283
  conditions.push({
257
284
  path: ["headers", key.toLowerCase()],
258
285
  operator: "equals",
@@ -277,7 +304,7 @@ function buildExactActionConditions(context) {
277
304
  conditions.push({
278
305
  path: ["inputs"],
279
306
  operator: "equals",
280
- value: context.inputs
307
+ value: structuredClone(context.inputs)
281
308
  });
282
309
  }
283
310
  return conditions;
@@ -294,7 +321,7 @@ var BuildHttpRequestContextOptionsSchema = z2.object({
294
321
  method: z2.string(),
295
322
  /** Raw URL string. Will be normalized with https:// if no protocol present. */
296
323
  url: z2.string(),
297
- /** Raw headers from the request. Will be filtered through IGNORED_HEADERS. */
324
+ /** Raw headers from the request. Will be filtered through isIgnoredHeader. */
298
325
  headers: z2.record(
299
326
  z2.string(),
300
327
  z2.union([z2.string(), z2.array(z2.string()), z2.undefined()])
@@ -302,7 +329,7 @@ var BuildHttpRequestContextOptionsSchema = z2.object({
302
329
  /** Raw body string */
303
330
  body: z2.string().nullable().optional(),
304
331
  /** Pre-parsed JSON body (e.g. from Fastify's automatic JSON parsing) */
305
- body_json: z2.json().optional(),
332
+ body_json: SafeJsonValueSchema.optional(),
306
333
  /** Connection ID associated with this request */
307
334
  connection_id: z2.string().optional()
308
335
  });
@@ -314,7 +341,7 @@ function buildHttpRequestContext(options) {
314
341
  if (parsed.headers) {
315
342
  for (const [key, value] of Object.entries(parsed.headers)) {
316
343
  const lowerKey = key.toLowerCase();
317
- if (!IGNORED_HEADERS.has(lowerKey) && typeof value === "string") {
344
+ if (!isIgnoredHeader(lowerKey) && typeof value === "string") {
318
345
  headers[lowerKey] = value;
319
346
  }
320
347
  }
@@ -337,7 +364,7 @@ function buildHttpRequestContext(options) {
337
364
  method: parsed.method.toUpperCase(),
338
365
  url,
339
366
  headers,
340
- body: parsed.body ?? null,
367
+ body: body_json === null ? parsed.body ?? null : null,
341
368
  body_json,
342
369
  connection_id: parsed.connection_id
343
370
  });
@@ -351,7 +378,7 @@ var BuildActionRunContextOptionsSchema = z2.object({
351
378
  message: "action_key must contain only [A-Za-z0-9._@:+-] (no globs, slashes, or whitespace)"
352
379
  }),
353
380
  connection_id: z2.string().optional(),
354
- inputs: z2.json().optional(),
381
+ inputs: SafeJsonValueSchema.optional(),
355
382
  reason: z2.string().optional()
356
383
  });
357
384
  function buildActionRunContext(options) {
@@ -372,6 +399,7 @@ function buildActionRunContext(options) {
372
399
  // src/policies.ts
373
400
  function buildAuthStatement(authenticationId) {
374
401
  return {
402
+ id: `s_${crypto.randomUUID()}`,
375
403
  effect: "allow",
376
404
  permissions: ["can_use"],
377
405
  resources: [`connection/${authenticationId}`],
@@ -379,36 +407,38 @@ function buildAuthStatement(authenticationId) {
379
407
  };
380
408
  }
381
409
  function httpContextToPolicy(context) {
382
- const statements = [];
383
- if (context.connection_id) {
384
- statements.push(buildAuthStatement(context.connection_id));
385
- }
386
410
  const conditions = buildExactHttpConditions(context);
387
411
  conditions.push({
388
412
  path: ["url", "query"],
389
413
  operator: "keys_in",
390
414
  value: Object.keys(context.url.query)
391
415
  });
392
- statements.push({
416
+ const statement = {
417
+ id: `s_${crypto.randomUUID()}`,
393
418
  effect: "allow",
394
419
  permissions: ["can_execute"],
395
420
  resources: ["http"],
396
421
  conditions
397
- });
398
- return { version: 2, statements };
422
+ };
423
+ const statements = context.connection_id ? [buildAuthStatement(context.connection_id), statement] : [statement];
424
+ return {
425
+ version: 3,
426
+ statements: [statements]
427
+ };
399
428
  }
400
429
  function actionContextToPolicy(context) {
401
- const statements = [];
402
- if (context.connection_id) {
403
- statements.push(buildAuthStatement(context.connection_id));
404
- }
405
- statements.push({
430
+ const statement = {
431
+ id: `s_${crypto.randomUUID()}`,
406
432
  effect: "allow",
407
433
  permissions: ["can_execute"],
408
434
  resources: [context.resource],
409
435
  conditions: buildExactActionConditions(context)
410
- });
411
- return { version: 2, statements };
436
+ };
437
+ const statements = context.connection_id ? [buildAuthStatement(context.connection_id), statement] : [statement];
438
+ return {
439
+ version: 3,
440
+ statements: [statements]
441
+ };
412
442
  }
413
443
  function toExactPolicy(context) {
414
444
  if (context.request_type === "http_request") {
@@ -417,9 +447,140 @@ function toExactPolicy(context) {
417
447
  return actionContextToPolicy(context);
418
448
  }
419
449
 
420
- // src/connection-policy.ts
450
+ // src/zapier-policy.ts
451
+ import { isStatementSubsumedBy } from "@zapier/policy";
452
+ import { PolicySchema } from "@zapier/policy-schema";
421
453
  import { z as z3 } from "zod";
422
- var AccessLevelSchema = z3.enum(["read", "read_write"]);
454
+ var SELECTED_API_GLOB_PART_PATTERN = /^[A-Za-z0-9._+*-]+$/;
455
+ var ACTION_KEY_GLOB_PART_PATTERN = /^[A-Za-z0-9._+:*-]+$/;
456
+ var PERMISSIONS = /* @__PURE__ */ new Set([
457
+ "can_execute",
458
+ "can_use",
459
+ "can_view",
460
+ "can_edit",
461
+ "*"
462
+ ]);
463
+ var EFFECT_PRECEDENCE = { allow: 0, ask: 1, deny: 2 };
464
+ function isSelectedApiPattern(value) {
465
+ return isGlobSegment(
466
+ value,
467
+ ACTION_RESOURCE_SEGMENT_PATTERN,
468
+ SELECTED_API_GLOB_PART_PATTERN
469
+ );
470
+ }
471
+ function isActionKeyPattern(value) {
472
+ return isGlobSegment(
473
+ value,
474
+ ACTION_KEY_SEGMENT_PATTERN,
475
+ ACTION_KEY_GLOB_PART_PATTERN
476
+ );
477
+ }
478
+ function isGlobSegment(value, exactPattern, globPartPattern) {
479
+ if (exactPattern.test(value)) {
480
+ return true;
481
+ }
482
+ if (!value.includes("*") || value.includes("**")) {
483
+ return false;
484
+ }
485
+ const parts = value.split("@");
486
+ return parts.length <= 2 && parts.every((part) => globPartPattern.test(part));
487
+ }
488
+ function isActionResourcePattern(value) {
489
+ const segments = value.split("/");
490
+ if (segments.length !== 4 || segments[0] !== "action") {
491
+ return false;
492
+ }
493
+ const [, selectedApi, actionType, actionKey] = segments;
494
+ return selectedApi !== void 0 && actionType !== void 0 && actionKey !== void 0 && isSelectedApiPattern(selectedApi) && (actionType === "*" || ActionTypeSchema.safeParse(actionType).success) && isActionKeyPattern(actionKey);
495
+ }
496
+ var ActionResourcePatternSchema = z3.string().refine(isActionResourcePattern, {
497
+ message: "Expected action/{selected_api}/{canonical_action_type|*}/{action_key_pattern}"
498
+ });
499
+ function resourceFamily(resource) {
500
+ if (resource === "*" || resource === "http") {
501
+ return resource;
502
+ }
503
+ if (ActionResourcePatternSchema.safeParse(resource).success) {
504
+ return "action";
505
+ }
506
+ if (CONNECTION_RESOURCE_PATTERN.test(resource)) {
507
+ return "connection";
508
+ }
509
+ return void 0;
510
+ }
511
+ function permissionAppliesTo(permission, family) {
512
+ if (permission === "*" || family === "*") {
513
+ return true;
514
+ }
515
+ return permission === "can_execute" ? family === "action" || family === "http" : family === "connection";
516
+ }
517
+ var ZapierPolicySchema = PolicySchema.superRefine((policy, ctx) => {
518
+ const tiers = policy.version === 3 ? policy.statements.map((statements, tierIndex) => ({
519
+ statements,
520
+ path: ["statements", tierIndex]
521
+ })) : [{ statements: policy.statements, path: ["statements"] }];
522
+ tiers.forEach(({ statements, path }) => {
523
+ statements.forEach((statement, statementIndex) => {
524
+ const families = statement.resources.map((resource, resourceIndex) => {
525
+ const family = resourceFamily(resource);
526
+ if (family === void 0) {
527
+ ctx.addIssue({
528
+ code: "custom",
529
+ message: 'Expected "http", "connection/{id|*}", a valid action pattern, or "*"',
530
+ path: [...path, statementIndex, "resources", resourceIndex]
531
+ });
532
+ }
533
+ return family;
534
+ });
535
+ statement.permissions.forEach((permission, permissionIndex) => {
536
+ if (!PERMISSIONS.has(permission)) {
537
+ ctx.addIssue({
538
+ code: "custom",
539
+ message: `Unknown Zapier permission "${permission}"`,
540
+ path: [...path, statementIndex, "permissions", permissionIndex]
541
+ });
542
+ return;
543
+ }
544
+ const incompatibleFamily = families.find(
545
+ (family) => family !== void 0 && !permissionAppliesTo(permission, family)
546
+ );
547
+ if (incompatibleFamily !== void 0) {
548
+ ctx.addIssue({
549
+ code: "custom",
550
+ message: `Permission "${permission}" cannot apply to ${incompatibleFamily} resources`,
551
+ path: [...path, statementIndex, "permissions", permissionIndex]
552
+ });
553
+ }
554
+ });
555
+ const maskingStatementIndex = statements.findIndex(
556
+ (maskingStatement, maskingIndex) => maskingIndex !== statementIndex && EFFECT_PRECEDENCE[maskingStatement.effect] > EFFECT_PRECEDENCE[statement.effect] && isStatementSubsumedBy(statement, {
557
+ ...maskingStatement,
558
+ effect: statement.effect
559
+ })
560
+ );
561
+ if (maskingStatementIndex !== -1) {
562
+ const maskingStatement = statements[maskingStatementIndex];
563
+ const maskingId = maskingStatement.id ? ` (id "${maskingStatement.id}")` : "";
564
+ ctx.addIssue({
565
+ code: "custom",
566
+ message: `Statement is entirely masked by higher-precedence statement at index ${maskingStatementIndex}${maskingId}`,
567
+ path: [...path, statementIndex]
568
+ });
569
+ }
570
+ });
571
+ });
572
+ });
573
+
574
+ // src/connection-policy.ts
575
+ import { z as z4 } from "zod";
576
+ var AccessLevelSchema = z4.enum(["read", "read_write"]);
577
+ function requireAccessLevel(accessLevel) {
578
+ if (!AccessLevelSchema.safeParse(accessLevel).success) {
579
+ throw new Error(
580
+ `invalid access_level "${accessLevel}": expected "read" or "read_write"`
581
+ );
582
+ }
583
+ }
423
584
  var READ_HTTP_METHODS = ["GET", "HEAD", "OPTIONS"];
424
585
  var READ_ACTION_TYPES = [
425
586
  "read",
@@ -439,7 +600,19 @@ function isWriteRequest(context) {
439
600
  }
440
601
  function buildConnectionPolicy(options) {
441
602
  const { connectionId, selectedApi, accessLevel } = options;
603
+ requireAccessLevel(accessLevel);
604
+ if (!CONNECTION_ID_PATTERN.test(connectionId)) {
605
+ throw new Error(
606
+ `invalid connection_id "${connectionId}": must not contain "/", "*", or whitespace`
607
+ );
608
+ }
609
+ if (!ACTION_RESOURCE_SEGMENT_PATTERN.test(selectedApi)) {
610
+ throw new Error(
611
+ `invalid selected_api "${selectedApi}": must not contain "/", "*", or whitespace`
612
+ );
613
+ }
442
614
  const authStatement = {
615
+ id: `s_${crypto.randomUUID()}`,
443
616
  effect: "allow",
444
617
  permissions: ["can_use"],
445
618
  resources: [`connection/${connectionId}`],
@@ -459,6 +632,7 @@ function buildConnectionPolicy(options) {
459
632
  });
460
633
  }
461
634
  const httpStatement = {
635
+ id: `s_${crypto.randomUUID()}`,
462
636
  effect: "allow",
463
637
  permissions: ["can_execute"],
464
638
  resources: ["http"],
@@ -467,20 +641,22 @@ function buildConnectionPolicy(options) {
467
641
  const apiPattern = globSelectedApiVersion(selectedApi);
468
642
  const actionResources = accessLevel === "read" ? READ_ACTION_TYPES.map((t) => `action/${apiPattern}/${t}/*`) : [`action/${apiPattern}/*/*`];
469
643
  const actionStatement = {
644
+ id: `s_${crypto.randomUUID()}`,
470
645
  effect: "allow",
471
646
  permissions: ["can_execute"],
472
647
  resources: actionResources,
473
648
  conditions: [connectionCondition]
474
649
  };
475
650
  return {
476
- version: 2,
477
- statements: [authStatement, httpStatement, actionStatement]
651
+ version: 3,
652
+ statements: [[authStatement, httpStatement, actionStatement]]
478
653
  };
479
654
  }
480
655
 
481
656
  // src/connectionless-policy.ts
482
657
  function buildConnectionlessPolicy(options) {
483
658
  const { context, accessLevel } = options;
659
+ requireAccessLevel(accessLevel);
484
660
  if (context.request_type === "http_request") {
485
661
  const conditions = [
486
662
  {
@@ -497,26 +673,35 @@ function buildConnectionlessPolicy(options) {
497
673
  });
498
674
  }
499
675
  const statement2 = {
676
+ id: `s_${crypto.randomUUID()}`,
500
677
  effect: "allow",
501
678
  permissions: ["can_execute"],
502
679
  resources: ["http"],
503
680
  conditions
504
681
  };
505
- return { version: 2, statements: [statement2] };
682
+ return {
683
+ version: 3,
684
+ statements: [[statement2]]
685
+ };
506
686
  }
507
687
  const { selected_api } = parseActionResource(context.resource);
508
688
  const apiPattern = globSelectedApiVersion(selected_api);
509
689
  const resources = accessLevel === "read" ? READ_ACTION_TYPES.map((t) => `action/${apiPattern}/${t}/*`) : [`action/${apiPattern}/*/*`];
510
690
  const statement = {
691
+ id: `s_${crypto.randomUUID()}`,
511
692
  effect: "allow",
512
693
  permissions: ["can_execute"],
513
694
  resources,
514
695
  conditions: []
515
696
  };
516
- return { version: 2, statements: [statement] };
697
+ return {
698
+ version: 3,
699
+ statements: [[statement]]
700
+ };
517
701
  }
518
702
  export {
519
703
  AccessLevelSchema,
704
+ ActionResourcePatternSchema,
520
705
  ActionRunContextInputSchema,
521
706
  ActionRunContextSchema,
522
707
  ActionTypeSchema,
@@ -529,6 +714,7 @@ export {
529
714
  READ_ACTION_TYPES,
530
715
  READ_HTTP_METHODS,
531
716
  RequestContextSchema,
717
+ ZapierPolicySchema,
532
718
  buildActionResource,
533
719
  buildActionRunContext,
534
720
  buildBroadActionResource,
@@ -540,6 +726,7 @@ export {
540
726
  buildHttpRequestContext,
541
727
  buildUrlString,
542
728
  globSelectedApiVersion,
729
+ isIgnoredHeader,
543
730
  isWriteRequest,
544
731
  parseActionResource,
545
732
  parseUrl,