Skip to content

Commit a0650e6

Browse files
committed
feat: migration 24 - wildcard pattern matching and main worker protection
- Add wildcard pattern matching for routes: * `/*` = prefix match (backward compatible for storage routes) * `/**` = multi-segment match (for function routes) - Add helper functions: pattern_to_regex, match_route_pattern, route_pattern_specificity - Fix 404 vs 502 distinction: return project_id when route not found but project exists - Add trigger to prevent direct deletion of main workers (must delete project instead) - Update resolve_worker_from_request to verify worker existence before returning Bump version to 0.2.11
1 parent 5138d37 commit a0650e6

4 files changed

Lines changed: 300 additions & 2 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "openworkers-cli"
3-
version = "0.2.10"
3+
version = "0.2.11"
44
edition = "2024"
55
license = "MIT"
66
description = "CLI for OpenWorkers - Self-hosted Cloudflare Workers runtime"
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
--
2+
-- OpenWorkers Database Schema - Wildcard Pattern Matching
3+
--
4+
-- Add support for wildcard patterns in routes:
5+
-- * = single path segment (matches anything except /)
6+
-- ** = multiple path segments (matches anything including /)
7+
--
8+
-- Examples:
9+
-- /status/*/* matches /status/200/OK
10+
-- /range/* matches /range/1024
11+
-- /drip/** matches /drip/10/2/0/200
12+
-- /basic-auth/*/* matches /basic-auth/user/pass
13+
--
14+
15+
BEGIN;
16+
17+
-- ============================================================================
18+
-- Helper function: Convert wildcard pattern to regex
19+
-- ============================================================================
20+
21+
CREATE OR REPLACE FUNCTION pattern_to_regex(pattern TEXT)
22+
RETURNS TEXT AS $$
23+
BEGIN
24+
-- Convert wildcard pattern to PostgreSQL regex:
25+
-- ** → .* (match anything including /)
26+
-- * → [^/]+ (match anything except /)
27+
28+
-- Use placeholder for ** to avoid conflicts with single *
29+
RETURN '^' ||
30+
regexp_replace(
31+
regexp_replace(
32+
regexp_replace(pattern, '\*\*', '__DOUBLESTAR__', 'g'),
33+
'\*', '[^/]+', 'g'
34+
),
35+
'__DOUBLESTAR__', '.*', 'g'
36+
) || '$';
37+
END;
38+
$$ LANGUAGE plpgsql IMMUTABLE;
39+
40+
COMMENT ON FUNCTION pattern_to_regex(TEXT) IS 'Converts wildcard pattern (* and **) to PostgreSQL regex';
41+
42+
-- ============================================================================
43+
-- Helper function: Match path against wildcard pattern
44+
-- ============================================================================
45+
46+
CREATE OR REPLACE FUNCTION match_route_pattern(path TEXT, pattern TEXT)
47+
RETURNS BOOLEAN AS $$
48+
BEGIN
49+
-- Exact match (no wildcards)
50+
IF position('*' in pattern) = 0 THEN
51+
RETURN path = pattern;
52+
END IF;
53+
54+
-- Single wildcard /* → prefix match (backward compatible for storage routes)
55+
IF pattern LIKE '%/*' AND position('**' in pattern) = 0 THEN
56+
RETURN path LIKE replace(pattern, '/*', '/%');
57+
END IF;
58+
59+
-- Double wildcard /** → full regex match (for function routes with multiple segments)
60+
RETURN path ~ pattern_to_regex(pattern);
61+
END;
62+
$$ LANGUAGE plpgsql IMMUTABLE;
63+
64+
COMMENT ON FUNCTION match_route_pattern(TEXT, TEXT) IS 'Matches a request path against a route pattern with wildcard support';
65+
66+
-- ============================================================================
67+
-- Helper function: Calculate route pattern specificity
68+
-- ============================================================================
69+
70+
CREATE OR REPLACE FUNCTION route_pattern_specificity(pattern TEXT)
71+
RETURNS INTEGER AS $$
72+
BEGIN
73+
-- Higher score = more specific = higher priority
74+
-- Static routes get highest priority
75+
-- Each * reduces score by 1
76+
-- Each ** reduces score by 10
77+
78+
IF position('*' in pattern) = 0 THEN
79+
-- Static route: use length as specificity
80+
RETURN length(pattern) + 1000;
81+
END IF;
82+
83+
-- Count wildcards and reduce specificity
84+
RETURN
85+
length(pattern)
86+
- (length(pattern) - length(replace(pattern, '*', ''))) -- Penalty for each *
87+
- (length(pattern) - length(replace(pattern, '**', '')) -
88+
(length(pattern) - length(replace(pattern, '*', '')))) * 9; -- Extra penalty for **
89+
END;
90+
$$ LANGUAGE plpgsql IMMUTABLE;
91+
92+
COMMENT ON FUNCTION route_pattern_specificity(TEXT) IS 'Calculates route specificity for ordering (higher = more specific)';
93+
94+
-- ============================================================================
95+
-- Update resolve_worker_from_request to use wildcard matching
96+
-- ============================================================================
97+
98+
CREATE OR REPLACE FUNCTION resolve_worker_from_request(
99+
p_domain varchar DEFAULT NULL,
100+
p_worker_id uuid DEFAULT NULL,
101+
p_worker_name varchar DEFAULT NULL,
102+
p_path varchar DEFAULT '/'
103+
)
104+
RETURNS request_resolution AS $$
105+
DECLARE
106+
v_result request_resolution;
107+
v_worker_id_temp uuid;
108+
v_project_id_temp uuid;
109+
v_route_record RECORD;
110+
BEGIN
111+
-- Priority 1: Direct worker_id (used for service bindings, worker-to-worker calls)
112+
IF p_worker_id IS NOT NULL THEN
113+
-- Verify worker exists before returning
114+
IF EXISTS (SELECT 1 FROM workers WHERE id = p_worker_id) THEN
115+
v_result.worker_id := p_worker_id;
116+
v_result.project_id := NULL;
117+
v_result.backend_type := 'worker'::enum_backend_type;
118+
v_result.assets_storage_id := NULL;
119+
v_result.priority := NULL;
120+
RETURN v_result;
121+
ELSE
122+
RETURN NULL; -- Worker not found
123+
END IF;
124+
END IF;
125+
126+
-- Priority 2: Resolve project_id/worker_id from worker_name (subdomain) or domain (custom domain)
127+
IF p_worker_name IS NOT NULL THEN
128+
-- Subdomain case: name.workers.rocks → lookup in endpoints
129+
-- Projects have priority over standalone workers for routing
130+
SELECT project_id, worker_id
131+
INTO v_project_id_temp, v_worker_id_temp
132+
FROM endpoints
133+
WHERE name = p_worker_name
134+
ORDER BY type DESC -- 'project' > 'worker' alphabetically
135+
LIMIT 1;
136+
137+
IF NOT FOUND THEN
138+
RETURN NULL; -- Endpoint not found
139+
END IF;
140+
ELSIF p_domain IS NOT NULL THEN
141+
-- Custom domain case: example.com → lookup in domains
142+
SELECT project_id, worker_id
143+
INTO v_project_id_temp, v_worker_id_temp
144+
FROM domains
145+
WHERE name = p_domain;
146+
147+
IF NOT FOUND THEN
148+
RETURN NULL; -- Domain not found
149+
END IF;
150+
ELSE
151+
-- No identifier provided
152+
RETURN NULL;
153+
END IF;
154+
155+
-- If we have a project, match route pattern
156+
IF v_project_id_temp IS NOT NULL THEN
157+
SELECT backend_type, worker_id, priority
158+
INTO v_route_record
159+
FROM project_routes
160+
WHERE project_id = v_project_id_temp
161+
AND (
162+
match_route_pattern(p_path, pattern)
163+
-- Allow /about.html to match route /about (prerendered fallback)
164+
OR (p_path LIKE '%.html' AND pattern = REPLACE(p_path, '.html', ''))
165+
)
166+
ORDER BY
167+
route_pattern_specificity(pattern) DESC,
168+
priority DESC
169+
LIMIT 1;
170+
171+
IF NOT FOUND THEN
172+
-- No route matches, but project exists (should be 404)
173+
v_result.project_id := v_project_id_temp;
174+
v_result.worker_id := NULL;
175+
v_result.backend_type := NULL;
176+
v_result.assets_storage_id := NULL;
177+
v_result.priority := NULL;
178+
RETURN v_result;
179+
END IF;
180+
181+
v_result.project_id := v_project_id_temp;
182+
v_result.worker_id := v_route_record.worker_id;
183+
v_result.backend_type := v_route_record.backend_type;
184+
v_result.priority := v_route_record.priority;
185+
186+
-- If storage backend, get ASSETS storage_config_id from main worker's environment
187+
IF v_route_record.backend_type = 'storage' THEN
188+
SELECT value::uuid
189+
INTO v_result.assets_storage_id
190+
FROM environment_values ev
191+
JOIN workers w ON w.environment_id = ev.environment_id
192+
WHERE w.id = v_project_id_temp -- main worker has same id as project
193+
AND ev.key = 'ASSETS'
194+
AND ev.type = 'assets';
195+
196+
IF v_result.assets_storage_id IS NULL THEN
197+
RETURN NULL; -- ASSETS binding not found
198+
END IF;
199+
ELSE
200+
v_result.assets_storage_id := NULL;
201+
END IF;
202+
203+
RETURN v_result;
204+
END IF;
205+
206+
-- If we have a standalone worker (not in a project)
207+
IF v_worker_id_temp IS NOT NULL THEN
208+
v_result.worker_id := v_worker_id_temp;
209+
v_result.project_id := NULL;
210+
v_result.backend_type := 'worker'::enum_backend_type;
211+
v_result.assets_storage_id := NULL;
212+
v_result.priority := NULL;
213+
RETURN v_result;
214+
END IF;
215+
216+
-- Nothing matched
217+
RETURN NULL;
218+
END;
219+
$$ LANGUAGE plpgsql;
220+
221+
COMMENT ON FUNCTION resolve_worker_from_request(varchar, uuid, varchar, varchar) IS 'Resolves endpoint and route with wildcard pattern matching (* and **) and .html fallback';
222+
223+
-- ============================================================================
224+
-- Prevent deletion of main workers
225+
-- ============================================================================
226+
227+
CREATE OR REPLACE FUNCTION prevent_main_worker_deletion()
228+
RETURNS TRIGGER AS $$
229+
BEGIN
230+
-- Check if this worker is a main worker (id matches a project)
231+
IF EXISTS (SELECT 1 FROM projects WHERE id = OLD.id) THEN
232+
RAISE EXCEPTION 'Cannot delete main worker - delete the project instead';
233+
END IF;
234+
235+
RETURN OLD;
236+
END;
237+
$$ LANGUAGE plpgsql;
238+
239+
CREATE TRIGGER prevent_main_worker_deletion_trigger
240+
BEFORE DELETE ON workers
241+
FOR EACH ROW
242+
EXECUTE FUNCTION prevent_main_worker_deletion();
243+
244+
COMMENT ON FUNCTION prevent_main_worker_deletion() IS 'Prevents direct deletion of main workers - projects must be deleted instead';
245+
246+
COMMIT;
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
--- 23_html_fallback_routing.sql: resolve_worker_from_request
2+
+++ 24_wildcard_pattern_matching.sql: resolve_worker_from_request
3+
@@ -27,10 +27,15 @@
4+
-- Priority 1: Direct worker_id (used for service bindings, worker-to-worker calls)
5+
IF p_worker_id IS NOT NULL THEN
6+
- v_result.worker_id := p_worker_id;
7+
- v_result.project_id := NULL;
8+
- v_result.backend_type := 'worker'::enum_backend_type;
9+
- v_result.assets_storage_id := NULL;
10+
- v_result.priority := NULL;
11+
- RETURN v_result;
12+
+ -- Verify worker exists before returning
13+
+ IF EXISTS (SELECT 1 FROM workers WHERE id = p_worker_id) THEN
14+
+ v_result.worker_id := p_worker_id;
15+
+ v_result.project_id := NULL;
16+
+ v_result.backend_type := 'worker'::enum_backend_type;
17+
+ v_result.assets_storage_id := NULL;
18+
+ v_result.priority := NULL;
19+
+ RETURN v_result;
20+
+ ELSE
21+
+ RETURN NULL; -- Worker not found
22+
+ END IF;
23+
END IF;
24+
25+
@@ -70,15 +75,21 @@
26+
INTO v_route_record
27+
FROM project_routes
28+
WHERE project_id = v_project_id_temp
29+
AND (
30+
- pattern = p_path
31+
- OR (pattern LIKE '%/*' AND p_path LIKE REPLACE(pattern, '/*', '') || '%')
32+
+ match_route_pattern(p_path, pattern)
33+
-- Allow /about.html to match route /about (prerendered fallback)
34+
OR (p_path LIKE '%.html' AND pattern = REPLACE(p_path, '.html', ''))
35+
)
36+
- ORDER BY priority DESC
37+
+ ORDER BY
38+
+ route_pattern_specificity(pattern) DESC,
39+
+ priority DESC
40+
LIMIT 1;
41+
42+
IF NOT FOUND THEN
43+
- RETURN NULL; -- No route matches
44+
+ -- No route matches, but project exists (should be 404)
45+
+ v_result.project_id := v_project_id_temp;
46+
+ v_result.worker_id := NULL;
47+
+ v_result.backend_type := NULL;
48+
+ v_result.assets_storage_id := NULL;
49+
+ v_result.priority := NULL;
50+
+ RETURN v_result;
51+
END IF;
52+

0 commit comments

Comments
 (0)