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
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion .cursor-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand Down
2 changes: 1 addition & 1 deletion .cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
18 changes: 15 additions & 3 deletions bin/agent
Original file line number Diff line number Diff line change
Expand Up @@ -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]$ ]] || {
Expand Down Expand Up @@ -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.
Expand All @@ -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")"
Expand Down Expand Up @@ -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":{'
Expand Down
55 changes: 49 additions & 6 deletions bin/config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:=}"
Expand Down Expand Up @@ -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
Expand All @@ -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]++'
}

Expand Down
2 changes: 1 addition & 1 deletion bin/gates
Original file line number Diff line number Diff line change
Expand Up @@ -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:-<none set>}, on $DEV"
echo " Iterable and device ladder — ${ITBL_BASE#https://}, user ${ITBL_EMAIL:-${ITBL_USER_ID:-<none set>}}, on $DEV"
echo
gate G10 "Iterable API keys work" - human g10
note G11 "Mobile app in Iterable" "human step — no API read-back exists"
Expand Down
16 changes: 8 additions & 8 deletions bin/gates-iterable.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -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; }
Expand Down
9 changes: 6 additions & 3 deletions bin/ios-provision
Original file line number Diff line number Diff line change
Expand Up @@ -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,}$ ]] ||
Expand Down Expand Up @@ -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' \
Expand Down Expand Up @@ -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')" \
Expand Down
35 changes: 21 additions & 14 deletions bin/iterable-keys
Original file line number Diff line number Diff line change
Expand Up @@ -79,21 +79,23 @@ 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
}

# 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
Expand Down Expand Up @@ -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=<the user's 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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.")"
Expand Down
6 changes: 3 additions & 3 deletions bin/proof-push
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -52,15 +52,15 @@ 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"
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"
Expand Down
Loading
Loading