main.mo
1,769 lines 63.2 KB
Raw
sha256:baa800aedf841dbf32081aa7b2befa288ac33dfc7175ac55014c55c4d8c742f9 docs: move durable-auth freeze/evidence to local developmen… Human 11 days ago
1 /**
2 * Knowtation Hub canister — minimal Hub API (vault + proposals) for ICP.
3 * Phase 15.1: notes partitioned by (userId, vault_id); X-Vault-Id on requests (default vault id: default).
4 * Implements GET /health, GET /api/v1/operator/export (operator backup key), GET/POST /api/v1/notes, DELETE /api/v1/notes/:path, POST /api/v1/notes/batch, POST /api/v1/notes/delete-by-prefix, GET /api/v1/notes/:path, GET /api/v1/vaults, DELETE /api/v1/vaults/:id (non-default), GET /api/v1/export,
5 * GET/POST /api/v1/proposals, evaluation, approve, discard.
6 * Auth: for dev use X-Test-User or X-User-Id header; canister validates proof from gateway in production.
7 * See docs/HUB-API.md and docs/CANISTER-AUTH-CONTRACT.md.
8 */
9
10 import Array "mo:base/Array";
11 import Blob "mo:base/Blob";
12 import Buffer "mo:base/Buffer";
13 import Char "mo:base/Char";
14 import HashMap "mo:base/HashMap";
15 import Iter "mo:base/Iter";
16 import Int "mo:base/Int";
17 import Nat "mo:base/Nat";
18 import Nat32 "mo:base/Nat32";
19 import Option "mo:base/Option";
20 import Text "mo:base/Text";
21 import Time "mo:base/Time";
22 import Debug "mo:base/Debug";
23 import JsonValidate "JsonValidate";
24 import Migration "Migration";
25 import Principal "mo:base/Principal";
26
27 (with migration = Migration.migration)
28 persistent actor Hub {
29
30 // --- HTTP types (IC gateway) ---
31 type Header = (Text, Text);
32 type HttpRequest = {
33 method : Text;
34 url : Text;
35 headers : [Header];
36 body : Blob;
37 };
38 type HttpResponse = {
39 status_code : Nat16;
40 headers : [Header];
41 body : Blob;
42 streaming_strategy : ?{
43 #Callback : {
44 callback : shared query (StreamingCallbackToken) -> async StreamingCallbackResponse;
45 token : StreamingCallbackToken;
46 };
47 };
48 /// When set to ?true, the ICP HTTP gateway re-invokes this request on http_request_update (required for POST mutations).
49 upgrade : ?Bool;
50 };
51 type StreamingCallbackToken = {
52 key : Text;
53 content_encoding : Text;
54 index : Nat;
55 sha256 : ?Blob;
56 };
57 type StreamingCallbackResponse = {
58 body : Blob;
59 token : ?StreamingCallbackToken;
60 };
61
62 // --- Storage ---
63 type NoteContent = { path : Text; frontmatter : Text; body : Text };
64 type ProposalRecord = Migration.ProposalRecord;
65 type BillingRecord = Migration.BillingRecord;
66 type StableStorage = Migration.StableStorage;
67
68 var storage : StableStorage = {
69 vaultEntries = [];
70 proposalEntries = [];
71 billingByUser = [];
72 operator_export_secret = "";
73 gateway_auth_secret = "";
74 cors_allowed_origin = "";
75 };
76
77 /// userId -> vaultId -> path -> (frontmatter, body)
78 transient var byUser = HashMap.HashMap<Text, HashMap.HashMap<Text, HashMap.HashMap<Text, (Text, Text)>>>(10, Text.equal, Text.hash);
79 transient var proposals = HashMap.HashMap<Text, [ProposalRecord]>(10, Text.equal, Text.hash);
80 transient var billingMap = HashMap.HashMap<Text, BillingRecord>(10, Text.equal, Text.hash);
81
82 func loadStable() {
83 for ((uid, vaultId, entries) in Array.vals(storage.vaultEntries)) {
84 let um = switch (byUser.get(uid)) {
85 case (?m) { m };
86 case null {
87 let m = HashMap.HashMap<Text, HashMap.HashMap<Text, (Text, Text)>>(10, Text.equal, Text.hash);
88 byUser.put(uid, m);
89 m;
90 };
91 };
92 let inner = switch (um.get(vaultId)) {
93 case (?m) { m };
94 case null {
95 let m = HashMap.HashMap<Text, (Text, Text)>(10, Text.equal, Text.hash);
96 um.put(vaultId, m);
97 m;
98 };
99 };
100 for ((path, fmBody) in Array.vals(entries)) {
101 inner.put(path, fmBody);
102 };
103 };
104 for ((uid, list) in Array.vals(storage.proposalEntries)) {
105 proposals.put(uid, list);
106 };
107 for ((uid, b) in Array.vals(storage.billingByUser)) {
108 billingMap.put(uid, b);
109 };
110 };
111
112 /// Serialize transient maps into stable storage. Uses Buffer for vault rows — `Array.append` in a loop is
113 /// quadratic and exceeds the per-message instruction limit (40B) on mainnet for large multi-user vaults.
114 func saveStable() {
115 let keepOperatorSecret = storage.operator_export_secret;
116 let keepGatewaySecret = storage.gateway_auth_secret;
117 let keepCorsOrigin = storage.cors_allowed_origin;
118 let vaultBuf = Buffer.Buffer<(Text, Text, [(Text, (Text, Text))])>(8);
119 for ((uid, um) in byUser.entries()) {
120 for ((vaultId, m) in um.entries()) {
121 vaultBuf.add((uid, vaultId, Iter.toArray(m.entries())));
122 };
123 };
124 storage := {
125 vaultEntries = Buffer.toArray(vaultBuf);
126 proposalEntries = Iter.toArray(
127 Iter.map<((Text, [ProposalRecord])), (Text, [ProposalRecord])>(proposals.entries(), func((uid, list) : (Text, [ProposalRecord])) : (Text, [ProposalRecord]) {
128 (uid, list);
129 }),
130 );
131 billingByUser = Iter.toArray(
132 Iter.map<((Text, BillingRecord)), (Text, BillingRecord)>(billingMap.entries(), func((uid, b) : (Text, BillingRecord)) : (Text, BillingRecord) {
133 (uid, b);
134 }),
135 );
136 operator_export_secret = keepOperatorSecret;
137 gateway_auth_secret = keepGatewaySecret;
138 cors_allowed_origin = keepCorsOrigin;
139 };
140 };
141
142 loadStable();
143
144 func charToLower(c : Char) : Char {
145 if (c >= 'A' and c <= 'Z') { Char.fromNat32(Char.toNat32(c) + 32) } else { c };
146 };
147 func getHeader(req : HttpRequest, name : Text) : ?Text {
148 let lower = Text.map(name, charToLower);
149 Array.find<Header>(req.headers, func(h : Header) : Bool { Text.map(h.0, charToLower) == lower })
150 |> Option.map<Header, Text>(_, func(h : Header) : Text { h.1 });
151 };
152
153 func userId(req : HttpRequest) : Text {
154 switch (getHeader(req, "X-User-Id")) {
155 case (?id) { id };
156 case null { "default" };
157 };
158 };
159
160 func isAsciiSpace(c : Char) : Bool {
161 c == ' ' or c == '\t' or c == '\n' or c == '\r';
162 };
163
164 func isVaultIdChar(c : Char) : Bool {
165 let n = Char.toNat32(c);
166 (n >= 48 and n <= 57) or (n >= 65 and n <= 90) or (n >= 97 and n <= 122) or c == '_' or c == '-';
167 };
168
169 /// Align with hub/bridge sanitizeVaultId: [a-zA-Z0-9_-], max 64; invalid chars -> '_'.
170 func sanitizeVaultId(raw : Text) : Text {
171 let chars = Text.toArray(raw);
172 var out = "";
173 var count : Nat = 0;
174 var i : Nat = 0;
175 while (i < chars.size() and count < 64) {
176 let c = chars[i];
177 if (isVaultIdChar(c)) {
178 out := out # Char.toText(c);
179 } else {
180 out := out # "_";
181 };
182 count += 1;
183 i += 1;
184 };
185 let t = Text.trim(out, #predicate isAsciiSpace);
186 if (t.size() == 0) { "default" } else { t };
187 };
188
189 func vaultIdFromRequest(req : HttpRequest) : Text {
190 switch (getHeader(req, "X-Vault-Id")) {
191 case (?v) { sanitizeVaultId(Text.trim(v, #predicate isAsciiSpace)) };
192 case null { "default" };
193 };
194 };
195
196 func effectiveVaultId(stored : Text) : Text {
197 let t = Text.trim(stored, #predicate isAsciiSpace);
198 if (t.size() == 0) { "default" } else { t };
199 };
200
201 func corsHeaders() : [Header] {
202 let origin = if (Text.size(storage.gateway_auth_secret) > 0 and Text.size(storage.cors_allowed_origin) > 0) {
203 storage.cors_allowed_origin;
204 } else {
205 "*";
206 };
207 [
208 ("Access-Control-Allow-Origin", origin),
209 ("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"),
210 ("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Vault-Id, X-User-Id, X-Gateway-Auth, X-Operator-Export-Key"),
211 ("Content-Type", "application/json"),
212 ];
213 };
214
215 func jsonBody(s : Text) : Blob { Text.encodeUtf8(s) };
216
217 func userVaultMap(uid : Text) : HashMap.HashMap<Text, HashMap.HashMap<Text, (Text, Text)>> {
218 switch (byUser.get(uid)) {
219 case (?m) { m };
220 case null {
221 let m = HashMap.HashMap<Text, HashMap.HashMap<Text, (Text, Text)>>(10, Text.equal, Text.hash);
222 byUser.put(uid, m);
223 m;
224 };
225 };
226 };
227
228 func getVault(uid : Text, vaultId : Text) : HashMap.HashMap<Text, (Text, Text)> {
229 let um = userVaultMap(uid);
230 switch (um.get(vaultId)) {
231 case (?m) { m };
232 case null {
233 let m = HashMap.HashMap<Text, (Text, Text)>(10, Text.equal, Text.hash);
234 um.put(vaultId, m);
235 m;
236 };
237 };
238 };
239
240 func getProposalsList(uid : Text) : [ProposalRecord] {
241 Option.get(proposals.get(uid), []);
242 };
243
244 func setProposalsList(uid : Text, list : [ProposalRecord]) {
245 proposals.put(uid, list);
246 };
247
248 func proposalsForVault(uid : Text, reqVault : Text) : [ProposalRecord] {
249 let eff = effectiveVaultId(reqVault);
250 let list = getProposalsList(uid);
251 Array.filter<ProposalRecord>(list, func(p : ProposalRecord) : Bool { effectiveVaultId(p.vault_id) == eff });
252 };
253
254 // Helpers for text slice and find (base library has no Text.sub / Text.find returning position).
255 func textSlice(t : Text, start : Nat, len : Nat) : Text {
256 let arr = Text.toArray(t);
257 let buf = Buffer.Buffer<Char>(len);
258 var i = start;
259 var n : Nat = 0;
260 while (n < len and i < arr.size()) {
261 buf.add(arr[i]);
262 i += 1;
263 n += 1;
264 };
265 Text.fromIter(buf.vals());
266 };
267
268 func capProposalExternalRef(t : Text) : Text {
269 let max : Nat = 512;
270 if (Text.size(t) <= max) {
271 t;
272 } else {
273 textSlice(t, 0, max);
274 };
275 };
276
277 /// Vault-relative path under prefix (exact match or prefix/...).
278 func notePathUnderProjectPrefix(p : Text, base : Text) : Bool {
279 if (p == base) { true } else { Text.startsWith(p, #text (base # "/")) };
280 };
281
282 func stripVaultPathPrefixSlashes(s : Text) : Text {
283 var x = s;
284 while (x.size() > 0 and textSlice(x, 0, 1) == "/") {
285 x := textSlice(x, 1, x.size() - 1);
286 };
287 while (x.size() > 0 and textSlice(x, x.size() - 1, 1) == "/") {
288 x := textSlice(x, 0, x.size() - 1);
289 };
290 x;
291 };
292
293 func normalizeDeletePrefixRaw(raw : Text) : ?Text {
294 let t = Text.trim(raw, #predicate isAsciiSpace);
295 if (t.size() == 0) { return null };
296 if (textFind(t, "..") != null) { return null };
297 let s = stripVaultPathPrefixSlashes(t);
298 if (s.size() == 0) { return null };
299 let parts = Iter.toArray(Text.split(s, #char '/'));
300 for (seg in Array.vals(parts)) {
301 if (seg == "." or seg == "..") { return null };
302 };
303 ?s;
304 };
305
306 func discardProposalsUnderPrefix(uid : Text, vid : Text, base : Text) : Nat {
307 let list = getProposalsList(uid);
308 let buf = Buffer.Buffer<ProposalRecord>(list.size());
309 var disc : Nat = 0;
310 let effVid = effectiveVaultId(vid);
311 let ts = nowIsoUtc();
312 for (r in Array.vals(list)) {
313 if (
314 r.status == "proposed" and effectiveVaultId(r.vault_id) == effVid and notePathUnderProjectPrefix(r.path, base)
315 ) {
316 disc += 1;
317 buf.add({
318 r with status = "discarded";
319 updated_at = ts;
320 });
321 } else {
322 buf.add(r);
323 };
324 };
325 setProposalsList(uid, Buffer.toArray(buf));
326 disc;
327 };
328
329 /// Linear-time substring search. The previous implementation compared via `textSlice` at every
330 /// index, and `textSlice` called `Text.toArray` on the full haystack each time — O(n²) on large
331 /// POST bodies (e.g. `POST /api/v1/notes/batch`), exceeding the per-message instruction limit.
332 func textFind(t : Text, needle : Text) : ?Nat {
333 let tarr = Text.toArray(t);
334 let narr = Text.toArray(needle);
335 let nlen = narr.size();
336 let tlen = tarr.size();
337 if (nlen == 0) { return ?0 };
338 if (nlen > tlen) { return null };
339 var i : Nat = 0;
340 while (i + nlen <= tlen) {
341 var j : Nat = 0;
342 var ok = true;
343 while (j < nlen) {
344 if (tarr[i + j] != narr[j]) {
345 ok := false;
346 j := nlen;
347 } else {
348 j += 1;
349 };
350 };
351 if (ok) { return ?i };
352 i += 1;
353 };
354 null;
355 };
356
357 /// HTTP gateway may pass a full URL (e.g. https://<canister>.icp0.io/api/v1/notes); routing must use the path only.
358 func pathOnly(rawUrl : Text) : Text {
359 let pathParts = Iter.toArray(Text.split(rawUrl, #char '?'));
360 var path = if (pathParts.size() > 0) { pathParts[0] } else { rawUrl };
361 switch (textFind(path, "://")) {
362 case (?k) {
363 let startAuth = k + 3;
364 let pathLen = Text.size(path);
365 // Avoid Nat underflow (M0155): only subtract when startAuth < pathLen.
366 if (startAuth < pathLen) {
367 let afterLen = pathLen - startAuth;
368 let after = textSlice(path, startAuth, afterLen);
369 switch (textFind(after, "/")) {
370 case (?m) {
371 let afterSz = Text.size(after);
372 if (m < afterSz) {
373 path := textSlice(after, m, afterSz - m);
374 } else {
375 path := "/";
376 };
377 };
378 case null { path := "/" };
379 };
380 } else {
381 path := "/";
382 };
383 };
384 case null {};
385 };
386 path;
387 };
388
389 func parsePath(url : Text) : (Text, Text) {
390 let path = pathOnly(url);
391 if (path == "/health" or path == "/health/") {
392 ("health", "");
393 } else if (path == "/api/v1/notes/batch" or path == "/api/v1/notes/batch/") {
394 ("notes_batch", "");
395 } else if (path == "/api/v1/notes/delete-by-prefix" or path == "/api/v1/notes/delete-by-prefix/") {
396 ("notes_delete_prefix", "");
397 } else if (Text.startsWith(path, #text "/api/v1/notes/")) {
398 let suffix = Text.trimStart(path, #text "/api/v1/notes/");
399 ("note", suffix);
400 } else if (path == "/api/v1/notes" or path == "/api/v1/notes/") {
401 ("notes", "");
402 } else if (Text.startsWith(path, #text "/api/v1/vaults/")) {
403 let rest = Text.trimStart(path, #text "/api/v1/vaults/");
404 let parts = Iter.toArray(Text.split(rest, #char '/'));
405 let idRaw = if (parts.size() > 0) { parts[0] } else { "" };
406 ("vault_delete", idRaw);
407 } else if (path == "/api/v1/vaults" or path == "/api/v1/vaults/") {
408 ("vaults", "");
409 } else if (Text.startsWith(path, #text "/api/v1/proposals/")) {
410 let rest = Text.trimStart(path, #text "/api/v1/proposals/");
411 let parts = Iter.toArray(Text.split(rest, #char '/'));
412 if (parts.size() >= 2 and parts[1] == "evaluation") { ("evaluation", parts[0]) }
413 else if (parts.size() >= 2 and parts[1] == "review-hints") { ("review_hints", parts[0]) }
414 else if (parts.size() >= 2 and parts[1] == "enrich") { ("enrich", parts[0]) }
415 else if (parts.size() == 1) { ("proposal", parts[0]) }
416 else if (parts.size() >= 2 and parts[1] == "approve") { ("approve", parts[0]) }
417 else if (parts.size() >= 2 and parts[1] == "discard") { ("discard", parts[0]) }
418 else { ("unknown", "") };
419 } else if (path == "/api/v1/proposals" or path == "/api/v1/proposals/") {
420 ("proposals", "");
421 } else if (path == "/api/v1/export" or path == "/api/v1/export/") {
422 ("export", "");
423 } else if (path == "/api/v1/operator/export" or path == "/api/v1/operator/export/") {
424 ("operator_export", "");
425 } else { ("unknown", "") };
426 };
427
428 // Minimal JSON: extract string value for key (finds "\"key\":\"...\"").
429 // Linear in output size: one Text.toArray on body, Buffer for result (avoids quadratic Text #= in a loop).
430 // Escape sequences are unescaped so the stored text matches the original value
431 // (e.g. "[\\"tag1\\"]" in the POST body becomes ["tag1"] in storage).
432 func extractJsonString(body : Text, key : Text) : ?Text {
433 let needle = "\"" # key # "\":\"";
434 switch (textFind(body, needle)) {
435 case null { null };
436 case (?start) {
437 let chars = Text.toArray(body);
438 var i = start + Text.size(needle);
439 let buf = Buffer.Buffer<Char>(32);
440 while (i < chars.size()) {
441 let ch = chars[i];
442 if (ch == '\\' and i + 1 < chars.size()) {
443 let next = chars[i + 1];
444 if (next == '\"') { buf.add('\"') }
445 else if (next == '\\') { buf.add('\\') }
446 else if (next == 'n') { buf.add('\n') }
447 else if (next == 'r') { buf.add('\r') }
448 else if (next == 't') { buf.add('\t') }
449 else if (next == '/') { buf.add('/') }
450 else { buf.add(ch); buf.add(next) };
451 i += 2;
452 } else if (ch == '\"') {
453 return ?Text.fromIter(buf.vals());
454 } else {
455 buf.add(ch);
456 i += 1;
457 };
458 };
459 null;
460 };
461 };
462 };
463
464 func skipWsFromCharIndex(chars : [Char], j0 : Nat) : Nat {
465 var j = j0;
466 while (j < chars.size()) {
467 let ch = chars[j];
468 if (ch == ' ' or ch == '\t' or ch == '\n' or ch == '\r') { j += 1 } else { return j };
469 };
470 j;
471 };
472
473 /// Balanced `{...}` starting at startBrace (must point at `{`). Respects strings and escapes.
474 func extractJsonObjectSlice(body : Text, startBrace : Nat) : ?Text {
475 let chars = Text.toArray(body);
476 if (startBrace >= chars.size()) { return null };
477 if (chars[startBrace] != '{') { return null };
478 var i = startBrace;
479 var depth : Int = 0;
480 var inStr = false;
481 var esc = false;
482 while (i < chars.size()) {
483 let ch = chars[i];
484 if (esc) {
485 esc := false;
486 i += 1;
487 } else if (inStr) {
488 if (ch == '\\') { esc := true } else if (ch == '\"') { inStr := false };
489 i += 1;
490 } else if (ch == '\"') {
491 inStr := true;
492 i += 1;
493 } else {
494 if (ch == '{') { depth += 1 };
495 if (ch == '}') {
496 depth -= 1;
497 if (depth == 0) {
498 let endExclusive = i + 1;
499 if (endExclusive < startBrace) { return null };
500 let len = endExclusive - startBrace;
501 return ?textSlice(body, startBrace, len);
502 };
503 };
504 i += 1;
505 };
506 };
507 null;
508 };
509
510 /// POST bodies often use `"frontmatter":{...}` (JSON object). extractJsonString only handled a quoted string; mismatch stored `"{}"` and hid metadata in the Hub.
511 func extractFrontmatterFromPostBody(body : Text) : Text {
512 switch (extractJsonString(body, "frontmatter")) {
513 case (?t) { t };
514 case null {
515 let needle = "\"frontmatter\":";
516 switch (textFind(body, needle)) {
517 case null { "{}" };
518 case (?start) {
519 let chars = Text.toArray(body);
520 let idx = skipWsFromCharIndex(chars, start + Text.size(needle));
521 if (idx >= chars.size()) { return "{}" };
522 if (chars[idx] == '\"') {
523 return Option.get(extractJsonString(body, "frontmatter"), "{}");
524 };
525 if (chars[idx] != '{') { return "{}" };
526 switch (extractJsonObjectSlice(body, idx)) {
527 case (?obj) { obj };
528 case null { "{}" };
529 };
530 };
531 };
532 };
533 };
534 };
535
536 func skipWsChars(chars : [Char], i0 : Nat) : Nat {
537 var i = i0;
538 while (i < chars.size()) {
539 let ch = chars[i];
540 if (ch == ' ' or ch == '\t' or ch == '\n' or ch == '\r') { i += 1 } else { return i };
541 };
542 i;
543 };
544
545 func sliceCharsToText(chars : [Char], start : Nat, len : Nat) : Text {
546 let buf = Buffer.Buffer<Char>(len);
547 var j : Nat = 0;
548 while (j < len) {
549 buf.add(chars[start + j]);
550 j += 1;
551 };
552 Text.fromIter(buf.vals());
553 };
554
555 /// `startBrace` must index `{`. Returns index of matching closing `}`.
556 func findJsonObjectEndChars(chars : [Char], startBrace : Nat) : ?Nat {
557 if (startBrace >= chars.size() or chars[startBrace] != '{') { return null };
558 var i = startBrace;
559 var depth : Int = 0;
560 var inStr = false;
561 var esc = false;
562 while (i < chars.size()) {
563 let ch = chars[i];
564 if (esc) {
565 esc := false;
566 i += 1;
567 } else if (inStr) {
568 if (ch == '\\') { esc := true } else if (ch == '\"') { inStr := false };
569 i += 1;
570 } else if (ch == '\"') {
571 inStr := true;
572 i += 1;
573 } else {
574 if (ch == '{') { depth += 1 };
575 if (ch == '}') {
576 depth -= 1;
577 if (depth == 0) { return ?i };
578 };
579 i += 1;
580 };
581 };
582 null;
583 };
584
585 /// Parse `{"notes":[{...},...]}` into (path, frontmatterJsonText, body) per element.
586 func parseNotesBatch(body : Text) : ?[(Text, Text, Text)] {
587 let chars = Text.toArray(body);
588 switch (textFind(body, "\"notes\"")) {
589 case null { null };
590 case (?nk) {
591 var i = nk + 7;
592 i := skipWsChars(chars, i);
593 if (i >= chars.size() or chars[i] != ':') { return null };
594 i += 1;
595 i := skipWsChars(chars, i);
596 if (i >= chars.size() or chars[i] != '[') { return null };
597 i += 1;
598 let out = Buffer.Buffer<(Text, Text, Text)>(8);
599 while (i < chars.size()) {
600 i := skipWsChars(chars, i);
601 if (i >= chars.size()) { return null };
602 if (chars[i] == ']') {
603 return ?Buffer.toArray(out);
604 };
605 if (out.size() >= 100) { return null };
606 if (chars[i] != '{') { return null };
607 switch (findJsonObjectEndChars(chars, i)) {
608 case null { return null };
609 case (?endIdx) {
610 let endExclusive = endIdx + 1;
611 if (endExclusive < i) { return null };
612 let objLen = endExclusive - i;
613 let objText = sliceCharsToText(chars, i, objLen);
614 let path = Option.get(extractJsonString(objText, "path"), "");
615 if (path.size() == 0) { return null };
616 let noteBody = Option.get(extractJsonString(objText, "body"), "");
617 let frontmatter = extractFrontmatterFromPostBody(objText);
618 out.add((path, frontmatter, noteBody));
619 i := endIdx + 1;
620 i := skipWsChars(chars, i);
621 if (i < chars.size() and chars[i] == ',') { i += 1 };
622 };
623 };
624 };
625 null;
626 };
627 };
628 };
629
630 /// Single hex digit (percent-decoding); avoids allocating one-char `Text` per path byte.
631 func hexDigitChar(ch : Char) : ?Nat {
632 let n = Char.toNat32(ch);
633 if (n >= 48 and n <= 57) return ?(Nat32.toNat(n - 48));
634 if (n >= 65 and n <= 70) return ?(Nat32.toNat(n - 55));
635 if (n >= 97 and n <= 102) return ?(Nat32.toNat(n - 87));
636 null;
637 };
638
639 /// Decode percent-encoded path segment (e.g. inbox%2Fnote.md -> inbox/note.md) so GET lookup matches POST-stored keys.
640 func decodePercentEncoded(s : Text) : Text {
641 let chars = Text.toArray(s);
642 let buf = Buffer.Buffer<Char>(chars.size());
643 var i : Nat = 0;
644 while (i < chars.size()) {
645 let c = chars[i];
646 if (c == '%' and i + 2 < chars.size()) {
647 switch (hexDigitChar(chars[i + 1]), hexDigitChar(chars[i + 2])) {
648 case (?a, ?b) {
649 let code = a * 16 + b;
650 buf.add(Char.fromNat32(Nat32.fromNat(code)));
651 i += 3;
652 };
653 case _ {
654 buf.add(c);
655 i += 1;
656 };
657 };
658 } else {
659 buf.add(c);
660 i += 1;
661 };
662 };
663 Text.fromIter(buf.vals());
664 };
665
666 /// Decode URL path segment and strip a single trailing slash (matches GET note lookup).
667 func normalizeNotePathFromArg(pathArg : Text) : Text {
668 let pathDecoded = decodePercentEncoded(pathArg);
669 if (Text.size(pathDecoded) > 0 and textSlice(pathDecoded, Text.size(pathDecoded) - 1, 1) == "/") {
670 textSlice(pathDecoded, 0, Text.size(pathDecoded) - 1)
671 } else {
672 pathDecoded
673 };
674 };
675
676 /// 4 lowercase hex digits (JSON \\uXXXX) for BMP code points; used for U+0000..U+001F.
677 func natToHex4(code : Nat32) : Text {
678 let n = Nat32.toNat(code);
679 func hd(div : Nat) : Text {
680 let d = (n / div) % 16;
681 switch (d) {
682 case 0 { "0" };
683 case 1 { "1" };
684 case 2 { "2" };
685 case 3 { "3" };
686 case 4 { "4" };
687 case 5 { "5" };
688 case 6 { "6" };
689 case 7 { "7" };
690 case 8 { "8" };
691 case 9 { "9" };
692 case 10 { "a" };
693 case 11 { "b" };
694 case 12 { "c" };
695 case 13 { "d" };
696 case 14 { "e" };
697 case 15 { "f" };
698 case _ { "0" };
699 };
700 };
701 hd(4096) # hd(256) # hd(16) # hd(1);
702 };
703
704 /// Human evaluation: approve allowed when status empty/none/passed (see docs/PROPOSAL-LIFECYCLE.md).
705 func evalStatusAllowsApprove(es : Text) : Bool {
706 if (es == "" or es == "none") { true } else if (es == "passed") { true } else { false };
707 };
708
709 func outcomeToEvaluationStatus(out : Text) : ?Text {
710 if (out == "pass") { ?"passed" } else if (out == "fail") { ?"failed" } else if (out == "needs_changes") { ?"needs_changes" } else { null };
711 };
712
713 /// Best-effort: reject pass if serialized checklist contains an explicit false (full JSON parse not in canister v1).
714 func checklistJsonOkForPass(checklistJson : Text) : Bool {
715 switch (textFind(checklistJson, "\"passed\":false")) {
716 case null { true };
717 case (?_) { false };
718 };
719 };
720
721 /// Non-negative `Int` → `Nat` for calendar math (rejects negative / parse failure → 0).
722 func intToNatSafe(i : Int) : Nat {
723 if (i < 0) {
724 return 0;
725 };
726 switch (Nat.fromText(Int.toText(i))) {
727 case null { 0 };
728 case (?n) { n };
729 };
730 };
731
732 func isLeapYear(y : Nat) : Bool {
733 (y % 4 == 0 and y % 100 != 0) or (y % 400 == 0);
734 };
735
736 func daysInMonth(y : Nat, m : Nat) : Nat {
737 switch (m) {
738 case 1 { 31 };
739 case 2 { if (isLeapYear(y)) { 29 } else { 28 } };
740 case 3 { 31 };
741 case 4 { 30 };
742 case 5 { 31 };
743 case 6 { 30 };
744 case 7 { 31 };
745 case 8 { 31 };
746 case 9 { 30 };
747 case 10 { 31 };
748 case 11 { 30 };
749 case 12 { 31 };
750 case _ { 31 };
751 };
752 };
753
754 func pad2(n : Nat) : Text {
755 if (n < 10) {
756 "0" # Nat.toText(n);
757 } else {
758 Nat.toText(n);
759 };
760 };
761
762 func pad3(n : Nat) : Text {
763 if (n < 10) {
764 "00" # Nat.toText(n);
765 } else if (n < 100) {
766 "0" # Nat.toText(n);
767 } else {
768 Nat.toText(n);
769 };
770 };
771
772 func pad4(n : Nat) : Text {
773 let t = Nat.toText(n);
774 let len = Text.size(t);
775 if (len >= 4) {
776 t;
777 } else if (len == 3) {
778 "0" # t;
779 } else if (len == 2) {
780 "00" # t;
781 } else if (len == 1) {
782 "000" # t;
783 } else {
784 "0000";
785 };
786 };
787
788 /// UTC ISO-8601 with milliseconds, e.g. `2026-03-29T16:47:13.042Z` (replaces former fixed placeholder).
789 func iso8601UtcFromUnixSeconds(secNat : Nat, msNat : Nat) : Text {
790 let secsPerDay = 86400;
791 let totalDays = secNat / secsPerDay;
792 var sod = secNat % secsPerDay;
793 var hour = sod / 3600;
794 sod := sod % 3600;
795 var minute = sod / 60;
796 var second = sod % 60;
797 var y : Nat = 1970;
798 var d = totalDays;
799 label yearLoop loop {
800 let diy = if (isLeapYear(y)) { 366 } else { 365 };
801 if (d >= diy) {
802 d -= diy;
803 y += 1;
804 } else {
805 break yearLoop;
806 };
807 };
808 var m : Nat = 1;
809 label monthLoop loop {
810 let dim = daysInMonth(y, m);
811 if (d >= dim) {
812 d -= dim;
813 m += 1;
814 } else {
815 break monthLoop;
816 };
817 };
818 let day = d + 1;
819 pad4(y) # "-" # pad2(m) # "-" # pad2(day) # "T" # pad2(hour) # ":" # pad2(minute) # ":" # pad2(second) # "." # pad3(msNat % 1000) # "Z";
820 };
821
822 func nowIsoUtc() : Text {
823 let ns = Time.now();
824 if (ns < 0) {
825 return "1970-01-01T00:00:00.000Z";
826 };
827 let secInt = ns / 1_000_000_000;
828 let remNs = ns % 1_000_000_000;
829 let secNat = intToNatSafe(secInt);
830 let msNat = intToNatSafe(remNs / 1_000_000);
831 iso8601UtcFromUnixSeconds(secNat, msNat);
832 };
833
834 /// RFC 8259: control chars U+0000..U+001F must be escaped; pass-through broke JSON.parse in the Hub.
835 func escapeJson(s : Text) : Text {
836 let chars = Text.toArray(s);
837 var out = "";
838 var idx : Nat = 0;
839 while (idx < chars.size()) {
840 let ch = chars[idx];
841 let code = Char.toNat32(ch);
842 if (code == 92) { out := out # "\\\\" }
843 else if (code == 34) { out := out # "\\\"" }
844 else if (code == 10) { out := out # "\\n" }
845 else if (code == 13) { out := out # "\\r" }
846 else if (code == 9) { out := out # "\\t" }
847 else if (code < 32) { out := out # "\\u" # natToHex4(code) }
848 else { out := out # Char.toText(ch) };
849 idx += 1;
850 };
851 out;
852 };
853
854 func vaultIdsForUser(uid : Text) : [Text] {
855 switch (byUser.get(uid)) {
856 case null { ["default"] };
857 case (?um) {
858 let keys = Iter.toArray(um.keys());
859 if (keys.size() == 0) { ["default"] } else { keys };
860 };
861 };
862 };
863
864 func vaultListJson(uid : Text) : Text {
865 let ids = vaultIdsForUser(uid);
866 var items : Text = "";
867 for (vid in Array.vals(ids)) {
868 if (items != "") { items := items # "," };
869 items := items # "{\"id\":\"" # escapeJson(vid) # "\",\"label\":\"" # escapeJson(vid) # "\"}";
870 };
871 "{\"vaults\":[" # items # "]}";
872 };
873
874 /// Query string parameter from full request URL (path may include `?`).
875 func queryParamFromUrl(url : Text, paramName : Text) : ?Text {
876 switch (textFind(url, "?")) {
877 case null { null };
878 case (?qi) {
879 let urlLen = Text.size(url);
880 let qStart = qi + 1;
881 if (qStart > urlLen) { return null };
882 let qLen = urlLen - qStart;
883 let qs = textSlice(url, qStart, qLen);
884 let keyEq = paramName # "=";
885 let pairs = Iter.toArray(Text.split(qs, #char '&'));
886 var pi : Nat = 0;
887 while (pi < pairs.size()) {
888 let pair = pairs[pi];
889 if (Text.startsWith(pair, #text keyEq)) {
890 let keyLen = Text.size(keyEq);
891 let pairLen = Text.size(pair);
892 if (pairLen >= keyLen) {
893 return ?textSlice(pair, keyLen, pairLen - keyLen);
894 };
895 };
896 pi += 1;
897 };
898 null;
899 };
900 };
901 };
902
903 func natFromTextDec(t0 : Text) : ?Nat {
904 let t = Text.trim(t0, #predicate isAsciiSpace);
905 if (Text.size(t) == 0) { return null };
906 let chars = Text.toArray(t);
907 var out : Nat = 0;
908 var i : Nat = 0;
909 while (i < chars.size()) {
910 let c = chars[i];
911 let d = Nat32.toNat(Char.toNat32(c));
912 if (d < 48 or d > 57) { return null };
913 out := out * 10 + (d - 48);
914 i += 1;
915 };
916 ?out;
917 };
918
919 func operatorExportAuthorized(req : HttpRequest) : Bool {
920 let expected = storage.operator_export_secret;
921 if (Text.size(expected) == 0) { return false };
922 switch (getHeader(req, "X-Operator-Export-Key")) {
923 case null { false };
924 case (?got) {
925 if (Text.size(got) != Text.size(expected)) { false } else { got == expected };
926 };
927 };
928 };
929
930 func gatewayAuthorized(req : HttpRequest) : Bool {
931 let expected = storage.gateway_auth_secret;
932 if (Text.size(expected) == 0) { return true };
933 switch (getHeader(req, "X-Gateway-Auth")) {
934 case null { false };
935 case (?got) {
936 if (Text.size(got) != Text.size(expected)) { false } else { got == expected };
937 };
938 };
939 };
940
941 let _GATEWAY_AUTH_DENIED : HttpResponse = {
942 status_code = 403;
943 headers = [("Content-Type", "application/json")];
944 body = Text.encodeUtf8("{\"error\":\"Gateway authentication required\",\"code\":\"GATEWAY_AUTH_REQUIRED\"}");
945 streaming_strategy = null;
946 upgrade = null;
947 };
948
949 func sortedOperatorUserIds() : [Text] {
950 let keys = Iter.toArray(byUser.keys());
951 Array.sort<Text>(keys, Text.compare)
952 };
953
954 func clampOperatorUserPageLimit(maybe : ?Text) : Nat {
955 let cap : Nat = 500;
956 let def : Nat = 100;
957 switch (maybe) {
958 case null { def };
959 case (?t) {
960 switch (natFromTextDec(t)) {
961 case null { def };
962 case (?n) {
963 if (n < 1) { 1 } else if (n > cap) { cap } else { n };
964 };
965 };
966 };
967 };
968 };
969
970 func operatorUserIndexJson(req : HttpRequest) : Text {
971 let lim = clampOperatorUserPageLimit(queryParamFromUrl(req.url, "limit"));
972 let cursorStart : Nat = switch (queryParamFromUrl(req.url, "cursor")) {
973 case null { 0 };
974 case (?c) {
975 switch (natFromTextDec(c)) {
976 case null { 0 };
977 case (?n) { n };
978 };
979 };
980 };
981 let ids = sortedOperatorUserIds();
982 let total = ids.size();
983 var start : Nat = cursorStart;
984 if (start > total) { start := total };
985 var end : Nat = start + lim;
986 if (end > total) { end := total };
987 var items : Text = "";
988 var j = start;
989 while (j < end) {
990 if (items != "") { items := items # "," };
991 items := items # "\"" # escapeJson(ids[j]) # "\"";
992 j += 1;
993 };
994 let done = end >= total;
995 let nextCur = if (done) { "" } else { Nat.toText(end) };
996 let ts = Int.toText(Time.now());
997 "{\"format_version\":3,\"kind\":\"knowtation-operator-user-index\",\"exported_at_ns\":\"" # ts # "\",\"user_ids\":[" # items # "],\"next_cursor\":\"" # nextCur # "\",\"done\":" # (if (done) { "true" } else { "false" }) # "}";
998 };
999
1000 public query func http_request(req : HttpRequest) : async HttpResponse {
1001 let (pathKind, pathArg) = parsePath(req.url);
1002
1003 if (pathKind == "health") {
1004 return {
1005 status_code = 200;
1006 headers = corsHeaders();
1007 body = jsonBody("{\"ok\":true}");
1008 streaming_strategy = null;
1009 upgrade = null;
1010 };
1011 };
1012
1013 if (req.method == "OPTIONS") {
1014 return { status_code = 204; headers = corsHeaders(); body = jsonBody(""); streaming_strategy = null; upgrade = null };
1015 };
1016
1017 if (not gatewayAuthorized(req)) { return _GATEWAY_AUTH_DENIED };
1018
1019 let uid = userId(req);
1020 let vid = vaultIdFromRequest(req);
1021
1022 if (pathKind == "vaults" and req.method == "GET") {
1023 return {
1024 status_code = 200;
1025 headers = corsHeaders();
1026 body = jsonBody(vaultListJson(uid));
1027 streaming_strategy = null;
1028 upgrade = null;
1029 };
1030 };
1031
1032 if (pathKind == "export" and req.method == "GET") {
1033 let vault = getVault(uid, vid);
1034 let entries = Iter.toArray(vault.entries());
1035 var items : Text = "";
1036 for ((p, fmBody) in Array.vals(entries)) {
1037 if (items != "") { items := items # "," };
1038 items := items # "{\"path\":\"" # escapeJson(p) # "\",\"frontmatter\":\"" # escapeJson(fmBody.0) # "\",\"body\":\"" # escapeJson(fmBody.1) # "\"}";
1039 };
1040 let json = "{\"notes\":[" # items # "]}";
1041 return { status_code = 200; headers = corsHeaders(); body = jsonBody(json); streaming_strategy = null; upgrade = null };
1042 };
1043
1044 if (pathKind == "operator_export" and req.method == "GET") {
1045 if (not operatorExportAuthorized(req)) {
1046 let (code, msg) = if (Text.size(storage.operator_export_secret) == 0) {
1047 (503 : Nat16, "{\"error\":\"Operator export not configured\",\"code\":\"SERVICE_UNAVAILABLE\"}");
1048 } else {
1049 (401 : Nat16, "{\"error\":\"Unauthorized\",\"code\":\"UNAUTHORIZED\"}");
1050 };
1051 return { status_code = code; headers = corsHeaders(); body = jsonBody(msg); streaming_strategy = null; upgrade = null };
1052 };
1053 return {
1054 status_code = 200;
1055 headers = corsHeaders();
1056 body = jsonBody(operatorUserIndexJson(req));
1057 streaming_strategy = null;
1058 upgrade = null;
1059 };
1060 };
1061
1062 if (pathKind == "notes" and req.method == "GET") {
1063 let vault = getVault(uid, vid);
1064 let entries = Iter.toArray(vault.entries());
1065 var items : Text = "";
1066 for ((p, fmBody) in Array.vals(entries)) {
1067 if (items != "") { items := items # "," };
1068 items := items # "{\"path\":\"" # escapeJson(p) # "\",\"frontmatter\":\"" # escapeJson(fmBody.0) # "\",\"body\":\"" # escapeJson(fmBody.1) # "\"}";
1069 };
1070 let json = "{\"notes\":[" # items # "],\"total\":" # Nat.toText(entries.size()) # "}";
1071 return { status_code = 200; headers = corsHeaders(); body = jsonBody(json); streaming_strategy = null; upgrade = null };
1072 };
1073
1074 if (pathKind == "note" and req.method == "GET") {
1075 let pathNormalized = normalizeNotePathFromArg(pathArg);
1076 let vault = getVault(uid, vid);
1077 switch (vault.get(pathNormalized)) {
1078 case (?fmBody) {
1079 let json = "{\"path\":\"" # escapeJson(pathNormalized) # "\",\"frontmatter\":\"" # escapeJson(fmBody.0) # "\",\"body\":\"" # escapeJson(fmBody.1) # "\"}";
1080 return { status_code = 200; headers = corsHeaders(); body = jsonBody(json); streaming_strategy = null; upgrade = null };
1081 };
1082 case null {
1083 switch (vault.get(pathArg)) {
1084 case (?fmBody) {
1085 let json = "{\"path\":\"" # escapeJson(pathArg) # "\",\"frontmatter\":\"" # escapeJson(fmBody.0) # "\",\"body\":\"" # escapeJson(fmBody.1) # "\"}";
1086 return { status_code = 200; headers = corsHeaders(); body = jsonBody(json); streaming_strategy = null; upgrade = null };
1087 };
1088 case null {
1089 return { status_code = 404; headers = corsHeaders(); body = jsonBody("{\"error\":\"Not found\",\"code\":\"NOT_FOUND\"}"); streaming_strategy = null; upgrade = null };
1090 };
1091 };
1092 };
1093 };
1094 };
1095
1096 if (pathKind == "proposals" and req.method == "GET") {
1097 let list = proposalsForVault(uid, vid);
1098 var items : Text = "";
1099 for (p in Array.vals(list)) {
1100 if (items != "") { items := items # "," };
1101 let afrList = if (Text.size(p.auto_flag_reasons_json) > 0) { p.auto_flag_reasons_json } else { "[]" };
1102 items := items # "{\"proposal_id\":\"" # escapeJson(p.proposal_id) # "\",\"path\":\"" # escapeJson(p.path) # "\",\"status\":\"" # escapeJson(p.status) # "\",\"intent\":\"" # escapeJson(p.intent) # "\",\"base_state_id\":\"" # escapeJson(p.base_state_id) # "\",\"external_ref\":\"" # escapeJson(p.external_ref) # "\",\"vault_id\":\"" # escapeJson(effectiveVaultId(p.vault_id)) # "\",\"created_at\":\"" # escapeJson(p.created_at) # "\",\"updated_at\":\"" # escapeJson(p.updated_at) # "\",\"evaluation_status\":\"" # escapeJson(p.evaluation_status) # "\",\"evaluation_grade\":\"" # escapeJson(p.evaluation_grade) # "\",\"evaluated_by\":\"" # escapeJson(p.evaluated_by) # "\",\"evaluated_at\":\"" # escapeJson(p.evaluated_at) # "\",\"review_queue\":\"" # escapeJson(p.review_queue) # "\",\"review_severity\":\"" # escapeJson(p.review_severity) # "\",\"auto_flag_reasons_json\":\"" # escapeJson(afrList) # "\"}";
1103 };
1104 let json = "{\"proposals\":[" # items # "],\"total\":" # Nat.toText(list.size()) # "}";
1105 return { status_code = 200; headers = corsHeaders(); body = jsonBody(json); streaming_strategy = null; upgrade = null };
1106 };
1107
1108 if (pathKind == "proposal" and req.method == "GET") {
1109 let list = proposalsForVault(uid, vid);
1110 switch (Array.find<ProposalRecord>(list, func(p : ProposalRecord) : Bool { p.proposal_id == pathArg })) {
1111 case (?p) {
1112 let clEnc = escapeJson(if (Text.size(p.evaluation_checklist) > 0) { p.evaluation_checklist } else { "[]" });
1113 let afrEnc = escapeJson(if (Text.size(p.auto_flag_reasons_json) > 0) { p.auto_flag_reasons_json } else { "[]" });
1114 let waiPart = if (Text.size(p.evaluation_waiver_json) > 0) {
1115 "\"evaluation_waiver\":\"" # escapeJson(p.evaluation_waiver_json) # "\"";
1116 } else {
1117 "\"evaluation_waiver\":null";
1118 };
1119 let sugJson = JsonValidate.normalizeJsonArrayFragment(p.suggested_labels_json);
1120 let fmJson = JsonValidate.normalizeJsonObjectFragment(p.assistant_suggested_frontmatter_json);
1121 let json = "{\"proposal_id\":\"" # escapeJson(p.proposal_id) # "\",\"path\":\"" # escapeJson(p.path) # "\",\"status\":\"" # escapeJson(p.status) # "\",\"intent\":\"" # escapeJson(p.intent) # "\",\"base_state_id\":\"" # escapeJson(p.base_state_id) # "\",\"external_ref\":\"" # escapeJson(p.external_ref) # "\",\"vault_id\":\"" # escapeJson(effectiveVaultId(p.vault_id)) # "\",\"body\":\"" # escapeJson(p.body) # "\",\"frontmatter\":\"" # escapeJson(p.frontmatter) # "\",\"created_at\":\"" # escapeJson(p.created_at) # "\",\"updated_at\":\"" # escapeJson(p.updated_at) # "\",\"evaluation_status\":\"" # escapeJson(p.evaluation_status) # "\",\"evaluation_grade\":\"" # escapeJson(p.evaluation_grade) # "\",\"evaluation_checklist\":\"" # clEnc # "\",\"evaluation_comment\":\"" # escapeJson(p.evaluation_comment) # "\",\"evaluated_by\":\"" # escapeJson(p.evaluated_by) # "\",\"evaluated_at\":\"" # escapeJson(p.evaluated_at) # "\"," # waiPart # ",\"review_queue\":\"" # escapeJson(p.review_queue) # "\",\"review_severity\":\"" # escapeJson(p.review_severity) # "\",\"auto_flag_reasons_json\":\"" # afrEnc # "\",\"review_hints\":\"" # escapeJson(p.review_hints) # "\",\"review_hints_at\":\"" # escapeJson(p.review_hints_at) # "\",\"review_hints_model\":\"" # escapeJson(p.review_hints_model) # "\",\"assistant_notes\":\"" # escapeJson(p.assistant_notes) # "\",\"assistant_model\":\"" # escapeJson(p.assistant_model) # "\",\"assistant_at\":\"" # escapeJson(p.assistant_at) # "\",\"suggested_labels\":" # sugJson # ",\"assistant_suggested_frontmatter\":" # fmJson # "}";
1122 return { status_code = 200; headers = corsHeaders(); body = jsonBody(json); streaming_strategy = null; upgrade = null };
1123 };
1124 case null {
1125 return { status_code = 404; headers = corsHeaders(); body = jsonBody("{\"error\":\"Proposal not found\",\"code\":\"NOT_FOUND\"}"); streaming_strategy = null; upgrade = null };
1126 };
1127 };
1128 };
1129
1130 // ICP HTTP gateway always invokes http_request (query) first. Mutating methods must return
1131 // upgrade = ?true so the gateway re-sends the same request to http_request_update (consensus).
1132 if (
1133 (req.method == "POST" and (pathKind == "notes" or pathKind == "notes_batch" or pathKind == "notes_delete_prefix" or pathKind == "proposals" or pathKind == "approve" or pathKind == "discard" or pathKind == "evaluation" or pathKind == "review_hints" or pathKind == "enrich"))
1134 or (req.method == "DELETE" and (pathKind == "note" or pathKind == "vault_delete"))
1135 ) {
1136 return {
1137 status_code = 200;
1138 headers = [];
1139 body = Blob.fromArray([]);
1140 streaming_strategy = null;
1141 upgrade = ?true;
1142 };
1143 };
1144
1145 return { status_code = 404; headers = corsHeaders(); body = jsonBody("{\"error\":\"Not found\",\"code\":\"NOT_FOUND\"}"); streaming_strategy = null; upgrade = null };
1146 };
1147
1148 public func http_request_update(req : HttpRequest) : async HttpResponse {
1149 if (not gatewayAuthorized(req)) { return _GATEWAY_AUTH_DENIED };
1150
1151 let uid = userId(req);
1152 let vid = vaultIdFromRequest(req);
1153 let (pathKind, pathArg) = parsePath(req.url);
1154 let bodyText = if (req.body.size() > 0) { Option.get(Text.decodeUtf8(req.body), "{}") } else { "{}" };
1155
1156 if (pathKind == "vault_delete" and req.method == "DELETE") {
1157 let targetVid = sanitizeVaultId(pathArg);
1158 if (targetVid == "default") {
1159 return {
1160 status_code = 400;
1161 headers = corsHeaders();
1162 body = jsonBody("{\"error\":\"Cannot delete the default vault\",\"code\":\"BAD_REQUEST\"}");
1163 streaming_strategy = null;
1164 upgrade = null;
1165 };
1166 };
1167 switch (byUser.get(uid)) {
1168 case null {
1169 return {
1170 status_code = 404;
1171 headers = corsHeaders();
1172 body = jsonBody("{\"error\":\"Vault not found\",\"code\":\"NOT_FOUND\"}");
1173 streaming_strategy = null;
1174 upgrade = null;
1175 };
1176 };
1177 case (?um) {
1178 switch (um.get(targetVid)) {
1179 case null {
1180 return {
1181 status_code = 404;
1182 headers = corsHeaders();
1183 body = jsonBody("{\"error\":\"Vault not found\",\"code\":\"NOT_FOUND\"}");
1184 streaming_strategy = null;
1185 upgrade = null;
1186 };
1187 };
1188 case (?_) { };
1189 };
1190 let _ = um.remove(targetVid);
1191 let list = getProposalsList(uid);
1192 let propBuf = Buffer.Buffer<ProposalRecord>(list.size());
1193 var dropped : Nat = 0;
1194 for (r in Array.vals(list)) {
1195 if (effectiveVaultId(r.vault_id) == targetVid) {
1196 dropped += 1;
1197 } else {
1198 propBuf.add(r);
1199 };
1200 };
1201 setProposalsList(uid, Buffer.toArray(propBuf));
1202 saveStable();
1203 return {
1204 status_code = 200;
1205 headers = corsHeaders();
1206 body = jsonBody(
1207 "{\"ok\":true,\"deleted_vault_id\":\"" # escapeJson(targetVid) # "\",\"proposals_removed\":" # Nat.toText(dropped) # "}",
1208 );
1209 streaming_strategy = null;
1210 upgrade = null;
1211 };
1212 };
1213 };
1214 };
1215
1216 if (pathKind == "note" and req.method == "DELETE") {
1217 let pathNormalized = normalizeNotePathFromArg(pathArg);
1218 let vault = getVault(uid, vid);
1219 var deletedPath : ?Text = null;
1220 switch (vault.remove(pathNormalized)) {
1221 case (?_) { deletedPath := ?pathNormalized };
1222 case null {
1223 switch (vault.remove(pathArg)) {
1224 case (?_) { deletedPath := ?pathArg };
1225 case null {};
1226 };
1227 };
1228 };
1229 switch (deletedPath) {
1230 case (?p) {
1231 saveStable();
1232 return {
1233 status_code = 200;
1234 headers = corsHeaders();
1235 body = jsonBody("{\"path\":\"" # escapeJson(p) # "\",\"deleted\":true}");
1236 streaming_strategy = null;
1237 upgrade = null;
1238 };
1239 };
1240 case null {
1241 return {
1242 status_code = 404;
1243 headers = corsHeaders();
1244 body = jsonBody("{\"error\":\"Not found\",\"code\":\"NOT_FOUND\"}");
1245 streaming_strategy = null;
1246 upgrade = null;
1247 };
1248 };
1249 };
1250 };
1251
1252 if (pathKind == "notes_batch" and req.method == "POST") {
1253 switch (parseNotesBatch(bodyText)) {
1254 case null {
1255 return {
1256 status_code = 400;
1257 headers = corsHeaders();
1258 body = jsonBody("{\"error\":\"Invalid notes batch: expect notes array, max 100 items, path and body per object\",\"code\":\"BAD_REQUEST\"}");
1259 streaming_strategy = null;
1260 upgrade = null;
1261 };
1262 };
1263 case (?items) {
1264 let vault = getVault(uid, vid);
1265 var count : Nat = 0;
1266 for (tup in Array.vals(items)) {
1267 let (p, fm, nb) = tup;
1268 if (p.size() > 0) {
1269 vault.put(p, (fm, nb));
1270 count += 1;
1271 };
1272 };
1273 saveStable();
1274 return {
1275 status_code = 200;
1276 headers = corsHeaders();
1277 body = jsonBody("{\"imported\":" # Nat.toText(count) # ",\"written\":true}");
1278 streaming_strategy = null;
1279 upgrade = null;
1280 };
1281 };
1282 };
1283 };
1284
1285 if (pathKind == "notes_delete_prefix" and req.method == "POST") {
1286 let rawPrefix = Option.get(extractJsonString(bodyText, "path_prefix"), "");
1287 switch (normalizeDeletePrefixRaw(rawPrefix)) {
1288 case null {
1289 return {
1290 status_code = 400;
1291 headers = corsHeaders();
1292 body = jsonBody("{\"error\":\"Invalid or missing path_prefix\",\"code\":\"BAD_REQUEST\"}");
1293 streaming_strategy = null;
1294 upgrade = null;
1295 };
1296 };
1297 case (?base) {
1298 let vault = getVault(uid, vid);
1299 let entries = Iter.toArray(vault.entries());
1300 let buf = Buffer.Buffer<Text>(8);
1301 for ((p, _) in Array.vals(entries)) {
1302 if (notePathUnderProjectPrefix(p, base)) { buf.add(p) };
1303 };
1304 let toRemove = Buffer.toArray(buf);
1305 for (p in Array.vals(toRemove)) { ignore vault.remove(p) };
1306 let propDisc = discardProposalsUnderPrefix(uid, vid, base);
1307 saveStable();
1308 var jsonPaths : Text = "";
1309 for (p in Array.vals(toRemove)) {
1310 if (jsonPaths != "") { jsonPaths := jsonPaths # "," };
1311 jsonPaths := jsonPaths # "\"" # escapeJson(p) # "\"";
1312 };
1313 let json = "{\"deleted\":" # Nat.toText(toRemove.size()) # ",\"paths\":[" # jsonPaths # "],\"proposals_discarded\":" # Nat.toText(propDisc) # "}";
1314 return { status_code = 200; headers = corsHeaders(); body = jsonBody(json); streaming_strategy = null; upgrade = null };
1315 };
1316 };
1317 };
1318
1319 if (pathKind == "notes" and req.method == "POST") {
1320 let vault = getVault(uid, vid);
1321 let path = if (pathArg.size() > 0) { pathArg } else { Option.get(extractJsonString(bodyText, "path"), "") };
1322 if (path.size() == 0) {
1323 return { status_code = 400; headers = corsHeaders(); body = jsonBody("{\"error\":\"path required\",\"code\":\"BAD_REQUEST\"}"); streaming_strategy = null; upgrade = null };
1324 };
1325 let noteBody = Option.get(extractJsonString(bodyText, "body"), bodyText);
1326 let frontmatter = extractFrontmatterFromPostBody(bodyText);
1327 vault.put(path, (frontmatter, noteBody));
1328 saveStable();
1329 return { status_code = 200; headers = corsHeaders(); body = jsonBody("{\"path\":\"" # escapeJson(path) # "\",\"written\":true}"); streaming_strategy = null; upgrade = null };
1330 };
1331
1332 if (pathKind == "proposals" and req.method == "POST") {
1333 let path = Option.get(extractJsonString(bodyText, "path"), "inbox/proposal-" # Int.toText(Time.now()) # ".md");
1334 let body = Option.get(extractJsonString(bodyText, "body"), "");
1335 let intent = Option.get(extractJsonString(bodyText, "intent"), "");
1336 let frontmatter = extractFrontmatterFromPostBody(bodyText);
1337 let base_state_id = Option.get(extractJsonString(bodyText, "base_state_id"), "");
1338 let external_ref = Option.get(extractJsonString(bodyText, "external_ref"), "");
1339 let evalIn = Option.get(extractJsonString(bodyText, "evaluation_status"), "");
1340 let evalInit = if (evalIn == "pending") { "pending" } else { "" };
1341 let rq = Option.get(extractJsonString(bodyText, "review_queue"), "");
1342 let rs = Option.get(extractJsonString(bodyText, "review_severity"), "");
1343 let afr = Option.get(extractJsonString(bodyText, "auto_flag_reasons_json"), "");
1344 let proposal_id = "prop-" # Int.toText(Time.now());
1345 let now = nowIsoUtc();
1346 var list = getProposalsList(uid);
1347 let newP : ProposalRecord = {
1348 proposal_id;
1349 path;
1350 status = "proposed";
1351 body;
1352 frontmatter;
1353 intent;
1354 base_state_id;
1355 external_ref;
1356 vault_id = vid;
1357 created_at = now;
1358 updated_at = now;
1359 evaluation_status = evalInit;
1360 evaluation_grade = "";
1361 evaluation_checklist = "";
1362 evaluation_comment = "";
1363 evaluated_by = "";
1364 evaluated_at = "";
1365 evaluation_waiver_json = "";
1366 review_queue = rq;
1367 review_severity = rs;
1368 auto_flag_reasons_json = afr;
1369 review_hints = "";
1370 review_hints_at = "";
1371 review_hints_model = "";
1372 assistant_notes = "";
1373 assistant_model = "";
1374 assistant_at = "";
1375 suggested_labels_json = "[]";
1376 assistant_suggested_frontmatter_json = "{}";
1377 };
1378 list := Array.append(list, [newP]);
1379 setProposalsList(uid, list);
1380 saveStable();
1381 let json = "{\"proposal_id\":\"" # escapeJson(proposal_id) # "\",\"path\":\"" # escapeJson(path) # "\",\"status\":\"proposed\"}";
1382 return { status_code = 200; headers = corsHeaders(); body = jsonBody(json); streaming_strategy = null; upgrade = null };
1383 };
1384
1385 if (pathKind == "evaluation" and req.method == "POST") {
1386 let outcome = Option.get(extractJsonString(bodyText, "outcome"), "");
1387 let grade = Option.get(extractJsonString(bodyText, "grade"), "");
1388 let comment = Option.get(extractJsonString(bodyText, "comment"), "");
1389 let checklistJson = Option.get(extractJsonString(bodyText, "evaluation_checklist_json"), "[]");
1390 switch (outcomeToEvaluationStatus(outcome)) {
1391 case null {
1392 return {
1393 status_code = 400;
1394 headers = corsHeaders();
1395 body = jsonBody("{\"error\":\"outcome must be pass, fail, or needs_changes\",\"code\":\"BAD_REQUEST\"}");
1396 streaming_strategy = null;
1397 upgrade = null;
1398 };
1399 };
1400 case (?evStatus) {
1401 var listEv = getProposalsList(uid);
1402 switch (Array.find<ProposalRecord>(listEv, func(p : ProposalRecord) : Bool { p.proposal_id == pathArg })) {
1403 case null {
1404 return {
1405 status_code = 404;
1406 headers = corsHeaders();
1407 body = jsonBody("{\"error\":\"Proposal not found\",\"code\":\"NOT_FOUND\"}");
1408 streaming_strategy = null;
1409 upgrade = null;
1410 };
1411 };
1412 case (?pEv) {
1413 if (pEv.status != "proposed") {
1414 return {
1415 status_code = 400;
1416 headers = corsHeaders();
1417 body = jsonBody("{\"error\":\"Can only evaluate proposed proposals\",\"code\":\"BAD_REQUEST\"}");
1418 streaming_strategy = null;
1419 upgrade = null;
1420 };
1421 };
1422 if ((evStatus == "failed" or evStatus == "needs_changes") and Text.size(comment) == 0) {
1423 return {
1424 status_code = 400;
1425 headers = corsHeaders();
1426 body = jsonBody("{\"error\":\"comment is required for fail and needs_changes\",\"code\":\"BAD_REQUEST\"}");
1427 streaming_strategy = null;
1428 upgrade = null;
1429 };
1430 };
1431 if (evStatus == "passed" and not checklistJsonOkForPass(checklistJson)) {
1432 return {
1433 status_code = 400;
1434 headers = corsHeaders();
1435 body = jsonBody("{\"error\":\"All checklist items must pass for a pass outcome\",\"code\":\"BAD_REQUEST\"}");
1436 streaming_strategy = null;
1437 upgrade = null;
1438 };
1439 };
1440 let nowEv = nowIsoUtc();
1441 listEv := Array.map<ProposalRecord, ProposalRecord>(listEv, func(x : ProposalRecord) : ProposalRecord {
1442 if (x.proposal_id == pathArg) {
1443 {
1444 x with
1445 evaluation_status = evStatus;
1446 evaluation_grade = grade;
1447 evaluation_checklist = checklistJson;
1448 evaluation_comment = comment;
1449 evaluated_by = uid;
1450 evaluated_at = nowEv;
1451 updated_at = nowEv;
1452 }
1453 } else { x }
1454 });
1455 setProposalsList(uid, listEv);
1456 saveStable();
1457 return {
1458 status_code = 200;
1459 headers = corsHeaders();
1460 body = jsonBody(
1461 "{\"proposal_id\":\"" # escapeJson(pathArg) # "\",\"evaluation_status\":\"" # escapeJson(evStatus) # "\"}",
1462 );
1463 streaming_strategy = null;
1464 upgrade = null;
1465 };
1466 };
1467 };
1468 };
1469 };
1470 };
1471
1472 if (pathKind == "review_hints" and req.method == "POST") {
1473 let hints = Option.get(extractJsonString(bodyText, "review_hints"), "");
1474 let model = Option.get(extractJsonString(bodyText, "review_hints_model"), "");
1475 var listRh = getProposalsList(uid);
1476 switch (Array.find<ProposalRecord>(listRh, func(p : ProposalRecord) : Bool { p.proposal_id == pathArg })) {
1477 case null {
1478 return {
1479 status_code = 404;
1480 headers = corsHeaders();
1481 body = jsonBody("{\"error\":\"Proposal not found\",\"code\":\"NOT_FOUND\"}");
1482 streaming_strategy = null;
1483 upgrade = null;
1484 };
1485 };
1486 case (?pRh) {
1487 if (pRh.status != "proposed") {
1488 return {
1489 status_code = 400;
1490 headers = corsHeaders();
1491 body = jsonBody("{\"error\":\"Can only attach hints to proposed proposals\",\"code\":\"BAD_REQUEST\"}");
1492 streaming_strategy = null;
1493 upgrade = null;
1494 };
1495 };
1496 let nowRh = nowIsoUtc();
1497 listRh := Array.map<ProposalRecord, ProposalRecord>(listRh, func(x : ProposalRecord) : ProposalRecord {
1498 if (x.proposal_id == pathArg) {
1499 {
1500 x with
1501 review_hints = hints;
1502 review_hints_model = model;
1503 review_hints_at = nowRh;
1504 updated_at = nowRh;
1505 }
1506 } else {
1507 x
1508 }
1509 });
1510 setProposalsList(uid, listRh);
1511 saveStable();
1512 return {
1513 status_code = 200;
1514 headers = corsHeaders();
1515 body = jsonBody("{\"proposal_id\":\"" # escapeJson(pathArg) # "\",\"ok\":true}");
1516 streaming_strategy = null;
1517 upgrade = null;
1518 };
1519 };
1520 };
1521 };
1522
1523 if (pathKind == "enrich" and req.method == "POST") {
1524 let notes = Option.get(extractJsonString(bodyText, "assistant_notes"), "");
1525 let modelEn = Option.get(extractJsonString(bodyText, "assistant_model"), "");
1526 let sugRaw = Option.get(extractJsonString(bodyText, "suggested_labels_json"), "[]");
1527 let fmRaw = Option.get(extractJsonString(bodyText, "assistant_suggested_frontmatter_json"), "{}");
1528 var listEn = getProposalsList(uid);
1529 switch (Array.find<ProposalRecord>(listEn, func(p : ProposalRecord) : Bool { p.proposal_id == pathArg })) {
1530 case null {
1531 return {
1532 status_code = 404;
1533 headers = corsHeaders();
1534 body = jsonBody("{\"error\":\"Proposal not found\",\"code\":\"NOT_FOUND\"}");
1535 streaming_strategy = null;
1536 upgrade = null;
1537 };
1538 };
1539 case (?pEn) {
1540 if (effectiveVaultId(pEn.vault_id) != effectiveVaultId(vid)) {
1541 return {
1542 status_code = 403;
1543 headers = corsHeaders();
1544 body = jsonBody("{\"error\":\"Access to this proposal is not allowed.\",\"code\":\"FORBIDDEN\"}");
1545 streaming_strategy = null;
1546 upgrade = null;
1547 };
1548 };
1549 if (pEn.status != "proposed") {
1550 return {
1551 status_code = 400;
1552 headers = corsHeaders();
1553 body = jsonBody("{\"error\":\"Can only enrich proposed proposals\",\"code\":\"BAD_REQUEST\"}");
1554 streaming_strategy = null;
1555 upgrade = null;
1556 };
1557 };
1558 let nowEn = nowIsoUtc();
1559 let notesTrim = if (Text.size(notes) > 16_000) { textSlice(notes, 0, 16_000) } else { notes };
1560 let modelTrim = if (Text.size(modelEn) > 128) { textSlice(modelEn, 0, 128) } else { modelEn };
1561 let sugFinal = switch (JsonValidate.prepareEnrichJsonArray(sugRaw, 4000)) {
1562 case (#ok(t)) { t };
1563 case (#coercedDefault) { "[]" };
1564 case (#tooLarge) {
1565 return {
1566 status_code = 400;
1567 headers = corsHeaders();
1568 body = jsonBody(
1569 "{\"error\":\"suggested_labels_json exceeds maximum length after validation (4000 characters)\",\"code\":\"BAD_REQUEST\"}",
1570 );
1571 streaming_strategy = null;
1572 upgrade = null;
1573 };
1574 };
1575 };
1576 let fmFinal = switch (JsonValidate.prepareEnrichJsonObject(fmRaw, 14_000)) {
1577 case (#ok(t)) { t };
1578 case (#coercedDefault) { "{}" };
1579 case (#tooLarge) {
1580 return {
1581 status_code = 400;
1582 headers = corsHeaders();
1583 body = jsonBody(
1584 "{\"error\":\"assistant_suggested_frontmatter_json exceeds maximum length after validation (14000 characters)\",\"code\":\"BAD_REQUEST\"}",
1585 );
1586 streaming_strategy = null;
1587 upgrade = null;
1588 };
1589 };
1590 };
1591 listEn := Array.map<ProposalRecord, ProposalRecord>(listEn, func(x : ProposalRecord) : ProposalRecord {
1592 if (x.proposal_id == pathArg) {
1593 {
1594 x with
1595 assistant_notes = notesTrim;
1596 assistant_model = modelTrim;
1597 suggested_labels_json = sugFinal;
1598 assistant_suggested_frontmatter_json = fmFinal;
1599 assistant_at = nowEn;
1600 updated_at = nowEn;
1601 }
1602 } else {
1603 x
1604 }
1605 });
1606 setProposalsList(uid, listEn);
1607 saveStable();
1608 return {
1609 status_code = 200;
1610 headers = corsHeaders();
1611 body = jsonBody("{\"proposal_id\":\"" # escapeJson(pathArg) # "\",\"ok\":true}");
1612 streaming_strategy = null;
1613 upgrade = null;
1614 };
1615 };
1616 };
1617 };
1618
1619 if (pathKind == "approve" and req.method == "POST") {
1620 let waiverRaw = Option.get(extractJsonString(bodyText, "waiver_reason"), "");
1621 var list = getProposalsList(uid);
1622 switch (Array.find<ProposalRecord>(list, func(p : ProposalRecord) : Bool { p.proposal_id == pathArg })) {
1623 case (?p) {
1624 let needsWaiver = not evalStatusAllowsApprove(p.evaluation_status);
1625 if (needsWaiver and Text.size(waiverRaw) < 3) {
1626 return {
1627 status_code = 403;
1628 headers = corsHeaders();
1629 body = jsonBody(
1630 "{\"error\":\"Evaluation must be passed before approve, or provide waiver_reason\",\"code\":\"EVALUATION_REQUIRED\"}",
1631 );
1632 streaming_strategy = null;
1633 upgrade = null;
1634 };
1635 };
1636 let targetVid = effectiveVaultId(p.vault_id);
1637 let vault = getVault(uid, targetVid);
1638 vault.put(p.path, (p.frontmatter, p.body));
1639 let nowAp = nowIsoUtc();
1640 let datePart = textSlice(nowAp, 0, 10);
1641 let logPath = "approvals/" # datePart # "-" # pathArg # ".md";
1642 var logFm = "kind: approval_log\nproposal_id: " # pathArg # "\ntarget_path: \"" # escapeJson(p.path) # "\"\napproved_at: \"" # escapeJson(nowAp) # "\"\napproved_by: \"" # escapeJson(uid) # "\"\n";
1643 if (Text.size(p.intent) > 0) {
1644 let it0 = if (Text.size(p.intent) > 400) { textSlice(p.intent, 0, 400) } else { p.intent };
1645 logFm := logFm # "intent: \"" # escapeJson(it0) # "\"\n";
1646 };
1647 let logBodyBase = "Approved vault change applied to `" # p.path # "`.\n\n- **Proposal ID:** " # pathArg # "\n- **Approved at:** " # nowAp # "\n";
1648 let excerptCap : Nat = 4000;
1649 let excerptRaw = if (Text.size(p.body) <= excerptCap) {
1650 p.body;
1651 } else {
1652 textSlice(p.body, 0, excerptCap);
1653 };
1654 let logBody = if (Text.size(excerptRaw) > 0) {
1655 logBodyBase # "\n\n## Proposal excerpt (for search)\n\n" # excerptRaw # "\n";
1656 } else {
1657 logBodyBase;
1658 };
1659 vault.put(logPath, (logFm, logBody));
1660 let waiverJson = if (needsWaiver and Text.size(waiverRaw) >= 3) {
1661 "{\"by\":\"" # escapeJson(uid) # "\",\"at\":\"" # nowAp # "\",\"reason\":\"" # escapeJson(waiverRaw) # "\"}"
1662 } else {
1663 ""
1664 };
1665 let extBodyRaw = Option.get(extractJsonString(bodyText, "external_ref"), "");
1666 let extFinal = if (Text.size(extBodyRaw) > 0) {
1667 capProposalExternalRef(extBodyRaw);
1668 } else {
1669 p.external_ref;
1670 };
1671 list := Array.map<ProposalRecord, ProposalRecord>(list, func(x : ProposalRecord) : ProposalRecord {
1672 if (x.proposal_id == pathArg) {
1673 {
1674 x with status = "approved";
1675 updated_at = nowAp;
1676 external_ref = extFinal;
1677 evaluation_waiver_json = if (Text.size(waiverJson) > 0) { waiverJson } else { x.evaluation_waiver_json };
1678 }
1679 } else {
1680 x
1681 }
1682 });
1683 setProposalsList(uid, list);
1684 saveStable();
1685 return {
1686 status_code = 200;
1687 headers = corsHeaders();
1688 body = jsonBody(
1689 "{\"proposal_id\":\""
1690 # pathArg
1691 # "\",\"status\":\"approved\",\"approval_log_path\":\""
1692 # escapeJson(logPath)
1693 # "\",\"approval_log_written\":true,\"external_ref\":\""
1694 # escapeJson(extFinal)
1695 # "\"}",
1696 );
1697 streaming_strategy = null;
1698 upgrade = null;
1699 };
1700 };
1701 case null {
1702 return { status_code = 404; headers = corsHeaders(); body = jsonBody("{\"error\":\"Proposal not found\",\"code\":\"NOT_FOUND\"}"); streaming_strategy = null; upgrade = null };
1703 };
1704 };
1705 };
1706
1707 if (pathKind == "discard" and req.method == "POST") {
1708 let tsDisc = nowIsoUtc();
1709 var list = getProposalsList(uid);
1710 list := Array.map<ProposalRecord, ProposalRecord>(list, func(x : ProposalRecord) : ProposalRecord {
1711 if (x.proposal_id == pathArg) { { x with status = "discarded"; updated_at = tsDisc } } else { x }
1712 });
1713 setProposalsList(uid, list);
1714 saveStable();
1715 return { status_code = 200; headers = corsHeaders(); body = jsonBody("{\"proposal_id\":\"" # pathArg # "\",\"status\":\"discarded\"}"); streaming_strategy = null; upgrade = null };
1716 };
1717
1718 return { status_code = 404; headers = corsHeaders(); body = jsonBody("{\"error\":\"Not found\",\"code\":\"NOT_FOUND\"}"); streaming_strategy = null; upgrade = null };
1719 };
1720
1721 /// Controllers only. Sets `X-Operator-Export-Key` value the HTTP endpoint checks (constant-length compare via `==` after length match).
1722 public shared ({ caller }) func admin_set_operator_export_secret(secret : Text) : async () {
1723 if (not Principal.isController(caller)) {
1724 Debug.trap("FORBIDDEN");
1725 };
1726 storage := {
1727 vaultEntries = storage.vaultEntries;
1728 proposalEntries = storage.proposalEntries;
1729 billingByUser = storage.billingByUser;
1730 operator_export_secret = secret;
1731 gateway_auth_secret = storage.gateway_auth_secret;
1732 cors_allowed_origin = storage.cors_allowed_origin;
1733 };
1734 };
1735
1736 /// Controllers only. Sets `X-Gateway-Auth` value checked on every non-health HTTP request.
1737 /// When empty (default after migration), auth is bypassed for backward compat until explicitly set.
1738 public shared ({ caller }) func admin_set_gateway_auth_secret(secret : Text) : async () {
1739 if (not Principal.isController(caller)) {
1740 Debug.trap("FORBIDDEN");
1741 };
1742 storage := {
1743 vaultEntries = storage.vaultEntries;
1744 proposalEntries = storage.proposalEntries;
1745 billingByUser = storage.billingByUser;
1746 operator_export_secret = storage.operator_export_secret;
1747 gateway_auth_secret = secret;
1748 cors_allowed_origin = storage.cors_allowed_origin;
1749 };
1750 };
1751
1752 /// Controllers only. Sets the `Access-Control-Allow-Origin` value for CORS responses.
1753 /// When both this and `gateway_auth_secret` are non-empty, `corsHeaders()` returns this
1754 /// origin instead of `*`. Call after deploy with the gateway origin (e.g. "https://hub.knowtation.com").
1755 public shared ({ caller }) func admin_set_cors_origin(origin : Text) : async () {
1756 if (not Principal.isController(caller)) {
1757 Debug.trap("FORBIDDEN");
1758 };
1759 storage := {
1760 vaultEntries = storage.vaultEntries;
1761 proposalEntries = storage.proposalEntries;
1762 billingByUser = storage.billingByUser;
1763 operator_export_secret = storage.operator_export_secret;
1764 gateway_auth_secret = storage.gateway_auth_secret;
1765 cors_allowed_origin = origin;
1766 };
1767 };
1768
1769 }
File History 1 commit
sha256:baa800aedf841dbf32081aa7b2befa288ac33dfc7175ac55014c55c4d8c742f9 docs: move durable-auth freeze/evidence to local developmen… Human 11 days ago