collavre_github 0.6.1 → 0.6.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 82dd93990d2dc692575a20c51b26144b69c9af885b36e60b4868fa7559c76363
4
- data.tar.gz: b929db48adbbdcb8c56d8cb72efb171bac40cf852721aa98927af101ee41d82b
3
+ metadata.gz: 3c290ca646f38dca20fc5579da040eb6afe437b763298b130b4cfdf33528b53b
4
+ data.tar.gz: 42b03d3de6dc5b1e7c3150b0acbf42400d1057f0c3dc0281ede2e91f13e9e70e
5
5
  SHA512:
6
- metadata.gz: e2d9681cdc7f1872a0b821b1288886c575207d3370c373d062f630ed23015e5fef8ad5c8c76b6e48ecbb7fd7c6dde99788ff3b5c68bbd690a2bcd4b7ed6c8d6e
7
- data.tar.gz: 67382060fae2662cee2481f0cef2ae6fb979f4b4653f3ef63db141cdfb95d35347f845259c9ca205b9e90c81efaac419470b75e48969579abac86bf5e0fdaa01
6
+ metadata.gz: d5a7fd65770ca4b23ad127dacef54e615300a1fa7b695ff2e7cbb071cef667457fec581d469d893c5a7c3e41be844fe24f9f32ee4c42034738fbb46b08c41448
7
+ data.tar.gz: 0c011cec94b20d833d185df0eb5666309f68814700a55a94c1ec41fe1be5d6105639c0b79f6b4c577275a81c36a047dbb17c71cb3272d7559a96ec93453804f8
@@ -0,0 +1,57 @@
1
+ import { deriveConnectState, shouldShowConnectNext } from '../github_wizard_state.js';
2
+
3
+ describe('deriveConnectState', () => {
4
+ test('connected user with selected repos: connected + existing', () => {
5
+ expect(deriveConnectState({ connected: true, selected_repositories: ['a/b'] }))
6
+ .toEqual({ userConnected: true, hasExistingIntegration: true });
7
+ });
8
+
9
+ test('connected user with no selected repos: connected, no existing', () => {
10
+ expect(deriveConnectState({ connected: true, selected_repositories: [] }))
11
+ .toEqual({ userConnected: true, hasExistingIntegration: false });
12
+ });
13
+
14
+ test('connected user ignores all_repositories for existing flag', () => {
15
+ // A connected user's existing state comes from their own selection,
16
+ // never from other members' all_repositories.
17
+ expect(deriveConnectState({ connected: true, selected_repositories: [], all_repositories: ['x/y', 'x/z'] }))
18
+ .toEqual({ userConnected: true, hasExistingIntegration: false });
19
+ });
20
+
21
+ test('unconnected user with repos linked by others: not connected but existing', () => {
22
+ expect(deriveConnectState({ connected: false, all_repositories: ['x/y', 'x/z', 'x/w'] }))
23
+ .toEqual({ userConnected: false, hasExistingIntegration: true });
24
+ });
25
+
26
+ test('unconnected user with no repos: not connected, no existing', () => {
27
+ expect(deriveConnectState({ connected: false, all_repositories: [] }))
28
+ .toEqual({ userConnected: false, hasExistingIntegration: false });
29
+ });
30
+
31
+ test('tolerates missing/empty payload', () => {
32
+ expect(deriveConnectState()).toEqual({ userConnected: false, hasExistingIntegration: false });
33
+ expect(deriveConnectState({})).toEqual({ userConnected: false, hasExistingIntegration: false });
34
+ });
35
+ });
36
+
37
+ describe('shouldShowConnectNext', () => {
38
+ test('shows Next only when the current user is connected AND repos exist', () => {
39
+ expect(shouldShowConnectNext({ hasExistingIntegration: true, userConnected: true })).toBe(true);
40
+ });
41
+
42
+ test('hides Next for an unconnected user even when repos exist (regression)', () => {
43
+ // Repos linked by other members must not let an unauthenticated user
44
+ // advance into an empty organization list — they must log in first.
45
+ expect(shouldShowConnectNext({ hasExistingIntegration: true, userConnected: false })).toBe(false);
46
+ });
47
+
48
+ test('hides Next when there is no existing integration', () => {
49
+ expect(shouldShowConnectNext({ hasExistingIntegration: false, userConnected: true })).toBe(false);
50
+ expect(shouldShowConnectNext({ hasExistingIntegration: false, userConnected: false })).toBe(false);
51
+ });
52
+
53
+ test('tolerates missing state', () => {
54
+ expect(shouldShowConnectNext()).toBe(false);
55
+ expect(shouldShowConnectNext({})).toBe(false);
56
+ });
57
+ });
@@ -1,5 +1,6 @@
1
1
  import { csrfToken, showError, clearError, updateStepVisibility, openOAuthPopup, fetchWithCsrf, setupModalClose } from 'collavre/modules/integration_wizard';
2
2
  import { alertDialog, confirmDialog } from 'collavre/lib/utils/dialog';
3
+ import { deriveConnectState, shouldShowConnectNext } from './github_wizard_state.js';
3
4
 
4
5
  let githubIntegrationInitialized = false;
5
6
 
@@ -51,6 +52,10 @@ if (!githubIntegrationInitialized) {
51
52
  let webhookDetails = {};
52
53
 
53
54
  let hasExistingIntegration = false;
55
+ // Whether the *current* user has their own connected GitHub account.
56
+ // hasExistingIntegration can be true from other members' linked repos while
57
+ // this user is still unauthenticated, so the two must be tracked separately.
58
+ let userConnected = false;
54
59
  let selectedReposForDeletion = new Set();
55
60
 
56
61
  const markdownSyncList = document.getElementById('github-markdown-sync-list');
@@ -64,6 +69,7 @@ if (!githubIntegrationInitialized) {
64
69
  webhookDetails = {};
65
70
 
66
71
  hasExistingIntegration = false;
72
+ userConnected = false;
67
73
  selectedReposForDeletion = new Set();
68
74
  statusEl.textContent = '';
69
75
  errorEl.style.display = 'none';
@@ -101,7 +107,10 @@ if (!githubIntegrationInitialized) {
101
107
 
102
108
  if (currentStep === 'connect') {
103
109
  prevBtn.style.display = 'none';
104
- if (hasExistingIntegration) {
110
+ // Only surface "Next" when the current user is actually connected. When
111
+ // existing repos come solely from other members, the user must log in
112
+ // first — otherwise Next leads to an empty (0 org) dead end.
113
+ if (shouldShowConnectNext({ hasExistingIntegration, userConnected })) {
105
114
  nextBtn.style.display = 'block';
106
115
  nextBtn.disabled = false;
107
116
  } else {
@@ -198,18 +207,19 @@ if (!githubIntegrationInitialized) {
198
207
  fetch(`/github/creatives/${creativeId}/integration`, { headers: { Accept: 'application/json' } })
199
208
  .then(function (response) { return response.json(); })
200
209
  .then(function (data) {
210
+ var connectState = deriveConnectState(data);
211
+ userConnected = connectState.userConnected;
212
+ hasExistingIntegration = connectState.hasExistingIntegration;
213
+
201
214
  if (!data.connected) {
202
- var allRepos = data.all_repositories || [];
203
- if (allRepos.length > 0) {
215
+ if (hasExistingIntegration) {
204
216
  // Other users have linked repositories to this creative
205
217
  statusEl.textContent = existingMessage;
206
- hasExistingIntegration = true;
207
- renderExistingConnections(allRepos, true);
218
+ renderExistingConnections(data.all_repositories || [], true);
208
219
  if (connectMessage) connectMessage.style.display = 'none';
209
220
  if (loginBtn) loginBtn.style.display = 'inline-block';
210
221
  } else {
211
222
  statusEl.textContent = '';
212
- hasExistingIntegration = false;
213
223
  renderExistingConnections([]);
214
224
  if (connectMessage) connectMessage.style.display = '';
215
225
  if (loginBtn) loginBtn.style.display = 'inline-block';
@@ -230,8 +240,7 @@ if (!githubIntegrationInitialized) {
230
240
  }
231
241
  });
232
242
 
233
- hasExistingIntegration = selectedRepos.size > 0;
234
-
243
+ // hasExistingIntegration was derived above from selected_repositories.
235
244
  if (loginBtn) loginBtn.style.display = 'none';
236
245
  renderExistingConnections(Array.from(selectedRepos));
237
246
 
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Pure-logic helpers for the GitHub integration wizard's connect step.
3
+ * Extracted so the "can this user advance past the connect step" decision
4
+ * can be unit-tested without the full DOM wizard.
5
+ */
6
+
7
+ /**
8
+ * Derive connect-step flags from the integration status payload.
9
+ *
10
+ * `userConnected` reflects whether the *current* user has their own linked
11
+ * GitHub account. `hasExistingIntegration` reflects whether the creative
12
+ * already has repositories linked — by this user when connected, or by *any*
13
+ * member (all_repositories) when the current user is not yet connected.
14
+ *
15
+ * @param {{connected?:boolean, all_repositories?:string[], selected_repositories?:string[]}} status
16
+ * @returns {{userConnected:boolean, hasExistingIntegration:boolean}}
17
+ */
18
+ export function deriveConnectState(status) {
19
+ const s = status || {};
20
+ const userConnected = !!s.connected;
21
+ const repos = userConnected ? s.selected_repositories : s.all_repositories;
22
+ const hasExistingIntegration = (repos || []).length > 0;
23
+ return { userConnected, hasExistingIntegration };
24
+ }
25
+
26
+ /**
27
+ * Whether the connect step's "Next" button should be shown.
28
+ *
29
+ * Existing repositories linked by *other* members must not let an
30
+ * unauthenticated user advance: organization lookup requires the user's own
31
+ * connected GitHub account, so advancing would dead-end on an empty
32
+ * organization list. Such a user must log in first.
33
+ *
34
+ * @param {{hasExistingIntegration?:boolean, userConnected?:boolean}} state
35
+ * @returns {boolean}
36
+ */
37
+ export function shouldShowConnectNext(state) {
38
+ const s = state || {};
39
+ return !!(s.hasExistingIntegration && s.userConnected);
40
+ }
@@ -6,6 +6,14 @@ module CollavreGithub
6
6
  belongs_to :github_account, class_name: "CollavreGithub::Account"
7
7
  belongs_to :markdown_root_creative, class_name: "Collavre::Creative", optional: true
8
8
 
9
+ # HMAC secret GitHub signs webhook deliveries with. Encrypted at rest so a
10
+ # DB/backup leak can't forge webhooks (mirrors CollavreGithub::Account#token
11
+ # and CollavreLinear::ProjectLink#webhook_secret). Non-deterministic: the
12
+ # value is only ever read back decrypted (WebhooksController, provisioner),
13
+ # never queried by ciphertext. A backfill migration re-encrypts pre-existing
14
+ # plaintext rows.
15
+ encrypts :webhook_secret, deterministic: false
16
+
9
17
  validates :repository_full_name, presence: true
10
18
  validates :webhook_secret, presence: true
11
19
 
@@ -73,7 +73,7 @@ module CollavreGithub
73
73
  "sha" => @tree_cache[path]
74
74
  )
75
75
  source.delete("rendered_html")
76
- creative.skip_github_validation = true
76
+ creative.skip_read_only_source_validation = true
77
77
  creative.update!(data: creative.data.merge("source" => source))
78
78
 
79
79
  processed, blobs = processor.process(content, path)
@@ -107,7 +107,7 @@ module CollavreGithub
107
107
  }
108
108
  }
109
109
  )
110
- creative.skip_github_validation = true
110
+ creative.skip_read_only_source_validation = true
111
111
  creative.save!
112
112
  @synced_creatives[path] = creative
113
113
 
@@ -130,15 +130,18 @@ module CollavreGithub
130
130
  branch == @link.markdown_sync_branch
131
131
  end
132
132
 
133
- # Load all synced creatives for this repository link, indexed by path
133
+ # Load all synced creatives for this repository link, indexed by path.
134
+ #
135
+ # The `->`/`->>` JSON operators are portable: both PostgreSQL and modern
136
+ # SQLite (bundled by the `sqlite3` gem used here) support them, so a
137
+ # single expression works on both adapters without an
138
+ # `adapter_name == "PostgreSQL"` branch. Wrapping in `CAST(... AS
139
+ # INTEGER)` (ANSI SQL, supported by both) avoids relying on Postgres's
140
+ # `::integer` cast syntax, which SQLite doesn't understand.
134
141
  def load_synced_creatives
135
- scope = Collavre::Creative.where(archived_at: nil)
136
-
137
- if scope.connection.adapter_name == "PostgreSQL"
138
- scope = scope.where("(data->'source'->>'repository_link_id')::integer = ?", @link.id)
139
- else
140
- scope = scope.where("json_extract(data, '$.source.repository_link_id') = ?", @link.id)
141
- end
142
+ scope = Collavre::Creative
143
+ .where(archived_at: nil)
144
+ .where("CAST(data -> 'source' ->> 'repository_link_id' AS INTEGER) = ?", @link.id)
142
145
 
143
146
  scope.each_with_object({}) { |c, h| h[c.data.dig("source", "path")] = c }
144
147
  end
@@ -216,7 +219,7 @@ module CollavreGithub
216
219
  }
217
220
  }
218
221
  )
219
- dir_creative.skip_github_validation = true
222
+ dir_creative.skip_read_only_source_validation = true
220
223
  dir_creative.save!
221
224
  @synced_creatives[dir_path] = dir_creative
222
225
  new_dirs << dir_creative
@@ -63,7 +63,7 @@ module CollavreGithub
63
63
  }
64
64
  }
65
65
  )
66
- creative.skip_github_validation = true
66
+ creative.skip_read_only_source_validation = true
67
67
  creative.save!
68
68
  file_creatives << creative
69
69
  created << creative
@@ -114,7 +114,7 @@ module CollavreGithub
114
114
  }
115
115
  }
116
116
  )
117
- creative.skip_github_validation = true
117
+ creative.skip_read_only_source_validation = true
118
118
  creative.save!
119
119
  creative
120
120
  end
@@ -175,7 +175,7 @@ module CollavreGithub
175
175
  }
176
176
  }
177
177
  )
178
- dir_creative.skip_github_validation = true
178
+ dir_creative.skip_read_only_source_validation = true
179
179
  dir_creative.save!
180
180
  dir_map[current_path] = dir_creative
181
181
  created << dir_creative
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # GitHub-synced Markdown creatives are read-only in Collavre: their description
4
+ # is owned by the upstream repository and must not be edited in-app. Register
5
+ # the source type into the core read-only-source registry so core enforces the
6
+ # read-only behavior (validation, attachment embedding, tree write flags)
7
+ # without naming GitHub. Runs on boot and every reload so the registration
8
+ # survives Zeitwerk clearing Collavre::Creative's class state.
9
+ Rails.application.config.to_prepare do
10
+ if defined?(Collavre::Creative)
11
+ Collavre::Creative.register_read_only_source("github_markdown")
12
+ end
13
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Re-encrypt pre-existing plaintext webhook_secret values in place now that
4
+ # CollavreGithub::RepositoryLink declares `encrypts :webhook_secret`. Reads the
5
+ # raw column with SQL (bypassing the model so we get plaintext, not a decrypt
6
+ # attempt), skips rows already in Active Record Encryption's JSON envelope, and
7
+ # writes the encrypted payload back with the model's own encryptor so the format
8
+ # matches runtime reads. `support_unencrypted_data = true` keeps unmigrated rows
9
+ # readable, so this is safe to run without downtime.
10
+ class EncryptGithubRepositoryLinkWebhookSecrets < ActiveRecord::Migration[8.1]
11
+ def up
12
+ say_with_time "Encrypting GitHub repository link webhook secrets" do
13
+ encrypt_column(CollavreGithub::RepositoryLink, :webhook_secret)
14
+ end
15
+ end
16
+
17
+ def down
18
+ # No-op: `encrypts` decrypts transparently, and support_unencrypted_data
19
+ # keeps any remaining plaintext readable.
20
+ end
21
+
22
+ private
23
+
24
+ def encrypt_column(model, attribute)
25
+ table_name = model.table_name
26
+ connection = ActiveRecord::Base.connection
27
+
28
+ rows = connection.select_all(
29
+ "SELECT id, #{attribute} FROM #{table_name} WHERE #{attribute} IS NOT NULL"
30
+ ).to_a
31
+
32
+ encryptor = model.type_for_attribute(attribute)
33
+
34
+ rows.each do |row|
35
+ plaintext = row[attribute.to_s]
36
+ next if plaintext.nil? || encrypted?(plaintext)
37
+
38
+ encrypted = encryptor.serialize(plaintext)
39
+ connection.execute(
40
+ "UPDATE #{table_name} SET #{attribute} = #{connection.quote(encrypted)} WHERE id = #{connection.quote(row['id'])}"
41
+ )
42
+ end
43
+ end
44
+
45
+ # Active Record Encryption stores a JSON envelope containing a "p" (payload)
46
+ # key; treat anything already in that shape as encrypted and leave it alone.
47
+ def encrypted?(value)
48
+ return false unless value.is_a?(String)
49
+
50
+ value.start_with?("{") && value.include?('"p":')
51
+ rescue StandardError
52
+ false
53
+ end
54
+ end
@@ -1,3 +1,3 @@
1
1
  module CollavreGithub
2
- VERSION = "0.6.1"
2
+ VERSION = "0.6.2"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: collavre_github
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.6.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Collavre
@@ -66,7 +66,9 @@ files:
66
66
  - app/controllers/collavre_github/auth_controller.rb
67
67
  - app/controllers/collavre_github/creatives/integrations_controller.rb
68
68
  - app/controllers/collavre_github/webhooks_controller.rb
69
+ - app/javascript/__tests__/github_wizard_state.test.js
69
70
  - app/javascript/collavre_github.js
71
+ - app/javascript/github_wizard_state.js
70
72
  - app/jobs/collavre_github/initial_markdown_sync_job.rb
71
73
  - app/jobs/collavre_github/markdown_sync_job.rb
72
74
  - app/models/collavre_github/account.rb
@@ -88,6 +90,7 @@ files:
88
90
  - app/views/collavre_github/auth/setup.html.erb
89
91
  - app/views/collavre_github/integrations/_modal.html.erb
90
92
  - config/initializers/integration_settings.rb
93
+ - config/initializers/read_only_source.rb
91
94
  - config/locales/en.yml
92
95
  - config/locales/ko.yml
93
96
  - config/routes.rb
@@ -97,6 +100,7 @@ files:
97
100
  - db/migrate/20260219095224_remove_github_gemini_prompt_from_creatives.rb
98
101
  - db/migrate/20260412000000_add_markdown_sync_to_repository_links.rb
99
102
  - db/migrate/20260416000000_add_markdown_root_creative_index.rb
103
+ - db/migrate/20260711000000_encrypt_github_repository_link_webhook_secrets.rb
100
104
  - db/seeds.rb
101
105
  - lib/collavre_github.rb
102
106
  - lib/collavre_github/engine.rb