rastrillo / aviso Public

reconcile never creates a subscription; write down the iOS smoke test

Codex's whole-branch review. reconcile subscribed whenever permission
was granted and nothing existed, which is exactly the state after
disable — so a person's opt-out was silently undone on their next
visit. It now repairs only a subscription that exists; creating is
enable's job, from a click. The node suite runs enable → disable →
reconcile and the browser drive does the same in Chromium.

The spec's manual iOS smoke test now exists as docs/ios-smoke.md: the
procedure, expected outcomes per step, and a table for the result. It
has not been run; the table says so.

Two v0.2 items taken early because they were cheap: Retry-After is
clamped at 24 hours (an unbounded delta-seconds overflowed
time.Duration into a negative value), and the sender tests now push a
payload at the bound through encryption and transport, prove a failed
selection is a batch error rather than zero devices, and cover 503
with an HTTP-date Retry-After and 404 pruning.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 08e8665191c83d886d3da6ece2824580db810345 parent feb58dc
7 files changed, +195 −18
  • SKILL.md +6 −2
  • browser_test.go +9 −1
  • docs/ios-smoke.md +58 −0
  • js/push.mjs +11 −10
  • js/push.test.mjs +22 −0
  • send.go +17 −5
  • send_test.go +72 −0
diff --git a/SKILL.md b/SKILL.md
index d8ede7f..55d66a3 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -67,8 +67,12 @@ await reconcile({ registration, publicKey, save }).catch(console.warn); // every
button.onclick = () => enable({ registration, publicKey, save }); // from the click; prompts
```
-`ready` matters: `subscribe()` on a registration whose worker is still
-installing rejects with InvalidStateError. `enable` must be called
+`reconcile` only repairs a subscription that exists; it never creates
+one, because permission stays granted after `disable` and a reconcile
+that subscribed whenever it could would undo the person's opt-out on
+their next visit. `ready` matters: `subscribe()` on a registration
+whose worker is still installing rejects with InvalidStateError.
+`enable` must be called
synchronously from the click handler — it prompts before its first
await, and a prompt after an await is denied. It resolves null when
denied. `disable({registration, remove})` removes the server row
diff --git a/browser_test.go b/browser_test.go
index eef30e8..2102513 100644
--- a/browser_test.go
+++ b/browser_test.go
@@ -177,5 +177,13 @@ func TestBrowserEnrolmentRoundTripsThroughTheHandlers(t *testing.T) {
if rows, _ := f.svc.List(ctx, "drive"); len(rows) != 0 {
t.Fatalf("after disable: %+v", rows)
}
- rig.Screen("#ready", "after enable, reconcile, disable")
+ // Permission is still granted; the next load's reconcile must not
+ // undo the opt-out.
+ if got := evalString(rig, "driver.reconcile()"); got != "" {
+ t.Fatalf("reconcile after disable re-enrolled: %q", got)
+ }
+ if rows, _ := f.svc.List(ctx, "drive"); len(rows) != 0 {
+ t.Fatalf("reconcile after disable stored: %+v", rows)
+ }
+ rig.Screen("#ready", "after enable, reconcile, disable, reconcile")
}
diff --git a/docs/ios-smoke.md b/docs/ios-smoke.md
new file mode 100644
index 0000000..ca59a7b
--- /dev/null
+++ b/docs/ios-smoke.md
@@ -0,0 +1,58 @@
+# iOS smoke test
+
+The one check no automated test in this repository can make. The
+browser drive proves the module, the worker import and the handlers
+agree in Chromium with a subscription double; Chromium's own push
+subscription cannot be pointed at a test service, and iOS cannot be
+driven at all. So this is a person, a phone, and ten minutes, run
+before a release that touches the JS halves or the handlers.
+
+**Last run:** not yet. Record the date, iOS version and outcome at
+the bottom when it is.
+
+## Setup
+
+1. Deploy the example (or any app wired per `SKILL.md`) at an https
+ origin the phone can reach. `http://localhost` is a secure context
+ on the machine itself, not across a network; use a real origin or
+ a tunnel that terminates TLS.
+2. Provision the key once and keep it across restarts:
+ `EXAMPLE_VAPID_PRIVATE_KEY="$(cat .vapid-key)"`.
+3. iPhone or iPad on iOS/iPadOS 16.4 or later, in Safari.
+
+## Steps and expected outcomes
+
+1. **Open the origin in Safari.** The page loads; the coaching
+ paragraph about the Home Screen is visible; the buttons are
+ disabled (Safari-in-browser reports `push: false` on iOS, so the
+ log says the browser cannot do push).
+2. **Share → Add to Home Screen. Open the installed copy.** The
+ coaching paragraph is hidden (`standalone` true); after the worker
+ activates, the buttons are enabled; the log shows `subscribed:
+ false`.
+3. **Tap Enable.** iOS shows its permission prompt (it must come from
+ the tap — if no prompt appears, the gesture was lost). Allow. The
+ log shows `enabled: https://web.push.apple.com/…`. On the server,
+ one row for `dev`.
+4. **Tap Notify me.** A notification appears in the system tray with
+ the title "Hello from aviso" while the app is in the foreground and
+ again after backgrounding the app and tapping Notify from another
+ device or a curl to `/notify`.
+5. **Tap the notification.** The installed app comes to the front at
+ `/` (the payload's `url`).
+6. **Tap Disable.** The log shows `disabled`; the server has zero
+ rows. Close the app fully and reopen it: the log shows `subscribed:
+ false` and the server still has zero rows — reconcile must not
+ re-enrol after an opt-out.
+7. **Tap Enable again, then rotate the key on the server** (new
+ `EXAMPLE_VAPID_PRIVATE_KEY`, restart). Reopen the app: reconcile
+ re-subscribes under the new key without a prompt, and Notify
+ delivers again.
+8. **Remove the app from the Home Screen.** The next send reports 410
+ from Apple's push service and the row is pruned.
+
+## Recording a run
+
+| Date | iOS | Device | Steps passed | Notes |
+|------|-----|--------|--------------|-------|
+| | | | | |
diff --git a/js/push.mjs b/js/push.mjs
index 7bd3f26..5e0d62e 100644
--- a/js/push.mjs
+++ b/js/push.mjs
@@ -101,22 +101,23 @@ export async function enable({ registration, publicKey, save }, env = globalThis
return sub;
}
-// reconcile repairs on every page load without prompting: with
-// permission granted it re-subscribes if the server key changed and
-// re-saves so the server's last_confirmed_at moves. Resolves the
-// subscription or null.
+// reconcile repairs an EXISTING subscription on every page load
+// without prompting: re-saves it so the server's last_confirmed_at
+// moves, or re-subscribes if the server key changed. It never creates
+// one from nothing — permission stays granted after disable(), and a
+// reconcile that subscribed whenever it could would silently undo the
+// person's opt-out on their next visit. Creating is enable's job.
+// Resolves the subscription or null.
export async function reconcile({ registration, publicKey, save }, env = globalThis) {
if (!env.Notification || env.Notification.permission !== "granted") return null;
const existing = await registration.pushManager.getSubscription();
- if (existing && sameKey(existing, publicKey)) {
+ if (!existing) return null;
+ if (sameKey(existing, publicKey)) {
await persist(save, body(existing, publicKey, ""));
return existing;
}
- let previous = "";
- if (existing) {
- previous = existing.endpoint;
- await existing.unsubscribe();
- }
+ const previous = existing.endpoint;
+ await existing.unsubscribe();
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: toBytes(publicKey),
diff --git a/js/push.test.mjs b/js/push.test.mjs
index faf8a22..4aceba6 100644
--- a/js/push.test.mjs
+++ b/js/push.test.mjs
@@ -146,6 +146,28 @@ test("reconcile re-subscribes under a new key and names the old endpoint", async
assert.equal(saved[0].subscription.endpoint, "https://push.example/new");
});
+test("reconcile never creates a subscription: enable, disable, reconcile stays disabled", async () => {
+ const e = env("granted");
+ let current = null;
+ const reg = {
+ pushManager: {
+ async getSubscription() { return current; },
+ async subscribe(opts) { current = fakeSub("https://push.example/new", new Uint8Array(opts.applicationServerKey)); return current; },
+ },
+ };
+ const { saved, save } = okSave();
+ const removed = [];
+ const remove = async (b) => { removed.push(b.endpoint); return { ok: true }; };
+ assert.ok(await enable({ registration: reg, publicKey: KEY, save }, e));
+ current.unsubscribe = async () => { current = null; return true; };
+ await disable({ registration: reg, remove });
+ assert.deepEqual(removed, ["https://push.example/new"]);
+ // Permission is still "granted"; the next page load must not re-enrol.
+ assert.equal(await reconcile({ registration: reg, publicKey: KEY, save }, e), null);
+ assert.equal(current, null);
+ assert.equal(saved.length, 1, "reconcile saved after disable");
+});
+
test("reconcile does nothing without permission", async () => {
for (const perm of ["default", "denied"]) {
const e = env(perm);
diff --git a/send.go b/send.go
index 210fcbc..c79baba 100644
--- a/send.go
+++ b/send.go
@@ -247,15 +247,27 @@ func redact(err error) error {
return ErrTransport
}
+// maxRetryAfter caps what a push service can ask for: a value past
+// this is meaningless to a scheduler, and an unbounded one overflows
+// time.Duration into a negative number.
+const maxRetryAfter = 24 * time.Hour
+
func parseRetryAfter(v string, now time.Time) time.Duration {
if v == "" {
return 0
}
- if secs, err := strconv.Atoi(v); err == nil && secs >= 0 {
- return time.Duration(secs) * time.Second
+ var d time.Duration
+ if strings.Trim(v, "0123456789") == "" { // delta-seconds, however many digits
+ secs, err := strconv.ParseInt(v, 10, 64)
+ if err != nil || secs > int64(maxRetryAfter/time.Second) { // err here is only ever "out of range"
+ return maxRetryAfter
+ }
+ d = time.Duration(secs) * time.Second
+ } else if t, err := http.ParseTime(v); err == nil && t.After(now) {
+ d = t.Sub(now)
}
- if t, err := http.ParseTime(v); err == nil && t.After(now) {
- return t.Sub(now)
+ if d > maxRetryAfter {
+ return maxRetryAfter
}
- return 0
+ return d
}
diff --git a/send_test.go b/send_test.go
index 2074ab7..b7e3fbb 100644
--- a/send_test.go
+++ b/send_test.go
@@ -176,6 +176,78 @@ func TestSendReportsRetryAfter(t *testing.T) {
}
}
+func TestParseRetryAfter(t *testing.T) {
+ now := time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC)
+ for in, want := range map[string]time.Duration{
+ "": 0,
+ "120": 120 * time.Second,
+ "-5": 0,
+ "nonsense": 0,
+ "9223372037": maxRetryAfter, // would overflow time.Duration
+ "99999999999999999999": maxRetryAfter,
+ "Mon, 07 Sep 2026 12:05:00 GMT": 5 * time.Minute,
+ "Mon, 07 Sep 2026 11:00:00 GMT": 0, // in the past
+ "Wed, 07 Oct 2026 12:00:00 GMT": maxRetryAfter,
+ } {
+ if got := parseRetryAfter(in, now); got != want {
+ t.Errorf("%q: got %v, want %v", in, got, want)
+ }
+ }
+}
+
+// A payload at the bound must actually encrypt and travel, not merely
+// pass validation with no recipients.
+func TestSendDeliversAPayloadAtTheBound(t *testing.T) {
+ p := newPushRecorder(t)
+ s := serviceAgainst(t, p)
+ ctx := context.Background()
+ _ = s.put(ctx, "alice", sub(p.url("/one")), "")
+ res, err := s.SendTo(ctx, "alice", make([]byte, maxPayload), Options{})
+ if err != nil || len(res) != 1 || res[0].Err != nil || res[0].Status != 201 {
+ t.Fatalf("res=%+v err=%v", res, err)
+ }
+ <-p.hdr
+}
+
+// A failed selection is a batch error, never "zero devices".
+func TestSendToReportsASelectionFailure(t *testing.T) {
+ s := newInternalService(t)
+ ctx := context.Background()
+ _ = s.put(ctx, "alice", sub("https://push.example/one"), "")
+ if _, err := s.cfg.DB.Exec(`DROP TABLE aviso_subscriptions`); err != nil {
+ t.Fatal(err)
+ }
+ res, err := s.SendTo(ctx, "alice", []byte("x"), Options{})
+ if err == nil || res != nil {
+ t.Fatalf("res=%+v err=%v; want nil results and a batch error", res, err)
+ }
+}
+
+func TestSendReportsServiceUnavailableAndPrunesNotFound(t *testing.T) {
+ p := newPushRecorder(t)
+ s := serviceAgainst(t, p)
+ ctx := context.Background()
+ _ = s.put(ctx, "alice", sub(p.url("/one")), "")
+ p.retry = "Mon, 07 Sep 2026 12:05:00 GMT"
+ p.status.Store(503)
+ s.now = func() time.Time { return time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC) }
+ res, _ := s.SendTo(ctx, "alice", []byte("x"), Options{})
+ if res[0].Status != 503 || res[0].RetryAfter != 5*time.Minute || res[0].Err == nil {
+ t.Fatalf("503: %+v", res[0])
+ }
+ if rows, _ := s.List(ctx, "alice"); len(rows) != 1 {
+ t.Fatal("503 pruned")
+ }
+ p.status.Store(404)
+ res, _ = s.SendTo(ctx, "alice", []byte("x"), Options{})
+ if res[0].Status != 404 || res[0].Err == nil {
+ t.Fatalf("404: %+v", res[0])
+ }
+ if rows, _ := s.List(ctx, "alice"); len(rows) != 0 {
+ t.Fatal("404 did not prune")
+ }
+}
+
func TestSendSkipsRowsUnderAnotherKey(t *testing.T) {
p := newPushRecorder(t)
s := serviceAgainst(t, p)