Skip to content

Push Notifications & Subscriptions epic (#379)#381

Draft
leogdion wants to merge 7 commits into
v1.0.0-beta.2from
379-push-notifications-subscriptions-epic
Draft

Push Notifications & Subscriptions epic (#379)#381
leogdion wants to merge 7 commits into
v1.0.0-beta.2from
379-push-notifications-subscriptions-epic

Conversation

@leogdion
Copy link
Copy Markdown
Member

Summary

Delivers the full server-side push-notification workflow for CloudKit Web Services — subscriptions (the triggers) plus APNs tokens (the delivery path) — closing epic #379 and sub-issues #49, #50, #51, #52, #53.

  • Subscriptions (subscriptions/list, /lookup, /modify): new CloudKitService methods, CLI commands (list-subscriptions, lookup-subscription, modify-subscriptions), SubscriptionRoundtripPhase in the live runner. Working live since the first commit on this branch.
  • APNs tokens (tokens/create, tokens/register): new CloudKitService methods, CLI commands (create-token, register-token), TokenRoundtripPhase. The endpoint route turned out to be /device/1/… (CloudKit JS uses setApiModuleName("device")), not /database/1/… as Apple's archived REST reference documents — that detail was the missing piece that resolved the persistent HTTP 405 on every server-side call. The clientId field CloudKit JS always sends is now part of both bodies, exposed as an optional caller-supplied parameter (defaults to a fresh UUID per call).
  • Verbose logging: --verbose on test-private / test-public now bootstraps LoggingSystem so MistKit's existing LoggingMiddleware actually emits the wire trace — necessary for diagnosing the 405 and useful for anyone reverse-engineering CloudKit JS in the future.

Live verification

swift run mistdemo test-private --verbose against iCloud.com.brightdigit.MistDemo — all 17 phases green, including:

  • Phase 15 SubscriptionRoundtripPhase (create / list / lookup / delete)
  • Phase 16 TokenRoundtripPhase (POST /device/1/.../tokens/create → 200, then tokens/register → 200)

test-public --verbose blocked on an unrelated pre-existing config bug (S2S key path has escaped backslashes — com\\~apple\\~CloudDocs instead of com~apple~CloudDocs). Filing as a separate follow-up; not in scope here.

Test plan

  • swift test — 498/498 MistKit unit tests
  • cd Examples/MistDemo && swift test — 941/941 MistDemo unit tests
  • ./Scripts/lint.sh — clean (only 4 pre-existing warnings unrelated to this branch)
  • swift run mistdemo test-private --verbose — 17/17 phases green live
  • CI on this PR
  • (Reviewer) re-run test-private --verbose from a clean checkout if you want to see the wire trace

Known follow-ups (out of scope for this PR)

  • Tighten tokens/register response spec — live server actually returns the same {apnsToken, apnsEnvironment, webcourierURL} body as tokens/create, not the empty 200 our spec assumes. Behaviorally harmless (we ignore the body) but accurate.
  • Doc-comment cleanup — CloudKitService+TokenOperations.swift still cites Apple's archived RegisterTokens.html as the source of truth; CloudKit JS is the actual authority now.
  • Wire-format unit test — MockTransport doesn't capture the request body, so we can't assert clientId lands in the JSON from unit tests. A capturing transport variant would let us regression-pin the wire shape.
  • Resources/js/tokens.js doesn't yet surface a clientId input. Backend accepts requests without it (auto-UUID), so the panel works; adding a field is pure UX polish.
  • S2S key-path escape bug in test-public config loading.

Closes #49, #50, #51, #52, #53, #379.

leogdion and others added 4 commits May 23, 2026 20:57
Add curated CloudKitService support for the five CloudKit Web Services
push-notification endpoints, plus MistDemo CLI, integration-phase, and
web-server wiring.

MistKit core (#49/#50/#51/#52/#53):
- listSubscriptions / lookupSubscriptions / modifySubscriptions (+ create/
  delete convenience) and createAPNsToken / registerAPNsToken
- New domain types under Models/Subscriptions and Models/Tokens; errors route
  through CloudKitServiceError; CloudKitResponseType conformances per op
- openapi.yaml: promote the records/query inline query shape into a shared
  named `Query` schema, referenced from both records/query and
  Subscription.query, so query subscriptions reuse the same query model;
  regenerated MistKitOpenAPI

MistDemo:
- Real list/lookup/create-token/register-token commands + new
  modify-subscriptions command; SubscriptionRoundtripPhase + TokenRoundtripPhase
  wired into the private pipeline
- Web server: WebBackend methods + routes for subscriptions/* and tokens/*,
  replacing the 501 pending stubs; tokens.js feeds the minted token into
  register

Native app left informational (per #52/#53). Adds unit + service + web-route
tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…p ci]

Surfaced by running the live MistDemo integration suite against a real
container.

- modifySubscriptions: CloudKit echoes a deleted subscription as a bare
  { subscriptionID } with no subscriptionType. Filter those deletion
  acknowledgements instead of fatal-erroring in SubscriptionInfo(from:).

- tokens/create + tokens/register: the endpoints are container-scoped
  (/database/{version}/{container}/{environment}/tokens/...) with no
  {database} segment. Corrected openapi.yaml + regenerated; added
  ContainerOperationInputPath for the database-less path init. (The live
  endpoints still return 405 server-side — tracked in #379.)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Cross-checked against Apple's authoritative reference:
- developer.apple.com/library/archive/.../CreateTokens.html
- developer.apple.com/library/archive/.../RegisterTokens.html

Two real spec bugs surfaced, independent of the ongoing 405 mystery:

1. tokens/register was missing apnsEnvironment in the request body.
   Apple lists both apnsEnvironment + apnsToken as Required. Updated
   openapi.yaml; registerAPNsToken now takes (token, environment, db).

2. TokenResponse shape was wrong. Apple returns { apnsEnvironment,
   apnsToken, webcourierURL } (long-poll URL for browser/SW callers).
   Ours had a speculative `webcAuthToken` and no webcourierURL.
   Reshaped TokenResponse + APNsTokenResult; webcourierURL is now a
   URL on the domain type, environment is echoed back.

Regenerated MistKitOpenAPI; updated CLI (--apns-environment flag on
register-token), MistDemo integration phase, web routes + JS panel,
and tests. 498 MistKit + 941 MistDemo unit tests pass; lint clean.

Live test-private still fails at Phase 16 (TokenRoundtripPhase) —
createAPNsToken returns 405 before we ever reach the corrected
registerAPNsToken. Skip CI: integration scope didn't change, and
the live live-suite failure is by design pending the next diagnostic
pass (see #379 comment).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CloudKit JS calls setApiModuleName("device") on token requests, producing
POST /device/1/{container}/{environment}/tokens/{create,register}. The
archived REST reference documents these under /database/... but the live
service only routes OPTIONS there, hence the persistent 405 with
Allow: OPTIONS. Switching to /device/ unblocks server-side calls.

Also surfaces clientId on both request bodies (CloudKit JS always sends
one, defaulting to a UUID) and exposes it as an optional caller-supplied
parameter so callers can pin both halves of the round-trip to one
logical client.

Live test-private phase 16 (createToken+registerToken) now returns 200
with the expected {apnsToken, apnsEnvironment, webcourierURL} body —
previously it failed at the first call with HTTP 405.

Adds MistDemoLoggingBootstrap so --verbose on the integration commands
turns on swift-log at .debug for com.brightdigit.MistKit.middleware,
letting the existing LoggingMiddleware actually emit the wire trace
that made this diagnosis possible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 25, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b19024f3-142a-4b20-901b-0bf522f6caa9

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch 379-push-notifications-subscriptions-epic

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@claude
Copy link
Copy Markdown

claude Bot commented May 25, 2026

Code Review: Push Notifications & Subscriptions epic (#379)

Overall this is a solid, well-structured implementation that follows the project's established patterns faithfully. The architecture mirrors CloudKitService+ZoneOperations.swift and friends cleanly, the ContainerOperationInputPath abstraction is clever and minimal, the /device/ URL discovery is well-documented, and the fail-loud conversion philosophy from the rest of the codebase is correctly extended to subscription/token types. The integration test suite in SubscriptionRoundtripPhase + TokenRoundtripPhase is nicely self-cleaning. Minor and moderate items below.


Moderate

1. Web server silently forwards empty strings for missing subscription fields
WebRequests+Subscriptions.swift:105-107:

subscriptionID: input.subscriptionID ?? "",
recordType: input.query?.recordType ?? "",
zoneID: ZoneID(zoneName: input.zoneID?.zoneName ?? "")

If the browser omits subscriptionID, recordType, or zoneName, the server sends an empty string to CloudKit rather than returning 400. CloudKit then decides whether to reject it or create a broken subscription. The SubscriptionCommandError types for the CLI side validate these, but the web path skips validation entirely. Prefer decoding these with decode (non-optional) or adding an explicit guard before calling backend.webModifySubscriptions, returning .badRequest to the client.

2. WebRequests.RegisterToken.apnsToken defaults to ""
WebRequests+Tokens.swift:85-86:

self.apnsToken =
  try container.decodeIfPresent(String.self, forKey: .apnsToken) ?? ""

A missing apnsToken in the request body becomes "", which passes decoding but hits CloudKitService.registerAPNsToken's empty-string guard and throws CloudKitError.badRequest. That error surfaces from runOperation as a 500 (internal server error) rather than a 400 to the caller. Either decode with decode (required field) or validate before the service call and return .badRequest.

3. registerRejectsEmpty test uses an overly broad error match
CloudKitServiceTests.Tokens+SuccessCases.swift:85:

await #expect(throws: CloudKitError.self) {

This catches any CloudKitError, including .invalidResponse or .conversionFailed. Narrowing to .badRequest(reason:) (or at least checking the error via #require + a pattern match) would prevent a future refactor from silently changing the error kind while the test still passes.


Minor / Informational

4. APNsEnvironment has two near-identical init(from:) extensions
APNsEnvironment.swift:46-66: The two extensions for createToken and registerToken payload types share identical switch bodies. This is unavoidable given the generated types, but a brief comment noting the duplication is structural (different generated namespaces, same semantics) would help future readers avoid mistaking it for an oversight.

5. schema computed properties are internal var instead of let
SubscriptionInfo.swift:56 and SubscriptionQuery.swift:41: Both are internal var on value types but are never mutated after initialization. Declaring them let (or internal var { get } in a protocol context) would signal immutability at the source.

6. Public memberwise init allows structurally invalid SubscriptionInfo
SubscriptionInfo.swift:67: The public init(subscriptionID:subscriptionType:query:zoneID:firesOn:) allows callers to create a .query type with no query payload, or .zone with no zoneID. The static factory methods .query(...) and .zone(...) are the intended construction surface. Making the memberwise init internal and only exposing the factories would prevent accidental invalid construction from library consumers.

7. clientId wire format not unit-tested (acknowledged)
The PR description notes this correctly — the current MockTransport doesn't capture request bodies, so there's no unit test pinning clientId to the wire JSON. Just confirming this is visible: when a capturing transport variant lands, a regression test for the clientId presence in both request bodies would be valuable.

8. AtomicBool custom implementation is appropriate
MistDemoLoggingBootstrap.swift:59-71: The NSLock-based flag is clean and the comment explaining why Swift Atomics isn't pulled in is helpful. No concern here — it's a one-shot flag and the implementation is correct.


Nit

CloudKitService+SubscriptionOperations.swift:37: The listSubscriptions doc-comment references createAPNsToken(environment:database:) (omitting clientId: from the label), which won't resolve as a symbol link now that clientId is part of the method signature. Update to createAPNsToken(environment:clientId:database:).


Overall the PR is in good shape. The two web-server input-validation issues (#1 and #2) are the most worth fixing before merge since they result in confusing errors for the browser demo; the rest are polish items.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 25, 2026

Codecov Report

❌ Patch coverage is 84.81375% with 53 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (v1.0.0-beta.2@3452060). Learn more about missing BASE report.

Files with missing lines Patch % Lines
...API/Operations/Operations.createToken.Output.swift 0.00% 7 Missing ⚠️
...erations/Operations.listSubscriptions.Output.swift 0.00% 7 Missing ⚠️
...ations/Operations.lookupSubscriptions.Output.swift 0.00% 7 Missing ⚠️
...ations/Operations.modifySubscriptions.Output.swift 0.00% 7 Missing ⚠️
...I/Operations/Operations.registerToken.Output.swift 0.00% 7 Missing ⚠️
...udKitService/CloudKitService+TokenOperations.swift 89.09% 6 Missing ⚠️
...vice/CloudKitResponseProcessor+Subscriptions.swift 87.50% 3 Missing ⚠️
...tService/CloudKitService+ModifySubscriptions.swift 92.10% 3 Missing ⚠️
...dKitService/CloudKitResponseProcessor+Tokens.swift 85.71% 2 Missing ⚠️
...rvice/CloudKitService+SubscriptionOperations.swift 95.12% 2 Missing ⚠️
... and 1 more
Additional details and impacted files
@@               Coverage Diff                @@
##             v1.0.0-beta.2     #381   +/-   ##
================================================
  Coverage                 ?   70.23%           
================================================
  Files                    ?      151           
  Lines                    ?     3383           
  Branches                 ?        0           
================================================
  Hits                     ?     2376           
  Misses                   ?     1007           
  Partials                 ?        0           
Flag Coverage Δ
mistdemo-spm-macos 11.05% <7.73%> (?)
mistdemo-swift-6.2-jammy 11.05% <7.73%> (?)
mistdemo-swift-6.2-noble 11.05% <7.73%> (?)
mistdemo-swift-6.3-jammy 11.05% <7.73%> (?)
mistdemo-swift-6.3-noble 11.05% <7.73%> (?)
spm 71.11% <84.24%> (?)
swift-6.1-jammy 71.05% <84.24%> (?)
swift-6.1-noble 71.23% <84.24%> (?)
swift-6.2-jammy 70.89% <84.24%> (?)
swift-6.2-noble 70.92% <84.24%> (?)
swift-6.3-jammy 70.86% <84.24%> (?)
swift-6.3-noble 70.92% <84.24%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

leogdion and others added 3 commits May 25, 2026 13:39
…ip ci]

Close the loop on subscription notifications with a headless, receive-side
test path and the matching web-app reception, using the verified CloudKit
web-courier wire format.

MistDemoKit (Swift):
- WebCourierPoller: long-polls a webcourierURL on a dedicated URLSession;
  exposes pollOnce/waitForFrame plus a notifications() AsyncThrowingStream
  (the addNotificationListener mirror). No de-dup: the courier is
  consume-on-delivery and nid is shared across subscriptions for one change.
- CourierFrame / CourierNotification: typed model mapping the {aps, ck}
  payload to the documented CloudKit.QueryNotification fields.
- NotificationRoundtripPhase: create subscription (create/update/delete on
  Note) -> mint+register courier token -> trigger a record change -> await
  the push filtered by subscriptionID -> self-clean. Soft on non-arrival
  (delivery is eventual); registered in the private-DB pipeline.

Web app (tokens.js): MistKit mode now long-polls the courier URL returned by
/api/tokens and renders incoming notifications, mirroring CloudKit JS mode's
registerForNotifications()/addNotificationListener.

Docs: WEB_COURIER_SPIKE.md records the resolved wire format (request shape,
{aps, ck} framing, consume-on-delivery / no-cursor). CLAUDE.md corrects the
stale --config-file reference to the real Swift Configuration precedence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…skip ci]

Pin the three real web-courier payload shapes captured against a live
container — an alerting push, a bare ck-only frame, and a silent
content-available push — plus the fo→reason mapping, as a decoding
regression for CourierNotification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… [skip ci]

modifySubscriptions silently dropped CloudKit's inline per-subscription
errors (e.g. INTERNAL_ERROR "could not find subscription we just created"),
returning an empty array where CloudKit JS shows a visible failure. Apply the
records' RecordResult success-or-failure pattern to subscriptions so failures
are surfaced consistently across the API.

- openapi.yaml: add SubscriptionOperationFailure; make SubscriptionsModifyResponse
  items oneOf:[SubscriptionOperationFailure, Subscription] (mirrors ModifyResponse).
  Regenerated MistKitOpenAPI.
- Hoist the server-error-code enum to a shared CloudKitServerErrorCode
  (RecordOperationFailure.ServerErrorCode kept as a typealias) so record and
  subscription failures share one code type.
- Add SubscriptionOperationFailure + SubscriptionResult (.success/.failure/.get())
  and CloudKitError.subscriptionOperationFailed.
- modifySubscriptions now returns [SubscriptionResult] (surfaces failures, still
  skips deletion acks); createSubscription throws on failure; deleteSubscription
  surfaces a failed delete. Web demo maps .get() so the panel shows the error.

Note: this surfaces the failure; it does not make the create succeed — that
CloudKit-side cause (likely a non-queryable record type) is unchanged.
Breaking: modifySubscriptions return type [SubscriptionInfo] -> [SubscriptionResult].

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can delete this

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant