Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,11 +192,9 @@ The plugin version lives in 3 places. Bump all 3 together — they must never di
- `PLUGIN_VERSION` in `scripts/checkpoint.sh` — every checkpoint reports this value as
`skill_version`.

Keep the version current: when a change set affects plugin behavior (skills, scripts,
references, templates, or manifests), bump the version in that same change set. Do not
leave the version stale. One bump per release is enough: if an unreleased change set on
`main` already moved the version past the last release, later change sets in the same
release do not bump it again.
Do not bump the version in a feature or fix change set. The release process below writes
the new version into the 3 places, so a change set that affects plugin behavior leaves
the version at the last release.

Each release ships 2 artifacts: the tagged tree (Claude Code and the Codex marketplace
install from it) and `onesignal-skills-<version>.zip` (the OpenAI directory upload). The
Expand All @@ -210,7 +208,7 @@ The workflows in `.github/workflows/` reuse `OneSignal/sdk-shared`:
merged PR titles since the latest stable version tag and picks the bump (`feat:` →
minor, `fix:`/`perf:` → patch, `!:` → major), or takes a version override. It creates or
rebases `rel/<version>`, writes the version into the 3 files, commits
`chore: Release <version>` (an empty commit when a change set already bumped the tree),
`chore: Release <version>` (an empty commit when the tree already has that version),
and opens the `chore: Release <version>` PR with release notes built from the PR titles.
2. Before merge, the release PR records 3 results, each with its session IDs or command:
- The behavior evals against the repository tree in Claude Code (check 7 in "How to
Expand Down
2 changes: 1 addition & 1 deletion references/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ The plugin needs one answer per platform: does the platform already have credent

| Platform | Configured when | Notes |
|---|---|---|
| APNs | `apns_env` is non-empty (`production` or `sandbox`) | Auth type: `apns_key_id` and `apns_team_id` both set → p8 key; both empty with `apns_env` set → p12 certificate; exactly one set → unknown (report `null`). Both upload paths always set `apns_env`; only the p8 path sets the key and team IDs. |
| APNs | `apns_env` is non-empty (`production` or `sandbox`) | Auth type: `apns_key_id` and `apns_team_id` both set → p8 key; both empty with `apns_env` set → p12 certificate; exactly one set → unknown (report `null`). Both upload paths always set `apns_env`; only the p8 path sets the key and team IDs. `apns_bundle_id` (non-secret; the script prints it as `platforms.apns.bundle_id`) is the Bundle ID the stored credential serves, and the server sends it to APNs as the topic of every push. APNs rejects a push whose topic differs from the app's Bundle ID, so a stored value that differs from the project's Bundle ID means no delivery to this app. Presence stays `true`; the fix is dashboard-only. |
| FCM | `fcm_sender_id` is non-empty | Match the exact name `fcm_sender_id`. Do not match `gcm_sender_id` or `chrome_web_gcm_sender_id`: a web-only app has `chrome_web_gcm_sender_id` set while `fcm_sender_id` is `null`. No non-secret field separates FCM v1 from the legacy key; do not try to tell them apart. |
| Web | `chrome_web_origin` is non-empty | The unauthenticated "Web platform config probe" below is the preferred web read; this field is the authenticated equivalent. |

Expand Down
17 changes: 12 additions & 5 deletions scripts/onesignal_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
404 = feature flag off (fall back to dashboard), 401 = wrong app key
- delivery stat `failed` counts UNSUBSCRIBED targets, not errors
- the view-app read decides credential presence from the non-secret fields
(apns_env, apns_key_id, apns_team_id, fcm_sender_id, chrome_web_origin),
never from the plaintext credential fields the server plans to remove
(apns_env, apns_key_id, apns_team_id, apns_bundle_id, fcm_sender_id,
chrome_web_origin), never from the plaintext credential fields the server
plans to remove
- the write-once credential upload echoes the stored credential fields back
in its 2xx body; `provision` drops that body and prints a verdict, so the
secret never reaches the agent transcript
Expand Down Expand Up @@ -220,8 +221,9 @@ def cmd_app(args):
"platforms": platforms,
"cross_check": cross_check,
"note": "Presence only, from non-secret fields (apns_env, apns_key_id, apns_team_id, "
"fcm_sender_id, chrome_web_origin). Not a validity check: the test send in "
"the verify skill proves the credential works."}
"apns_bundle_id, fcm_sender_id, chrome_web_origin). Not a validity check: the "
"test send in the verify skill proves the credential works. On iOS, compare "
"platforms.apns.bundle_id with the project's Bundle ID."}
print(json.dumps(out, indent=2))


Expand Down Expand Up @@ -253,8 +255,13 @@ def _presence(data):
"apns_listed": apns_listed, "fcm_listed": gcm_listed,
"mismatch": (apns_listed != apns_configured) or (gcm_listed != fcm_configured)}

# The stored Bundle ID is the APNs topic the server sends with each push.
# It is not a secret, and a push to a device whose app has a different
# Bundle ID is rejected by APNs, so the caller compares it with the project.
bundle_id = data.get("apns_bundle_id")
platforms = {
"apns": {"configured": apns_configured, "auth_type": apns_auth},
"apns": {"configured": apns_configured, "auth_type": apns_auth,
"bundle_id": bundle_id if _present(bundle_id) else None},
"fcm": {"configured": fcm_configured},
"web": {"configured": web_configured},
}
Expand Down
12 changes: 6 additions & 6 deletions skills/credentials/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,21 +100,21 @@ Many existing apps already have credentials for the target platform. Check for t

The probe reads and their response semantics come from [../../references/api-reference.md](../../references/api-reference.md) (the view-app read and the "Web platform config probe"), and `python3 <plugin>/scripts/onesignal_api.py <command>` encodes them. Use those; do not hand-roll the calls.

1. **Push platforms (iOS / Android):** run `onesignal_api.py app <app_id>` — the view-app read, `GET /api/v1/apps/{app_id}` — with an app-scoped key (the script takes `--key` or reads `$ONESIGNAL_REST_API_KEY` / `$ONESIGNAL_SETUP_TOKEN`). No MCP tool returns the per-app platform config (api-reference.md), so this read has no MCP path. The script prints a per-platform presence verdict under `platforms` — `apns.configured`, `fcm.configured`, `web.configured` — and that verdict decides. It reads only the **non-secret presence fields** the api-reference names (`apns_env` for APNs, `fcm_sender_id` for FCM, `chrome_web_origin` for web), and treats `null`, `""`, and an absent key the same. Never decide from the plaintext credential fields (`apns_p8`, `apns_certificates`, `fcm_v1_service_account_json`, `gcm_key`): the server plans to remove them from the response, and their absence does not mean the platform is unconfigured. If you must read the raw `GET` without the script, apply the presence fields **only** to a 2xx response whose body is the app's JSON object (it carries this App ID under `"id"`), and inspect those fields and nothing else; any other response — a non-2xx, an empty or non-JSON body, a curl error — is item 7, not "not configured". `cross_check.mismatch: true` (from `channels.push.platforms`) is not a different verdict: it usually means the dashboard platform toggle and the stored credentials disagree, so tell the user in one sentence to check Settings > Push Platforms, and keep the verdict (api-reference.md "View an app"). A `status: error` from the script is "presence unknown", not "not configured" — re-run it once, and if it still errors, go to item 7.
1. **Push platforms (iOS / Android):** run `onesignal_api.py app <app_id>` — the view-app read, `GET /api/v1/apps/{app_id}` — with an app-scoped key (the script takes `--key` or reads `$ONESIGNAL_REST_API_KEY` / `$ONESIGNAL_SETUP_TOKEN`). No MCP tool returns the per-app platform config (api-reference.md), so this read has no MCP path. The script prints a per-platform presence verdict under `platforms` — `apns.configured`, `fcm.configured`, `web.configured` — and that verdict decides. For APNs it also prints `apns.auth_type` (`p8` or `p12`) and `apns.bundle_id`, the Bundle ID the stored credential serves; item 4 uses both. It reads only the **non-secret presence fields** the api-reference names (`apns_env`, `apns_key_id`, `apns_team_id`, and `apns_bundle_id` for APNs, `fcm_sender_id` for FCM, `chrome_web_origin` for web), and treats `null`, `""`, and an absent key the same. Never decide from the plaintext credential fields (`apns_p8`, `apns_certificates`, `fcm_v1_service_account_json`, `gcm_key`): the server plans to remove them from the response, and their absence does not mean the platform is unconfigured. If you must read the raw `GET` without the script, apply the presence fields **only** to a 2xx response whose body is the app's JSON object (it carries this App ID under `"id"`), and inspect those fields and nothing else; any other response — a non-2xx, an empty or non-JSON body, a curl error — is item 7, not "not configured". `cross_check.mismatch: true` (from `channels.push.platforms`) is not a different verdict: it usually means the dashboard platform toggle and the stored credentials disagree, so tell the user in one sentence to check Settings > Push Platforms, and keep the verdict (api-reference.md "View an app"). A `status: error` from the script is "presence unknown", not "not configured" — re-run it once, and if it still errors, go to item 7.
2. **Web:** run `onesignal_api.py web-probe <app_id>` — no key needed. The script wraps the unauthenticated sync probe and always appends the throwaway `?fresh=` param that bypasses the ~1 h CDN cache (api-reference.md). A `status: provisioned` line means the web platform is provisioned (the script wraps the raw `success: true` as that status).
3. **Email / SMS:** no credential-presence endpoint exists for these channels here — go straight to [guided-channels.md](guided-channels.md).
4. **Platform configured →** tell the user which platform and App ID already have credentials, and move on: continue into the `verify` skill (or `setup` if the SDK is not installed yet). Do not upload anything, and do not re-validate the stored credentials. If the user believes the stored credentials are wrong, replacement is dashboard-only (Settings > Push Platforms) or org-key — never this skill's endpoint.
4. **Platform configured →** tell the user which platform and App ID already have credentials, and move on: continue into the `verify` skill (or `setup` if the SDK is not installed yet). Do not upload anything, and do not re-validate the stored credentials. If the user believes the stored credentials are wrong, replacement is dashboard-only (Settings > Push Platforms, `https://dashboard.onesignal.com/apps/<APP_ID>/settings/push/platforms`) or org-key — never this skill's endpoint. **iOS adds one comparison.** Read the Bundle ID of the main app target the way the [.p8 flow](#apple-apns-p8-flow) does (the Xcode project first, never an extension target) and compare it with `apns.bundle_id`. A match needs no comment. A difference is the one stored-credential fault you can detect without a test send: APNs rejects every push whose topic is not the app's Bundle ID (api-reference.md "View an app"), so push will not deliver to this app. Say so in one sentence — name both values and the auth type (`p8` key or `p12` certificate; when `apns.auth_type` is `null`, the stored fields do not show the type, so do not name one) — and send the dashboard URL above; the fix is dashboard-only. Keep the verdict and continue. Stay silent when `apns.bundle_id` is `null` or the project's Bundle ID is unknown.
5. **Platform not configured →** record that fact, then continue into the matching flow. This record matters later: the 409 disambiguation in the [validation loop](#credential-validation-loop) depends on the platform's state before your first upload attempt, and this check is that state.
6. **Wrong or unknown App ID** (`no_such_app` from the web probe, `not_found` from the app read) **→ STOP.** Do not start a portal walkthrough and do not route toward an upload — the endpoint is write-once, and a credential aimed at a mistyped App ID lands on the wrong app. Re-ask the user for the App ID (it is public — grep the init code for it) and re-run this step. A probe `status: unknown` is not a decision either: re-run the probe or fall back to the raw `GET` before you continue.
7. **Check cannot run** (no key available, the `GET` itself fails, or the script returns `status: error`) **→** say so and continue into the flow anyway, but record the Step 1 state as **unknown** — never as "not configured". The write-once endpoint still guards the case: an upload against an already-configured platform returns a 409, and the validation loop maps it. With an unknown before-state, that 409 is indeterminate (case (b) in the [validation loop](#credential-validation-loop)), so a recovery re-call cannot claim success from it.
7. **Check cannot run** (no key available, the `GET` itself fails, or the script returns `status: error`) **→** say so, in one sentence that names the cause, and ask the user to read the dashboard for you before any portal walkthrough. A session with only the OneSignal MCP connected is the common case: the MCP signs in with OAuth and exposes no view-app read, so this check needs an app-scoped key the session does not have. Send `https://dashboard.onesignal.com/apps/<APP_ID>/settings/push/platforms` and ask one structured question (safety contract §14): "Does the page show Apple iOS (APNs) as configured?" (or Google Android (FCM) for Android), with "Yes, a key is already uploaded", "No, nothing is configured", and "I am not sure" as the choices. Record "Yes" as item 4 and skip the portal walkthrough (on iOS, the dashboard shows the auth type and Bundle ID; ask the user to compare the Bundle ID with the project's if they can), "No" as item 5, and "I am not sure" as **unknown** — never as "not configured". On unknown, continue into the flow anyway. The write-once endpoint still guards the case: an upload against an already-configured platform returns a 409, and the validation loop maps it. With an unknown before-state, that 409 is indeterminate (case (b) in the [validation loop](#credential-validation-loop)), so a recovery re-call cannot claim success from it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Before this change every item 7 outcome recorded credentials.detected fail unknown probe_unavailable, which was an honest "we did not observe this." Now "Yes" routes to item 4 and "No" to item 5, so a user's reading of the dashboard produces the same ok and fail credentials_missing rows a successful view-app read produces, and only "I am not sure" keeps probe_unavailable.

The user-flow half of this reads like a clear improvement. The cost is that credentials.detected ok stops meaning "OneSignal confirmed the credential is stored" and starts meaning "either that, or someone said yes." Since the MCP-only session is described here as the common case, that could be a meaningful share of the rows.

Worth a distinct reason slug on the self-reported path, if the telemetry contract's vocabulary has room for one, so the two stay separable?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

In the telemetry contract, only the auth_resolved milestones can put a reason on an ok row, so adding one here needs a contract change first. I'd still make that change in this PR. Otherwise, once this merges, we can't tell a user's "Yes" from a real API check. "No" has the same problem.


**Checkpoint** (telemetry contract rules apply, consent included): report `credentials.detected` the moment this step resolves — it is the presence verdict for the target platform. Email and SMS have no presence check here and send no row. `fail credentials_missing` is the normal entry into the flows below, not a stop. On the wrong-App-ID stop, fire the row **before you end the turn to re-ask** (a session that never resumes otherwise leaves no trace); when the step re-runs with a good App ID, the new row records the recovery. Run exactly one of:

```bash
bash <plugin>/scripts/checkpoint.sh credentials.detected ok # platform already configured
bash <plugin>/scripts/checkpoint.sh credentials.detected fail credentials_missing # not configured — continue into the flow
bash <plugin>/scripts/checkpoint.sh credentials.detected fail invalid_app_id # wrong or unknown App ID — stop and re-ask
bash <plugin>/scripts/checkpoint.sh credentials.detected fail unknown probe_unavailable # the check cannot run (item 7)
bash <plugin>/scripts/checkpoint.sh credentials.detected fail unknown probe_unavailable # the check cannot run and the user is not sure (item 7)
```

On a direct run, append `--app-id <APP_ID>` to the line you pick ("Checkpoint consent" above): the script records the App ID and flushes the events held before it.
Expand Down Expand Up @@ -171,8 +171,8 @@ Read the "Credential provisioning" section of [../../references/api-reference.md

Full portal detail (screenshots-equivalent steps, .p8-vs-.p12 disambiguation, troubleshooting) is in [api-uploaded-credentials.md](api-uploaded-credentials.md#apple-apns-p8). Summary of the hand-off:

1. **Human, in the Apple Developer portal:** Certificates, Identifiers & Profiles → **Keys** → blue **+** → select **Apple Push Notifications service (APNs)** with **Sandbox & Production** → name, Continue, Register → **Download the `.p8`** (one-time download — it cannot be re-downloaded). Requires the **paid** Apple Developer account.
2. **Human captures four values** and gives you the **file path** to the downloaded `.p8` plus the three below. Ask for the path, the Key ID, and the Team ID with the structured-question tool, one question per value (safety contract §14): the user types the value in the free-text field, and every listed option is a fallback — "Help me find it" (repeat the portal location) and "Pause — I'll come back". Do not ask for these values as a plain paste-into-chat message. The Bundle ID rarely needs an ask — read it from the Xcode project first and confirm it. If that read fails (no `.xcodeproj` yet — e.g. Expo before prebuild), returns more than one candidate, or the user rejects the value, ask for the Bundle ID the same way as the Key ID. Never guess it: all four params are required, and the endpoint is write-once.
1. **Human, in the Apple Developer portal:** Certificates, Identifiers & Profiles → **Keys** → blue **+** → select **Apple Push Notifications service (APNs)** with **Sandbox & Production** → name, Continue, Register → **Download the `.p8`** (one-time download — it cannot be re-downloaded). Requires the **paid** Apple Developer account. **Ask before they create a key:** a `.p8` key belongs to the Apple team, not to one app. A **Team Scoped** key serves every app and Bundle ID under the team, and Apple allows at most 2 Team Scoped keys per environment (Sandbox, Production). A **Topic Specific** key serves only the Bundle IDs it lists, and Apple allows many more of them. If the team already has an APNs key — the Keys section lists it — reuse that key's `.p8` file and Key ID instead of creating another; only the Bundle ID in step 2 is specific to this app. Two exceptions, both visible on the key's detail page: a key that Apple scoped to one environment must cover **Sandbox & Production**, and a **Topic Specific** key must list this app's Bundle ID. A `.p12` certificate is the other credential type; this endpoint does not accept it (api-uploaded-credentials.md).
2. **Human captures four values** and gives you the **file path** to the downloaded `.p8` plus the three below. Ask for the path, the Key ID, and the Team ID with the structured-question tool, one question per value (safety contract §14): the user types the value in the free-text field, and every listed option is a fallback — "Help me find it" (repeat the portal location) and "Pause — I'll come back". Do not ask for these values as a plain paste-into-chat message. The Bundle ID rarely needs an ask — read it from the Xcode project first and confirm it. Read it from the main app target (`productType = "com.apple.product-type.application"`), never from an extension target such as the Notification Service Extension, which has its own Bundle ID. If that read fails (no `.xcodeproj` yet — e.g. Expo before prebuild), returns more than one candidate, or the user rejects the value, ask for the Bundle ID the same way as the Key ID. Never guess it: all four params are required, and the endpoint is write-once.
- **Key ID** — 10-char string next to the key name in the Keys section.
- **Team ID** — 10-char string by the team name, top-right of the Apple Developer account. **Not the same as Key ID** — the most common misconfiguration is swapping them. If both are 10 chars and you're unsure, ask the user to re-confirm which came from where.
- **App Bundle ID** — reverse-domain string (e.g. `com.example.app`) from the Identifiers section or Xcode → Signing & Capabilities.
Expand Down
Loading
Loading