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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +348 -0
- data/lib/patchwork/bridge_assertion.rb +64 -0
- data/lib/patchwork/configuration.rb +63 -0
- data/lib/patchwork/errors.rb +9 -0
- data/lib/patchwork/gateway.rb +180 -0
- data/lib/patchwork/health_check.rb +36 -0
- data/lib/patchwork/mint.rb +38 -0
- data/lib/patchwork/presented.rb +53 -0
- data/lib/patchwork/rails.rb +70 -0
- data/lib/patchwork/secure_compare.rb +13 -0
- data/lib/patchwork/session_token.rb +65 -0
- data/lib/patchwork/signature.rb +105 -0
- data/lib/patchwork/signing_key.rb +69 -0
- data/lib/patchwork/subject.rb +162 -0
- data/lib/patchwork/version.rb +3 -0
- data/lib/patchwork/webhook.rb +77 -0
- data/lib/patchwork-rb.rb +1 -0
- data/lib/patchwork.rb +41 -0
- metadata +109 -0
|
@@ -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,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
|
data/lib/patchwork-rb.rb
ADDED
|
@@ -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: []
|