diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 9282873..00dedf8 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "iterable-sdk", - "version": "26.10.6-beta", + "version": "26.10.7-beta", "description": "Takes an Android or iOS app — native or React Native — from nothing to a push notification proven on a device: Firebase or Apple (APNs) provisioning, version-pinned SDK integration, silent-failure pitfalls, and device-side proof that a push actually arrived. Android proof runs on a device or emulator; iOS proof runs on the iOS Simulator.", "author": { "name": "Iterable" }, "repository": "https://github.com/Iterable/iterable-sdk-skill", diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 3bc6a2f..fb34c80 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -3,7 +3,7 @@ "owner": { "name": "Iterable" }, "metadata": { "description": "Iterable's official skills for AI coding assistants — mobile SDK integration guidance.", - "version": "26.10.6-beta" + "version": "26.10.7-beta" }, "plugins": [ { diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 012d16d..89d48f6 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "iterable-sdk", - "version": "26.10.6-beta", + "version": "26.10.7-beta", "description": "Takes an Android or iOS app — native or React Native — from nothing to a push notification proven on a device: Firebase or Apple (APNs) provisioning, version-pinned SDK integration, silent-failure pitfalls, and device-side proof that a push actually arrived. Android proof runs on a device or emulator; iOS proof runs on the iOS Simulator.", "author": { "name": "Iterable" }, "repository": "https://github.com/Iterable/iterable-sdk-skill", diff --git a/bin/agent b/bin/agent index a30afc4..da58848 100755 --- a/bin/agent +++ b/bin/agent @@ -212,6 +212,8 @@ case "${1:-status}" in # free-text option, so the developer pastes a project id and the agent relays it # here. Untrusted input, and the one place to say so — the format check is what # makes "paste it" a safe option to offer at all. + # The identity screens have one typed field for an email or a userId. + [[ "$k" == ITBL_IDENTITY ]] && k="$(identity_key "$v")" case "$k" in PID) [[ -z "$v" || "$v" =~ ^[a-z][a-z0-9-]{4,28}[a-z0-9]$ ]] || { @@ -241,6 +243,12 @@ case "${1:-status}" in [[ -z "$v" || "$v" =~ ^[^@[:space:]]+@[^@[:space:]]+\.[A-Za-z]{2,}$ ]] || { printf '{"error":%s}\n' "$(jstr "not an email address: '$v'. This is the value the app passes to setEmail(), and it has to match character for character.")" exit 30; } ;; + # The User ID rules allow up to 128 printable characters. Whitespace and quotes are + # refused as well, because the value lands in a line-based file that strips quotes. + ITBL_USER_ID) + [[ -z "$v" || ( ${#v} -le 128 && "$v" =~ ^[^[:space:][:cntrl:]\"\']+$ ) ]] || { + printf '{"error":%s}\n' "$(jstr "not a user ID this tool can record: '$v'. It is the value the app passes to setUserId(): up to 128 characters, no spaces or quotes.")" + exit 30; } ;; # An allowlist, not a format check, because this is where the server-side key gets # spent: a hostname accepted from a chat is a hostname somebody's credential can be # sent to. Iterable has two data centres and no third answer. @@ -254,7 +262,11 @@ case "${1:-status}" in # writes and the last one config.sh reads. Recorded in resolved.env instead, an # answer given here would silently lose to an older one given in the terminal. case "$k" in - ITBL_EMAIL|ITBL_BASE) DEST="$ITBL_ENV"; save_env "$k" "$v" ;; + ITBL_BASE) DEST="$ITBL_ENV"; save_env "$k" "$v" ;; + # The app identifies its user one way, so recording one identity drops the other. + ITBL_EMAIL|ITBL_USER_ID) + DEST="$ITBL_ENV"; save_env "$k" "$v" && + { [[ -z "$v" ]] || save_env "$([[ "$k" == ITBL_EMAIL ]] && echo ITBL_USER_ID || echo ITBL_EMAIL)" ""; } ;; *) DEST="$WS/resolved.env"; save_resolved "$k" "$v" ;; esac || { printf '{"error":%s}\n' "$(jstr "$k was not saved: could not write to $WS")" @@ -330,9 +342,9 @@ printf '"workspace":%s,' "$(jstr "$WS")" printf '"missing_tools":[' sep=""; while read -r t; do [[ -n "$t" ]] || continue; printf '%s%s' "$sep" "$(jstr "$t")"; sep=","; done <<< "$MISSING" printf '],' -printf '"target":{"project":%s,"package":%s,"device":%s,"email":%s,"iterable_base":%s},' \ +printf '"target":{"project":%s,"package":%s,"device":%s,"email":%s,"user_id":%s,"iterable_base":%s},' \ "$(jstr "$PID")" "$(jstr "$PACKAGE")" "$(jstr "${TARGET_DEVICE:-}")" \ - "$(jstr "$ITBL_EMAIL")" "$(jstr "$ITBL_BASE")" + "$(jstr "$ITBL_EMAIL")" "$(jstr "$ITBL_USER_ID")" "$(jstr "$ITBL_BASE")" # Paths, never values. The keys exist on disk at 0600 and that is the only place # they are ever named — a credential in a JSON report is a credential in a log. printf '"artifacts":{' diff --git a/bin/config.sh b/bin/config.sh index 4cce49f..659c0a6 100755 --- a/bin/config.sh +++ b/bin/config.sh @@ -503,14 +503,22 @@ advice_for() { G14) if [[ -z "$ITBL_MOBILE_KEY" ]]; then echo "finish the Iterable steps first — the app needs a mobile key before it can register anything" elif [[ "$(gate_detail G14)" == *"never called registerDeviceToken"* ]]; then - echo "identify a user in the app — IterableApi.getInstance().setEmail(\"${ITBL_EMAIL:-your test user}\") — then relaunch it; the SDK registers no token until it knows who it is for" + if [[ "$(itbl_identity_kind)" == user-id ]]; then + echo "identify a user in the app — IterableApi.getInstance().setUserId(\"$ITBL_USER_ID\") — then relaunch it; the SDK registers no token until it knows who it is for" + else + echo "identify a user in the app — IterableApi.getInstance().setEmail(\"${ITBL_EMAIL:-your test user}\") — then relaunch it; the SDK registers no token until it knows who it is for" + fi else echo "launch the app, so the SDK registers a token" fi ;; # Naming the address is the point. This is the join key between the app and # Iterable, and a test user who signs in as anyone else looks exactly like a # broken integration from here. - G15) echo "sign in to the app as ${ITBL_EMAIL:-your test user} — that exact address, or no token is ever filed under it" ;; + G15) if [[ "$(itbl_identity_kind)" == user-id ]]; then + echo "sign in to the app as the user whose ID is $ITBL_USER_ID — that exact ID passed to setUserId(), or no token is ever filed under it" + else + echo "sign in to the app as ${ITBL_EMAIL:-your test user} — that exact address, or no token is ever filed under it" + fi ;; G16) echo "send the proof push" ;; G17) echo "set ITBL_CAMPAIGN_ID and send through a campaign, for server-side corroboration" ;; esac @@ -1522,6 +1530,40 @@ load_env_file "$ITBL_ENV" : "${ITBL_SERVER_KEY:=}" : "${ITBL_MOBILE_KEY:=}" : "${ITBL_EMAIL:=}" +# For an app that identifies users with setUserId() and has no email to give. ITBL_EMAIL +# wins when both are set, so a workspace that has only ever had an email reads as before. +: "${ITBL_USER_ID:=}" +itbl_identity() { printf '%s' "${ITBL_EMAIL:-$ITBL_USER_ID}"; } +itbl_identity_kind() { + if [[ -n "$ITBL_EMAIL" ]]; then printf email + elif [[ -n "$ITBL_USER_ID" ]]; then printf user-id + fi +} +# Which of the two a typed or logged value belongs on. The User ID rules say a userId +# never holds an email address, so anything shaped like one is an email. +identity_key() { + if [[ "$1" =~ ^[^@[:space:]]+@[^@[:space:]]+\.[A-Za-z]{2,}$ ]]; then printf ITBL_EMAIL + else printf ITBL_USER_ID + fi +} +itbl_user_path() { + if [[ -n "$ITBL_EMAIL" ]]; then printf '/api/users/getByEmail?email=%s' "$(urlenc "$ITBL_EMAIL")" + else printf '/api/users/byUserId/%s' "$(urlenc "$ITBL_USER_ID")" + fi +} +itbl_events_path() { + if [[ -n "$ITBL_EMAIL" ]]; then printf '/api/events/%s?limit=50' "$(urlenc "$ITBL_EMAIL")" + else printf '/api/events/byUserId/%s?limit=50' "$(urlenc "$ITBL_USER_ID")" + fi +} +itbl_recipient_field() { [[ -n "$ITBL_EMAIL" ]] && printf recipientEmail || printf recipientUserId; } +# Iterable answers an unknown user with a 400; the userId lookups can word it "not found". +itbl_no_user() { + [[ "$1" == 400 ]] || return 1 + if [[ -n "$ITBL_EMAIL" ]]; then grep -qi 'no user exists' <<< "$2" + else grep -qiE 'no user exists|not found' <<< "$2" + fi +} # Set only when the proof push was sent through a campaign. A proof send raises no # pushSend event, so G17 can corroborate nothing without this — see the plan. : "${ITBL_CAMPAIGN_ID:=}" @@ -1640,8 +1682,8 @@ logcat_for_package() { # Which identity the app actually registered, straight out of the SDK's own request # body in logcat. The developer cannot reliably answer this from memory — the app # decides it in code, and a wrong answer looks exactly like a broken Iterable -# project — so ask the device instead of asking them. Prints one address per line, -# most recent first; empty if the buffer has rotated or the app never registered. +# project — so ask the device instead of asking them. Prints one email or userId per +# line, most recent first; empty if the buffer has rotated or the app never registered. # # Silent unless the read can be attributed to the chosen package. A confident wrong # join key is worse than no suggestion: this offered an address belonging to a @@ -1651,8 +1693,9 @@ app_identity() { out="$(logcat_for_package "$d")" || return 0 printf '%s\n' "$out" \ | grep -F 'IterableRequest' \ - | grep -oE '"email": *"[^"]+"' \ - | grep -oE '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}' \ + | grep -oE '"(email|userId)": *"[^"]+"' \ + | awk '/^"email"/ { if (match($0, /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z][A-Za-z]+/)) print substr($0, RSTART, RLENGTH); next } + { v = $0; sub(/^"userId": *"/, "", v); sub(/"$/, "", v); if (length(v) <= 128 && v !~ /[[:space:]\047]/) print v }' \ | tail -r | awk '!seen[$0]++' } diff --git a/bin/gates b/bin/gates index fc863ce..afe0a90 100755 --- a/bin/gates +++ b/bin/gates @@ -291,7 +291,7 @@ gate G9 "Key actually works" G8 tool g9 echo DEV="$(device_serial)" || DEV="no device" -echo " Iterable and device ladder — ${ITBL_BASE#https://}, user ${ITBL_EMAIL:-}, on $DEV" +echo " Iterable and device ladder — ${ITBL_BASE#https://}, user ${ITBL_EMAIL:-${ITBL_USER_ID:-}}, on $DEV" echo gate G10 "Iterable API keys work" - human g10 note G11 "Mobile app in Iterable" "human step — no API read-back exists" diff --git a/bin/gates-iterable.sh b/bin/gates-iterable.sh index 9bd395a..a7a807b 100755 --- a/bin/gates-iterable.sh +++ b/bin/gates-iterable.sh @@ -55,17 +55,17 @@ g10() { # Iterable's own docs send you to Audience > User Lookup for this; the same data # is on the user profile, so it needs no dashboard. g15() { - [[ -n "$ITBL_EMAIL" ]] || { echo "no test user set (ITBL_EMAIL)"; return 1; } + [[ -n "$(itbl_identity)" ]] || { echo "no test user set (ITBL_EMAIL, or ITBL_USER_ID for a user ID)"; return 1; } local out - itbl_get "$ITBL_SERVER_KEY" "/api/users/getByEmail?email=$(urlenc "$ITBL_EMAIL")" || true + itbl_get "$ITBL_SERVER_KEY" "$(itbl_user_path)" || true # "No user exists" is neither a defect nor a bad key: the app has not run and # registered a token yet. Gate rule 2 — a not-yet state reported as a failure # blames the customer for work that is merely pending. Returns 2, not 1. # The other reading of the same answer, and the more common one: the app signs in # as somebody else. Naming both is the difference between a next step and a # mystery, and nothing on this side can tell them apart. - if [[ "$ITBL_CODE" == 400 ]] && grep -qi 'no user exists' <<< "$ITBL_BODY"; then - echo "no user yet — nothing has registered $ITBL_EMAIL (is that what the app signs in as?)" + if itbl_no_user "$ITBL_CODE" "$ITBL_BODY"; then + echo "no user yet — nothing has registered $(itbl_identity) (is that what the app signs in as?)" return 2 fi out="$(classify_itbl_response "$ITBL_CODE" "$ITBL_BODY")" || { echo "$out"; return 1; } @@ -96,11 +96,11 @@ g15() { # G17 — Iterable agrees it sent the push. Corroborates G16 from the server side: # the device saying it arrived and Iterable saying it sent are separate claims. g17() { - [[ -n "$ITBL_EMAIL" ]] || { echo "no test user set (ITBL_EMAIL)"; return 1; } + [[ -n "$(itbl_identity)" ]] || { echo "no test user set (ITBL_EMAIL, or ITBL_USER_ID for a user ID)"; return 1; } local out - itbl_get "$ITBL_SERVER_KEY" "/api/events/$(urlenc "$ITBL_EMAIL")?limit=50" || true - if [[ "$ITBL_CODE" == 400 ]] && grep -qi 'no user exists' <<< "$ITBL_BODY"; then - echo "no user yet — nothing has been sent to $ITBL_EMAIL" + itbl_get "$ITBL_SERVER_KEY" "$(itbl_events_path)" || true + if itbl_no_user "$ITBL_CODE" "$ITBL_BODY"; then + echo "no user yet — nothing has been sent to $(itbl_identity)" return 2 fi out="$(classify_itbl_response "$ITBL_CODE" "$ITBL_BODY")" || { echo "$out"; return 1; } diff --git a/bin/ios-provision b/bin/ios-provision index adb58d2..36fa39a 100755 --- a/bin/ios-provision +++ b/bin/ios-provision @@ -3703,12 +3703,13 @@ import_from_android() { android_setup_present || die "no Android setup with a Mobile key was found at $(android_env_path)" require_ios_next "import-from-android" import_android - local mobile server base email region delivery existing server_lines="" android_env + local mobile server base email user_id region delivery existing server_lines="" android_env android_env="$(android_env_path)" mobile="$(android_env_value ITBL_MOBILE_KEY)" server="$(android_env_value ITBL_SERVER_KEY)" base="$(android_env_value ITBL_BASE)" email="$(android_env_value ITBL_EMAIL)" + user_id="$(android_env_value ITBL_USER_ID)" region=US [[ "$base" != https://api.eu.iterable.com ]] || region=EU [[ "$mobile" =~ ^[A-Za-z0-9._~+/=-]{20,}$ ]] || @@ -3739,15 +3740,16 @@ import_from_android() { ( umask 077 node -e ' - const [path, current, region, email, source] = process.argv.slice(1); + const [path, current, region, email, source, userId] = process.argv.slice(1); const value = JSON.parse(current); value.android_import = "imported"; value.android_import_path = source; value.data_region = region; value.jwt_enabled = false; if (email && !value.identity_mode) value.identity_mode = "email"; + else if (userId && !value.identity_mode) value.identity_mode = "user-id"; require("fs").writeFileSync(path, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 }); - ' "$DISCOVERY_META" "$current" "$region" "$email" "$android_env" + ' "$DISCOVERY_META" "$current" "$region" "$email" "$android_env" "$user_id" ) chmod 600 "$DISCOVERY_META" printf 'Reused the Android setup from %s: the Mobile%s key, region %s, and No JWT. Both keys are validated again for iOS later; nothing was printed.\n' \ @@ -3790,6 +3792,7 @@ android_import_summary() { [[ "$(android_env_value ITBL_BASE)" != https://api.eu.iterable.com ]] || region=EU [[ -z "$(android_env_value ITBL_SERVER_KEY)" ]] || server=yes email="$(android_env_value ITBL_EMAIL)" + [[ -n "$email" ]] || email="$(android_env_value ITBL_USER_ID)" printf 'Android workspace: %s\nRegion: %s\nMobile key: recorded\nServer-side key: %s\nTest identity: %s' \ "$(dirname "$(android_env_path)")" "$region" \ "$([[ "$server" == yes ]] && printf recorded || printf 'not recorded; iOS asks for it later')" \ diff --git a/bin/iterable-keys b/bin/iterable-keys index 757ed46..5fadc89 100755 --- a/bin/iterable-keys +++ b/bin/iterable-keys @@ -79,13 +79,15 @@ step_identity() { $(bold "Step 4 of 4 — the test identity") - The address your app signs in with — the value it passes to - $(bold "setEmail()"). It is the join key: if the app and Iterable disagree, the - token is filed under one identity and the proof push goes to the other, - which reads exactly like a broken integration and is not one. + The identity your app signs the test user in with — the value it passes to + $(bold "setEmail()"), or to $(bold "setUserId()") when its users have no email. It is the join + key: if the app and Iterable disagree, the token is filed under one identity + and the proof push goes to the other, which reads exactly like a broken + integration and is not one. - Nobody signs in yet? Use something disposable and make the app use the same - value — it becomes a real user profile in this Iterable project. + Use what the app already sets; if it sets nothing yet, the integration's + identity decision settles it, not this step. Never change the app's identity, + or invent an email address, to fit this step: that changes it for every user. EOF } @@ -93,7 +95,7 @@ EOF # The question-screen form: the identity text above is a paragraph a chat selector can't # hold, and the join-key point is the one thing in it that has to survive. step_identity_short() { - echo "Use the exact address your app passes to setEmail(). It is the join key: if the app and Iterable disagree, the token is filed under one identity and the proof push goes to the other." + echo "Use the exact email or user ID your app passes to setEmail() or setUserId(). It is the join key: if the app and Iterable disagree, the token is filed under one identity and the proof push goes to the other. Never change the app's identity, or invent an email address, to fit this step." } # A step as a question prompt: no heading or colour, and each numbered line or paragraph @@ -136,8 +138,9 @@ where_identity() { Not a secret, so it can just be said out loud: $(bold "$(cmd_path agent) set ITBL_EMAIL=you@example.com") + $(bold "$(cmd_path agent) set ITBL_USER_ID=") # setUserId() apps - Or put it on $(bold ITBL_EMAIL) in $(wsd "$ITBL_ENV"). + Or put it on $(bold ITBL_EMAIL) or $(bold ITBL_USER_ID) in $(wsd "$ITBL_ENV"). EOF } @@ -170,6 +173,8 @@ ensure_env() { ITBL_SERVER_KEY= ITBL_MOBILE_KEY= ITBL_EMAIL= +# Apps that identify users with setUserId(): +# ITBL_USER_ID= # EU (EDC) projects only: # ITBL_BASE=https://api.eu.iterable.com EOF @@ -409,14 +414,16 @@ if [[ -n "$SEEN" ]]; then printf ' %s\n' $SEEN echo DEFAULT="$(printf '%s\n' "$SEEN" | head -1)" - read -r -p " Email the app signs in as [$DEFAULT]: " EMAIL < /dev/tty - EMAIL="${EMAIL:-$DEFAULT}" + read -r -p " Email or user ID the app signs in as [$DEFAULT]: " WHO < /dev/tty + WHO="${WHO:-$DEFAULT}" else - read -r -p " Email the app signs in as: " EMAIL < /dev/tty + read -r -p " Email or user ID the app signs in as: " WHO < /dev/tty fi -[[ -n "$EMAIL" ]] || { echo " No email given."; exit 10; } -save_env ITBL_EMAIL "$EMAIL" -echo " $(green ✓) test user: $EMAIL" +[[ -n "$WHO" ]] || { echo " No identity given."; exit 10; } +KEY="$(identity_key "$WHO")" +save_env "$KEY" "$WHO" +save_env "$([[ "$KEY" == ITBL_EMAIL ]] && echo ITBL_USER_ID || echo ITBL_EMAIL)" "" +echo " $(green ✓) test user: $WHO" echo echo " $(bold "Now proving it.")" diff --git a/bin/proof-push b/bin/proof-push index 8d4f78a..0b9c139 100755 --- a/bin/proof-push +++ b/bin/proof-push @@ -14,7 +14,7 @@ source "$(dirname "${BASH_SOURCE[0]}")/config.sh" CLIENT_TEMPLATE_ID="itbl-onboard-g16-proof" [[ -n "$ITBL_SERVER_KEY" ]] || { echo "no server-side key. Get one with: $BIN/iterable-keys --step 1" >&2; exit 1; } -[[ -n "$ITBL_EMAIL" ]] || { echo "no ITBL_EMAIL set — the proof needs a user to send to" >&2; exit 1; } +[[ -n "$(itbl_identity)" ]] || { echo "no ITBL_EMAIL or ITBL_USER_ID set — the proof needs a user to send to" >&2; exit 1; } # The marker is what makes the arrival attributable. Without it G16 can only say # "an Iterable push is on screen"; with it, it can say this send arrived. @@ -52,7 +52,7 @@ fi sent_at="$(node -e 'process.stdout.write(String(Date.now()))')" itbl_post "$ITBL_SERVER_KEY" /api/templates/push/proof \ - "$(node -e 'process.stdout.write(JSON.stringify({templateId:Number(process.argv[1]),recipientEmail:process.argv[2]}))' "$tid" "$ITBL_EMAIL")" \ + "$(node -e 'process.stdout.write(JSON.stringify({templateId:Number(process.argv[1]),[process.argv[2]]:process.argv[3]}))' "$tid" "$(itbl_recipient_field)" "$(itbl_identity)")" \ || { echo "proof send failed: $(itbl_err "$ITBL_BODY")" >&2; exit 1; } save_resolved ITBL_PROOF_TEMPLATE_ID "$tid" @@ -60,7 +60,7 @@ save_resolved ITBL_PROOF_MARKER "$MARKER" save_resolved ITBL_PROOF_SENT_AT "$sent_at" echo -echo " Sent template $tid to $ITBL_EMAIL, marked $MARKER." +echo " Sent template $tid to $(itbl_identity), marked $MARKER." if [[ -n "$PRIOR_PROOF" ]]; then echo echo " $(bold "An earlier proof is still in the shade.") Android will bundle this one with" diff --git a/bin/questions.sh b/bin/questions.sh index 3c05cb9..83afcf2 100644 --- a/bin/questions.sh +++ b/bin/questions.sh @@ -602,8 +602,10 @@ q_run_app_context() { echo "" if [[ -n "${ITBL_EMAIL:-}" ]]; then echo " 1. Sign in as $ITBL_EMAIL ← that exact address" + elif [[ -n "${ITBL_USER_ID:-}" ]]; then + echo " 1. Sign in as the user whose ID is $ITBL_USER_ID ← the exact ID the app passes to setUserId()" else - echo " 1. Sign in, then tell the skill which address you used. The token is stored under that address." + echo " 1. Sign in, then tell the skill which email or user ID you used. The token is stored under it." fi echo " 2. Allow notifications when Android asks" echo "" @@ -613,7 +615,8 @@ q_run_app_context() { } q_run_app() { - local who="Sign in, then tell the skill which address you used" + local who="Sign in, then tell the skill which email or user ID you used" + [[ -z "${ITBL_USER_ID:-}" ]] || who="Sign in as the user whose ID is $ITBL_USER_ID, the exact ID the app passes to setUserId()" [[ -z "${ITBL_EMAIL:-}" ]] || who="Sign in as $ITBL_EMAIL, that exact address" printf '{"header":"Your app","prompt":%s,' \ "$(jstr "In your app on ${TARGET_DEVICE:-the device}: @@ -634,7 +637,7 @@ Signed in on ${TARGET_DEVICE:-the device}, with notifications allowed?")" "$BIN/agent" printf ',' q_opt "It has no sign-in yet" \ - "Then the app never calls setEmail, and no token can exist for any address. That is code in your app, not a setting. The app's integration code is what calls setEmail." \ + "Then the app never calls setEmail or setUserId, and no token can exist for any user. That is code in your app, not a setting. The app's integration code is what identifies the user." \ "nothing changed; no identity for a token to be filed under" "" printf ',' q_opt "Android never asked about notifications" \ @@ -644,9 +647,9 @@ Signed in on ${TARGET_DEVICE:-the device}, with notifications allowed?")" # The identity, because a mismatch here is the most common way a working integration # looks broken: the token is filed under one address and the proof push goes to another. printf '"free_text":%s}' "$(q_free_text \ - "the address your app actually signs in as" \ - "records ITBL_EMAIL — the identity the proof push is sent to" \ - "$BIN/agent set ITBL_EMAIL={{answer}}")" + "the email or user ID your app actually signs in as" \ + "records ITBL_EMAIL, or ITBL_USER_ID when it is not an email address — the identity the proof push is sent to" \ + "$BIN/agent set ITBL_IDENTITY={{answer}}")" } # ------------------------------------------------------- part 1: the Google consent @@ -804,9 +807,9 @@ q_iterable_step() { else prompt="Which key is on your clipboard?" fi ;; - 4) [[ -n "$ITBL_EMAIL" ]] \ - && prompt="The test identity is $ITBL_EMAIL. Prove the whole thing now?" \ - || prompt="What address does your app sign in as?" ;; + 4) [[ -n "$(itbl_identity)" ]] \ + && prompt="The test identity is $(itbl_identity). Prove the whole thing now?" \ + || prompt="What email or user ID does your app sign the test user in with?" ;; esac prompt="$("$BIN/iterable-keys" --step "$n" --prompt 2>/dev/null) @@ -870,30 +873,32 @@ $prompt" # app has run and identified anybody, and offering that is how a mismatch between the # two gets caught here rather than three gates later. local seen; seen="$(app_identity 2>/dev/null | head -1)" - if [[ -n "$ITBL_EMAIL" ]]; then + local what=address call='setEmail()' key=ITBL_EMAIL + [[ "$(itbl_identity_kind)" != user-id ]] || { what='user ID'; call='setUserId()'; key=ITBL_USER_ID; } + if [[ -n "$(itbl_identity)" ]]; then rec="Done — now prove it" q_opt "$rec" \ - "Both keys are used in a real Iterable call, and $ITBL_EMAIL is looked up by name. Nothing in the last four steps is taken on trust." \ + "Both keys are used in a real Iterable call, and $(itbl_identity) is looked up by name. Nothing in the last four steps is taken on trust." \ "walk complete; ladder re-run to verify all four steps" "" \ "$BIN/onboard" printf ',' - q_opt "That is the wrong address" \ - "Type the right address below. It has to match the value the app passes to setEmail(), character for character. If they differ, the token is stored for one user and the push goes to another." \ - "ITBL_EMAIL stays as it is until you give another" "" + q_opt "That is the wrong $what" \ + "Type the right one below. It has to match the value the app passes to $call, character for character. If they differ, the token is stored for one user and the push goes to another." \ + "$key stays as it is until you give another" "" elif [[ -n "$seen" ]]; then rec="Use $seen" q_opt "$rec" \ - "This is the address the app on the device registered. It comes from the app's own registerDeviceToken call, so it matches that registration." \ - "ITBL_EMAIL recorded as $seen" "" \ - "$BIN/agent set ITBL_EMAIL=$seen" "$BIN/onboard" + "This is the identity the app on the device registered. It comes from the app's own registerDeviceToken call, so it matches that registration." \ + "$(identity_key "$seen") recorded as $seen" "" \ + "$BIN/agent set $(identity_key "$seen")=$seen" "$BIN/onboard" printf ',' q_opt "Show step 4 again" \ - "The step is shown again, including any addresses the device has registered." \ + "The step is shown again, including any identity the device has registered." \ "step 4 still open" "" \ "$BIN/agent question iterable_step 4" else q_opt "Show step 4 again" \ - "The step is shown again, unchanged. Enter the address below, or set ITBL_EMAIL in the 0600 file." \ + "The step is shown again, unchanged. Enter the email or user ID below, or set ITBL_EMAIL or ITBL_USER_ID in the 0600 file." \ "step 4 still open" "" \ "$BIN/agent question iterable_step 4" printf ',' @@ -902,7 +907,7 @@ $prompt" # what is missing, which is a better place to be stuck than on a screen with no exit. q_opt "I don't know it yet — read the ladder" \ "Runs the checks for what's done. The keys are still used in a real call. The test-identity row stays open and says what's missing, instead of this screen blocking the rest." \ - "ITBL_EMAIL left unset; ladder re-read" "" \ + "test identity left unset; ladder re-read" "" \ "$BIN/onboard" fi fi @@ -913,13 +918,13 @@ $prompt" "$(cmd_path iterable-keys)" printf ']' # Only where the answer is a value the developer types, and never for a key. The - # identity is an email address and belongs in the open; a key put here would be a key + # identity, an email or a user ID, belongs in the open; a key put here would be a key # in the transcript, which is the one thing this walk is arranged to avoid. if ((n == 4)); then printf ',"free_text":%s' "$(q_free_text \ - "the address your app signs in with, e.g. you@example.com" \ - "records ITBL_EMAIL and re-runs the ladder" \ - "$BIN/agent set ITBL_EMAIL={{answer}}")" + "the email or user ID your app signs the test user in with" \ + "records ITBL_EMAIL, or ITBL_USER_ID when it is not an email address, and re-runs the ladder" \ + "$BIN/agent set ITBL_IDENTITY={{answer}}")" fi # No recommendation on the steps that only the developer can judge: whether the click # landed is not something out here can know, and a tool nudging "done" from outside the diff --git a/iterable-android-verify/SKILL.md b/iterable-android-verify/SKILL.md index 7ed4722..6dbad71 100644 --- a/iterable-android-verify/SKILL.md +++ b/iterable-android-verify/SKILL.md @@ -106,8 +106,8 @@ send again, not to doubt the check. The other honest answers, in order of how often they are the real one: the app was never launched after install, so no token exists; the app signs in as a -different address than `target.email`, so the token is filed under one identity -and the proof went to another; the push integration is set to **Notification +different user than `target.email` (or `target.user_id`), so the token is filed +under one identity and the proof went to another; the push integration is set to **Notification messages** instead of **Data notifications**, so the Firebase SDK swallows it before Iterable's SDK sees it; notification permission was denied (the verdict distinguishes "denied" from "never asked"). diff --git a/iterable-provision/SKILL.md b/iterable-provision/SKILL.md index 967c3c3..a35f4e8 100644 --- a/iterable-provision/SKILL.md +++ b/iterable-provision/SKILL.md @@ -571,11 +571,15 @@ Both are human steps, so they are the two most worth saying out loud: - **FCM type must be `Data notifications`**, not "Notification messages". With the wrong one the Firebase SDK swallows the push, Iterable's SDK never sees it, and everything looks configured while no push ever arrives. -- **The test identity is a join key.** It has to be the exact address the app passes - to `setEmail()`. Any other address files the token under one identity and sends the - proof to another, which reads precisely like a broken integration. If the app has - already run, `/bin/iterable-keys` reads what it actually registered out of the SDK's - own request log and offers that — prefer it over anyone's memory. +- **The test identity is a join key.** It has to be the exact value the app passes + to `setEmail()`, or to `setUserId()` when its users have no email; step 4 takes either. + Any other value files the token under one identity and sends the proof to another, + which reads precisely like a broken integration. If the app has already run, + `/bin/iterable-keys` reads what it actually registered out of the SDK's own + request log and offers that — prefer it over anyone's memory. +- **Never change the app's identity to fit the proof**, and never invent an email + address (such as `@.local`) for a user who has none. That changes the identity + of every real user. A userId-only app records `ITBL_USER_ID`. ## Where this goes next diff --git a/tests/agent-questions.sh b/tests/agent-questions.sh index abe17bf..41eefaf 100644 --- a/tests/agent-questions.sh +++ b/tests/agent-questions.sh @@ -345,7 +345,7 @@ step_has 2 "its URL" "https://app.iterable.com/integrations/mobileApps/create" step_has 2 "the package, character for character" "Package name com.sampleshop ← character for character" step_has 3 "the FCM type" 'Data notifications ← not "Notification messages"' step_has 3 "the absolute service-account key path" "Upload $W/artifacts/sa-key.json" -step_has 4 "the setEmail join key" "passes to setEmail(). It is the join key" +step_has 4 "the identity join key" "passes to setEmail() or setUserId(). It is the join key" for n in 1 2 3 4; do prm "$n" | node -e ' let s=""; process.stdin.on("data",d=>s+=d).on("end",()=>{ @@ -399,7 +399,7 @@ rm -f "$W/.env" # Step 4 in both states it can be read in: with the identity recorded and without. The # second one is where a screen with no exit used to be — the app may sign nobody in yet, # and "I don't know" has to lead somewhere. -for st in "ITBL_EMAIL=dev@example.com" ""; do +for st in "ITBL_EMAIL=dev@example.com" "ITBL_USER_ID=u-0a1b2c" ""; do [[ -n "$st" ]] && printf '%s\n' "$st" > "$W/.env" || rm -f "$W/.env" s4="$(q iterable_step 4)" grep -qF "step 5" <<< "$s4" && bad "step 4 invents a fifth step" \ @@ -430,13 +430,13 @@ ok "every step leaves 'that did not work' as an answer that changes nothing" # The one thing no screen in this walk may have: somewhere to type a key. free_text is how # a typed answer reaches `agent set`, and a key typed there is a key in the transcript — -# so the identity, which is an email address and not a secret, is the only value in part 2 +# so the identity, an email or a user ID and not a secret, is the only value in part 2 # collected that way. for n in 1 2 3 4; do ft="$(q iterable_step "$n" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const q=JSON.parse(s);console.log(q.free_text?q.free_text.command:"")})')" case "$n" in - 4) [[ "$ft" == *"set ITBL_EMAIL="* ]] \ - && ok "step 4 takes the test identity as typed text — an address, not a secret" \ + 4) [[ "$ft" == *"set ITBL_IDENTITY="* ]] \ + && ok "step 4 takes the test identity as typed text — an email or user ID, not a secret" \ || bad "step 4 has no way to say what address the app signs in as: '$ft'" ;; *) [[ -z "$ft" ]] && ok "step $n has no typed-answer field at all" \ || bad "step $n invites typed input, and the value in front of it is a key: $ft" ;; @@ -801,7 +801,7 @@ grep -q '"commands":\["[^"]*/bin/agent"\]' <<< "$app" \ || bad "no option re-reads the device — the developer has nothing to proceed with" # The mismatch that makes a working integration look broken: the token filed under one # address, the proof push sent to another. -grep -qF 'set ITBL_EMAIL={{answer}}' <<< "$app" \ +grep -qF 'set ITBL_IDENTITY={{answer}}' <<< "$app" \ && ok "and the address it actually signs in as can be corrected here" \ || bad "no way to say which identity the app really uses" diff --git a/tests/android-userid-proof.sh b/tests/android-userid-proof.sh new file mode 100755 index 0000000..142184c --- /dev/null +++ b/tests/android-userid-proof.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# The Android proof for an app that identifies users with setUserId and has no email. +# Until this existed the ladder only knew ITBL_EMAIL, and a trial agent with a correct +# setUserId integration rewrote the app to sign every user in as `@.local` +# so the proof would run. So: a userId is recorded, looked up and sent to as a userId, +# and a workspace with an email sends exactly what it always did. +# +# No network, no credentials, no device: curl and adb are stubs that record each call. + +set -uo pipefail +cd "$(dirname "$0")/.." + +FAILED=0 +ok() { printf ' \033[32mPASS\033[0m %s\n' "$1"; } +bad() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; FAILED=1; } + +TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT +STUB="$TMP/bin"; mkdir -p "$STUB" +export STUB_CALLS="$TMP/calls" + +cat > "$STUB/curl" <<'CURL' +#!/usr/bin/env bash +cat >/dev/null +method="" data="" url="" +while (($#)); do + case "$1" in + -X) method="$2"; shift ;; + -d) data="$2"; shift ;; + http*) url="$1" ;; + esac + shift +done +printf '%s %s %s\n' "$method" "${url#https://api.iterable.com}" "$data" >> "$STUB_CALLS" +case "$url" in + */api/templates/push/upsert) printf '{"msg":"Upserted 1 templates with IDs: 42"}\n200' ;; + */api/templates/push/proof) printf '{"msg":"ok"}\n200' ;; + */api/users/byUserId/*|*/api/users/getByEmail*) printf '%s\n%s' "$STUB_USER" "${STUB_CODE:-200}" ;; + */api/events/*) printf '{"events":[{"eventName":"pushSend"}]}\n200' ;; + *) printf '{"msg":"unexpected path"}\n404' ;; +esac +CURL + +cat > "$STUB/adb" <<'ADB' +#!/usr/bin/env bash +[[ "$1" == devices ]] && { echo "List of devices attached"; [[ -n "${STUB_LOGCAT:-}" ]] && printf 'emulator-5554\tdevice\n'; exit 0; } +[[ "$1" == -s ]] && shift 2 +case "$*" in + "emu avd name") echo Pixel_9_Pro; exit 0 ;; + "shell pm list packages -U com.example.app") echo "package:com.example.app uid:10218"; exit 0 ;; +esac +[[ "$1 $2 $3" == "logcat -d --uid=10218" ]] && { cat "$STUB_LOGCAT"; exit 0; } +exit 0 +ADB +chmod +x "$STUB/curl" "$STUB/adb" + +# One workspace per case, so an identity recorded by one cannot answer for another. +run() { + local ws="$TMP/ws-$1"; shift + mkdir -p "$ws" + PATH="$STUB:$PATH" WS="$ws" PACKAGE=com.example.app ITBL_SERVER_KEY=server-key-canary \ + DEVICE_WAIT=0 bash -c "$@" +} +calls() { cat "$STUB_CALLS" 2>/dev/null; : > "$STUB_CALLS"; } + +echo +echo " Android proof by userId — stubbed Iterable, no device" +echo + +# ---------------------------------------------------------------- the proof send +: > "$STUB_CALLS" +run uid 'ITBL_USER_ID=u-0a1b2c bin/proof-push' >/dev/null 2>&1 +grep -qxF 'POST /api/templates/push/proof {"templateId":42,"recipientUserId":"u-0a1b2c"}' <<< "$(calls)" \ + && ok "a userId workspace sends the proof to recipientUserId" \ + || bad "the userId proof body is wrong: $(cat "$STUB_CALLS")" + +run email 'ITBL_EMAIL=dev@example.com bin/proof-push' >/dev/null 2>&1 +grep -qxF 'POST /api/templates/push/proof {"templateId":42,"recipientEmail":"dev@example.com"}' <<< "$(calls)" \ + && ok "an email workspace sends the same body as before, to recipientEmail" \ + || bad "the email proof body changed: $(cat "$STUB_CALLS")" + +# ---------------------------------------------------------------- G15 and G17 +export STUB_USER='{"user":{"userId":"u-0a1b2c","dataFields":{"devices":[{"applicationName":"com.example.app","endpointEnabled":true}]}}}' +out="$(run uid 'source bin/config.sh >/dev/null 2>&1; source bin/gates-iterable.sh; ITBL_USER_ID="u 0a/1b" g15' 2>&1)"; rc=$? +c="$(calls)" +((rc == 0)) && grep -qF 'GET /api/users/byUserId/u%200a%2F1b' <<< "$c" \ + && ok "G15 looks the user up by userId, path-encoded, and finds the device" \ + || bad "G15 by userId: rc $rc, calls: $c, out: $out" + +out="$(STUB_CODE=400 STUB_USER='{"msg":"User not found","code":"BadParams"}' \ + run uid 'source bin/config.sh >/dev/null 2>&1; source bin/gates-iterable.sh; ITBL_USER_ID=u-0a1b2c g15' 2>&1)"; rc=$? +calls >/dev/null +((rc == 2)) && grep -qF 'u-0a1b2c' <<< "$out" \ + && ok "an unknown userId is pending, not broken, and names the ID" \ + || bad "unknown userId: rc $rc, $out" + +out="$(run email 'source bin/config.sh >/dev/null 2>&1; source bin/gates-iterable.sh; ITBL_EMAIL=dev@example.com g15' 2>&1)"; rc=$? +grep -qF 'GET /api/users/getByEmail?email=dev%40example.com' <<< "$(calls)" \ + && ok "G15 for an email still reads getByEmail" \ + || bad "G15 for an email no longer reads getByEmail: $out" + +out="$(run uid 'source bin/config.sh >/dev/null 2>&1; source bin/gates-iterable.sh; ITBL_USER_ID=u-0a1b2c g17' 2>&1)"; rc=$? +((rc == 0)) && grep -qF 'GET /api/events/byUserId/u-0a1b2c?limit=50' <<< "$(calls)" \ + && ok "G17 reads the userId's events" \ + || bad "G17 by userId: rc $rc, $out" + +# ---------------------------------------------------------------- recording it +ws="$TMP/ws-set"; mkdir -p "$ws" +envof() { grep -E '^ITBL_(EMAIL|USER_ID)=' "$ws/.env" 2>/dev/null | tr '\n' ' '; } +agent_set() { PATH="$STUB:$PATH" WS="$ws" bin/agent set "$1" >/dev/null 2>&1; } + +agent_set ITBL_EMAIL=dev@example.com +agent_set ITBL_IDENTITY=u-0a1b2c +[[ "$(envof)" == "ITBL_USER_ID=u-0a1b2c " ]] \ + && ok "a typed answer that is not an email is recorded as ITBL_USER_ID, and the email is dropped" \ + || bad "typed userId recorded as: $(envof)" +agent_set ITBL_IDENTITY=dev@example.com +[[ "$(envof)" == "ITBL_EMAIL=dev@example.com " ]] \ + && ok "a typed email is recorded as ITBL_EMAIL, and the userId is dropped" \ + || bad "typed email recorded as: $(envof)" +agent_set "ITBL_USER_ID=has space" && bad "a userId with a space was accepted" \ + || ok "a userId the env file cannot hold is refused" + +# ---------------------------------------------------------------- what the device saw +printf '%s\n' \ + '10-22 15:40:00.000 9000 9001 V IterableRequest: "userId": "u-0a1b2c",' \ + '10-22 15:40:00.000 9000 9001 V IterableRequest: "preferUserId": true,' > "$TMP/logcat.txt" +seen="$(STUB_LOGCAT="$TMP/logcat.txt" run uid 'source bin/config.sh >/dev/null 2>&1; app_identity' 2>/dev/null)" +[[ "$seen" == u-0a1b2c ]] \ + && ok "the userId the app registered is read off the device" \ + || bad "app_identity read '$seen' from a userId registration" + +# ---------------------------------------------------------------- the step 4 screen +s4="$(run uid 'ITBL_USER_ID=u-0a1b2c bin/agent question iterable_step 4' 2>/dev/null)" +grep -qF 'That is the wrong user ID' <<< "$s4" && grep -qF 'setUserId()' <<< "$s4" \ + && ok "step 4 with a userId talks about the user ID and setUserId()" \ + || bad "step 4 with a userId still talks about an address" +grep -qF 'set ITBL_IDENTITY={{answer}}' <<< "$s4" \ + && ok "and its typed answer goes through the identity router" \ + || bad "step 4's typed answer still writes ITBL_EMAIL only" +grep -qiF 'invent an email' <<< "$s4" \ + && ok "and it says never to invent an email address to fit the step" \ + || bad "step 4 no longer warns against inventing an email" + +echo +((FAILED)) && { echo " FAILED"; exit 1; } +echo " All good — a userId app is proved as a userId app."