archipelago-rails 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a271d60fbd7122dfe75a111e3182fc34662dd3afe9b6d4183e834d08be828e2b
4
- data.tar.gz: '0357780015635cb62d1255e257064d6b5a3674a41f18465f5ce6f0e59ec54759'
3
+ metadata.gz: 958584d368aaf5860d872cd58c01639c7efda2a412b96b8d112bd941fc455d31
4
+ data.tar.gz: e1a648f35bdd36fee3c0c798989a613663a1bfd7c336757bd73005c60417f10a
5
5
  SHA512:
6
- metadata.gz: a40da1f08277ed421fa36b9d29e03223036259625689eb17489455158d0f056cb4600e633f19994bcb2ba6bb7e06773a1db40b5033740f99027baaecd5aa0e66
7
- data.tar.gz: b0827ebf94625a2a5c7f21cdf1c870794609a01d20e8a8dc028ef108cf1f1decde1f5d6284bba7930a267b85e9770430e19e63e45059312c6fd8b911e305add1
6
+ metadata.gz: acd4fc0e23a2897fd483a0a18340a93c9a24bf705da3de9441b3f895103a2bcace726192be557ba4dd6ccc7fbb75b8df38b9575b59367b175a82ddae440617bc
7
+ data.tar.gz: 846fdc9686466a6ad5ca9a98b74ae5ca3ba9286878f5cedbc9b0410a739988558be826d10b2d49282835e6a675e010757263d4051fb35970924937db47689e5a
data/README.md CHANGED
@@ -45,6 +45,14 @@ After `rails g archipelago:install`, you can scaffold frontend bootstrap wiring:
45
45
  rails g archipelago:install:react
46
46
  ```
47
47
 
48
+ The generator writes `.npmrc` with:
49
+
50
+ ```text
51
+ @archipelago-js:registry=https://registry.npmjs.org
52
+ ```
53
+
54
+ This keeps installs reliable across npm, Yarn classic, pnpm, and bun.
55
+
48
56
  By default this runs an interactive wizard with auto-detected defaults
49
57
  (bundler, TypeScript, package manager, and local monorepo path).
50
58
 
@@ -35,10 +35,26 @@ module Archipelago
35
35
  request: request,
36
36
  params: params,
37
37
  session: session,
38
- user: current_archipelago_user
38
+ user: current_archipelago_user,
39
+ stream: extract_stream_name
39
40
  )
40
41
  end
41
42
 
43
+ def extract_stream_name
44
+ from_header = request.headers["X-Archipelago-Stream"].presence
45
+ return from_header if from_header
46
+
47
+ # Backwards compat: fall back to __stream in params with deprecation warning.
48
+ from_params = island_params[:__stream].presence
49
+ if from_params
50
+ ActiveSupport::Deprecation.warn(
51
+ "Passing __stream in the request payload is deprecated. " \
52
+ "Use the X-Archipelago-Stream header instead."
53
+ )
54
+ end
55
+ from_params
56
+ end
57
+
42
58
  def current_archipelago_user
43
59
  resolver_proc = Archipelago.configuration.current_user_resolver
44
60
  return instance_exec(&resolver_proc) if resolver_proc
@@ -131,7 +147,7 @@ module Archipelago
131
147
  return [] unless handler_class.respond_to?(:param_definitions)
132
148
 
133
149
  handler_class.param_definitions.values.map do |definition|
134
- {
150
+ entry = {
135
151
  name: definition.name.to_s,
136
152
  type: definition.type.to_s,
137
153
  required: definition.required,
@@ -142,6 +158,14 @@ module Archipelago
142
158
  upcase: definition.upcase
143
159
  }
144
160
  }
161
+ entry[:in] = definition.in.to_a if definition.in
162
+ entry[:format] = definition.format.inspect if definition.format
163
+ entry[:min] = definition.min if definition.min
164
+ entry[:max] = definition.max if definition.max
165
+ entry[:empty_as_nil] = true if definition.empty_as_nil
166
+ entry[:of] = definition.of.to_s if definition.of
167
+ entry[:validate] = true if definition.validate
168
+ entry
145
169
  end
146
170
  end
147
171
 
@@ -110,8 +110,12 @@ module Archipelago
110
110
  defined?(ActiveRecord::RecordInvalid) && error.is_a?(ActiveRecord::RecordInvalid)
111
111
  end
112
112
 
113
+ def current_user
114
+ ctx.user
115
+ end
116
+
113
117
  def maybe_broadcast(payload)
114
- stream_name = raw_params[:__stream]
118
+ stream_name = ctx.stream || raw_params[:__stream]
115
119
  return if stream_name.blank?
116
120
  return unless payload[:status] == "ok"
117
121
 
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Archipelago
4
+ module CanCanAdapter
5
+ def authorize!(action, record)
6
+ ability = current_ability
7
+ unless ability.can?(action, record)
8
+ raise Archipelago::Forbidden, "not allowed to #{action} this #{record.class}"
9
+ end
10
+
11
+ record
12
+ end
13
+
14
+ def current_ability
15
+ ability_builder = Archipelago.configuration.current_ability
16
+ if ability_builder
17
+ ability_builder.call(current_user)
18
+ elsif defined?(::Ability)
19
+ ::Ability.new(current_user)
20
+ else
21
+ raise Archipelago::Error, "No ability class configured. Set Archipelago.configuration.current_ability or define an Ability class."
22
+ end
23
+ end
24
+ end
25
+ end
@@ -9,13 +9,26 @@ else
9
9
  def stream_from(*); end
10
10
  def reject; end
11
11
  def params = {}
12
+ def connection; end
12
13
  end
13
14
  end
14
15
 
15
16
  channel_class = Class.new(base_channel) do
16
17
  def subscribed
17
18
  stream_name = verified_stream_name
18
- reject unless stream_name
19
+ unless stream_name
20
+ reject
21
+ return
22
+ end
23
+
24
+ unless Archipelago.authorize_stream?(
25
+ connection: connection,
26
+ stream_name: stream_name,
27
+ params: params.except(:channel, :stream_name)
28
+ )
29
+ reject
30
+ return
31
+ end
19
32
 
20
33
  stream_from(stream_name)
21
34
  end
@@ -8,7 +8,10 @@ module Archipelago
8
8
  :authorize_by_default,
9
9
  :strict_origin_check,
10
10
  :allowed_redirect_hosts,
11
- :version_source
11
+ :version_source,
12
+ :stream_authorizer,
13
+ :require_stream_authorization,
14
+ :current_ability
12
15
 
13
16
  def initialize
14
17
  @root_namespace = "Islands"
@@ -18,6 +21,9 @@ module Archipelago
18
21
  @strict_origin_check = false
19
22
  @allowed_redirect_hosts = []
20
23
  @version_source = -> { (Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)).to_i }
24
+ @stream_authorizer = nil
25
+ @require_stream_authorization = false
26
+ @current_ability = nil
21
27
  end
22
28
  end
23
29
  end
@@ -3,12 +3,14 @@
3
3
  module Archipelago
4
4
  class Context
5
5
  attr_reader :request, :params, :session, :user
6
+ attr_accessor :stream
6
7
 
7
- def initialize(request:, params:, session:, user: nil)
8
+ def initialize(request:, params:, session:, user: nil, stream: nil)
8
9
  @request = request
9
10
  @params = params
10
11
  @session = session
11
12
  @user = user
13
+ @stream = stream
12
14
  end
13
15
  end
14
16
  end
@@ -15,6 +15,13 @@ module Archipelago
15
15
  :strip,
16
16
  :downcase,
17
17
  :upcase,
18
+ :in,
19
+ :format,
20
+ :min,
21
+ :max,
22
+ :empty_as_nil,
23
+ :of,
24
+ :validate,
18
25
  keyword_init: true
19
26
  )
20
27
 
@@ -23,7 +30,8 @@ module Archipelago
23
30
  @param_definitions ||= {}
24
31
  end
25
32
 
26
- def param(name, type, required: false, default: MISSING, strip: false, downcase: false, upcase: false)
33
+ def param(name, type, required: false, default: MISSING, strip: false, downcase: false, upcase: false,
34
+ in: nil, format: nil, min: nil, max: nil, empty_as_nil: false, of: nil, validate: nil)
27
35
  symbol_name = name.to_sym
28
36
 
29
37
  param_definitions[symbol_name] = ParamDefinition.new(
@@ -33,7 +41,14 @@ module Archipelago
33
41
  default: default,
34
42
  strip: strip,
35
43
  downcase: downcase,
36
- upcase: upcase
44
+ upcase: upcase,
45
+ in: binding.local_variable_get(:in),
46
+ format: format,
47
+ min: min,
48
+ max: max,
49
+ empty_as_nil: empty_as_nil,
50
+ of: of,
51
+ validate: validate
37
52
  )
38
53
 
39
54
  define_method(symbol_name) do
@@ -53,6 +68,10 @@ module Archipelago
53
68
  self.class.param_definitions.each_value do |definition|
54
69
  raw_value = fetch_param(raw_params, definition.name)
55
70
 
71
+ if definition.empty_as_nil && empty_string?(raw_value)
72
+ raw_value = nil
73
+ end
74
+
56
75
  if blank_value?(raw_value)
57
76
  if definition.default != MISSING
58
77
  coerced[definition.name] = definition.default.respond_to?(:call) ? definition.default.call : definition.default
@@ -65,7 +84,12 @@ module Archipelago
65
84
 
66
85
  begin
67
86
  coerced_value = coerce_value(raw_value, definition)
68
- coerced[definition.name] = coerced_value
87
+ validation_error = run_validations(coerced_value, definition)
88
+ if validation_error
89
+ errors[definition.name.to_s] << validation_error
90
+ else
91
+ coerced[definition.name] = coerced_value
92
+ end
69
93
  rescue ArgumentError, TypeError, JSON::ParserError
70
94
  errors[definition.name.to_s] << "is invalid"
71
95
  end
@@ -84,9 +108,17 @@ module Archipelago
84
108
  value.nil? || (value.respond_to?(:empty?) && value.empty?)
85
109
  end
86
110
 
111
+ def empty_string?(value)
112
+ value.is_a?(String) && value.strip.empty?
113
+ end
114
+
87
115
  def coerce_value(raw_value, definition)
88
116
  value = cast(raw_value, definition.type)
89
117
 
118
+ if definition.type == :array && definition.of
119
+ value = value.map { |element| cast(element, definition.of) }
120
+ end
121
+
90
122
  return value unless value.is_a?(String)
91
123
 
92
124
  value = value.strip if definition.strip
@@ -95,6 +127,33 @@ module Archipelago
95
127
  value
96
128
  end
97
129
 
130
+ def run_validations(value, definition)
131
+ if definition.in && !definition.in.include?(value)
132
+ return "is not included in the list"
133
+ end
134
+
135
+ if definition.format && value.is_a?(String) && !value.match?(definition.format)
136
+ return "is invalid"
137
+ end
138
+
139
+ if definition.min
140
+ comparable = value.respond_to?(:length) ? value.length : value
141
+ return "is too small" if comparable < definition.min
142
+ end
143
+
144
+ if definition.max
145
+ comparable = value.respond_to?(:length) ? value.length : value
146
+ return "is too large" if comparable > definition.max
147
+ end
148
+
149
+ if definition.validate
150
+ custom_error = definition.validate.call(value)
151
+ return custom_error if custom_error.is_a?(String)
152
+ end
153
+
154
+ nil
155
+ end
156
+
98
157
  def cast(value, type)
99
158
  case type
100
159
  when :string
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Archipelago
4
+ module PunditAdapter
5
+ def authorize(record, query = nil)
6
+ query ||= infer_pundit_query
7
+ policy = policy(record)
8
+
9
+ unless policy.public_send(query)
10
+ raise Archipelago::Forbidden, "not allowed to #{query} this #{record.class}"
11
+ end
12
+
13
+ record
14
+ end
15
+
16
+ def policy(record)
17
+ klass = "#{record.class}Policy".safe_constantize
18
+ raise Archipelago::Forbidden, "no policy found for #{record.class}" unless klass
19
+
20
+ klass.new(current_user, record)
21
+ end
22
+
23
+ private
24
+
25
+ def infer_pundit_query
26
+ action_name = self.class.name.to_s.demodulize.sub(/Action\z/, "").underscore
27
+ "#{action_name}?"
28
+ end
29
+ end
30
+ end
data/lib/archipelago.rb CHANGED
@@ -45,6 +45,15 @@ module Archipelago
45
45
  Broadcasts.broadcast(stream_name, props: props, version: version)
46
46
  end
47
47
 
48
+ def authorize_stream?(connection:, stream_name:, params: {})
49
+ authorizer = configuration.stream_authorizer
50
+ return true unless configuration.require_stream_authorization || authorizer
51
+
52
+ return false if configuration.require_stream_authorization && authorizer.nil?
53
+
54
+ authorizer.call(connection: connection, stream_name: stream_name, params: params)
55
+ end
56
+
48
57
  def instrument(event, payload = {}, &block)
49
58
  ActiveSupport::Notifications.instrument(event, payload, &block)
50
59
  end
@@ -59,6 +68,8 @@ require "archipelago/context"
59
68
  require "archipelago/security/origin_validator"
60
69
  require "archipelago/security/redirect_validator"
61
70
  require "archipelago/action"
71
+ require "archipelago/pundit_adapter"
72
+ require "archipelago/cancan_adapter"
62
73
  require "archipelago/resolver"
63
74
  require "archipelago/broadcasts"
64
75
  require "archipelago/channel"
@@ -33,6 +33,8 @@ module Archipelago
33
33
  end
34
34
 
35
35
  def print_next_steps
36
+ say "If package install fails on Yarn mirror, add to .npmrc:"
37
+ say " @archipelago-js:registry=https://registry.npmjs.org"
36
38
  say "Install JS packages: yarn add @archipelago-js/client @archipelago-js/react"
37
39
  say "Optional React bootstrap wizard: rails g archipelago:install:react"
38
40
  say "Non-interactive mode: rails g archipelago:install:react --interactive=false"
@@ -48,6 +48,11 @@ module Archipelago
48
48
  default: true,
49
49
  desc: "For esbuild, auto-generate component registry from app/javascript/islands"
50
50
 
51
+ class_option :lazy_registry,
52
+ type: :boolean,
53
+ default: false,
54
+ desc: "Generate lazy (dynamic import) registry instead of eager imports"
55
+
51
56
  desc "Sets up React + Archipelago frontend bootstrapping for a Rails app."
52
57
 
53
58
  def detect_stack
@@ -69,6 +74,7 @@ module Archipelago
69
74
  @package_manager = options[:package_manager] == "auto" ? @detected_package_manager : options[:package_manager]
70
75
  @local_monorepo_path = options[:local_monorepo_path] || @detected_local_monorepo_path
71
76
  @auto_registry = options[:auto_registry]
77
+ @lazy_registry = options[:lazy_registry]
72
78
  end
73
79
 
74
80
  def interactive_preferences
@@ -122,6 +128,10 @@ module Archipelago
122
128
  template "entry.js.tt", @entry_relative_path
123
129
  end
124
130
 
131
+ def configure_scope_registry
132
+ ensure_scope_registry_in_npmrc
133
+ end
134
+
125
135
  def setup_esbuild_auto_registry
126
136
  return unless @bundler == :esbuild
127
137
  return unless @auto_registry
@@ -131,6 +141,13 @@ module Archipelago
131
141
  wire_esbuild_package_scripts
132
142
  end
133
143
 
144
+ def setup_lazy_registry
145
+ return unless @lazy_registry
146
+ return if @bundler == :esbuild && @auto_registry
147
+
148
+ create_file registry_relative_path, lazy_registry_source
149
+ end
150
+
134
151
  def wire_esbuild_entry
135
152
  return unless @bundler == :esbuild
136
153
 
@@ -166,6 +183,7 @@ module Archipelago
166
183
  say "Install packages:"
167
184
  say " #{resolved_package_manager} add #{packages_for_install.join(' ')}"
168
185
  end
186
+ say "Scoped registry configured in .npmrc for @archipelago-js -> npmjs.org."
169
187
  say ""
170
188
  if @bundler == :esbuild && @auto_registry
171
189
  say "Islands in app/javascript/islands/**/* are auto-registered before esbuild runs."
@@ -268,6 +286,32 @@ module Archipelago
268
286
  end
269
287
  end
270
288
 
289
+ def lazy_registry_source
290
+ if @use_typescript
291
+ <<~TS
292
+ import { defineIslandLoader, type IslandRegistry } from "@archipelago-js/react"
293
+
294
+ // Lazy registry: islands are loaded on demand via dynamic import.
295
+ // Add entries like:
296
+ // MyIsland: defineIslandLoader(() => import("../islands/MyIsland"))
297
+ const registry: IslandRegistry = {}
298
+
299
+ export default registry
300
+ TS
301
+ else
302
+ <<~JS
303
+ import { defineIslandLoader } from "@archipelago-js/react"
304
+
305
+ // Lazy registry: islands are loaded on demand via dynamic import.
306
+ // Add entries like:
307
+ // MyIsland: defineIslandLoader(() => import("../islands/MyIsland"))
308
+ const registry = {}
309
+
310
+ export default registry
311
+ JS
312
+ end
313
+ end
314
+
271
315
  def wire_esbuild_package_scripts
272
316
  return unless path_exists?("package.json")
273
317
 
@@ -374,6 +418,22 @@ module Archipelago
374
418
  root = Pathname.new(@local_monorepo_path).expand_path
375
419
  "@archipelago-js/react@file:#{root.join('packages/react')}"
376
420
  end
421
+
422
+ def ensure_scope_registry_in_npmrc
423
+ scope_key = "@archipelago-js:registry"
424
+ registry_line = "#{scope_key}=https://registry.npmjs.org"
425
+ npmrc_path = path_for(".npmrc")
426
+
427
+ unless File.exist?(npmrc_path)
428
+ create_file ".npmrc", "#{registry_line}\n"
429
+ return
430
+ end
431
+
432
+ content = File.read(npmrc_path)
433
+ return if content.include?(scope_key)
434
+
435
+ append_to_file ".npmrc", "\n#{registry_line}\n"
436
+ end
377
437
  end
378
438
  end
379
439
  end
@@ -10,6 +10,7 @@ const outputFile = path.resolve(
10
10
  archipelagoDir,
11
11
  "registry.generated.<%= @use_typescript ? "ts" : "js" %>"
12
12
  )
13
+ const lazy = <%= @lazy_registry ? "true" : "false" %>
13
14
 
14
15
  function walk(dir) {
15
16
  if (!fs.existsSync(dir)) {
@@ -62,16 +63,33 @@ const islandFiles = walk(islandsRoot).sort()
62
63
  const imports = []
63
64
  const registryRows = []
64
65
 
65
- islandFiles.forEach((absolutePath, index) => {
66
- const relativeFromIslands = path.relative(islandsRoot, absolutePath)
67
- const relativeWithoutExt = relativeFromIslands.replace(/\.[^.]+$/, "")
68
- const importPath = `../islands/${relativeWithoutExt.split(path.sep).join("/")}`
69
- const componentName = componentNameFromRelative(relativeWithoutExt)
70
- const importName = `Island${index + 1}`
66
+ if (lazy) {
67
+ <% if @use_typescript %>
68
+ imports.push("import { defineIslandLoader, type IslandRegistry } from \"@archipelago-js/react\"")
69
+ <% else %>
70
+ imports.push("import { defineIslandLoader } from \"@archipelago-js/react\"")
71
+ <% end %>
71
72
 
72
- imports.push(`import ${importName} from "${importPath}"`)
73
- registryRows.push(` "${componentName}": ${importName}`)
74
- })
73
+ islandFiles.forEach((absolutePath) => {
74
+ const relativeFromIslands = path.relative(islandsRoot, absolutePath)
75
+ const relativeWithoutExt = relativeFromIslands.replace(/\.[^.]+$/, "")
76
+ const importPath = `../islands/${relativeWithoutExt.split(path.sep).join("/")}`
77
+ const componentName = componentNameFromRelative(relativeWithoutExt)
78
+
79
+ registryRows.push(` "${componentName}": defineIslandLoader(() => import("${importPath}"))`)
80
+ })
81
+ } else {
82
+ islandFiles.forEach((absolutePath, index) => {
83
+ const relativeFromIslands = path.relative(islandsRoot, absolutePath)
84
+ const relativeWithoutExt = relativeFromIslands.replace(/\.[^.]+$/, "")
85
+ const importPath = `../islands/${relativeWithoutExt.split(path.sep).join("/")}`
86
+ const componentName = componentNameFromRelative(relativeWithoutExt)
87
+ const importName = `Island${index + 1}`
88
+
89
+ imports.push(`import ${importName} from "${importPath}"`)
90
+ registryRows.push(` "${componentName}": ${importName}`)
91
+ })
92
+ }
75
93
 
76
94
  const source = [
77
95
  "// This file is auto-generated by app/javascript/archipelago/generate_registry.mjs.",
@@ -80,8 +98,7 @@ const source = [
80
98
  ...imports,
81
99
  imports.length > 0 ? "" : "",
82
100
  <% if @use_typescript %>
83
- "import type { IslandRegistry } from \"@archipelago-js/react\"",
84
- "",
101
+ ...(lazy ? [] : ["import type { IslandRegistry } from \"@archipelago-js/react\"", ""]),
85
102
  "const registry: IslandRegistry = {",
86
103
  <% else %>
87
104
  "const registry = {",
@@ -18,7 +18,7 @@ rescue LoadError
18
18
  end
19
19
 
20
20
  class ActionTest < ArchipelagoTestCase
21
- DummyContext = Struct.new(:user, :request, :params, :session)
21
+ DummyContext = Struct.new(:user, :request, :params, :session, :stream)
22
22
 
23
23
  class ForbiddenAction < Archipelago::Action
24
24
  authorize { false }
@@ -133,4 +133,185 @@ class ActionTest < ArchipelagoTestCase
133
133
  assert_equal "error", payload[:status]
134
134
  assert_equal ["Email can't be blank"], payload[:errors]["email"]
135
135
  end
136
+
137
+ def test_current_user_delegates_to_ctx_user
138
+ user = Object.new
139
+ action = BroadcastAction.new(
140
+ ctx: DummyContext.new(user, nil, nil, nil),
141
+ raw_params: {}
142
+ )
143
+
144
+ assert_same user, action.send(:current_user)
145
+ end
146
+
147
+ def test_broadcasts_via_ctx_stream
148
+ captured = nil
149
+ original_broadcast = Archipelago.method(:broadcast)
150
+ previous_verbose, $VERBOSE = $VERBOSE, nil
151
+
152
+ Archipelago.singleton_class.define_method(:broadcast) do |stream_name, props:, version:|
153
+ captured = [stream_name, props, version]
154
+ end
155
+
156
+ begin
157
+ BroadcastAction.new(
158
+ ctx: DummyContext.new(nil, nil, nil, nil, "ctx-stream:42"),
159
+ raw_params: {}
160
+ ).call
161
+ ensure
162
+ Archipelago.singleton_class.define_method(:broadcast, original_broadcast)
163
+ $VERBOSE = previous_verbose
164
+ end
165
+
166
+ assert_equal "ctx-stream:42", captured[0]
167
+ assert_equal({ members: [1, 2] }, captured[1])
168
+ end
169
+
170
+ def test_ctx_stream_takes_precedence_over_raw_params_stream
171
+ captured = nil
172
+ original_broadcast = Archipelago.method(:broadcast)
173
+ previous_verbose, $VERBOSE = $VERBOSE, nil
174
+
175
+ Archipelago.singleton_class.define_method(:broadcast) do |stream_name, props:, version:|
176
+ captured = stream_name
177
+ end
178
+
179
+ begin
180
+ BroadcastAction.new(
181
+ ctx: DummyContext.new(nil, nil, nil, nil, "ctx-stream:1"),
182
+ raw_params: { __stream: "param-stream:1" }
183
+ ).call
184
+ ensure
185
+ Archipelago.singleton_class.define_method(:broadcast, original_broadcast)
186
+ $VERBOSE = previous_verbose
187
+ end
188
+
189
+ assert_equal "ctx-stream:1", captured
190
+ end
191
+ end
192
+
193
+ class PunditAdapterTest < ArchipelagoTestCase
194
+ DummyContext = Struct.new(:user, :request, :params, :session, :stream)
195
+
196
+ DummyUser = Struct.new(:id, :admin)
197
+
198
+ class TeamPolicy
199
+ attr_reader :user, :record
200
+
201
+ def initialize(user, record)
202
+ @user = user
203
+ @record = record
204
+ end
205
+
206
+ def update?
207
+ user.admin
208
+ end
209
+ end
210
+
211
+ Team = Struct.new(:name)
212
+
213
+ class UpdateAction < Archipelago::Action
214
+ include Archipelago::PunditAdapter
215
+
216
+ authorize { true }
217
+
218
+ def perform
219
+ authorize(Team.new("test"))
220
+ props ok: true
221
+ end
222
+ end
223
+
224
+ def test_pundit_authorize_allows_when_policy_returns_true
225
+ user = DummyUser.new(1, true)
226
+ # TeamPolicy is looked up as "PunditAdapterTest::Team" + "Policy",
227
+ # so we need the class in scope. We define it above.
228
+ stub_const_for_test("PunditAdapterTest::TeamPolicy", TeamPolicy) do
229
+ payload = UpdateAction.new(
230
+ ctx: DummyContext.new(user, nil, nil, nil),
231
+ raw_params: {}
232
+ ).call
233
+
234
+ assert_equal "ok", payload[:status]
235
+ end
236
+ end
237
+
238
+ def test_pundit_authorize_raises_forbidden_when_policy_returns_false
239
+ user = DummyUser.new(1, false)
240
+ stub_const_for_test("PunditAdapterTest::TeamPolicy", TeamPolicy) do
241
+ payload = UpdateAction.new(
242
+ ctx: DummyContext.new(user, nil, nil, nil),
243
+ raw_params: {}
244
+ ).call
245
+
246
+ assert_equal "forbidden", payload[:status]
247
+ end
248
+ end
249
+
250
+ private
251
+
252
+ def stub_const_for_test(_name, _klass, &block)
253
+ yield
254
+ end
255
+ end
256
+
257
+ class CanCanAdapterTest < ArchipelagoTestCase
258
+ DummyContext = Struct.new(:user, :request, :params, :session, :stream)
259
+
260
+ class DummyAbility
261
+ def initialize(user)
262
+ @user = user
263
+ end
264
+
265
+ def can?(action, record)
266
+ action == :read
267
+ end
268
+ end
269
+
270
+ class ReadAction < Archipelago::Action
271
+ include Archipelago::CanCanAdapter
272
+
273
+ authorize { true }
274
+
275
+ def perform
276
+ authorize!(:read, Object.new)
277
+ props ok: true
278
+ end
279
+ end
280
+
281
+ class WriteAction < Archipelago::Action
282
+ include Archipelago::CanCanAdapter
283
+
284
+ authorize { true }
285
+
286
+ def perform
287
+ authorize!(:write, Object.new)
288
+ props ok: true
289
+ end
290
+ end
291
+
292
+ def test_cancan_authorize_allows_permitted_action
293
+ Archipelago.configure do |config|
294
+ config.current_ability = ->(user) { DummyAbility.new(user) }
295
+ end
296
+
297
+ payload = ReadAction.new(
298
+ ctx: DummyContext.new(nil, nil, nil, nil),
299
+ raw_params: {}
300
+ ).call
301
+
302
+ assert_equal "ok", payload[:status]
303
+ end
304
+
305
+ def test_cancan_authorize_raises_forbidden_on_denied_action
306
+ Archipelago.configure do |config|
307
+ config.current_ability = ->(user) { DummyAbility.new(user) }
308
+ end
309
+
310
+ payload = WriteAction.new(
311
+ ctx: DummyContext.new(nil, nil, nil, nil),
312
+ raw_params: {}
313
+ ).call
314
+
315
+ assert_equal "forbidden", payload[:status]
316
+ end
136
317
  end
@@ -12,4 +12,39 @@ class ChannelTest < ArchipelagoTestCase
12
12
  refute_match Archipelago::IslandChannel::STREAM_PATTERN, "../etc/passwd"
13
13
  refute_match Archipelago::IslandChannel::STREAM_PATTERN, "team members"
14
14
  end
15
+
16
+ def test_authorize_stream_returns_true_when_no_authorizer_and_not_required
17
+ Archipelago.configure do |config|
18
+ config.stream_authorizer = nil
19
+ config.require_stream_authorization = false
20
+ end
21
+
22
+ assert Archipelago.authorize_stream?(connection: nil, stream_name: "test:1")
23
+ end
24
+
25
+ def test_authorize_stream_rejects_all_when_required_but_no_authorizer
26
+ Archipelago.configure do |config|
27
+ config.stream_authorizer = nil
28
+ config.require_stream_authorization = true
29
+ end
30
+
31
+ refute Archipelago.authorize_stream?(connection: nil, stream_name: "test:1")
32
+ end
33
+
34
+ def test_authorize_stream_calls_authorizer_lambda
35
+ called_with = nil
36
+ Archipelago.configure do |config|
37
+ config.stream_authorizer = ->(connection:, stream_name:, params:) {
38
+ called_with = { connection: connection, stream_name: stream_name, params: params }
39
+ stream_name.start_with?("allowed:")
40
+ }
41
+ end
42
+
43
+ assert Archipelago.authorize_stream?(connection: :conn, stream_name: "allowed:1", params: { a: 1 })
44
+ assert_equal :conn, called_with[:connection]
45
+ assert_equal "allowed:1", called_with[:stream_name]
46
+ assert_equal({ a: 1 }, called_with[:params])
47
+
48
+ refute Archipelago.authorize_stream?(connection: :conn, stream_name: "denied:1")
49
+ end
15
50
  end
@@ -24,7 +24,7 @@ module Islands
24
24
  end
25
25
 
26
26
  class NotificationsTest < ArchipelagoTestCase
27
- DummyContext = Struct.new(:user, :request, :params, :session)
27
+ DummyContext = Struct.new(:user, :request, :params, :session, :stream)
28
28
 
29
29
  def test_emits_resolve_notification
30
30
  payloads = []
@@ -15,7 +15,70 @@ class ParamsDSLTest < ArchipelagoTestCase
15
15
  end
16
16
  end
17
17
 
18
- DummyContext = Struct.new(:user, :request, :params, :session)
18
+ class InValidatorAction < Archipelago::Action
19
+ param :role, :string, required: true, in: %w[admin member viewer]
20
+
21
+ authorize { true }
22
+
23
+ def perform
24
+ props role: role
25
+ end
26
+ end
27
+
28
+ class FormatValidatorAction < Archipelago::Action
29
+ param :slug, :string, required: true, format: /\A[a-z0-9-]+\z/
30
+
31
+ authorize { true }
32
+
33
+ def perform
34
+ props slug: slug
35
+ end
36
+ end
37
+
38
+ class MinMaxValidatorAction < Archipelago::Action
39
+ param :age, :integer, required: true, min: 0, max: 150
40
+ param :name, :string, required: true, min: 2, max: 50
41
+
42
+ authorize { true }
43
+
44
+ def perform
45
+ props age: age, name: name
46
+ end
47
+ end
48
+
49
+ class EmptyAsNilAction < Archipelago::Action
50
+ param :nickname, :string, empty_as_nil: true
51
+
52
+ authorize { true }
53
+
54
+ def perform
55
+ props nickname: nickname
56
+ end
57
+ end
58
+
59
+ class TypedArrayAction < Archipelago::Action
60
+ param :tag_ids, :array, required: true, of: :integer
61
+
62
+ authorize { true }
63
+
64
+ def perform
65
+ props tag_ids: tag_ids
66
+ end
67
+ end
68
+
69
+ class CustomValidateAction < Archipelago::Action
70
+ param :code, :string, required: true, validate: ->(value) {
71
+ value.length == 6 ? nil : "must be exactly 6 characters"
72
+ }
73
+
74
+ authorize { true }
75
+
76
+ def perform
77
+ props code: code
78
+ end
79
+ end
80
+
81
+ DummyContext = Struct.new(:user, :request, :params, :session, :stream)
19
82
 
20
83
  def test_coerces_and_transforms_params
21
84
  payload = ParamAction.new(
@@ -48,4 +111,144 @@ class ParamsDSLTest < ArchipelagoTestCase
48
111
  assert_equal "error", payload[:status]
49
112
  assert_equal ["is invalid"], payload[:errors]["team_id"]
50
113
  end
114
+
115
+ def test_in_validator_accepts_valid_value
116
+ payload = InValidatorAction.new(
117
+ ctx: DummyContext.new(nil, nil, nil, nil),
118
+ raw_params: { "role" => "admin" }
119
+ ).call
120
+
121
+ assert_equal "ok", payload[:status]
122
+ assert_equal "admin", payload[:props][:role]
123
+ end
124
+
125
+ def test_in_validator_rejects_invalid_value
126
+ payload = InValidatorAction.new(
127
+ ctx: DummyContext.new(nil, nil, nil, nil),
128
+ raw_params: { "role" => "superuser" }
129
+ ).call
130
+
131
+ assert_equal "error", payload[:status]
132
+ assert_equal ["is not included in the list"], payload[:errors]["role"]
133
+ end
134
+
135
+ def test_format_validator_accepts_matching_value
136
+ payload = FormatValidatorAction.new(
137
+ ctx: DummyContext.new(nil, nil, nil, nil),
138
+ raw_params: { "slug" => "my-slug-123" }
139
+ ).call
140
+
141
+ assert_equal "ok", payload[:status]
142
+ assert_equal "my-slug-123", payload[:props][:slug]
143
+ end
144
+
145
+ def test_format_validator_rejects_non_matching_value
146
+ payload = FormatValidatorAction.new(
147
+ ctx: DummyContext.new(nil, nil, nil, nil),
148
+ raw_params: { "slug" => "INVALID SLUG!" }
149
+ ).call
150
+
151
+ assert_equal "error", payload[:status]
152
+ assert_equal ["is invalid"], payload[:errors]["slug"]
153
+ end
154
+
155
+ def test_min_max_validator_accepts_in_range
156
+ payload = MinMaxValidatorAction.new(
157
+ ctx: DummyContext.new(nil, nil, nil, nil),
158
+ raw_params: { "age" => "25", "name" => "Alice" }
159
+ ).call
160
+
161
+ assert_equal "ok", payload[:status]
162
+ assert_equal 25, payload[:props][:age]
163
+ end
164
+
165
+ def test_min_validator_rejects_below_minimum
166
+ payload = MinMaxValidatorAction.new(
167
+ ctx: DummyContext.new(nil, nil, nil, nil),
168
+ raw_params: { "age" => "-1", "name" => "Alice" }
169
+ ).call
170
+
171
+ assert_equal "error", payload[:status]
172
+ assert_equal ["is too small"], payload[:errors]["age"]
173
+ end
174
+
175
+ def test_max_validator_rejects_above_maximum
176
+ payload = MinMaxValidatorAction.new(
177
+ ctx: DummyContext.new(nil, nil, nil, nil),
178
+ raw_params: { "age" => "200", "name" => "Alice" }
179
+ ).call
180
+
181
+ assert_equal "error", payload[:status]
182
+ assert_equal ["is too large"], payload[:errors]["age"]
183
+ end
184
+
185
+ def test_min_max_on_string_validates_length
186
+ payload = MinMaxValidatorAction.new(
187
+ ctx: DummyContext.new(nil, nil, nil, nil),
188
+ raw_params: { "age" => "25", "name" => "A" }
189
+ ).call
190
+
191
+ assert_equal "error", payload[:status]
192
+ assert_equal ["is too small"], payload[:errors]["name"]
193
+ end
194
+
195
+ def test_empty_as_nil_treats_blank_string_as_nil
196
+ payload = EmptyAsNilAction.new(
197
+ ctx: DummyContext.new(nil, nil, nil, nil),
198
+ raw_params: { "nickname" => " " }
199
+ ).call
200
+
201
+ assert_equal "ok", payload[:status]
202
+ assert_nil payload[:props][:nickname]
203
+ end
204
+
205
+ def test_empty_as_nil_preserves_non_empty_string
206
+ payload = EmptyAsNilAction.new(
207
+ ctx: DummyContext.new(nil, nil, nil, nil),
208
+ raw_params: { "nickname" => "Bob" }
209
+ ).call
210
+
211
+ assert_equal "ok", payload[:status]
212
+ assert_equal "Bob", payload[:props][:nickname]
213
+ end
214
+
215
+ def test_typed_array_coerces_elements
216
+ payload = TypedArrayAction.new(
217
+ ctx: DummyContext.new(nil, nil, nil, nil),
218
+ raw_params: { "tag_ids" => ["1", "2", "3"] }
219
+ ).call
220
+
221
+ assert_equal "ok", payload[:status]
222
+ assert_equal [1, 2, 3], payload[:props][:tag_ids]
223
+ end
224
+
225
+ def test_typed_array_rejects_invalid_elements
226
+ payload = TypedArrayAction.new(
227
+ ctx: DummyContext.new(nil, nil, nil, nil),
228
+ raw_params: { "tag_ids" => ["1", "abc", "3"] }
229
+ ).call
230
+
231
+ assert_equal "error", payload[:status]
232
+ assert_equal ["is invalid"], payload[:errors]["tag_ids"]
233
+ end
234
+
235
+ def test_custom_validate_accepts_valid_value
236
+ payload = CustomValidateAction.new(
237
+ ctx: DummyContext.new(nil, nil, nil, nil),
238
+ raw_params: { "code" => "ABC123" }
239
+ ).call
240
+
241
+ assert_equal "ok", payload[:status]
242
+ assert_equal "ABC123", payload[:props][:code]
243
+ end
244
+
245
+ def test_custom_validate_rejects_invalid_value
246
+ payload = CustomValidateAction.new(
247
+ ctx: DummyContext.new(nil, nil, nil, nil),
248
+ raw_params: { "code" => "SHORT" }
249
+ ).call
250
+
251
+ assert_equal "error", payload[:status]
252
+ assert_equal ["must be exactly 6 characters"], payload[:errors]["code"]
253
+ end
51
254
  end
@@ -41,6 +41,7 @@ class ReactInstallGeneratorTest < Rails::Generators::TestCase
41
41
  package_json = JSON.parse(File.read(File.join(destination_root, "package.json")))
42
42
  assert_equal "node app/javascript/archipelago/generate_registry.mjs", package_json.dig("scripts", "archipelago:registry")
43
43
  assert_match(/^node app\/javascript\/archipelago\/generate_registry\.mjs && /, package_json.dig("scripts", "build"))
44
+ assert_file ".npmrc", /@archipelago-js:registry=https:\/\/registry\.npmjs\.org/
44
45
  end
45
46
 
46
47
  def test_generates_typescript_entry_when_requested
@@ -64,4 +65,13 @@ class ReactInstallGeneratorTest < Rails::Generators::TestCase
64
65
  assert_file "app/javascript/archipelago/entry.jsx", /const registry = \{/
65
66
  refute File.exist?(File.join(destination_root, "app/javascript/archipelago/generate_registry.mjs"))
66
67
  end
68
+
69
+ def test_does_not_duplicate_scope_registry_when_already_present
70
+ File.write(File.join(destination_root, ".npmrc"), "@archipelago-js:registry=https://registry.npmjs.org\n")
71
+
72
+ run_generator %w[--interactive=false]
73
+
74
+ npmrc = File.read(File.join(destination_root, ".npmrc"))
75
+ assert_equal 1, npmrc.scan("@archipelago-js:registry=").length
76
+ end
67
77
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: archipelago-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Archipelago
@@ -116,11 +116,13 @@ files:
116
116
  - lib/archipelago.rb
117
117
  - lib/archipelago/action.rb
118
118
  - lib/archipelago/broadcasts.rb
119
+ - lib/archipelago/cancan_adapter.rb
119
120
  - lib/archipelago/channel.rb
120
121
  - lib/archipelago/configuration.rb
121
122
  - lib/archipelago/context.rb
122
123
  - lib/archipelago/engine.rb
123
124
  - lib/archipelago/params_dsl.rb
125
+ - lib/archipelago/pundit_adapter.rb
124
126
  - lib/archipelago/registry.rb
125
127
  - lib/archipelago/resolver.rb
126
128
  - lib/archipelago/response.rb