@crewhaus/channel-adapter-whatsapp 0.1.1 → 0.1.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/channel-adapter-whatsapp",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "description": "WhatsApp Business Cloud API channel adapter: X-Hub-Signature-256 verification + text/media/interactive parsing + idempotent retry handling (Section 33)",
6
6
  "main": "src/index.ts",
@@ -12,13 +12,13 @@
12
12
  "test": "bun test src"
13
13
  },
14
14
  "dependencies": {
15
- "@crewhaus/errors": "0.1.1"
15
+ "@crewhaus/errors": "0.1.3"
16
16
  },
17
17
  "license": "Apache-2.0",
18
18
  "author": {
19
19
  "name": "Max Meier",
20
- "email": "max@studiomax.io",
21
- "url": "https://studiomax.io"
20
+ "email": "max@crewhaus.ai",
21
+ "url": "https://crewhaus.ai"
22
22
  },
23
23
  "repository": {
24
24
  "type": "git",
@@ -30,12 +30,7 @@
30
30
  "url": "https://github.com/crewhaus/factory/issues"
31
31
  },
32
32
  "publishConfig": {
33
- "access": "restricted"
33
+ "access": "public"
34
34
  },
35
- "files": [
36
- "src",
37
- "README.md",
38
- "LICENSE",
39
- "NOTICE"
40
- ]
35
+ "files": ["src", "README.md", "LICENSE", "NOTICE"]
41
36
  }
package/src/index.test.ts CHANGED
@@ -22,6 +22,11 @@ const APP_SECRET = "wa-app-secret";
22
22
  const PHONE_NUMBER_ID = "999000999000";
23
23
  const ACCESS_TOKEN = "EAAxxxxxxxxx";
24
24
 
25
+ // The static fixtures carry a fixed (old) messages[].timestamp; pin the
26
+ // replay-window clock near it so the signature tests exercise auth, not age.
27
+ const FIXTURE_TS = 1700000123;
28
+ const FIXTURE_NOW = () => FIXTURE_TS * 1000;
29
+
25
30
  function signedHeaders(body: string): Headers {
26
31
  const sig = signWhatsAppBody({ body, appSecret: APP_SECRET });
27
32
  const h = new Headers();
@@ -34,6 +39,7 @@ function adapter(overrides: { fetch?: typeof fetch } = {}): ChannelAdapter {
34
39
  { phoneNumberId: PHONE_NUMBER_ID, accessToken: ACCESS_TOKEN, appSecret: APP_SECRET },
35
40
  {
36
41
  apiBaseUrl: "https://test.graph.local",
42
+ now: FIXTURE_NOW,
37
43
  ...(overrides.fetch ? { fetch: overrides.fetch } : {}),
38
44
  },
39
45
  );
@@ -42,14 +48,43 @@ function adapter(overrides: { fetch?: typeof fetch } = {}): ChannelAdapter {
42
48
  describe("verifyWhatsAppSignature (T8)", () => {
43
49
  test("matches a valid signature", () => {
44
50
  const body = fixture("text_message");
45
- const r = verifyWhatsAppSignature({
46
- headers: signedHeaders(body),
47
- body,
48
- appSecret: APP_SECRET,
49
- });
51
+ const r = verifyWhatsAppSignature(
52
+ {
53
+ headers: signedHeaders(body),
54
+ body,
55
+ appSecret: APP_SECRET,
56
+ },
57
+ { now: FIXTURE_NOW },
58
+ );
50
59
  expect(r).toBe(true);
51
60
  });
52
61
 
62
+ // SECURITY: WhatsApp signs only the body (no signed timestamp), so a captured
63
+ // signed POST verified forever. Bound it with messages[].timestamp.
64
+ test("rejects a stale captured message under the real clock (replay window)", () => {
65
+ const body = fixture("text_message"); // fixture ts is years old vs Date.now()
66
+ expect(
67
+ verifyWhatsAppSignature({ headers: signedHeaders(body), body, appSecret: APP_SECRET }),
68
+ ).toBe(false);
69
+ });
70
+
71
+ test("rejects a message whose timestamp is in the future beyond tolerance", () => {
72
+ const body = fixture("text_message");
73
+ expect(
74
+ verifyWhatsAppSignature(
75
+ { headers: signedHeaders(body), body, appSecret: APP_SECRET },
76
+ { now: () => (FIXTURE_TS - 3600) * 1000 },
77
+ ),
78
+ ).toBe(false);
79
+ });
80
+
81
+ test("accepts a status-only webhook with no message timestamp (no freshness gate)", () => {
82
+ const body = fixture("status_only");
83
+ expect(
84
+ verifyWhatsAppSignature({ headers: signedHeaders(body), body, appSecret: APP_SECRET }),
85
+ ).toBe(true);
86
+ });
87
+
53
88
  test("rejects tampered body", () => {
54
89
  const body = fixture("text_message");
55
90
  const headers = signedHeaders(body);
@@ -273,4 +308,313 @@ describe("WhatsAppAdapterError", () => {
273
308
  expect(e.code).toBe("channel");
274
309
  expect(e.name).toBe("WhatsAppAdapterError");
275
310
  });
311
+
312
+ test("preserves the cause chain via toJSON()", () => {
313
+ const root = new Error("socket hang up");
314
+ const e = new WhatsAppAdapterError("send failed", root);
315
+ expect(e.cause).toBe(root);
316
+ expect(e.toJSON()).toEqual({
317
+ name: "WhatsAppAdapterError",
318
+ code: "channel",
319
+ message: "send failed",
320
+ cause: { name: "Error", message: "socket hang up" },
321
+ });
322
+ });
323
+ });
324
+
325
+ describe("signWhatsAppBody (T8)", () => {
326
+ test('produces a "sha256=<64-hex>" header that verify accepts', () => {
327
+ const body = fixture("text_message");
328
+ const sig = signWhatsAppBody({ body, appSecret: APP_SECRET });
329
+ expect(sig).toMatch(/^sha256=[0-9a-f]{64}$/);
330
+ const h = new Headers({ "X-Hub-Signature-256": sig });
331
+ expect(
332
+ verifyWhatsAppSignature({ headers: h, body, appSecret: APP_SECRET }, { now: FIXTURE_NOW }),
333
+ ).toBe(true);
334
+ });
335
+
336
+ test("is deterministic for a given (body, secret)", () => {
337
+ const a = signWhatsAppBody({ body: "{}", appSecret: APP_SECRET });
338
+ const b = signWhatsAppBody({ body: "{}", appSecret: APP_SECRET });
339
+ expect(a).toBe(b);
340
+ });
341
+
342
+ test("differs when the secret differs", () => {
343
+ const a = signWhatsAppBody({ body: "{}", appSecret: APP_SECRET });
344
+ const b = signWhatsAppBody({ body: "{}", appSecret: "other-secret" });
345
+ expect(a).not.toBe(b);
346
+ });
347
+ });
348
+
349
+ describe("parseInbound — branch coverage (T2)", () => {
350
+ // Build a well-formed text payload and let callers perturb a single field.
351
+ function payloadWith(msg: Record<string, unknown>, value: Record<string, unknown> = {}): string {
352
+ return JSON.stringify({
353
+ object: "whatsapp_business_account",
354
+ entry: [
355
+ {
356
+ id: "100100100100",
357
+ changes: [
358
+ {
359
+ field: "messages",
360
+ value: {
361
+ messaging_product: "whatsapp",
362
+ metadata: { phone_number_id: PHONE_NUMBER_ID },
363
+ messages: [{ id: "wamid.gen-1", from: "15554443333", ...msg }],
364
+ ...value,
365
+ },
366
+ },
367
+ ],
368
+ },
369
+ ],
370
+ });
371
+ }
372
+
373
+ test("non-object JSON (e.g. a bare number) → skip", () => {
374
+ const a = adapter();
375
+ expect(a.parseInbound({ headers: new Headers(), body: "42" }).kind).toBe("skip");
376
+ });
377
+
378
+ test("JSON null literal → skip", () => {
379
+ const a = adapter();
380
+ expect(a.parseInbound({ headers: new Headers(), body: "null" }).kind).toBe("skip");
381
+ });
382
+
383
+ test("change.field other than 'messages' → skip", () => {
384
+ const a = adapter();
385
+ const body = JSON.stringify({
386
+ object: "whatsapp_business_account",
387
+ entry: [{ changes: [{ field: "message_template_status_update", value: {} }] }],
388
+ });
389
+ expect(a.parseInbound({ headers: new Headers(), body }).kind).toBe("skip");
390
+ });
391
+
392
+ test("messages field present but value missing → skip", () => {
393
+ const a = adapter();
394
+ const body = JSON.stringify({
395
+ object: "whatsapp_business_account",
396
+ entry: [{ changes: [{ field: "messages" }] }],
397
+ });
398
+ expect(a.parseInbound({ headers: new Headers(), body }).kind).toBe("skip");
399
+ });
400
+
401
+ test("message missing id (non-string) → skip", () => {
402
+ const a = adapter();
403
+ const body = payloadWith({ id: undefined, type: "text", text: { body: "hi" } });
404
+ expect(a.parseInbound({ headers: new Headers(), body }).kind).toBe("skip");
405
+ });
406
+
407
+ test("message missing from (non-string) → skip", () => {
408
+ const a = adapter();
409
+ const body = payloadWith({ from: undefined, type: "text", text: { body: "hi" } });
410
+ expect(a.parseInbound({ headers: new Headers(), body }).kind).toBe("skip");
411
+ });
412
+
413
+ test("metadata present but phone_number_id empty → skip", () => {
414
+ const a = adapter();
415
+ const body = payloadWith(
416
+ { type: "text", text: { body: "hi" } },
417
+ { metadata: { display_phone_number: "15551112222" } },
418
+ );
419
+ expect(a.parseInbound({ headers: new Headers(), body }).kind).toBe("skip");
420
+ });
421
+
422
+ test("text message with empty body → event with text ''", () => {
423
+ const a = adapter();
424
+ const body = payloadWith({ type: "text", text: { body: "" } });
425
+ const r = a.parseInbound({ headers: new Headers(), body }) as Extract<
426
+ ParsedInbound,
427
+ { kind: "event" }
428
+ >;
429
+ expect(r.kind).toBe("event");
430
+ expect(r.event.text).toBe("");
431
+ });
432
+
433
+ test("text message with no text field → event with text ''", () => {
434
+ const a = adapter();
435
+ const body = payloadWith({ type: "text" });
436
+ const r = a.parseInbound({ headers: new Headers(), body }) as Extract<
437
+ ParsedInbound,
438
+ { kind: "event" }
439
+ >;
440
+ expect(r.event.text).toBe("");
441
+ });
442
+
443
+ test("missing timestamp → event ts defaults to ''", () => {
444
+ const a = adapter();
445
+ const body = payloadWith({ type: "text", text: { body: "hi" } });
446
+ const r = a.parseInbound({ headers: new Headers(), body }) as Extract<
447
+ ParsedInbound,
448
+ { kind: "event" }
449
+ >;
450
+ expect(r.event.ts).toBe("");
451
+ });
452
+
453
+ test("button_reply with missing id → text '[button:]'", () => {
454
+ const a = adapter();
455
+ const body = payloadWith({ type: "interactive", interactive: { type: "button_reply" } });
456
+ const r = a.parseInbound({ headers: new Headers(), body }) as Extract<
457
+ ParsedInbound,
458
+ { kind: "event" }
459
+ >;
460
+ expect(r.event.text).toBe("[button:]");
461
+ });
462
+
463
+ test("list_reply with missing id → text '[list:]'", () => {
464
+ const a = adapter();
465
+ const body = payloadWith({ type: "interactive", interactive: { type: "list_reply" } });
466
+ const r = a.parseInbound({ headers: new Headers(), body }) as Extract<
467
+ ParsedInbound,
468
+ { kind: "event" }
469
+ >;
470
+ expect(r.event.text).toBe("[list:]");
471
+ });
472
+
473
+ test("interactive with unknown sub-type → skip", () => {
474
+ const a = adapter();
475
+ const body = payloadWith({ type: "interactive", interactive: { type: "flow_reply" } });
476
+ expect(a.parseInbound({ headers: new Headers(), body }).kind).toBe("skip");
477
+ });
478
+
479
+ test("interactive with no interactive object → skip", () => {
480
+ const a = adapter();
481
+ const body = payloadWith({ type: "interactive" });
482
+ expect(a.parseInbound({ headers: new Headers(), body }).kind).toBe("skip");
483
+ });
484
+
485
+ test("video type (unsupported) → skip via default branch", () => {
486
+ const a = adapter();
487
+ const body = payloadWith({ type: "video" });
488
+ expect(a.parseInbound({ headers: new Headers(), body }).kind).toBe("skip");
489
+ });
490
+
491
+ test("ignores prototype-polluting keys in the payload", () => {
492
+ const a = adapter();
493
+ const body = `{"object":"whatsapp_business_account","__proto__":{"polluted":true},"entry":[{"changes":[{"field":"messages","value":{"metadata":{"phone_number_id":"${PHONE_NUMBER_ID}"},"messages":[{"id":"wamid.p","from":"15554443333","type":"text","text":{"body":"hi"}}]}}]}]}`;
494
+ const r = a.parseInbound({ headers: new Headers(), body });
495
+ expect(r.kind).toBe("event");
496
+ // No global prototype mutation occurred.
497
+ expect(({} as Record<string, unknown>)["polluted"]).toBeUndefined();
498
+ });
499
+ });
500
+
501
+ describe("createWhatsAppAdapter — config / options", () => {
502
+ const event: InboundEvent = {
503
+ idempotencyKey: "wamid.xx",
504
+ workspaceId: PHONE_NUMBER_ID,
505
+ channelId: "15554443333",
506
+ userId: "15554443333",
507
+ ts: "1700000123",
508
+ text: "hello",
509
+ subtype: "message",
510
+ };
511
+
512
+ test("id is 'whatsapp'", () => {
513
+ expect(adapter().id).toBe("whatsapp");
514
+ });
515
+
516
+ test("defaults to graph.facebook.com / v22.0 when no base url given", async () => {
517
+ const calls: string[] = [];
518
+ const f = (async (input: string | Request | URL) => {
519
+ calls.push(String(input));
520
+ return new Response("", { status: 200 });
521
+ }) as unknown as typeof fetch;
522
+ // No apiBaseUrl override and no env var → default host.
523
+ const prev = process.env["WHATSAPP_API_BASE_URL"];
524
+ Reflect.deleteProperty(process.env, "WHATSAPP_API_BASE_URL");
525
+ try {
526
+ const a = createWhatsAppAdapter(
527
+ { phoneNumberId: PHONE_NUMBER_ID, accessToken: ACCESS_TOKEN, appSecret: APP_SECRET },
528
+ { fetch: f },
529
+ );
530
+ await a.sendReply({ event, text: "x" });
531
+ } finally {
532
+ if (prev === undefined) Reflect.deleteProperty(process.env, "WHATSAPP_API_BASE_URL");
533
+ else process.env["WHATSAPP_API_BASE_URL"] = prev;
534
+ }
535
+ expect(calls[0]).toBe(`https://graph.facebook.com/v22.0/${PHONE_NUMBER_ID}/messages`);
536
+ });
537
+
538
+ test("honours WHATSAPP_API_BASE_URL env override", async () => {
539
+ const calls: string[] = [];
540
+ const f = (async (input: string | Request | URL) => {
541
+ calls.push(String(input));
542
+ return new Response("", { status: 200 });
543
+ }) as unknown as typeof fetch;
544
+ const prev = process.env["WHATSAPP_API_BASE_URL"];
545
+ process.env["WHATSAPP_API_BASE_URL"] = "https://env.graph.local";
546
+ try {
547
+ const a = createWhatsAppAdapter(
548
+ { phoneNumberId: PHONE_NUMBER_ID, accessToken: ACCESS_TOKEN, appSecret: APP_SECRET },
549
+ { fetch: f },
550
+ );
551
+ await a.sendReply({ event, text: "x" });
552
+ } finally {
553
+ if (prev === undefined) Reflect.deleteProperty(process.env, "WHATSAPP_API_BASE_URL");
554
+ else process.env["WHATSAPP_API_BASE_URL"] = prev;
555
+ }
556
+ expect(calls[0]).toBe(`https://env.graph.local/v22.0/${PHONE_NUMBER_ID}/messages`);
557
+ });
558
+
559
+ test("honours apiVersion option", async () => {
560
+ const calls: string[] = [];
561
+ const f = (async (input: string | Request | URL) => {
562
+ calls.push(String(input));
563
+ return new Response("", { status: 200 });
564
+ }) as unknown as typeof fetch;
565
+ const a = createWhatsAppAdapter(
566
+ { phoneNumberId: PHONE_NUMBER_ID, accessToken: ACCESS_TOKEN, appSecret: APP_SECRET },
567
+ { apiBaseUrl: "https://test.graph.local", apiVersion: "v21.0", fetch: f },
568
+ );
569
+ await a.sendReply({ event, text: "x" });
570
+ expect(calls[0]).toBe(`https://test.graph.local/v21.0/${PHONE_NUMBER_ID}/messages`);
571
+ });
572
+ });
573
+
574
+ describe("sendReply — response handling (T3)", () => {
575
+ const event: InboundEvent = {
576
+ idempotencyKey: "wamid.xx",
577
+ workspaceId: PHONE_NUMBER_ID,
578
+ channelId: "15554443333",
579
+ userId: "15554443333",
580
+ ts: "1700000123",
581
+ text: "hello",
582
+ subtype: "message",
583
+ };
584
+
585
+ test("resolves on a 200 with non-JSON content-type (body not parsed)", async () => {
586
+ const f = (async () =>
587
+ new Response("OK", {
588
+ status: 200,
589
+ headers: { "content-type": "text/plain" },
590
+ })) as unknown as typeof fetch;
591
+ const a = adapter({ fetch: f });
592
+ await expect(a.sendReply({ event, text: "x" })).resolves.toBeUndefined();
593
+ });
594
+
595
+ test("resolves on a 200 with no content-type header", async () => {
596
+ const f = (async () => new Response("", { status: 200 })) as unknown as typeof fetch;
597
+ const a = adapter({ fetch: f });
598
+ await expect(a.sendReply({ event, text: "x" })).resolves.toBeUndefined();
599
+ });
600
+
601
+ test("resolves on a 200 JSON success envelope without an error field", async () => {
602
+ const f = (async () =>
603
+ new Response(JSON.stringify({ messages: [{ id: "wamid.ok" }] }), {
604
+ status: 200,
605
+ headers: { "content-type": "application/json" },
606
+ })) as unknown as typeof fetch;
607
+ const a = adapter({ fetch: f });
608
+ await expect(a.sendReply({ event, text: "x" })).resolves.toBeUndefined();
609
+ });
610
+
611
+ test("error envelope with no message falls back to 'unknown'", async () => {
612
+ const f = (async () =>
613
+ new Response(JSON.stringify({ error: {} }), {
614
+ status: 200,
615
+ headers: { "content-type": "application/json" },
616
+ })) as unknown as typeof fetch;
617
+ const a = adapter({ fetch: f });
618
+ await expect(a.sendReply({ event, text: "x" })).rejects.toThrow(/messages POST error: unknown/);
619
+ });
276
620
  });
package/src/index.ts CHANGED
@@ -77,6 +77,8 @@ export type WhatsAppAdapterOptions = {
77
77
  readonly apiBaseUrl?: string;
78
78
  readonly fetch?: typeof fetch;
79
79
  readonly apiVersion?: string;
80
+ /** Clock injection for the signature replay-window check (tests). */
81
+ readonly now?: () => number;
80
82
  };
81
83
 
82
84
  const DEFAULT_API_BASE_URL = "https://graph.facebook.com";
@@ -96,11 +98,14 @@ export function createWhatsAppAdapter(
96
98
  id: "whatsapp",
97
99
 
98
100
  verify(req: RawRequest): boolean {
99
- return verifyWhatsAppSignature({
100
- headers: req.headers,
101
- body: req.body,
102
- appSecret: config.appSecret,
103
- });
101
+ return verifyWhatsAppSignature(
102
+ {
103
+ headers: req.headers,
104
+ body: req.body,
105
+ appSecret: config.appSecret,
106
+ },
107
+ opts.now !== undefined ? { now: opts.now } : {},
108
+ );
104
109
  },
105
110
 
106
111
  parseInbound(req: RawRequest): ParsedInbound {
package/src/verify.ts CHANGED
@@ -15,7 +15,66 @@ export type WhatsAppVerifyArgs = {
15
15
  readonly appSecret: string;
16
16
  };
17
17
 
18
- export function verifyWhatsAppSignature(args: WhatsAppVerifyArgs): boolean {
18
+ export type WhatsAppVerifyOptions = {
19
+ readonly now?: () => number;
20
+ readonly toleranceMs?: number;
21
+ };
22
+
23
+ // WhatsApp signs only the body — there is no signed timestamp header — so the
24
+ // replay-window check reads `messages[].timestamp` from the AUTHENTICATED body
25
+ // after the HMAC passes. Unlike the Slack/Discord real-time interaction
26
+ // windows, WhatsApp redelivers failed webhooks on its own (multi-minute) retry
27
+ // schedule, so this default is deliberately more generous: it bounds INDEFINITE
28
+ // replay of a captured message rather than enforcing sub-minute freshness.
29
+ // Operators can widen/tighten it via `toleranceMs`; the gateway's durable
30
+ // cross-process dedup is the complementary, stronger control.
31
+ const DEFAULT_TOLERANCE_MS = 10 * 60 * 1000;
32
+
33
+ function asRecord(v: unknown): Record<string, unknown> | undefined {
34
+ return typeof v === "object" && v !== null ? (v as Record<string, unknown>) : undefined;
35
+ }
36
+
37
+ /**
38
+ * Newest `messages[].timestamp` (unix seconds) in a WhatsApp webhook body, or
39
+ * undefined when there is none (e.g. status-only webhooks, which are skipped
40
+ * downstream). An attacker cannot strip the timestamp from a signed message
41
+ * body without invalidating the HMAC, so "no timestamp ⇒ accept" is safe.
42
+ */
43
+ function newestMessageTimestamp(body: string): number | undefined {
44
+ let parsed: unknown;
45
+ try {
46
+ parsed = JSON.parse(body);
47
+ } catch {
48
+ return undefined;
49
+ }
50
+ const entries = asRecord(parsed)?.["entry"];
51
+ if (!Array.isArray(entries)) return undefined;
52
+ let newest: number | undefined;
53
+ const consider = (raw: unknown): void => {
54
+ const n =
55
+ typeof raw === "number"
56
+ ? raw
57
+ : typeof raw === "string"
58
+ ? Number.parseInt(raw, 10)
59
+ : Number.NaN;
60
+ if (Number.isFinite(n)) newest = newest === undefined ? n : Math.max(newest, n);
61
+ };
62
+ for (const entryRaw of entries) {
63
+ const changes = asRecord(entryRaw)?.["changes"];
64
+ if (!Array.isArray(changes)) continue;
65
+ for (const changeRaw of changes) {
66
+ const messages = asRecord(asRecord(changeRaw)?.["value"])?.["messages"];
67
+ if (!Array.isArray(messages)) continue;
68
+ for (const msgRaw of messages) consider(asRecord(msgRaw)?.["timestamp"]);
69
+ }
70
+ }
71
+ return newest;
72
+ }
73
+
74
+ export function verifyWhatsAppSignature(
75
+ args: WhatsAppVerifyArgs,
76
+ opts: WhatsAppVerifyOptions = {},
77
+ ): boolean {
19
78
  const supplied = args.headers.get("x-hub-signature-256");
20
79
  if (!supplied) return false;
21
80
  if (!supplied.startsWith("sha256=")) return false;
@@ -26,7 +85,14 @@ export function verifyWhatsAppSignature(args: WhatsAppVerifyArgs): boolean {
26
85
  const expectedBuf = Buffer.from(expected, "utf8");
27
86
  const actualBuf = Buffer.from(sigHex.toLowerCase(), "utf8");
28
87
  if (expectedBuf.length !== actualBuf.length) return false;
29
- return timingSafeEqual(expectedBuf, actualBuf);
88
+ if (!timingSafeEqual(expectedBuf, actualBuf)) return false;
89
+
90
+ // HMAC valid — now bound replay on the authenticated body.
91
+ const now = opts.now ?? (() => Date.now());
92
+ const tolerance = opts.toleranceMs ?? DEFAULT_TOLERANCE_MS;
93
+ const ts = newestMessageTimestamp(args.body);
94
+ if (ts !== undefined && Math.abs(now() - ts * 1000) > tolerance) return false;
95
+ return true;
30
96
  }
31
97
 
32
98
  /**