Harden iOS sign-in against stale-connection POST failures
iPad users hit a spurious "network error" on the first tap of "Send sign-in link", with a second tap succeeding. Cause is iOS Safari dispatching the POST onto a pooled keep-alive socket the server/proxy already closed; unlike a GET it isn't transparently re-sent, so it surfaces as a transport TypeError. The single 500ms auto-retry was too quick and reused the same dead socket. Both sign-in entry points (auth.html postWithRetry, index.html fetchWithRetry) now retry 3x with growing backoff (0 -> +400ms -> +1.6s) to outlast Safari evicting the socket. Frontend-only. Ships as 0.2.156.
This commit is contained in:
@@ -125,7 +125,7 @@ unsure whether a change is contract-affecting, assume it is and check.
|
|||||||
|
|
||||||
## Current state
|
## Current state
|
||||||
|
|
||||||
**Live on the operator's StartOS box** (app **0.2.155** + relay **0.2.124**, installed 2026-06-09):
|
**Live on the operator's StartOS box** (app **0.2.156** installed 2026-06-15 + relay **0.2.124**). Note: `recaps.cc` is served from this same box via Start9 Pages + StartTunnel, so a `make install` here updates the public cloud site automatically — there is no separate cloud deploy.
|
||||||
|
|
||||||
- **Self-serve purchase COMPLETE — all 5 phases** (`docs/self-serve-purchase-plan.md`). Signed-in cloud users buy Pro/Max themselves: "Pay with Bitcoin" renders an inline Lightning QR on-screen (no redirect); "Pay by card" mints a Zaprite one-time order (the card link shows only when the operator has configured Zaprite). Prepaid 30-day periods; the relay owns tier + expiry; both settle webhooks land at `extendUserTier`. Expiry-reminder emails (7d / 1d / lapsed) ride the existing System SMTP; operator test trigger: `POST /api/admin/reminders/run` with `{test_email}`. Tier cards show the real per-period credit allotment from the relay quota config (this box: Max = 120, Pro = 50).
|
- **Self-serve purchase COMPLETE — all 5 phases** (`docs/self-serve-purchase-plan.md`). Signed-in cloud users buy Pro/Max themselves: "Pay with Bitcoin" renders an inline Lightning QR on-screen (no redirect); "Pay by card" mints a Zaprite one-time order (the card link shows only when the operator has configured Zaprite). Prepaid 30-day periods; the relay owns tier + expiry; both settle webhooks land at `extendUserTier`. Expiry-reminder emails (7d / 1d / lapsed) ride the existing System SMTP; operator test trigger: `POST /api/admin/reminders/run` with `{test_email}`. Tier cards show the real per-period credit allotment from the relay quota config (this box: Max = 120, Pro = 50).
|
||||||
- **Core-decoupling live** (relay owns cloud tier; `docs/core-decoupling-plan.md`) and **per-tenant subscriptions live** (`docs/per-tenant-subscriptions-plan.md`).
|
- **Core-decoupling live** (relay owns cloud tier; `docs/core-decoupling-plan.md`) and **per-tenant subscriptions live** (`docs/per-tenant-subscriptions-plan.md`).
|
||||||
@@ -133,6 +133,8 @@ unsure whether a change is contract-affecting, assume it is and check.
|
|||||||
|
|
||||||
**This session (2026-06-15):** ran the 2026-06-14 full-eval (`EVALUATION.md`) and cleared its 3 P0 + 4 P1 findings. Five code fixes shipped with tests + a reviewer pass — library-import file-write, podcast SSRF, ESM `require`, multi-mode concurrency lock, and the `X-Forwarded-For` trial-cap bypass — committed + pushed (`d0e9842`), 119 tests pass. The leaked Gemini key was purged from all git history and force-pushed; **the rewrite re-hashed every commit** (solo private repo, no other clones — harmless). Registry-submission blockers deferred.
|
**This session (2026-06-15):** ran the 2026-06-14 full-eval (`EVALUATION.md`) and cleared its 3 P0 + 4 P1 findings. Five code fixes shipped with tests + a reviewer pass — library-import file-write, podcast SSRF, ESM `require`, multi-mode concurrency lock, and the `X-Forwarded-For` trial-cap bypass — committed + pushed (`d0e9842`), 119 tests pass. The leaked Gemini key was purged from all git history and force-pushed; **the rewrite re-hashed every commit** (solo private repo, no other clones — harmless). Registry-submission blockers deferred.
|
||||||
|
|
||||||
|
**Also this session — iOS sign-in flake fixed (shipped as 0.2.156, built + installed + verified on the box):** an iPad user hit a spurious "network error" on the first tap of *Send sign-in link*, with the second tap succeeding. Root cause is the classic iOS Safari behavior of dispatching a `POST` onto a pooled keep-alive socket the server/proxy has already closed; unlike a GET it isn't transparently re-sent, so it surfaces as a transport `TypeError`. The existing single 500 ms auto-retry was too quick — it reused the same dead socket. Both sign-in entry points (`public/auth.html` `postWithRetry`, `public/index.html` `fetchWithRetry`) now retry 3× with growing backoff (0 → +400 ms → +1.6 s) to outlast Safari evicting the socket. Frontend-only, no server change; the embedded JS has no test harness. Mitigation not cure — if it ever recurs, confirm via box logs whether `/auth/request-link` is hit once (request never arrived → my diagnosis) or twice (failure on the response path → different bug) before widening the backoff.
|
||||||
|
|
||||||
**Pending operator actions:**
|
**Pending operator actions:**
|
||||||
1. (optional) Rotate the Gemini key in AI Studio — the purge removed it from the repo, but the key itself is still live. Then delete the pre-purge backup: `rm /Users/macpro/Projects/recap-keyleak-purge-backup.bundle` (it contains the old key).
|
1. (optional) Rotate the Gemini key in AI Studio — the purge removed it from the repo, but the key itself is still live. Then delete the pre-purge backup: `rm /Users/macpro/Projects/recap-keyleak-purge-backup.bundle` (it contains the old key).
|
||||||
2. Real-world cloud tests: first on-device Bitcoin purchase (Core tenant → Upgrade → Pay with Bitcoin → badge flips); enable cards (relay "Set Zaprite Connection" + webhook `https://<relay-host>/relay/zaprite/webhook`); eyeball a reminder email (`POST /api/admin/reminders/run` `{test_email}`).
|
2. Real-world cloud tests: first on-device Bitcoin purchase (Core tenant → Upgrade → Pay with Bitcoin → badge flips); enable cards (relay "Set Zaprite Connection" + webhook `https://<relay-host>/relay/zaprite/webhook`); eyeball a reminder email (`POST /api/admin/reminders/run` `{test_email}`).
|
||||||
|
|||||||
+18
-10
@@ -335,22 +335,30 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Silent retry on the first fetch — iOS Safari sometimes
|
// Silent retry on transport failures — iOS Safari can dispatch a
|
||||||
// aborts the very first request from a cold tab with a generic
|
// POST onto a pooled keep-alive socket the server (or a proxy in
|
||||||
// "Load failed" TypeError. A single ~500ms retry hides the
|
// front of it) has already closed. Unlike a GET, a non-idempotent
|
||||||
// flake; server-side errors (4xx/5xx) are not retried because
|
// POST isn't transparently re-sent on a fresh connection; it
|
||||||
// they're deliberate responses, not transport issues.
|
// surfaces a "Load failed" TypeError instead. A single quick retry
|
||||||
|
// tends to reuse the same dead socket and fail again (the reported
|
||||||
|
// "first tap errors, second works"), so retry a few times with
|
||||||
|
// growing backoff to outlast Safari evicting the socket. Server
|
||||||
|
// errors (4xx/5xx) are returned as-is and never retried — they're
|
||||||
|
// deliberate responses, not transport flakes.
|
||||||
const reqInit = {
|
const reqInit = {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email }),
|
body: JSON.stringify({ email }),
|
||||||
};
|
};
|
||||||
async function postWithRetry() {
|
async function postWithRetry() {
|
||||||
try {
|
const backoffsMs = [400, 1200];
|
||||||
return await fetch('/auth/request-link', reqInit);
|
for (let attempt = 0; ; attempt++) {
|
||||||
} catch (e) {
|
try {
|
||||||
await new Promise((r) => setTimeout(r, 500));
|
return await fetch('/auth/request-link', reqInit);
|
||||||
return await fetch('/auth/request-link', reqInit);
|
} catch (e) {
|
||||||
|
if (attempt >= backoffsMs.length) throw e;
|
||||||
|
await new Promise((r) => setTimeout(r, backoffsMs[attempt]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
+17
-14
@@ -6020,21 +6020,24 @@
|
|||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchWithRetry — wraps fetch with one silent retry on a network
|
// fetchWithRetry — wraps fetch with silent retries on a transport
|
||||||
// failure (TypeError). iOS Safari sometimes silently aborts the
|
// failure (TypeError). iOS Safari can dispatch a request onto a
|
||||||
// first fetch from a cold tab and surfaces it as "Load failed",
|
// pooled keep-alive socket the server (or a proxy in front of it)
|
||||||
// which the user sees as a misleading error on flows where the
|
// has already closed; for non-idempotent POSTs it surfaces a "Load
|
||||||
// server would have happily processed the request. Short backoff,
|
// failed" TypeError instead of transparently re-sending on a fresh
|
||||||
// single retry. Server errors (4xx/5xx) are NOT retried — those
|
// connection. A single quick retry often reuses the same dead socket
|
||||||
// are deliberate responses, not transport flakes.
|
// and fails again, so retry a few times with growing backoff to
|
||||||
|
// outlast Safari evicting the socket. Server errors (4xx/5xx) are
|
||||||
|
// returned as-is and NOT retried — those are deliberate responses.
|
||||||
async function fetchWithRetry(input, init) {
|
async function fetchWithRetry(input, init) {
|
||||||
try {
|
const backoffsMs = [400, 1200];
|
||||||
return await fetch(input, init);
|
for (let attempt = 0; ; attempt++) {
|
||||||
} catch (err) {
|
try {
|
||||||
// Wait ~500ms then try once more. The TCP/TLS state is warm
|
return await fetch(input, init);
|
||||||
// by then and the second attempt nearly always succeeds.
|
} catch (err) {
|
||||||
await new Promise((r) => setTimeout(r, 500));
|
if (attempt >= backoffsMs.length) throw err;
|
||||||
return await fetch(input, init);
|
await new Promise((r) => setTimeout(r, backoffsMs[attempt]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -174,8 +174,9 @@ import { v_0_2_152 } from './v0.2.152'
|
|||||||
import { v_0_2_153 } from './v0.2.153'
|
import { v_0_2_153 } from './v0.2.153'
|
||||||
import { v_0_2_154 } from './v0.2.154'
|
import { v_0_2_154 } from './v0.2.154'
|
||||||
import { v_0_2_155 } from './v0.2.155'
|
import { v_0_2_155 } from './v0.2.155'
|
||||||
|
import { v_0_2_156 } from './v0.2.156'
|
||||||
|
|
||||||
export const versionGraph = VersionGraph.of({
|
export const versionGraph = VersionGraph.of({
|
||||||
current: v_0_2_155,
|
current: v_0_2_156,
|
||||||
other: [v_0_2_154, v_0_2_153, v_0_2_152, v_0_2_151, v_0_2_150, v_0_2_149, v_0_2_148, v_0_2_147, v_0_2_146, v_0_2_145, v_0_2_144, v_0_2_143, v_0_2_142, v_0_2_141, v_0_2_140, v_0_2_139, v_0_2_138, v_0_2_137, v_0_2_136, v_0_2_135, v_0_2_134, v_0_2_133, v_0_2_132, v_0_2_131, v_0_2_130, v_0_2_129, v_0_2_128, v_0_2_127, v_0_2_126, v_0_2_125, v_0_2_124, v_0_2_123, v_0_2_122, v_0_2_121, v_0_2_120, v_0_2_119, v_0_2_118, v_0_2_117, v_0_2_116, v_0_2_115, v_0_2_114, v_0_2_113, v_0_2_112, v_0_2_111, v_0_2_110, v_0_2_109, v_0_2_108, v_0_2_107, v_0_2_106, v_0_2_105, v_0_2_104, v_0_2_103, v_0_2_102, v_0_2_101, v_0_2_100, v_0_2_99, v_0_2_98, v_0_2_97, v_0_2_96, v_0_2_95, v_0_2_94, v_0_2_93, v_0_2_92, v_0_2_91, v_0_2_90, v_0_2_89, v_0_2_88, v_0_2_87, v_0_2_86, v_0_2_85, v_0_2_84, v_0_2_83, v_0_2_82, v_0_2_81, v_0_2_80, v_0_2_79, v_0_2_78, v_0_2_77, v_0_2_76, v_0_2_75, v_0_2_74, v_0_2_73, v_0_2_72, v_0_2_71, v_0_2_70, v_0_2_69, v_0_2_68, v_0_2_67, v_0_2_66, v_0_2_65, v_0_2_64, v_0_2_63, v_0_2_62, v_0_2_61, v_0_2_60, v_0_2_59, v_0_2_58, v_0_2_57, v_0_2_56, v_0_2_55, v_0_2_54, v_0_2_53, v_0_2_52, v_0_2_51, v_0_2_50, v_0_2_49, v_0_2_48, v_0_2_47, v_0_2_46, v_0_2_45, v_0_2_44, v_0_2_43, v_0_2_42, v_0_2_41, v_0_2_40, v_0_2_39, v_0_2_38, v_0_2_37, v_0_2_36, v_0_2_35, v_0_2_34, v_0_2_33, v_0_2_32, v_0_2_31, v_0_2_30, v_0_2_29, v_0_2_28, v_0_2_27, v_0_2_26, v_0_2_25, v_0_2_24, v_0_2_23, v_0_2_22, v_0_2_21, v_0_2_20, v_0_2_19, v_0_2_18, v_0_2_17, v_0_2_16, v_0_2_15, v_0_2_14, v_0_2_13, v_0_2_12, v_0_2_11, v_0_2_10, v_0_2_9, v_0_2_8, v_0_2_7, v_0_2_6, v_0_2_5, v_0_2_4, v_0_2_3, v_0_2_2, v_0_2_1, v_0_2_0, v_0_1_18, v_0_1_17, v_0_1_16, v_0_1_15, v_0_1_14, v_0_1_13, v_0_1_12, v_0_1_11, v_0_1_10, v_0_1_9, v_0_1_8, v_0_1_7, v_0_1_6, v_0_1_5, v_0_1_4, v_0_1_3, v_0_1_2, v_0_1_1, v_0_1_0],
|
other: [v_0_2_155, v_0_2_154, v_0_2_153, v_0_2_152, v_0_2_151, v_0_2_150, v_0_2_149, v_0_2_148, v_0_2_147, v_0_2_146, v_0_2_145, v_0_2_144, v_0_2_143, v_0_2_142, v_0_2_141, v_0_2_140, v_0_2_139, v_0_2_138, v_0_2_137, v_0_2_136, v_0_2_135, v_0_2_134, v_0_2_133, v_0_2_132, v_0_2_131, v_0_2_130, v_0_2_129, v_0_2_128, v_0_2_127, v_0_2_126, v_0_2_125, v_0_2_124, v_0_2_123, v_0_2_122, v_0_2_121, v_0_2_120, v_0_2_119, v_0_2_118, v_0_2_117, v_0_2_116, v_0_2_115, v_0_2_114, v_0_2_113, v_0_2_112, v_0_2_111, v_0_2_110, v_0_2_109, v_0_2_108, v_0_2_107, v_0_2_106, v_0_2_105, v_0_2_104, v_0_2_103, v_0_2_102, v_0_2_101, v_0_2_100, v_0_2_99, v_0_2_98, v_0_2_97, v_0_2_96, v_0_2_95, v_0_2_94, v_0_2_93, v_0_2_92, v_0_2_91, v_0_2_90, v_0_2_89, v_0_2_88, v_0_2_87, v_0_2_86, v_0_2_85, v_0_2_84, v_0_2_83, v_0_2_82, v_0_2_81, v_0_2_80, v_0_2_79, v_0_2_78, v_0_2_77, v_0_2_76, v_0_2_75, v_0_2_74, v_0_2_73, v_0_2_72, v_0_2_71, v_0_2_70, v_0_2_69, v_0_2_68, v_0_2_67, v_0_2_66, v_0_2_65, v_0_2_64, v_0_2_63, v_0_2_62, v_0_2_61, v_0_2_60, v_0_2_59, v_0_2_58, v_0_2_57, v_0_2_56, v_0_2_55, v_0_2_54, v_0_2_53, v_0_2_52, v_0_2_51, v_0_2_50, v_0_2_49, v_0_2_48, v_0_2_47, v_0_2_46, v_0_2_45, v_0_2_44, v_0_2_43, v_0_2_42, v_0_2_41, v_0_2_40, v_0_2_39, v_0_2_38, v_0_2_37, v_0_2_36, v_0_2_35, v_0_2_34, v_0_2_33, v_0_2_32, v_0_2_31, v_0_2_30, v_0_2_29, v_0_2_28, v_0_2_27, v_0_2_26, v_0_2_25, v_0_2_24, v_0_2_23, v_0_2_22, v_0_2_21, v_0_2_20, v_0_2_19, v_0_2_18, v_0_2_17, v_0_2_16, v_0_2_15, v_0_2_14, v_0_2_13, v_0_2_12, v_0_2_11, v_0_2_10, v_0_2_9, v_0_2_8, v_0_2_7, v_0_2_6, v_0_2_5, v_0_2_4, v_0_2_3, v_0_2_2, v_0_2_1, v_0_2_0, v_0_1_18, v_0_1_17, v_0_1_16, v_0_1_15, v_0_1_14, v_0_1_13, v_0_1_12, v_0_1_11, v_0_1_10, v_0_1_9, v_0_1_8, v_0_1_7, v_0_1_6, v_0_1_5, v_0_1_4, v_0_1_3, v_0_1_2, v_0_1_1, v_0_1_0],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_156 = VersionInfo.of({
|
||||||
|
version: '0.2.156:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US: 'Sign-in is more reliable on iPad/iPhone: the "Send sign-in link" button now retries a few times with growing backoff when Safari dispatches the request onto a stale connection, so it no longer shows a spurious "network error" on the first tap.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user