@cavuno/board 1.29.0 → 1.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.mjs +364 -115
- package/dist/filters.d.mts +1 -1
- package/dist/filters.d.ts +1 -1
- package/dist/format.d.mts +1 -1
- package/dist/format.d.ts +1 -1
- package/dist/index.d.mts +6 -5
- package/dist/index.d.ts +6 -5
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{jobs-DK5mPBgq.d.mts → jobs-lzeoVGY6.d.mts} +2 -0
- package/dist/{jobs-DK5mPBgq.d.ts → jobs-lzeoVGY6.d.ts} +2 -0
- package/dist/{salaries-CXt6Vkrp.d.ts → salaries-Cy3MhZov.d.ts} +1 -1
- package/dist/{salaries-CrJsaZe6.d.mts → salaries-DpH5CxdM.d.mts} +1 -1
- package/dist/seo.d.mts +2 -2
- package/dist/seo.d.ts +2 -2
- package/dist/server.d.mts +2 -2
- package/dist/server.d.ts +2 -2
- package/dist/sitemap.d.mts +2 -2
- package/dist/sitemap.d.ts +2 -2
- package/package.json +1 -1
- package/skills/cavuno-board-smoke-test/SKILL.md +14 -0
- package/skills/manifest.json +1 -1
package/dist/bin.mjs
CHANGED
|
@@ -98,20 +98,8 @@ function summarize(results) {
|
|
|
98
98
|
return { exitCode: failed.length > 0 ? 1 : 0, passed, failed, skipped };
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
// src/doctor/
|
|
102
|
-
import { existsSync, readFileSync as readFileSync2 } from "fs";
|
|
103
|
-
import { join } from "path";
|
|
101
|
+
// src/doctor/probe.ts
|
|
104
102
|
var FETCH_TIMEOUT_MS = 1e4;
|
|
105
|
-
var READ = {
|
|
106
|
-
home: record("read.home", 2),
|
|
107
|
-
jobs: record("read.jobs", 2),
|
|
108
|
-
jsonld: record("read.jsonld", 2),
|
|
109
|
-
sitemap: record("read.sitemap", 2),
|
|
110
|
-
robots: record("read.robots", 2)
|
|
111
|
-
};
|
|
112
|
-
var STATIC_API = record("static.api", 1);
|
|
113
|
-
var STATIC_BOARD = record("static.board", 1);
|
|
114
|
-
var STATIC_SKILLS = record("static.skills", 1);
|
|
115
103
|
async function probe(fetchImpl, url) {
|
|
116
104
|
try {
|
|
117
105
|
const response = await fetchImpl(url, {
|
|
@@ -130,6 +118,316 @@ async function probe(fetchImpl, url) {
|
|
|
130
118
|
};
|
|
131
119
|
}
|
|
132
120
|
}
|
|
121
|
+
|
|
122
|
+
// src/doctor/read.ts
|
|
123
|
+
var READ = {
|
|
124
|
+
home: record("read.home", 2),
|
|
125
|
+
jobs: record("read.jobs", 2),
|
|
126
|
+
jsonld: record("read.jsonld", 2),
|
|
127
|
+
sitemap: record("read.sitemap", 2),
|
|
128
|
+
robots: record("read.robots", 2)
|
|
129
|
+
};
|
|
130
|
+
function skipReadProbes(reason) {
|
|
131
|
+
return Object.values(READ).map((make) => make("skip", reason));
|
|
132
|
+
}
|
|
133
|
+
async function probeJobsAndJsonLd(fetchImpl, base) {
|
|
134
|
+
const jobs = await probe(fetchImpl, `${base}/jobs`);
|
|
135
|
+
const jobLink = jobs.ok ? extractJobDetailLink(jobs.body) : null;
|
|
136
|
+
const jobsResult = jobs.ok && jobLink ? READ.jobs("pass", `listing renders with job detail links (${jobLink})`) : READ.jobs(
|
|
137
|
+
"fail",
|
|
138
|
+
jobs.ok ? "listing renders but contains no /companies/{c}/jobs/{s} detail links" : `listing HTTP ${jobs.status}`
|
|
139
|
+
);
|
|
140
|
+
if (!jobLink) {
|
|
141
|
+
return [
|
|
142
|
+
jobsResult,
|
|
143
|
+
READ.jsonld(
|
|
144
|
+
"skip",
|
|
145
|
+
jobs.ok ? "not probed \u2014 the listing rendered no job detail link (see read.jobs failure)" : "not probed \u2014 the /jobs listing itself failed (see read.jobs failure)"
|
|
146
|
+
)
|
|
147
|
+
];
|
|
148
|
+
}
|
|
149
|
+
const detail = await probe(fetchImpl, `${base}${jobLink}`);
|
|
150
|
+
const posting = detail.ok ? extractJobPostingJsonLd(detail.body) : null;
|
|
151
|
+
return [
|
|
152
|
+
jobsResult,
|
|
153
|
+
posting ? READ.jsonld(
|
|
154
|
+
"pass",
|
|
155
|
+
`JobPosting JSON-LD present (${String(posting.title ?? "")})`
|
|
156
|
+
) : READ.jsonld(
|
|
157
|
+
"fail",
|
|
158
|
+
detail.ok ? "job detail page has no parseable JobPosting JSON-LD" : `job detail HTTP ${detail.status}`
|
|
159
|
+
)
|
|
160
|
+
];
|
|
161
|
+
}
|
|
162
|
+
function onFrontend(base, locUrl) {
|
|
163
|
+
try {
|
|
164
|
+
const parsed = new URL(locUrl);
|
|
165
|
+
return `${base}${parsed.pathname}${parsed.search}`;
|
|
166
|
+
} catch {
|
|
167
|
+
return locUrl;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async function probeSitemap(fetchImpl, base) {
|
|
171
|
+
const sitemap = await probe(fetchImpl, `${base}/sitemap.xml`);
|
|
172
|
+
if (!sitemap.ok) {
|
|
173
|
+
return READ.sitemap("fail", `sitemap.xml HTTP ${sitemap.status}`);
|
|
174
|
+
}
|
|
175
|
+
const doc = parseSitemap(sitemap.body);
|
|
176
|
+
if (!doc || doc.urls.length === 0) {
|
|
177
|
+
return READ.sitemap(
|
|
178
|
+
"fail",
|
|
179
|
+
doc ? "sitemap.xml has no <loc> entries" : "sitemap.xml is not a sitemap"
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
let urls = doc.urls;
|
|
183
|
+
if (doc.kind === "index") {
|
|
184
|
+
const child = await probe(fetchImpl, onFrontend(base, urls[0]));
|
|
185
|
+
const childDoc = child.ok ? parseSitemap(child.body) : null;
|
|
186
|
+
if (!childDoc || childDoc.urls.length === 0) {
|
|
187
|
+
return READ.sitemap(
|
|
188
|
+
"fail",
|
|
189
|
+
`child sitemap ${urls[0]} ${child.ok ? "has no <loc> entries" : `HTTP ${child.status}`}`
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
urls = childDoc.urls;
|
|
193
|
+
}
|
|
194
|
+
const sampleUrl = onFrontend(base, urls[0]);
|
|
195
|
+
const sample = await probe(fetchImpl, sampleUrl);
|
|
196
|
+
return sample.ok ? READ.sitemap("pass", `${urls.length} entries; sample page resolves`) : READ.sitemap("fail", `sitemap page ${sampleUrl} \u2192 HTTP ${sample.status}`);
|
|
197
|
+
}
|
|
198
|
+
async function runReadProbes(fetchImpl, frontendUrl) {
|
|
199
|
+
const base = frontendUrl.replace(/\/$/, "");
|
|
200
|
+
const [home, jobsAndJsonLd, sitemap, robots] = await Promise.all([
|
|
201
|
+
probe(fetchImpl, base),
|
|
202
|
+
probeJobsAndJsonLd(fetchImpl, base),
|
|
203
|
+
probeSitemap(fetchImpl, base),
|
|
204
|
+
probe(fetchImpl, `${base}/robots.txt`)
|
|
205
|
+
]);
|
|
206
|
+
return [
|
|
207
|
+
home.ok && /<(html|body|div|main)[\s>]/i.test(home.body) ? READ.home("pass", "home renders") : READ.home(
|
|
208
|
+
"fail",
|
|
209
|
+
home.ok ? "home returned 200 but no HTML document \u2014 captive portal or empty shell?" : `home HTTP ${home.status}`
|
|
210
|
+
),
|
|
211
|
+
...jobsAndJsonLd,
|
|
212
|
+
sitemap,
|
|
213
|
+
robots.ok && /user-agent\s*:/i.test(robots.body) ? READ.robots("pass", "present") : READ.robots(
|
|
214
|
+
"fail",
|
|
215
|
+
robots.ok ? "robots.txt returned 200 but has no User-agent directive" : `robots.txt HTTP ${robots.status}`
|
|
216
|
+
)
|
|
217
|
+
];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// src/doctor/writes.ts
|
|
221
|
+
var WRITE = {
|
|
222
|
+
board: record("write.board", 3),
|
|
223
|
+
register: record("write.register", 3),
|
|
224
|
+
login: record("write.login", 3),
|
|
225
|
+
email: record("write.email", 3),
|
|
226
|
+
apply: record("write.apply", 3),
|
|
227
|
+
alert: record("write.alert", 3)
|
|
228
|
+
};
|
|
229
|
+
var DEPENDENT_CHECKS = [
|
|
230
|
+
WRITE.register,
|
|
231
|
+
WRITE.login,
|
|
232
|
+
WRITE.email,
|
|
233
|
+
WRITE.apply,
|
|
234
|
+
WRITE.alert
|
|
235
|
+
];
|
|
236
|
+
function skipWriteProbes(reason) {
|
|
237
|
+
return Object.values(WRITE).map((make) => make("skip", reason));
|
|
238
|
+
}
|
|
239
|
+
var FETCH_TIMEOUT_MS2 = 1e4;
|
|
240
|
+
var RESEND_API_BASE = "https://api.resend.com";
|
|
241
|
+
async function request(fetchImpl, url, init) {
|
|
242
|
+
try {
|
|
243
|
+
const response = await fetchImpl(url, {
|
|
244
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS2),
|
|
245
|
+
...init
|
|
246
|
+
});
|
|
247
|
+
let json = null;
|
|
248
|
+
try {
|
|
249
|
+
json = await response.json();
|
|
250
|
+
} catch {
|
|
251
|
+
json = null;
|
|
252
|
+
}
|
|
253
|
+
return { ok: response.ok, status: response.status, json };
|
|
254
|
+
} catch (error) {
|
|
255
|
+
return {
|
|
256
|
+
ok: false,
|
|
257
|
+
status: 0,
|
|
258
|
+
json: { error: error instanceof Error ? error.message : String(error) }
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function post(fetchImpl, url, body, bearer) {
|
|
263
|
+
return request(fetchImpl, url, {
|
|
264
|
+
method: "POST",
|
|
265
|
+
headers: {
|
|
266
|
+
"content-type": "application/json",
|
|
267
|
+
accept: "application/json",
|
|
268
|
+
...bearer ? { authorization: `Bearer ${bearer}` } : {}
|
|
269
|
+
},
|
|
270
|
+
body: JSON.stringify(body)
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
274
|
+
async function observeVerificationEmail(fetchImpl, apiKey, recipient, pollMs, timeoutMs) {
|
|
275
|
+
const deadline = Date.now() + timeoutMs;
|
|
276
|
+
const subjectMarker = `[To: ${recipient}]`;
|
|
277
|
+
do {
|
|
278
|
+
const list = await request(
|
|
279
|
+
fetchImpl,
|
|
280
|
+
`${RESEND_API_BASE}/emails?limit=20`,
|
|
281
|
+
{
|
|
282
|
+
headers: { authorization: `Bearer ${apiKey}` }
|
|
283
|
+
}
|
|
284
|
+
);
|
|
285
|
+
if (!list.ok) {
|
|
286
|
+
return WRITE.email(
|
|
287
|
+
"fail",
|
|
288
|
+
`Resend list returned HTTP ${list.status} \u2014 wrong RESEND_API_KEY?`
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
const items = list.json?.data ?? [];
|
|
292
|
+
const match = items.find(
|
|
293
|
+
(item) => item.subject?.includes(subjectMarker) || item.to?.includes(recipient)
|
|
294
|
+
);
|
|
295
|
+
if (match) {
|
|
296
|
+
return WRITE.email(
|
|
297
|
+
"pass",
|
|
298
|
+
`verification send observed in Resend for ${recipient}`
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
await sleep(pollMs);
|
|
302
|
+
} while (Date.now() < deadline);
|
|
303
|
+
return WRITE.email(
|
|
304
|
+
"fail",
|
|
305
|
+
`no verification send for ${recipient} appeared in Resend within ${timeoutMs}ms`
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
async function runWriteProbes(options) {
|
|
309
|
+
const { fetchImpl } = options;
|
|
310
|
+
const origin = new URL(options.env.apiUrl).origin;
|
|
311
|
+
const base = `${origin}/v1/boards/${encodeURIComponent(options.env.boardKey)}`;
|
|
312
|
+
const nonce = options.nonce ?? `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
313
|
+
const email = `doctor+${nonce}@cavuno.com`;
|
|
314
|
+
const password = `doctor-probe-${nonce}-A1!`;
|
|
315
|
+
let context = options.boardContext ?? null;
|
|
316
|
+
if (context === null) {
|
|
317
|
+
const r = await request(fetchImpl, base);
|
|
318
|
+
if (!r.ok) {
|
|
319
|
+
return [
|
|
320
|
+
WRITE.board("fail", `board context did not resolve (HTTP ${r.status})`),
|
|
321
|
+
...DEPENDENT_CHECKS.map(
|
|
322
|
+
(make) => make("skip", "not run \u2014 the write gate failed (see write.board)")
|
|
323
|
+
)
|
|
324
|
+
];
|
|
325
|
+
}
|
|
326
|
+
context = r.json ?? {};
|
|
327
|
+
}
|
|
328
|
+
if (context.sandbox !== true) {
|
|
329
|
+
return [
|
|
330
|
+
WRITE.board(
|
|
331
|
+
"fail",
|
|
332
|
+
"refusing write probes: this board is not the platform sandbox (`sandbox` is not true) \u2014 point PUBLIC_CAVUNO_BOARD at the sandbox pk_"
|
|
333
|
+
),
|
|
334
|
+
...DEPENDENT_CHECKS.map(
|
|
335
|
+
(make) => make("skip", "not run \u2014 the write gate failed (see write.board)")
|
|
336
|
+
)
|
|
337
|
+
];
|
|
338
|
+
}
|
|
339
|
+
const results = [
|
|
340
|
+
WRITE.board("pass", "board is the platform sandbox")
|
|
341
|
+
];
|
|
342
|
+
const registered = await post(fetchImpl, `${base}/auth/register`, {
|
|
343
|
+
role: "candidate",
|
|
344
|
+
method: "emailpass",
|
|
345
|
+
email,
|
|
346
|
+
password,
|
|
347
|
+
displayName: "Doctor Probe"
|
|
348
|
+
});
|
|
349
|
+
const sessionOk = (r) => r.ok && typeof r.json?.accessToken === "string";
|
|
350
|
+
results.push(
|
|
351
|
+
sessionOk(registered) ? WRITE.register("pass", `candidate registered (${email})`) : WRITE.register(
|
|
352
|
+
"fail",
|
|
353
|
+
`register returned HTTP ${registered.status}` + (registered.status === 429 ? " \u2014 rate-limited: register allows 5 signups / 15 min per IP; wait before rerunning" : "")
|
|
354
|
+
)
|
|
355
|
+
);
|
|
356
|
+
let accessToken = null;
|
|
357
|
+
if (sessionOk(registered)) {
|
|
358
|
+
const login = await post(fetchImpl, `${base}/auth/login`, {
|
|
359
|
+
email,
|
|
360
|
+
password
|
|
361
|
+
});
|
|
362
|
+
if (sessionOk(login)) {
|
|
363
|
+
accessToken = login.json.accessToken;
|
|
364
|
+
results.push(WRITE.login("pass", "emailpass login returns a session"));
|
|
365
|
+
} else {
|
|
366
|
+
results.push(WRITE.login("fail", `login returned HTTP ${login.status}`));
|
|
367
|
+
}
|
|
368
|
+
} else {
|
|
369
|
+
results.push(WRITE.login("skip", "not run \u2014 register failed"));
|
|
370
|
+
}
|
|
371
|
+
if (!sessionOk(registered)) {
|
|
372
|
+
results.push(WRITE.email("skip", "not run \u2014 register failed"));
|
|
373
|
+
} else if (!options.resendApiKey) {
|
|
374
|
+
results.push(
|
|
375
|
+
WRITE.email(
|
|
376
|
+
"skip",
|
|
377
|
+
"RESEND_API_KEY not set \u2014 the verification send was NOT verified (platform-operator check)"
|
|
378
|
+
)
|
|
379
|
+
);
|
|
380
|
+
} else {
|
|
381
|
+
results.push(
|
|
382
|
+
await observeVerificationEmail(
|
|
383
|
+
fetchImpl,
|
|
384
|
+
options.resendApiKey,
|
|
385
|
+
email,
|
|
386
|
+
options.emailPollMs ?? 2e3,
|
|
387
|
+
options.emailTimeoutMs ?? 3e4
|
|
388
|
+
)
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
if (!accessToken) {
|
|
392
|
+
results.push(WRITE.apply("skip", "not run \u2014 no session (see above)"));
|
|
393
|
+
} else {
|
|
394
|
+
const listing = await request(fetchImpl, `${base}/jobs?limit=1`);
|
|
395
|
+
const card = listing.json?.data?.[0];
|
|
396
|
+
if (!listing.ok || !card?.slug) {
|
|
397
|
+
results.push(
|
|
398
|
+
WRITE.apply(
|
|
399
|
+
"fail",
|
|
400
|
+
listing.ok ? "the board has no jobs to apply to \u2014 is the sandbox seeded?" : `jobs listing returned HTTP ${listing.status}`
|
|
401
|
+
)
|
|
402
|
+
);
|
|
403
|
+
} else {
|
|
404
|
+
const applied = await post(
|
|
405
|
+
fetchImpl,
|
|
406
|
+
`${base}/jobs/${encodeURIComponent(card.slug)}/apply`,
|
|
407
|
+
{},
|
|
408
|
+
accessToken
|
|
409
|
+
);
|
|
410
|
+
results.push(
|
|
411
|
+
applied.ok && typeof applied.json?.id === "string" ? WRITE.apply("pass", `applied to ${card.slug}`) : WRITE.apply("fail", `apply returned HTTP ${applied.status}`)
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const alert = await post(fetchImpl, `${base}/job-alerts`, {
|
|
416
|
+
email,
|
|
417
|
+
consent: true
|
|
418
|
+
});
|
|
419
|
+
results.push(
|
|
420
|
+
alert.ok ? WRITE.alert("pass", "job-alert subscription accepted") : WRITE.alert("fail", `job-alerts returned HTTP ${alert.status}`)
|
|
421
|
+
);
|
|
422
|
+
return results;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// src/doctor/run.ts
|
|
426
|
+
import { existsSync, readFileSync as readFileSync2 } from "fs";
|
|
427
|
+
import { join } from "path";
|
|
428
|
+
var STATIC_API = record("static.api", 1);
|
|
429
|
+
var STATIC_BOARD = record("static.board", 1);
|
|
430
|
+
var STATIC_SKILLS = record("static.skills", 1);
|
|
133
431
|
async function checkApiReachable(fetchImpl, apiUrl) {
|
|
134
432
|
let origin;
|
|
135
433
|
try {
|
|
@@ -163,21 +461,31 @@ async function checkBoardResolves(fetchImpl, env) {
|
|
|
163
461
|
`${origin}/v1/boards/${encodeURIComponent(env.boardKey)}`
|
|
164
462
|
);
|
|
165
463
|
if (!result.ok) {
|
|
166
|
-
return
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
464
|
+
return {
|
|
465
|
+
result: STATIC_BOARD(
|
|
466
|
+
"fail",
|
|
467
|
+
`board did not resolve for this key (HTTP ${result.status}) \u2014 revoked or wrong key?`
|
|
468
|
+
),
|
|
469
|
+
context: null
|
|
470
|
+
};
|
|
170
471
|
}
|
|
472
|
+
let parsed = null;
|
|
171
473
|
try {
|
|
172
|
-
|
|
474
|
+
parsed = JSON.parse(result.body);
|
|
173
475
|
if (typeof parsed !== "object" || parsed === null) throw new Error("shape");
|
|
174
476
|
} catch {
|
|
175
|
-
return
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
477
|
+
return {
|
|
478
|
+
result: STATIC_BOARD(
|
|
479
|
+
"fail",
|
|
480
|
+
"board endpoint returned 200 but not JSON \u2014 proxy or captive portal in the way?"
|
|
481
|
+
),
|
|
482
|
+
context: null
|
|
483
|
+
};
|
|
179
484
|
}
|
|
180
|
-
return
|
|
485
|
+
return {
|
|
486
|
+
result: STATIC_BOARD("pass", "publishable key resolves the board"),
|
|
487
|
+
context: parsed
|
|
488
|
+
};
|
|
181
489
|
}
|
|
182
490
|
var SKILL_ROOTS = [".claude/skills", ".agents/skills"];
|
|
183
491
|
function checkSkillsFreshness(projectRoot) {
|
|
@@ -218,71 +526,6 @@ function checkSkillsFreshness(projectRoot) {
|
|
|
218
526
|
`stale skills (re-run \`npx @cavuno/board setup\`): ${[...stale].join(", ")}`
|
|
219
527
|
);
|
|
220
528
|
}
|
|
221
|
-
async function probeJobsAndJsonLd(fetchImpl, base) {
|
|
222
|
-
const jobs = await probe(fetchImpl, `${base}/jobs`);
|
|
223
|
-
const jobLink = jobs.ok ? extractJobDetailLink(jobs.body) : null;
|
|
224
|
-
const jobsResult = jobs.ok && jobLink ? READ.jobs("pass", `listing renders with job detail links (${jobLink})`) : READ.jobs(
|
|
225
|
-
"fail",
|
|
226
|
-
jobs.ok ? "listing renders but contains no /companies/{c}/jobs/{s} detail links" : `listing HTTP ${jobs.status}`
|
|
227
|
-
);
|
|
228
|
-
if (!jobLink) {
|
|
229
|
-
return [
|
|
230
|
-
jobsResult,
|
|
231
|
-
READ.jsonld(
|
|
232
|
-
"skip",
|
|
233
|
-
jobs.ok ? "not probed \u2014 the listing rendered no job detail link (see read.jobs failure)" : "not probed \u2014 the /jobs listing itself failed (see read.jobs failure)"
|
|
234
|
-
)
|
|
235
|
-
];
|
|
236
|
-
}
|
|
237
|
-
const detail = await probe(fetchImpl, `${base}${jobLink}`);
|
|
238
|
-
const posting = detail.ok ? extractJobPostingJsonLd(detail.body) : null;
|
|
239
|
-
return [
|
|
240
|
-
jobsResult,
|
|
241
|
-
posting ? READ.jsonld(
|
|
242
|
-
"pass",
|
|
243
|
-
`JobPosting JSON-LD present (${String(posting.title ?? "")})`
|
|
244
|
-
) : READ.jsonld(
|
|
245
|
-
"fail",
|
|
246
|
-
detail.ok ? "job detail page has no parseable JobPosting JSON-LD" : `job detail HTTP ${detail.status}`
|
|
247
|
-
)
|
|
248
|
-
];
|
|
249
|
-
}
|
|
250
|
-
function onFrontend(base, locUrl) {
|
|
251
|
-
try {
|
|
252
|
-
const parsed = new URL(locUrl);
|
|
253
|
-
return `${base}${parsed.pathname}${parsed.search}`;
|
|
254
|
-
} catch {
|
|
255
|
-
return locUrl;
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
async function probeSitemap(fetchImpl, base) {
|
|
259
|
-
const sitemap = await probe(fetchImpl, `${base}/sitemap.xml`);
|
|
260
|
-
if (!sitemap.ok) {
|
|
261
|
-
return READ.sitemap("fail", `sitemap.xml HTTP ${sitemap.status}`);
|
|
262
|
-
}
|
|
263
|
-
const doc = parseSitemap(sitemap.body);
|
|
264
|
-
if (!doc || doc.urls.length === 0) {
|
|
265
|
-
return READ.sitemap(
|
|
266
|
-
"fail",
|
|
267
|
-
doc ? "sitemap.xml has no <loc> entries" : "sitemap.xml is not a sitemap"
|
|
268
|
-
);
|
|
269
|
-
}
|
|
270
|
-
let urls = doc.urls;
|
|
271
|
-
if (doc.kind === "index") {
|
|
272
|
-
const child = await probe(fetchImpl, onFrontend(base, urls[0]));
|
|
273
|
-
const childDoc = child.ok ? parseSitemap(child.body) : null;
|
|
274
|
-
if (!childDoc || childDoc.urls.length === 0) {
|
|
275
|
-
return READ.sitemap(
|
|
276
|
-
"fail",
|
|
277
|
-
`child sitemap ${urls[0]} ${child.ok ? "has no <loc> entries" : `HTTP ${child.status}`}`
|
|
278
|
-
);
|
|
279
|
-
}
|
|
280
|
-
urls = childDoc.urls;
|
|
281
|
-
}
|
|
282
|
-
const sampleUrl = onFrontend(base, urls[0]);
|
|
283
|
-
const sample = await probe(fetchImpl, sampleUrl);
|
|
284
|
-
return sample.ok ? READ.sitemap("pass", `${urls.length} entries; sample page resolves`) : READ.sitemap("fail", `sitemap page ${sampleUrl} \u2192 HTTP ${sample.status}`);
|
|
285
|
-
}
|
|
286
529
|
async function runDoctor(options) {
|
|
287
530
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
288
531
|
const results = [];
|
|
@@ -297,35 +540,37 @@ async function runDoctor(options) {
|
|
|
297
540
|
} else {
|
|
298
541
|
results.push(STATIC_API("skip", "PUBLIC_CAVUNO_API_URL not set"));
|
|
299
542
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
}
|
|
308
|
-
return { results, summary: summarize(results) };
|
|
543
|
+
let boardContext = null;
|
|
544
|
+
if (envOk) {
|
|
545
|
+
const resolved = await checkBoardResolves(fetchImpl, options.env);
|
|
546
|
+
boardContext = resolved.context;
|
|
547
|
+
results.push(resolved.result);
|
|
548
|
+
} else {
|
|
549
|
+
results.push(STATIC_BOARD("skip", "env checks failed \u2014 fix them first"));
|
|
309
550
|
}
|
|
310
|
-
|
|
311
|
-
const [home, jobsAndJsonLd, sitemap, robots] = await Promise.all([
|
|
312
|
-
probe(fetchImpl, base),
|
|
313
|
-
probeJobsAndJsonLd(fetchImpl, base),
|
|
314
|
-
probeSitemap(fetchImpl, base),
|
|
315
|
-
probe(fetchImpl, `${base}/robots.txt`)
|
|
316
|
-
]);
|
|
551
|
+
results.push(checkSkillsFreshness(options.projectRoot ?? process.cwd()));
|
|
317
552
|
results.push(
|
|
318
|
-
|
|
319
|
-
"fail",
|
|
320
|
-
home.ok ? "home returned 200 but no HTML document \u2014 captive portal or empty shell?" : `home HTTP ${home.status}`
|
|
321
|
-
),
|
|
322
|
-
...jobsAndJsonLd,
|
|
323
|
-
sitemap,
|
|
324
|
-
robots.ok && /user-agent\s*:/i.test(robots.body) ? READ.robots("pass", "present") : READ.robots(
|
|
325
|
-
"fail",
|
|
326
|
-
robots.ok ? "robots.txt returned 200 but has no User-agent directive" : `robots.txt HTTP ${robots.status}`
|
|
327
|
-
)
|
|
553
|
+
...options.frontendUrl ? await runReadProbes(fetchImpl, options.frontendUrl) : skipReadProbes("no --frontend <url> given")
|
|
328
554
|
);
|
|
555
|
+
if (!options.sandbox) {
|
|
556
|
+
results.push(
|
|
557
|
+
...skipWriteProbes(
|
|
558
|
+
"write probes not enabled \u2014 run with --sandbox against the platform sandbox"
|
|
559
|
+
)
|
|
560
|
+
);
|
|
561
|
+
} else if (!envOk) {
|
|
562
|
+
results.push(...skipWriteProbes("env checks failed \u2014 fix them first"));
|
|
563
|
+
} else {
|
|
564
|
+
results.push(
|
|
565
|
+
...await runWriteProbes({
|
|
566
|
+
env: options.env,
|
|
567
|
+
fetchImpl,
|
|
568
|
+
resendApiKey: options.resendApiKey,
|
|
569
|
+
nonce: options.writeProbeNonce,
|
|
570
|
+
boardContext
|
|
571
|
+
})
|
|
572
|
+
);
|
|
573
|
+
}
|
|
329
574
|
return { results, summary: summarize(results) };
|
|
330
575
|
}
|
|
331
576
|
|
|
@@ -375,7 +620,11 @@ async function doctor(argv) {
|
|
|
375
620
|
apiUrl: process.env.PUBLIC_CAVUNO_API_URL,
|
|
376
621
|
boardKey: process.env.PUBLIC_CAVUNO_BOARD
|
|
377
622
|
},
|
|
378
|
-
frontendUrl
|
|
623
|
+
frontendUrl,
|
|
624
|
+
// Tier-3 write probes: opt-in via --sandbox, and refused server-side
|
|
625
|
+
// unless the resolved board is platform-marked test (writes.ts gate).
|
|
626
|
+
sandbox: argv.includes("--sandbox"),
|
|
627
|
+
resendApiKey: process.env.RESEND_API_KEY
|
|
379
628
|
});
|
|
380
629
|
console.log("\n@cavuno/board doctor");
|
|
381
630
|
for (const result of results) {
|
package/dist/filters.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { J as JobSort, E as EmploymentType, R as RemoteOption, S as Seniority } from './jobs-
|
|
1
|
+
import { J as JobSort, E as EmploymentType, R as RemoteOption, S as Seniority } from './jobs-lzeoVGY6.mjs';
|
|
2
2
|
|
|
3
3
|
declare const REMOTE_OPTIONS: readonly RemoteOption[];
|
|
4
4
|
/**
|
package/dist/filters.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { J as JobSort, E as EmploymentType, R as RemoteOption, S as Seniority } from './jobs-
|
|
1
|
+
import { J as JobSort, E as EmploymentType, R as RemoteOption, S as Seniority } from './jobs-lzeoVGY6.js';
|
|
2
2
|
|
|
3
3
|
declare const REMOTE_OPTIONS: readonly RemoteOption[];
|
|
4
4
|
/**
|
package/dist/format.d.mts
CHANGED
package/dist/format.d.ts
CHANGED
package/dist/index.d.mts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { b as Schemas, R as RemoteOption, E as EmploymentType, S as Seniority, L as ListEnvelope, c as components, d as JobsListQuery, e as JobCardListEnvelope, f as JobsSearchBody, g as JobCardSearchEnvelope, h as JobsSimilarQuery, i as SearchEnvelope } from './jobs-
|
|
2
|
-
export { C as CustomFieldValue, j as CustomFieldValues, k as EducationRequirement, l as JobCompany, J as JobSort, O as OfficeLocation, P as PublicJob, a as PublicJobCard, m as RelatedSearch, n as RemotePermit, o as RemoteTimezone, p as StorefrontPagination } from './jobs-
|
|
3
|
-
import { c as CompaniesListQuery, d as CompanyListEnvelope, e as CompaniesSearchBody, f as CompanyJobsListQuery, g as CompanySimilarQuery, h as CompanyMarketsListQuery, i as SalaryDetailQuery, j as BlogPostsListQuery, k as BlogSimilarQuery, l as BlogSearchBody } from './salaries-
|
|
4
|
-
export { B as BlogAuthorEmbed, m as BlogTagEmbed, C as CompanyCategorySalary, n as CompanyMarket, o as CompanyMarketRef, b as CompanySalary, p as CustomFieldDefinition, q as CustomFieldOption, r as CustomFieldType, L as LocationSalaryDetail, s as LocationSkillsIndex, t as LocationTitlesIndex, u as PublicBlogAdjacentPosts, v as PublicBlogAuthor, w as PublicBlogPost, a as PublicBlogPostSummary, x as PublicBlogTag, P as PublicBoard, y as PublicBoardAnalytics, z as PublicBoardFeatures, A as PublicBoardTheme, D as PublicCompany, E as PublicCompanyDetail, F as SalaryCompany, G as SalaryLocation, H as SalarySkill, I as SalaryTitle, J as SkillLocationSalary, K as SkillLocationsIndex, S as SkillSalaryDetail, M as TitleLocationSalary, N as TitleLocationsIndex, T as TitleSalaryDetail } from './salaries-
|
|
1
|
+
import { b as Schemas, R as RemoteOption, E as EmploymentType, S as Seniority, L as ListEnvelope, c as components, d as JobsListQuery, e as JobCardListEnvelope, f as JobsSearchBody, g as JobCardSearchEnvelope, h as JobsSimilarQuery, i as SearchEnvelope } from './jobs-lzeoVGY6.mjs';
|
|
2
|
+
export { C as CustomFieldValue, j as CustomFieldValues, k as EducationRequirement, l as JobCompany, J as JobSort, O as OfficeLocation, P as PublicJob, a as PublicJobCard, m as RelatedSearch, n as RemotePermit, o as RemoteTimezone, p as StorefrontPagination } from './jobs-lzeoVGY6.mjs';
|
|
3
|
+
import { c as CompaniesListQuery, d as CompanyListEnvelope, e as CompaniesSearchBody, f as CompanyJobsListQuery, g as CompanySimilarQuery, h as CompanyMarketsListQuery, i as SalaryDetailQuery, j as BlogPostsListQuery, k as BlogSimilarQuery, l as BlogSearchBody } from './salaries-DpH5CxdM.mjs';
|
|
4
|
+
export { B as BlogAuthorEmbed, m as BlogTagEmbed, C as CompanyCategorySalary, n as CompanyMarket, o as CompanyMarketRef, b as CompanySalary, p as CustomFieldDefinition, q as CustomFieldOption, r as CustomFieldType, L as LocationSalaryDetail, s as LocationSkillsIndex, t as LocationTitlesIndex, u as PublicBlogAdjacentPosts, v as PublicBlogAuthor, w as PublicBlogPost, a as PublicBlogPostSummary, x as PublicBlogTag, P as PublicBoard, y as PublicBoardAnalytics, z as PublicBoardFeatures, A as PublicBoardTheme, D as PublicCompany, E as PublicCompanyDetail, F as SalaryCompany, G as SalaryLocation, H as SalarySkill, I as SalaryTitle, J as SkillLocationSalary, K as SkillLocationsIndex, S as SkillSalaryDetail, M as TitleLocationSalary, N as TitleLocationsIndex, T as TitleSalaryDetail } from './salaries-DpH5CxdM.mjs';
|
|
5
5
|
|
|
6
6
|
type Awaitable<T> = T | Promise<T>;
|
|
7
7
|
/**
|
|
@@ -203,7 +203,7 @@ declare function paginate<Q extends Record<string, unknown>, P extends PageShape
|
|
|
203
203
|
* constant because the package is platform-neutral and cannot read
|
|
204
204
|
* package.json at runtime.
|
|
205
205
|
*/
|
|
206
|
-
declare const SDK_VERSION = "1.
|
|
206
|
+
declare const SDK_VERSION = "1.30.0";
|
|
207
207
|
|
|
208
208
|
type SavedJob = Schemas['SavedJob'];
|
|
209
209
|
type SavedJobsListQuery = {
|
|
@@ -654,6 +654,7 @@ declare function createBoardClient(options: CreateBoardClientOptions): {
|
|
|
654
654
|
logoUrl: string | null;
|
|
655
655
|
primaryDomain: string | null;
|
|
656
656
|
showCavunoBranding: boolean;
|
|
657
|
+
sandbox: boolean;
|
|
657
658
|
features: {
|
|
658
659
|
jobAlerts: boolean;
|
|
659
660
|
candidates: boolean;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { b as Schemas, R as RemoteOption, E as EmploymentType, S as Seniority, L as ListEnvelope, c as components, d as JobsListQuery, e as JobCardListEnvelope, f as JobsSearchBody, g as JobCardSearchEnvelope, h as JobsSimilarQuery, i as SearchEnvelope } from './jobs-
|
|
2
|
-
export { C as CustomFieldValue, j as CustomFieldValues, k as EducationRequirement, l as JobCompany, J as JobSort, O as OfficeLocation, P as PublicJob, a as PublicJobCard, m as RelatedSearch, n as RemotePermit, o as RemoteTimezone, p as StorefrontPagination } from './jobs-
|
|
3
|
-
import { c as CompaniesListQuery, d as CompanyListEnvelope, e as CompaniesSearchBody, f as CompanyJobsListQuery, g as CompanySimilarQuery, h as CompanyMarketsListQuery, i as SalaryDetailQuery, j as BlogPostsListQuery, k as BlogSimilarQuery, l as BlogSearchBody } from './salaries-
|
|
4
|
-
export { B as BlogAuthorEmbed, m as BlogTagEmbed, C as CompanyCategorySalary, n as CompanyMarket, o as CompanyMarketRef, b as CompanySalary, p as CustomFieldDefinition, q as CustomFieldOption, r as CustomFieldType, L as LocationSalaryDetail, s as LocationSkillsIndex, t as LocationTitlesIndex, u as PublicBlogAdjacentPosts, v as PublicBlogAuthor, w as PublicBlogPost, a as PublicBlogPostSummary, x as PublicBlogTag, P as PublicBoard, y as PublicBoardAnalytics, z as PublicBoardFeatures, A as PublicBoardTheme, D as PublicCompany, E as PublicCompanyDetail, F as SalaryCompany, G as SalaryLocation, H as SalarySkill, I as SalaryTitle, J as SkillLocationSalary, K as SkillLocationsIndex, S as SkillSalaryDetail, M as TitleLocationSalary, N as TitleLocationsIndex, T as TitleSalaryDetail } from './salaries-
|
|
1
|
+
import { b as Schemas, R as RemoteOption, E as EmploymentType, S as Seniority, L as ListEnvelope, c as components, d as JobsListQuery, e as JobCardListEnvelope, f as JobsSearchBody, g as JobCardSearchEnvelope, h as JobsSimilarQuery, i as SearchEnvelope } from './jobs-lzeoVGY6.js';
|
|
2
|
+
export { C as CustomFieldValue, j as CustomFieldValues, k as EducationRequirement, l as JobCompany, J as JobSort, O as OfficeLocation, P as PublicJob, a as PublicJobCard, m as RelatedSearch, n as RemotePermit, o as RemoteTimezone, p as StorefrontPagination } from './jobs-lzeoVGY6.js';
|
|
3
|
+
import { c as CompaniesListQuery, d as CompanyListEnvelope, e as CompaniesSearchBody, f as CompanyJobsListQuery, g as CompanySimilarQuery, h as CompanyMarketsListQuery, i as SalaryDetailQuery, j as BlogPostsListQuery, k as BlogSimilarQuery, l as BlogSearchBody } from './salaries-Cy3MhZov.js';
|
|
4
|
+
export { B as BlogAuthorEmbed, m as BlogTagEmbed, C as CompanyCategorySalary, n as CompanyMarket, o as CompanyMarketRef, b as CompanySalary, p as CustomFieldDefinition, q as CustomFieldOption, r as CustomFieldType, L as LocationSalaryDetail, s as LocationSkillsIndex, t as LocationTitlesIndex, u as PublicBlogAdjacentPosts, v as PublicBlogAuthor, w as PublicBlogPost, a as PublicBlogPostSummary, x as PublicBlogTag, P as PublicBoard, y as PublicBoardAnalytics, z as PublicBoardFeatures, A as PublicBoardTheme, D as PublicCompany, E as PublicCompanyDetail, F as SalaryCompany, G as SalaryLocation, H as SalarySkill, I as SalaryTitle, J as SkillLocationSalary, K as SkillLocationsIndex, S as SkillSalaryDetail, M as TitleLocationSalary, N as TitleLocationsIndex, T as TitleSalaryDetail } from './salaries-Cy3MhZov.js';
|
|
5
5
|
|
|
6
6
|
type Awaitable<T> = T | Promise<T>;
|
|
7
7
|
/**
|
|
@@ -203,7 +203,7 @@ declare function paginate<Q extends Record<string, unknown>, P extends PageShape
|
|
|
203
203
|
* constant because the package is platform-neutral and cannot read
|
|
204
204
|
* package.json at runtime.
|
|
205
205
|
*/
|
|
206
|
-
declare const SDK_VERSION = "1.
|
|
206
|
+
declare const SDK_VERSION = "1.30.0";
|
|
207
207
|
|
|
208
208
|
type SavedJob = Schemas['SavedJob'];
|
|
209
209
|
type SavedJobsListQuery = {
|
|
@@ -654,6 +654,7 @@ declare function createBoardClient(options: CreateBoardClientOptions): {
|
|
|
654
654
|
logoUrl: string | null;
|
|
655
655
|
primaryDomain: string | null;
|
|
656
656
|
showCavunoBranding: boolean;
|
|
657
|
+
sandbox: boolean;
|
|
657
658
|
features: {
|
|
658
659
|
jobAlerts: boolean;
|
|
659
660
|
candidates: boolean;
|
package/dist/index.js
CHANGED
package/dist/index.mjs
CHANGED
|
@@ -2382,6 +2382,8 @@ interface components {
|
|
|
2382
2382
|
primaryDomain: string | null;
|
|
2383
2383
|
/** @description Whitelabel toggle (default `true`). Render the "Powered by Cavuno" badge unless `false`. */
|
|
2384
2384
|
showCavunoBranding: boolean;
|
|
2385
|
+
/** @description True ONLY for the platform sandbox fixture tenant — the safe target for write probes (doctor tier 3); data is reset nightly. Every real tenant board is `false`. */
|
|
2386
|
+
sandbox: boolean;
|
|
2385
2387
|
features: {
|
|
2386
2388
|
jobAlerts: boolean;
|
|
2387
2389
|
candidates: boolean;
|
|
@@ -2382,6 +2382,8 @@ interface components {
|
|
|
2382
2382
|
primaryDomain: string | null;
|
|
2383
2383
|
/** @description Whitelabel toggle (default `true`). Render the "Powered by Cavuno" badge unless `false`. */
|
|
2384
2384
|
showCavunoBranding: boolean;
|
|
2385
|
+
/** @description True ONLY for the platform sandbox fixture tenant — the safe target for write probes (doctor tier 3); data is reset nightly. Every real tenant board is `false`. */
|
|
2386
|
+
sandbox: boolean;
|
|
2385
2387
|
features: {
|
|
2386
2388
|
jobAlerts: boolean;
|
|
2387
2389
|
candidates: boolean;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { b as Schemas, L as ListEnvelope, m as RelatedSearch } from './jobs-
|
|
1
|
+
import { b as Schemas, L as ListEnvelope, m as RelatedSearch } from './jobs-lzeoVGY6.js';
|
|
2
2
|
|
|
3
3
|
type PublicBoard = Schemas['PublicBoardContext'];
|
|
4
4
|
type PublicBoardFeatures = PublicBoard['features'];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { b as Schemas, L as ListEnvelope, m as RelatedSearch } from './jobs-
|
|
1
|
+
import { b as Schemas, L as ListEnvelope, m as RelatedSearch } from './jobs-lzeoVGY6.mjs';
|
|
2
2
|
|
|
3
3
|
type PublicBoard = Schemas['PublicBoardContext'];
|
|
4
4
|
type PublicBoardFeatures = PublicBoard['features'];
|
package/dist/seo.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { P as PublicBoard, a as PublicBlogPostSummary, B as BlogAuthorEmbed, C as CompanyCategorySalary, b as CompanySalary, L as LocationSalaryDetail, S as SkillSalaryDetail, T as TitleSalaryDetail } from './salaries-
|
|
2
|
-
import { P as PublicJob } from './jobs-
|
|
1
|
+
import { P as PublicBoard, a as PublicBlogPostSummary, B as BlogAuthorEmbed, C as CompanyCategorySalary, b as CompanySalary, L as LocationSalaryDetail, S as SkillSalaryDetail, T as TitleSalaryDetail } from './salaries-DpH5CxdM.mjs';
|
|
2
|
+
import { P as PublicJob } from './jobs-lzeoVGY6.mjs';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Google for Jobs `JobPosting` structured data on the `@cavuno/board` wire
|
package/dist/seo.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { P as PublicBoard, a as PublicBlogPostSummary, B as BlogAuthorEmbed, C as CompanyCategorySalary, b as CompanySalary, L as LocationSalaryDetail, S as SkillSalaryDetail, T as TitleSalaryDetail } from './salaries-
|
|
2
|
-
import { P as PublicJob } from './jobs-
|
|
1
|
+
import { P as PublicBoard, a as PublicBlogPostSummary, B as BlogAuthorEmbed, C as CompanyCategorySalary, b as CompanySalary, L as LocationSalaryDetail, S as SkillSalaryDetail, T as TitleSalaryDetail } from './salaries-Cy3MhZov.js';
|
|
2
|
+
import { P as PublicJob } from './jobs-lzeoVGY6.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Google for Jobs `JobPosting` structured data on the `@cavuno/board` wire
|
package/dist/server.d.mts
CHANGED
package/dist/server.d.ts
CHANGED
package/dist/sitemap.d.mts
CHANGED
package/dist/sitemap.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -29,6 +29,20 @@ named in its summary and still needs the manual checks below. Use the
|
|
|
29
29
|
rest of this skill for the behavioral checks doctor does not automate
|
|
30
30
|
(auth flows, gating semantics, production build).
|
|
31
31
|
|
|
32
|
+
Add `--sandbox` to also run the write probes (register → login → apply →
|
|
33
|
+
alert-signup). They are refused unless the board the `pk_` resolves is
|
|
34
|
+
THE platform sandbox (`sandbox: true` in the board context — set only by
|
|
35
|
+
the platform's sandbox seed and stripped from any client settings write)
|
|
36
|
+
— never point them at a real tenant. The
|
|
37
|
+
email-delivery check additionally needs `RESEND_API_KEY` (a platform
|
|
38
|
+
operator credential) and SKIPs loudly without it.
|
|
39
|
+
|
|
40
|
+
Rate limits are real on the sandbox: the auth endpoints share a
|
|
41
|
+
10-requests/min bucket and register allows 5 signups per 15 minutes per
|
|
42
|
+
IP, and one probe run consumes 4 requests + 1 signup. Rapid reruns will
|
|
43
|
+
start failing with HTTP 429 — that's the limiter working, not a broken
|
|
44
|
+
board; wait a few minutes.
|
|
45
|
+
|
|
32
46
|
## 1 — Probe the API directly (before blaming app code)
|
|
33
47
|
|
|
34
48
|
Use the real env values the app reads (`PUBLIC_CAVUNO_API_URL`,
|
package/skills/manifest.json
CHANGED