diff --git a/AGENTS.md b/AGENTS.md
index e047c071..07f41d64 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -497,3 +497,5 @@ Si algo cambia en el proyecto, se actualiza la frase o se añade una nueva, pero
* **2026-11-08** · Fuera *Show Release Notes* del menú Ayuda y de la paleta («quita lo de mostrar notas de la versión»): llevaba a la página de releases del repositorio, y *Show What's New* ya cuenta lo que cambió.
* **2026-11-08** · Las incidencias no recargan al cambiar de panel («cuando cambio entre paneles las issues recargan siempre»): al volver sale al momento la última lista, abierta como estaba; si tiene más de un minuto se comprueba por detrás y solo se repinta si cambió algo. *Actualizar* sigue pidiéndola de verdad.
* **2026-11-08** · Fuera el selector de permisos del chat («Sí, quítalo»): *Ask / Allow all* prometía una elección que no existía, porque pi ejecuta sin preguntar. Los MCP conectados en el propio editor siguen preguntando (ADR-040, adenda). La web y los términos ya dicen que el agente actúa sin preguntar.
+* **2026-11-09** · Conectar Jira ya no depende del navegador («no conecta jira», «Sí, arréglalo»): Edge no abría el editor al pulsar *Open PiCode*. Ahora el servidor sigue el intento y el editor le pregunta cómo va, como en GitHub. La página de vuelta enseña a qué cuenta se conecta y pide confirmar, para que nadie pueda llevarse el Jira de otra persona con un enlace (ADR-042, adenda 12).
+* **2026-11-09** · Release 2.0.1 en experimental («saca release no? para que probar esto»): lleva la conexión con Jira sin depender del navegador y las builds locales que ya no le quitan los enlaces a la copia instalada.
diff --git a/build/build.sh b/build/build.sh
index 246ce74f..07ec099e 100644
--- a/build/build.sh
+++ b/build/build.sh
@@ -575,6 +575,18 @@ product.dataFolderName = `.${CHANNEL.slug}`;
product.sharedDataFolderName = `.${CHANNEL.slug}-shared`;
product.serverDataFolderName = `.${CHANNEL.slug}-server`;
+// A portable build made on this machine, to test a change: it must not pass for the copy installed
+// here. With the same name and user model id, Windows treats both as one program and its shortcuts
+// and pins end up opening the test build. Releases and installers never get this.
+if (!process.env.GITHUB_ACTIONS && process.env.PICODE_SKIP_INSTALLER === 'yes') {
+ product.picodeLocalBuild = true;
+ product.nameLong = `${product.nameLong} (local build)`;
+ product.win32AppUserModelId = `${product.win32AppUserModelId}.LocalBuild`;
+ product.win32RegValueName = `${product.win32RegValueName}LocalBuild`;
+ product.win32MutexName = `${product.win32MutexName}-local-build`;
+ console.log(' local build: its own name and identity, beside the installed copy');
+}
+
// The AppIds are derived, not random: a channel always gets the same
// identifiers, so a rebuild is the same installer, and two channels can sit
// side by side. One hash per key, so x64 and arm64 never share an id.
diff --git a/docs/CUENTA.md b/docs/CUENTA.md
index 7fa107b8..1d3958a8 100644
--- a/docs/CUENTA.md
+++ b/docs/CUENTA.md
@@ -58,7 +58,7 @@ El login vive en las tarjetas de *Connections* y las issues se ven en la columna
**Qué ve el usuario**
-1. En *Connections*, cada tarjeta (GitHub, Jira) tiene *Connect*. En GitHub, el editor copia un código, abre la página de GitHub y deja el código en la tarjeta por si hace falta de nuevo. Al terminar, si la App de PiCode aún no está instalada en sus repositorios (sin instalar no se ve ninguno), abre sola la página para instalarla y las issues salen en cuanto se instala; si luego falta, el panel lo dice con un botón *Install on GitHub*. En Jira se abre la página de Atlassian y, al aceptar, vuelve solo al editor. Conectada, la tarjeta dice *Connected* (en Jira, a qué sitio) y tiene *Disconnect*. Un intento fallido lo dice en una frase.
+1. En *Connections*, cada tarjeta (GitHub, Jira) tiene *Connect*. En GitHub, el editor copia un código, abre la página de GitHub y deja el código en la tarjeta por si hace falta de nuevo. Al terminar, si la App de PiCode aún no está instalada en sus repositorios (sin instalar no se ve ninguno), abre sola la página para instalarla y las issues salen en cuanto se instala; si luego falta, el panel lo dice con un botón *Install on GitHub*. En Jira se abre la página de Atlassian y, al aceptar, una página de PiCode enseña la cuenta a la que se conectará y pide *Connect*; el editor se entera solo, sin depender de que el navegador lo abra. Conectada, la tarjeta dice *Connected* (en Jira, a qué sitio) y tiene *Disconnect*. Un intento fallido lo dice en una frase.
2. En *Status* aparece un panel por servicio conectado (*GitHub issues*, *Jira issues*): las 5 issues abiertas más recientes, con *Show N more*, y *Refresh*. Las del proyecto abierto van primero; en GitHub, hasta 15 repos. Sin conectar, el panel dice *Not connected* y su botón *Connect* abre la ventana Account. Sin plan Pro o sin identificadores registrados, el panel no sale.
3. Pulsar una issue abre el visor (ver abajo). Cada issue ocupa dos líneas: el título entero (hasta dos líneas) y debajo, en pequeño, su etiqueta (si el título empieza por `[algo]`, sale como etiqueta y no come espacio), la clave, la antigüedad y *Outside report* si la escribió alguien ajeno al equipo. A la derecha, *Fix with pi* es un botón solo de icono (el texto sale al pasar el ratón). El título del panel lleva el total de issues abiertas.
4. *Fix with pi* abre un chat nuevo y **escribe** la issue sin enviarla. El usuario la lee y envía. Sin carpeta abierta, con la carpeta sin confianza o con una issue de otro repo, el botón sale apagado con su motivo.
@@ -103,7 +103,7 @@ Límites: hasta 300 issues por proyecto (lo avisa); sin el permiso de tableros e
| Paneles de Status y *Fix with pi* | `account/issues/picodeStatusIssues.ts`, `picodeIssueActions.ts` |
| Visor (pestaña del editor) | `account/issues/picodeIssuesEditor.ts`, `picodeIssuesEditorInput.ts`, `picodeIssues.contribution.ts` (comando `picode.issues.open`) |
| Lógica pura con pruebas | `picodeConnectorAuth.ts`, `picodeIssueSources.ts`, `picodeIssueBoard.ts`, `picodeIssuePrompt.ts` |
-| Servidor (secretos de Atlassian y renovación) | `host/cloud/sync-api`: `POST /api/v1/connect/token`, `POST /api/v1/connect/github`, `GET /api/v1/connect/atlassian/callback` |
+| Servidor (secretos de Atlassian y renovación) | `host/cloud/sync-api`: `POST /api/v1/connect/atlassian`, `GET` y `POST /api/v1/connect/atlassian/callback`, `POST /api/v1/connect/github` (y `POST /api/v1/connect/token` para las versiones anteriores a la 2.0.1) |
| Identificadores públicos | `product.json` → `picode.connectors` |
- **GitHub:** GitHub App con inicio por código de un solo uso. Las páginas de login de GitHub no admiten llamadas desde la ventana del editor (sin permiso entre orígenes: «Failed to fetch», visto el 2026-11-06), así que el editor pide el código y la espera por el servidor (`connect/github`), que no guarda nada. Permisos mínimos: *Issues: lectura*. Los repos son los que el usuario eligió al instalarla (`/user/installations`).
diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md
index b5bafb1c..f3af14e0 100644
--- a/docs/DECISIONS.md
+++ b/docs/DECISIONS.md
@@ -1842,4 +1842,6 @@ usuario; la App da lectura de issues y solo en los repos que el usuario elija al
**Adenda 10 (2026-11-08) — las issues también en el diseño Editor, y el plan del agente en Status.** *"en la ventana editor no hay nada de las issues de jira y github, quiero… que solo se activa si tienes cuenta pro"*. Una vista nativa del editor (`picodeIssuesView.ts`) pinta el mismo panel dentro del contenedor de PiCode; se une a él cuando el conector lo declara (el contenedor viene de su manifiesto) y se muestra con la clave `picode.account.isPro`. Además, *Status* y *Tasks* enseñan el plan del agente (la herramienta `todo` de gentle-pi), leído del resultado de cada llamada, que trae la lista entera: no se reconstruye nada. La lista queda mientras haya una tarea abierta y 5 minutos después de cerrarse la última. El fichero de estado de gentle-pi 4.7.0 no sirve para esto: solo se escribe cuando pi corre en la terminal.
+**Adenda 12 (2026-11-09) — conectar Jira ya no depende del enlace del navegador.** *"no conecta jira"*, *"Sí, arréglalo"*. La vuelta de Atlassian al editor iba por `picode://`: Edge no lo abría al pulsar *Open PiCode* (causa sin averiguar; el mismo enlace lanzado desde Windows sí funciona) y además otra copia de PiCode podía quedarse con el enlace. Ahora el servidor abre un intento para la cuenta, y el editor pregunta cada 2,5 s cómo va, como ya hacía GitHub. **El riesgo nuevo:** si el código se entrega a quien tenga el `state`, alguien podría abrir un intento con su cuenta, mandar el enlace de aprobación a otra persona y recibir el Jira de esa persona. Por eso la página de vuelta enseña a qué cuenta de PiCode se conectará, con la dirección entera y solo si está verificada (una dirección tapada se confunde con otra parecida), y pide confirmar (sin scripts y sin poder meterla en un marco), y el servidor canjea el código en ese momento: nunca se guarda ni llega al editor. **Alternativa descartada:** guardar el código y que lo recogiera el editor, sin confirmación, porque deja abierto ese robo. **Coste:** un clic más. El `state` se guarda solo como hash; los intentos caducan a los 10 minutos y `expireAt` queda listo para una regla TTL de Firestore que borre los abandonados. Las versiones publicadas siguen funcionando con su página y `connect/token`, hasta que se puedan retirar; los `state` del servidor empiezan por `att-`, así que su código nunca sale por `picode://`, ni siquiera con el intento caducado.
+
**Adenda 11 (2026-11-08) — los tokens de GitHub y Jira pasan al servidor.** *"protégelo anti hacks"*, *"Sí, en el servidor"*. El editor llamaba directo a GitHub y Jira con un token guardado en el equipo, así que cualquier comprobación de Pro se podía quitar parcheando el código. Ahora el servidor guarda los tokens cifrados y hace cada llamada, comprobando el plan cada vez y solo para las llamadas de una lista cerrada. **Alternativa descartada por el dueño:** dejarlos en el equipo, sin credenciales ajenas que custodiar, a costa de que parchear la app permitiera ver las propias issues. **Coste:** responsabilidad sobre credenciales de terceros, más tráfico de servidor y ninguna lectura sin conexión. Se quitan las rutas que devolvían tokens: ninguna release publicada las usaba.
diff --git a/docs/howto-build.md b/docs/howto-build.md
index 29cd287c..08b85197 100644
--- a/docs/howto-build.md
+++ b/docs/howto-build.md
@@ -84,6 +84,16 @@ salida —build bueno, build fallido o `Ctrl-C`—, así que después de compila
limpio. Si el build muere de golpe (sin trampa de salida), la copia sigue ahí y la siguiente
fase 1 la devuelve **antes** de leer nada.
+### Un build local no se hace pasar por la copia instalada
+
+Un build portable hecho en tu equipo (`PICODE_SKIP_INSTALLER=yes`, fuera de GitHub Actions) sale
+como `PiCode Experimental - … (local build)`, con su propia identidad para Windows. Con la misma,
+Windows los trataba como un solo programa y sus accesos terminaban abriendo el build de `D:`.
+
+Tampoco se queda con los enlaces `picode://` si ya los tiene otra copia. Para probar el inicio de
+sesión con un build local, arráncalo con `PICODE_CLAIM_LINKS=1`, y abre después la copia instalada
+para que los recupere.
+
## Dependencias en Windows
Los scripts están en **Bash**, así que en Windows se ejecutan desde **Git Bash**
diff --git a/extensions/picode/test/picode-connector-auth.test.ts b/extensions/picode/test/picode-connector-auth.test.ts
index 9473b500..33c59966 100644
--- a/extensions/picode/test/picode-connector-auth.test.ts
+++ b/extensions/picode/test/picode-connector-auth.test.ts
@@ -7,9 +7,7 @@
import assert from 'assert';
import { test } from 'node:test';
-import { atlassianAuthorizeUrl, connectorCodeFromCallback, parseAtlassianSites, parseDeviceCode, parseDevicePoll } from '../../../src/vs/workbench/contrib/picode/browser/account/issues/picodeConnectorAuth.ts';
-
-const callback = (query: string, path = '/connect/callback', scheme = 'picode') => ({ scheme, path, query });
+import { atlassianAuthorizeUrl, parseAtlassianSites, parseDeviceCode, parseDevicePoll, readAtlassianAttemptState, readAtlassianAttemptStatus } from '../../../src/vs/workbench/contrib/picode/browser/account/issues/picodeConnectorAuth.ts';
test('a device code from GitHub is read, with its wait in milliseconds', () => {
const code = parseDeviceCode({ device_code: 'd', user_code: 'ABCD-1234', verification_uri: 'https://github.com/login/device', interval: 5, expires_in: 900 });
@@ -38,26 +36,22 @@ test('a token in the answer is not taken for a connection, nor kept', () => {
assert.deepStrictEqual(parseDevicePoll({ access_token: 'a', refresh_token: 'r' }), { kind: 'failed' });
});
-test('the callback of this attempt gives its code', () => {
- assert.strictEqual(connectorCodeFromCallback(callback('service=atlassian&state=S1&code=abc'), 'picode', 'atlassian', 'S1'), 'abc');
-});
-
-test('the address the cloud page really sends (connect as host, /callback as path) gives its code', () => {
- const real = { scheme: 'picode', authority: 'connect', path: '/callback', query: 'service=atlassian&state=S1&code=abc' };
- assert.strictEqual(connectorCodeFromCallback(real, 'picode', 'atlassian', 'S1'), 'abc');
- assert.strictEqual(connectorCodeFromCallback({ ...real, authority: 'other' }, 'picode', 'atlassian', 'S1'), undefined);
-});
-
-test('a callback with another state, service, path or scheme is left alone', () => {
- assert.strictEqual(connectorCodeFromCallback(callback('service=atlassian&state=OTHER&code=abc'), 'picode', 'atlassian', 'S1'), undefined);
- assert.strictEqual(connectorCodeFromCallback(callback('service=github&state=S1&code=abc'), 'picode', 'atlassian', 'S1'), undefined);
- assert.strictEqual(connectorCodeFromCallback(callback('service=atlassian&state=S1&code=abc', '/auth/callback'), 'picode', 'atlassian', 'S1'), undefined);
- assert.strictEqual(connectorCodeFromCallback(callback('service=atlassian&state=S1&code=abc', '/connect/callback', 'other'), 'picode', 'atlassian', 'S1'), undefined);
+test('the state of a Jira attempt is taken only when it is plain', () => {
+ assert.strictEqual(readAtlassianAttemptState({ state: '0f8fad5b-d9cb-469f' }), '0f8fad5b-d9cb-469f');
+ assert.strictEqual(readAtlassianAttemptState({ state: 'short' }), undefined);
+ assert.strictEqual(readAtlassianAttemptState({ state: 'a&redirect_uri=https://evil' }), undefined);
+ assert.strictEqual(readAtlassianAttemptState({}), undefined);
+ assert.strictEqual(readAtlassianAttemptState(undefined), undefined);
});
-test('a callback without a code, or with no state expected, is not claimed', () => {
- assert.strictEqual(connectorCodeFromCallback(callback('service=atlassian&state=S1'), 'picode', 'atlassian', 'S1'), undefined);
- assert.strictEqual(connectorCodeFromCallback(callback('service=atlassian&state=&code=abc'), 'picode', 'atlassian', ''), undefined);
+test('a Jira attempt ends only when the server says so', () => {
+ for (const status of ['connected', 'failed', 'notPro', 'expired'] as const) {
+ assert.strictEqual(readAtlassianAttemptStatus({ status }), status);
+ }
+ assert.strictEqual(readAtlassianAttemptStatus({ status: 'pending' }), 'waiting');
+ assert.strictEqual(readAtlassianAttemptStatus({ status: 'trading' }), 'waiting');
+ assert.strictEqual(readAtlassianAttemptStatus({ status: 'other' }), 'waiting');
+ assert.strictEqual(readAtlassianAttemptStatus(undefined), 'waiting');
});
test('the permission page asks only to read issues, and renews itself', () => {
diff --git a/host/cloud/sync-api/README.md b/host/cloud/sync-api/README.md
index 46f3c24a..b3d999d6 100644
--- a/host/cloud/sync-api/README.md
+++ b/host/cloud/sync-api/README.md
@@ -58,8 +58,9 @@ test/ # pure rules: plans, retention
| `usage-store.ts` | Los contadores diarios en Firestore (`usage/{día}`) y su lectura para el resumen. |
| `connect.ts` | 🔌 Conectar GitHub y Jira: qué se le pide al proveedor (con el secreto del servidor) y la dirección `picode://` de vuelta. Los tokens nunca vuelven al editor. Sin red ni Next.js. |
| `connect-proxy.ts` | 🔌 La lista cerrada de llamadas a GitHub y Jira que el editor puede pedir. Sin red ni Next.js. |
+| `connect-attempts.ts` | 🔌 Cada intento de conectar Jira: la cuenta que lo abrió, cómo va y quién puede preguntarlo. Sin Firestore ni red. |
| `connect-seal.ts` | 🔐 Cifrado de los tokens, con clave propia y ligados a su dueño y servicio. Sin clave no se guarda nada. |
-| `connect-store.ts` | 🔌 Las conexiones en `/users/{uid}/connectors/{service}`. |
+| `connect-store.ts` | 🔌 Las conexiones en `/users/{uid}/connectors/{service}`, los intentos en `/connectAttempts` y el canje del código de Jira. |
| `connect-renewal.ts` | 🔌 La renovación de los tokens, una a la vez, sin perder una conexión por un fallo pasajero. Sin Firestore ni red. |
| `stats-auth.ts` | Quién puede leer `/api/stats`: solo quien lleve `STATS_TOKEN`; sin token configurado, nadie. |
| `downloads.ts` | Las descargas de la release actual de cada canal, leídas de GitHub. |
@@ -78,10 +79,11 @@ test/ # pure rules: plans, retention
| `v1/resource/route.ts` | Borra todos los datos del usuario + manifiesto. |
| `v1/collection/` · `v1/collection/[id]/` | Lista, crea y borra colecciones (idempotente). |
| `v1/account/route.ts` | La cuenta, con CORS (la llama el renderer del editor). |
-| `v1/connect/token/route.ts` | 🔌 Cambia un código de Atlassian por la conexión y la guarda. Solo Pro (`402`); no devuelve tokens. |
+| `v1/connect/atlassian/route.ts` | 🔌 Abre un intento de conectar Jira y dice cómo va. Solo Pro (`402`). |
+| `v1/connect/token/route.ts` | 🔌 Para versiones anteriores a los intentos: cambia un código de Atlassian por la conexión y la guarda. Solo Pro (`402`); no devuelve tokens. |
| `v1/connect/proxy/route.ts` | 🔌 Hace una llamada a GitHub o Jira con la conexión guardada. Solo Pro (`402`), en cada llamada; sin Pro, borra las conexiones. |
| `v1/connect/disconnect/route.ts` | 🔌 Borra una conexión. Para cualquier plan. |
-| `v1/connect/atlassian/callback/route.ts` | 🔌 A donde vuelve el navegador tras Atlassian: reenvía el código al editor por `picode://connect/callback`. |
+| `v1/connect/atlassian/callback/route.ts` | 🔌 A donde vuelve el navegador tras Atlassian: pide confirmar la cuenta y canjea el código. Sin intento abierto (versiones anteriores), lo reenvía al editor por `picode://connect/callback`. |
| `v1/download/` · `v1/download/latest/` | `404` en v1 (no se usa), pero autenticado y con control de plan. |
La tabla completa de métodos y códigos de respuesta está más abajo, en *API endpoints*.
diff --git a/host/cloud/sync-api/src/app/api/v1/connect/atlassian/callback/route.ts b/host/cloud/sync-api/src/app/api/v1/connect/atlassian/callback/route.ts
index 50d94fea..e6f7d148 100644
--- a/host/cloud/sync-api/src/app/api/v1/connect/atlassian/callback/route.ts
+++ b/host/cloud/sync-api/src/app/api/v1/connect/atlassian/callback/route.ts
@@ -1,31 +1,92 @@
import { NextRequest, NextResponse } from "next/server";
-import { editorCallbackUrl } from "@/lib/connect";
+import { editorCallbackUrl, isCallbackCode, isCallbackState } from "@/lib/connect";
+import { AttemptRecord, isAttemptState } from "@/lib/connect-attempts";
+import { connectAttempts, connectWithCode } from "@/lib/connect-store";
import { textResponse } from "@/lib/http";
+import { readUserPlan } from "@/lib/quota";
export const dynamic = "force-dynamic";
const PAGE_HEADERS = {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-store",
- // No scripts, no outside loads: the page is a link back to the editor and nothing else.
- "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'",
+ // No scripts, no outside loads: the only thing the page can do is post its own form back here.
+ // It cannot be framed, so the confirmation cannot be clicked without being seen.
+ "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",
+ "X-Frame-Options": "DENY",
"Referrer-Policy": "no-referrer",
};
+const STYLE = "body{font:16px system-ui,sans-serif;display:grid;place-items:center;min-height:100vh;margin:0;background:#111;color:#eee}main{max-width:26rem;padding:2rem;text-align:center}a,button{display:inline-block;margin-top:1rem;padding:.6rem 1.2rem;border:0;border-radius:.5rem;background:#4f8cff;color:#fff;font:inherit;text-decoration:none;cursor:pointer}small{display:block;margin-top:1.5rem;color:#aaa}";
+
function page(title: string, body: string, head = ""): NextResponse {
- const html = `
${title}${head}${body}`;
+ const html = `${title}${head}${body}`;
return textResponse(html, 200, PAGE_HEADERS);
}
+function escapeHtml(text: string): string {
+ return text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
+}
+
+const notConnected = (): NextResponse =>
+ page("Jira was not connected", "Jira was not connected
Go back to PiCode and try again.
");
+
+const notPro = (): NextResponse =>
+ page("Jira was not connected", "Jira was not connected
Connecting Jira needs a PiCode Pro plan.
");
+
+const connected = (): NextResponse =>
+ page("Jira is connected", "Jira is connected
You can close this page and go back to PiCode.
");
+
+function confirmation(attempt: AttemptRecord, state: string, code: string): NextResponse {
+ return page(
+ "Connect Jira",
+ `Connect Jira to PiCode?
Jira will be connected to ${escapeHtml(attempt.accountLabel)}.
`
+ + ``
+ + "Only continue if you started this in PiCode and you recognise this account. If not, close this page: whoever holds it would see your Jira.",
+ );
+}
+
+/** How an attempt stands when there is nothing left to confirm. */
+function outcome(attempt: AttemptRecord | undefined): NextResponse {
+ switch (attempt?.status) {
+ case "connected":
+ return connected();
+ case "trading":
+ return page("Connecting Jira", "Connecting Jira
Go back to PiCode: it will show when this is done.
");
+ case "notPro":
+ return notPro();
+ default:
+ return notConnected();
+ }
+}
+
/**
* GET /api/v1/connect/atlassian/callback
- * Where Atlassian sends the browser after the person agrees. It hands the code and the `state`
- * to the editor through the `picode://` address; the editor checks the state is its own.
+ * Where Atlassian sends the browser after the person agrees. With an attempt open on this server
+ * it asks to confirm the account (`POST` below). Without one, the editor is from before attempts:
+ * it gets the code and the `state` through `picode://` and checks the state is its own.
*/
-export function GET(request: NextRequest): NextResponse {
- const target = editorCallbackUrl(request.nextUrl.searchParams);
+export async function GET(request: NextRequest): Promise {
+ const query = request.nextUrl.searchParams;
+ const state = query.get("state");
+ if (isCallbackState(state) && isAttemptState(state)) {
+ // Never the page below, even when the attempt is gone: this code must not leave through a link.
+ try {
+ const attempt = await connectAttempts.find(state);
+ const code = query.get("code");
+ if (attempt?.status === "pending" && (query.has("error") || !isCallbackCode(code))) {
+ // The person said no on Atlassian's page: the editor can stop waiting.
+ await connectAttempts.finish(state, "failed");
+ return notConnected();
+ }
+ return attempt?.status === "pending" && isCallbackCode(code) ? confirmation(attempt, state, code) : outcome(attempt);
+ } catch {
+ return notConnected();
+ }
+ }
+ const target = editorCallbackUrl(query);
if (!target) {
- return page("Jira was not connected", "Jira was not connected
Go back to PiCode and try again.
");
+ return notConnected();
}
// `target` holds only token characters and percent-escapes, so it is safe inside an attribute.
return page(
@@ -34,3 +95,34 @@ export function GET(request: NextRequest): NextResponse {
``,
);
}
+
+/**
+ * POST /api/v1/connect/atlassian/callback
+ * The person confirmed the account: trade the code for that account's connection. The plan is
+ * read here, not trusted from when the attempt was opened.
+ */
+export async function POST(request: NextRequest): Promise {
+ const form = await request.formData().catch(() => undefined);
+ const state = form?.get("state");
+ const code = form?.get("code");
+ if (!isCallbackState(state) || !isAttemptState(state) || !isCallbackCode(code)) {
+ return notConnected();
+ }
+ try {
+ const { won, record } = await connectAttempts.claim(state);
+ if (!won || !record) {
+ return outcome(record);
+ }
+ if ((await readUserPlan(record.uid)) !== "pro") {
+ await connectAttempts.finish(state, "notPro");
+ return notPro();
+ }
+ await connectWithCode(record.uid, { service: "atlassian", grant: "authorization_code", code });
+ } catch {
+ await connectAttempts.finish(state, "failed").catch(() => undefined);
+ return notConnected();
+ }
+ // Connected already: failing to note it must not tell the person otherwise.
+ await connectAttempts.finish(state, "connected").catch(() => undefined);
+ return connected();
+}
diff --git a/host/cloud/sync-api/src/app/api/v1/connect/atlassian/route.ts b/host/cloud/sync-api/src/app/api/v1/connect/atlassian/route.ts
new file mode 100644
index 00000000..55114cf3
--- /dev/null
+++ b/host/cloud/sync-api/src/app/api/v1/connect/atlassian/route.ts
@@ -0,0 +1,47 @@
+import { NextRequest, NextResponse } from "next/server";
+import { authenticateRequest, requirePro } from "@/lib/auth";
+import { parseAttemptRequest } from "@/lib/connect-attempts";
+import { connectAttempts } from "@/lib/connect-store";
+import { errorResponse, jsonResponse, operationIdHeaders } from "@/lib/http";
+
+export const dynamic = "force-dynamic";
+
+/** Called from the editor's renderer, so CORS applies (same reasoning as `/api/v1/account`). */
+const CORS: Record = {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Headers": "authorization, content-type, x-client-name, x-client-version, x-account-type",
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
+};
+
+/**
+ * POST /api/v1/connect/atlassian
+ * `begin` opens a Jira connection attempt for the signed-in Pro account and returns the `state`
+ * to send to Atlassian; `poll` says how that attempt is going and `cancel` closes it when the
+ * editor stops waiting (`lib/connect-attempts.ts`).
+ */
+export async function POST(request: NextRequest): Promise {
+ try {
+ const context = await authenticateRequest(request);
+ requirePro(context);
+ const attempt = parseAttemptRequest(await request.json().catch(() => undefined));
+ if (attempt.action === "begin") {
+ return jsonResponse({ state: await connectAttempts.begin(context.uid, context.email, context.emailVerified) }, 200, CORS);
+ }
+ if (attempt.action === "cancel") {
+ await connectAttempts.cancel(context.uid, attempt.state);
+ return jsonResponse({ cancelled: true }, 200, CORS);
+ }
+ return jsonResponse({ status: await connectAttempts.poll(context.uid, attempt.state) }, 200, CORS);
+ } catch (error) {
+ // The editor has to be able to read a refusal: a 402 means "not Pro", not "try again".
+ const response = errorResponse(error);
+ for (const [name, value] of Object.entries(CORS)) {
+ response.headers.set(name, value);
+ }
+ return response;
+ }
+}
+
+export function OPTIONS(): NextResponse {
+ return new NextResponse(null, { status: 204, headers: { ...CORS, ...operationIdHeaders() } });
+}
diff --git a/host/cloud/sync-api/src/app/api/v1/connect/token/route.ts b/host/cloud/sync-api/src/app/api/v1/connect/token/route.ts
index 863c83a9..ba222fe5 100644
--- a/host/cloud/sync-api/src/app/api/v1/connect/token/route.ts
+++ b/host/cloud/sync-api/src/app/api/v1/connect/token/route.ts
@@ -1,9 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { authenticateRequest, requirePro } from "@/lib/auth";
-import { buildProviderRequest, parseConnectRequest, readProviderTokens } from "@/lib/connect";
-import { connectorKey } from "@/lib/connect-seal";
-import { saveConnection } from "@/lib/connect-store";
-import { HttpError } from "@/lib/errors";
+import { parseConnectRequest } from "@/lib/connect";
+import { connectWithCode } from "@/lib/connect-store";
import { errorResponse, jsonResponse, operationIdHeaders } from "@/lib/http";
export const dynamic = "force-dynamic";
@@ -17,34 +15,16 @@ const CORS: Record = {
/**
* POST /api/v1/connect/token
- * Trades a Jira code for the connection of the signed-in Pro account. The provider's secret lives
- * in this server's environment and the tokens stay here, sealed: the editor is only told it worked.
+ * Trades a Jira code for the connection of the signed-in Pro account, for editors from before
+ * `/api/v1/connect/atlassian`: they receive the code through `picode://`. The editor is only told
+ * it worked.
*/
export async function POST(request: NextRequest): Promise {
try {
const context = await authenticateRequest(request);
requirePro(context);
const connect = parseConnectRequest(await request.json().catch(() => undefined));
- // Before the one-use code is spent: with no way to store the tokens, there is no point.
- connectorKey(process.env);
- const provider = buildProviderRequest(connect, process.env);
- let response: Response;
- try {
- response = await fetch(provider.url, {
- method: "POST",
- headers: { "Content-Type": "application/json", Accept: "application/json", "User-Agent": "PiCode" },
- body: JSON.stringify(provider.body),
- signal: AbortSignal.timeout(15_000),
- });
- } catch {
- throw new HttpError(502, "BadGateway", "The service could not be reached.");
- }
- const tokens = readProviderTokens(await response.json().catch(() => undefined));
- if (!tokens) {
- // The provider refused (revoked, expired, wrong code). The editor treats 401 as "connect again".
- throw new HttpError(401, "Unauthorized", "The connection needs to be made again.");
- }
- await saveConnection(context.uid, connect.service, tokens);
+ await connectWithCode(context.uid, connect);
return jsonResponse({ connected: true }, 200, CORS);
} catch (error) {
return errorResponse(error);
diff --git a/host/cloud/sync-api/src/lib/auth.ts b/host/cloud/sync-api/src/lib/auth.ts
index 97203150..b28231bb 100644
--- a/host/cloud/sync-api/src/lib/auth.ts
+++ b/host/cloud/sync-api/src/lib/auth.ts
@@ -101,6 +101,7 @@ export async function authenticateRequest(
return {
uid: decoded.uid,
email: typeof decoded.email === "string" && decoded.email ? decoded.email : undefined,
+ emailVerified: decoded.email_verified === true,
accountType,
clientName,
clientVersion: request.headers.get("x-client-version") ?? "0",
diff --git a/host/cloud/sync-api/src/lib/connect-attempts.ts b/host/cloud/sync-api/src/lib/connect-attempts.ts
new file mode 100644
index 00000000..5359a9db
--- /dev/null
+++ b/host/cloud/sync-api/src/lib/connect-attempts.ts
@@ -0,0 +1,172 @@
+import { createHash, randomUUID } from "node:crypto";
+import { HttpError } from "./errors";
+
+/**
+ * One try at connecting Jira, tracked on this server so the editor never depends on the browser
+ * handing it back through `picode://` (a browser can refuse that link, and another copy of PiCode
+ * can have claimed it).
+ *
+ * The editor opens an attempt for its signed-in account and asks how it is going. Whoever comes
+ * back from Atlassian with that attempt's `state` is shown which PiCode account will get the
+ * connection and must confirm it: without that step, someone could send a victim their own
+ * approval link and receive the victim's Jira in their own account.
+ *
+ * The code is traded when the person confirms, so it is never stored and never reaches the editor.
+ * Pure (no Next.js, no database): `connect-store.ts` supplies the records.
+ */
+
+export type AttemptStatus = "pending" | "trading" | "connected" | "failed" | "notPro";
+/** What the editor is told. `expired` also covers an unknown attempt or someone else's. */
+export type AttemptAnswer = AttemptStatus | "expired";
+
+export interface AttemptRecord {
+ readonly uid: string;
+ /** Shown on the confirmation page; never the whole address. */
+ readonly accountLabel: string;
+ readonly status: AttemptStatus;
+ readonly expiresAt: number;
+}
+
+export interface AttemptRecords {
+ create(key: string, record: AttemptRecord): Promise;
+ read(key: string): Promise;
+ remove(key: string): Promise;
+ /** Runs `decide` on the current record inside one transaction; `next` replaces it when given. */
+ change(key: string, decide: (current: AttemptRecord | undefined) => { next?: AttemptRecord; result: T }): Promise;
+}
+
+/** The longest the person can take on Atlassian's pages and the confirmation. */
+export const ATTEMPT_TTL_MS = 10 * 60_000;
+
+const STATE = /^[A-Za-z0-9-]{8,128}$/;
+
+/**
+ * What the confirmation page calls the account that will get the connection. The whole address, or
+ * two similar ones would look the same. An address nobody verified could be someone else's, so it
+ * is not shown as if it identified the account, and the wording never reads as the visitor's own.
+ */
+export function accountLabel(email: string | undefined, verified: boolean): string {
+ return email && verified ? email : "a PiCode account whose email is not verified";
+}
+
+/** States this server issued, told apart without a record: their code never goes to `picode://`. */
+export const ATTEMPT_STATE_PREFIX = "att-";
+
+export function isAttemptState(state: string): boolean {
+ return state.startsWith(ATTEMPT_STATE_PREFIX);
+}
+
+/** The state travels through the browser; only its hash is kept, so the records alone open nothing. */
+export function attemptKey(state: string): string {
+ return createHash("sha256").update(state).digest("hex");
+}
+
+export type AttemptRequest =
+ | { readonly action: "begin" }
+ | { readonly action: "poll" | "cancel"; readonly state: string };
+
+export function parseAttemptRequest(raw: unknown): AttemptRequest {
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
+ throw new HttpError(400, "BadRequest", "Expected a JSON object.");
+ }
+ const body = raw as Record;
+ if (body.action === "begin") {
+ return { action: "begin" };
+ }
+ if ((body.action === "poll" || body.action === "cancel") && typeof body.state === "string" && STATE.test(body.state)) {
+ return { action: body.action, state: body.state };
+ }
+ throw new HttpError(400, "BadRequest", "Unknown action.");
+}
+
+export interface AttemptDeps {
+ readonly records: AttemptRecords;
+ readonly now?: () => number;
+ readonly newState?: () => string;
+}
+
+export function createAttempts(deps: AttemptDeps) {
+ const { records } = deps;
+ const now = deps.now ?? Date.now;
+ const newState = deps.newState ?? (() => `${ATTEMPT_STATE_PREFIX}${randomUUID()}`);
+
+ const live = (record: AttemptRecord | undefined): AttemptRecord | undefined =>
+ record && record.expiresAt > now() ? record : undefined;
+
+ return {
+ /** Opens an attempt for this account and returns the `state` to send to Atlassian. */
+ async begin(uid: string, email: string | undefined, emailVerified: boolean): Promise {
+ const state = newState();
+ await records.create(attemptKey(state), {
+ uid,
+ accountLabel: accountLabel(email, emailVerified),
+ status: "pending",
+ expiresAt: now() + ATTEMPT_TTL_MS,
+ });
+ return state;
+ },
+
+ /** The attempt as the browser page needs it; `undefined` when unknown or too old. */
+ async find(state: string): Promise {
+ return live(await records.read(attemptKey(state)));
+ },
+
+ /**
+ * Takes the attempt for trading. Only one confirmation wins (a double click must not spend the
+ * one-use code twice); the others get the record back to show how it stands.
+ */
+ claim(state: string): Promise<{ readonly won: boolean; readonly record: AttemptRecord | undefined }> {
+ return records.change<{ readonly won: boolean; readonly record: AttemptRecord | undefined }>(attemptKey(state), (current) => {
+ const record = live(current);
+ if (record?.status !== "pending") {
+ return { result: { won: false, record } };
+ }
+ const next: AttemptRecord = { ...record, status: "trading" };
+ return { next, result: { won: true, record: next } };
+ });
+ },
+
+ /**
+ * Records how it ended; a finished attempt stays as it is. A trade already under way may end
+ * after the time limit, and the owner still gets time to hear how it went.
+ */
+ finish(state: string, status: "connected" | "failed" | "notPro"): Promise {
+ return records.change(attemptKey(state), (current) => {
+ if (!current || (current.status !== "trading" && !(current.status === "pending" && live(current)))) {
+ return { result: undefined };
+ }
+ return { next: { ...current, status, expiresAt: now() + ATTEMPT_TTL_MS }, result: undefined };
+ });
+ },
+
+ /** The owner stopped waiting: from here the attempt can no longer be confirmed. */
+ cancel(uid: string, state: string): Promise {
+ const key = attemptKey(state);
+ return records.change(key, (current) => {
+ if (current?.uid !== uid || current.status !== "pending") {
+ return { result: undefined };
+ }
+ return { next: { ...current, status: "failed" }, result: undefined };
+ });
+ },
+
+ /** What the editor sees. An answer that ends the attempt also removes it. */
+ async poll(uid: string, state: string): Promise {
+ const key = attemptKey(state);
+ const current = await records.read(key);
+ // Someone else's attempt looks exactly like one that does not exist.
+ if (!current || current.uid !== uid) {
+ return "expired";
+ }
+ if (!live(current)) {
+ await records.remove(key);
+ return "expired";
+ }
+ if (current.status === "pending" || current.status === "trading") {
+ return current.status;
+ }
+ await records.remove(key);
+ return current.status;
+ },
+ };
+}
diff --git a/host/cloud/sync-api/src/lib/connect-store.ts b/host/cloud/sync-api/src/lib/connect-store.ts
index 40e1f5d7..f3c0e6fe 100644
--- a/host/cloud/sync-api/src/lib/connect-store.ts
+++ b/host/cloud/sync-api/src/lib/connect-store.ts
@@ -1,5 +1,9 @@
-import type { ConnectService } from "./connect";
+import { buildProviderRequest, readProviderTokens } from "./connect";
+import type { ConnectRequest, ConnectService } from "./connect";
+import { AttemptRecord, createAttempts } from "./connect-attempts";
import { ConnectionRecord, ConnectionRecords, createConnections } from "./connect-renewal";
+import { connectorKey } from "./connect-seal";
+import { HttpError } from "./errors";
import { FIRESTORE_COLLECTIONS, firestore } from "./firebase-admin";
/**
@@ -50,6 +54,77 @@ export const saveConnection = connections.save;
export const deleteConnection = connections.remove;
export const accessTokenFor = connections.accessTokenFor;
+/**
+ * Trades a Jira code for the account's connection. The provider's secret lives in this server's
+ * environment and the tokens stay here, sealed.
+ */
+export async function connectWithCode(uid: string, connect: ConnectRequest): Promise {
+ // Before the one-use code is spent: with no way to store the tokens, there is no point.
+ connectorKey(process.env);
+ const provider = buildProviderRequest(connect, process.env);
+ let response: Response;
+ try {
+ response = await fetch(provider.url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", Accept: "application/json", "User-Agent": "PiCode" },
+ body: JSON.stringify(provider.body),
+ signal: AbortSignal.timeout(15_000),
+ });
+ } catch {
+ throw new HttpError(502, "BadGateway", "The service could not be reached.");
+ }
+ const tokens = readProviderTokens(await response.json().catch(() => undefined));
+ if (!tokens) {
+ // The provider refused (revoked, expired, wrong code). The editor treats 401 as "connect again".
+ throw new HttpError(401, "Unauthorized", "The connection needs to be made again.");
+ }
+ await saveConnection(uid, connect.service, tokens);
+}
+
+/**
+ * Jira connection attempts (`connect-attempts.ts`). Not under the user's document: the browser
+ * comes back with only the `state`. Client access is denied by the Firestore rules' default, and
+ * `expireAt` is there for a TTL policy to clear abandoned attempts.
+ */
+const ATTEMPTS_COLLECTION = "connectAttempts";
+
+function attemptReference(key: string) {
+ return firestore().collection(ATTEMPTS_COLLECTION).doc(key);
+}
+
+function toAttempt(data: FirebaseFirestore.DocumentData | undefined): AttemptRecord | undefined {
+ if (!data || typeof data.uid !== "string" || typeof data.expiresAt !== "number") {
+ return undefined;
+ }
+ return { uid: data.uid, accountLabel: String(data.accountLabel ?? ""), status: data.status, expiresAt: data.expiresAt };
+}
+
+const withExpiry = (record: AttemptRecord) => ({ ...record, expireAt: new Date(record.expiresAt) });
+
+export const connectAttempts = createAttempts({
+ records: {
+ async create(key, record) {
+ await attemptReference(key).create(withExpiry(record));
+ },
+ async read(key) {
+ return toAttempt((await attemptReference(key).get()).data());
+ },
+ async remove(key) {
+ await attemptReference(key).delete();
+ },
+ change(key, decide) {
+ const ref = attemptReference(key);
+ return firestore().runTransaction(async (transaction) => {
+ const { next, result } = decide(toAttempt((await transaction.get(ref)).data()));
+ if (next) {
+ transaction.set(ref, withExpiry(next));
+ }
+ return result;
+ });
+ },
+ },
+});
+
export async function deleteAllConnections(uid: string): Promise {
await Promise.all((["github", "atlassian"] as const).map((service) => connections.remove(uid, service)));
}
diff --git a/host/cloud/sync-api/src/lib/connect.ts b/host/cloud/sync-api/src/lib/connect.ts
index c4bd9af1..04dac444 100644
--- a/host/cloud/sync-api/src/lib/connect.ts
+++ b/host/cloud/sync-api/src/lib/connect.ts
@@ -184,15 +184,23 @@ export function readGithubDeviceAnswer(action: GithubDeviceRequest["action"], js
return undefined;
}
+export function isCallbackCode(code: unknown): code is string {
+ return typeof code === "string" && OPAQUE.test(code);
+}
+
+export function isCallbackState(state: unknown): state is string {
+ return typeof state === "string" && /^[A-Za-z0-9-]{8,128}$/.test(state);
+}
+
/**
- * Where the browser goes after Atlassian: back into the editor, carrying the code and the
- * `state` it must recognise. Nothing but plain token characters is passed on, so the address
- * can not be bent into something else.
+ * Where the browser goes after Atlassian when the editor is from before connection attempts: back
+ * into it, carrying the code and the `state` it must recognise. Nothing but plain token characters
+ * is passed on, so the address can not be bent into something else.
*/
export function editorCallbackUrl(query: URLSearchParams): string | undefined {
const code = query.get("code");
const state = query.get("state");
- if (!code || !state || !OPAQUE.test(code) || !/^[A-Za-z0-9-]{8,128}$/.test(state)) {
+ if (!isCallbackCode(code) || !isCallbackState(state)) {
return undefined;
}
return `picode://connect/callback?service=atlassian&state=${encodeURIComponent(state)}&code=${encodeURIComponent(code)}`;
diff --git a/host/cloud/sync-api/src/lib/contract.ts b/host/cloud/sync-api/src/lib/contract.ts
index 9dad7817..4510abf6 100644
--- a/host/cloud/sync-api/src/lib/contract.ts
+++ b/host/cloud/sync-api/src/lib/contract.ts
@@ -71,6 +71,8 @@ export interface SyncRequestContext {
uid: string;
/** Email claim of the verified Firebase ID token, when present. */
email?: string;
+ /** Whether the identity provider vouches for that email; an unverified one could be someone else's. */
+ emailVerified: boolean;
/** X-Account-Type header value (authentication provider id, e.g. "picode"). */
accountType: string;
/** X-Client-Name header value, e.g. "picode". */
diff --git a/host/cloud/sync-api/test/connect-attempts.test.ts b/host/cloud/sync-api/test/connect-attempts.test.ts
new file mode 100644
index 00000000..d661eaac
--- /dev/null
+++ b/host/cloud/sync-api/test/connect-attempts.test.ts
@@ -0,0 +1,166 @@
+import assert from "node:assert/strict";
+import { register } from "node:module";
+import { test } from "node:test";
+
+// The library imports its siblings without an extension (the Next.js way); under plain node
+// they resolve to their `.ts` file.
+register(
+ "data:text/javascript," +
+ encodeURIComponent(
+ "export async function resolve(s, c, next) { try { return await next(s, c); } catch (e) { if (s.startsWith('.') && !s.endsWith('.ts')) return next(s + '.ts', c); throw e; } }",
+ ),
+);
+
+const { accountLabel, ATTEMPT_TTL_MS, attemptKey, createAttempts, isAttemptState, parseAttemptRequest } = await import("../src/lib/connect-attempts.ts");
+const { HttpError } = await import("../src/lib/errors.ts");
+
+type Rec = import("../src/lib/connect-attempts.ts").AttemptRecord;
+
+const STATE = "0f8fad5b-d9cb-469f-a165-70867728950e";
+const OWNER = "owner";
+
+/** Records in memory, with a transaction that runs after a pause, as a real database's would. */
+function setup() {
+ const store = new Map();
+ let clock = 1_000;
+ const attempts = createAttempts({
+ now: () => clock,
+ newState: () => STATE,
+ records: {
+ async create(key, record) {
+ assert.ok(!store.has(key), "create must not replace an attempt");
+ store.set(key, record);
+ },
+ async read(key) {
+ return store.get(key);
+ },
+ async remove(key) {
+ store.delete(key);
+ },
+ async change(key, decide) {
+ const { next, result } = decide(store.get(key));
+ await new Promise((resolve) => setTimeout(resolve, 1));
+ if (next) {
+ store.set(key, next);
+ }
+ return result;
+ },
+ },
+ });
+ return { attempts, store, pass: (ms: number) => { clock += ms; } };
+}
+
+test("the confirmation names the account in full, and only when its email is verified", () => {
+ assert.equal(accountLabel("tomas@gmail.com", true), "tomas@gmail.com");
+ assert.notEqual(accountLabel("tomas@gmail.com", true), accountLabel("t.attacker@gmail.com", true));
+ for (const label of [accountLabel("tomas@gmail.com", false), accountLabel(undefined, true), accountLabel(undefined, false)]) {
+ assert.ok(!label.includes("tomas"));
+ assert.ok(!label.toLowerCase().includes("your"));
+ }
+});
+
+test("states this server issues are told apart from the ones older editors make", () => {
+ const issued = createAttempts({ records: { create: async () => undefined } as never });
+ return issued.begin(OWNER, undefined, false).then((state: string) => {
+ assert.equal(isAttemptState(state), true);
+ assert.equal(isAttemptState(STATE), false);
+ });
+});
+
+test("the owner can close an attempt, and then it cannot be confirmed", async () => {
+ const { attempts } = setup();
+ await attempts.begin(OWNER, undefined, false);
+ await attempts.cancel("other", STATE);
+ assert.equal((await attempts.find(STATE))?.status, "pending");
+ await attempts.cancel(OWNER, STATE);
+ assert.equal((await attempts.claim(STATE)).won, false);
+});
+
+test("a trade that ends after the time limit is still told to its owner", async () => {
+ const { attempts, pass } = setup();
+ await attempts.begin(OWNER, undefined, false);
+ await attempts.claim(STATE);
+ pass(ATTEMPT_TTL_MS + 1);
+ await attempts.finish(STATE, "connected");
+ assert.equal(await attempts.poll(OWNER, STATE), "connected");
+});
+
+test("a pending attempt that is too old cannot be finished", async () => {
+ const { attempts, pass } = setup();
+ await attempts.begin(OWNER, undefined, false);
+ pass(ATTEMPT_TTL_MS + 1);
+ await attempts.finish(STATE, "connected");
+ assert.equal(await attempts.poll(OWNER, STATE), "expired");
+});
+
+test("an attempt waits for the person, and the state itself is not what is stored", async () => {
+ const { attempts, store } = setup();
+ assert.equal(await attempts.begin(OWNER, "tomas@gmail.com", true), STATE);
+ assert.deepEqual([...store.keys()], [attemptKey(STATE)]);
+ assert.ok(!JSON.stringify([...store.entries()]).includes(STATE));
+ assert.equal(await attempts.poll(OWNER, STATE), "pending");
+ assert.equal((await attempts.find(STATE))?.accountLabel, "tomas@gmail.com");
+});
+
+test("once connected, the owner is told once and the attempt is gone", async () => {
+ const { attempts, store } = setup();
+ await attempts.begin(OWNER, undefined, false);
+ assert.equal((await attempts.claim(STATE)).won, true);
+ assert.equal(await attempts.poll(OWNER, STATE), "trading");
+ await attempts.finish(STATE, "connected");
+ assert.equal(await attempts.poll(OWNER, STATE), "connected");
+ assert.equal(store.size, 0);
+ assert.equal(await attempts.poll(OWNER, STATE), "expired");
+});
+
+test("someone else's attempt looks like one that does not exist, and is left untouched", async () => {
+ const { attempts, store } = setup();
+ await attempts.begin(OWNER, undefined, false);
+ await attempts.finish(STATE, "connected");
+ assert.equal(await attempts.poll("other", STATE), "expired");
+ assert.equal(store.size, 1);
+ assert.equal(await attempts.poll(OWNER, STATE), "connected");
+});
+
+test("a second confirmation does not win, so a code is not spent twice", async () => {
+ const { attempts } = setup();
+ await attempts.begin(OWNER, undefined, false);
+ assert.equal((await attempts.claim(STATE)).won, true);
+ const second = await attempts.claim(STATE);
+ assert.equal(second.won, false);
+ assert.equal(second.record?.status, "trading");
+});
+
+test("an old attempt cannot be confirmed and ends as expired", async () => {
+ const { attempts, store, pass } = setup();
+ await attempts.begin(OWNER, undefined, false);
+ pass(ATTEMPT_TTL_MS + 1);
+ assert.equal(await attempts.find(STATE), undefined);
+ assert.deepEqual(await attempts.claim(STATE), { won: false, record: undefined });
+ assert.equal(await attempts.poll(OWNER, STATE), "expired");
+ assert.equal(store.size, 0);
+});
+
+test("an unknown state can be neither confirmed nor finished", async () => {
+ const { attempts, store } = setup();
+ assert.deepEqual(await attempts.claim(STATE), { won: false, record: undefined });
+ await attempts.finish(STATE, "connected");
+ assert.equal(store.size, 0);
+});
+
+test("a finished attempt does not change its ending", async () => {
+ const { attempts } = setup();
+ await attempts.begin(OWNER, undefined, false);
+ await attempts.finish(STATE, "connected");
+ await attempts.finish(STATE, "failed");
+ assert.equal(await attempts.poll(OWNER, STATE), "connected");
+});
+
+test("only the two actions are accepted, and only a plain state", () => {
+ assert.deepEqual(parseAttemptRequest({ action: "begin", state: "ignored" }), { action: "begin" });
+ assert.deepEqual(parseAttemptRequest({ action: "poll", state: STATE }), { action: "poll", state: STATE });
+ assert.deepEqual(parseAttemptRequest({ action: "cancel", state: STATE }), { action: "cancel", state: STATE });
+ for (const raw of [undefined, [], { action: "other" }, { action: "poll" }, { action: "poll", state: "short" }, { action: "poll", state: "a\">