openship 0.1.11 → 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.
Files changed (42) hide show
  1. package/dist/index.js +680 -104
  2. package/dist/server/index.js +17063 -8214
  3. package/dist/server/lua/geo_country.lua +101 -0
  4. package/dist/server/lua/mgmt_api.lua +291 -0
  5. package/dist/server/lua/pipe_log.lua +71 -0
  6. package/dist/server/lua/pipe_stream.lua +90 -0
  7. package/dist/server/lua/rules_guard.lua +118 -0
  8. package/dist/server/lua/rules_lib.lua +176 -0
  9. package/dist/server/lua/site_logger.lua +182 -0
  10. package/dist/server/lua/webhook_handler.lua +126 -0
  11. package/dist/server/migrations/0035_thankful_morgan_stark.sql +1 -0
  12. package/dist/server/migrations/0036_gorgeous_scalphunter.sql +17 -0
  13. package/dist/server/migrations/0037_clever_makkari.sql +28 -0
  14. package/dist/server/migrations/0038_young_magneto.sql +1 -0
  15. package/dist/server/migrations/0039_secret_lady_ursula.sql +15 -0
  16. package/dist/server/migrations/0040_sharp_red_wolf.sql +16 -0
  17. package/dist/server/migrations/0041_round_spyke.sql +4 -0
  18. package/dist/server/migrations/0042_dapper_havok.sql +1 -0
  19. package/dist/server/migrations/0043_fast_sharon_ventura.sql +15 -0
  20. package/dist/server/migrations/0044_amused_shape.sql +8 -0
  21. package/dist/server/migrations/0045_amusing_korath.sql +1 -0
  22. package/dist/server/migrations/0046_white_harrier.sql +35 -0
  23. package/dist/server/migrations/0047_sloppy_strong_guy.sql +2 -0
  24. package/dist/server/migrations/0048_funny_emma_frost.sql +2 -0
  25. package/dist/server/migrations/0049_nappy_piledriver.sql +3 -0
  26. package/dist/server/migrations/meta/0035_snapshot.json +7610 -0
  27. package/dist/server/migrations/meta/0036_snapshot.json +7752 -0
  28. package/dist/server/migrations/meta/0037_snapshot.json +7978 -0
  29. package/dist/server/migrations/meta/0038_snapshot.json +7985 -0
  30. package/dist/server/migrations/meta/0039_snapshot.json +8090 -0
  31. package/dist/server/migrations/meta/0040_snapshot.json +8198 -0
  32. package/dist/server/migrations/meta/0041_snapshot.json +8211 -0
  33. package/dist/server/migrations/meta/0042_snapshot.json +8217 -0
  34. package/dist/server/migrations/meta/0043_snapshot.json +8316 -0
  35. package/dist/server/migrations/meta/0044_snapshot.json +8360 -0
  36. package/dist/server/migrations/meta/0045_snapshot.json +8367 -0
  37. package/dist/server/migrations/meta/0046_snapshot.json +8657 -0
  38. package/dist/server/migrations/meta/0047_snapshot.json +8669 -0
  39. package/dist/server/migrations/meta/0048_snapshot.json +8683 -0
  40. package/dist/server/migrations/meta/0049_snapshot.json +8702 -0
  41. package/dist/server/migrations/meta/_journal.json +105 -0
  42. package/package.json +4 -1
@@ -0,0 +1,176 @@
1
+ -- rules_lib.lua
2
+ -- Per-worker helpers + parsed-rule cache for rules_guard.lua (access phase).
3
+ -- Loaded ONCE per worker via require("openship.rules_lib"). The access file is
4
+ -- re-executed on every request, so all parsing/compiling/matching lives here to
5
+ -- avoid re-creating closures and re-decoding JSON on the hot path.
6
+ --
7
+ -- Shared-dict value `rules[host]` = JSON array of { pathPrefix, spec } where
8
+ -- spec matches @repo/core RouteRuleSpec. parse() decodes + precompiles CIDRs to
9
+ -- integers, lowercases UA/referer needles, and builds O(1) sets for
10
+ -- countries/methods ONCE; the compiled result is LRU-cached per host and reused
11
+ -- until the raw string changes (rule edits are rare, requests are hot).
12
+ --
13
+ -- SECURITY: nothing here turns a rule value into Lua code or a Lua pattern.
14
+ -- Strings are compared with `==`/set-lookups; user-agent matching is
15
+ -- string.find(hay, needle, 1, true) — PLAIN, never a regex.
16
+
17
+ local cjson = require "cjson.safe"
18
+ local lrucache = require "resty.lrucache"
19
+
20
+ local M = {}
21
+
22
+ local cache = lrucache.new(1024) -- compiled rules, per worker, ~1024 hosts
23
+
24
+ -- ── IPv4 helpers ──
25
+ local function ipv4_to_int(s)
26
+ local a, b, c, d = string.match(s, "^(%d+)%.(%d+)%.(%d+)%.(%d+)$")
27
+ if not a then return nil end
28
+ a, b, c, d = tonumber(a), tonumber(b), tonumber(c), tonumber(d)
29
+ if a > 255 or b > 255 or c > 255 or d > 255 then return nil end
30
+ return a * 16777216 + b * 65536 + c * 256 + d
31
+ end
32
+ M.ipv4_to_int = ipv4_to_int
33
+
34
+ -- Precompile a CIDR / bare-IP string into a cheap match descriptor.
35
+ local function compile_cidr(s)
36
+ local base, bits = string.match(s, "^([^/]+)/(%d+)$")
37
+ if base then
38
+ bits = tonumber(bits)
39
+ local basei = ipv4_to_int(base)
40
+ if not basei or bits < 0 or bits > 32 then return { exact = s } end
41
+ if bits == 0 then return { all = true } end
42
+ local div = 2 ^ (32 - bits) -- compare the top `bits` bits
43
+ return { net = math.floor(basei / div), div = div }
44
+ end
45
+ local i = ipv4_to_int(s)
46
+ if i then return { ip = i } end
47
+ return { exact = s } -- IPv6 / unparseable → literal string compare
48
+ end
49
+
50
+ local function compile_list(list)
51
+ if type(list) ~= "table" then return nil end
52
+ local out, n = {}, 0
53
+ for _, s in ipairs(list) do
54
+ if type(s) == "string" and s ~= "" then
55
+ n = n + 1
56
+ out[n] = compile_cidr(s)
57
+ end
58
+ end
59
+ return n > 0 and out or nil
60
+ end
61
+
62
+ -- Match a client (int form `ipi`, may be nil for non-IPv4; raw `ips`) against a
63
+ -- compiled CIDR/IP list.
64
+ local function match_compiled(compiled, ipi, ips)
65
+ if not compiled then return false end
66
+ for i = 1, #compiled do
67
+ local c = compiled[i]
68
+ if c.all then
69
+ return true
70
+ elseif c.ip then
71
+ if ipi and ipi == c.ip then return true end
72
+ elseif c.net then
73
+ if ipi and math.floor(ipi / c.div) == c.net then return true end
74
+ elseif c.exact == ips then
75
+ return true
76
+ end
77
+ end
78
+ return false
79
+ end
80
+ M.match_compiled = match_compiled
81
+
82
+ local function lower_list(list)
83
+ if type(list) ~= "table" then return nil end
84
+ local out, n = {}, 0
85
+ for _, s in ipairs(list) do
86
+ if type(s) == "string" and s ~= "" then
87
+ n = n + 1
88
+ out[n] = string.lower(s)
89
+ end
90
+ end
91
+ return n > 0 and out or nil
92
+ end
93
+
94
+ -- Build an O(1) lookup set from a string list (optionally lowercased).
95
+ local function to_set(list, lower)
96
+ if type(list) ~= "table" then return nil end
97
+ local set, any = {}, false
98
+ for _, s in ipairs(list) do
99
+ if type(s) == "string" and s ~= "" then
100
+ set[lower and string.lower(s) or s] = true
101
+ any = true
102
+ end
103
+ end
104
+ return any and set or nil
105
+ end
106
+
107
+ -- Compile one RouteRuleSpec into a fast-match shape.
108
+ local function compile_spec(spec)
109
+ if type(spec) ~= "table" then return { blockStatus = 403 } end
110
+ local out = {}
111
+
112
+ local rl = spec.rateLimit
113
+ local rps = rl and tonumber(rl.rps)
114
+ if rps and rps > 0 then
115
+ out.rl = {
116
+ limit = math.floor(rps) + math.floor(tonumber(rl.burst) or 0),
117
+ status = tonumber(rl.status) or 429,
118
+ }
119
+ end
120
+
121
+ local ban = spec.ban
122
+ if type(ban) == "table" then
123
+ out.ban = {
124
+ ips = compile_list(ban.ips),
125
+ cidrs = compile_list(ban.cidrs),
126
+ countries = to_set(ban.countries),
127
+ uas = lower_list(ban.userAgents),
128
+ emptyUA = ban.emptyUserAgent == true,
129
+ }
130
+ end
131
+
132
+ local acc = spec.access
133
+ if type(acc) == "table" then
134
+ out.access = {
135
+ allow = compile_list(acc.allowCidrs),
136
+ deny = compile_list(acc.denyCidrs),
137
+ allowCountries = to_set(acc.allowCountries),
138
+ methods = to_set(acc.methods),
139
+ }
140
+ end
141
+
142
+ local hot = spec.hotlink
143
+ if type(hot) == "table" then
144
+ local refs = to_set(hot.allowReferers, true)
145
+ if refs then
146
+ out.hotlink = { referers = refs, allowEmpty = hot.allowEmpty ~= false }
147
+ end
148
+ end
149
+
150
+ out.blockStatus = (type(spec.block) == "table" and tonumber(spec.block.status)) or 403
151
+ return out
152
+ end
153
+
154
+ local function parse(raw)
155
+ local entries = cjson.decode(raw)
156
+ if type(entries) ~= "table" then return {} end
157
+ local out, n = {}, 0
158
+ for _, e in ipairs(entries) do
159
+ if type(e) == "table" then
160
+ n = n + 1
161
+ out[n] = { pathPrefix = e.pathPrefix, spec = compile_spec(e.spec) }
162
+ end
163
+ end
164
+ return out
165
+ end
166
+
167
+ -- LRU-cached parse: reuse the compiled form until the raw dict string changes.
168
+ function M.get(host, raw)
169
+ local hit = cache:get(host)
170
+ if hit and hit.raw == raw then return hit.parsed end
171
+ local parsed = parse(raw)
172
+ cache:set(host, { raw = raw, parsed = parsed })
173
+ return parsed
174
+ end
175
+
176
+ return M
@@ -0,0 +1,182 @@
1
+ -- site_logger.lua
2
+ -- OpenResty log_by_lua - runs AFTER the response is sent.
3
+ -- Pure shared-dict analytics. No Redis, no timers for counters, no batching.
4
+ -- Every incr() is atomic across workers.
5
+ --
6
+ -- Shared dict key schema (analytics zone):
7
+ -- s:{domain}:{epoch_min}:r request count (TTL 24h)
8
+ -- s:{domain}:{epoch_min}:i bandwidth in bytes (TTL 24h)
9
+ -- s:{domain}:{epoch_min}:o bandwidth out bytes (TTL 24h)
10
+ -- s:{domain}:{epoch_min}:t response time sum (seconds) (TTL 24h)
11
+ -- s:{domain}:{epoch_min}:u unique (non-static) reqs (TTL 24h)
12
+ -- g:{domain}:{YYYYMMDD}:{CC} country hit count (TTL 48h)
13
+ -- c:{domain}:{epoch_min}:{CC} country per minute (TTL 24h)
14
+ -- t:{domain}:r / :i / :o lifetime totals (no TTL)
15
+ -- d:{domain} domain index marker (no TTL)
16
+ --
17
+ -- Shared dict key schema (request_data zone):
18
+ -- rlog:{domain}:seq monotonic write pointer
19
+ -- rlog:{domain}:{slot} JSON entry (ring buf) (TTL 1h)
20
+ -- log_pipe:sub:{domain} live subscriber flag (TTL 30s)
21
+ -- log_pipe:q:{domain} live log queue entries
22
+
23
+ local cjson = require "cjson.safe"
24
+
25
+ -- SAFE LOAD: GeoIP Module (per-worker, cached by require)
26
+ local geo_ok, geo = pcall(require, "openship.geo_country")
27
+ if not geo_ok then
28
+ geo = nil
29
+ ngx.log(ngx.WARN, "[site_logger] geo_country module not available. GeoIP disabled.")
30
+ end
31
+
32
+ -- SAFE LOAD: Pipe module (per-worker, cached by require)
33
+ local pipe_ok, pipe_log = pcall(require, "openship.pipe_log")
34
+ if not pipe_ok then pipe_log = nil end
35
+
36
+ local analytics = ngx.shared.analytics
37
+ local request_data = ngx.shared.request_data
38
+ if not analytics or not request_data then return end
39
+
40
+ -- ── Helpers ──────────────────────────────────────────────────────────────────
41
+
42
+ local function normalize(host)
43
+ if not host then return nil end
44
+ local h = host:lower()
45
+ if h:sub(1, 4) == "www." then h = h:sub(5) end
46
+ return h
47
+ end
48
+
49
+ local _day = { str = "", ts = 0 }
50
+ local function today()
51
+ local now = ngx.now()
52
+ if now - _day.ts > 60 then
53
+ _day.str = os.date("!%Y%m%d", now)
54
+ _day.ts = now
55
+ end
56
+ return _day.str
57
+ end
58
+
59
+ -- Fast check if URI is a static asset (for unique_requests counter)
60
+ local function is_static_asset(u)
61
+ local lower = u:lower()
62
+
63
+ -- Static file extensions
64
+ if lower:match("%.js$") or lower:match("%.css$") or
65
+ lower:match("%.jpg$") or lower:match("%.jpeg$") or
66
+ lower:match("%.png$") or lower:match("%.gif$") or
67
+ lower:match("%.svg$") or lower:match("%.webp$") or
68
+ lower:match("%.ico$") or lower:match("%.bmp$") or
69
+ lower:match("%.woff2?$") or lower:match("%.ttf$") or
70
+ lower:match("%.eot$") or lower:match("%.otf$") or
71
+ lower:match("%.mp4$") or lower:match("%.webm$") or
72
+ lower:match("%.mp3$") or lower:match("%.wav$") or
73
+ lower:match("%.pdf$") or lower:match("%.zip$") or
74
+ lower:match("%.tar$") or lower:match("%.gz$") or
75
+ lower:match("%.xml$") or lower:match("%.json$") or
76
+ lower:match("%.txt$") or lower:match("%.map$") then
77
+ return true
78
+ end
79
+
80
+ -- Common static paths
81
+ if lower:match("^/_next/static/") or
82
+ lower:match("^/static/") or
83
+ lower:match("^/assets/") or
84
+ lower:match("^/public/") or
85
+ lower:match("/favicon%.ico") then
86
+ return true
87
+ end
88
+
89
+ return false
90
+ end
91
+
92
+ local RING = 1000
93
+ local D24H = 86400
94
+ local D48H = 172800
95
+ local D1H = 3600
96
+
97
+ -- ── Capture ──────────────────────────────────────────────────────────────────
98
+
99
+ local host = normalize(ngx.var.host)
100
+ if not host then return end
101
+
102
+ local ip = ngx.var.remote_addr or "0.0.0.0"
103
+ local ts = ngx.now()
104
+ local ua = ngx.var.http_user_agent or ""
105
+ local uri = ngx.var.request_uri or "/"
106
+ local req_len = tonumber(ngx.var.request_length) or 0
107
+ local bytes = tonumber(ngx.var.bytes_sent) or 0
108
+ local rt = tonumber(ngx.var.request_time) or 0 -- seconds (float)
109
+ local method = ngx.var.request_method or "GET"
110
+ local status = ngx.var.status or "0"
111
+
112
+ -- ── 1. Minute-bucket counters ────────────────────────────────────────────────
113
+
114
+ local minute = math.floor(ts / 60)
115
+ local p = "s:" .. host .. ":" .. minute
116
+
117
+ analytics:incr(p .. ":r", 1, 0, D24H)
118
+ analytics:incr(p .. ":i", req_len, 0, D24H)
119
+ analytics:incr(p .. ":o", bytes, 0, D24H)
120
+
121
+ -- Response time as float seconds (matches original precision)
122
+ if rt > 0 then
123
+ -- safe_add initializes, incr adds - emulate HINCRBYFLOAT with integer micros
124
+ local rt_us = math.floor(rt * 1000000)
125
+ analytics:incr(p .. ":t", rt_us, 0, D24H)
126
+ end
127
+
128
+ -- Unique (non-static) request counter
129
+ if not is_static_asset(uri) then
130
+ analytics:incr(p .. ":u", 1, 0, D24H)
131
+ end
132
+
133
+ -- ── 2. Lifetime totals ──────────────────────────────────────────────────────
134
+
135
+ analytics:incr("t:" .. host .. ":r", 1, 0)
136
+ analytics:incr("t:" .. host .. ":i", req_len, 0)
137
+ analytics:incr("t:" .. host .. ":o", bytes, 0)
138
+
139
+ -- Domain index (set once, ignore subsequent "exists" errors)
140
+ analytics:safe_add("d:" .. host, 1)
141
+
142
+ -- ── 3. GeoIP ─────────────────────────────────────────────────────────────────
143
+
144
+ if geo then
145
+ local cc = geo.get_country_code(ip)
146
+ if cc and cc ~= "" then
147
+ -- Daily geo (for /analytics/geo endpoint)
148
+ analytics:incr("g:" .. host .. ":" .. today() .. ":" .. cc, 1, 0, D48H)
149
+ -- Per-minute geo (for time-series country breakdown)
150
+ analytics:incr("c:" .. host .. ":" .. minute .. ":" .. cc, 1, 0, D24H)
151
+ end
152
+ end
153
+
154
+ -- ── 4. Raw request ring buffer ──────────────────────────────────────────────
155
+
156
+ pcall(function()
157
+ local ok_j, j = pcall(cjson.encode, {
158
+ ip = ip,
159
+ ts = ts,
160
+ method = method,
161
+ status = status,
162
+ uri = uri,
163
+ ua = ua,
164
+ bw_in = req_len,
165
+ bw_out = bytes,
166
+ rt = rt,
167
+ })
168
+ if ok_j and j then
169
+ local seq = request_data:incr("rlog:" .. host .. ":seq", 1, 0)
170
+ local slot = seq % RING
171
+ request_data:set("rlog:" .. host .. ":" .. slot, j, D1H)
172
+ end
173
+ end)
174
+
175
+ -- ── 5. Live-log pipe (only when a subscriber is watching) ───────────────────
176
+ -- Fire via timer to avoid blocking the log phase
177
+
178
+ if pipe_log and pipe_log.pipe_request_log
179
+ and request_data:get("log_pipe:sub:" .. host) then
180
+ ngx.timer.at(0, pipe_log.pipe_request_log,
181
+ host, ip, ts, ua, uri, req_len, bytes, rt, method, status)
182
+ end
@@ -0,0 +1,126 @@
1
+ -- webhook_handler.lua
2
+ -- Handles incoming webhook events forwarded from the SaaS edge.
3
+ -- Internal management port only (127.0.0.1:9145).
4
+ --
5
+ -- Routes:
6
+ -- POST /_hooks/push - receive a forwarded GitHub push event
7
+ -- GET /_hooks/pending - fetch stored events (for local app polling)
8
+ -- POST /_hooks/ack - acknowledge consumed events
9
+ --
10
+ -- Behavior on POST /_hooks/push:
11
+ -- 1. Try to proxy to a local Openship API (configurable via X-Openship-Url header)
12
+ -- 2. If Openship is not reachable, store the event in shared dict for later polling
13
+
14
+ local cjson = require "cjson.safe"
15
+
16
+ local webhook_events = ngx.shared.webhook_events
17
+ local uri = ngx.var.uri
18
+ local method = ngx.req.get_method()
19
+
20
+ local function json_response(data, code)
21
+ ngx.status = code or 200
22
+ ngx.header["Content-Type"] = "application/json"
23
+ ngx.say(cjson.encode(data))
24
+ return ngx.exit(ngx.status)
25
+ end
26
+
27
+ -- ── POST /_hooks/push ────────────────────────────────────────────────────────
28
+ -- Receives forwarded webhook payload from SaaS.
29
+ -- Headers:
30
+ -- X-Openship-Url: http://127.0.0.1:3456 (if Openship runs on this server)
31
+ -- X-Hook-Id: the subscription hookId (for correlation)
32
+
33
+ if uri == "/_hooks/push" and method == "POST" then
34
+ ngx.req.read_body()
35
+ local body = ngx.req.get_body_data()
36
+ if not body then
37
+ return json_response({ error = "empty body" }, 400)
38
+ end
39
+
40
+ local openship_url = ngx.req.get_headers()["x-openship-url"]
41
+
42
+ -- If Openship runs on this server, proxy directly
43
+ if openship_url and openship_url ~= "" then
44
+ local http = require "resty.http"
45
+ local httpc = http.new()
46
+ httpc:set_timeout(5000)
47
+
48
+ local target = openship_url .. "/api/webhooks/push"
49
+ local res, err = httpc:request_uri(target, {
50
+ method = "POST",
51
+ body = body,
52
+ headers = {
53
+ ["Content-Type"] = "application/json",
54
+ ["X-Hook-Id"] = ngx.req.get_headers()["x-hook-id"] or "",
55
+ },
56
+ })
57
+
58
+ if res and res.status < 500 then
59
+ -- Openship handled it (2xx or 4xx)
60
+ return json_response({ ok = true, proxied = true, status = res.status })
61
+ end
62
+
63
+ -- Openship unreachable or errored - fall through to store
64
+ end
65
+
66
+ -- Store for later polling by local/desktop app
67
+ local event_id = ngx.now() .. ":" .. math.random(100000, 999999)
68
+ local event = cjson.encode({
69
+ id = event_id,
70
+ hook_id = ngx.req.get_headers()["x-hook-id"] or "",
71
+ payload = body,
72
+ received = ngx.now(),
73
+ })
74
+
75
+ -- Use a slot-based ring buffer (max 200 events)
76
+ local max_events = 200
77
+ local idx = webhook_events:incr("_idx", 1, 0) or 0
78
+ local slot = (idx % max_events)
79
+ webhook_events:set("evt:" .. slot, event, 3600) -- 1h TTL
80
+ webhook_events:set("_count", math.min(idx, max_events))
81
+
82
+ return json_response({ ok = true, stored = true, event_id = event_id })
83
+ end
84
+
85
+ -- ── GET /_hooks/pending ──────────────────────────────────────────────────────
86
+ -- Returns stored webhook events for local app to consume.
87
+
88
+ if uri == "/_hooks/pending" and method == "GET" then
89
+ if not webhook_events then
90
+ return json_response({ events = {} })
91
+ end
92
+
93
+ local count = webhook_events:get("_count") or 0
94
+ local idx = webhook_events:get("_idx") or 0
95
+ local events = {}
96
+
97
+ -- Read all stored events (newest first)
98
+ local max_events = 200
99
+ local start = math.max(0, idx - count)
100
+ for i = start, idx - 1 do
101
+ local slot = i % max_events
102
+ local raw = webhook_events:get("evt:" .. slot)
103
+ if raw then
104
+ local parsed = cjson.decode(raw)
105
+ if parsed then
106
+ events[#events + 1] = parsed
107
+ end
108
+ end
109
+ end
110
+
111
+ return json_response({ events = events })
112
+ end
113
+
114
+ -- ── POST /_hooks/ack ─────────────────────────────────────────────────────────
115
+ -- Acknowledge events have been consumed - clears the store.
116
+
117
+ if uri == "/_hooks/ack" and method == "POST" then
118
+ if webhook_events then
119
+ webhook_events:flush_all()
120
+ webhook_events:flush_expired()
121
+ end
122
+ return json_response({ ok = true })
123
+ end
124
+
125
+ -- ── Fallback ─────────────────────────────────────────────────────────────────
126
+ return json_response({ error = "not found" }, 404)
@@ -0,0 +1 @@
1
+ ALTER TABLE "domain" ADD COLUMN "external_ingress" boolean DEFAULT false NOT NULL;
@@ -0,0 +1,17 @@
1
+ CREATE TABLE "route_rule" (
2
+ "id" text PRIMARY KEY NOT NULL,
3
+ "organization_id" text NOT NULL,
4
+ "project_id" text NOT NULL,
5
+ "domain_id" text,
6
+ "path_prefix" text,
7
+ "spec" jsonb DEFAULT '{}'::jsonb NOT NULL,
8
+ "enabled" boolean DEFAULT true NOT NULL,
9
+ "created_at" timestamp DEFAULT now() NOT NULL,
10
+ "updated_at" timestamp DEFAULT now() NOT NULL
11
+ );
12
+ --> statement-breakpoint
13
+ ALTER TABLE "route_rule" ADD CONSTRAINT "route_rule_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
14
+ ALTER TABLE "route_rule" ADD CONSTRAINT "route_rule_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
15
+ ALTER TABLE "route_rule" ADD CONSTRAINT "route_rule_domain_id_domain_id_fk" FOREIGN KEY ("domain_id") REFERENCES "public"."domain"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
16
+ CREATE INDEX "idx_route_rule_project" ON "route_rule" USING btree ("project_id");--> statement-breakpoint
17
+ CREATE INDEX "idx_route_rule_domain" ON "route_rule" USING btree ("domain_id");
@@ -0,0 +1,28 @@
1
+ CREATE TABLE "docker_migration_run" (
2
+ "id" text PRIMARY KEY NOT NULL,
3
+ "organization_id" text NOT NULL,
4
+ "source_server_id" text,
5
+ "target_server_id" text,
6
+ "project_id" text,
7
+ "project_name" text NOT NULL,
8
+ "service_names" jsonb DEFAULT '[]'::jsonb NOT NULL,
9
+ "status" text DEFAULT 'queued' NOT NULL,
10
+ "mode" text DEFAULT 'cross_server' NOT NULL,
11
+ "deployment_id" text,
12
+ "kill_originals" boolean DEFAULT false NOT NULL,
13
+ "confirmation_token" text,
14
+ "volume_plan" jsonb DEFAULT '[]'::jsonb,
15
+ "scanned_container_ids" jsonb DEFAULT '{}'::jsonb,
16
+ "bytes_moved" bigint,
17
+ "error_message" text,
18
+ "started_at" timestamp DEFAULT now() NOT NULL,
19
+ "finished_at" timestamp,
20
+ "last_event_at" timestamp DEFAULT now() NOT NULL
21
+ );
22
+ --> statement-breakpoint
23
+ ALTER TABLE "docker_migration_run" ADD CONSTRAINT "docker_migration_run_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
24
+ ALTER TABLE "docker_migration_run" ADD CONSTRAINT "docker_migration_run_source_server_id_servers_id_fk" FOREIGN KEY ("source_server_id") REFERENCES "public"."servers"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
25
+ ALTER TABLE "docker_migration_run" ADD CONSTRAINT "docker_migration_run_target_server_id_servers_id_fk" FOREIGN KEY ("target_server_id") REFERENCES "public"."servers"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
26
+ ALTER TABLE "docker_migration_run" ADD CONSTRAINT "docker_migration_run_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
27
+ CREATE INDEX "idx_docker_migration_run_org_started" ON "docker_migration_run" USING btree ("organization_id","started_at");--> statement-breakpoint
28
+ CREATE INDEX "idx_docker_migration_run_in_flight" ON "docker_migration_run" USING btree ("status") WHERE "docker_migration_run"."status" IN ('queued','adopting','moving_data','deploying','verifying','awaiting_cutover','cutover');
@@ -0,0 +1 @@
1
+ ALTER TABLE "domain" ADD COLUMN "manual_ssl" boolean DEFAULT false NOT NULL;
@@ -0,0 +1,15 @@
1
+ CREATE TABLE "job_run" (
2
+ "id" text PRIMARY KEY NOT NULL,
3
+ "job_id" text NOT NULL,
4
+ "kind" text DEFAULT 'system' NOT NULL,
5
+ "trigger" text DEFAULT 'schedule' NOT NULL,
6
+ "status" text NOT NULL,
7
+ "started_at" timestamp DEFAULT now() NOT NULL,
8
+ "finished_at" timestamp,
9
+ "duration_ms" integer,
10
+ "summary" jsonb,
11
+ "error" text,
12
+ "created_at" timestamp DEFAULT now() NOT NULL
13
+ );
14
+ --> statement-breakpoint
15
+ CREATE INDEX "job_run_job_started_idx" ON "job_run" USING btree ("job_id","started_at");
@@ -0,0 +1,16 @@
1
+ CREATE TABLE "job" (
2
+ "id" text PRIMARY KEY NOT NULL,
3
+ "key" text NOT NULL,
4
+ "kind" text DEFAULT 'system' NOT NULL,
5
+ "label" text NOT NULL,
6
+ "cron_expression" text NOT NULL,
7
+ "enabled" boolean DEFAULT true NOT NULL,
8
+ "action_type" text DEFAULT 'builtin' NOT NULL,
9
+ "action_config" jsonb,
10
+ "created_by" text,
11
+ "created_at" timestamp DEFAULT now() NOT NULL,
12
+ "updated_at" timestamp DEFAULT now() NOT NULL,
13
+ CONSTRAINT "job_key_unique" UNIQUE("key")
14
+ );
15
+ --> statement-breakpoint
16
+ CREATE INDEX "job_kind_idx" ON "job" USING btree ("kind");
@@ -0,0 +1,4 @@
1
+ ALTER TABLE "project" ADD COLUMN "is_app" boolean DEFAULT false NOT NULL;--> statement-breakpoint
2
+ ALTER TABLE "project" ADD COLUMN "app_template_id" text;--> statement-breakpoint
3
+ -- Backfill: existing webmail projects are managed apps → move them to the Apps tab.
4
+ UPDATE "project" SET "is_app" = true, "app_template_id" = 'mail-webmail' WHERE "framework" = 'webmail';
@@ -0,0 +1 @@
1
+ ALTER TABLE "job_run" ADD COLUMN "output" text;
@@ -0,0 +1,15 @@
1
+ CREATE TABLE "system_notice" (
2
+ "id" text PRIMARY KEY NOT NULL,
3
+ "severity" text DEFAULT 'info' NOT NULL,
4
+ "title" text NOT NULL,
5
+ "message" text NOT NULL,
6
+ "action_label" text,
7
+ "action_url" text,
8
+ "active" boolean DEFAULT true NOT NULL,
9
+ "starts_at" timestamp,
10
+ "ends_at" timestamp,
11
+ "created_at" timestamp DEFAULT now() NOT NULL,
12
+ "updated_at" timestamp DEFAULT now() NOT NULL
13
+ );
14
+ --> statement-breakpoint
15
+ CREATE INDEX "idx_system_notice_active" ON "system_notice" USING btree ("active");
@@ -0,0 +1,8 @@
1
+ ALTER TABLE "job" ALTER COLUMN "cron_expression" DROP NOT NULL;--> statement-breakpoint
2
+ ALTER TABLE "job" ADD COLUMN "schedule_type" text DEFAULT 'recurring' NOT NULL;--> statement-breakpoint
3
+ ALTER TABLE "job" ADD COLUMN "run_at" timestamp;--> statement-breakpoint
4
+ ALTER TABLE "job" ADD COLUMN "depends_on" text[];--> statement-breakpoint
5
+ ALTER TABLE "job" ADD COLUMN "trigger_events" text[];--> statement-breakpoint
6
+ ALTER TABLE "job" ADD COLUMN "notify_config" jsonb;--> statement-breakpoint
7
+ ALTER TABLE "job_run" ADD COLUMN "server_id" text;--> statement-breakpoint
8
+ ALTER TABLE "job_run" ADD COLUMN "attempt" integer DEFAULT 1 NOT NULL;
@@ -0,0 +1 @@
1
+ ALTER TABLE "service" ADD COLUMN "public_endpoints" jsonb DEFAULT '[]'::jsonb;
@@ -0,0 +1,35 @@
1
+ CREATE TABLE "github_deploy_key" (
2
+ "id" text PRIMARY KEY NOT NULL,
3
+ "server_id" text NOT NULL,
4
+ "organization_id" text NOT NULL,
5
+ "owner" text NOT NULL,
6
+ "repo" text NOT NULL,
7
+ "github_key_id" integer,
8
+ "private_key_encrypted" text NOT NULL,
9
+ "public_key" text NOT NULL,
10
+ "read_only" boolean DEFAULT true NOT NULL,
11
+ "created_at" timestamp DEFAULT now() NOT NULL
12
+ );
13
+ --> statement-breakpoint
14
+ CREATE TABLE "server_github_auth" (
15
+ "id" text PRIMARY KEY NOT NULL,
16
+ "server_id" text NOT NULL,
17
+ "organization_id" text NOT NULL,
18
+ "mode" text NOT NULL,
19
+ "token_encrypted" text,
20
+ "token_source" text,
21
+ "token_login" text,
22
+ "server_key_private_encrypted" text,
23
+ "server_key_public" text,
24
+ "created_at" timestamp DEFAULT now() NOT NULL,
25
+ "updated_at" timestamp DEFAULT now() NOT NULL
26
+ );
27
+ --> statement-breakpoint
28
+ ALTER TABLE "github_deploy_key" ADD CONSTRAINT "github_deploy_key_server_id_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
29
+ ALTER TABLE "github_deploy_key" ADD CONSTRAINT "github_deploy_key_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
30
+ ALTER TABLE "server_github_auth" ADD CONSTRAINT "server_github_auth_server_id_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
31
+ ALTER TABLE "server_github_auth" ADD CONSTRAINT "server_github_auth_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
32
+ CREATE UNIQUE INDEX "uq_github_deploy_key_server_repo" ON "github_deploy_key" USING btree ("server_id","owner","repo");--> statement-breakpoint
33
+ CREATE INDEX "idx_github_deploy_key_org_server" ON "github_deploy_key" USING btree ("organization_id","server_id");--> statement-breakpoint
34
+ CREATE UNIQUE INDEX "uq_server_github_auth_server" ON "server_github_auth" USING btree ("server_id");--> statement-breakpoint
35
+ CREATE INDEX "idx_server_github_auth_org" ON "server_github_auth" USING btree ("organization_id");
@@ -0,0 +1,2 @@
1
+ ALTER TABLE "deployment" ADD COLUMN "release_version" text;--> statement-breakpoint
2
+ ALTER TABLE "project" ADD COLUMN "release_source" jsonb;
@@ -0,0 +1,2 @@
1
+ ALTER TABLE "user_settings" ADD COLUMN "transfer_mode" text DEFAULT 'auto' NOT NULL;--> statement-breakpoint
2
+ ALTER TABLE "user_settings" ADD COLUMN "transfer_compression" text DEFAULT 'auto' NOT NULL;
@@ -0,0 +1,3 @@
1
+ ALTER TABLE "domain" ADD COLUMN "verify_attempts" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
2
+ ALTER TABLE "domain" ADD COLUMN "last_verify_error" text;--> statement-breakpoint
3
+ ALTER TABLE "domain" ADD COLUMN "last_checked_at" timestamp;