verify-hosted-hub-api.mjs
328 lines 12.9 KB
Raw
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