icoa-cli 2.19.356 → 2.19.357

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.
Files changed (75) hide show
  1. package/dist/commands/ai4ctf.js +1 -1
  2. package/dist/commands/ctf.js +787 -1
  3. package/dist/commands/ctf4ai-demo.js +1 -1
  4. package/dist/commands/ctf4vla.js +1 -1
  5. package/dist/commands/demo2.js +1502 -1
  6. package/dist/commands/exam.js +1 -1
  7. package/dist/commands/files.js +59 -1
  8. package/dist/commands/lang.js +202 -1
  9. package/dist/commands/log.js +171 -1
  10. package/dist/commands/shell.js +151 -1
  11. package/dist/commands/sim.js +389 -1
  12. package/dist/index.js +355 -1
  13. package/dist/lib/access.js +184 -1
  14. package/dist/lib/aienv.js +205 -1
  15. package/dist/lib/arena-submit.js +21 -1
  16. package/dist/lib/banner.js +31 -1
  17. package/dist/lib/budget.js +6 -1
  18. package/dist/lib/challenge-dir.js +16 -1
  19. package/dist/lib/colors.js +17 -1
  20. package/dist/lib/comms.js +212 -1
  21. package/dist/lib/config.js +93 -1
  22. package/dist/lib/countdown.js +43 -1
  23. package/dist/lib/country-lang.js +39 -1
  24. package/dist/lib/ctfd-client.js +417 -1
  25. package/dist/lib/demo-exam.js +478 -1
  26. package/dist/lib/demo-flags.js +27 -1
  27. package/dist/lib/demo-stats.js +62 -1
  28. package/dist/lib/demo2-progress.js +102 -1
  29. package/dist/lib/docker-probe.d.ts +45 -0
  30. package/dist/lib/docker-probe.js +118 -0
  31. package/dist/lib/editor-spawn.js +53 -1
  32. package/dist/lib/exam-client.js +54 -1
  33. package/dist/lib/exam-sandbox.js +201 -1
  34. package/dist/lib/exam-setup.js +36 -1
  35. package/dist/lib/exam-state.js +273 -1
  36. package/dist/lib/gemini.js +247 -1
  37. package/dist/lib/i18n.js +302 -1
  38. package/dist/lib/integrity-snapshot.js +88 -1
  39. package/dist/lib/interactive-spawn.js +55 -1
  40. package/dist/lib/ipynb-input.js +65 -1
  41. package/dist/lib/kernel-protocol.js +88 -1
  42. package/dist/lib/kernel.js +146 -2
  43. package/dist/lib/learn-curricula.js +309 -1
  44. package/dist/lib/learn-i18n.js +184 -1
  45. package/dist/lib/learn-input.js +101 -1
  46. package/dist/lib/learn-render.js +863 -1
  47. package/dist/lib/learn-state.js +103 -1
  48. package/dist/lib/log-sync.js +155 -1
  49. package/dist/lib/logger.js +49 -1
  50. package/dist/lib/main-rl.js +7 -1
  51. package/dist/lib/menu-nav.js +105 -1
  52. package/dist/lib/notebook-doc.js +137 -1
  53. package/dist/lib/open-file.js +55 -1
  54. package/dist/lib/paper-upgrade.js +119 -1
  55. package/dist/lib/platform.js +99 -1
  56. package/dist/lib/render-card.js +112 -1
  57. package/dist/lib/repl-asker.js +67 -1
  58. package/dist/lib/sample-runner.js +227 -1
  59. package/dist/lib/sandbox.d.ts +7 -1
  60. package/dist/lib/sandbox.js +144 -1
  61. package/dist/lib/shell-split.js +69 -1
  62. package/dist/lib/sim-cooldown.js +75 -1
  63. package/dist/lib/theme.js +119 -1
  64. package/dist/lib/token-format.js +74 -1
  65. package/dist/lib/tool-man.js +418 -1
  66. package/dist/lib/toolset-hash.js +48 -1
  67. package/dist/lib/translation.js +80 -1
  68. package/dist/lib/translations-fetcher.js +95 -1
  69. package/dist/lib/ui.js +99 -1
  70. package/dist/lib/update-check.js +114 -1
  71. package/dist/lib/version.js +24 -1
  72. package/dist/postinstall.js +48 -1
  73. package/dist/repl.js +2391 -1
  74. package/dist/types/index.js +63 -1
  75. package/package.json +1 -1
@@ -1 +1,43 @@
1
- import chalk from"chalk";export async function fetchWithCountdown(t,e={}){const o=e.from??15,r=e.waitText??" one more moment...";let n=!1;t.then(()=>{n=!0},()=>{n=!0});for(let t=o;t>=1&&!n;t--)process.stdout.write("\r"+chalk.bold.cyan(` ${t}`)+chalk.gray(" ...").padEnd(40," ")),await new Promise(t=>setTimeout(t,1e3));return process.stdout.write("\r"+" ".repeat(40)+"\r"),n||process.stdout.write(chalk.gray(r)+"\n"),t}
1
+ /**
2
+ * 5-4-3-2-1 countdown wrapper for in-flight fetches.
3
+ *
4
+ * Wraps a promise with a visible countdown so the user has something to
5
+ * watch instead of a spinner. If the promise resolves DURING countdown,
6
+ * the remaining seconds are skipped and the line is cleared. If the
7
+ * promise hasn't resolved by the time countdown ends, a "wait" message
8
+ * stays on screen until it finishes.
9
+ *
10
+ * Deliberately avoids mentioning servers, network, or any architectural
11
+ * detail — students should experience the operation as one unbroken
12
+ * "ICOA does it" magic moment.
13
+ */
14
+ import chalk from 'chalk';
15
+ export async function fetchWithCountdown(fetchPromise, opts = {}) {
16
+ // 15s default — covers cold-start renders (model lazy-load + 90-frame
17
+ // baseline) without the "one more moment..." fallback firing too often.
18
+ // Warm-cache calls finish in 4-7s and the countdown exits early —
19
+ // no UX penalty.
20
+ const startSeconds = opts.from ?? 15;
21
+ const waitText = opts.waitText ?? ' one more moment...';
22
+ let resolved = false;
23
+ fetchPromise.then(() => {
24
+ resolved = true;
25
+ }, () => {
26
+ resolved = true;
27
+ });
28
+ for (let i = startSeconds; i >= 1; i--) {
29
+ if (resolved)
30
+ break;
31
+ // \r overwrites the same line. Pad with spaces to clear any prior
32
+ // longer text (e.g. on countdown from 10 → 9, the second char needs clearing).
33
+ process.stdout.write('\r' + chalk.bold.cyan(` ${i}`) + chalk.gray(' ...').padEnd(40, ' '));
34
+ await new Promise((r) => setTimeout(r, 1000));
35
+ }
36
+ // Clear the countdown line
37
+ process.stdout.write('\r' + ' '.repeat(40) + '\r');
38
+ if (!resolved) {
39
+ // Stay-and-wait message — never mention WHY it's slow
40
+ process.stdout.write(chalk.gray(waitText) + '\n');
41
+ }
42
+ return fetchPromise;
43
+ }
@@ -1 +1,39 @@
1
- export const COUNTRY_LANG={UA:"uk",PE:"es",CN:"zh",AU:"en",JP:"ja",KR:"ko",BR:"pt",SA:"ar",FR:"fr",DE:"de",IN:"hi",ID:"id",TH:"th",VN:"vi",TR:"tr",RU:"ru",EG:"ar",HT:"ht",PH:"en",MY:"en",MM:"en",SG:"en",ZA:"en",KE:"sw",TZ:"sw",MO:"zh",UZ:"uz",GH:"en",LA:"lo",BD:"bn",BI:"fr",US:"en",UK:"en",NZ:"en",LK:"si",BW:"en",VE:"es"};
1
+ export const COUNTRY_LANG = {
2
+ UA: 'uk',
3
+ PE: 'es',
4
+ CN: 'zh',
5
+ AU: 'en',
6
+ JP: 'ja',
7
+ KR: 'ko',
8
+ BR: 'pt',
9
+ SA: 'ar',
10
+ FR: 'fr',
11
+ DE: 'de',
12
+ IN: 'hi',
13
+ ID: 'id',
14
+ TH: 'th',
15
+ VN: 'vi',
16
+ TR: 'tr',
17
+ RU: 'ru',
18
+ EG: 'ar',
19
+ HT: 'ht',
20
+ PH: 'en',
21
+ MY: 'en',
22
+ MM: 'en',
23
+ SG: 'en',
24
+ ZA: 'en',
25
+ KE: 'sw',
26
+ TZ: 'sw',
27
+ MO: 'zh',
28
+ UZ: 'uz',
29
+ GH: 'en',
30
+ LA: 'lo',
31
+ BD: 'bn',
32
+ BI: 'fr',
33
+ US: 'en',
34
+ UK: 'en',
35
+ NZ: 'en',
36
+ LK: 'si',
37
+ BW: 'en',
38
+ VE: 'es',
39
+ };
@@ -1 +1,417 @@
1
- import{createWriteStream as t,mkdirSync as e}from"node:fs";import{join as s}from"node:path";import{pipeline as n}from"node:stream/promises";import{Readable as o}from"node:stream";export class CTFdClient{baseUrl;token;sessionCookie;csrfNonce;constructor(t,e,s,n){this.baseUrl=t.replace(/\/+$/,""),this.token=e,this.sessionCookie=s||"",this.csrfNonce=n||""}getAuthHeaders(){if(this.token)return{Authorization:`Token ${this.token}`};if(this.sessionCookie){const t={Cookie:this.sessionCookie};return this.csrfNonce&&(t["CSRF-Token"]=this.csrfNonce),t}return{}}async fetchCsrfNonce(){if(this.csrfNonce)return this.csrfNonce;try{const t=await fetch(this.baseUrl,{headers:this.sessionCookie?{Cookie:this.sessionCookie}:{}}),e=(await t.text()).match(/csrfNonce['":\s]+['"]([^'"]+)['"]/);if(e)return this.csrfNonce=e[1],this.csrfNonce}catch{}return""}async request(t,e,s){this.sessionCookie&&!this.csrfNonce&&await this.fetchCsrfNonce();const n=`${this.baseUrl}/api/v1${e}`,o={...this.getAuthHeaders()};(void 0!==s||this.token)&&(o["Content-Type"]="application/json");const i=await fetch(n,{method:t,headers:o,body:void 0!==s?JSON.stringify(s):void 0,redirect:"manual"});if(i.status>=300&&i.status<400)throw new Error("Authentication failed: token invalid or expired. Re-run `join <url>` to sign in again.");if(!i.ok){const t=await i.text().catch(()=>"Unknown error");if(t.trimStart().startsWith("<")){if(/banned/i.test(t))throw new Error("Account banned: this account has been banned from the competition. Contact the organizer if you believe this is an error.");throw new Error("Access denied (403). Either your session expired — re-run `join <url>` — or this account is banned / not enabled for the competition (try a different account, or contact the organizer).")}throw new Error(`CTFd API error (${i.status}): ${t}`)}if(!(i.headers.get("content-type")||"").includes("application/json")&&(await i.text().catch(()=>"")).trimStart().startsWith("<"))throw new Error("Authentication failed: server returned a login page instead of data. Re-run `join <url>`.");const r=await i.json();if(!1===r.success)throw new Error(`CTFd error: ${r.errors?.join(", ")||"Unknown error"}`);return r.data}async testConnection(){try{return await this.request("GET","/users/me")}catch(t){if(this.sessionCookie&&(t.message?.includes("403")||t.message?.includes("Authentication failed")))return this.testConnectionViaProfile();throw t}}async testConnectionViaProfile(){const t=await fetch(`${this.baseUrl}/settings`,{headers:{Cookie:this.sessionCookie}});if(!t.ok){if(403===t.status)throw new Error("ACCOUNT_BLOCKED: signed in, but this account is banned / disabled / not enabled for the competition. Use a different account, or contact the organizer.");throw new Error("Session expired or invalid.")}const e=await t.text(),s=e.match(/name="name"[^>]*value="([^"]+)"/)||e.match(/<input[^>]*id="name"[^>]*value="([^"]+)"/),n=e.match(/user_id['":\s]+(\d+)/)||e.match(/userId['":\s]+(\d+)/);if(!s&&!n)throw new Error("Authentication failed: session is not signed in. Re-run `join <url>` and check your username/password.");const o=s?.[1]||"User",i=n?parseInt(n[1],10):0,r=e.match(/csrfNonce['":\s]+['"]([^'"]+)['"]/);return r&&(this.csrfNonce=r[1]),{id:i,name:o,score:0,team_id:0,country:""}}async getChallenges(){return this.request("GET","/challenges")}async getChallenge(t){return this.request("GET",`/challenges/${t}`)}async submitFlag(t,e){this.sessionCookie&&!this.csrfNonce&&await this.fetchCsrfNonce();const s=await fetch(`${this.baseUrl}/api/v1/challenges/attempt`,{method:"POST",headers:{...this.getAuthHeaders(),"Content-Type":"application/json"},body:JSON.stringify({challenge_id:t,submission:e})});if(!s.ok){const t=await s.text().catch(()=>"Unknown error");if(t.trimStart().startsWith("<")){if(/banned/i.test(t))throw new Error("Account banned: this account has been banned from the competition. Your submission was not recorded. Contact the organizer if you believe this is an error.");throw new Error("Authentication failed: not signed in (server returned the login page). Re-run `join <url>` and check your username/password.")}throw new Error(`CTFd API error (${s.status}): ${t}`)}return(await s.json()).data}async getScoreboard(){return this.request("GET","/scoreboard")}async getTeam(){return this.request("GET","/teams/me")}async getCompetitionMeta(){const t=await fetch(this.baseUrl),e=await t.text(),s=e.match(/'start'\s*:\s*(\d+)/),n=e.match(/'end'\s*:\s*(\d+)/),o=e.match(/'userMode'\s*:\s*"([^"]+)"/),i=e.match(/'csrfNonce'\s*:\s*"([^"]+)"/);return{start:s?parseInt(s[1],10):null,end:n?parseInt(n[1],10):null,userMode:o?.[1]||"users",csrfNonce:i?.[1]||""}}async getChallengeFiles(t){return(await this.getChallenge(t)).files||[]}async downloadFile(i,r){e(r,{recursive:!0});const a=i.startsWith("http")?i:`${this.baseUrl}/${i.replace(/^\//,"")}`,c=await fetch(a,{headers:this.getAuthHeaders(),redirect:"follow"});if(!c.ok||!c.body)throw new Error(`Failed to download: ${a}`);const h=(i.split("/").pop()||"file").split("?")[0],d=s(r,h),u=t(d);return await n(o.fromWeb(c.body),u),d}async getTokenViaIcoaApi(t,e){const s=JSON.stringify({name:t,password:e}),n={"Content-Type":"application/json"};try{const t=await fetch(`${this.baseUrl}/api/icoa/token`,{method:"POST",headers:n,body:s,signal:AbortSignal.timeout(5e3)});if(t.ok){const e=await t.json();if(e.success&&e.data?.token)return e.data.token}}catch{}try{const t=await fetch(`${this.baseUrl}:9090/api/icoa/token`,{method:"POST",headers:n,body:s,signal:AbortSignal.timeout(5e3)});if(t.ok){const e=await t.json();if(e.success&&e.data?.token)return e.data.token}}catch{}return null}async tokenAuthenticates(t){try{const e=await fetch(`${this.baseUrl}/api/v1/users/me`,{headers:{Authorization:`Token ${t}`,"Content-Type":"application/json"},redirect:"manual",signal:AbortSignal.timeout(5e3)});if(200!==e.status)return!1;if(!(e.headers.get("content-type")||"").includes("application/json"))return!1;const s=await e.json();return!0===s?.success}catch{return!1}}async loginWithCredentials(t,e){const s=await this.getTokenViaIcoaApi(t,e);if(s&&await this.tokenAuthenticates(s))return{token:s,session:"",csrf:""};const n=await fetch(`${this.baseUrl}/login`),o=await n.text(),i=o.match(/name="nonce"[^>]*value="([^"]+)"/)||o.match(/value="([^"]+)"[^>]*name="nonce"/)||o.match(/id="nonce"[^>]*value="([^"]+)"/)||o.match(/csrfNonce['":\s]+['"]([^'"]+)['"]/),r=i?.[1]||"";if(!r)throw new Error("Could not extract CSRF nonce from login page.");const a=n.headers.getSetCookie?.()||[],c=a.map(t=>t.split(";")[0]).join("; "),h=await fetch(`${this.baseUrl}/login`,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded",Cookie:c},body:new URLSearchParams({name:t,password:e,nonce:r,_submit:"Submit"}),redirect:"manual"}),d=h.headers.getSetCookie?.()||[],u=new Map;for(const t of[...a,...d]){const e=t.split(";")[0],s=e.indexOf("=");s>0&&u.set(e.slice(0,s),e.slice(s+1))}const l=[...u.entries()].map(([t,e])=>`${t}=${e}`).join("; "),f=h.headers.get("location")||"";if(!(h.status>=300&&h.status<400)||f.includes("/login"))throw new Error("Invalid username or password.");try{const t=await fetch(`${this.baseUrl}/settings`,{headers:{Cookie:l}}),e=(await t.text()).match(/csrfNonce['":\s]+['"]([^'"]+)['"]/),s=e?.[1]||r,n=await fetch(`${this.baseUrl}/api/v1/tokens`,{method:"POST",headers:{"Content-Type":"application/json",Cookie:l,"CSRF-Token":s},body:JSON.stringify({expiration:"2026-12-31T23:59:59+00:00"})});if(n.ok){const t=await n.json();if(t.success&&t.data?.value)return{token:t.data.value,session:"",csrf:""}}}catch{}let w="";try{const t=await fetch(`${this.baseUrl}/challenges`,{headers:{Cookie:l}}),e=(await t.text()).match(/csrfNonce['":\s]+['"]([^'"]+)['"]/);e&&(w=e[1])}catch{}return{token:"",session:l,csrf:w}}}
1
+ import { createWriteStream, mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { pipeline } from 'node:stream/promises';
4
+ import { Readable } from 'node:stream';
5
+ export class CTFdClient {
6
+ baseUrl;
7
+ token;
8
+ sessionCookie;
9
+ csrfNonce;
10
+ constructor(baseUrl, token, sessionCookie, csrfNonce) {
11
+ this.baseUrl = baseUrl.replace(/\/+$/, '');
12
+ this.token = token;
13
+ this.sessionCookie = sessionCookie || '';
14
+ this.csrfNonce = csrfNonce || '';
15
+ }
16
+ getAuthHeaders() {
17
+ if (this.token) {
18
+ return { Authorization: `Token ${this.token}` };
19
+ }
20
+ if (this.sessionCookie) {
21
+ const headers = { Cookie: this.sessionCookie };
22
+ if (this.csrfNonce) {
23
+ headers['CSRF-Token'] = this.csrfNonce;
24
+ }
25
+ return headers;
26
+ }
27
+ return {};
28
+ }
29
+ async fetchCsrfNonce() {
30
+ if (this.csrfNonce)
31
+ return this.csrfNonce;
32
+ try {
33
+ const res = await fetch(this.baseUrl, {
34
+ headers: this.sessionCookie ? { Cookie: this.sessionCookie } : {},
35
+ });
36
+ const html = await res.text();
37
+ const match = html.match(/csrfNonce['":\s]+['"]([^'"]+)['"]/);
38
+ if (match) {
39
+ this.csrfNonce = match[1];
40
+ return this.csrfNonce;
41
+ }
42
+ }
43
+ catch {
44
+ /* ignore */
45
+ }
46
+ return '';
47
+ }
48
+ async request(method, path, body) {
49
+ // Auto-fetch CSRF nonce for session-based auth
50
+ if (this.sessionCookie && !this.csrfNonce) {
51
+ await this.fetchCsrfNonce();
52
+ }
53
+ const url = `${this.baseUrl}/api/v1${path}`;
54
+ const headers = {
55
+ ...this.getAuthHeaders(),
56
+ };
57
+ // Content-Type policy is auth-mode-specific:
58
+ // · Session-cookie mode: CTFd returns 403 for a bodyless GET that carries
59
+ // `Content-Type: application/json` (it tries to parse an empty body), so
60
+ // only set it when there's an actual body — otherwise every CTFd-account
61
+ // `join` breaks (challenges/open/scoreboard).
62
+ // · Token mode: the OPPOSITE — CTFd's before_request only honors the
63
+ // `Authorization: Token …` header when `request.is_json`, so a bodyless
64
+ // GET MUST carry `Content-Type: application/json` or CTFd ignores the
65
+ // token and 302-redirects to /login. (Token auth takes priority in
66
+ // getAuthHeaders, so key off this.token here.)
67
+ if (body !== undefined || this.token) {
68
+ headers['Content-Type'] = 'application/json';
69
+ }
70
+ const res = await fetch(url, {
71
+ method,
72
+ headers,
73
+ body: body !== undefined ? JSON.stringify(body) : undefined,
74
+ // Do NOT auto-follow redirects. When the token/session is invalid or
75
+ // expired, CTFd (account visibility = private) 302-redirects API calls to
76
+ // the HTML /login page instead of returning 403 JSON. fetch's default
77
+ // redirect:'follow' lands on that 200 HTML page, and res.json() then throws
78
+ // the cryptic `Unexpected token '<', "<!DOCTYPE "...`. Catch the 3xx here.
79
+ redirect: 'manual',
80
+ });
81
+ // A redirect (almost always → /login) means we were not authenticated.
82
+ if (res.status >= 300 && res.status < 400) {
83
+ throw new Error('Authentication failed: token invalid or expired. Re-run `join <url>` to sign in again.');
84
+ }
85
+ if (!res.ok) {
86
+ const text = await res.text().catch(() => 'Unknown error');
87
+ // CTFd (account visibility = private) answers an unauthenticated API call
88
+ // with the HTML login page at 401/403 instead of a JSON error. Dumping that
89
+ // raw `<!DOCTYPE html>...` page tells the user nothing — surface the same
90
+ // actionable message as the redirect / 2xx-HTML guards. Keep the phrase
91
+ // "Authentication failed" so testConnection's session-mode fallback (which
92
+ // matches on it) still fires. The previous code path leaked the whole login
93
+ // page on every API call when a join produced an unauthenticated session.
94
+ if (text.trimStart().startsWith('<')) {
95
+ // CTFd returns an HTML page (not JSON) for two distinct states. The ban
96
+ // page carries a recognizable banner ("You have been banned from this
97
+ // CTF") — when we see it, say so definitively. Otherwise we can't tell a
98
+ // banned account from an expired session apart from the body alone (a
99
+ // banned account's /login still 302-redirects, so login "succeeds" but
100
+ // every API call 403s with HTML), so name both real fixes. (A1)
101
+ if (/banned/i.test(text)) {
102
+ throw new Error('Account banned: this account has been banned from the competition. Contact the organizer if you believe this is an error.');
103
+ }
104
+ throw new Error('Access denied (403). Either your session expired — re-run `join <url>` — or this account is banned / not enabled for the competition (try a different account, or contact the organizer).');
105
+ }
106
+ throw new Error(`CTFd API error (${res.status}): ${text}`);
107
+ }
108
+ // Guard against an HTML body slipping through on a 2xx (proxy/maintenance
109
+ // page). Without this, res.json() emits the same opaque parse error.
110
+ const contentType = res.headers.get('content-type') || '';
111
+ if (!contentType.includes('application/json')) {
112
+ const text = await res.text().catch(() => '');
113
+ if (text.trimStart().startsWith('<')) {
114
+ throw new Error('Authentication failed: server returned a login page instead of data. Re-run `join <url>`.');
115
+ }
116
+ }
117
+ const json = (await res.json());
118
+ if (json.success === false) {
119
+ throw new Error(`CTFd error: ${json.errors?.join(', ') || 'Unknown error'}`);
120
+ }
121
+ return json.data;
122
+ }
123
+ async testConnection() {
124
+ try {
125
+ return await this.request('GET', '/users/me');
126
+ }
127
+ catch (err) {
128
+ // Session mode fallback: the API may 403 or (with redirect:'manual')
129
+ // surface as an "Authentication failed" redirect. Either way, try scraping
130
+ // the profile page before giving up on a session login.
131
+ if (this.sessionCookie && (err.message?.includes('403') || err.message?.includes('Authentication failed'))) {
132
+ return this.testConnectionViaProfile();
133
+ }
134
+ throw err;
135
+ }
136
+ }
137
+ async testConnectionViaProfile() {
138
+ const res = await fetch(`${this.baseUrl}/settings`, {
139
+ headers: { Cookie: this.sessionCookie },
140
+ });
141
+ if (!res.ok) {
142
+ // A banned / disabled account authenticates (login 302-redirects) but every
143
+ // authenticated page — including /settings — comes back 403. Surface that as
144
+ // a distinct, actionable state so `join` doesn't mislabel it "limited API".
145
+ if (res.status === 403) {
146
+ throw new Error('ACCOUNT_BLOCKED: signed in, but this account is banned / disabled / not enabled for the competition. Use a different account, or contact the organizer.');
147
+ }
148
+ throw new Error('Session expired or invalid.');
149
+ }
150
+ const html = await res.text();
151
+ // Extract user name from settings page
152
+ const nameMatch = html.match(/name="name"[^>]*value="([^"]+)"/) || html.match(/<input[^>]*id="name"[^>]*value="([^"]+)"/);
153
+ // Extract user ID from page
154
+ const idMatch = html.match(/user_id['":\s]+(\d+)/) || html.match(/userId['":\s]+(\d+)/);
155
+ // When the session is NOT authenticated, /settings redirects to the login
156
+ // page (HTTP 200) where neither the name input nor a user id exists. The old
157
+ // code then returned a fabricated {id:0, name:'User'}, so `join` reported a
158
+ // bogus "Connected" success and the failure only surfaced later on the first
159
+ // real API call. Treat "no name AND no id" as the login page → fail at join
160
+ // time with an actionable message.
161
+ if (!nameMatch && !idMatch) {
162
+ throw new Error('Authentication failed: session is not signed in. Re-run `join <url>` and check your username/password.');
163
+ }
164
+ const name = nameMatch?.[1] || 'User';
165
+ const id = idMatch ? parseInt(idMatch[1], 10) : 0;
166
+ // Update CSRF nonce from settings page
167
+ const csrfMatch = html.match(/csrfNonce['":\s]+['"]([^'"]+)['"]/);
168
+ if (csrfMatch)
169
+ this.csrfNonce = csrfMatch[1];
170
+ return { id, name, score: 0, team_id: 0, country: '' };
171
+ }
172
+ async getChallenges() {
173
+ return this.request('GET', '/challenges');
174
+ }
175
+ async getChallenge(id) {
176
+ return this.request('GET', `/challenges/${id}`);
177
+ }
178
+ async submitFlag(challengeId, submission) {
179
+ // Session-mode POSTs require a session-bound CSRF token. join() persists the
180
+ // cookie but not the nonce, so a client rebuilt from config has none → CTFd
181
+ // rejects the submission with 403. Fetch it on demand (same as request()).
182
+ if (this.sessionCookie && !this.csrfNonce) {
183
+ await this.fetchCsrfNonce();
184
+ }
185
+ const res = await fetch(`${this.baseUrl}/api/v1/challenges/attempt`, {
186
+ method: 'POST',
187
+ headers: {
188
+ ...this.getAuthHeaders(),
189
+ 'Content-Type': 'application/json',
190
+ },
191
+ body: JSON.stringify({ challenge_id: challengeId, submission }),
192
+ });
193
+ if (!res.ok) {
194
+ const text = await res.text().catch(() => 'Unknown error');
195
+ if (text.trimStart().startsWith('<')) {
196
+ // Distinguish a ban from a dead session — same as request(). (A1)
197
+ if (/banned/i.test(text)) {
198
+ throw new Error('Account banned: this account has been banned from the competition. Your submission was not recorded. Contact the organizer if you believe this is an error.');
199
+ }
200
+ throw new Error('Authentication failed: not signed in (server returned the login page). Re-run `join <url>` and check your username/password.');
201
+ }
202
+ throw new Error(`CTFd API error (${res.status}): ${text}`);
203
+ }
204
+ const json = (await res.json());
205
+ return json.data;
206
+ }
207
+ async getScoreboard() {
208
+ return this.request('GET', '/scoreboard');
209
+ }
210
+ async getTeam() {
211
+ return this.request('GET', '/teams/me');
212
+ }
213
+ async getCompetitionMeta() {
214
+ const res = await fetch(this.baseUrl);
215
+ const html = await res.text();
216
+ const startMatch = html.match(/'start'\s*:\s*(\d+)/);
217
+ const endMatch = html.match(/'end'\s*:\s*(\d+)/);
218
+ const modeMatch = html.match(/'userMode'\s*:\s*"([^"]+)"/);
219
+ const csrfMatch = html.match(/'csrfNonce'\s*:\s*"([^"]+)"/);
220
+ return {
221
+ start: startMatch ? parseInt(startMatch[1], 10) : null,
222
+ end: endMatch ? parseInt(endMatch[1], 10) : null,
223
+ userMode: modeMatch?.[1] || 'users',
224
+ csrfNonce: csrfMatch?.[1] || '',
225
+ };
226
+ }
227
+ async getChallengeFiles(id) {
228
+ const challenge = await this.getChallenge(id);
229
+ return challenge.files || [];
230
+ }
231
+ async downloadFile(filePath, destDir) {
232
+ mkdirSync(destDir, { recursive: true });
233
+ const url = filePath.startsWith('http') ? filePath : `${this.baseUrl}/${filePath.replace(/^\//, '')}`;
234
+ const res = await fetch(url, {
235
+ headers: this.getAuthHeaders(),
236
+ redirect: 'follow',
237
+ });
238
+ if (!res.ok || !res.body) {
239
+ throw new Error(`Failed to download: ${url}`);
240
+ }
241
+ const rawName = filePath.split('/').pop() || 'file';
242
+ const fileName = rawName.split('?')[0];
243
+ const destPath = join(destDir, fileName);
244
+ const fileStream = createWriteStream(destPath);
245
+ await pipeline(Readable.fromWeb(res.body), fileStream);
246
+ return destPath;
247
+ }
248
+ async getTokenViaIcoaApi(username, password) {
249
+ const body = JSON.stringify({ name: username, password });
250
+ const headers = { 'Content-Type': 'application/json' };
251
+ // Try via nginx proxy first (same origin, no port)
252
+ try {
253
+ const res = await fetch(`${this.baseUrl}/api/icoa/token`, {
254
+ method: 'POST',
255
+ headers,
256
+ body,
257
+ signal: AbortSignal.timeout(5000),
258
+ });
259
+ if (res.ok) {
260
+ const json = (await res.json());
261
+ if (json.success && json.data?.token)
262
+ return json.data.token;
263
+ }
264
+ }
265
+ catch {
266
+ /* proxy not available */
267
+ }
268
+ // Fallback: direct port 9090
269
+ try {
270
+ const res = await fetch(`${this.baseUrl}:9090/api/icoa/token`, {
271
+ method: 'POST',
272
+ headers,
273
+ body,
274
+ signal: AbortSignal.timeout(5000),
275
+ });
276
+ if (res.ok) {
277
+ const json = (await res.json());
278
+ if (json.success && json.data?.token)
279
+ return json.data.token;
280
+ }
281
+ }
282
+ catch {
283
+ /* API not available */
284
+ }
285
+ return null;
286
+ }
287
+ // Verify a token actually authenticates against CTFd. The ICOA token API
288
+ // (handle_token) returns whatever token row exists for a user WITHOUT checking
289
+ // expiration, so a stale token comes back looking valid. Probe /users/me with
290
+ // it (manual redirect → a 302 to /login = not authenticated) before trusting
291
+ // it; otherwise the caller would short-circuit a working session login.
292
+ //
293
+ // CTFd's before_request token loader only honors an `Authorization: Token …`
294
+ // header when `request.is_json` is true — i.e. the request MUST carry
295
+ // `Content-Type: application/json`. Without it CTFd silently ignores the token
296
+ // and 302-redirects this @authed_only endpoint to /login, so a perfectly valid
297
+ // token looks unauthenticated and every join fell through to session mode.
298
+ async tokenAuthenticates(token) {
299
+ try {
300
+ const res = await fetch(`${this.baseUrl}/api/v1/users/me`, {
301
+ headers: { Authorization: `Token ${token}`, 'Content-Type': 'application/json' },
302
+ redirect: 'manual',
303
+ signal: AbortSignal.timeout(5000),
304
+ });
305
+ if (res.status !== 200)
306
+ return false;
307
+ if (!(res.headers.get('content-type') || '').includes('application/json'))
308
+ return false;
309
+ const json = (await res.json());
310
+ return json?.success === true;
311
+ }
312
+ catch {
313
+ return false;
314
+ }
315
+ }
316
+ async loginWithCredentials(username, password) {
317
+ // Try ICOA token API first (fastest path). Only trust the token if it really
318
+ // authenticates — a stale token row (e.g. account ppdd) would otherwise lock
319
+ // the user out, since we'd skip the standard session login that still works.
320
+ const icoaToken = await this.getTokenViaIcoaApi(username, password);
321
+ if (icoaToken && (await this.tokenAuthenticates(icoaToken))) {
322
+ return { token: icoaToken, session: '', csrf: '' };
323
+ }
324
+ // icoaToken missing or stale → fall through to the standard CTFd /login flow,
325
+ // which re-establishes a working session and mints a fresh API token.
326
+ // Fallback: standard CTFd login flow
327
+ // Step 1: GET /login to get nonce
328
+ const loginPageRes = await fetch(`${this.baseUrl}/login`);
329
+ const loginHtml = await loginPageRes.text();
330
+ const nonceMatch = loginHtml.match(/name="nonce"[^>]*value="([^"]+)"/) ||
331
+ loginHtml.match(/value="([^"]+)"[^>]*name="nonce"/) ||
332
+ loginHtml.match(/id="nonce"[^>]*value="([^"]+)"/) ||
333
+ loginHtml.match(/csrfNonce['":\s]+['"]([^'"]+)['"]/);
334
+ const nonce = nonceMatch?.[1] || '';
335
+ if (!nonce) {
336
+ throw new Error('Could not extract CSRF nonce from login page.');
337
+ }
338
+ const cookies = loginPageRes.headers.getSetCookie?.() || [];
339
+ const cookieStr = cookies.map((c) => c.split(';')[0]).join('; ');
340
+ // Step 2: POST /login with credentials
341
+ const loginRes = await fetch(`${this.baseUrl}/login`, {
342
+ method: 'POST',
343
+ headers: {
344
+ 'Content-Type': 'application/x-www-form-urlencoded',
345
+ Cookie: cookieStr,
346
+ },
347
+ body: new URLSearchParams({ name: username, password, nonce, _submit: 'Submit' }),
348
+ redirect: 'manual',
349
+ });
350
+ const loginCookies = loginRes.headers.getSetCookie?.() || [];
351
+ // Dedupe by cookie name, letting the post-login Set-Cookie win. Otherwise
352
+ // we'd send `session=<pre-login>; session=<post-login>` and CTFd honours the
353
+ // first (unauthenticated) one, serving the login page (200 HTML) for every
354
+ // API call → "failed to load challenges" for CTFd-account logins.
355
+ const cookieJar = new Map();
356
+ for (const c of [...cookies, ...loginCookies]) {
357
+ const pair = c.split(';')[0];
358
+ const eq = pair.indexOf('=');
359
+ if (eq > 0)
360
+ cookieJar.set(pair.slice(0, eq), pair.slice(eq + 1));
361
+ }
362
+ const allCookies = [...cookieJar.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
363
+ // A successful CTFd login is a 3xx redirect away from /login (→ /challenges
364
+ // or /). A wrong password re-renders the login page at HTTP 200 with NO
365
+ // redirect ("Your username or password is incorrect"). The old check only
366
+ // caught a /login redirect, so a typo'd password slipped through as a fake
367
+ // success: an unauthenticated session that then failed downstream with the
368
+ // cryptic "Connected (session mode — limited API)" + `<!DOCTYPE` on every
369
+ // API call. Require a real redirect that isn't back to /login.
370
+ const location = loginRes.headers.get('location') || '';
371
+ const redirected = loginRes.status >= 300 && loginRes.status < 400;
372
+ if (!redirected || location.includes('/login')) {
373
+ throw new Error('Invalid username or password.');
374
+ }
375
+ // Step 3: Try to generate API token (some CTFd instances allow it)
376
+ try {
377
+ const settingsRes = await fetch(`${this.baseUrl}/settings`, {
378
+ headers: { Cookie: allCookies },
379
+ });
380
+ const settingsHtml = await settingsRes.text();
381
+ const csrfMatch = settingsHtml.match(/csrfNonce['":\s]+['"]([^'"]+)['"]/);
382
+ const csrfNonce = csrfMatch?.[1] || nonce;
383
+ const tokenRes = await fetch(`${this.baseUrl}/api/v1/tokens`, {
384
+ method: 'POST',
385
+ headers: {
386
+ 'Content-Type': 'application/json',
387
+ Cookie: allCookies,
388
+ 'CSRF-Token': csrfNonce,
389
+ },
390
+ body: JSON.stringify({ expiration: '2026-12-31T23:59:59+00:00' }),
391
+ });
392
+ if (tokenRes.ok) {
393
+ const tokenJson = (await tokenRes.json());
394
+ if (tokenJson.success && tokenJson.data?.value) {
395
+ return { token: tokenJson.data.value, session: '', csrf: '' };
396
+ }
397
+ }
398
+ }
399
+ catch {
400
+ /* Token generation not available, use session */
401
+ }
402
+ // Fallback: use session cookie + CSRF nonce
403
+ // Get CSRF nonce from the main page
404
+ let csrf = '';
405
+ try {
406
+ const mainRes = await fetch(`${this.baseUrl}/challenges`, { headers: { Cookie: allCookies } });
407
+ const mainHtml = await mainRes.text();
408
+ const csrfMatch = mainHtml.match(/csrfNonce['":\s]+['"]([^'"]+)['"]/);
409
+ if (csrfMatch)
410
+ csrf = csrfMatch[1];
411
+ }
412
+ catch {
413
+ /* ignore */
414
+ }
415
+ return { token: '', session: allCookies, csrf };
416
+ }
417
+ }