patchwork-rb 0.1.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.
@@ -0,0 +1,162 @@
1
+ module Patchwork
2
+ # A subject is an opaque string you choose. Patchwork stores it, partitions
3
+ # threads and memory on it, and hands it back on every tool call — it never
4
+ # parses it. So this gem never parses it either, unless you ask it to.
5
+ #
6
+ # Two levels:
7
+ #
8
+ # Patchwork::Subject.validate!("ticket:8821") # any shape, just checked
9
+ #
10
+ # Membership = Patchwork::Subject.define(:user_id, :workspace_id)
11
+ # Membership.encode(user_id: "usr_1", workspace_id: "ws_acme") # => "usr_1:ws_acme"
12
+ # Membership.decode("usr_1:ws_acme").workspace_id # => "ws_acme"
13
+ #
14
+ # A definition is a format *you* declare, with as many parts as your isolation
15
+ # boundary needs, an optional namespace prefix, and your own delimiter.
16
+ module Subject
17
+ DEFAULT_DELIMITER = ":".freeze
18
+
19
+ # The rules every subject obeys, composed or not. Patchwork itself rejects
20
+ # only the empty string; the gem also rejects surrounding whitespace, because
21
+ # a trailing space from a template silently creates a second partition.
22
+ def self.validate!(subject)
23
+ raise ArgumentError, "subject must be a String, got #{subject.class}" unless subject.is_a?(String)
24
+
25
+ text = utf8(subject)
26
+ raise ArgumentError, "subject must be valid UTF-8" if text.nil?
27
+ raise ArgumentError, "subject must not be empty" if text.empty?
28
+ raise ArgumentError, "subject must not have leading or trailing whitespace" if padded?(text)
29
+ raise ArgumentError, "subject must not contain control characters" if CONTROL.match?(text)
30
+
31
+ subject
32
+ end
33
+
34
+ # Unicode whitespace, not just ASCII: a non-breaking space pasted from HTML
35
+ # is the same silent second partition as a trailing ASCII space.
36
+ EDGE_SPACE = /\A[[:space:]\u200B\u2060\uFEFF]|[[:space:]\u200B\u2060\uFEFF]\z/
37
+ # CR and LF matter beyond hygiene: in relay the subject travels in a header.
38
+ CONTROL = /[[:cntrl:]]/
39
+
40
+ def self.padded?(text)
41
+ EDGE_SPACE.match?(text)
42
+ end
43
+
44
+ # Returns a UTF-8 copy, or nil when the bytes are not valid in their own
45
+ # encoding or have no UTF-8 form. Never raises on hostile input.
46
+ def self.utf8(value)
47
+ return nil unless value.valid_encoding?
48
+
49
+ text = value.encoding == Encoding::UTF_8 ? value : value.encode(Encoding::UTF_8)
50
+ text.valid_encoding? ? text : nil
51
+ rescue EncodingError
52
+ nil
53
+ end
54
+
55
+ def self.define(*parts, delimiter: DEFAULT_DELIMITER, prefix: nil)
56
+ Format.new(parts, delimiter: delimiter, prefix: prefix)
57
+ end
58
+
59
+ class Format
60
+ attr_reader :parts, :delimiter, :prefix
61
+
62
+ def initialize(parts, delimiter:, prefix:)
63
+ raise ArgumentError, "define at least one part" if parts.empty?
64
+ raise ArgumentError, "parts must be symbols" unless parts.all?(Symbol)
65
+ raise ArgumentError, "parts must be unique" unless parts.uniq.size == parts.size
66
+ parts.each { |part| name!(part) }
67
+ raise ArgumentError, "delimiter must be a non-empty String" unless delimiter.is_a?(String) && !delimiter.empty?
68
+
69
+ text = Subject.utf8(delimiter)
70
+ if text.nil? || /[[:space:][:cntrl:]]/.match?(text)
71
+ raise ArgumentError, "delimiter must be valid UTF-8 with no whitespace or control characters"
72
+ end
73
+
74
+ @parts = parts.freeze
75
+ @delimiter = text.dup.freeze
76
+ @prefix = prefix&.to_s&.then { |value| component!(:prefix, value) }&.freeze
77
+ @ref = Struct.new(*parts, keyword_init: true)
78
+ freeze
79
+ end
80
+
81
+ # Keyword-only, so the order of the parts is fixed by the definition and
82
+ # never by a call site — a flipped order is a different subject.
83
+ def encode(**values)
84
+ missing = parts - values.keys
85
+ unknown = values.keys - parts
86
+ raise ArgumentError, "missing subject parts: #{missing.join(', ')}" if missing.any?
87
+ raise ArgumentError, "unknown subject parts: #{unknown.join(', ')}" if unknown.any?
88
+
89
+ segments = parts.map { |part| component!(part, values[part].to_s) }
90
+ subject = [ prefix, *segments ].compact.join(delimiter)
91
+
92
+ # With a multi-character delimiter a part can end with half of it
93
+ # ("acme:" + "::" + "x"), making two tuples encode to one string. The
94
+ # only complete check is that the string decodes back to what went in.
95
+ unless decode(subject)&.to_h == parts.zip(segments).to_h
96
+ raise ArgumentError, "subject parts are ambiguous with the delimiter #{delimiter.inspect}"
97
+ end
98
+
99
+ subject
100
+ end
101
+
102
+ # Returns a struct with one reader per part, or nil when the subject is not
103
+ # in this format. Never guesses: the part count must match exactly.
104
+ def decode(subject)
105
+ return nil unless subject.is_a?(String)
106
+
107
+ body = Subject.utf8(subject)
108
+ return nil if body.nil?
109
+ if prefix
110
+ head = "#{prefix}#{delimiter}"
111
+ return nil unless body.start_with?(head)
112
+
113
+ body = body.delete_prefix(head)
114
+ end
115
+
116
+ segments = body.split(delimiter, -1)
117
+ return nil unless segments.size == parts.size
118
+ return nil if segments.any? { |segment| segment.empty? || Subject.padded?(segment) || CONTROL.match?(segment) }
119
+
120
+ @ref.new(**parts.zip(segments).to_h).freeze
121
+ end
122
+
123
+ def decode!(subject)
124
+ decode(subject) || raise(UnknownSubject, "subject is not in the #{inspect} format")
125
+ end
126
+
127
+ def match?(subject)
128
+ !decode(subject).nil?
129
+ end
130
+
131
+ def inspect
132
+ "#<Patchwork::Subject #{[ prefix, *parts.map { |part| "<#{part}>" } ].compact.join(delimiter)}>"
133
+ end
134
+ alias to_s inspect
135
+
136
+ private
137
+
138
+ # A part containing the delimiter makes the string ambiguous to decode, so
139
+ # it is refused at encode time rather than mis-split later.
140
+ def component!(name, value)
141
+ text = Subject.utf8(value)
142
+ raise ArgumentError, "subject part #{name} must be valid UTF-8" if text.nil?
143
+ raise ArgumentError, "subject part #{name} is empty" if text.empty?
144
+ raise ArgumentError, "subject part #{name} contains the delimiter #{delimiter.inspect}" if text.include?(delimiter)
145
+ raise ArgumentError, "subject part #{name} has leading or trailing whitespace" if Subject.padded?(text)
146
+ raise ArgumentError, "subject part #{name} contains control characters" if CONTROL.match?(text)
147
+
148
+ text
149
+ end
150
+
151
+ # A part named like a Struct method (:freeze, :hash, :class, :to_h)
152
+ # would shadow it on the decoded ref, so decode could hand back a part's
153
+ # raw string where a frozen ref was expected.
154
+ RESERVED = Struct.new(:placeholder).new.public_methods.freeze
155
+
156
+ def name!(part)
157
+ raise ArgumentError, "part #{part.inspect} must be a lowercase identifier" unless /\A[a-z_][a-z0-9_]*\z/.match?(part)
158
+ raise ArgumentError, "part #{part.inspect} clashes with a Struct method" if RESERVED.include?(part)
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,3 @@
1
+ module Patchwork
2
+ VERSION = "0.1.0".freeze
3
+ end
@@ -0,0 +1,77 @@
1
+ require "json"
2
+ require "uri"
3
+
4
+ module Patchwork
5
+ module Webhook
6
+ EVENT_HEADER = "Patchwork-Event".freeze
7
+ DELIVERY_HEADER = "Patchwork-Delivery".freeze
8
+
9
+ Event = Struct.new(:id, :type, :created_at, :origin, :source, :context, :data, :payload, keyword_init: true) do
10
+ def run_id = payload["run_id"]
11
+ def thread_id = payload["thread_id"]
12
+ def workspace_id = context.is_a?(Hash) ? context["workspace_id"] : nil
13
+ def test? = type == "endpoint.test"
14
+ end
15
+
16
+ # Webhooks are keyed with the endpoint's own secret, not the request secret.
17
+ # Pass `secret:`, or `secrets:` with the current and previous during a
18
+ # rotation.
19
+ def self.verify!(body:, signature:, path:, secret: nil, secrets: nil, skew: Signature::SKEW, now: Time.now.to_i)
20
+ raise ArgumentError, "pass secret: or secrets:, not both" if secret && secrets
21
+
22
+ Signature.verify!(
23
+ secrets: secrets || [ secret ],
24
+ header: signature,
25
+ method: "POST",
26
+ path: normalize(path),
27
+ body: body,
28
+ skew: skew,
29
+ now: now
30
+ )
31
+
32
+ parse(body)
33
+ end
34
+
35
+ # Like verify!, but returns nil for a bad or stale signature. A missing
36
+ # secret still raises: that is a deploy problem, not a forged delivery.
37
+ def self.verify(**options)
38
+ verify!(**options)
39
+ rescue InvalidSignature
40
+ nil
41
+ end
42
+
43
+ def self.parse(body)
44
+ payload = JSON.parse(body.to_s)
45
+ raise Error, "webhook body is not a JSON object" unless payload.is_a?(Hash)
46
+
47
+ Event.new(
48
+ id: payload["id"],
49
+ type: payload["type"],
50
+ created_at: payload["created_at"],
51
+ origin: payload["origin"],
52
+ source: payload["source"],
53
+ context: payload["context"],
54
+ data: payload["data"],
55
+ payload: payload
56
+ )
57
+ rescue JSON::ParserError => e
58
+ raise Error, "webhook body is not JSON: #{e.message}"
59
+ end
60
+
61
+ # Only the path is signed — never the host, query or fragment — so accept a
62
+ # registered URL, a path, or request.fullpath and reduce each to the path.
63
+ def self.normalize(path)
64
+ value = path.to_s
65
+ if value.match?(%r{\Ahttps?://}i)
66
+ begin
67
+ value = URI(value).path.to_s
68
+ rescue URI::InvalidURIError
69
+ raise ArgumentError, "path is not a valid URL: #{value.inspect}"
70
+ end
71
+ end
72
+ value = value.split(/[?#]/, 2).first.to_s
73
+ value.empty? ? "/" : value
74
+ end
75
+ private_class_method :normalize
76
+ end
77
+ end
@@ -0,0 +1 @@
1
+ require "patchwork"
data/lib/patchwork.rb ADDED
@@ -0,0 +1,41 @@
1
+ require "patchwork/version"
2
+ require "patchwork/errors"
3
+ require "patchwork/configuration"
4
+ require "patchwork/secure_compare"
5
+ require "patchwork/signature"
6
+ require "patchwork/signing_key"
7
+ require "patchwork/subject"
8
+ require "patchwork/session_token"
9
+ require "patchwork/presented"
10
+ require "patchwork/webhook"
11
+ require "patchwork/health_check"
12
+ require "patchwork/mint"
13
+ require "patchwork/bridge_assertion"
14
+
15
+ module Patchwork
16
+ class << self
17
+ def config
18
+ @config ||= Configuration.new
19
+ end
20
+
21
+ def configure
22
+ yield config
23
+ SigningKey.reset!
24
+ config
25
+ end
26
+
27
+ def reset!
28
+ @config = Configuration.new
29
+ SigningKey.reset!
30
+ end
31
+ end
32
+ end
33
+
34
+ # The gateway is optional: it is defined only when Rack is available. Only a
35
+ # missing Rack is tolerated — any other LoadError in the gateway still raises.
36
+ begin
37
+ require "rack"
38
+ rescue LoadError
39
+ nil
40
+ end
41
+ require "patchwork/gateway" if defined?(Rack)
metadata ADDED
@@ -0,0 +1,109 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: patchwork-rb
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Chromablue Labs
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: jwt
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 2.10.3
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '4.0'
22
+ - - "!="
23
+ - !ruby/object:Gem::Version
24
+ version: 3.0.0
25
+ - - "!="
26
+ - !ruby/object:Gem::Version
27
+ version: 3.1.0
28
+ - - "!="
29
+ - !ruby/object:Gem::Version
30
+ version: 3.1.1
31
+ - - "!="
32
+ - !ruby/object:Gem::Version
33
+ version: 3.1.2
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: 2.10.3
41
+ - - "<"
42
+ - !ruby/object:Gem::Version
43
+ version: '4.0'
44
+ - - "!="
45
+ - !ruby/object:Gem::Version
46
+ version: 3.0.0
47
+ - - "!="
48
+ - !ruby/object:Gem::Version
49
+ version: 3.1.0
50
+ - - "!="
51
+ - !ruby/object:Gem::Version
52
+ version: 3.1.1
53
+ - - "!="
54
+ - !ruby/object:Gem::Version
55
+ version: 3.1.2
56
+ description: 'Server-side helpers for integrating with Patchwork: RS256 session tokens,
57
+ HMAC request signing and verification, webhook signature verification, and Rack
58
+ middleware for hosted tool calls. This is the inbound half — everything Patchwork
59
+ sends you, and the credentials you hand it. A client for calling the Patchwork API
60
+ arrives in 0.2.'
61
+ executables: []
62
+ extensions: []
63
+ extra_rdoc_files: []
64
+ files:
65
+ - LICENSE
66
+ - README.md
67
+ - lib/patchwork-rb.rb
68
+ - lib/patchwork.rb
69
+ - lib/patchwork/bridge_assertion.rb
70
+ - lib/patchwork/configuration.rb
71
+ - lib/patchwork/errors.rb
72
+ - lib/patchwork/gateway.rb
73
+ - lib/patchwork/health_check.rb
74
+ - lib/patchwork/mint.rb
75
+ - lib/patchwork/presented.rb
76
+ - lib/patchwork/rails.rb
77
+ - lib/patchwork/secure_compare.rb
78
+ - lib/patchwork/session_token.rb
79
+ - lib/patchwork/signature.rb
80
+ - lib/patchwork/signing_key.rb
81
+ - lib/patchwork/subject.rb
82
+ - lib/patchwork/version.rb
83
+ - lib/patchwork/webhook.rb
84
+ homepage: https://usepatchwork.co
85
+ licenses:
86
+ - MIT
87
+ metadata:
88
+ homepage_uri: https://usepatchwork.co
89
+ source_code_uri: https://github.com/chromablue-labs/crossbar
90
+ bug_tracker_uri: https://github.com/chromablue-labs/crossbar/issues
91
+ rubygems_mfa_required: 'true'
92
+ rdoc_options: []
93
+ require_paths:
94
+ - lib
95
+ required_ruby_version: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ version: '3.1'
100
+ required_rubygems_version: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: '0'
105
+ requirements: []
106
+ rubygems_version: 3.6.9
107
+ specification_version: 4
108
+ summary: Ruby SDK for Patchwork — mint session tokens, verify tool calls and webhooks
109
+ test_files: []