verify-hosted-hub-api.mjs
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠ breaking
1 day ago
| 1 | #!/usr/bin/env node |
| 2 | /** |
| 3 | * Hosted Hub API probe: list notes, report empty frontmatter, optional GET one path. |
| 4 | * Use after sign-in: copy JWT from localStorage hub_token (DevTools → Application). |
| 5 | * |
| 6 | * Usage: |
| 7 | * KNOWTATION_HUB_TOKEN='<jwt>' node scripts/verify-hosted-hub-api.mjs |
| 8 | * KNOWTATION_HUB_TOKEN_FILE=~/.config/knowtation/hub_jwt.txt node scripts/verify-hosted-hub-api.mjs |
| 9 | * |
| 10 | * Full investigation (A1 + default detail path + A2 write probe): |
| 11 | * KNOWTATION_HUB_INVESTIGATE=1 KNOWTATION_HUB_TOKEN='...' node scripts/verify-hosted-hub-api.mjs |
| 12 | * |
| 13 | * Repo deploy snapshot only (no JWT; Phase B facts from git + canister_ids.json + live /health): |
| 14 | * KNOWTATION_HUB_SNAPSHOT_ONLY=1 node scripts/verify-hosted-hub-api.mjs |
| 15 | * |
| 16 | * Loads optional `KNOWTATION_HUB_TOKEN` / `HUB_JWT` from repo-root `.env` (dotenv) when present. |
| 17 | * |
| 18 | * Optional write probe (creates/overwrites a probe note — use a throwaway path): |
| 19 | * KNOWTATION_HUB_PROBE_PATH='inbox/.hub-probe-delete-me.md' KNOWTATION_HUB_DO_PROBE=1 \ |
| 20 | * KNOWTATION_HUB_TOKEN='...' node scripts/verify-hosted-hub-api.mjs |
| 21 | */ |
| 22 | |
| 23 | import { execSync } from 'child_process'; |
| 24 | import crypto from 'crypto'; |
| 25 | import fs from 'fs'; |
| 26 | import path from 'path'; |
| 27 | import { fileURLToPath } from 'url'; |
| 28 | import dotenv from 'dotenv'; |
| 29 | import { materializeListFrontmatter, deriveFacetsFromCanisterNotes } from '../hub/gateway/note-facets.mjs'; |
| 30 | |
| 31 | function probeDetailPathFromNotes(notes) { |
| 32 | const list = Array.isArray(notes) ? notes : []; |
| 33 | const prefer = list.find((n) => n.path === 'inbox/note-hello-world.md'); |
| 34 | const nonProbe = list.filter((n) => !String(n.path || '').includes('.hub-probe-delete-me')); |
| 35 | return prefer?.path || nonProbe[0]?.path || list[0]?.path || ''; |
| 36 | } |
| 37 | |
| 38 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| 39 | const repoRoot = path.resolve(__dirname, '..'); |
| 40 | dotenv.config({ path: path.join(repoRoot, '.env') }); |
| 41 | |
| 42 | function resolveToken() { |
| 43 | let t = process.env.KNOWTATION_HUB_TOKEN || process.env.HUB_JWT || ''; |
| 44 | const fp = (process.env.KNOWTATION_HUB_TOKEN_FILE || '').trim(); |
| 45 | if (!t && fp) { |
| 46 | const expanded = fp.startsWith('~') ? path.join(process.env.HOME || '', fp.slice(1)) : fp; |
| 47 | try { |
| 48 | t = fs.readFileSync(expanded, 'utf8').trim(); |
| 49 | } catch (e) { |
| 50 | console.error('KNOWTATION_HUB_TOKEN_FILE read failed:', expanded, e.message); |
| 51 | process.exit(1); |
| 52 | } |
| 53 | } |
| 54 | return t; |
| 55 | } |
| 56 | |
| 57 | const token = resolveToken(); |
| 58 | const apiBase = (process.env.KNOWTATION_HUB_API || 'https://knowtation-gateway.netlify.app').replace(/\/$/, ''); |
| 59 | const vaultId = process.env.KNOWTATION_HUB_VAULT_ID || 'default'; |
| 60 | const envNotePath = (process.env.KNOWTATION_HUB_NOTE_PATH || '').trim(); |
| 61 | let probePath = (process.env.KNOWTATION_HUB_PROBE_PATH || '').trim(); |
| 62 | let doProbe = process.env.KNOWTATION_HUB_DO_PROBE === '1' || process.env.KNOWTATION_HUB_DO_PROBE === 'true'; |
| 63 | const investigate = process.env.KNOWTATION_HUB_INVESTIGATE === '1' || process.env.KNOWTATION_HUB_INVESTIGATE === 'true'; |
| 64 | const snapshotOnly = process.env.KNOWTATION_HUB_SNAPSHOT_ONLY === '1' || process.env.KNOWTATION_HUB_SNAPSHOT_ONLY === 'true'; |
| 65 | |
| 66 | function headers() { |
| 67 | const h = { Accept: 'application/json', 'Content-Type': 'application/json', 'X-Vault-Id': vaultId }; |
| 68 | if (token) h.Authorization = 'Bearer ' + token; |
| 69 | return h; |
| 70 | } |
| 71 | |
| 72 | async function httpHealth(url, label) { |
| 73 | try { |
| 74 | const r = await fetch(url, { method: 'GET' }); |
| 75 | return { label, url, status: r.status, ok: r.ok }; |
| 76 | } catch (e) { |
| 77 | return { label, url, status: null, ok: false, error: e.message }; |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | function printDeploySnapshot() { |
| 82 | console.log('--- Phase B: deploy alignment snapshot (verify against Netlify + ICP dashboards) ---'); |
| 83 | const idsPath = path.join(repoRoot, 'hub', 'icp', 'canister_ids.json'); |
| 84 | let canisterId = '(missing canister_ids.json)'; |
| 85 | try { |
| 86 | const j = JSON.parse(fs.readFileSync(idsPath, 'utf8')); |
| 87 | canisterId = j?.hub?.ic || canisterId; |
| 88 | } catch { |
| 89 | /* ignore */ |
| 90 | } |
| 91 | console.log('repo hub/icp/canister_ids.json hub.ic:', canisterId); |
| 92 | if (!String(canisterId).startsWith('(')) { |
| 93 | console.log('docs expect CANISTER_URL (raw):', `https://${canisterId}.raw.icp0.io`); |
| 94 | } |
| 95 | console.log('Motoko extractFrontmatterFromPostBody: see git log hub/icp/src/hub/main.mo (e.g. fad98ec, 7e55a25)'); |
| 96 | try { |
| 97 | const head = execSync('git rev-parse HEAD', { cwd: repoRoot, encoding: 'utf8' }).trim(); |
| 98 | console.log('repo git HEAD', head); |
| 99 | } catch { |
| 100 | /* not a git checkout */ |
| 101 | } |
| 102 | const localWasm = path.join(repoRoot, 'hub', 'icp', '.dfx', 'local', 'canisters', 'hub', 'hub.wasm'); |
| 103 | try { |
| 104 | if (fs.existsSync(localWasm)) { |
| 105 | const buf = fs.readFileSync(localWasm); |
| 106 | const sha = crypto.createHash('sha256').update(buf).digest('hex'); |
| 107 | console.log('local hub.wasm sha256 (if built):', sha); |
| 108 | console.log('compare to Internet Computer dashboard module hash for canister', canisterId); |
| 109 | } else { |
| 110 | console.log('local hub.wasm: (not built) run: cd hub/icp && dfx build hub'); |
| 111 | } |
| 112 | } catch { |
| 113 | /* ignore */ |
| 114 | } |
| 115 | return canisterId; |
| 116 | } |
| 117 | |
| 118 | /** |
| 119 | * @param {{ token?: string, apiBase?: string, vaultId?: string, notePath?: string, probePath?: string, doProbe?: boolean, autoDetailPath?: boolean }} opts |
| 120 | * @returns {Promise<Record<string, unknown>>} |
| 121 | */ |
| 122 | export async function runHostedHubVerification(opts = {}) { |
| 123 | const base = (opts.apiBase || apiBase).replace(/\/$/, ''); |
| 124 | const vid = opts.vaultId || vaultId; |
| 125 | const tok = opts.token ?? token; |
| 126 | const h = () => { |
| 127 | const out = { Accept: 'application/json', 'Content-Type': 'application/json', 'X-Vault-Id': vid }; |
| 128 | if (tok) out.Authorization = 'Bearer ' + tok; |
| 129 | return out; |
| 130 | }; |
| 131 | |
| 132 | /** @type {Record<string, unknown>} */ |
| 133 | const report = { |
| 134 | apiBase: base, |
| 135 | vaultId: vid, |
| 136 | list_status: null, |
| 137 | empty_frontmatter_count: null, |
| 138 | notes_length: null, |
| 139 | facets_status: null, |
| 140 | gateway_facets_tag_count: null, |
| 141 | detail_path: null, |
| 142 | detail_status: null, |
| 143 | detail_fm_key_count: null, |
| 144 | probe_post_status: null, |
| 145 | probe_get_status: null, |
| 146 | after_probe_fm_key_count: null, |
| 147 | interpretation: null, |
| 148 | }; |
| 149 | |
| 150 | const listUrl = `${base}/api/v1/notes?limit=200&offset=0`; |
| 151 | const listRes = await fetch(listUrl, { headers: h() }); |
| 152 | const listText = await listRes.text(); |
| 153 | report.list_status = listRes.status; |
| 154 | console.log('GET /api/v1/notes', listRes.status, listRes.ok ? 'ok' : 'FAIL'); |
| 155 | if (!listRes.ok) { |
| 156 | console.log(listText.slice(0, 500)); |
| 157 | report.interpretation = 'list_failed'; |
| 158 | return report; |
| 159 | } |
| 160 | let data; |
| 161 | try { |
| 162 | data = JSON.parse(listText); |
| 163 | } catch (e) { |
| 164 | console.error('List response is not JSON:', e.message); |
| 165 | console.log(listText.slice(0, 400)); |
| 166 | report.interpretation = 'list_not_json'; |
| 167 | return report; |
| 168 | } |
| 169 | const notes = Array.isArray(data.notes) ? data.notes : []; |
| 170 | const total = data.total ?? notes.length; |
| 171 | report.notes_length = notes.length; |
| 172 | console.log('notes.length', notes.length, 'total', total); |
| 173 | |
| 174 | let emptyFm = 0; |
| 175 | let sampleNonEmpty = 0; |
| 176 | for (const n of notes) { |
| 177 | const fm = materializeListFrontmatter(n.frontmatter); |
| 178 | const keys = Object.keys(fm); |
| 179 | if (keys.length === 0) emptyFm += 1; |
| 180 | else if (sampleNonEmpty < 2) { |
| 181 | sampleNonEmpty += 1; |
| 182 | console.log('sample path', n.path, 'fm keys', keys.slice(0, 12).join(', ')); |
| 183 | } |
| 184 | } |
| 185 | report.empty_frontmatter_count = emptyFm; |
| 186 | console.log('empty_frontmatter_count', emptyFm, '/', notes.length); |
| 187 | const derived = deriveFacetsFromCanisterNotes(notes); |
| 188 | console.log('derived facets projects', derived.projects.length, 'tags', derived.tags.length, 'folders', derived.folders.length); |
| 189 | if (derived.tags.length) console.log('tags sample', derived.tags.slice(0, 8).join(', ')); |
| 190 | |
| 191 | const facetsRes = await fetch(`${base}/api/v1/notes/facets`, { headers: h() }); |
| 192 | const facetsText = await facetsRes.text(); |
| 193 | report.facets_status = facetsRes.status; |
| 194 | console.log('GET /api/v1/notes/facets', facetsRes.status, facetsRes.ok ? 'ok' : 'FAIL'); |
| 195 | if (facetsRes.ok) { |
| 196 | try { |
| 197 | const f = JSON.parse(facetsText); |
| 198 | report.gateway_facets_tag_count = (f.tags || []).length; |
| 199 | console.log('gateway facets tags', (f.tags || []).length, 'projects', (f.projects || []).length); |
| 200 | } catch { |
| 201 | console.log(facetsText.slice(0, 200)); |
| 202 | } |
| 203 | } else { |
| 204 | console.log(facetsText.slice(0, 300)); |
| 205 | } |
| 206 | |
| 207 | let pathForDetail = |
| 208 | opts.notePath != null && String(opts.notePath).trim() !== '' ? String(opts.notePath).trim() : ''; |
| 209 | if (!pathForDetail && notes.length && opts.autoDetailPath) { |
| 210 | pathForDetail = probeDetailPathFromNotes(notes); |
| 211 | } |
| 212 | |
| 213 | if (pathForDetail) { |
| 214 | report.detail_path = pathForDetail; |
| 215 | const enc = encodeURIComponent(pathForDetail); |
| 216 | const oneUrl = `${base}/api/v1/notes/${enc}`; |
| 217 | const oneRes = await fetch(oneUrl, { headers: h() }); |
| 218 | const oneText = await oneRes.text(); |
| 219 | report.detail_status = oneRes.status; |
| 220 | console.log('GET /api/v1/notes/' + pathForDetail, oneRes.status, oneRes.ok ? 'ok' : 'FAIL'); |
| 221 | if (oneRes.ok) { |
| 222 | try { |
| 223 | const note = JSON.parse(oneText); |
| 224 | const raw = typeof note.frontmatter === 'string' ? note.frontmatter : JSON.stringify(note.frontmatter); |
| 225 | let fm = materializeListFrontmatter(note.frontmatter); |
| 226 | if (typeof note.frontmatter === 'string' && Object.keys(fm).length === 0 && raw.trim().length > 2) { |
| 227 | try { |
| 228 | JSON.parse(raw.replace(/^\uFEFF/, '').trim()); |
| 229 | } catch (e) { |
| 230 | console.log('detail frontmatter JSON.parse error:', e && e.message ? e.message : String(e)); |
| 231 | console.log('detail frontmatter first_80_codepoints', [...raw.slice(0, 80)].map((c) => c.charCodeAt(0)).join(',')); |
| 232 | } |
| 233 | } |
| 234 | report.detail_fm_key_count = Object.keys(fm).length; |
| 235 | console.log('detail frontmatter keys', Object.keys(fm).join(', ') || '(none)'); |
| 236 | console.log('detail frontmatter raw length', raw.length, 'preview', raw.slice(0, 160).replace(/\n/g, ' ')); |
| 237 | } catch { |
| 238 | console.log(oneText.slice(0, 400)); |
| 239 | } |
| 240 | } else { |
| 241 | console.log(oneText.slice(0, 300)); |
| 242 | } |
| 243 | } |
| 244 | |
| 245 | const runProbe = opts.doProbe !== undefined ? opts.doProbe : doProbe; |
| 246 | const pPath = (opts.probePath != null && String(opts.probePath).trim() !== '' ? String(opts.probePath).trim() : probePath); |
| 247 | if (runProbe && pPath) { |
| 248 | const body = JSON.stringify({ |
| 249 | path: pPath, |
| 250 | body: '# probe\n', |
| 251 | frontmatter: JSON.stringify({ title: 'Hub probe', tags: 'probe-tag', date: new Date().toISOString().slice(0, 10) }), |
| 252 | }); |
| 253 | const postRes = await fetch(`${base}/api/v1/notes`, { |
| 254 | method: 'POST', |
| 255 | headers: h(), |
| 256 | body, |
| 257 | }); |
| 258 | const postText = await postRes.text(); |
| 259 | report.probe_post_status = postRes.status; |
| 260 | console.log('POST /api/v1/notes (probe)', postRes.status, postText.slice(0, 200)); |
| 261 | const enc = encodeURIComponent(pPath); |
| 262 | const verify = await fetch(`${base}/api/v1/notes/${enc}`, { headers: h() }); |
| 263 | const verifyText = await verify.text(); |
| 264 | report.probe_get_status = verify.status; |
| 265 | console.log('GET after probe', verify.status); |
| 266 | if (verify.ok) { |
| 267 | const note = JSON.parse(verifyText); |
| 268 | const fm = materializeListFrontmatter(note.frontmatter); |
| 269 | report.after_probe_fm_key_count = Object.keys(fm).length; |
| 270 | console.log('after_probe frontmatter keys', Object.keys(fm).join(', ') || '(none)'); |
| 271 | } |
| 272 | } |
| 273 | |
| 274 | if (report.after_probe_fm_key_count != null) { |
| 275 | if (report.after_probe_fm_key_count > 0) report.interpretation = 'write_path_ok_legacy_data_likely'; |
| 276 | else report.interpretation = 'write_path_broken_or_empty_probe_response'; |
| 277 | } else if (report.list_status === 200 && report.detail_fm_key_count != null) { |
| 278 | report.interpretation = |
| 279 | report.detail_fm_key_count === 0 && report.empty_frontmatter_count === report.notes_length |
| 280 | ? 'all_notes_empty_fm_check_canister_deploy_and_post_path' |
| 281 | : 'mixed_or_partial_metadata'; |
| 282 | } |
| 283 | |
| 284 | return report; |
| 285 | } |
| 286 | |
| 287 | async function main() { |
| 288 | if (snapshotOnly) { |
| 289 | const cid = printDeploySnapshot(); |
| 290 | const g = await httpHealth(`${apiBase}/health`, 'gateway'); |
| 291 | const rawUrl = |
| 292 | cid && !String(cid).startsWith('(') |
| 293 | ? `https://${cid}.raw.icp0.io/health` |
| 294 | : 'https://rsovz-byaaa-aaaaa-qgira-cai.raw.icp0.io/health'; |
| 295 | const c = await httpHealth(rawUrl, 'canister_raw'); |
| 296 | console.log('live_checks', JSON.stringify({ gateway: g, canister_raw: c })); |
| 297 | process.exit(0); |
| 298 | } |
| 299 | |
| 300 | if (!token) { |
| 301 | console.error('Set KNOWTATION_HUB_TOKEN (or HUB_JWT) or KNOWTATION_HUB_TOKEN_FILE to your Hub JWT from localStorage hub_token.'); |
| 302 | console.error('Or run KNOWTATION_HUB_SNAPSHOT_ONLY=1 for Phase B repo + health snapshot without auth.'); |
| 303 | process.exit(1); |
| 304 | } |
| 305 | |
| 306 | if (investigate) { |
| 307 | if (!probePath) probePath = 'inbox/.hub-probe-delete-me.md'; |
| 308 | if (!doProbe) doProbe = true; |
| 309 | } |
| 310 | |
| 311 | const report = await runHostedHubVerification({ |
| 312 | notePath: envNotePath || undefined, |
| 313 | probePath, |
| 314 | doProbe, |
| 315 | autoDetailPath: investigate, |
| 316 | }); |
| 317 | |
| 318 | if (investigate) { |
| 319 | console.log('__INVESTIGATION_JSON__', JSON.stringify(report)); |
| 320 | } |
| 321 | |
| 322 | if (report.list_status !== 200) process.exit(1); |
| 323 | } |
| 324 | |
| 325 | main().catch((e) => { |
| 326 | console.error(e); |
| 327 | process.exit(1); |
| 328 | }); |
File History
2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠
1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6
docs: accept Calendar Events v0 spec with Phase 0 security …
Human
2 days ago