collavre_linear 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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/app/controllers/collavre_linear/application_controller.rb +21 -0
  3. data/app/controllers/collavre_linear/auth_controller.rb +135 -0
  4. data/app/controllers/collavre_linear/creatives/integrations_controller.rb +271 -0
  5. data/app/controllers/collavre_linear/webhooks_controller.rb +187 -0
  6. data/app/javascript/collavre_linear.js +199 -0
  7. data/app/jobs/collavre_linear/inbound_apply_job.rb +36 -0
  8. data/app/jobs/collavre_linear/outbound_archive_job.rb +29 -0
  9. data/app/jobs/collavre_linear/outbound_comment_delete_job.rb +41 -0
  10. data/app/jobs/collavre_linear/outbound_comment_sync_job.rb +112 -0
  11. data/app/jobs/collavre_linear/outbound_comment_update_job.rb +55 -0
  12. data/app/jobs/collavre_linear/outbound_sync_job.rb +39 -0
  13. data/app/models/collavre_linear/account.rb +29 -0
  14. data/app/models/collavre_linear/application_record.rb +7 -0
  15. data/app/models/collavre_linear/comment_link.rb +12 -0
  16. data/app/models/collavre_linear/issue_link.rb +20 -0
  17. data/app/models/collavre_linear/project_link.rb +76 -0
  18. data/app/observers/collavre_linear/comment_sync_observer.rb +97 -0
  19. data/app/observers/collavre_linear/creative_sync_observer.rb +162 -0
  20. data/app/services/collavre_linear/client.rb +425 -0
  21. data/app/services/collavre_linear/comment_formatter.rb +34 -0
  22. data/app/services/collavre_linear/comment_syncability.rb +33 -0
  23. data/app/services/collavre_linear/creative_exporter.rb +352 -0
  24. data/app/services/collavre_linear/echo_guard.rb +36 -0
  25. data/app/services/collavre_linear/field_mapper.rb +119 -0
  26. data/app/services/collavre_linear/inbound_applier.rb +599 -0
  27. data/app/services/collavre_linear/o_auth_token_service.rb +163 -0
  28. data/app/views/collavre_linear/auth/setup.html.erb +63 -0
  29. data/app/views/collavre_linear/integrations/_modal.html.erb +200 -0
  30. data/config/initializers/integration_settings.rb +8 -0
  31. data/config/locales/en.yml +49 -0
  32. data/config/locales/ko.yml +49 -0
  33. data/config/routes.rb +22 -0
  34. data/db/migrate/20260701000000_create_linear_accounts.rb +20 -0
  35. data/db/migrate/20260701000001_create_collavre_linear_project_links.rb +18 -0
  36. data/db/migrate/20260701000002_create_collavre_linear_issue_links.rb +22 -0
  37. data/db/migrate/20260701000003_create_collavre_linear_comment_links.rb +17 -0
  38. data/db/migrate/20260701000004_add_last_outbound_at_to_linear_links.rb +6 -0
  39. data/db/migrate/20260701000005_add_webhook_id_to_linear_project_links.rb +5 -0
  40. data/db/migrate/20260701000006_add_lookup_indexes_to_linear_project_links.rb +17 -0
  41. data/db/migrate/20260701000007_enforce_one_project_link_per_creative.rb +19 -0
  42. data/db/migrate/20260701000008_allow_null_webhook_secret_on_linear_project_links.rb +8 -0
  43. data/lib/collavre_linear/engine.rb +122 -0
  44. data/lib/collavre_linear/version.rb +3 -0
  45. data/lib/collavre_linear.rb +5 -0
  46. metadata +115 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5cc482eb53e316ac259f9565368c50a59cbd1dce01be3b74100c41b239919731
4
+ data.tar.gz: cccca601d9c57cd0d45ee603d50b46663d6d3daac3373e93a4b4e3b9b59348b1
5
+ SHA512:
6
+ metadata.gz: e4a88144150cd9d67ff55433fde009579709cb572cdc08a7cd5209c6d187394917a0efdcd8bbd6a73d60e85fc46ae5d21e8ac731b015b0a0973819e73b51cd61
7
+ data.tar.gz: 28ee1fc888af6a9a86fd2b18f30883668d273111ad01912a14cc73e0eb598ae412c0efd0733e850013e4da2e81bd4b888b564e74e0e654619df849457d13ec2c
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CollavreLinear
4
+ class ApplicationController < ::ApplicationController
5
+ private
6
+
7
+ # Public HTTPS URL Linear will POST inbound webhook deliveries to. The
8
+ # engine is `isolate_namespace`d and mounted at MOUNT_PATH (/linear), so we
9
+ # fold the mount prefix back in explicitly via `script_name:` — the
10
+ # generated URL MUST be the fully mounted /linear/webhook, never the
11
+ # engine-internal /webhook, or Linear delivers to a 404 and inbound sync
12
+ # never runs.
13
+ def linear_webhook_url
14
+ CollavreLinear::Engine.routes.url_helpers.webhook_url(
15
+ Rails.application.config.action_mailer.default_url_options.merge(
16
+ script_name: CollavreLinear::Engine::MOUNT_PATH
17
+ )
18
+ )
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CollavreLinear
4
+ class AuthController < ApplicationController
5
+ # callback is reached after the user authorizes on Linear's site; no
6
+ # session exists yet at that point so we allow unauthenticated access.
7
+ # We then require a logged-in user to associate the account.
8
+ before_action :require_authenticated_user!, only: %i[callback store_creative setup]
9
+
10
+ # GET /linear/auth/callback?code=...&state=...
11
+ # Exchange the authorization code, create/update the Account, capture
12
+ # the OAuth app actor id via the GraphQL viewer query.
13
+ def callback
14
+ expected_state = session.delete(:linear_oauth_state)
15
+ unless params[:state].present? && params[:state] == expected_state
16
+ redirect_to collavre.creatives_path,
17
+ alert: I18n.t("collavre_linear.auth.invalid_state",
18
+ default: "Invalid OAuth state. Please try connecting again.")
19
+ return
20
+ end
21
+
22
+ tokens = OAuthTokenService.exchange(params[:code])
23
+
24
+ # Build a temporary account so we can call the viewer query
25
+ temp_account = CollavreLinear::Account.new(
26
+ access_token: tokens[:access_token]
27
+ )
28
+ client = CollavreLinear::Client.new(temp_account)
29
+ viewer = client.viewer_and_app_actor
30
+
31
+ # Never reassign a Linear identity already owned by another Collavre user
32
+ # (that would steal their account + project links). Bind by the CURRENT
33
+ # user instead (user_id is unique — one Linear account per user), so a
34
+ # reconnect with a different Linear UID updates in place rather than
35
+ # violating the unique index.
36
+ if CollavreLinear::Account.where(linear_uid: viewer[:user_id])
37
+ .where.not(user_id: Current.user.id).exists?
38
+ redirect_to collavre.creatives_path,
39
+ alert: I18n.t("collavre_linear.auth.already_linked_other")
40
+ return
41
+ end
42
+
43
+ account = CollavreLinear::Account.find_or_initialize_by(
44
+ user_id: Current.user.id
45
+ )
46
+ account.linear_uid = viewer[:user_id]
47
+ account.access_token = tokens[:access_token]
48
+ account.refresh_token = tokens[:refresh_token]
49
+ account.token_expires_at = Time.current + tokens[:expires_in].to_i.seconds if tokens[:expires_in]
50
+ account.app_actor_id = viewer[:app_actor_id]
51
+ account.workspace_id = viewer[:organization_id]
52
+ account.save!
53
+
54
+ creative_id = session.delete(:linear_creative_id)
55
+ if creative_id.present?
56
+ # Redirect to the MOUNTED path (/linear/auth/setup). The engine is
57
+ # detached, so we fold its mount prefix in explicitly via `script_name:`
58
+ # — the bare engine-internal /auth/setup would 404 in the popup.
59
+ redirect_to linear_engine.setup_auth_path(
60
+ creative_id: creative_id,
61
+ script_name: CollavreLinear::Engine::MOUNT_PATH
62
+ )
63
+ else
64
+ redirect_to collavre.creatives_path,
65
+ notice: I18n.t("collavre_linear.auth.connected", default: "Linear connected.")
66
+ end
67
+ end
68
+
69
+ # GET /linear/auth/setup?creative_id=...
70
+ # Setup wizard entry point — mirrors the collavre_github pattern.
71
+ def setup
72
+ @creative_id = params[:creative_id]
73
+
74
+ unless @creative_id.present?
75
+ render plain: I18n.t("collavre_linear.integration.missing_creative",
76
+ default: "creative_id is required"),
77
+ status: :bad_request
78
+ return
79
+ end
80
+
81
+ @creative = Collavre::Creative.find_by(id: @creative_id)
82
+ unless @creative
83
+ render plain: I18n.t("collavre_linear.integration.missing_creative",
84
+ default: "Creative not found"),
85
+ status: :not_found
86
+ return
87
+ end
88
+
89
+ unless @creative.has_permission?(Current.user, :admin)
90
+ render plain: I18n.t("collavre_linear.errors.forbidden",
91
+ default: "Access denied"),
92
+ status: :forbidden
93
+ return
94
+ end
95
+
96
+ render layout: false
97
+ end
98
+
99
+ # POST /linear/auth/store_creative
100
+ # Persist creative_id and generate a CSRF state token in the session, then
101
+ # redirect the popup window straight to Linear's OAuth screen. The form
102
+ # POSTs into a popup with no JS handler, so returning JSON here would just
103
+ # render raw JSON in the popup and never start OAuth — a 303 redirect makes
104
+ # the popup follow through to Linear.
105
+ def store_creative
106
+ missing = OAuthTokenService.missing_config
107
+ if missing.any?
108
+ render plain: I18n.t("collavre_linear.auth.oauth_config_missing",
109
+ keys: missing.join(", ")),
110
+ status: :service_unavailable
111
+ return
112
+ end
113
+
114
+ session[:linear_creative_id] = params[:creative_id]
115
+ state = SecureRandom.hex(24)
116
+ session[:linear_oauth_state] = state
117
+ authorize_url = OAuthTokenService.authorize_url(state: state, creative_id: params[:creative_id])
118
+ redirect_to authorize_url, allow_other_host: true, status: :see_other
119
+ end
120
+
121
+ private
122
+
123
+ def require_authenticated_user!
124
+ return if Current.user
125
+
126
+ redirect_to collavre.new_session_path,
127
+ alert: I18n.t("collavre_linear.auth.login_first",
128
+ default: "Please log in first.")
129
+ end
130
+
131
+ def linear_engine
132
+ CollavreLinear::Engine.routes.url_helpers
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CollavreLinear
4
+ module Creatives
5
+ class IntegrationsController < ApplicationController
6
+ include Collavre::IntegrationSetup
7
+ include Collavre::IntegrationPermission
8
+
9
+ before_action :set_creative
10
+ before_action :set_origin
11
+ before_action :ensure_read_permission
12
+ before_action :ensure_admin_permission, only: %i[create destroy resync update_secret options]
13
+
14
+ # POST /linear/creatives/:creative_id/integration
15
+ #
16
+ # Links a Creative subtree to a Linear team + project, provisions a
17
+ # webhook for the team, and enqueues an initial full export.
18
+ def create
19
+ account = Current.user.linear_account
20
+ unless account
21
+ render json: { error: I18n.t("collavre_linear.errors.not_connected") },
22
+ status: :unprocessable_entity
23
+ return
24
+ end
25
+
26
+ team_id = params[:team_id].to_s.presence
27
+ linear_project_id = params[:linear_project_id].to_s.presence
28
+
29
+ unless team_id && linear_project_id
30
+ render json: { error: I18n.t("collavre_linear.errors.missing_params") },
31
+ status: :unprocessable_entity
32
+ return
33
+ end
34
+
35
+ # Serialize concurrent link attempts within the same tree. The overlap
36
+ # checks below are check-then-save with NO DB constraint spanning
37
+ # ancestors/descendants, so two requests linking a parent and a child
38
+ # Creative could both pass the overlap check and double-link the subtree.
39
+ # Every Creative in a tree shares one root, so a row lock on that root
40
+ # forces overlapping attempts to run one at a time; the loser then sees
41
+ # the winner's ProjectLink and is rejected. (project_taken's CROSS-tree
42
+ # race stays covered by the linear_project_id unique index +
43
+ # RecordNotUnique backstop below, since disjoint trees have distinct roots
44
+ # and would not serialize on this lock.)
45
+ overlapping_link = false
46
+ link = nil
47
+ begin
48
+ @origin.root.with_lock do
49
+ # Reject linking inside/around an already-linked subtree. A second
50
+ # ProjectLink on an ancestor/descendant would sync against the wrong
51
+ # project: IssueLink is unique per Creative and the exporter resolves
52
+ # both ProjectLink and IssueLink per creative WITHOUT account scope, so
53
+ # the new project would silently update the old project's issues. The
54
+ # check must span ALL accounts — a second admin with their own Linear
55
+ # account could otherwise hijack the subtree. Re-linking @origin to the
56
+ # SAME project stays idempotent (find_or_initialize_by below).
57
+ overlapping_ids =
58
+ (@origin.self_and_ancestors.ids + @origin.self_and_descendants.ids).uniq - [ @origin.id ]
59
+ overlapping = overlapping_ids.any? &&
60
+ CollavreLinear::ProjectLink.where(creative_id: overlapping_ids).exists?
61
+
62
+ # @origin may only carry the idempotent re-link target (same account
63
+ # AND same project). Any other existing link on @origin conflicts: a
64
+ # different project (the exporter would keep updating the old issue),
65
+ # or the same subtree claimed by another account. The ancestor/
66
+ # descendant check above excludes @origin, so guard @origin here.
67
+ origin_conflict = @origin.linear_project_links
68
+ .where.not(account: account, linear_project_id: linear_project_id)
69
+ .exists?
70
+
71
+ # One Linear project maps to exactly one Collavre root. Inbound
72
+ # webhooks resolve the project with an UNSCOPED
73
+ # find_by(linear_project_id:), so a second ProjectLink for the same
74
+ # project on a DISJOINT creative (not an ancestor/descendant, so the
75
+ # overlap check above misses it) would make imports/updates land on
76
+ # whichever row the DB returns while both roots export into it. Reject
77
+ # globally; re-linking @origin stays idempotent.
78
+ project_taken = CollavreLinear::ProjectLink
79
+ .where(linear_project_id: linear_project_id)
80
+ .where.not(creative_id: @origin.id)
81
+ .exists?
82
+
83
+ if overlapping || origin_conflict || project_taken
84
+ overlapping_link = true
85
+ else
86
+ link = @origin.linear_project_links.find_or_initialize_by(
87
+ account: account,
88
+ linear_project_id: linear_project_id
89
+ )
90
+ link.team_id = team_id
91
+ link.save!
92
+ end
93
+ end
94
+ rescue ActiveRecord::RecordNotUnique
95
+ # Race-safe backstop for the project_taken check above: the check is
96
+ # check-then-save, so two DISJOINT-tree requests (which do not share the
97
+ # root lock) can both pass it; the linear_project_id unique index rejects
98
+ # the loser's insert. Surface the same conflict instead of a 500.
99
+ overlapping_link = true
100
+ end
101
+
102
+ if overlapping_link
103
+ render json: { error: I18n.t("collavre_linear.errors.overlapping_link") },
104
+ status: :unprocessable_entity
105
+ return
106
+ end
107
+
108
+ # Existing descendants never fire after-commit callbacks during linking,
109
+ # so enqueue an outbound export for the whole subtree — otherwise a
110
+ # populated tree would only create the root Linear issue. Idempotent:
111
+ # the exporter skips unchanged content via its content hash.
112
+ enqueue_subtree_sync
113
+
114
+ # Inbound sync needs a webhook Linear can't auto-provision (app actors
115
+ # lack the admin scope), so the admin sets it up by hand and pastes the
116
+ # secret Linear generates via update_secret. The modal shows that guide.
117
+ render json: { success: true, project_link: serialize_link(link) }
118
+ rescue ActiveRecord::RecordInvalid => e
119
+ render json: { error: e.message }, status: :unprocessable_entity
120
+ end
121
+
122
+ # DELETE /linear/creatives/:creative_id/integration
123
+ #
124
+ # Unlinks the Creative from its Linear project. The webhook is set up by
125
+ # hand in Linear (we never learn its id), so there is nothing to
126
+ # deregister here — the admin removes it in Linear's settings if desired.
127
+ def destroy
128
+ account = Current.user.linear_account
129
+ unless account
130
+ render json: { error: I18n.t("collavre_linear.errors.not_connected") },
131
+ status: :unprocessable_entity
132
+ return
133
+ end
134
+
135
+ link = @origin.linear_project_links.find_by(account: account)
136
+ unless link
137
+ render json: { error: I18n.t("collavre_linear.errors.not_found") },
138
+ status: :not_found
139
+ return
140
+ end
141
+
142
+ link.destroy!
143
+
144
+ render json: { success: true }
145
+ end
146
+
147
+ # POST /linear/creatives/:creative_id/integration/resync
148
+ #
149
+ # Re-enqueues a full outbound export for the whole subtree. Mirrors the
150
+ # create path: enqueuing only the root would never recover stale/missing
151
+ # descendant issues. The ParentNotExportedError + retry_on deferral in
152
+ # OutboundSyncJob protects against parent-before-child races, so enqueuing
153
+ # the full subtree is safe.
154
+ def resync
155
+ account = Current.user.linear_account
156
+ unless account
157
+ render json: { error: I18n.t("collavre_linear.errors.not_connected") },
158
+ status: :unprocessable_entity
159
+ return
160
+ end
161
+
162
+ unless @origin.linear_project_links.where(account: account).exists?
163
+ render json: { error: I18n.t("collavre_linear.errors.not_found") },
164
+ status: :not_found
165
+ return
166
+ end
167
+
168
+ # Resync is the human resolution path the conflict notice points to, so it
169
+ # must break the :conflict freeze — CreativeExporter#update_issue! (and the
170
+ # inbound applier) HALT on conflicted links, so without this the enqueued
171
+ # jobs would report success while making no API call and leaving the
172
+ # conflict stuck. Reopen conflicted links as :dirty so the export re-runs
173
+ # and pushes the local (kept) content Linear never received.
174
+ clear_conflicted_links
175
+
176
+ enqueue_subtree_sync
177
+
178
+ render json: { success: true, message: I18n.t("collavre_linear.integration.resync_started") }
179
+ end
180
+
181
+ # POST /linear/creatives/:creative_id/integration/secret
182
+ #
183
+ # Store the signing secret the admin copied from Linear's webhook settings.
184
+ # Linear owns the secret (it generates one per webhook and won't let us pick
185
+ # it), so the admin pastes it here and we propagate it to the team's sibling
186
+ # links — the one value all of the team's deliveries verify against.
187
+ def update_secret
188
+ account = Current.user.linear_account
189
+ unless account
190
+ render json: { error: I18n.t("collavre_linear.errors.not_connected") },
191
+ status: :unprocessable_entity
192
+ return
193
+ end
194
+
195
+ link = @origin.linear_project_links.find_by(account: account)
196
+ unless link
197
+ render json: { error: I18n.t("collavre_linear.errors.not_found") },
198
+ status: :not_found
199
+ return
200
+ end
201
+
202
+ secret = params[:webhook_secret].to_s.strip
203
+ if secret.blank?
204
+ render json: { error: I18n.t("collavre_linear.errors.missing_secret") },
205
+ status: :unprocessable_entity
206
+ return
207
+ end
208
+
209
+ link.update_webhook_secret!(secret)
210
+
211
+ render json: { success: true }
212
+ end
213
+
214
+ # GET /linear/creatives/:creative_id/integration/options
215
+ #
216
+ # Teams and projects the connected account can see, so the link modal can
217
+ # offer dropdowns instead of asking the user to type raw Linear IDs.
218
+ def options
219
+ account = Current.user.linear_account
220
+ unless account
221
+ render json: { error: I18n.t("collavre_linear.errors.not_connected") },
222
+ status: :unprocessable_entity
223
+ return
224
+ end
225
+
226
+ client = CollavreLinear::Client.new(account)
227
+ render json: { teams: client.list_teams, projects: client.list_projects }
228
+ rescue CollavreLinear::Client::Error => e
229
+ # Surface the Linear-side failure (expired token, revoked scope) instead
230
+ # of leaving the dropdowns silently empty.
231
+ render json: { error: e.message }, status: :bad_gateway
232
+ end
233
+
234
+ private
235
+
236
+ # Enqueue an outbound export for every Creative in the @origin subtree.
237
+ # Shared by create (initial link) and resync so both cover descendants.
238
+ def enqueue_subtree_sync
239
+ @origin.self_and_descendants.ids.each do |creative_id|
240
+ CollavreLinear::OutboundSyncJob.perform_later(creative_id)
241
+ end
242
+ end
243
+
244
+ # Reopen conflicted issue links in the subtree so an explicit resync can
245
+ # actually push. update_all bypasses the exporter's per-link lock, but a
246
+ # concurrent inbound re-marking one conflict just means that link no-ops
247
+ # again (the user resyncs once more) — no data loss, and it unsticks the
248
+ # common case the resync button promises to fix.
249
+ def clear_conflicted_links
250
+ CollavreLinear::IssueLink
251
+ .where(creative_id: @origin.self_and_descendants.ids)
252
+ .where(sync_state: CollavreLinear::IssueLink.sync_states[:conflict])
253
+ .update_all(sync_state: CollavreLinear::IssueLink.sync_states[:dirty])
254
+ end
255
+
256
+ def integration_forbidden_message
257
+ I18n.t("collavre_linear.errors.forbidden")
258
+ end
259
+
260
+ def serialize_link(link)
261
+ {
262
+ id: link.id,
263
+ team_id: link.team_id,
264
+ linear_project_id: link.linear_project_id,
265
+ sync_state: link.sync_state,
266
+ webhook_id: link.webhook_id
267
+ }
268
+ end
269
+ end
270
+ end
271
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CollavreLinear
4
+ # Receives inbound webhooks from Linear.
5
+ #
6
+ # Security pipeline (all BEFORE enqueueing any work):
7
+ # 1. Verify `Linear-Signature` = HMAC-SHA256(webhook_secret, raw_body) with
8
+ # a constant-time compare. Bad/missing signature -> 401.
9
+ # 2. Verify `webhookTimestamp` (ms epoch) is within +/- 60s of now to reject
10
+ # replayed/stale deliveries. Out-of-window -> 401.
11
+ # 3. Drop our own events (EchoGuard) to avoid sync loops. Echo -> 200 ack,
12
+ # but nothing enqueued.
13
+ #
14
+ # Machine-to-machine endpoint authenticated by the HMAC signature below, not a
15
+ # user session. Inherits ActionController::API, which carries no CSRF
16
+ # middleware — so there is nothing to skip, and no session cookie to forge.
17
+ class WebhooksController < ActionController::API
18
+ TIMESTAMP_WINDOW_MS = 60_000
19
+
20
+ def create
21
+ raw_body = request.raw_post.presence || request.body.read
22
+ payload = JSON.parse(raw_body)
23
+
24
+ link = find_project_link(payload)
25
+ # Resolve the secret at the TEAM level, not off the matched row's own
26
+ # column. find_project_link can return a specific row (project/issue/comment
27
+ # branches) that committed blank in the paste race even though a sibling
28
+ # holds the team's secret — verifying against the team's non-blank secret
29
+ # keeps those deliveries from a spurious 401.
30
+ secret = link && CollavreLinear::ProjectLink.team_webhook_secret(link.team_id)
31
+
32
+ return head :unauthorized unless valid_signature?(raw_body, secret)
33
+ return head :unauthorized unless fresh_timestamp?(payload)
34
+ # Signature only proves the sender holds the signing link's per-TEAM secret.
35
+ # InboundApplier routes by the payload's own id/projectId, so a payload
36
+ # signed with team A's secret but naming team B's project/issue would let a
37
+ # user who can see A's (UI-visible) secret forge writes into B. Require every
38
+ # other identifier to resolve to the SAME team before enqueueing.
39
+ return head :unauthorized unless payload_identifiers_consistent?(payload, link)
40
+
41
+ account = resolve_account(link)
42
+ if account && CollavreLinear::EchoGuard.our_event?(account, payload)
43
+ # Our own actor bounced back — ack so Linear stops retrying, but do not
44
+ # re-apply our own change.
45
+ return head :ok
46
+ end
47
+
48
+ # Enqueue on the sequential linear_inbound queue (single-thread worker) so
49
+ # verified events apply strictly in receipt order — a comment that arrives
50
+ # after its issue's create must also be APPLIED after it.
51
+ CollavreLinear::InboundApplyJob.perform_later(payload)
52
+ head :ok
53
+ rescue JSON::ParserError
54
+ head :bad_request
55
+ end
56
+
57
+ private
58
+
59
+ # Match the payload's team/project to a ProjectLink so we can use its
60
+ # per-link webhook secret and account. Returns nil when nothing matches —
61
+ # the secret then resolves to nil and the delivery is rejected (401). There
62
+ # is no ENV/admin fallback: the secret lives only on the ProjectLink.
63
+ def find_project_link(payload)
64
+ team_id = extract_team_id(payload)
65
+ project_id = extract_project_id(payload)
66
+
67
+ if team_id.present?
68
+ # A team shares one webhook secret, but a sibling created concurrently
69
+ # with the admin's paste can commit blank and would 401 the delivery if
70
+ # the verifier picked it. Prefer any sibling that already holds the pasted
71
+ # secret; fall back to any team row so routing still resolves before a
72
+ # secret is ever pasted (that case 401s on the nil secret, as intended).
73
+ siblings = CollavreLinear::ProjectLink.where(team_id: team_id).to_a
74
+ link = siblings.find { |s| s.webhook_secret.present? } || siblings.first
75
+ return link if link
76
+ end
77
+
78
+ if project_id.present?
79
+ link = CollavreLinear::ProjectLink.find_by(linear_project_id: project_id)
80
+ return link if link
81
+ end
82
+
83
+ # Comment deliveries carry no team/project fields — they reference the
84
+ # parent issue instead. Resolve via the linked issue so verification uses
85
+ # that project's per-link secret; without this the delivery matches no
86
+ # link and is rejected (401).
87
+ if (issue_id = extract_issue_id(payload)).present?
88
+ issue_link = CollavreLinear::IssueLink.find_by(linear_issue_id: issue_id)
89
+ return issue_link.project_link if issue_link
90
+ end
91
+
92
+ nil
93
+ end
94
+
95
+ # The webhook secret is shared per TEAM (siblings adopt it, rotation rolls the
96
+ # whole team), so the team is the trust boundary. Every identifier the payload
97
+ # carries that resolves to a ProjectLink must belong to the signing link's
98
+ # team; a mismatch means a cross-team forgery signed with the wrong secret.
99
+ def payload_identifiers_consistent?(payload, link)
100
+ return false unless link
101
+
102
+ project_id = extract_project_id(payload)
103
+ if project_id.present?
104
+ other = CollavreLinear::ProjectLink.find_by(linear_project_id: project_id)
105
+ return false if other && other.team_id != link.team_id
106
+ end
107
+
108
+ issue_ids_in_payload(payload).each do |issue_id|
109
+ issue_link = CollavreLinear::IssueLink.find_by(linear_issue_id: issue_id)
110
+ return false if issue_link && issue_link.project_link.team_id != link.team_id
111
+ end
112
+
113
+ comment_ids_in_payload(payload).each do |comment_id|
114
+ comment_link = CollavreLinear::CommentLink.find_by(linear_comment_id: comment_id)
115
+ return false if comment_link &&
116
+ comment_link.issue_link.project_link.team_id != link.team_id
117
+ end
118
+
119
+ true
120
+ end
121
+
122
+ # Every issue identifier InboundApplier may route by: the comment's parent
123
+ # issue (data.issue.id / data.issueId), the Issue event's own id (data.id —
124
+ # what update_issue!/remove_issue!/create_issue! key on), and a create/
125
+ # reparent target (data.parentId). The comment-shaped extract_issue_id alone
126
+ # missed data.id, letting an Issue payload signed with team A's secret but
127
+ # naming team B's issue slip past the cross-team guard.
128
+ def issue_ids_in_payload(payload)
129
+ ids = [ extract_issue_id(payload), payload.dig("data", "parentId") ]
130
+ ids << payload.dig("data", "id") if payload["type"] == "Issue"
131
+ ids.compact_blank
132
+ end
133
+
134
+ # Comment events route update/remove by the Comment's own data.id (a
135
+ # linear_comment_id), which none of the issue-shaped extractors surface.
136
+ # Without checking it, a payload signed with team A's secret but naming team
137
+ # B's CommentLink slips past the guard and apply_comment! edits/deletes B's
138
+ # mirror. Only a Comment payload's data.id is a comment id (Issue payloads key
139
+ # it as an issue, already covered above).
140
+ def comment_ids_in_payload(payload)
141
+ return [] unless payload["type"] == "Comment"
142
+
143
+ [ payload.dig("data", "id") ].compact_blank
144
+ end
145
+
146
+ def extract_issue_id(payload)
147
+ payload.dig("data", "issue", "id") || payload.dig("data", "issueId")
148
+ end
149
+
150
+ def extract_team_id(payload)
151
+ payload.dig("data", "teamId") ||
152
+ payload.dig("data", "team", "id") ||
153
+ payload["teamId"]
154
+ end
155
+
156
+ def extract_project_id(payload)
157
+ payload.dig("data", "projectId") ||
158
+ payload.dig("data", "project", "id")
159
+ end
160
+
161
+ def resolve_account(link)
162
+ link&.account || CollavreLinear::Account.first
163
+ end
164
+
165
+ def valid_signature?(raw_body, secret)
166
+ return false if secret.blank?
167
+
168
+ signature = request.headers["Linear-Signature"] ||
169
+ request.get_header("HTTP_LINEAR_SIGNATURE")
170
+ return false if signature.blank?
171
+
172
+ expected = OpenSSL::HMAC.hexdigest("SHA256", secret, raw_body)
173
+ ActiveSupport::SecurityUtils.secure_compare(expected, signature)
174
+ end
175
+
176
+ def fresh_timestamp?(payload)
177
+ ts = payload["webhookTimestamp"]
178
+ return false if ts.blank?
179
+
180
+ ts_ms = Integer(ts)
181
+ now_ms = (Time.now.to_f * 1000).to_i
182
+ (now_ms - ts_ms).abs <= TIMESTAMP_WINDOW_MS
183
+ rescue ArgumentError, TypeError
184
+ false
185
+ end
186
+ end
187
+ end