code wiki / wiki / nx_wiki_page_save.nx

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}