parse-stack-next 5.3.0 → 5.4.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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/CHANGELOG.md +461 -0
  4. data/Gemfile +7 -0
  5. data/Gemfile.lock +12 -4
  6. data/README.md +160 -3
  7. data/Rakefile +52 -3
  8. data/docs/atlas_vector_search_guide.md +86 -2
  9. data/docs/client_sdk_guide.md +5 -0
  10. data/docs/mcp_guide.md +59 -4
  11. data/docs/mongodb_direct_guide.md +93 -1
  12. data/docs/usage_guide.md +11 -1
  13. data/docs/webhooks_guide.md +418 -0
  14. data/examples/README.md +46 -0
  15. data/examples/basic_client.rb +93 -0
  16. data/examples/basic_server.rb +109 -0
  17. data/examples/live_query_listener.rb +98 -0
  18. data/examples/rag_chatbot.rb +221 -0
  19. data/examples/webhook_server.rb +111 -0
  20. data/lib/parse/agent/mcp_rack_app.rb +285 -62
  21. data/lib/parse/agent/tools.rb +45 -5
  22. data/lib/parse/api/aggregate.rb +7 -1
  23. data/lib/parse/api/cloud_functions.rb +12 -4
  24. data/lib/parse/api/hooks.rb +46 -9
  25. data/lib/parse/api/objects.rb +16 -2
  26. data/lib/parse/api/path_segment.rb +33 -0
  27. data/lib/parse/api/server.rb +94 -0
  28. data/lib/parse/api/users.rb +58 -2
  29. data/lib/parse/atlas_search.rb +7 -7
  30. data/lib/parse/client/body_builder.rb +5 -0
  31. data/lib/parse/client/protocol.rb +4 -0
  32. data/lib/parse/client.rb +55 -2
  33. data/lib/parse/embeddings/spend_cap.rb +255 -0
  34. data/lib/parse/embeddings.rb +1 -0
  35. data/lib/parse/live_query/client.rb +3 -1
  36. data/lib/parse/live_query/subscription.rb +32 -5
  37. data/lib/parse/model/acl.rb +4 -2
  38. data/lib/parse/model/classes/audience.rb +52 -4
  39. data/lib/parse/model/classes/user.rb +180 -3
  40. data/lib/parse/model/core/embed_managed.rb +113 -0
  41. data/lib/parse/model/core/querying.rb +3 -1
  42. data/lib/parse/model/core/vector_searchable.rb +161 -0
  43. data/lib/parse/model/object.rb +28 -5
  44. data/lib/parse/mongodb.rb +7 -1
  45. data/lib/parse/pipeline_security.rb +5 -3
  46. data/lib/parse/query/constraints.rb +29 -0
  47. data/lib/parse/query.rb +265 -27
  48. data/lib/parse/retrieval/agent_tool.rb +49 -0
  49. data/lib/parse/retrieval/reranker/cohere.rb +218 -0
  50. data/lib/parse/retrieval/reranker.rb +157 -0
  51. data/lib/parse/retrieval/retriever.rb +110 -23
  52. data/lib/parse/stack/version.rb +1 -1
  53. data/lib/parse/stack.rb +17 -0
  54. data/lib/parse/two_factor_auth/user_extension.rb +123 -31
  55. data/lib/parse/vector_search/hybrid.rb +578 -0
  56. data/lib/parse/webhooks/payload.rb +252 -7
  57. data/lib/parse/webhooks/trigger_audit.rb +502 -0
  58. data/lib/parse/webhooks.rb +215 -3
  59. data/scripts/docker/Dockerfile.parse +5 -1
  60. data/scripts/docker/docker-compose.test.yml +31 -0
  61. data/scripts/docker/docker-compose.verifyemail.yml +4 -0
  62. data/scripts/docker/preflight.sh +76 -0
  63. data/scripts/start-parse.sh +52 -4
  64. metadata +15 -1
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Live Query Listener for parse-stack-next
5
+ #
6
+ # An interactive console listener — like a tail / `rails console` that just
7
+ # prints: it logs in as a user, opens a LiveQuery subscription scoped to that
8
+ # user's session token, and prints every event (create / update / delete /
9
+ # enter / leave) until you press Ctrl-C.
10
+ #
11
+ # Because the subscription carries the user's `sessionToken`, Parse Server
12
+ # enforces ACLs on the live stream too: you only receive events for objects
13
+ # that user is allowed to read — "whatever the user can hear." Swap in a
14
+ # master-key subscription (use_master_key: true) to hear everything.
15
+ #
16
+ # Prerequisite: the `Post` class must exist on the server (run
17
+ # examples/basic_server.rb first, which provisions it). You also need a Parse
18
+ # Server with the LiveQuery websocket server enabled for the Post class.
19
+ #
20
+ # Run it, then in another terminal create/update/destroy Posts (e.g. via
21
+ # examples/basic_client.rb or the dashboard) and watch them stream in:
22
+ # export PARSE_SERVER_URL=http://localhost:1337/parse
23
+ # export PARSE_APP_ID=... PARSE_REST_KEY=...
24
+ # export PARSE_LIVE_QUERY_URL=ws://localhost:1337/parse # ws:// or wss://
25
+ # ruby examples/live_query_listener.rb
26
+
27
+ require "parse-stack-next"
28
+ require "parse/live_query"
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # 1. Configure the REST client + the LiveQuery websocket client
32
+ # ---------------------------------------------------------------------------
33
+ Parse.setup(
34
+ server_url: ENV.fetch("PARSE_SERVER_URL", "http://localhost:1337/parse"),
35
+ app_id: ENV.fetch("PARSE_APP_ID"),
36
+ api_key: ENV.fetch("PARSE_REST_KEY"),
37
+ master_key: nil, # a plain client — the session token does the scoping
38
+ logging: false,
39
+ )
40
+
41
+ Parse.live_query_enabled = true
42
+ Parse::LiveQuery.configure do |config|
43
+ config.url = ENV.fetch("PARSE_LIVE_QUERY_URL", "ws://localhost:1337/parse")
44
+ config.application_id = ENV.fetch("PARSE_APP_ID")
45
+ config.client_key = ENV.fetch("PARSE_REST_KEY")
46
+ end
47
+
48
+ class Post < Parse::Object
49
+ property :title, :string
50
+ property :body, :string
51
+ end
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # 2. Authenticate (so the subscription is ACL-scoped to this user)
55
+ # ---------------------------------------------------------------------------
56
+ USERNAME = ENV.fetch("PARSE_USERNAME", "ada")
57
+ PASSWORD = ENV.fetch("PARSE_PASSWORD", "p4ssw0rd!")
58
+
59
+ user = Parse::User.login(USERNAME, PASSWORD) ||
60
+ Parse::User.signup(USERNAME, PASSWORD, "ada@example.com")
61
+ puts "Listening as #{user.username} (#{user.id}) — only Posts this user can read.\n\n"
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # 3. Open the subscription + register handlers
65
+ # ---------------------------------------------------------------------------
66
+ def stamp = Time.now.strftime("%H:%M:%S")
67
+
68
+ # `where:` narrows the live query (omit it to hear every readable Post);
69
+ # `session_token:` is what makes Parse Server apply this user's ACL to the
70
+ # stream. Use `use_master_key: true` instead to listen to everything.
71
+ subscription = Post.subscribe(
72
+ where: {}, # e.g. { :title.exists => true }
73
+ session_token: user.session_token,
74
+ )
75
+
76
+ subscription.on(:subscribe) { puts "[#{stamp}] subscribed — waiting for events…" }
77
+ subscription.on(:create) { |post| puts "[#{stamp}] CREATE #{post.id} #{post.title.inspect}" }
78
+ subscription.on(:update) { |post, _orig| puts "[#{stamp}] UPDATE #{post.id} #{post.title.inspect}" }
79
+ subscription.on(:delete) { |post| puts "[#{stamp}] DELETE #{post.id}" }
80
+ subscription.on(:enter) { |post, _orig| puts "[#{stamp}] ENTER #{post.id} (now matches query)" }
81
+ subscription.on(:leave) { |post, _orig| puts "[#{stamp}] LEAVE #{post.id} (no longer matches)" }
82
+ subscription.on(:error) { |err| warn "[#{stamp}] ERROR #{err}" }
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # 4. Block and print until Ctrl-C
86
+ # ---------------------------------------------------------------------------
87
+ running = true
88
+ trap("INT") do
89
+ running = false # keep the handler tiny — just flip the flag
90
+ end
91
+
92
+ puts "Press Ctrl-C to stop.\n\n"
93
+ sleep 0.2 while running # events arrive on the websocket thread
94
+
95
+ puts "\nStopping…"
96
+ subscription.unsubscribe
97
+ Parse::LiveQuery.reset! # closes the websocket connection
98
+ puts "Done."
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # RAG Chatbot Example for parse-stack-next
5
+ #
6
+ # A retrieval-augmented-generation (RAG) chatbot in a single file:
7
+ #
8
+ # 1. Store documents in Parse; the SDK manages their embeddings on save.
9
+ # 2. Retrieve the most relevant passages for a question with the
10
+ # `semantic_search` agent tool.
11
+ # 3. Hand those passages to an LLM to write the answer.
12
+ #
13
+ # The SDK owns the **R** (retrieval) and the embedding lifecycle. The **G**
14
+ # (generation) is a thin add-in over the OpenAI or Anthropic HTTP API, shown
15
+ # here with zero extra gems (`net/http` only).
16
+ #
17
+ # Retrieval (`Parse::Retrieval` / `semantic_search`) shipped in v5.2; managed
18
+ # `embed` and the embeddings provider registry shipped in v5.1. Vector search
19
+ # runs against MongoDB Atlas (`$vectorSearch`), so point the SDK at an
20
+ # Atlas-backed Parse Server / Mongo URI.
21
+ #
22
+ # Run it:
23
+ # export OPENAI_API_KEY=sk-... # embeddings + (optionally) generation
24
+ # export ANTHROPIC_API_KEY=sk-ant-... # if using the Anthropic backend
25
+ # export PARSE_SERVER_URL=http://localhost:1337/parse
26
+ # export PARSE_APP_ID=... PARSE_REST_KEY=... PARSE_MASTER_KEY=...
27
+ # ruby examples/rag_chatbot.rb
28
+
29
+ require "parse-stack-next"
30
+ require "net/http"
31
+ require "json"
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # 1. Embedding provider + Parse connection
35
+ # ---------------------------------------------------------------------------
36
+
37
+ # text-embedding-3-small is 1536-dim. Register it under :openai so the model's
38
+ # :vector property can resolve it by name at save / query time.
39
+ Parse::Embeddings.register(
40
+ :openai,
41
+ Parse::Embeddings::OpenAI.new(api_key: ENV.fetch("OPENAI_API_KEY")),
42
+ )
43
+
44
+ Parse.setup(
45
+ server_url: ENV.fetch("PARSE_SERVER_URL", "http://localhost:1337/parse"),
46
+ app_id: ENV.fetch("PARSE_APP_ID"),
47
+ api_key: ENV.fetch("PARSE_REST_KEY"),
48
+ master_key: ENV.fetch("PARSE_MASTER_KEY"),
49
+ )
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # 2. The model
53
+ # ---------------------------------------------------------------------------
54
+ #
55
+ # `embed` declares a MANAGED embedding: list the source fields, name the
56
+ # :vector property they feed, and the SDK recomputes the vector on `save`
57
+ # whenever those fields change (digest-tracked, so a no-op save makes zero
58
+ # provider calls). You never assign `embedding` yourself — it is
59
+ # write-protected. Set title/body and save.
60
+ #
61
+ # `agent_searchable` opts the class into the `semantic_search` agent tool and
62
+ # declares which fields an agent may filter on.
63
+ class KnowledgeArticle < Parse::Object
64
+ property :title, :string
65
+ property :body, :string
66
+ property :category, :string
67
+
68
+ property :embedding, :vector, dimensions: 1536, provider: :openai
69
+
70
+ # title + body feed :embedding, recomputed on save.
71
+ embed :title, :body, into: :embedding
72
+
73
+ # Opt into semantic_search; allow filtering on :category.
74
+ agent_searchable field: :embedding, filter_fields: %i[category]
75
+
76
+ # Declare the Atlas $vectorSearch index that retrieval needs.
77
+ mongo_search_index "knowledge_embedding",
78
+ { fields: [{ type: "vector", path: "embedding",
79
+ numDimensions: 1536, similarity: "cosine" }] },
80
+ type: "vectorSearch"
81
+ end
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # 3. The LLM generation add-in (NOT part of the SDK)
85
+ # ---------------------------------------------------------------------------
86
+ #
87
+ # ~15 lines of HTTP per backend. Both take the retrieved chunks as context and
88
+ # return an answer grounded in them.
89
+ module ChatAnswerer
90
+ PROMPT = <<~SYS
91
+ You are a support assistant. Answer ONLY from the context below.
92
+ If the context does not contain the answer, say you don't know.
93
+ SYS
94
+
95
+ module_function
96
+
97
+ def context(chunks)
98
+ chunks.map { |c| "- #{c[:content]}" }.join("\n")
99
+ end
100
+
101
+ # --- OpenAI backend ---
102
+ def openai(question, chunks, model: "gpt-4o-mini")
103
+ post("https://api.openai.com/v1/chat/completions",
104
+ { "Authorization" => "Bearer #{ENV.fetch('OPENAI_API_KEY')}" },
105
+ { model: model,
106
+ messages: [
107
+ { role: "system", content: PROMPT },
108
+ { role: "user",
109
+ content: "Context:\n#{context(chunks)}\n\nQuestion: #{question}" },
110
+ ] })
111
+ .dig("choices", 0, "message", "content")
112
+ end
113
+
114
+ # --- Anthropic backend ---
115
+ def anthropic(question, chunks, model: "claude-opus-4-8")
116
+ post("https://api.anthropic.com/v1/messages",
117
+ { "x-api-key" => ENV.fetch("ANTHROPIC_API_KEY"),
118
+ "anthropic-version" => "2023-06-01" },
119
+ { model: model, max_tokens: 1024, system: PROMPT,
120
+ messages: [
121
+ { role: "user",
122
+ content: "Context:\n#{context(chunks)}\n\nQuestion: #{question}" },
123
+ ] })
124
+ .dig("content", 0, "text")
125
+ end
126
+
127
+ def post(url, headers, body)
128
+ uri = URI(url)
129
+ req = Net::HTTP::Post.new(uri, { "Content-Type" => "application/json" }.merge(headers))
130
+ req.body = JSON.generate(body)
131
+ res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
132
+ JSON.parse(res.body)
133
+ end
134
+ end
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # 4. Retrieval helper
138
+ # ---------------------------------------------------------------------------
139
+ #
140
+ # An unscoped Parse::Agent (no session_token: / acl_user: / acl_role:) runs in
141
+ # master posture using the master key already on the default client from
142
+ # Parse.setup — there is NO `master_key:` constructor argument.
143
+ #
144
+ # To scope retrieval to a signed-in user (so the bot only ever sees documents
145
+ # that user may read), construct it as:
146
+ # Parse::Agent.new(session_token: user.session_token)
147
+ #
148
+ # `execute(:semantic_search, ...)` returns
149
+ # { success:, data: { chunks:, documents:, count: } }.
150
+ def retrieve(agent, question, k: 4)
151
+ result = agent.execute(:semantic_search,
152
+ class_name: "KnowledgeArticle",
153
+ query: question,
154
+ k: k)
155
+ raise result[:error].to_s unless result[:success]
156
+
157
+ # Each chunk: { id:, score:, content:, metadata: { object_id:, ... } }.
158
+ # The parent record lives once in data[:documents], keyed by objectId.
159
+ result[:data][:chunks]
160
+ end
161
+
162
+ # ---------------------------------------------------------------------------
163
+ # 5. Seed a corpus + chat loop (runs when executed directly)
164
+ # ---------------------------------------------------------------------------
165
+
166
+ CORPUS = [
167
+ { title: "Resetting your password",
168
+ body: "Open Settings, choose Security, then Reset Password. A link is emailed to you.",
169
+ category: "account" },
170
+ { title: "Exporting your data",
171
+ body: "Use Settings > Export to download a ZIP of all your documents as JSON.",
172
+ category: "data" },
173
+ { title: "Billing cycles",
174
+ body: "Plans renew monthly on the date you subscribed. Cancel anytime before renewal.",
175
+ category: "billing" },
176
+ ].freeze
177
+
178
+ # Bulk back-fill / precompute outside the managed-save path: call the provider
179
+ # directly. `embed_text_batched` splits into the provider's recommended batch
180
+ # size (OpenAI: 100) and returns one vector per string, in order. (Not used by
181
+ # the demo below — `save` handles embedding — but shown for completeness.)
182
+ def precompute_vectors(texts)
183
+ provider = Parse::Embeddings.provider(:openai)
184
+ provider.embed_text_batched(texts, input_type: :search_document)
185
+ end
186
+
187
+ def seed_corpus!
188
+ # $vectorSearch needs an Atlas vector index on `embedding`, or retrieval
189
+ # raises IndexNotResolved. The model declares it; apply it once. This uses
190
+ # the mongo-direct writer (requires Parse::MongoDB configured against Atlas)
191
+ # — or create the same index in the Atlas UI.
192
+ KnowledgeArticle.apply_search_indexes!(wait: true)
193
+
194
+ # Managed embedding means ingestion is just `save`: each save that changes a
195
+ # source field makes one embedding call; unchanged re-saves make none.
196
+ CORPUS.each { |attrs| KnowledgeArticle.new(attrs).save }
197
+ end
198
+
199
+ def chat_loop(backend: :anthropic)
200
+ # Master posture (reads everything). Emits a one-time master-key warning to
201
+ # stderr; silence it with Parse::Agent.suppress_master_key_warning = true.
202
+ agent = Parse::Agent.new
203
+
204
+ puts "Ask a question (Ctrl-D to quit):"
205
+ while (question = $stdin.gets&.strip)
206
+ next if question.empty?
207
+
208
+ chunks = retrieve(agent, question)
209
+ answer = ChatAnswerer.public_send(backend, question, chunks)
210
+
211
+ puts "\n#{answer}\n"
212
+ sources = chunks.map { |c| c.dig(:metadata, :object_id) }.uniq.join(", ")
213
+ puts " (sources: #{sources})\n\n"
214
+ end
215
+ end
216
+
217
+ if __FILE__ == $PROGRAM_NAME
218
+ seed_corpus!
219
+ # Pick :openai or :anthropic for the generation step.
220
+ chat_loop(backend: :anthropic)
221
+ end
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Cloud Code Webhooks for parse-stack-next
5
+ #
6
+ # Webhooks are how a Ruby backend runs SERVER-SIDE trigger logic. Without them,
7
+ # a Parse::Object's ActiveModel callbacks (before_save, after_create, …) run
8
+ # ONLY in the Ruby process that initiated the save. A write that comes from a
9
+ # JS/Swift/REST client — or the Parse Dashboard — never touches your Ruby code,
10
+ # so that logic is silently skipped server-side.
11
+ #
12
+ # Registering a webhook flips that: Parse Server calls back into this Ruby app
13
+ # on the matching trigger, and your ActiveModel callbacks + webhook blocks run
14
+ # for EVERY client, not just Ruby ones.
15
+ #
16
+ # This file is a Rack app. Mount it (config.ru):
17
+ #
18
+ # require_relative "examples/webhook_server"
19
+ # run Parse::Webhooks
20
+ #
21
+ # and point Parse Server's `webhookKey` at the same value as Parse::Webhooks.key.
22
+ #
23
+ # See docs/webhooks_guide.md for the full picture (trigger types, the
24
+ # ActiveModel↔Parse hook relationship, latency, and replay protection).
25
+
26
+ require "parse-stack-next"
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # 1. Configure the (master-key) client + the webhook key
30
+ # ---------------------------------------------------------------------------
31
+ Parse.setup(
32
+ server_url: ENV.fetch("PARSE_SERVER_URL", "http://localhost:1337/parse"),
33
+ app_id: ENV.fetch("PARSE_APP_ID"),
34
+ api_key: ENV.fetch("PARSE_REST_KEY"),
35
+ master_key: ENV.fetch("PARSE_MASTER_KEY"),
36
+ )
37
+
38
+ # Shared secret Parse Server sends as `X-Parse-Webhook-Key`. Set the same value
39
+ # in Parse Server's `webhookKey` option. Requests without it are rejected.
40
+ Parse::Webhooks.key = ENV.fetch("PARSE_WEBHOOK_KEY")
41
+
42
+ # Optional: server-initiated replay/freshness protection (see the guide). The
43
+ # body+request-id dedup is always on; an HMAC signing secret adds freshness
44
+ # verification of X-Parse-Webhook-Timestamp / X-Parse-Webhook-Signature.
45
+ Parse::Webhooks::ReplayProtection.signing_secret = ENV["PARSE_WEBHOOK_SIGNING_SECRET"]
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # 2. A model with both ActiveModel callbacks AND webhook blocks
49
+ # ---------------------------------------------------------------------------
50
+ class Post < Parse::Object
51
+ property :title, :string, required: true
52
+ property :slug, :string
53
+ property :published, :boolean, default: false
54
+
55
+ # ActiveModel callbacks. For a Ruby-initiated save these run locally; for a
56
+ # NON-Ruby client they run here only if a beforeSave/afterSave webhook is
57
+ # registered for Post. Registering beforeSave enables BOTH before_save and
58
+ # before_create; afterSave enables both after_save and after_create.
59
+ before_save :normalize_slug
60
+ before_create { self.published = false } # created drafts start unpublished
61
+ after_create :enqueue_welcome # see note on afterSave below
62
+
63
+ def normalize_slug
64
+ self.slug = title.to_s.downcase.strip.gsub(/[^a-z0-9]+/, "-") if title_changed?
65
+ end
66
+
67
+ def enqueue_welcome
68
+ # AFTER-SAVE BEST PRACTICE: enqueue, don't execute. Parse Server blocks the
69
+ # client's write until this webhook returns — even though afterSave's return
70
+ # is a no-op — so long work here adds latency to every save. And do NOT save
71
+ # another object here if you can avoid it: each cascading save fires more
72
+ # webhooks (a latency cascade). Hand off to a background job instead.
73
+ # BackgroundJobs.enqueue(:index_post, id)
74
+ end
75
+ end
76
+
77
+ Post.auto_upgrade!
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # 3. Webhook blocks (optional) — server-side logic without a Ruby model callback
81
+ # ---------------------------------------------------------------------------
82
+ # A block runs in the scope of a Parse::Webhooks::Payload. A beforeSave block
83
+ # returns `parse_object` (the SDK turns it into the changes Parse Server wants);
84
+ # returning `false` halts the save.
85
+ class Post
86
+ webhook :before_save do
87
+ # `parse_object` is the incoming object; mutate it, then return it.
88
+ parse_object
89
+ end
90
+
91
+ # afterSave/afterDelete blocks may register more than one handler.
92
+ webhook :after_save do
93
+ # keep this short or enqueue — see enqueue_welcome above.
94
+ true
95
+ end
96
+ end
97
+
98
+ # NOTE: there is no `webhook :before_create` / `:after_create`. Parse Server has
99
+ # no such trigger — register beforeSave/afterSave and your create callbacks fire
100
+ # within them for new objects. Asking for a create webhook raises with guidance.
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # 4. Register the webhooks with Parse Server (run once at deploy, needs master key)
104
+ # ---------------------------------------------------------------------------
105
+ # `endpoint` is the public HTTPS URL where THIS Rack app is reachable.
106
+ if ENV["PARSE_WEBHOOK_ENDPOINT"]
107
+ endpoint = ENV.fetch("PARSE_WEBHOOK_ENDPOINT") # e.g. https://hooks.example.com/webhooks
108
+ Parse::Webhooks.register_functions!(endpoint)
109
+ Parse::Webhooks.register_triggers!(endpoint)
110
+ puts "Registered webhooks at #{endpoint}"
111
+ end