@certalis/agent-smith 0.1.1 → 0.1.2

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.js CHANGED
@@ -1275,12 +1275,30 @@ function parseNumber(value) {
1275
1275
  const parsed = Number(value);
1276
1276
  return Number.isFinite(parsed) ? parsed : undefined;
1277
1277
  }
1278
+ function normalizeEnrollmentEmail(value) {
1279
+ if (typeof value !== "string")
1280
+ return;
1281
+ const email = value.trim().toLowerCase();
1282
+ return email.length <= 254 && patterns.email.test(email) ? email : undefined;
1283
+ }
1284
+ function sanitizeEnrollmentEmails(value) {
1285
+ if (!Array.isArray(value))
1286
+ return;
1287
+ const emails = value.map(normalizeEnrollmentEmail).filter((email) => email !== undefined);
1288
+ return [...new Set(emails)].slice(0, MAX_ENROLLMENT_EMAILS);
1289
+ }
1278
1290
  function sanitizeConfigData(value) {
1279
1291
  if (!isRecord(value)) {
1280
1292
  return {};
1281
1293
  }
1282
1294
  const sanitized = {};
1283
1295
  for (const [key, entry] of Object.entries(value)) {
1296
+ if (key === "enrollment_emails") {
1297
+ const emails = sanitizeEnrollmentEmails(entry);
1298
+ if (emails)
1299
+ sanitized.enrollment_emails = emails;
1300
+ continue;
1301
+ }
1284
1302
  if (key === "team_routing") {
1285
1303
  const teamRouting = sanitizeTeamRouting(entry);
1286
1304
  if (teamRouting)
@@ -1430,7 +1448,7 @@ function sanitizeTeamRouting(value) {
1430
1448
  }
1431
1449
  return result;
1432
1450
  }
1433
- var log2, PROVIDER_STRATEGIES, BUILTIN_PROVIDER_CONFIGS, BUILTIN_PROVIDER_CONFIG_BY_NAME, PROVIDER_NAME_PATTERN, TEAM_ROUTING_DEFAULTS, Config;
1451
+ var log2, PROVIDER_STRATEGIES, BUILTIN_PROVIDER_CONFIGS, BUILTIN_PROVIDER_CONFIG_BY_NAME, PROVIDER_NAME_PATTERN, TEAM_ROUTING_DEFAULTS, MAX_ENROLLMENT_EMAILS = 200, Config;
1434
1452
  var init_src4 = __esm(() => {
1435
1453
  init_src2();
1436
1454
  init_src3();
@@ -1674,11 +1692,28 @@ var init_src4 = __esm(() => {
1674
1692
  this.persistAccountAllowlist(current.filter((uuid) => uuid !== accountUuid));
1675
1693
  }
1676
1694
  persistAccountAllowlist(accountUuids) {
1677
- const teamRouting = {
1695
+ this.persistStrictly("team_routing", {
1678
1696
  ...this.data.team_routing,
1679
1697
  allowlisted_account_uuids: accountUuids
1680
- };
1681
- const next = { ...this.data, team_routing: teamRouting };
1698
+ });
1699
+ }
1700
+ getEnrollmentEmails() {
1701
+ return [...this.data.enrollment_emails ?? []];
1702
+ }
1703
+ setEnrollmentEmails(emails) {
1704
+ const normalized = emails.map(normalizeEnrollmentEmail);
1705
+ if (normalized.some((email) => email === undefined)) {
1706
+ throw new ValidationError("Invalid enrollment email", "enrollment_emails");
1707
+ }
1708
+ const unique = [...new Set(normalized)];
1709
+ if (unique.length > MAX_ENROLLMENT_EMAILS) {
1710
+ throw new ValidationError(`At most ${MAX_ENROLLMENT_EMAILS} enrollment emails`, "enrollment_emails");
1711
+ }
1712
+ this.persistStrictly("enrollment_emails", unique);
1713
+ return [...unique];
1714
+ }
1715
+ persistStrictly(key, value) {
1716
+ const next = { ...this.data, [key]: value };
1682
1717
  const temporary = `${this.configPath}.${crypto.randomUUID()}.tmp`;
1683
1718
  try {
1684
1719
  writeFileSync(temporary, JSON.stringify(next, null, 2), {
@@ -1689,13 +1724,9 @@ var init_src4 = __esm(() => {
1689
1724
  } finally {
1690
1725
  rmSync(temporary, { force: true });
1691
1726
  }
1692
- const oldValue = this.data.team_routing;
1727
+ const oldValue = this.data[key];
1693
1728
  this.data = next;
1694
- this.emit("change", {
1695
- key: "team_routing",
1696
- oldValue,
1697
- newValue: teamRouting
1698
- });
1729
+ this.emit("change", { key, oldValue, newValue: value });
1699
1730
  }
1700
1731
  getRuntime() {
1701
1732
  const defaults = {
@@ -7014,72 +7045,157 @@ function enrollmentPage() {
7014
7045
  return new Response(`<!doctype html>
7015
7046
  <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
7016
7047
  <title>Connect a Claude subscription</title>
7017
- <style nonce="${nonce}">
7018
- :root{color-scheme:light;font-family:"Avenir Next",Avenir,"Segoe UI",sans-serif;color:#17334a;background:#edf3f7;line-height:1.6}
7019
- body{margin:0;padding:clamp(20px,6vw,64px)}main{max-width:42rem;margin:auto;background:#fff;padding:clamp(24px,5vw,48px);border-top:6px solid #235e82}
7020
- h1{font-size:clamp(1.8rem,5vw,2.5rem);line-height:1.2;margin:0 0 24px}h2{font-size:1.2rem;margin-top:32px}p{max-width:65ch}button,a.button{font:inherit;display:inline-block;border:0;border-radius:6px;background:#235e82;color:#fff;padding:12px 20px;text-decoration:none;cursor:pointer}button:disabled{opacity:.55;cursor:wait}button:focus-visible,a:focus-visible,input:focus-visible{outline:3px solid #b15c00;outline-offset:4px}input[type=password]{box-sizing:border-box;display:block;width:100%;font:inherit;padding:12px;border:1px solid #647686;border-radius:4px;margin:8px 0 16px}label{display:block}#account{font-weight:600;overflow-wrap:anywhere}#status{white-space:pre-line}small{display:block;margin-top:32px;color:#4b6172}[hidden]{display:none!important}
7021
- </style></head><body><main>
7022
- <h1>Connect your Claude subscription</h1>
7023
- <p>This adds your subscription to the shared agents pool. Only connect an account you have permission to share. Your password stays with Claude; the pool stores the authorization needed to run agents.</p>
7024
- <section id="begin"><h2>1. Sign in with Claude</h2><p>You will open Claude in a separate tab. Choose the account you want to share.</p><button id="start" type="button">Connect Claude</button><p><a id="authorize" class="button" target="_blank" rel="noopener noreferrer" hidden>Open Claude sign-in</a></p></section>
7025
- <form id="verify" hidden><h2>2. Check your account</h2><label for="code">Paste the authorization code from Claude</label><input id="code" type="password" autocomplete="off" spellcheck="false" required maxlength="4096"><button type="submit">Check account</button></form>
7026
- <form id="confirm" hidden><h2>3. Add this subscription</h2><p>Claude verified this account:</p><p id="account"></p><label><input id="consent" type="checkbox" required> I authorize the shared agents pool to use this subscription.</label><p><button type="submit">Add subscription to pool</button></p><p>Wrong account? Do not confirm. Close this page and ask the operator for a new link.</p></form>
7027
- <section id="connected" hidden><h2>Subscription connected</h2><p id="connected-account"></p><p>Testing this flow? Disconnect removes this seat's saved credentials and pool membership before letting you sign in again.</p><button id="disconnect" type="button">Disconnect and try again</button><p>Keep this page open to disconnect. This button expires after 24 hours; a page reload or server restart requires operator help. It does not sign you out of Claude's website, cancel your subscription, or stop a request already running.</p></section>
7028
- <p id="status" role="status" aria-live="polite"></p>
7029
- <small>This private link works once. After starting, each step expires in 10 minutes. Do not share this link or paste authorization codes into chat. Reloading this page requires a new invitation.</small>
7030
- <noscript>JavaScript is required for this private sign-in flow.</noscript>
7031
- </main><script nonce="${nonce}">
7048
+ <style nonce="${nonce}">${styles}</style></head><body>
7049
+ <header>Shared agents pool<small>Connect a Claude subscription</small><div class="bar"><span id="progress"></span></div></header>
7050
+ <main>
7051
+ <ol id="stepper" aria-label="Progress">
7052
+ <li data-step="email"><span class="dot">1</span><span class="label">Email</span></li>
7053
+ <li data-step="signin"><span class="dot">2</span><span class="label">Sign in</span></li>
7054
+ <li data-step="code"><span class="dot">3</span><span class="label">Paste code</span></li>
7055
+ <li data-step="confirm"><span class="dot">4</span><span class="label">Confirm</span></li>
7056
+ <li data-step="done"><span class="dot">5</span><span class="label">Done</span></li>
7057
+ </ol>
7058
+ <section class="card" id="step-email" hidden>
7059
+ <div class="avatar">${icon("mail")}</div>
7060
+ <h1 tabindex="-1">Connect your Claude subscription</h1>
7061
+ <p class="lead">This adds your subscription to the shared agents pool. Start with your work email: if it is on this pool's list, the next step lets you sign in with Claude.</p>
7062
+ <form id="request">
7063
+ <p class="notice err" id="email-error" role="alert" hidden></p>
7064
+ <label for="email">Work email<input id="email" type="email" autocomplete="email" spellcheck="false" required maxlength="254"></label>
7065
+ <button class="button" type="submit">Continue</button>
7066
+ </form>
7067
+ </section>
7068
+ <section class="card" id="step-signin" hidden>
7069
+ <p class="notice" id="signin-notice" role="status" hidden></p>
7070
+ <div class="avatar">${icon("key")}</div>
7071
+ <h1 tabindex="-1">Sign in with Claude</h1>
7072
+ <p class="lead" id="signin-for" hidden>Use the Claude account of <strong id="signin-email"></strong>. Only connect an account you have permission to share.</p>
7073
+ <ul class="facts">
7074
+ <li>Claude opens in a new tab. Sign in and pick the account to share.</li>
7075
+ <li>Your password stays with Claude. The pool only keeps the authorization it needs to run agents.</li>
7076
+ <li>Claude then shows a code: you paste it here in the next step.</li>
7077
+ </ul>
7078
+ <button class="button" id="start" type="button">Continue to Claude ${icon("open")}</button>
7079
+ </section>
7080
+ <section class="card" id="step-code" hidden>
7081
+ <div class="avatar">${icon("code")}</div>
7082
+ <h1 tabindex="-1">Paste the code from Claude</h1>
7083
+ <p class="lead" id="code-lead">Claude opened in a new tab. Once you have signed in, it shows an authorization code: copy it and paste it below.</p>
7084
+ <form id="verify">
7085
+ <label for="code">Authorization code<input id="code" type="password" autocomplete="off" spellcheck="false" required maxlength="4096"><span class="hint">The code stays hidden on screen. Never paste it into chat.</span></label>
7086
+ <button class="button" type="submit">Verify account</button>
7087
+ </form>
7088
+ <p class="aside">Closed the Claude tab? <a class="link" id="reopen" target="_blank" rel="noopener noreferrer">Open Claude sign-in again ${icon("open")}</a></p>
7089
+ </section>
7090
+ <section class="card" id="step-confirm" hidden>
7091
+ <div class="avatar">${icon("person")}</div>
7092
+ <h1 tabindex="-1">Is this the right account?</h1>
7093
+ <p class="lead">Claude verified the account below. Check it before adding it to the pool.</p>
7094
+ <form id="confirm">
7095
+ <div class="account">${icon("check")}<span id="account"></span></div>
7096
+ <label class="consent"><input id="consent" type="checkbox" required><span>I authorize the shared agents pool to use this subscription.</span></label>
7097
+ <button class="button" type="submit">Add to the pool</button>
7098
+ </form>
7099
+ <p class="aside">Wrong account? Do not confirm: start again and sign in with the right one.<a class="button secondary" href="/pool-enroll">Start again</a></p>
7100
+ </section>
7101
+ <section class="card" id="step-done" hidden>
7102
+ <div class="avatar ok">${icon("check")}</div>
7103
+ <h1 tabindex="-1">Subscription connected</h1>
7104
+ <p class="lead"><strong id="connected-account"></strong> is now part of the shared agents pool. You can close this page.</p>
7105
+ <div class="aside"><strong>Only testing?</strong> Disconnect removes this account's saved authorization and pool membership, then lets you sign in again. It stays available for 24 hours while this page is open; it does not sign you out of Claude or cancel your subscription.<button class="button secondary" id="disconnect" type="button">Disconnect and try again</button></div>
7106
+ </section>
7107
+ <section class="card" id="step-error" hidden>
7108
+ <div class="avatar err">${icon("alert")}</div>
7109
+ <h1 tabindex="-1">This sign-in can't continue</h1>
7110
+ <p class="lead" id="error-message"></p>
7111
+ <p class="aside">Nothing was added to the pool from this step.<a class="button secondary" href="/pool-enroll">Start again</a></p>
7112
+ </section>
7113
+ <p id="status" class="notice err" role="alert" hidden></p>
7114
+ </main>
7115
+ <footer>Each step expires after 10 minutes. Nothing is added to the pool until you confirm.</footer>
7116
+ <noscript><p class="notice err">JavaScript is required for this sign-in.</p></noscript>
7117
+ <script nonce="${nonce}">
7032
7118
  let token = location.hash.slice(1);
7033
7119
  history.replaceState(null, '', location.pathname);
7034
7120
  addEventListener('hashchange', () => { if (location.hash) location.reload(); });
7035
7121
  const get = id => document.getElementById(id);
7036
- const status = get('status');
7037
- async function post(step, values) {
7038
- const response = await fetch('/pool-enroll/' + step, {method:'POST', credentials:'omit', cache:'no-store', redirect:'error', headers:{'content-type':'application/json'}, body:JSON.stringify({token, ...values})});
7122
+ const steps = ['email', 'signin', 'code', 'confirm', 'done'];
7123
+ let current = 'email';
7124
+ function show(step) {
7125
+ const failed = step === 'error';
7126
+ const index = steps.indexOf(failed ? current : step);
7127
+ if (!failed) current = step;
7128
+ for (const name of [...steps, 'error']) get('step-' + name).hidden = name !== step;
7129
+ document.querySelectorAll('#stepper li').forEach((item, position) => {
7130
+ item.className = position < index || step === 'done' ? 'done' : position === index ? (failed ? 'failed' : 'active') : '';
7131
+ item.querySelector('.dot').innerHTML = item.className === 'done' ? '${icon("check")}' : String(position + 1);
7132
+ if (position === index) item.setAttribute('aria-current', 'step'); else item.removeAttribute('aria-current');
7133
+ });
7134
+ get('progress').style.width = Math.max(5, ((index + (step === 'done' ? 1 : 0)) / steps.length) * 100) + '%';
7135
+ (step === 'email' ? get('email') : get('step-' + step).querySelector('h1')).focus();
7136
+ }
7137
+ async function send(step, body) {
7138
+ const response = await fetch('/pool-enroll/' + step, {method:'POST', credentials:'omit', cache:'no-store', redirect:'error', headers:{'content-type':'application/json'}, body:JSON.stringify(body)});
7039
7139
  const data = await response.json();
7040
- if (!response.ok) throw new Error(data.error || 'Sign-in failed. Ask the operator for a new link.');
7140
+ if (!response.ok) throw new Error(data.error || 'Sign-in failed. Start again.');
7041
7141
  token = data.token || '';
7042
7142
  return data;
7043
7143
  }
7144
+ const post = (step, values) => send(step, {token, ...values});
7044
7145
  function fail(error) {
7045
- token = ''; get('code').value = ''; get('begin').hidden = true; get('verify').hidden = true; get('confirm').hidden = true;
7046
- status.textContent = error instanceof Error ? error.message : 'Sign-in failed. Ask for a new link.';
7146
+ token = ''; get('code').value = '';
7147
+ get('error-message').textContent = error instanceof Error ? error.message : 'Sign-in failed. Start again.';
7148
+ show('error');
7047
7149
  }
7048
- if (!/^[A-Za-z0-9_-]{43}$/.test(token)) fail(new Error('Open the private invitation link supplied by your pool operator.'));
7150
+ show(/^[A-Za-z0-9_-]{43}$/.test(token) ? 'signin' : 'email');
7151
+ get('request').addEventListener('submit', async event => {
7152
+ event.preventDefault(); event.submitter.disabled = true; get('email-error').hidden = true;
7153
+ try {
7154
+ const data = await send('request', {email: get('email').value});
7155
+ get('signin-email').textContent = data.email; get('signin-for').hidden = false;
7156
+ show('signin');
7157
+ } catch (error) {
7158
+ get('email-error').textContent = error instanceof Error ? error.message : 'This email cannot connect. Ask the operator.';
7159
+ get('email-error').hidden = false; get('email').focus();
7160
+ } finally { event.submitter.disabled = false; }
7161
+ });
7049
7162
  get('start').addEventListener('click', async () => {
7163
+ const tab = window.open('', '_blank');
7050
7164
  get('start').disabled = true;
7051
7165
  try {
7052
7166
  const data = await post('start', {});
7053
7167
  const url = new URL(data.authUrl);
7054
- if (url.origin !== 'https://claude.ai' || url.pathname !== '/oauth/authorize') throw new Error('Invalid sign-in destination. Ask the operator for help.');
7055
- get('authorize').href = url.href; get('authorize').hidden = false; get('start').hidden = true;
7056
- get('verify').hidden = false; status.textContent = 'Open Claude sign-in above, then return here with the code.';
7057
- } catch(error) { fail(error); }
7168
+ if (url.origin !== 'https://claude.ai' || url.pathname !== '/oauth/authorize') throw new Error('Invalid sign-in destination. Ask for help.');
7169
+ get('reopen').href = url.href;
7170
+ if (tab) { tab.opener = null; tab.location.href = url.href; }
7171
+ else get('code-lead').textContent = 'Your browser blocked the new tab. Open Claude sign-in with the link below, sign in, then paste the code it shows.';
7172
+ show('code');
7173
+ } catch (error) { if (tab) tab.close(); fail(error); }
7058
7174
  });
7059
7175
  get('verify').addEventListener('submit', async event => {
7060
- event.preventDefault(); const button = event.submitter; button.disabled = true;
7176
+ event.preventDefault(); event.submitter.disabled = true;
7061
7177
  const code = get('code').value.trim(); get('code').value = '';
7062
- try { const data = await post('verify', {code}); get('begin').hidden = true; get('verify').hidden = true; get('account').textContent = data.email; get('confirm').hidden = false; status.textContent = 'Check the account above before confirming.'; }
7063
- catch(error) { fail(error); }
7178
+ try { const data = await post('verify', {code}); get('account').textContent = data.email; show('confirm'); }
7179
+ catch (error) { fail(error); }
7064
7180
  });
7065
7181
  get('confirm').addEventListener('submit', async event => {
7066
7182
  event.preventDefault(); if (!get('consent').checked) return; event.submitter.disabled = true;
7067
- try { await post('confirm', {consent:true}); get('confirm').hidden = true; get('connected-account').textContent = get('account').textContent; get('connected').hidden = false; status.textContent = 'Subscription connected. If you are testing, disconnect before closing this page.'; }
7068
- catch(error) { fail(error); }
7183
+ try { await post('confirm', {consent:true}); get('connected-account').textContent = get('account').textContent; show('done'); }
7184
+ catch (error) { fail(error); }
7069
7185
  });
7070
7186
  get('disconnect').addEventListener('click', async () => {
7071
- get('disconnect').disabled = true;
7187
+ get('disconnect').disabled = true; get('status').hidden = true;
7072
7188
  try {
7073
7189
  await post('disconnect', {});
7074
- get('connected').hidden = true; get('connected-account').textContent = ''; get('account').textContent = '';
7075
- get('consent').checked = false; get('code').value = '';
7076
- get('authorize').removeAttribute('href'); get('authorize').hidden = true;
7190
+ get('account').textContent = ''; get('connected-account').textContent = ''; get('consent').checked = false;
7077
7191
  document.querySelectorAll('button').forEach(button => { button.disabled = false; });
7078
- get('start').hidden = false; get('begin').hidden = false;
7079
- status.textContent = 'Subscription disconnected. It is no longer in the pool. You can close this page or connect again.';
7080
- } catch(error) {
7192
+ get('signin-notice').textContent = 'Subscription disconnected: it is no longer in the pool. You can connect again or close this page.';
7193
+ get('signin-notice').hidden = false;
7194
+ show('signin');
7195
+ } catch (error) {
7081
7196
  get('disconnect').disabled = false;
7082
- status.textContent = 'Removal is not confirmed. ' + (error instanceof Error ? error.message : 'Keep this page open and ask the operator to check this seat.');
7197
+ get('status').textContent = 'Removal is not confirmed. ' + (error instanceof Error ? error.message : 'Keep this page open and ask for help with this seat.');
7198
+ get('status').hidden = false;
7083
7199
  }
7084
7200
  });
7085
7201
  </script></body></html>`, {
@@ -7093,7 +7209,61 @@ get('disconnect').addEventListener('click', async () => {
7093
7209
  }
7094
7210
  });
7095
7211
  }
7096
- var init_enrollment_page = () => {};
7212
+ var icons, icon = (name) => `<svg viewBox="0 0 24 24" aria-hidden="true">${icons[name]}</svg>`, styles = `
7213
+ :root{--page:#faf9f6;--card:#fff;--text:#191b21;--muted:#5a606b;--faint:#9aa0ab;--line:#e6e8ec;--border:rgba(25,27,33,.14);--brand:#fb5d22;--accent:#dc5518;--tint:#fff3ec;--ink:#182025;--ink-hover:#2a333b;--ok:#17a06b;--ok-bg:#e3f6ed;--ok-text:#0c6444;--err:#d92d20;--err-bg:#fef3f2;--err-text:#912018;color-scheme:light;font:15px/1.55 "Public Sans",ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;color:var(--text);background:var(--page)}
7214
+ *{box-sizing:border-box}body{margin:0;min-height:100vh}
7215
+ header{position:relative;background:#fff;border-bottom:1px solid var(--line);padding:16px clamp(16px,4vw,32px);font-weight:600}
7216
+ header small{display:block;font-weight:400;color:var(--muted);font-size:13px}
7217
+ .bar{position:absolute;left:0;right:0;bottom:-1px;height:4px;background:#f2f1ec}.bar span{display:block;height:100%;width:5%;background:var(--ink);transition:width .3s ease-in-out}
7218
+ main{max-width:560px;margin:0 auto;padding:32px 16px 48px}
7219
+ ol{list-style:none;display:flex;margin:0 0 28px;padding:0}
7220
+ ol li{flex:1;display:flex;flex-direction:column;align-items:center;gap:6px;position:relative;font-size:13px;color:var(--faint)}
7221
+ ol li+li::before{content:"";position:absolute;top:15px;right:calc(50% + 20px);width:calc(100% - 40px);height:2px;background:var(--line)}
7222
+ ol li.done+li::before{background:var(--ok-text)}
7223
+ ol .dot{width:32px;height:32px;border-radius:50%;display:grid;place-items:center;font-weight:600;background:var(--line);color:var(--faint);border:2px solid var(--line)}
7224
+ ol .dot svg{width:16px;height:16px;stroke:#fff;stroke-width:3}
7225
+ ol li.active{color:var(--accent);font-weight:600}ol li.active .dot{background:var(--brand);border-color:var(--brand);color:#fff}
7226
+ ol li.done{color:var(--muted)}ol li.done .dot{background:var(--ok);border-color:var(--ok)}
7227
+ ol li.failed .dot{background:var(--err);border-color:var(--err);color:#fff}
7228
+ .card{background:var(--card);border-radius:16px;box-shadow:0 1px 2px rgba(16,18,24,.05);border:1px solid rgba(25,27,33,.08);padding:clamp(24px,5vw,40px);text-align:center}
7229
+ .avatar{width:64px;height:64px;margin:0 auto 16px;border-radius:50%;display:grid;place-items:center;background:var(--tint)}
7230
+ .avatar svg,.button svg{width:28px;height:28px;fill:none;stroke:var(--accent);stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
7231
+ .avatar.ok{background:var(--ok-bg)}.avatar.ok svg{stroke:var(--ok-text);stroke-width:2.4}.avatar.err{background:var(--err-bg)}.avatar.err svg{stroke:var(--err)}
7232
+ h1{font-size:22px;line-height:1.3;margin:0 0 8px;outline:none}
7233
+ .lead{color:var(--muted);margin:0 auto 24px;max-width:42ch}
7234
+ .facts{list-style:none;padding:0;margin:0 0 28px;text-align:left;display:grid;gap:10px}
7235
+ .facts li{display:flex;gap:10px;align-items:flex-start}.facts li::before{content:"";flex:none;width:6px;height:6px;margin-top:9px;border-radius:50%;background:var(--brand)}
7236
+ .button{font:inherit;font-weight:600;display:inline-flex;align-items:center;justify-content:center;gap:8px;width:100%;min-height:44px;padding:10px 16px;border-radius:9px;border:0;background:var(--ink);color:#fff;cursor:pointer;text-decoration:none}
7237
+ .button:hover{background:var(--ink-hover);box-shadow:0 6px 18px -6px rgb(24 32 37/.5)}.button svg{width:18px;height:18px;stroke:currentColor}
7238
+ .button.secondary{background:#fff;color:var(--text);border:1px solid var(--border)}.button.secondary:hover{box-shadow:0 1px 3px rgba(47,43,67,.12)}
7239
+ .button:disabled{opacity:.5;cursor:progress;box-shadow:none}
7240
+ .button:focus-visible,input:focus-visible,a:focus-visible{outline:3px solid var(--brand);outline-offset:3px}
7241
+ .link{display:inline-flex;align-items:center;gap:6px;color:var(--accent);font-weight:600;text-decoration:none}.link svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2}
7242
+ form{text-align:left;display:grid;gap:16px}label{font-weight:600}
7243
+ input[type=password],input[type=email]{display:block;width:100%;font:inherit;padding:11px 12px;margin-top:6px;border:1px solid var(--border);border-radius:9px;background:#fff}
7244
+ .hint{font-size:13px;color:var(--muted);margin:6px 0 0;font-weight:400}
7245
+ .account{display:flex;align-items:center;gap:12px;padding:14px 16px;border-radius:12px;background:var(--page);border:1px solid rgba(25,27,33,.08);font-weight:600;overflow-wrap:anywhere}
7246
+ .account svg{flex:none;width:22px;height:22px;fill:none;stroke:var(--ok-text);stroke-width:2.4}
7247
+ .consent{display:flex;gap:10px;align-items:flex-start;font-weight:400}.consent input{width:18px;height:18px;margin:2px 0 0;accent-color:var(--ink)}
7248
+ .notice{border-radius:12px;padding:12px 16px;margin:0 0 20px;text-align:left;background:var(--ok-bg);color:var(--ok-text)}
7249
+ .notice.err{background:var(--err-bg);color:var(--err-text)}
7250
+ .aside{margin-top:24px;padding-top:20px;border-top:1px solid var(--line);text-align:left;color:var(--muted);font-size:14px}
7251
+ .aside .button{margin-top:12px}
7252
+ footer{max-width:560px;margin:0 auto;padding:0 16px 32px;color:var(--muted);font-size:13px;text-align:center}
7253
+ @media (max-width:480px){ol li:not(.active) .label{display:none}}
7254
+ [hidden]{display:none!important}
7255
+ `;
7256
+ var init_enrollment_page = __esm(() => {
7257
+ icons = {
7258
+ mail: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/>',
7259
+ key: '<path d="M15 7.5a3.5 3.5 0 1 1-3.4 4.3L4 19.5V22h3v-2h2v-2h2l1.2-1.2A3.5 3.5 0 0 1 15 7.5Z"/><circle cx="16" cy="11" r="1"/>',
7260
+ code: '<rect x="4" y="6" width="16" height="12" rx="2"/><path d="M8 10h.01M12 10h.01M16 10h.01M8 14h8"/>',
7261
+ person: '<circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/>',
7262
+ check: '<path d="m5 12.5 4.5 4.5L19 7.5"/>',
7263
+ alert: '<path d="M12 8v5M12 16.5h.01"/><circle cx="12" cy="12" r="9"/>',
7264
+ open: '<path d="M14 5h5v5M19 5l-8 8M18 14v4a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h4"/>'
7265
+ };
7266
+ });
7097
7267
 
7098
7268
  // ../../packages/api/src/handlers/enrollment.ts
7099
7269
  import { createHash, randomBytes as randomBytes2 } from "crypto";
@@ -7135,13 +7305,59 @@ class EnrollmentHandlers {
7135
7305
  return await action();
7136
7306
  } catch (error) {
7137
7307
  return error instanceof EnrollmentError ? json({ error: error.message }, error.status) : json({
7138
- error: "Enrollment could not be saved. Ask the operator to check the seat and issue a new link."
7308
+ error: "Enrollment could not be saved. Ask the operator to check the seat."
7139
7309
  }, 500);
7140
7310
  }
7141
7311
  }
7142
- issue = (req) => this.guarded(async () => {
7312
+ requireClaudeCode() {
7143
7313
  if (!this.context.providerRegistry.getOAuthProvider("claude-code"))
7144
7314
  throw new EnrollmentError(400, "Provider 'claude-code' is disabled. Set providers.claude-code.enabled to true in your config.");
7315
+ }
7316
+ reserve(name) {
7317
+ if (this.context.dbOps.getAccountByName(name))
7318
+ throw new EnrollmentError(409, "This seat is already in the pool.");
7319
+ for (const entry of this.entries.values())
7320
+ if (entry.details.name === name && entry.state.kind === "busy")
7321
+ throw new EnrollmentError(409, "Sign-in is already in progress");
7322
+ for (const [key, entry] of this.entries)
7323
+ if (entry.details.name === name)
7324
+ this.entries.delete(key);
7325
+ if (this.entries.size >= 100)
7326
+ throw new EnrollmentError(429, "Too many pending invitations");
7327
+ }
7328
+ request(body) {
7329
+ this.requireClaudeCode();
7330
+ if (Object.keys(body).some((key) => key !== "email"))
7331
+ throw new EnrollmentError(400, "Unknown request field");
7332
+ const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : "";
7333
+ if (!this.context.config.getEnrollmentEmails().includes(email))
7334
+ throw new EnrollmentError(403, "This email is not on the list. Ask the operator to add it.");
7335
+ const name = email.slice(0, email.indexOf("@")).replace(/[^a-z0-9._+-]/g, "-").slice(0, 64);
7336
+ this.reserve(name);
7337
+ const issued = this.mint({ name, email, contactEmail: email }, { kind: "invited" }, tenMinutes);
7338
+ return json({ ...issued, email });
7339
+ }
7340
+ emails = (req) => this.guarded(async () => {
7341
+ const body = await readBody(req);
7342
+ const lists = Object.entries(body);
7343
+ if (lists.some(([key, value]) => key !== "add" && key !== "remove" || !Array.isArray(value) || value.some((email) => typeof email !== "string")))
7344
+ throw new EnrollmentError(400, "Expected { add?: string[], remove?: string[] }");
7345
+ const { config } = this.context;
7346
+ const add = body.add ?? [];
7347
+ const remove = new Set((body.remove ?? []).map((email) => email.trim().toLowerCase()));
7348
+ if (add.length === 0 && remove.size === 0)
7349
+ return json({ emails: config.getEnrollmentEmails() });
7350
+ const next = [...config.getEnrollmentEmails(), ...add].filter((email) => !remove.has(email.trim().toLowerCase()));
7351
+ try {
7352
+ return json({ emails: config.setEnrollmentEmails(next) });
7353
+ } catch (error) {
7354
+ if (error instanceof ValidationError)
7355
+ throw new EnrollmentError(400, error.message);
7356
+ throw error;
7357
+ }
7358
+ });
7359
+ issue = (req) => this.guarded(async () => {
7360
+ this.requireClaudeCode();
7145
7361
  const input = await readBody(req);
7146
7362
  const allowed = new Set(["name", "email", "contactEmail", "slackUserId"]);
7147
7363
  if (Object.keys(input).some((key) => !allowed.has(key)))
@@ -7164,16 +7380,7 @@ class EnrollmentHandlers {
7164
7380
  }
7165
7381
  if (details.name.length > 80 || details.slackUserId && !/^U[A-Z0-9]{8,20}$/.test(details.slackUserId) || [details.email, details.contactEmail].some((email) => email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)))
7166
7382
  throw new EnrollmentError(400, "Invalid invitation details");
7167
- if (this.context.dbOps.getAccountByName(details.name))
7168
- throw new EnrollmentError(409, "Seat already exists");
7169
- for (const entry of this.entries.values())
7170
- if (entry.details.name === details.name && entry.state.kind === "busy")
7171
- throw new EnrollmentError(409, "Sign-in is already in progress");
7172
- for (const [key, entry] of this.entries)
7173
- if (entry.details.name === details.name)
7174
- this.entries.delete(key);
7175
- if (this.entries.size >= 100)
7176
- throw new EnrollmentError(429, "Too many pending invitations");
7383
+ this.reserve(details.name);
7177
7384
  const issued = this.mint(details, { kind: "invited" }, oneDay);
7178
7385
  return json({
7179
7386
  url: `${this.origin.origin}/pool-enroll#${issued.token}`,
@@ -7195,12 +7402,14 @@ class EnrollmentHandlers {
7195
7402
  if (req.headers.get("origin") !== this.origin.origin)
7196
7403
  throw new EnrollmentError(403, "Untrusted enrollment origin");
7197
7404
  const body = await readBody(req);
7405
+ if (url.pathname === "/pool-enroll/request")
7406
+ return this.request(body);
7198
7407
  const token = typeof body.token === "string" && /^[A-Za-z0-9_-]{43}$/.test(body.token) ? body.token : "";
7199
7408
  const key = hash(token);
7200
7409
  const entry = this.entries.get(key);
7201
7410
  const expected = url.pathname === "/pool-enroll/start" ? "invited" : url.pathname === "/pool-enroll/verify" ? "started" : url.pathname === "/pool-enroll/confirm" ? "verified" : "connected";
7202
7411
  if (!entry || entry.expiresAt <= this.now() || entry.state.kind !== expected)
7203
- throw new EnrollmentError(410, "This link has expired or was already used. Ask the operator for a new link.");
7412
+ throw new EnrollmentError(410, "This sign-in has expired or was already used. Start again.");
7204
7413
  const state = entry.state;
7205
7414
  entry.state = { kind: "busy" };
7206
7415
  try {
@@ -7219,12 +7428,12 @@ class EnrollmentHandlers {
7219
7428
  try {
7220
7429
  seat = await this.oauth.verifyClaudeSeat(state.flow, body.code.trim());
7221
7430
  } catch {
7222
- throw new EnrollmentError(400, "Claude sign-in could not be verified. Ask for a new link and try again.");
7431
+ throw new EnrollmentError(400, "Claude sign-in could not be verified. Start again.");
7223
7432
  }
7224
7433
  this.assertCurrent(key, entry);
7225
7434
  const { email } = seat.identity;
7226
7435
  if (!email || entry.details.email && email.toLowerCase() !== entry.details.email.toLowerCase())
7227
- throw new EnrollmentError(400, "This Claude account does not match the invitation. Ask for a new link.");
7436
+ throw new EnrollmentError(400, "This Claude account does not match your email. Start again and sign in with the matching account.");
7228
7437
  this.assertNewSeat(entry.details.name, seat);
7229
7438
  return json({
7230
7439
  ...this.mint(entry.details, { kind: "verified", seat }, tenMinutes),
@@ -7267,7 +7476,7 @@ class EnrollmentHandlers {
7267
7476
  }
7268
7477
  assertCurrent(key, entry) {
7269
7478
  if (this.entries.get(key) !== entry || entry.expiresAt <= this.now())
7270
- throw new EnrollmentError(410, "Sign-in expired. Ask for a new link.");
7479
+ throw new EnrollmentError(410, "Sign-in expired. Start again.");
7271
7480
  }
7272
7481
  assertNewSeat(name, seat) {
7273
7482
  if (this.context.dbOps.getAccountByName(name) || this.context.dbOps.getAccountsByProvider("claude-code").some((account2) => account2.account_uuid === seat.identity.accountUuid))
@@ -7347,11 +7556,13 @@ async function readBody(req) {
7347
7556
  }
7348
7557
  var paths, tenMinutes = 600000, oneDay = 86400000, headers2, json = (value, status = 200) => Response.json(value, { status, headers: headers2 }), hash = (token) => createHash("sha256").update(token).digest("hex"), EnrollmentError;
7349
7558
  var init_enrollment = __esm(() => {
7559
+ init_src2();
7350
7560
  init_src8();
7351
7561
  init_src();
7352
7562
  init_enrollment_page();
7353
7563
  paths = new Set([
7354
7564
  "/pool-enroll",
7565
+ "/pool-enroll/request",
7355
7566
  "/pool-enroll/start",
7356
7567
  "/pool-enroll/verify",
7357
7568
  "/pool-enroll/confirm",
@@ -7768,8 +7979,10 @@ class APIRouter {
7768
7979
  this.registerHandlers();
7769
7980
  }
7770
7981
  registerHandlers() {
7771
- if (this.enrollment)
7982
+ if (this.enrollment) {
7772
7983
  this.staticHandlers.set("POST:/api/enrollment-invitations", this.enrollment.issue);
7984
+ this.staticHandlers.set("POST:/api/enrollment-emails", this.enrollment.emails);
7985
+ }
7773
7986
  const { config, dbOps, getProviders, getRuntimeHealth, providerRegistry } = this.context;
7774
7987
  const healthHandler = createHealthHandler(dbOps, config, getProviders, getRuntimeHealth);
7775
7988
  const statsHandler = createStatsHandler(dbOps);
@@ -1327,12 +1327,30 @@ function parseNumber(value) {
1327
1327
  const parsed = Number(value);
1328
1328
  return Number.isFinite(parsed) ? parsed : undefined;
1329
1329
  }
1330
+ function normalizeEnrollmentEmail(value) {
1331
+ if (typeof value !== "string")
1332
+ return;
1333
+ const email = value.trim().toLowerCase();
1334
+ return email.length <= 254 && patterns.email.test(email) ? email : undefined;
1335
+ }
1336
+ function sanitizeEnrollmentEmails(value) {
1337
+ if (!Array.isArray(value))
1338
+ return;
1339
+ const emails = value.map(normalizeEnrollmentEmail).filter((email) => email !== undefined);
1340
+ return [...new Set(emails)].slice(0, MAX_ENROLLMENT_EMAILS);
1341
+ }
1330
1342
  function sanitizeConfigData(value) {
1331
1343
  if (!isRecord(value)) {
1332
1344
  return {};
1333
1345
  }
1334
1346
  const sanitized = {};
1335
1347
  for (const [key, entry] of Object.entries(value)) {
1348
+ if (key === "enrollment_emails") {
1349
+ const emails = sanitizeEnrollmentEmails(entry);
1350
+ if (emails)
1351
+ sanitized.enrollment_emails = emails;
1352
+ continue;
1353
+ }
1336
1354
  if (key === "team_routing") {
1337
1355
  const teamRouting = sanitizeTeamRouting(entry);
1338
1356
  if (teamRouting)
@@ -1482,7 +1500,7 @@ function sanitizeTeamRouting(value) {
1482
1500
  }
1483
1501
  return result;
1484
1502
  }
1485
- var log2, PROVIDER_STRATEGIES, BUILTIN_PROVIDER_CONFIGS, BUILTIN_PROVIDER_CONFIG_BY_NAME, PROVIDER_NAME_PATTERN, TEAM_ROUTING_DEFAULTS, Config;
1503
+ var log2, PROVIDER_STRATEGIES, BUILTIN_PROVIDER_CONFIGS, BUILTIN_PROVIDER_CONFIG_BY_NAME, PROVIDER_NAME_PATTERN, TEAM_ROUTING_DEFAULTS, MAX_ENROLLMENT_EMAILS = 200, Config;
1486
1504
  var init_src4 = __esm(() => {
1487
1505
  init_src2();
1488
1506
  init_src3();
@@ -1726,11 +1744,28 @@ var init_src4 = __esm(() => {
1726
1744
  this.persistAccountAllowlist(current.filter((uuid) => uuid !== accountUuid));
1727
1745
  }
1728
1746
  persistAccountAllowlist(accountUuids) {
1729
- const teamRouting = {
1747
+ this.persistStrictly("team_routing", {
1730
1748
  ...this.data.team_routing,
1731
1749
  allowlisted_account_uuids: accountUuids
1732
- };
1733
- const next = { ...this.data, team_routing: teamRouting };
1750
+ });
1751
+ }
1752
+ getEnrollmentEmails() {
1753
+ return [...this.data.enrollment_emails ?? []];
1754
+ }
1755
+ setEnrollmentEmails(emails) {
1756
+ const normalized = emails.map(normalizeEnrollmentEmail);
1757
+ if (normalized.some((email) => email === undefined)) {
1758
+ throw new ValidationError("Invalid enrollment email", "enrollment_emails");
1759
+ }
1760
+ const unique = [...new Set(normalized)];
1761
+ if (unique.length > MAX_ENROLLMENT_EMAILS) {
1762
+ throw new ValidationError(`At most ${MAX_ENROLLMENT_EMAILS} enrollment emails`, "enrollment_emails");
1763
+ }
1764
+ this.persistStrictly("enrollment_emails", unique);
1765
+ return [...unique];
1766
+ }
1767
+ persistStrictly(key, value) {
1768
+ const next = { ...this.data, [key]: value };
1734
1769
  const temporary = `${this.configPath}.${crypto.randomUUID()}.tmp`;
1735
1770
  try {
1736
1771
  writeFileSync(temporary, JSON.stringify(next, null, 2), {
@@ -1741,13 +1776,9 @@ var init_src4 = __esm(() => {
1741
1776
  } finally {
1742
1777
  rmSync(temporary, { force: true });
1743
1778
  }
1744
- const oldValue = this.data.team_routing;
1779
+ const oldValue = this.data[key];
1745
1780
  this.data = next;
1746
- this.emit("change", {
1747
- key: "team_routing",
1748
- oldValue,
1749
- newValue: teamRouting
1750
- });
1781
+ this.emit("change", { key, oldValue, newValue: value });
1751
1782
  }
1752
1783
  getRuntime() {
1753
1784
  const defaults = {
package/dist/server.js CHANGED
@@ -3831,12 +3831,30 @@ function parseNumber(value) {
3831
3831
  const parsed = Number(value);
3832
3832
  return Number.isFinite(parsed) ? parsed : undefined;
3833
3833
  }
3834
+ function normalizeEnrollmentEmail(value) {
3835
+ if (typeof value !== "string")
3836
+ return;
3837
+ const email = value.trim().toLowerCase();
3838
+ return email.length <= 254 && patterns.email.test(email) ? email : undefined;
3839
+ }
3840
+ function sanitizeEnrollmentEmails(value) {
3841
+ if (!Array.isArray(value))
3842
+ return;
3843
+ const emails = value.map(normalizeEnrollmentEmail).filter((email) => email !== undefined);
3844
+ return [...new Set(emails)].slice(0, MAX_ENROLLMENT_EMAILS);
3845
+ }
3834
3846
  function sanitizeConfigData(value) {
3835
3847
  if (!isRecord(value)) {
3836
3848
  return {};
3837
3849
  }
3838
3850
  const sanitized = {};
3839
3851
  for (const [key, entry] of Object.entries(value)) {
3852
+ if (key === "enrollment_emails") {
3853
+ const emails = sanitizeEnrollmentEmails(entry);
3854
+ if (emails)
3855
+ sanitized.enrollment_emails = emails;
3856
+ continue;
3857
+ }
3840
3858
  if (key === "team_routing") {
3841
3859
  const teamRouting = sanitizeTeamRouting(entry);
3842
3860
  if (teamRouting)
@@ -3986,7 +4004,7 @@ function sanitizeTeamRouting(value) {
3986
4004
  }
3987
4005
  return result;
3988
4006
  }
3989
- var log6, PROVIDER_STRATEGIES, BUILTIN_PROVIDER_CONFIGS, BUILTIN_PROVIDER_CONFIG_BY_NAME, PROVIDER_NAME_PATTERN, TEAM_ROUTING_DEFAULTS, Config;
4007
+ var log6, PROVIDER_STRATEGIES, BUILTIN_PROVIDER_CONFIGS, BUILTIN_PROVIDER_CONFIG_BY_NAME, PROVIDER_NAME_PATTERN, TEAM_ROUTING_DEFAULTS, MAX_ENROLLMENT_EMAILS = 200, Config;
3990
4008
  var init_src7 = __esm(() => {
3991
4009
  init_src2();
3992
4010
  init_src4();
@@ -4230,11 +4248,28 @@ var init_src7 = __esm(() => {
4230
4248
  this.persistAccountAllowlist(current.filter((uuid) => uuid !== accountUuid));
4231
4249
  }
4232
4250
  persistAccountAllowlist(accountUuids) {
4233
- const teamRouting = {
4251
+ this.persistStrictly("team_routing", {
4234
4252
  ...this.data.team_routing,
4235
4253
  allowlisted_account_uuids: accountUuids
4236
- };
4237
- const next = { ...this.data, team_routing: teamRouting };
4254
+ });
4255
+ }
4256
+ getEnrollmentEmails() {
4257
+ return [...this.data.enrollment_emails ?? []];
4258
+ }
4259
+ setEnrollmentEmails(emails) {
4260
+ const normalized = emails.map(normalizeEnrollmentEmail);
4261
+ if (normalized.some((email) => email === undefined)) {
4262
+ throw new ValidationError("Invalid enrollment email", "enrollment_emails");
4263
+ }
4264
+ const unique = [...new Set(normalized)];
4265
+ if (unique.length > MAX_ENROLLMENT_EMAILS) {
4266
+ throw new ValidationError(`At most ${MAX_ENROLLMENT_EMAILS} enrollment emails`, "enrollment_emails");
4267
+ }
4268
+ this.persistStrictly("enrollment_emails", unique);
4269
+ return [...unique];
4270
+ }
4271
+ persistStrictly(key, value) {
4272
+ const next = { ...this.data, [key]: value };
4238
4273
  const temporary = `${this.configPath}.${crypto.randomUUID()}.tmp`;
4239
4274
  try {
4240
4275
  writeFileSync(temporary, JSON.stringify(next, null, 2), {
@@ -4245,13 +4280,9 @@ var init_src7 = __esm(() => {
4245
4280
  } finally {
4246
4281
  rmSync(temporary, { force: true });
4247
4282
  }
4248
- const oldValue = this.data.team_routing;
4283
+ const oldValue = this.data[key];
4249
4284
  this.data = next;
4250
- this.emit("change", {
4251
- key: "team_routing",
4252
- oldValue,
4253
- newValue: teamRouting
4254
- });
4285
+ this.emit("change", { key, oldValue, newValue: value });
4255
4286
  }
4256
4287
  getRuntime() {
4257
4288
  const defaults = {
@@ -7013,72 +7044,157 @@ function enrollmentPage() {
7013
7044
  return new Response(`<!doctype html>
7014
7045
  <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
7015
7046
  <title>Connect a Claude subscription</title>
7016
- <style nonce="${nonce}">
7017
- :root{color-scheme:light;font-family:"Avenir Next",Avenir,"Segoe UI",sans-serif;color:#17334a;background:#edf3f7;line-height:1.6}
7018
- body{margin:0;padding:clamp(20px,6vw,64px)}main{max-width:42rem;margin:auto;background:#fff;padding:clamp(24px,5vw,48px);border-top:6px solid #235e82}
7019
- h1{font-size:clamp(1.8rem,5vw,2.5rem);line-height:1.2;margin:0 0 24px}h2{font-size:1.2rem;margin-top:32px}p{max-width:65ch}button,a.button{font:inherit;display:inline-block;border:0;border-radius:6px;background:#235e82;color:#fff;padding:12px 20px;text-decoration:none;cursor:pointer}button:disabled{opacity:.55;cursor:wait}button:focus-visible,a:focus-visible,input:focus-visible{outline:3px solid #b15c00;outline-offset:4px}input[type=password]{box-sizing:border-box;display:block;width:100%;font:inherit;padding:12px;border:1px solid #647686;border-radius:4px;margin:8px 0 16px}label{display:block}#account{font-weight:600;overflow-wrap:anywhere}#status{white-space:pre-line}small{display:block;margin-top:32px;color:#4b6172}[hidden]{display:none!important}
7020
- </style></head><body><main>
7021
- <h1>Connect your Claude subscription</h1>
7022
- <p>This adds your subscription to the shared agents pool. Only connect an account you have permission to share. Your password stays with Claude; the pool stores the authorization needed to run agents.</p>
7023
- <section id="begin"><h2>1. Sign in with Claude</h2><p>You will open Claude in a separate tab. Choose the account you want to share.</p><button id="start" type="button">Connect Claude</button><p><a id="authorize" class="button" target="_blank" rel="noopener noreferrer" hidden>Open Claude sign-in</a></p></section>
7024
- <form id="verify" hidden><h2>2. Check your account</h2><label for="code">Paste the authorization code from Claude</label><input id="code" type="password" autocomplete="off" spellcheck="false" required maxlength="4096"><button type="submit">Check account</button></form>
7025
- <form id="confirm" hidden><h2>3. Add this subscription</h2><p>Claude verified this account:</p><p id="account"></p><label><input id="consent" type="checkbox" required> I authorize the shared agents pool to use this subscription.</label><p><button type="submit">Add subscription to pool</button></p><p>Wrong account? Do not confirm. Close this page and ask the operator for a new link.</p></form>
7026
- <section id="connected" hidden><h2>Subscription connected</h2><p id="connected-account"></p><p>Testing this flow? Disconnect removes this seat's saved credentials and pool membership before letting you sign in again.</p><button id="disconnect" type="button">Disconnect and try again</button><p>Keep this page open to disconnect. This button expires after 24 hours; a page reload or server restart requires operator help. It does not sign you out of Claude's website, cancel your subscription, or stop a request already running.</p></section>
7027
- <p id="status" role="status" aria-live="polite"></p>
7028
- <small>This private link works once. After starting, each step expires in 10 minutes. Do not share this link or paste authorization codes into chat. Reloading this page requires a new invitation.</small>
7029
- <noscript>JavaScript is required for this private sign-in flow.</noscript>
7030
- </main><script nonce="${nonce}">
7047
+ <style nonce="${nonce}">${styles}</style></head><body>
7048
+ <header>Shared agents pool<small>Connect a Claude subscription</small><div class="bar"><span id="progress"></span></div></header>
7049
+ <main>
7050
+ <ol id="stepper" aria-label="Progress">
7051
+ <li data-step="email"><span class="dot">1</span><span class="label">Email</span></li>
7052
+ <li data-step="signin"><span class="dot">2</span><span class="label">Sign in</span></li>
7053
+ <li data-step="code"><span class="dot">3</span><span class="label">Paste code</span></li>
7054
+ <li data-step="confirm"><span class="dot">4</span><span class="label">Confirm</span></li>
7055
+ <li data-step="done"><span class="dot">5</span><span class="label">Done</span></li>
7056
+ </ol>
7057
+ <section class="card" id="step-email" hidden>
7058
+ <div class="avatar">${icon("mail")}</div>
7059
+ <h1 tabindex="-1">Connect your Claude subscription</h1>
7060
+ <p class="lead">This adds your subscription to the shared agents pool. Start with your work email: if it is on this pool's list, the next step lets you sign in with Claude.</p>
7061
+ <form id="request">
7062
+ <p class="notice err" id="email-error" role="alert" hidden></p>
7063
+ <label for="email">Work email<input id="email" type="email" autocomplete="email" spellcheck="false" required maxlength="254"></label>
7064
+ <button class="button" type="submit">Continue</button>
7065
+ </form>
7066
+ </section>
7067
+ <section class="card" id="step-signin" hidden>
7068
+ <p class="notice" id="signin-notice" role="status" hidden></p>
7069
+ <div class="avatar">${icon("key")}</div>
7070
+ <h1 tabindex="-1">Sign in with Claude</h1>
7071
+ <p class="lead" id="signin-for" hidden>Use the Claude account of <strong id="signin-email"></strong>. Only connect an account you have permission to share.</p>
7072
+ <ul class="facts">
7073
+ <li>Claude opens in a new tab. Sign in and pick the account to share.</li>
7074
+ <li>Your password stays with Claude. The pool only keeps the authorization it needs to run agents.</li>
7075
+ <li>Claude then shows a code: you paste it here in the next step.</li>
7076
+ </ul>
7077
+ <button class="button" id="start" type="button">Continue to Claude ${icon("open")}</button>
7078
+ </section>
7079
+ <section class="card" id="step-code" hidden>
7080
+ <div class="avatar">${icon("code")}</div>
7081
+ <h1 tabindex="-1">Paste the code from Claude</h1>
7082
+ <p class="lead" id="code-lead">Claude opened in a new tab. Once you have signed in, it shows an authorization code: copy it and paste it below.</p>
7083
+ <form id="verify">
7084
+ <label for="code">Authorization code<input id="code" type="password" autocomplete="off" spellcheck="false" required maxlength="4096"><span class="hint">The code stays hidden on screen. Never paste it into chat.</span></label>
7085
+ <button class="button" type="submit">Verify account</button>
7086
+ </form>
7087
+ <p class="aside">Closed the Claude tab? <a class="link" id="reopen" target="_blank" rel="noopener noreferrer">Open Claude sign-in again ${icon("open")}</a></p>
7088
+ </section>
7089
+ <section class="card" id="step-confirm" hidden>
7090
+ <div class="avatar">${icon("person")}</div>
7091
+ <h1 tabindex="-1">Is this the right account?</h1>
7092
+ <p class="lead">Claude verified the account below. Check it before adding it to the pool.</p>
7093
+ <form id="confirm">
7094
+ <div class="account">${icon("check")}<span id="account"></span></div>
7095
+ <label class="consent"><input id="consent" type="checkbox" required><span>I authorize the shared agents pool to use this subscription.</span></label>
7096
+ <button class="button" type="submit">Add to the pool</button>
7097
+ </form>
7098
+ <p class="aside">Wrong account? Do not confirm: start again and sign in with the right one.<a class="button secondary" href="/pool-enroll">Start again</a></p>
7099
+ </section>
7100
+ <section class="card" id="step-done" hidden>
7101
+ <div class="avatar ok">${icon("check")}</div>
7102
+ <h1 tabindex="-1">Subscription connected</h1>
7103
+ <p class="lead"><strong id="connected-account"></strong> is now part of the shared agents pool. You can close this page.</p>
7104
+ <div class="aside"><strong>Only testing?</strong> Disconnect removes this account's saved authorization and pool membership, then lets you sign in again. It stays available for 24 hours while this page is open; it does not sign you out of Claude or cancel your subscription.<button class="button secondary" id="disconnect" type="button">Disconnect and try again</button></div>
7105
+ </section>
7106
+ <section class="card" id="step-error" hidden>
7107
+ <div class="avatar err">${icon("alert")}</div>
7108
+ <h1 tabindex="-1">This sign-in can't continue</h1>
7109
+ <p class="lead" id="error-message"></p>
7110
+ <p class="aside">Nothing was added to the pool from this step.<a class="button secondary" href="/pool-enroll">Start again</a></p>
7111
+ </section>
7112
+ <p id="status" class="notice err" role="alert" hidden></p>
7113
+ </main>
7114
+ <footer>Each step expires after 10 minutes. Nothing is added to the pool until you confirm.</footer>
7115
+ <noscript><p class="notice err">JavaScript is required for this sign-in.</p></noscript>
7116
+ <script nonce="${nonce}">
7031
7117
  let token = location.hash.slice(1);
7032
7118
  history.replaceState(null, '', location.pathname);
7033
7119
  addEventListener('hashchange', () => { if (location.hash) location.reload(); });
7034
7120
  const get = id => document.getElementById(id);
7035
- const status = get('status');
7036
- async function post(step, values) {
7037
- const response = await fetch('/pool-enroll/' + step, {method:'POST', credentials:'omit', cache:'no-store', redirect:'error', headers:{'content-type':'application/json'}, body:JSON.stringify({token, ...values})});
7121
+ const steps = ['email', 'signin', 'code', 'confirm', 'done'];
7122
+ let current = 'email';
7123
+ function show(step) {
7124
+ const failed = step === 'error';
7125
+ const index = steps.indexOf(failed ? current : step);
7126
+ if (!failed) current = step;
7127
+ for (const name of [...steps, 'error']) get('step-' + name).hidden = name !== step;
7128
+ document.querySelectorAll('#stepper li').forEach((item, position) => {
7129
+ item.className = position < index || step === 'done' ? 'done' : position === index ? (failed ? 'failed' : 'active') : '';
7130
+ item.querySelector('.dot').innerHTML = item.className === 'done' ? '${icon("check")}' : String(position + 1);
7131
+ if (position === index) item.setAttribute('aria-current', 'step'); else item.removeAttribute('aria-current');
7132
+ });
7133
+ get('progress').style.width = Math.max(5, ((index + (step === 'done' ? 1 : 0)) / steps.length) * 100) + '%';
7134
+ (step === 'email' ? get('email') : get('step-' + step).querySelector('h1')).focus();
7135
+ }
7136
+ async function send(step, body) {
7137
+ const response = await fetch('/pool-enroll/' + step, {method:'POST', credentials:'omit', cache:'no-store', redirect:'error', headers:{'content-type':'application/json'}, body:JSON.stringify(body)});
7038
7138
  const data = await response.json();
7039
- if (!response.ok) throw new Error(data.error || 'Sign-in failed. Ask the operator for a new link.');
7139
+ if (!response.ok) throw new Error(data.error || 'Sign-in failed. Start again.');
7040
7140
  token = data.token || '';
7041
7141
  return data;
7042
7142
  }
7143
+ const post = (step, values) => send(step, {token, ...values});
7043
7144
  function fail(error) {
7044
- token = ''; get('code').value = ''; get('begin').hidden = true; get('verify').hidden = true; get('confirm').hidden = true;
7045
- status.textContent = error instanceof Error ? error.message : 'Sign-in failed. Ask for a new link.';
7145
+ token = ''; get('code').value = '';
7146
+ get('error-message').textContent = error instanceof Error ? error.message : 'Sign-in failed. Start again.';
7147
+ show('error');
7046
7148
  }
7047
- if (!/^[A-Za-z0-9_-]{43}$/.test(token)) fail(new Error('Open the private invitation link supplied by your pool operator.'));
7149
+ show(/^[A-Za-z0-9_-]{43}$/.test(token) ? 'signin' : 'email');
7150
+ get('request').addEventListener('submit', async event => {
7151
+ event.preventDefault(); event.submitter.disabled = true; get('email-error').hidden = true;
7152
+ try {
7153
+ const data = await send('request', {email: get('email').value});
7154
+ get('signin-email').textContent = data.email; get('signin-for').hidden = false;
7155
+ show('signin');
7156
+ } catch (error) {
7157
+ get('email-error').textContent = error instanceof Error ? error.message : 'This email cannot connect. Ask the operator.';
7158
+ get('email-error').hidden = false; get('email').focus();
7159
+ } finally { event.submitter.disabled = false; }
7160
+ });
7048
7161
  get('start').addEventListener('click', async () => {
7162
+ const tab = window.open('', '_blank');
7049
7163
  get('start').disabled = true;
7050
7164
  try {
7051
7165
  const data = await post('start', {});
7052
7166
  const url = new URL(data.authUrl);
7053
- if (url.origin !== 'https://claude.ai' || url.pathname !== '/oauth/authorize') throw new Error('Invalid sign-in destination. Ask the operator for help.');
7054
- get('authorize').href = url.href; get('authorize').hidden = false; get('start').hidden = true;
7055
- get('verify').hidden = false; status.textContent = 'Open Claude sign-in above, then return here with the code.';
7056
- } catch(error) { fail(error); }
7167
+ if (url.origin !== 'https://claude.ai' || url.pathname !== '/oauth/authorize') throw new Error('Invalid sign-in destination. Ask for help.');
7168
+ get('reopen').href = url.href;
7169
+ if (tab) { tab.opener = null; tab.location.href = url.href; }
7170
+ else get('code-lead').textContent = 'Your browser blocked the new tab. Open Claude sign-in with the link below, sign in, then paste the code it shows.';
7171
+ show('code');
7172
+ } catch (error) { if (tab) tab.close(); fail(error); }
7057
7173
  });
7058
7174
  get('verify').addEventListener('submit', async event => {
7059
- event.preventDefault(); const button = event.submitter; button.disabled = true;
7175
+ event.preventDefault(); event.submitter.disabled = true;
7060
7176
  const code = get('code').value.trim(); get('code').value = '';
7061
- try { const data = await post('verify', {code}); get('begin').hidden = true; get('verify').hidden = true; get('account').textContent = data.email; get('confirm').hidden = false; status.textContent = 'Check the account above before confirming.'; }
7062
- catch(error) { fail(error); }
7177
+ try { const data = await post('verify', {code}); get('account').textContent = data.email; show('confirm'); }
7178
+ catch (error) { fail(error); }
7063
7179
  });
7064
7180
  get('confirm').addEventListener('submit', async event => {
7065
7181
  event.preventDefault(); if (!get('consent').checked) return; event.submitter.disabled = true;
7066
- try { await post('confirm', {consent:true}); get('confirm').hidden = true; get('connected-account').textContent = get('account').textContent; get('connected').hidden = false; status.textContent = 'Subscription connected. If you are testing, disconnect before closing this page.'; }
7067
- catch(error) { fail(error); }
7182
+ try { await post('confirm', {consent:true}); get('connected-account').textContent = get('account').textContent; show('done'); }
7183
+ catch (error) { fail(error); }
7068
7184
  });
7069
7185
  get('disconnect').addEventListener('click', async () => {
7070
- get('disconnect').disabled = true;
7186
+ get('disconnect').disabled = true; get('status').hidden = true;
7071
7187
  try {
7072
7188
  await post('disconnect', {});
7073
- get('connected').hidden = true; get('connected-account').textContent = ''; get('account').textContent = '';
7074
- get('consent').checked = false; get('code').value = '';
7075
- get('authorize').removeAttribute('href'); get('authorize').hidden = true;
7189
+ get('account').textContent = ''; get('connected-account').textContent = ''; get('consent').checked = false;
7076
7190
  document.querySelectorAll('button').forEach(button => { button.disabled = false; });
7077
- get('start').hidden = false; get('begin').hidden = false;
7078
- status.textContent = 'Subscription disconnected. It is no longer in the pool. You can close this page or connect again.';
7079
- } catch(error) {
7191
+ get('signin-notice').textContent = 'Subscription disconnected: it is no longer in the pool. You can connect again or close this page.';
7192
+ get('signin-notice').hidden = false;
7193
+ show('signin');
7194
+ } catch (error) {
7080
7195
  get('disconnect').disabled = false;
7081
- status.textContent = 'Removal is not confirmed. ' + (error instanceof Error ? error.message : 'Keep this page open and ask the operator to check this seat.');
7196
+ get('status').textContent = 'Removal is not confirmed. ' + (error instanceof Error ? error.message : 'Keep this page open and ask for help with this seat.');
7197
+ get('status').hidden = false;
7082
7198
  }
7083
7199
  });
7084
7200
  </script></body></html>`, {
@@ -7092,7 +7208,61 @@ get('disconnect').addEventListener('click', async () => {
7092
7208
  }
7093
7209
  });
7094
7210
  }
7095
- var init_enrollment_page = () => {};
7211
+ var icons, icon = (name) => `<svg viewBox="0 0 24 24" aria-hidden="true">${icons[name]}</svg>`, styles = `
7212
+ :root{--page:#faf9f6;--card:#fff;--text:#191b21;--muted:#5a606b;--faint:#9aa0ab;--line:#e6e8ec;--border:rgba(25,27,33,.14);--brand:#fb5d22;--accent:#dc5518;--tint:#fff3ec;--ink:#182025;--ink-hover:#2a333b;--ok:#17a06b;--ok-bg:#e3f6ed;--ok-text:#0c6444;--err:#d92d20;--err-bg:#fef3f2;--err-text:#912018;color-scheme:light;font:15px/1.55 "Public Sans",ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;color:var(--text);background:var(--page)}
7213
+ *{box-sizing:border-box}body{margin:0;min-height:100vh}
7214
+ header{position:relative;background:#fff;border-bottom:1px solid var(--line);padding:16px clamp(16px,4vw,32px);font-weight:600}
7215
+ header small{display:block;font-weight:400;color:var(--muted);font-size:13px}
7216
+ .bar{position:absolute;left:0;right:0;bottom:-1px;height:4px;background:#f2f1ec}.bar span{display:block;height:100%;width:5%;background:var(--ink);transition:width .3s ease-in-out}
7217
+ main{max-width:560px;margin:0 auto;padding:32px 16px 48px}
7218
+ ol{list-style:none;display:flex;margin:0 0 28px;padding:0}
7219
+ ol li{flex:1;display:flex;flex-direction:column;align-items:center;gap:6px;position:relative;font-size:13px;color:var(--faint)}
7220
+ ol li+li::before{content:"";position:absolute;top:15px;right:calc(50% + 20px);width:calc(100% - 40px);height:2px;background:var(--line)}
7221
+ ol li.done+li::before{background:var(--ok-text)}
7222
+ ol .dot{width:32px;height:32px;border-radius:50%;display:grid;place-items:center;font-weight:600;background:var(--line);color:var(--faint);border:2px solid var(--line)}
7223
+ ol .dot svg{width:16px;height:16px;stroke:#fff;stroke-width:3}
7224
+ ol li.active{color:var(--accent);font-weight:600}ol li.active .dot{background:var(--brand);border-color:var(--brand);color:#fff}
7225
+ ol li.done{color:var(--muted)}ol li.done .dot{background:var(--ok);border-color:var(--ok)}
7226
+ ol li.failed .dot{background:var(--err);border-color:var(--err);color:#fff}
7227
+ .card{background:var(--card);border-radius:16px;box-shadow:0 1px 2px rgba(16,18,24,.05);border:1px solid rgba(25,27,33,.08);padding:clamp(24px,5vw,40px);text-align:center}
7228
+ .avatar{width:64px;height:64px;margin:0 auto 16px;border-radius:50%;display:grid;place-items:center;background:var(--tint)}
7229
+ .avatar svg,.button svg{width:28px;height:28px;fill:none;stroke:var(--accent);stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
7230
+ .avatar.ok{background:var(--ok-bg)}.avatar.ok svg{stroke:var(--ok-text);stroke-width:2.4}.avatar.err{background:var(--err-bg)}.avatar.err svg{stroke:var(--err)}
7231
+ h1{font-size:22px;line-height:1.3;margin:0 0 8px;outline:none}
7232
+ .lead{color:var(--muted);margin:0 auto 24px;max-width:42ch}
7233
+ .facts{list-style:none;padding:0;margin:0 0 28px;text-align:left;display:grid;gap:10px}
7234
+ .facts li{display:flex;gap:10px;align-items:flex-start}.facts li::before{content:"";flex:none;width:6px;height:6px;margin-top:9px;border-radius:50%;background:var(--brand)}
7235
+ .button{font:inherit;font-weight:600;display:inline-flex;align-items:center;justify-content:center;gap:8px;width:100%;min-height:44px;padding:10px 16px;border-radius:9px;border:0;background:var(--ink);color:#fff;cursor:pointer;text-decoration:none}
7236
+ .button:hover{background:var(--ink-hover);box-shadow:0 6px 18px -6px rgb(24 32 37/.5)}.button svg{width:18px;height:18px;stroke:currentColor}
7237
+ .button.secondary{background:#fff;color:var(--text);border:1px solid var(--border)}.button.secondary:hover{box-shadow:0 1px 3px rgba(47,43,67,.12)}
7238
+ .button:disabled{opacity:.5;cursor:progress;box-shadow:none}
7239
+ .button:focus-visible,input:focus-visible,a:focus-visible{outline:3px solid var(--brand);outline-offset:3px}
7240
+ .link{display:inline-flex;align-items:center;gap:6px;color:var(--accent);font-weight:600;text-decoration:none}.link svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2}
7241
+ form{text-align:left;display:grid;gap:16px}label{font-weight:600}
7242
+ input[type=password],input[type=email]{display:block;width:100%;font:inherit;padding:11px 12px;margin-top:6px;border:1px solid var(--border);border-radius:9px;background:#fff}
7243
+ .hint{font-size:13px;color:var(--muted);margin:6px 0 0;font-weight:400}
7244
+ .account{display:flex;align-items:center;gap:12px;padding:14px 16px;border-radius:12px;background:var(--page);border:1px solid rgba(25,27,33,.08);font-weight:600;overflow-wrap:anywhere}
7245
+ .account svg{flex:none;width:22px;height:22px;fill:none;stroke:var(--ok-text);stroke-width:2.4}
7246
+ .consent{display:flex;gap:10px;align-items:flex-start;font-weight:400}.consent input{width:18px;height:18px;margin:2px 0 0;accent-color:var(--ink)}
7247
+ .notice{border-radius:12px;padding:12px 16px;margin:0 0 20px;text-align:left;background:var(--ok-bg);color:var(--ok-text)}
7248
+ .notice.err{background:var(--err-bg);color:var(--err-text)}
7249
+ .aside{margin-top:24px;padding-top:20px;border-top:1px solid var(--line);text-align:left;color:var(--muted);font-size:14px}
7250
+ .aside .button{margin-top:12px}
7251
+ footer{max-width:560px;margin:0 auto;padding:0 16px 32px;color:var(--muted);font-size:13px;text-align:center}
7252
+ @media (max-width:480px){ol li:not(.active) .label{display:none}}
7253
+ [hidden]{display:none!important}
7254
+ `;
7255
+ var init_enrollment_page = __esm(() => {
7256
+ icons = {
7257
+ mail: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/>',
7258
+ key: '<path d="M15 7.5a3.5 3.5 0 1 1-3.4 4.3L4 19.5V22h3v-2h2v-2h2l1.2-1.2A3.5 3.5 0 0 1 15 7.5Z"/><circle cx="16" cy="11" r="1"/>',
7259
+ code: '<rect x="4" y="6" width="16" height="12" rx="2"/><path d="M8 10h.01M12 10h.01M16 10h.01M8 14h8"/>',
7260
+ person: '<circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/>',
7261
+ check: '<path d="m5 12.5 4.5 4.5L19 7.5"/>',
7262
+ alert: '<path d="M12 8v5M12 16.5h.01"/><circle cx="12" cy="12" r="9"/>',
7263
+ open: '<path d="M14 5h5v5M19 5l-8 8M18 14v4a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h4"/>'
7264
+ };
7265
+ });
7096
7266
 
7097
7267
  // ../../packages/api/src/handlers/enrollment.ts
7098
7268
  import { createHash, randomBytes as randomBytes2 } from "crypto";
@@ -7134,13 +7304,59 @@ class EnrollmentHandlers {
7134
7304
  return await action();
7135
7305
  } catch (error) {
7136
7306
  return error instanceof EnrollmentError ? json({ error: error.message }, error.status) : json({
7137
- error: "Enrollment could not be saved. Ask the operator to check the seat and issue a new link."
7307
+ error: "Enrollment could not be saved. Ask the operator to check the seat."
7138
7308
  }, 500);
7139
7309
  }
7140
7310
  }
7141
- issue = (req) => this.guarded(async () => {
7311
+ requireClaudeCode() {
7142
7312
  if (!this.context.providerRegistry.getOAuthProvider("claude-code"))
7143
7313
  throw new EnrollmentError(400, "Provider 'claude-code' is disabled. Set providers.claude-code.enabled to true in your config.");
7314
+ }
7315
+ reserve(name) {
7316
+ if (this.context.dbOps.getAccountByName(name))
7317
+ throw new EnrollmentError(409, "This seat is already in the pool.");
7318
+ for (const entry of this.entries.values())
7319
+ if (entry.details.name === name && entry.state.kind === "busy")
7320
+ throw new EnrollmentError(409, "Sign-in is already in progress");
7321
+ for (const [key, entry] of this.entries)
7322
+ if (entry.details.name === name)
7323
+ this.entries.delete(key);
7324
+ if (this.entries.size >= 100)
7325
+ throw new EnrollmentError(429, "Too many pending invitations");
7326
+ }
7327
+ request(body) {
7328
+ this.requireClaudeCode();
7329
+ if (Object.keys(body).some((key) => key !== "email"))
7330
+ throw new EnrollmentError(400, "Unknown request field");
7331
+ const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : "";
7332
+ if (!this.context.config.getEnrollmentEmails().includes(email))
7333
+ throw new EnrollmentError(403, "This email is not on the list. Ask the operator to add it.");
7334
+ const name = email.slice(0, email.indexOf("@")).replace(/[^a-z0-9._+-]/g, "-").slice(0, 64);
7335
+ this.reserve(name);
7336
+ const issued = this.mint({ name, email, contactEmail: email }, { kind: "invited" }, tenMinutes);
7337
+ return json({ ...issued, email });
7338
+ }
7339
+ emails = (req) => this.guarded(async () => {
7340
+ const body = await readBody(req);
7341
+ const lists = Object.entries(body);
7342
+ if (lists.some(([key, value]) => key !== "add" && key !== "remove" || !Array.isArray(value) || value.some((email) => typeof email !== "string")))
7343
+ throw new EnrollmentError(400, "Expected { add?: string[], remove?: string[] }");
7344
+ const { config } = this.context;
7345
+ const add = body.add ?? [];
7346
+ const remove = new Set((body.remove ?? []).map((email) => email.trim().toLowerCase()));
7347
+ if (add.length === 0 && remove.size === 0)
7348
+ return json({ emails: config.getEnrollmentEmails() });
7349
+ const next = [...config.getEnrollmentEmails(), ...add].filter((email) => !remove.has(email.trim().toLowerCase()));
7350
+ try {
7351
+ return json({ emails: config.setEnrollmentEmails(next) });
7352
+ } catch (error) {
7353
+ if (error instanceof ValidationError)
7354
+ throw new EnrollmentError(400, error.message);
7355
+ throw error;
7356
+ }
7357
+ });
7358
+ issue = (req) => this.guarded(async () => {
7359
+ this.requireClaudeCode();
7144
7360
  const input = await readBody(req);
7145
7361
  const allowed = new Set(["name", "email", "contactEmail", "slackUserId"]);
7146
7362
  if (Object.keys(input).some((key) => !allowed.has(key)))
@@ -7163,16 +7379,7 @@ class EnrollmentHandlers {
7163
7379
  }
7164
7380
  if (details.name.length > 80 || details.slackUserId && !/^U[A-Z0-9]{8,20}$/.test(details.slackUserId) || [details.email, details.contactEmail].some((email) => email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)))
7165
7381
  throw new EnrollmentError(400, "Invalid invitation details");
7166
- if (this.context.dbOps.getAccountByName(details.name))
7167
- throw new EnrollmentError(409, "Seat already exists");
7168
- for (const entry of this.entries.values())
7169
- if (entry.details.name === details.name && entry.state.kind === "busy")
7170
- throw new EnrollmentError(409, "Sign-in is already in progress");
7171
- for (const [key, entry] of this.entries)
7172
- if (entry.details.name === details.name)
7173
- this.entries.delete(key);
7174
- if (this.entries.size >= 100)
7175
- throw new EnrollmentError(429, "Too many pending invitations");
7382
+ this.reserve(details.name);
7176
7383
  const issued = this.mint(details, { kind: "invited" }, oneDay);
7177
7384
  return json({
7178
7385
  url: `${this.origin.origin}/pool-enroll#${issued.token}`,
@@ -7194,12 +7401,14 @@ class EnrollmentHandlers {
7194
7401
  if (req.headers.get("origin") !== this.origin.origin)
7195
7402
  throw new EnrollmentError(403, "Untrusted enrollment origin");
7196
7403
  const body = await readBody(req);
7404
+ if (url.pathname === "/pool-enroll/request")
7405
+ return this.request(body);
7197
7406
  const token = typeof body.token === "string" && /^[A-Za-z0-9_-]{43}$/.test(body.token) ? body.token : "";
7198
7407
  const key = hash(token);
7199
7408
  const entry = this.entries.get(key);
7200
7409
  const expected = url.pathname === "/pool-enroll/start" ? "invited" : url.pathname === "/pool-enroll/verify" ? "started" : url.pathname === "/pool-enroll/confirm" ? "verified" : "connected";
7201
7410
  if (!entry || entry.expiresAt <= this.now() || entry.state.kind !== expected)
7202
- throw new EnrollmentError(410, "This link has expired or was already used. Ask the operator for a new link.");
7411
+ throw new EnrollmentError(410, "This sign-in has expired or was already used. Start again.");
7203
7412
  const state = entry.state;
7204
7413
  entry.state = { kind: "busy" };
7205
7414
  try {
@@ -7218,12 +7427,12 @@ class EnrollmentHandlers {
7218
7427
  try {
7219
7428
  seat = await this.oauth.verifyClaudeSeat(state.flow, body.code.trim());
7220
7429
  } catch {
7221
- throw new EnrollmentError(400, "Claude sign-in could not be verified. Ask for a new link and try again.");
7430
+ throw new EnrollmentError(400, "Claude sign-in could not be verified. Start again.");
7222
7431
  }
7223
7432
  this.assertCurrent(key, entry);
7224
7433
  const { email } = seat.identity;
7225
7434
  if (!email || entry.details.email && email.toLowerCase() !== entry.details.email.toLowerCase())
7226
- throw new EnrollmentError(400, "This Claude account does not match the invitation. Ask for a new link.");
7435
+ throw new EnrollmentError(400, "This Claude account does not match your email. Start again and sign in with the matching account.");
7227
7436
  this.assertNewSeat(entry.details.name, seat);
7228
7437
  return json({
7229
7438
  ...this.mint(entry.details, { kind: "verified", seat }, tenMinutes),
@@ -7266,7 +7475,7 @@ class EnrollmentHandlers {
7266
7475
  }
7267
7476
  assertCurrent(key, entry) {
7268
7477
  if (this.entries.get(key) !== entry || entry.expiresAt <= this.now())
7269
- throw new EnrollmentError(410, "Sign-in expired. Ask for a new link.");
7478
+ throw new EnrollmentError(410, "Sign-in expired. Start again.");
7270
7479
  }
7271
7480
  assertNewSeat(name, seat) {
7272
7481
  if (this.context.dbOps.getAccountByName(name) || this.context.dbOps.getAccountsByProvider("claude-code").some((account2) => account2.account_uuid === seat.identity.accountUuid))
@@ -7346,11 +7555,13 @@ async function readBody(req) {
7346
7555
  }
7347
7556
  var paths, tenMinutes = 600000, oneDay = 86400000, headers2, json = (value, status = 200) => Response.json(value, { status, headers: headers2 }), hash = (token) => createHash("sha256").update(token).digest("hex"), EnrollmentError;
7348
7557
  var init_enrollment = __esm(() => {
7558
+ init_src2();
7349
7559
  init_src6();
7350
7560
  init_src();
7351
7561
  init_enrollment_page();
7352
7562
  paths = new Set([
7353
7563
  "/pool-enroll",
7564
+ "/pool-enroll/request",
7354
7565
  "/pool-enroll/start",
7355
7566
  "/pool-enroll/verify",
7356
7567
  "/pool-enroll/confirm",
@@ -7767,8 +7978,10 @@ class APIRouter {
7767
7978
  this.registerHandlers();
7768
7979
  }
7769
7980
  registerHandlers() {
7770
- if (this.enrollment)
7981
+ if (this.enrollment) {
7771
7982
  this.staticHandlers.set("POST:/api/enrollment-invitations", this.enrollment.issue);
7983
+ this.staticHandlers.set("POST:/api/enrollment-emails", this.enrollment.emails);
7984
+ }
7772
7985
  const { config, dbOps, getProviders, getRuntimeHealth, providerRegistry } = this.context;
7773
7986
  const healthHandler = createHealthHandler(dbOps, config, getProviders, getRuntimeHealth);
7774
7987
  const statsHandler = createStatsHandler(dbOps);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@certalis/agent-smith",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "PROOF OF CONCEPT for ideation only: an experimental local proxy and provider pool for Claude Code and Codex",
5
5
  "license": "MIT",
6
6
  "author": "Certalis",