nx_wiki_page_save.nx source
↩ module page · 345 lines · 15931 B
1// nx_wiki_page_save.nx -- wiki R1: the AUTH-GATED page EDIT + SAVE backbone.
2//
3// Adds the ability to EDIT a wiki page and SAVE it, persisted to the sovereign
4// append-only seg_store, so the new content renders + is searchable, and every
5// save is an IMMUTABLE revision (this is R2 versioning's history substrate).
6//
7// SECURITY-CRITICAL: a save is permitted ONLY when the caller presents a VALID
8// session token. The auth gate REUSES the proven validator already shipping in
9// the wiki -- hub/nx_modern_auth_flow::nx_modern_auth_validate_session (the
10// exact OPAQUE/no-cookie Ed25519 session check nx_wiki_admin_handle uses on
11// every protected request). NO new/weaker auth is invented here. An
12// unauthenticated edit is REFUSED and persists NOTHING.
13//
14// COMPOSES (avoid-duplicate-primitives cardinal; each imported ONCE):
15// hub/nx_modern_auth_flow nx_modern_auth_validate_session (the auth gate)
16// wiki/nx_wiki_index_builder NxWikiDocStore (+ find_by_url, lookup, add,
17// the new update_body_by_url) -- in-memory store
18// the read/render/search paths already consult
19// nx_seg_store ss_begin / ss_add / ss_commit (persist) +
20// ss_open / ss_hget (read overlay back)
21//
22// COMPOSED BY:
23// wiki/nx_wiki_edit_handler GET/POST /wiki/<slug>/edit HTTP handlers
24// wiki/nx_wiki_main startup overlay loader (seg_store > .md)
25// runtime/_hdl_build/_wiki_edit_gate unit gate (authed save, unauth refuse,
26// readback, revision)
27//
28// PERSISTENCE MODEL (additive-data law, cardinal 13):
29// prefix "knowledge/store/wikipage-"
30// key wikicur:<slug> value = current markdown bytes
31// key wikirev:<slug>:<epoch> value = "author=<a>\nepoch=<e>\n" + content
32// Every save = a NEW segment (ss_commit). Old segments are never mutated or
33// deleted; wikicur:<slug>'s LATEST put wins on read (ss_hget), the wikirev:*
34// rows accumulate as the immutable revision log R2 walks.
35//
36// Status: R1. 2026-06-14. license_tier: ORIGINAL
37
38import "nx_syscalls.nx"
39import "hub/nx_modern_auth_flow.nx"
40import "wiki/nx_wiki_index_builder.nx"
41import "nx_seg_store.nx"
42
43// ===== Sealed verdict surface (codes 2900-2919) =================================================
44const NX_WPS_OK: i64 = 0
45const NX_WPS_REFUSED: i64 = 2900 // auth gate refused (no/invalid token)
46const NX_WPS_BAD_INPUT: i64 = 2901
47const NX_WPS_PERSIST_FAILED: i64 = 2902
48const NX_WPS_STORE_FAILED: i64 = 2903 // in-memory doc-store update failed
49const NX_WPS_OVERFLOW: i64 = 2904
50
51// ===== Named constants (M7) =================================================
52const NX_WPS_PREFIX: *u8 = "knowledge/store/wikipage-" as *u8
53const NX_WPS_MAX_SLUG_LEN: i64 = 200
54const NX_WPS_MAX_CONTENT_LEN: i64 = 262144 // 256 KB per page edit
55const NX_WPS_DEFAULT_AUTHOR: *u8 = "elderwesto" as *u8
56const NX_WPS_DEFAULT_AUTHOR_N: i64 = 10
57const NX_WPS_KEY_CAP: i64 = 320 // "wikirev:" + slug + ":" + epoch
58const NX_WPS_URL_CAP: i64 = 256 // "/wiki/" + slug
59
60// ===== seg_store prefix accessor (one definition; callers + gate reuse) ====
61func nx_wps_prefix() -> *u8 { return NX_WPS_PREFIX }
62
63// ===== small local helpers =================================================
64
65// append NUL-terminated src into dst at off; returns new off.
66func _wps_cat(dst: *u8, off: i64, src: *u8) -> i64 {
67 var i: i64 = 0
68 while src[i] != (0 as u8) { dst[off + i] = src[i]; i = i + 1 }
69 return off + i
70}
71
72// append counted src into dst at off; returns new off.
73func _wps_catb(dst: *u8, off: i64, src: *u8, n: i64) -> i64 {
74 var i: i64 = 0
75 while i < n { dst[off + i] = src[i]; i = i + 1 }
76 return off + n
77}
78
79// append decimal of v (v >= 0) into dst at off; returns new off.
80func _wps_catn(dst: *u8, off: i64, v: i64) -> i64 {
81 var m: i64 = v
82 var o: i64 = off
83 let t: *u8 = sys_mmap(28)
84 var k: i64 = 0
85 if m == 0 { t[0] = 48 as u8; k = 1 }
86 while m > 0 { t[k] = (48 + (m % 10)) as u8; m = m / 10; k = k + 1 }
87 var i: i64 = 0
88 while i < k { dst[o + i] = t[k - 1 - i]; i = i + 1 }
89 return o + k
90}
91
92// Build the current-content key "wikicur:<slug>" (NUL-terminated for ss_add,
93// which ss_len's the key). Returns key length.
94func nx_wps_cur_key(slug: *u8, slug_n: i64, out: *u8) -> i64 {
95 var o: i64 = 0
96 o = _wps_cat(out, o, "wikicur:" as *u8)
97 o = _wps_catb(out, o, slug, slug_n)
98 out[o] = 0 as u8
99 return o
100}
101
102// Build a revision key "wikirev:<slug>:<epoch>" (NUL-terminated). Returns len.
103func nx_wps_rev_key(slug: *u8, slug_n: i64, epoch: i64, out: *u8) -> i64 {
104 var o: i64 = 0
105 o = _wps_cat(out, o, "wikirev:" as *u8)
106 o = _wps_catb(out, o, slug, slug_n)
107 out[o] = 58 as u8 // ':'
108 o = o + 1
109 o = _wps_catn(out, o, epoch)
110 out[o] = 0 as u8
111 return o
112}
113
114// Build the page URL "/wiki/<slug>" (NUL-terminated). Returns url length.
115func nx_wps_url(slug: *u8, slug_n: i64, out: *u8) -> i64 {
116 var o: i64 = 0
117 o = _wps_cat(out, o, "/wiki/" as *u8)
118 o = _wps_catb(out, o, slug, slug_n)
119 out[o] = 0 as u8
120 return o
121}
122
123// next free segment id under the prefix = current live-manifest line count.
124// Each save commits a NEW segment (additive); ids never collide because the
125// manifest only ever GROWS (ss_commit appends one line per commit).
126func nx_wps_next_segid(prefix: *u8) -> i64 {
127 // fresh max+1 (ss_next_segid, UNCAPPED) -- the capped ss_manifest count stuck at the cap forever
128 // once exceeded, so every later page-save overwrote the same segment (silent version loss)
129 return ss_next_segid(prefix)
130}
131
132// ===== In-memory doc-store update: replace one page's body by URL ==========
133//
134// The read/render path (nx_wiki_doc_handle) resolves a page via the doc store's
135// find_by_url + lookup -- it reads the BODY pool, not the finalized inverted
136// index. So updating the doc store body makes GET /wiki/<slug> serve the edit
137// immediately (no rebuild needed; search re-index is the restart-overlay path).
138//
139// The body pool is append-only packed; "replace" = append the new bytes and
140// REPOINT the rowid's offset/len at them (old bytes orphaned in the pool, which
141// is fine -- the seg_store holds the durable history). If the URL is not yet in
142// the store, a NEW row is added. Returns NX_WPS_OK or a -verdict.
143func nx_wps_doc_store_put(store: *NxWikiDocStore,
144 url: *u8, url_n: i64,
145 title: *u8, title_n: i64,
146 body: *u8, body_n: i64) -> i64 {
147 if (store as i64) == 0 { return 0 - NX_WPS_STORE_FAILED }
148 if store.valid != 1 { return 0 - NX_WPS_STORE_FAILED }
149 if body_n < 0 { return 0 - NX_WPS_BAD_INPUT }
150 if body_n > NX_WIB_MAX_BODY_LEN { return 0 - NX_WPS_OVERFLOW }
151
152 let rowid: i64 = nx_wiki_doc_store_find_by_url(store, url, url_n)
153 if rowid < 0 {
154 // New page: append a row via the store's own add (title + url + body).
155 let rc_add: i64 = nx_wiki_doc_store_add(store, title, title_n,
156 url, url_n, body, body_n)
157 if rc_add < 0 { return 0 - NX_WPS_STORE_FAILED }
158 return NX_WPS_OK
159 }
160
161 // Existing page: append new body bytes to the body pool + repoint rowid.
162 if store.bodies_pool_used + body_n > store.bodies_pool_cap {
163 return 0 - NX_WPS_OVERFLOW
164 }
165 let b_off: i64 = store.bodies_pool_used
166 var i: i64 = 0
167 while i < body_n {
168 store.bodies_pool[b_off + i] = body[i]
169 i = i + 1
170 }
171 store.bodies_pool_used = b_off + body_n
172 store.bodies_offs[rowid] = b_off
173 store.bodies_lens[rowid] = body_n
174 return NX_WPS_OK
175}
176
177// ===== THE SAVE FUNCTION (auth-gated persist) =================================================
178//
179// nx_wiki_page_save:
180// 1. AUTH GATE (security-critical): validate the session token via the
181// reused nx_modern_auth_validate_session. If it is NOT NX_MAUTH_OK ->
182// return NX_WPS_REFUSED and persist NOTHING.
183// 2. PERSIST (append-only): write wikicur:<slug> (current) AND
184// wikirev:<slug>:<epoch> (immutable revision incl. author + epoch) into a
185// NEW seg_store segment under NX_WPS_PREFIX. ss_commit is the atomic
186// temp->rename commit point (crash before it = invisible).
187// 3. UPDATE the in-memory doc store so reads + render reflect the edit.
188//
189// Args:
190// store *NxWikiDocStore (in-memory page store; 0 allowed = persist
191// only, e.g. headless callers)
192// ctx *NxAuthContext (the realm auth context the daemon already
193// builds; carries the Ed25519 verify pubkey)
194// slug, slug_n page slug (no "/wiki/" prefix; e.g. "nishi-wiki-charter")
195// new_content,
196// new_n the edited markdown bytes
197// session_token,
198// token_n the raw 152-byte session token (caller base64-decodes the
199// X-Nishi-Session header before calling)
200// now_unix_s current epoch seconds (for expiry check + revision stamp)
201//
202// Returns NX_WPS_OK on success; NX_WPS_REFUSED if auth fails; other -verdicts
203// on input / persist / store errors.
204func nx_wiki_page_save(
205 store: *NxWikiDocStore,
206 ctx: *NxAuthContext,
207 slug: *u8, slug_n: i64,
208 new_content: *u8, new_n: i64,
209 session_token: *u8, token_n: i64,
210 now_unix_s: i64
211) -> i64 {
212 // ---- input validation (defensive at boundary; cardinal 12) ----
213 if (slug as i64) == 0 { return 0 - NX_WPS_BAD_INPUT }
214 if slug_n < 1 { return 0 - NX_WPS_BAD_INPUT }
215 if slug_n > NX_WPS_MAX_SLUG_LEN { return 0 - NX_WPS_BAD_INPUT }
216 if (new_content as i64) == 0 { return 0 - NX_WPS_BAD_INPUT }
217 if new_n < 0 { return 0 - NX_WPS_BAD_INPUT }
218 if new_n > NX_WPS_MAX_CONTENT_LEN { return 0 - NX_WPS_OVERFLOW }
219
220 // ---- 1. AUTH GATE (REUSED validator; no weaker check) ----
221 // nx_modern_auth_validate_session returns NX_MAUTH_OK (0) ONLY for a token
222 // whose Ed25519 signature verifies AND is unexpired AND realm-matches. A
223 // null / wrong-length / forged / expired token returns a negative verdict.
224 if (ctx as i64) == 0 { return 0 - NX_WPS_REFUSED }
225 let auth_rc: i64 = nx_modern_auth_validate_session(
226 ctx, session_token, token_n, now_unix_s,
227 (0 as i64) as *u8, 0, (0 as i64) as *i64)
228 if auth_rc != NX_MAUTH_OK { return 0 - NX_WPS_REFUSED }
229
230 // ---- 2. PERSIST to the append-only seg_store (NEW segment) ----
231 let w: *i64 = ss_begin()
232
233 // current-content row
234 let curk: *u8 = sys_mmap(NX_WPS_KEY_CAP)
235 nx_wps_cur_key(slug, slug_n, curk)
236 if ss_add(w, 1, curk, new_content, new_n) != 0 { return 0 - NX_WPS_PERSIST_FAILED }
237
238 // immutable revision row: value = "author=<a>\nepoch=<e>\n" + content
239 let revk: *u8 = sys_mmap(NX_WPS_KEY_CAP)
240 nx_wps_rev_key(slug, slug_n, now_unix_s, revk)
241 let rval: *u8 = sys_mmap(new_n + 128)
242 var ro: i64 = 0
243 ro = _wps_cat(rval, ro, "author=" as *u8)
244 ro = _wps_catb(rval, ro, NX_WPS_DEFAULT_AUTHOR, NX_WPS_DEFAULT_AUTHOR_N)
245 ro = _wps_cat(rval, ro, "\nepoch=" as *u8)
246 ro = _wps_catn(rval, ro, now_unix_s)
247 ro = _wps_cat(rval, ro, "\n" as *u8)
248 ro = _wps_catb(rval, ro, new_content, new_n)
249 if ss_add(w, 1, revk, rval, ro) != 0 { return 0 - NX_WPS_PERSIST_FAILED }
250
251 let segid: i64 = nx_wps_next_segid(NX_WPS_PREFIX)
252 if ss_commit(NX_WPS_PREFIX, w, segid) != 0 { return 0 - NX_WPS_PERSIST_FAILED }
253
254 // ---- 3. UPDATE the in-memory doc store (reads/render reflect the edit) ----
255 if (store as i64) != 0 {
256 let url: *u8 = sys_mmap(NX_WPS_URL_CAP)
257 let url_n: i64 = nx_wps_url(slug, slug_n, url)
258 // Title = the slug (V1; a richer title is the markdown '#' heading the
259 // render path already extracts for display chrome).
260 let rc_put: i64 = nx_wps_doc_store_put(store, url, url_n,
261 slug, slug_n,
262 new_content, new_n)
263 if rc_put != NX_WPS_OK { return rc_put }
264 }
265 return NX_WPS_OK
266}
267
268// ===== READ-BACK helper (overlay): seg_store wikicur:<slug> -> caller buffer ==
269//
270// Returns the byte length written into out (>=0) if a persisted current version
271// exists for the slug, or -1 if absent / tombstoned. Used by the startup
272// overlay loader (prefer a saved page over the filesystem .md) AND by the gate
273// for ground-truth readback.
274func nx_wiki_page_load_current(slug: *u8, slug_n: i64,
275 out: *u8, out_cap: i64) -> i64 {
276 if (slug as i64) == 0 { return 0 - 1 }
277 if slug_n < 1 { return 0 - 1 }
278 let curk: *u8 = sys_mmap(NX_WPS_KEY_CAP)
279 nx_wps_cur_key(slug, slug_n, curk)
280 let h: *i64 = ss_open(NX_WPS_PREFIX)
281 if (h as i64) == 0 { return 0 - 1 }
282 let pp: *i64 = sys_mmap(16) as *i64
283 let pl: *i64 = sys_mmap(16) as *i64
284 let r: i64 = ss_hget(h, curk, pp, pl)
285 if r != 1 { return 0 - 1 }
286 let n: i64 = pl[0]
287 if n > out_cap { return 0 - 1 }
288 let src: *u8 = pp[0] as *u8
289 var i: i64 = 0
290 while i < n { out[i] = src[i]; i = i + 1 }
291 return n
292}
293
294// ===== STARTUP OVERLAY: seg_store-persisted pages REPLACE the .md seeds ======
295//
296// Called from the daemon AFTER filesystem discovery + index finalize: for every
297// page already in the doc store, if a seg_store wikicur:<slug> exists, repoint
298// the store body to the persisted (edited) bytes so edits survive restart. The
299// filesystem .md is the SEED; the seg_store is the edited OVERLAY (cardinal 13
300// additive-data: history lives in the store, not the container fs).
301//
302// Slug = the URL with the leading "/wiki/" stripped. Returns the number of
303// pages overlaid (>=0). Bodies that grew are appended to the body pool; a page
304// whose persisted bytes do not fit is skipped (graceful; cardinal 14).
305func nx_wiki_page_overlay_store(store: *NxWikiDocStore) -> i64 {
306 if (store as i64) == 0 { return 0 }
307 if store.valid != 1 { return 0 }
308 let h: *i64 = ss_open(NX_WPS_PREFIX)
309 if (h as i64) == 0 { return 0 }
310 let pp: *i64 = sys_mmap(16) as *i64
311 let pl: *i64 = sys_mmap(16) as *i64
312 let curk: *u8 = sys_mmap(NX_WPS_KEY_CAP)
313 var overlaid: i64 = 0
314 var rid: i64 = 0
315 let n_docs: i64 = store.doc_count
316 while rid < n_docs {
317 let url_off: i64 = store.urls_offs[rid]
318 let url_n: i64 = store.urls_lens[rid]
319 let url: *u8 = ((store.urls_pool as i64) + url_off) as *u8
320 // strip "/wiki/" (6 bytes) to get the slug
321 if url_n > 6 {
322 let slug: *u8 = (url as i64 + 6) as *u8
323 let slug_n: i64 = url_n - 6
324 nx_wps_cur_key(slug, slug_n, curk)
325 if ss_hget(h, curk, pp, pl) == 1 {
326 let body_n: i64 = pl[0]
327 if store.bodies_pool_used + body_n <= store.bodies_pool_cap {
328 let b_off: i64 = store.bodies_pool_used
329 let src: *u8 = pp[0] as *u8
330 var i: i64 = 0
331 while i < body_n {
332 store.bodies_pool[b_off + i] = src[i]
333 i = i + 1
334 }
335 store.bodies_pool_used = b_off + body_n
336 store.bodies_offs[rid] = b_off
337 store.bodies_lens[rid] = body_n
338 overlaid = overlaid + 1
339 }
340 }
341 }
342 rid = rid + 1
343 }
344 return overlaid
345}