assinafy 1.11.0 → 2.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.
@@ -29,7 +29,10 @@ module Assinafy
29
29
  # certificate (an e-CPF, or an e-CNPJ naming them as legal
30
30
  # representative); a CNPJ requires an e-CNPJ for that company, from any
31
31
  # of its representatives.
32
- VERIFICATION_METHODS = %w[Email Whatsapp DigitalCertificate].freeze
32
+ # - `IdDocumentFaceMatchBasic` — government ID photos, face match and liveness.
33
+ # Requires its account feature and a valid CPF in `government_id`; priced per signer.
34
+ VERIFICATION_METHODS = %w[Email Whatsapp DigitalCertificate IdDocumentFaceMatchBasic].freeze
35
+ IDENTITY_REVIEW_IMAGES = %w[document_front document_back document_pdf face].freeze
33
36
 
34
37
  # Channels used to notify a signer of the request. Exactly one per signer;
35
38
  # WhatsApp incurs an additional cost and is available only on paid
@@ -52,12 +55,14 @@ module Assinafy
52
55
  # - `signers: [{ id:, verification_method:, notification_methods:, step: }]`
53
56
  # - Legacy `signer_ids:`/`signerIds:` arrays of IDs
54
57
  #
55
- # @note Both methods require top-level `signers`; `collect` additionally
56
- # requires positioned `entries`.
58
+ # @note Creation requires top-level `signers` for both methods, and `collect` additionally
59
+ # requires positioned `entries`; both are optional in estimate-cost payloads.
57
60
  # @param payload [Hash]
58
61
  # @param options [Hash]
59
62
  # @option options [Boolean] :allow_signers_without_id allow estimate-cost
60
63
  # payloads where method-only signer descriptors carry no id
64
+ # @option options [Boolean] :require_signers (true) false for estimate-cost payloads, where
65
+ # `signers` and `entries` are optional; an empty `signers` list is then omitted
61
66
  # @return [Hash] string-keyed body suitable for {#create} / {#estimate_cost}
62
67
  # @raise [Assinafy::ValidationError] on missing required fields
63
68
  # @example Bare signer IDs are normalised into { id: } hashes (virtual method)
@@ -114,12 +119,13 @@ module Assinafy
114
119
  entries = p[:entries]
115
120
  method = (p[:method] || 'virtual').to_s
116
121
 
117
- validate_method!(method, signers, entries, p)
122
+ validate_method!(method, signers, entries, p, options.fetch(:require_signers, true))
118
123
  validate_optional_fields!(p)
119
124
 
120
125
  result = { method: method }
121
- result[:signers] = signers.map { |ref| normalise_signer_ref(ref, options) }
122
- validate_signing_steps!(result[:signers])
126
+ normalised = signers.map { |ref| normalise_signer_ref(ref, options) }
127
+ validate_signing_steps!(normalised)
128
+ result[:signers] = normalised unless normalised.empty?
123
129
  OPTIONAL_FIELDS.each { |key| result[key] = p[key] if p[key] }
124
130
  result[:entries] = entries if entries
125
131
  Utils.body_params(result)
@@ -127,13 +133,13 @@ module Assinafy
127
133
 
128
134
  private
129
135
 
130
- def validate_method!(method, signers, entries, payload)
136
+ def validate_method!(method, signers, entries, payload, require_signers)
131
137
  unless METHODS.include?(method)
132
138
  raise ValidationError.new("Assignment method must be one of: #{METHODS.join(', ')}")
133
139
  end
140
+ return unless require_signers
134
141
 
135
- # `signers` is required for both methods: creation needs to know who signs, and the
136
- # estimate is priced per signer. Positioned fields do not replace this list.
142
+ # Creation needs `signers` for both methods; positioned fields do not replace this list.
137
143
  if signers.empty?
138
144
  raise ValidationError.new(
139
145
  'At least one signer is required',
@@ -241,7 +247,8 @@ module Assinafy
241
247
  def validate_method_pair!(signer)
242
248
  verification = signer[:verification_method]
243
249
  notification = signer[:notification_methods]&.first
244
- return if verification.nil? || notification.nil? || verification == 'DigitalCertificate'
250
+ return if verification.nil? || notification.nil? ||
251
+ %w[DigitalCertificate IdDocumentFaceMatchBasic].include?(verification)
245
252
  return if verification == notification
246
253
 
247
254
  raise ValidationError.new('notification_methods must match the verification_method')
@@ -356,7 +363,7 @@ module Assinafy
356
363
  # `id`. An empty descriptor (`{}`) defaults both methods to `Email`.
357
364
  #
358
365
  # @param document_id [String]
359
- # @param payload [Hash]
366
+ # @param payload [Hash] `method`, and optional `signers` (method-only descriptors) and `entries`
360
367
  # @return [Hash] cost breakdown (documents, credits, needs_extra_document, extra_document_cost,
361
368
  # total_credits, breakdown[], document_balance, credit_balance, has_sufficient_resources,
362
369
  # blocking_reason, message)
@@ -379,7 +386,7 @@ module Assinafy
379
386
  # # }
380
387
  def estimate_cost(document_id, payload)
381
388
  doc_id = require_id(document_id, 'Document ID')
382
- body = self.class.build_payload(payload, allow_signers_without_id: true)
389
+ body = self.class.build_payload(payload, allow_signers_without_id: true, require_signers: false)
383
390
 
384
391
  call('Failed to estimate assignment cost') do
385
392
  http_post("documents/#{doc_id}/assignments/estimate-cost", body)
@@ -485,6 +492,95 @@ module Assinafy
485
492
  end
486
493
  end
487
494
 
495
+ # Read the account review of a signer's biometric identity verification.
496
+ # Evidence contains summaries; images are available only while InReview.
497
+ # The signer can sign during review, but certification awaits approval.
498
+ #
499
+ # @param document_id [String]
500
+ # @param assignment_id [String]
501
+ # @param signer_id [String]
502
+ # @return [Hash] SignerIdentityReview; see docs/API_REFERENCE.md#signeridentityreview for all fields
503
+ # @raise [Assinafy::ApiError] on failure, including 404 when no verification exists
504
+ # @raise [Assinafy::NetworkError] on transport or TLS failure
505
+ # @raise [Assinafy::ValidationError] on invalid IDs
506
+ # @see GET /documents/{documentId}/assignments/{assignmentId}/signers/{signerId}/identity-verification
507
+ # @example Read an open review (no request body; workspace credentials)
508
+ # client.assignments.identity_review('document-id', 'assignment-id', 'signer-id')
509
+ # # => { 'id' => 'verification-id', 'status' => 'InReview', 'provider' => 'AssinafyVerify',
510
+ # # 'verification_method' => 'IdDocumentFaceMatchBasic',
511
+ # # 'reason_codes' => [{ 'code' => 'NAME_MISMATCH', 'description' => 'Name differs' }],
512
+ # # 'evidence_available' => true, 'expected' => { 'name' => 'Example Signer', 'cpf' => nil },
513
+ # # 'document' => { 'type' => 'CNH', 'name' => 'EXAMPLE SIGNER', 'cpf' => nil },
514
+ # # 'matches' => { 'cpf' => true, 'name' => 0.52, 'birth_date' => nil },
515
+ # # 'checks' => [{ 'name' => 'name', 'result' => 'review', 'score' => 0.52,
516
+ # # 'reasons' => ['NAME_MISMATCH'] }], 'images' => ['document_front', 'face'],
517
+ # # 'ruleset_version' => '2026-10-12.2', 'evidence_sha256' => 'sha256-placeholder',
518
+ # # 'reviewed_by' => nil, 'reviewed_at' => nil, 'review_note' => nil, 'completed_at' => nil }
519
+ def identity_review(document_id, assignment_id, signer_id)
520
+ path = identity_review_path(document_id, assignment_id, signer_id)
521
+
522
+ call('Failed to fetch signer identity review') { http_get(path) }
523
+ end
524
+
525
+ # Download one evidence image or document PDF while a review is InReview.
526
+ # Fetched from the provider on each call; keep the sensitive bytes out of logs and caches.
527
+ #
528
+ # @param document_id [String]
529
+ # @param assignment_id [String]
530
+ # @param signer_id [String]
531
+ # @param name [String] document_front, document_back, document_pdf or face, as listed in the review
532
+ # @return [String] binary JPEG, PNG or PDF bytes
533
+ # @raise [Assinafy::ApiError] on failure; 404 after a decision or when evidence is unavailable
534
+ # @raise [Assinafy::NetworkError] on transport or TLS failure
535
+ # @raise [Assinafy::ValidationError] on invalid IDs or image name
536
+ # @see GET /documents/{document}/assignments/{assignment}/signers/{signer}/identity-verification/images/{name}
537
+ # @example Read the face image (no request body; workspace credentials)
538
+ # client.assignments.identity_review_image('document-id', 'assignment-id', 'signer-id', 'face')
539
+ # # => "\x89PNG\r\n\x1A\n..." # binary String
540
+ def identity_review_image(document_id, assignment_id, signer_id, name)
541
+ path = identity_review_path(document_id, assignment_id, signer_id)
542
+ unless IDENTITY_REVIEW_IMAGES.include?(name)
543
+ raise ValidationError.new("Image name must be one of: #{IDENTITY_REVIEW_IMAGES.join(', ')}")
544
+ end
545
+
546
+ call_binary('Failed to download signer identity review image') { http_get("#{path}/images/#{name}") }
547
+ end
548
+
549
+ # Approve or reject an InReview identity verification. Rejection cancels the
550
+ # document and notifies every signer and copy receiver. The note is recorded
551
+ # in activities and sent to the provider; it is never shown to the signer.
552
+ #
553
+ # @param document_id [String]
554
+ # @param assignment_id [String]
555
+ # @param signer_id [String]
556
+ # @param decision [String] Approved or Rejected
557
+ # @param note [String, nil] optional review note, at most 2000 characters
558
+ # @return [Hash] SignerIdentityReview, the same fields as {#identity_review}, with the decision recorded
559
+ # @raise [Assinafy::ApiError] on failure; 400 unless InReview and awaiting signatures or identity review
560
+ # @raise [Assinafy::NetworkError] on transport or TLS failure
561
+ # @raise [Assinafy::ValidationError] on invalid IDs, decision or note
562
+ # @see POST /documents/{documentId}/assignments/{assignmentId}/signers/{signerId}/identity-verification/review
563
+ # @example Approve a review with workspace credentials
564
+ # client.assignments.decide_identity_review('document-id', 'assignment-id', 'signer-id',
565
+ # decision: 'Approved', note: 'Identity checked')
566
+ # # Body: { 'decision' => 'Approved', 'note' => 'Identity checked' }
567
+ # # => the identity_review object with 'status' => 'Approved', 'images' => [],
568
+ # # 'reviewed_by' => { 'id' => 'user-id', 'name' => 'Example Reviewer' },
569
+ # # 'reviewed_at' => '2026-10-16T12:00:00Z', 'review_note' => 'Identity checked'
570
+ def decide_identity_review(document_id, assignment_id, signer_id, decision:, note: nil)
571
+ path = identity_review_path(document_id, assignment_id, signer_id)
572
+ unless %w[Approved Rejected].include?(decision)
573
+ raise ValidationError.new('Decision must be Approved or Rejected')
574
+ end
575
+ unless note.nil? || (note.is_a?(String) && note.length <= 2000)
576
+ raise ValidationError.new('Review note must be a String of at most 2000 characters')
577
+ end
578
+
579
+ call('Failed to decide signer identity review') do
580
+ http_post("#{path}/review", body_params(decision: decision, note: note))
581
+ end
582
+ end
583
+
488
584
  # Fetch the document a signer is being asked to sign (signer-access-code auth).
489
585
  #
490
586
  # @param signer_access_code [String]
@@ -623,6 +719,13 @@ module Assinafy
623
719
 
624
720
  private
625
721
 
722
+ def identity_review_path(document_id, assignment_id, signer_id)
723
+ doc_id = require_id(document_id, 'Document ID')
724
+ asg_id = require_id(assignment_id, 'Assignment ID')
725
+ sid = require_id(signer_id, 'Signer ID')
726
+ "documents/#{doc_id}/assignments/#{asg_id}/signers/#{sid}/identity-verification"
727
+ end
728
+
626
729
  def normalise_sign_item(item)
627
730
  normalised = require_payload(item, 'Assignment item').each_with_object({}) do |(key, value), result|
628
731
  raw = key.to_s
@@ -17,6 +17,11 @@ module Assinafy
17
17
  total: 'x-pagination-total-count',
18
18
  last_page: 'x-pagination-page-count'
19
19
  }.freeze
20
+ RATE_LIMIT_HEADERS = {
21
+ limit: 'x-rate-limit-limit',
22
+ remaining: 'x-rate-limit-remaining',
23
+ reset: 'x-rate-limit-reset'
24
+ }.freeze
20
25
 
21
26
  # @param connection [Faraday::Connection]
22
27
  # @param default_account_id [String, nil] used by `account_id` when no override is passed
@@ -297,23 +302,32 @@ module Assinafy
297
302
 
298
303
  # On a `403` the `WWW-Authenticate` challenge names the scope an OAuth
299
304
  # token is missing, which tells "reconnect with more scope" apart from
300
- # "wrong workspace or role", so it travels with the error.
305
+ # "wrong workspace or role", so it travels with the error, as do the
306
+ # rate-limit headers a `429` needs for backing off.
301
307
  def check_status!(response, _label)
302
308
  return if (200..299).cover?(response.status)
303
309
 
304
- raise with_challenge(error_class.from_response(response.status, response.body), response)
310
+ raise with_response_headers(error_class.from_response(response.status, response.body), response)
305
311
  end
306
312
 
307
- # A 2xx envelope can still carry an error status, with the same challenge.
313
+ # A 2xx envelope can still carry an error status, with the same headers.
308
314
  def unwrap(response)
309
315
  Utils.handle_assinafy_response(response.body)
310
316
  rescue ApiError => e
311
- raise with_challenge(e, response)
317
+ raise with_response_headers(e, response)
312
318
  end
313
319
 
314
- def with_challenge(error, response)
315
- challenge = response.headers&.[]('www-authenticate')
320
+ def with_response_headers(error, response)
321
+ headers = response.headers || {}
322
+ challenge = headers['www-authenticate']
316
323
  error.context[:www_authenticate] = challenge if challenge
324
+ rate_limit = RATE_LIMIT_HEADERS.filter_map do |key, header|
325
+ value = to_int(headers[header])
326
+ [key, value] if value
327
+ end.to_h
328
+ error.context[:rate_limit] = rate_limit unless rate_limit.empty?
329
+ retry_after = to_int(headers['retry-after'])
330
+ error.context[:retry_after] = retry_after if retry_after
317
331
  error
318
332
  end
319
333
 
@@ -131,7 +131,7 @@ module Assinafy
131
131
  # Lightweight search over an account's documents (id/name/status/artifacts),
132
132
  # without the heavier per-document detail returned by {#list}.
133
133
  #
134
- # @param query [String] free-text search term
134
+ # @param query [String, nil] optional free-text search term; nil omits `search`
135
135
  # @param params [Hash] extra query parameters (`status`, `page`, `per_page`, ...)
136
136
  # @param account_id_override [String, nil]
137
137
  # @return [Hash{Symbol=>Array,Hash}] `{ data: [...], meta: {..} | nil }`
@@ -162,10 +162,10 @@ module Assinafy
162
162
  # # ... (one Hash per matching document)
163
163
  # ]
164
164
  # }
165
- def search(query, params = {}, account_id_override = nil)
165
+ def search(query = nil, params = {}, account_id_override = nil)
166
166
  acc_id = account_id(account_id_override)
167
167
  filters = require_payload(params, 'Document search parameters')
168
- require_string(query, 'Search query')
168
+ require_string(query, 'Search query') unless query.nil?
169
169
 
170
170
  call_list('Failed to search documents') do
171
171
  http_get("accounts/#{acc_id}/documents/search", filters.merge(search: query))
@@ -253,7 +253,7 @@ module Assinafy
253
253
  # Rename a document.
254
254
  #
255
255
  # @param document_id [String]
256
- # @param name [String] the new display name
256
+ # @param name [String] the new display name (at most 255 characters)
257
257
  # @return [Hash] the updated document object (envelope `data` unwrapped)
258
258
  # @raise [Assinafy::ApiError] on an unsuccessful API response
259
259
  # @raise [Assinafy::NetworkError] on transport or TLS failure
@@ -281,7 +281,7 @@ module Assinafy
281
281
  # }
282
282
  def rename(document_id, name)
283
283
  doc_id = require_id(document_id, 'Document ID')
284
- new_name = require_string(name, 'Name')
284
+ new_name = require_string(name, 'Name', max_length: 255)
285
285
 
286
286
  call('Failed to rename document') do
287
287
  http_patch("documents/#{doc_id}", body_params(name: new_name))
@@ -664,67 +664,41 @@ module Assinafy
664
664
  end
665
665
  end
666
666
 
667
- # Send a 6-digit access token for the document to a signer (public endpoint).
668
- # The current OpenAPI permits no body or an `{ email: }` body, while the
669
- # deployed API requires `{ recipient:, channel: }` when a recipient is supplied.
667
+ # Email a 6-digit access token for the document to its signer (public endpoint;
668
+ # no workspace credentials are sent).
670
669
  #
671
670
  # @param document_id [String]
672
- # @param recipient [String, nil] deployed-API email address or WhatsApp phone number
673
- # @param channel [String, nil] deployed-API `email` or `whatsapp` channel
674
- # @param email [String, nil] email address for the current OpenAPI request shape
675
- # @return [nil, Hash] `nil` for the OpenAPI's no-data envelope; the deployed
676
- # API returns `{ 'document' => {..}, 'channel' => String, 'recipient' => String }`
671
+ # @param recipient [String] the signer's email address (required)
672
+ # @param channel [String] delivery channel; only `"email"` is accepted
673
+ # @param email [String, nil] earlier name for `recipient:`; sent as `recipient`
674
+ # @return [Hash] `{ 'document' => Hash, 'channel' => String, 'recipient' => String }`
677
675
  # @raise [Assinafy::ApiError] on an unsuccessful API response
678
676
  # @raise [Assinafy::NetworkError] on transport or TLS failure
679
- # @raise [Assinafy::ValidationError] on invalid required input
677
+ # @raise [Assinafy::ValidationError] when the recipient is missing or not an email address,
678
+ # when the channel is not `email`, or when both `recipient:` and `email:` are given
680
679
  # @see PUT /public/documents/{document_id}/send-token
681
- # @example Ask the API to use the document's signer contact (no auth required)
682
- # client.documents.send_token('document-id')
680
+ # @example Email the access token to the signer (no auth required)
681
+ # client.documents.send_token('document-id', recipient: 'signer@example.com')
683
682
  #
684
- # @example Email a signer using the current OpenAPI request (no auth required)
685
683
  # # Request: PUT /public/documents/{document_id}/send-token
686
- # # Body: { "email": "signer@example.com" }
687
- # client.documents.send_token('document-id', email: 'signer@example.com')
688
- #
689
- # @example Use the deployed-API request shape
690
- # # Body: { "recipient": "signer@example.com", "channel": "email" }
691
- # client.documents.send_token('document-id', recipient: 'signer@example.com', channel: 'email')
684
+ # # Body: { "channel": "email", "recipient": "signer@example.com" }
692
685
  #
693
- # # Deployed-API response (unwrapped data payload):
694
- # {
695
- # 'document' => {
696
- # 'resource' => 'document',
697
- # 'id' => 'doc1',
698
- # 'name' => '1.pdf',
699
- # 'page_count' => '1',
700
- # 'created_by' => 'John Smith'
701
- # },
702
- # 'channel' => 'email',
703
- # 'recipient' => 'signer@example.com'
704
- # }
705
- # # => nil when the API returns the documented no-data envelope
706
- def send_token(document_id, recipient: nil, channel: nil, email: nil)
686
+ # # => {
687
+ # # 'document' => { 'resource' => 'document', 'id' => 'doc1', 'name' => '1.pdf',
688
+ # # 'page_count' => 1, 'created_by' => 'John Smith' },
689
+ # # 'channel' => 'email',
690
+ # # 'recipient' => 'signer@example.com'
691
+ # # }
692
+ def send_token(document_id, recipient: nil, channel: 'email', email: nil)
707
693
  doc_id = require_id(document_id, 'Document ID')
708
- if email.nil? && recipient.nil? && channel.nil?
709
- payload = nil
710
- elsif !email.nil?
711
- if recipient || channel
712
- raise ValidationError.new('Use either email or recipient/channel, not both')
713
- end
694
+ raise ValidationError.new('Use either recipient or email, not both') if recipient && email
695
+ raise ValidationError.new('Channel must be email') unless channel == 'email'
714
696
 
715
- payload = { email: Utils.require_email(email) }
716
- else
717
- require_string(recipient, 'Recipient')
718
- delivery_channel = require_present(channel, 'Channel').to_s
719
- unless %w[email whatsapp].include?(delivery_channel)
720
- raise ValidationError.new('Channel must be email or whatsapp')
721
- end
722
-
723
- payload = { recipient: recipient, channel: delivery_channel }
724
- end
697
+ address = Utils.require_email(recipient || email)
725
698
 
726
699
  call('Failed to send signer token') do
727
- http_put("public/documents/#{doc_id}/send-token", payload && body_params(payload), workspace_auth: false)
700
+ http_put("public/documents/#{doc_id}/send-token", { 'channel' => 'email', 'recipient' => address },
701
+ workspace_auth: false)
728
702
  end
729
703
  end
730
704
 
@@ -7,12 +7,17 @@ module Assinafy
7
7
  # See https://api.assinafy.com.br/v1/docs#field-definition for the
8
8
  # documentation of these endpoints.
9
9
  class FieldResource < BaseResource
10
+ # Default for `signer_access_code:`, telling an omitted keyword (workspace credentials)
11
+ # apart from an explicit nil (rejected).
12
+ WORKSPACE = Object.new.freeze
13
+ private_constant :WORKSPACE
14
+
10
15
  # Create a field definition.
11
16
  #
12
17
  # @param payload [Hash]
13
18
  # @option payload [String] :type required — e.g. `text`, `cpf`, `email` — see {#types}
14
19
  # @option payload [String] :name required — display label
15
- # @option payload [String] :regex optional validation regex (text fields)
20
+ # @option payload [String, nil] :regex optional validation regex (text fields); nil is sent as null
16
21
  # @option payload [Boolean] :is_required default `true`
17
22
  # @param account_id_override [String, nil]
18
23
  # @return [Hash] the created field definition (envelope `data` unwrapped)
@@ -41,7 +46,8 @@ module Assinafy
41
46
  # # }
42
47
  def create(payload, account_id_override = nil)
43
48
  acc_id = account_id(account_id_override)
44
- body = body_params(require_payload(payload))
49
+ raw = require_payload(payload)
50
+ body = with_explicit_regex(raw, body_params(raw))
45
51
  require_string(body['name'], 'Field name')
46
52
  require_string(body['type'], 'Field type')
47
53
  require_boolean(body['is_required'], 'is_required') if body.key?('is_required')
@@ -163,9 +169,7 @@ module Assinafy
163
169
  acc_id = account_id(account_id_override)
164
170
  fid = require_id(field_id, 'Field ID')
165
171
  raw = require_payload(payload)
166
- body = body_params(raw)
167
- body['regex'] = nil if (raw.key?(:regex) && raw[:regex].nil?) ||
168
- (raw.key?('regex') && raw['regex'].nil?)
172
+ body = with_explicit_regex(raw, body_params(raw))
169
173
  require_string(body['name'], 'Field name') if body.key?('name')
170
174
  require_boolean(body['is_active'], 'is_active') if body.key?('is_active')
171
175
 
@@ -197,14 +201,15 @@ module Assinafy
197
201
 
198
202
  # Validate a single value against a field definition.
199
203
  #
200
- # The OpenAPI declares workspace Authorization. The deployed API also
201
- # accepts signer-access-code authentication; pass `signer_access_code:`
202
- # to use that compatibility path without sending workspace credentials.
204
+ # The OpenAPI declares workspace Authorization, which is used when
205
+ # `signer_access_code:` is omitted. The deployed API also accepts the signer
206
+ # access code: when the keyword is given it must be a non-blank code, and
207
+ # only the code is sent (never the workspace credentials).
203
208
  #
204
209
  # @param field_id [String]
205
210
  # @param value [Object]
206
211
  # @param account_id_override [String, nil]
207
- # @param signer_access_code [String, nil]
212
+ # @param signer_access_code [String] optional keyword; nil or blank raises
208
213
  # @return [Hash{String=>Object}] `{ "type" =>, "success" =>, "error_message" => }`
209
214
  # @raise [Assinafy::ApiError] on an unsuccessful API response
210
215
  # @raise [Assinafy::NetworkError] on transport or TLS failure
@@ -221,12 +226,12 @@ module Assinafy
221
226
  # @example Validate as a signer (signer-access-code auth, sent as a query param)
222
227
  # client.fields.validate('field-id', 'Some text', signer_access_code: 'signer-access-code')
223
228
  # # => { "type" => "text", "success" => true, "error_message" => "" }
224
- def validate(field_id, value, account_id_override = nil, signer_access_code: nil)
229
+ def validate(field_id, value, account_id_override = nil, signer_access_code: WORKSPACE)
225
230
  acc_id = account_id(account_id_override)
226
231
  fid = require_id(field_id, 'Field ID')
227
232
  raise ValidationError.new('Field value is required') if value.nil?
228
233
 
229
- access_code = signer_access_code.nil? ? nil : require_signer_access_code(signer_access_code)
234
+ access_code = field_access_code(signer_access_code)
230
235
 
231
236
  call('Failed to validate field value') do
232
237
  http_post("accounts/#{acc_id}/fields/#{fid}/validate", body_params(value: value),
@@ -236,12 +241,12 @@ module Assinafy
236
241
 
237
242
  # Validate many `{ field_id:, value: }` pairs in a single call.
238
243
  #
239
- # As with {#validate}, signer-access-code support is a deployed-API
240
- # compatibility extension beyond the current OpenAPI security declaration.
244
+ # As with {#validate}, omitting `signer_access_code:` uses the workspace
245
+ # credentials; giving it requires a non-blank code and sends only the code.
241
246
  #
242
247
  # @param values [Array<Hash>]
243
248
  # @param account_id_override [String, nil]
244
- # @param signer_access_code [String, nil]
249
+ # @param signer_access_code [String] optional keyword; nil or blank raises
245
250
  # @return [Array<Hash>] one validation Hash per input, each carrying its `field_id`
246
251
  # @raise [Assinafy::ApiError] on an unsuccessful API response
247
252
  # @raise [Assinafy::NetworkError] on transport or TLS failure
@@ -265,10 +270,10 @@ module Assinafy
265
270
  # # { "field_id" => "field-id-2", "type" => "email",
266
271
  # # "success" => true, "error_message" => "" }
267
272
  # # ]
268
- def validate_multiple(values, account_id_override = nil, signer_access_code: nil)
273
+ def validate_multiple(values, account_id_override = nil, signer_access_code: WORKSPACE)
269
274
  acc_id = account_id(account_id_override)
270
275
  list = require_array(values, 'Field values')
271
- access_code = signer_access_code.nil? ? nil : require_signer_access_code(signer_access_code)
276
+ access_code = field_access_code(signer_access_code)
272
277
 
273
278
  call_array('Failed to validate field values') do
274
279
  http_post("accounts/#{acc_id}/fields/validate-multiple",
@@ -308,6 +313,16 @@ module Assinafy
308
313
 
309
314
  private
310
315
 
316
+ def field_access_code(signer_access_code)
317
+ signer_access_code.equal?(WORKSPACE) ? nil : require_signer_access_code(signer_access_code)
318
+ end
319
+
320
+ # `regex` is nullable: an explicit nil is sent as JSON null to clear it.
321
+ def with_explicit_regex(raw, body)
322
+ body['regex'] = nil if [:regex, 'regex'].any? { |key| raw.key?(key) && raw[key].nil? }
323
+ body
324
+ end
325
+
311
326
  def field_value_payload(item)
312
327
  payload = require_payload(item, 'Field value')
313
328
  field_id = payload.key?(:field_id) ? payload[:field_id] : payload['field_id']