findxpand 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.
data/lib/findxpand.rb ADDED
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Findxpand origin middleware — server-side SEO remediation in your own stack.
4
+ #
5
+ # RUBYOPT="-rfindxpand/auto" bundle exec puma
6
+ #
7
+ # One deploy. After that, approved fixes arrive as a manifest pushed to
8
+ # `/__findxpand/manifest` and no further release is needed — which is the reason
9
+ # this gem exists rather than a JSON file in the repository.
10
+ #
11
+ # The corrected bytes are produced on the server, before anything leaves the
12
+ # process. No JavaScript is involved anywhere, because AI crawlers do not run it
13
+ # (§3 rule 1) — a title injected on the client is not a remediation path.
14
+ #
15
+ # ## What is in here
16
+ #
17
+ # rewrite.rb the rewrite: pure, shared semantics, no I/O
18
+ # store.rb the manifest, the counters, the cache file, the verdict
19
+ # signature.rb HMAC over `timestamp.body`, constant time, 300s window
20
+ # admin.rb the two endpoints, and the only branches that fail closed
21
+ # middleware.rb the Rack middleware
22
+ # encoding.rb accept-encoding negotiation, and gzip on the way out
23
+ # auto.rb the zero-code attach
24
+ # railtie.rb Rails, at position 0
25
+ # cli.rb init / doctor / status
26
+ #
27
+ # Requiring this file is inert: it defines the classes and, if Rails is already
28
+ # loaded, registers the Railtie — which is what a Gemfile entry does and what a
29
+ # Rails developer expects a gem to do. It patches nothing. `findxpand/auto` is
30
+ # the file that attaches, and it has to be asked for by name.
31
+ #
32
+ # **Zero runtime dependencies, deliberately.** This runs inside somebody else's
33
+ # application, and a middleware that drags in a dependency tree is a middleware
34
+ # that can break their build. It is a Rack middleware that does not require Rack:
35
+ # nothing here needs more of Rack than the calling convention.
36
+ #
37
+ # Frozen string literals: on, in every file of this gem.
38
+
39
+ require 'findxpand/rewrite'
40
+ require 'findxpand/store'
41
+ require 'findxpand/signature'
42
+ require 'findxpand/encoding'
43
+ require 'findxpand/admin'
44
+ require 'findxpand/middleware'
45
+
46
+ module Findxpand
47
+ VERSION = '0.1.0'
48
+
49
+ # Not published to RubyGems, and nothing in this gem may claim otherwise.
50
+ #
51
+ # Mirrors `engine.fix.origin.PUBLISHED_TO_REGISTRIES`, which was written on
52
+ # 31 Aug 2026 after checking npm and PyPI and finding that three surfaces had
53
+ # been printing `npm install @findxpand/middleware` — a command that answers
54
+ # 404, and the first thing a customer would learn about us. There is no publish
55
+ # workflow in this tree for any of the three packages, so `gem install
56
+ # findxpand` fails the same way. The CLI and the README ask here.
57
+ PUBLISHED_TO_REGISTRIES = false
58
+
59
+ # Said once, wherever an install instruction appears, for as long as the gem is
60
+ # unpublished. Without it the command reads as a mistake.
61
+ UNPUBLISHED_NOTE =
62
+ 'We send you this file - the gem is not on RubyGems yet, so there is nothing to ' \
63
+ 'install by name. Keep it somewhere your build can reach; the version in the ' \
64
+ 'filename is what a support conversation refers to.'
65
+
66
+ def self.install_command
67
+ return 'gem install findxpand' if PUBLISHED_TO_REGISTRIES
68
+
69
+ # Versioned deliberately: `gem install ./findxpand.gem` would install
70
+ # whatever happens to be in the client's download folder, and the version is
71
+ # the only thing tying a support conversation to a build.
72
+ "gem install ./findxpand-#{VERSION}.gem"
73
+ end
74
+
75
+ # How this gem attaches without the customer editing anything.
76
+ #
77
+ # Mirrors the `node` and `python` rows of `engine.fix.origin._ATTACH`, which
78
+ # has no `ruby` row yet — see the README.
79
+ ATTACH = 'RUBYOPT="-rfindxpand/auto"'
80
+
81
+ @store = nil
82
+ @store_lock = Mutex.new
83
+ @store_paths = nil
84
+ @path_conflict = nil
85
+
86
+ # The one manifest this process serves.
87
+ #
88
+ # Memoised, because Ruby has three attach points and two of them can be live at
89
+ # once: the builder prepend wraps what `config.ru` builds and the Railtie sits
90
+ # at position 0 inside it. Two `Store` objects would mean two
91
+ # manifests in one process — a push landing in the instance that happens to own
92
+ # `/__findxpand/manifest` and a rewrite reading the other one, which is a fix
93
+ # that reports as stored and never appears on a page. §20.1 rule 2: one
94
+ # implementation of "what are this process's rules", and as many readers as the
95
+ # stack needs.
96
+ #
97
+ # **What used to be written here was false, and it was the worst defect in the
98
+ # gem.** It said: "the first caller's paths win — that is the `RUBYOPT` attach
99
+ # in practice, since it runs before the application's first line". On Rails the
100
+ # first caller is *always the Railtie*, because Rails builds and instantiates
101
+ # its middleware stack during `require config/environment`, before `config.ru`
102
+ # reaches `to_app`. And until 2 Sep 2026 `Findxpand.options_from_env` had
103
+ # exactly one caller in the whole gem (`auto.rb`), so the Railtie and the
104
+ # documented hand mount both arrived here with `cache_file: nil` — whatever
105
+ # `FINDXPAND_CACHE_FILE` said. The memoised store was therefore the one that
106
+ # could not persist: `Store#write_cache` returned at its first line, a push
107
+ # answered `{"ok":true,"version":"…"}`, pages were rewritten, and `/status`
108
+ # reported health. The next restart reverted the site. Worse,
109
+ # `OriginMiddleware.apply` then read an empty deployed
110
+ # manifest and merged the next fix into `{}`, so every previously approved fix
111
+ # vanished with no error anywhere.
112
+ #
113
+ # The repair is in `Middleware#initialize`, which now resolves **every** option
114
+ # from the environment on every construction path, matching
115
+ # `middleware/php/src/Middleware.php:97-106` (`$options = array_merge(
116
+ # Auto::options(), $options )` — "defaults come from the environment, so a
117
+ # container binding with no arguments behaves like the bootstrap"). Once every
118
+ # caller resolves the same environment, every caller asks for the same paths
119
+ # and the memoisation has nothing left to lose.
120
+ #
121
+ # What is left here is the case that survives that: somebody passing an
122
+ # explicit path in code from a second mount point. First caller still wins — a
123
+ # second store would split the manifest, which is what this memoisation exists
124
+ # to prevent — but the discarded value is now *observed*, so it is reported
125
+ # rather than dropped on the floor (§20.1 rule 3). Once, on stderr, naming both.
126
+ def self.store(cache_file: nil, manifest_file: nil, clock: nil)
127
+ wanted = [cache_file, manifest_file]
128
+ @store_lock.synchronize do
129
+ if @store.nil?
130
+ @store_paths = wanted
131
+ @store = Store.new(cache_file: cache_file, manifest_file: manifest_file, clock: clock)
132
+ elsif wanted != @store_paths && @path_conflict.nil?
133
+ @path_conflict = "serving #{@store_paths.inspect}; #{wanted.inspect} was ignored"
134
+ warn_stderr('two mount points asked for different manifest paths - ' \
135
+ "#{@path_conflict}. One process serves one manifest: set " \
136
+ 'FINDXPAND_CACHE_FILE and pass no paths in code.')
137
+ end
138
+ @store
139
+ end
140
+ end
141
+
142
+ # For the suite only. A process that discarded its store mid-flight would lose
143
+ # every rule until the next push.
144
+ def self.reset_store!
145
+ @store_lock.synchronize do
146
+ @store = nil
147
+ @store_paths = nil
148
+ @path_conflict = nil
149
+ end
150
+ end
151
+
152
+ # How this gem talks to stderr, in one place.
153
+ #
154
+ # `Auto.warn_stderr` delegates here rather than carrying its own copy: two
155
+ # spellings of the prefix is the §20.1 rule 2 shape at its smallest, and the
156
+ # prefix is what a customer greps their boot log for. `$stderr.write` rather
157
+ # than `Kernel#warn` because `warn` is silenced by `-W0` and by
158
+ # `Warning[:deprecated] = false`-style configuration in somebody else's
159
+ # application, and this is not a deprecation notice — it is the only channel an
160
+ # attach that did not attach has.
161
+ def self.warn_stderr(message)
162
+ $stderr.write("findxpand: #{message}\n")
163
+ end
164
+
165
+ # What attached in this process, for `GET /__findxpand/status`.
166
+ #
167
+ # Additive keys. The engine spreads whatever the endpoint answers
168
+ # (`engine.fix.origin.status`, `{"ok": True, "installed": True, **payload}`), and
169
+ # `middleware/php/src/Store.php`'s `runtime`/`reach_reason` block is the
170
+ # precedent: a package reports the failures only its own runtime can have,
171
+ # named for what they mean rather than squeezed into a shared key.
172
+ #
173
+ # This answers the question the builder prepend could not. The total failure —
174
+ # `findxpand/auto` ran and nothing of ours was ever constructed — cannot be
175
+ # reported *here*, because there is then no middleware in the path to answer
176
+ # `/status` at all; that one goes to stderr from `Auto`. What is reported here
177
+ # is the partial case, which is the one a monitor can actually reach:
178
+ # `/status` answers because the Railtie mounted, `builders` names only
179
+ # `Rack::Builder`, and `built` is 0 — meaning the server parsed `config.ru`
180
+ # with its own vendored builder and the `RUBYOPT` attach never fired. That was
181
+ # the shipped behaviour under Puma until 2 Sep 2026 (`auto.rb`).
182
+ #
183
+ # `middlewares` is `Middleware.constructed`, and it is here rather than in
184
+ # `Auto` because it is true of the process however the middleware got there —
185
+ # the Railtie and a hand-written `use` move it too.
186
+ def self.attach_report
187
+ report = { 'auto' => defined?(Auto) ? true : false,
188
+ 'middlewares' => Middleware.constructed }
189
+ report['store_conflict'] = @path_conflict unless @path_conflict.nil?
190
+ begin
191
+ report.merge!(Auto.report) if defined?(Auto)
192
+ rescue StandardError => e
193
+ # `/status` is the one endpoint that reports whether anything is wrong, and
194
+ # the engine's `verify` reads it. Raising here would take the health check
195
+ # down over the *reporting* of health, which is the tail wagging the dog —
196
+ # so the failure becomes a field instead of a 500. Named rather than
197
+ # swallowed: an absent `attach` block would read as "nothing to say", and
198
+ # this is "we could not look" (§20.1 rule 3).
199
+ report['attach_error'] = "#{e.class}: #{e.message}"
200
+ end
201
+ report
202
+ end
203
+
204
+ # `Findxpand.middleware(app, **options)` — the explicit mount, for anybody who
205
+ # would rather see it in their `config.ru` than trust a hook.
206
+ #
207
+ # use Findxpand::Middleware
208
+ # # or
209
+ # run Findxpand.middleware(MyApp, token: ENV['FINDXPAND_TOKEN'])
210
+ #
211
+ # Mount it **first**, which in Rack is the first `use` in the file, and read
212
+ # `encoding.rb` before deciding that is enough: it is enough only because this
213
+ # middleware takes over the encoding negotiation on the paths it rewrites.
214
+ def self.middleware(app, **options)
215
+ Middleware.new(app, **options)
216
+ end
217
+
218
+ # `false`, `0`, `no` and `off` are off; anything else present is on.
219
+ #
220
+ # Written out because `ENV['X'] ? true : false` makes the string "false" true,
221
+ # and an operator who sets `FINDXPAND_ENABLED=false` to turn the gem off and
222
+ # finds it still running has no reason to suspect the flag.
223
+ def self.flag(name, fallback)
224
+ raw = ENV[name]
225
+ return fallback if raw.nil? || raw.empty?
226
+
227
+ !/\A(?:false|0|no|off)\z/i.match?(raw.strip)
228
+ end
229
+
230
+ # Middleware options from the environment, in one place.
231
+ def self.options_from_env
232
+ settings = {
233
+ token: ENV['FINDXPAND_TOKEN'].to_s,
234
+ cache_file: env_path('FINDXPAND_CACHE_FILE'),
235
+ manifest_file: env_path('FINDXPAND_MANIFEST_FILE'),
236
+ enabled: flag('FINDXPAND_ENABLED', true),
237
+ require_signature: flag('FINDXPAND_REQUIRE_SIGNATURE', true),
238
+ recompress: flag('FINDXPAND_RECOMPRESS', true)
239
+ }
240
+ raw = ENV['FINDXPAND_MAX_BYTES'].to_s
241
+ settings[:max_bytes] = raw.to_i if /\A\d+\z/.match?(raw) && raw.to_i.positive?
242
+ settings
243
+ end
244
+
245
+ def self.env_path(name)
246
+ value = ENV[name].to_s
247
+ value.empty? ? nil : value
248
+ end
249
+ end
250
+
251
+ # Registered here rather than in `auto.rb` because a Railtie is the shape a Rails
252
+ # developer expects a gem to have, and because `auto.rb` is loaded by `RUBYOPT`
253
+ # *before* Rails exists — at which point there is no `Rails::Railtie` to subclass.
254
+ # A Gemfile entry loads this file after Rails, which is when this line is true.
255
+ require 'findxpand/railtie' if defined?(::Rails::Railtie)
metadata ADDED
@@ -0,0 +1,62 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: findxpand
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Nextoria
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-09-02 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: One deploy. After that, approved fixes arrive as a manifest pushed to
14
+ your own origin and no further release is needed. The corrected HTML is produced
15
+ on the server, so crawlers that do not run JavaScript still see it. Zero runtime
16
+ dependencies; it never makes an outbound request.
17
+ email:
18
+ executables:
19
+ - findxpand
20
+ extensions: []
21
+ extra_rdoc_files: []
22
+ files:
23
+ - README.md
24
+ - exe/findxpand
25
+ - findxpand.gemspec
26
+ - lib/findxpand.rb
27
+ - lib/findxpand/admin.rb
28
+ - lib/findxpand/auto.rb
29
+ - lib/findxpand/cli.rb
30
+ - lib/findxpand/encoding.rb
31
+ - lib/findxpand/middleware.rb
32
+ - lib/findxpand/railtie.rb
33
+ - lib/findxpand/rewrite.rb
34
+ - lib/findxpand/signature.rb
35
+ - lib/findxpand/store.rb
36
+ homepage: https://nextoria.ae/findxpand
37
+ licenses:
38
+ - MIT
39
+ metadata:
40
+ homepage_uri: https://nextoria.ae/findxpand
41
+ rubygems_mfa_required: 'true'
42
+ post_install_message:
43
+ rdoc_options: []
44
+ require_paths:
45
+ - lib
46
+ required_ruby_version: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: '3.0'
51
+ required_rubygems_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '0'
56
+ requirements: []
57
+ rubygems_version: 3.3.27
58
+ signing_key:
59
+ specification_version: 4
60
+ summary: Server-side SEO remediation at your origin, for Rack, Rails, Sinatra and
61
+ Hanami.
62
+ test_files: []