diff --git a/CHANGELOG.md b/CHANGELOG.md index 22b2166..0b29f45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- **Guest-play apps rejected as a login wall (Play).** `reviewAccess.instructions` with `signInRequired: false` holds + guest steps: `storebound check` warns when a Play app has a first-run gate (name entry, code / transfer entry) or + in-app purchases and no steps, and the `play-sign-in-details` plan then declares one entry without credentials, + the dialog's required full-access box as the owner's step (store-pitfalls 73). + ## 0.8.10 · 2026-10-15 - **Neutral names only.** Docs, pitfalls, tests and comments use example names instead of the projects that reported diff --git a/cli/check.ts b/cli/check.ts index 1cc8934..4039912 100644 --- a/cli/check.ts +++ b/cli/check.ts @@ -23,7 +23,8 @@ import { dataSafetyIssues, dataSafetyRows } from './lib/data-safety'; import { credentialStatus, reviewDemoAccount } from './lib/credentials'; import { log, printJson, warn } from './lib/log'; import { profileIssues } from './lib/profile'; -import { detectProject } from './lib/scan/project'; +import { detectProject, grepOwn, where } from './lib/scan/project'; +import { SIGNALS } from './lib/scan/rules'; import { reviewShotProblem } from './apple/iap-review'; import { allSubscriptions } from './lib/subscriptions'; import { type CopyFile, validateCopy } from './shots/lib'; @@ -205,7 +206,12 @@ export async function run(ctx: Ctx): Promise { if (file) for (const i of versionSourceIssues(file)) note(`play: ${versionSourceMessage(i, relative(ctx.loaded.root, file), t.framework)}`); } catch { /* no Gradle project here (yet): play build says so */ } } - for (const i of profileIssues(config,{ demoAccount: !!reviewDemoAccount(ctx.loaded.root) })) { + // first-run gate (name / code entry) in a Play app without reviewer steps: read from the code only when it matters + let firstRunGate: string[] = []; + if (config.play && !config.reviewAccess?.signInRequired && !config.reviewAccess?.instructions?.trim()) { + try { const f = facts ?? detectProject(ctx.loaded.root); firstRunGate = grepOwn(f, SIGNALS.firstRunGate, 3).hits.map(where); } catch { /* no project sources here */ } + } + for (const i of profileIssues(config, { demoAccount: !!reviewDemoAccount(ctx.loaded.root), firstRunGate })) { if (i.level === 'error') problems.push(i.msg); else warn('check', i.msg); } // Play steps without an API: the App category (Store settings) and the App content answers the owner still owes diff --git a/cli/lib/console-plan.ts b/cli/lib/console-plan.ts index 84eb872..3afc200 100644 --- a/cli/lib/console-plan.ts +++ b/cli/lib/console-plan.ts @@ -9,7 +9,7 @@ import { consoleLink, consoleUrl } from './console-links'; import { playCategoryOf, type ShipConfig } from './config'; import type { ProjectSignals } from './project-signals'; import { type Answer, type AnswerValue, consoleAnswers, type FormId, formMeta, isPlanOnly, type PlanFormId, type Store, valueText } from './console-answers'; -import { APP_CONTENT_CAPTURE, APP_CONTENT_CARDS, appContentStart, PLAY_MATERIAL_FACT, playAppPage, playOnlyPart, SEND_FOR_REVIEW, SEND_FOR_REVIEW_WHERE, startLabel } from './console-play-plans'; +import { APP_CONTENT_CAPTURE, APP_CONTENT_CARDS, appContentStart, PLAY_FULL_ACCESS_BOX, PLAY_MATERIAL_FACT, playAppPage, playOnlyPart, SEND_FOR_REVIEW, SEND_FOR_REVIEW_WHERE, startLabel } from './console-play-plans'; import { FORM_BUTTONS, formDef, type Name, NEVER_PRESS, PLAY_NEVER_PRESS, playAppList, playCreateApp, setUpTexts, typeLabels } from './console-forms'; import { PLAY_CREATE_CAPTURE } from './console-verify'; import { APPLE_PURPOSES } from './console-answers'; @@ -445,6 +445,8 @@ function ownerBatchOf(form: PlanFormId, cfg: ShipConfig, p: PlanIds, answers: Pl const open = answers.filter(a => (a.status === 'ask-owner' || a.status === 'owner-check') && !a.id.endsWith('credentials')); if (open.length) out.push({ when: 'before', kind: 'answers', text: `Answer ${open.length} question(s) in ONE batch (askOwner), then write the answers into storebound.config.ts and rerun the plan: ${open.map(a => a.question).join('; ')}` }); if (answers.some(a => a.status === 'ask-owner' && a.id.endsWith('credentials'))) out.push({ when: 'before', kind: 'credentials', text: 'Put the App Review demo account into .storebound/credentials.json (reviewDemoAccount: username, password) so the agent types it without printing it; or type the username and password into the Sign in details card yourself', where: `${consoleLink('play.app-access', '> Add instructions')}` }); + if (answers.some(a => a.status === 'agreement' && /fullAccess$/.test(a.id))) + out.push({ when: 'after', kind: 'agreement', text: `When the agent stops in the Sign in details "Add instructions" dialog: tick the required box "${PLAY_FULL_ACCESS_BOX}" (the agent then presses Add and Save)`, where: `${consoleLink('play.app-access', '> Add instructions')}` }); const iarc = answers.some(a => a.status === 'agreement' && /iarcTerms$/.test(a.id)); if (iarc) out.push({ when: 'before', kind: 'agreement', text: IARC_TERMS, where: iarcWhere(cfg, p) }); if (form === 'play-app-record') { diff --git a/cli/lib/console-play-plans.ts b/cli/lib/console-play-plans.ts index b851e61..9490373 100644 --- a/cli/lib/console-play-plans.ts +++ b/cli/lib/console-play-plans.ts @@ -209,13 +209,34 @@ function adsDeclaration(config: ShipConfig, p: PlanIds): Part { }; } +/** Play's Add instructions dialog: "Any other information required to access your app" holds at most 500 characters. */ +export const PLAY_ACCESS_INFO_MAX = 500; +/** The name of the one entry a guest-play app declares (no account, no credentials). */ +export const GUEST_ENTRY_NAME = 'Guest play (no account needed)'; +/** The Add instructions dialog's required box: an agreement, ticked by the owner only. */ +export const PLAY_FULL_ACCESS_BOX = 'Sign in details in this declaration provide full access to all the features and content within this app, including premium or paid content'; + function signInDetails(config: ShipConfig, p: PlanIds): Part { const ra = config.reviewAccess; const all = 'All functionality in my app is available without any access restrictions', some = 'All or some functionality in my app is restricted'; - const out: PlanAnswer[] = [ra ? ans('access', 'Is any functionality restricted (sign-in, membership, location)?', ra.signInRequired, 'certain', 'reviewAccess.signInRequired', { control: 'radio', action: 'pick', label: ra.signInRequired ? some : all, where: 'Sign in details (App access)' }) - : ans('access', 'Is any functionality restricted (sign-in, membership, location)?', null, 'ask-owner', 'reviewAccess.signInRequired', { control: 'radio', where: 'Sign in details', note: 'Set reviewAccess { signInRequired } (review-access skill).' })]; - if (ra?.signInRequired) { - out.push(ra.instructions ? ans('instructions', 'Instructions for the reviewer', ra.instructions, 'certain', 'reviewAccess.instructions', { control: 'text', action: 'type', label: 'Any other information required to access your app', where: 'Add instructions' }) + const info = 'Any other information required to access your app'; + // a guest-play app with reviewer steps (signInRequired false + instructions): Play rejected such a game as a login + // wall ("provide valid login credentials") while it was declared unrestricted, so it is declared restricted with + // one entry that has no credentials and says how to get in as a guest + const guest = !!ra && !ra.signInRequired && !!ra.instructions?.trim(); + const restricted = !!ra && (ra.signInRequired || guest); + const q = 'Is any functionality restricted (sign-in, membership, location)?'; + const out: PlanAnswer[] = [ra ? ans('access', q, restricted, 'certain', guest ? 'reviewAccess.instructions (guest steps, no sign-in)' : 'reviewAccess.signInRequired', { control: 'radio', action: 'pick', label: restricted ? some : all, where: 'Sign in details (App access)', + ...(guest ? { note: 'No sign-in, but guest steps: answered restricted so the reviewer reads them (Play rejected a guest-play game declared unrestricted as a login wall).' } : {}) }) + : ans('access', q, null, 'ask-owner', 'reviewAccess.signInRequired', { control: 'radio', where: 'Sign in details', note: 'Set reviewAccess { signInRequired } (review-access skill).' })]; + const long = (t: string) => t.length > PLAY_ACCESS_INFO_MAX ? { note: `${t.length} characters: Play takes at most ${PLAY_ACCESS_INFO_MAX}; shorten reviewAccess.instructions.` } : {}; + if (guest) { + const steps = ra!.instructions!.trim(); + out.push(ans('entryName', 'Name of the instructions entry', GUEST_ENTRY_NAME, 'certain', 'reviewAccess.signInRequired false', { control: 'text', action: 'type', label: GUEST_ENTRY_NAME, where: 'Add instructions: Name' })); + out.push(ans('credentials', 'Username and password', null, 'certain', 'reviewAccess.signInRequired false', { control: 'text', where: 'Add instructions: Username, Password', text: 'leave both empty (no account)', note: 'Guest play: no credentials exist; type nothing into Username or Password.' })); + out.push(ans('instructions', 'Instructions for the reviewer', steps, steps.length > PLAY_ACCESS_INFO_MAX ? 'owner-check' : 'certain', 'reviewAccess.instructions', { control: 'text', action: 'type', label: info, where: 'Add instructions: Any other information', ...long(steps) })); + } else if (ra?.signInRequired) { + out.push(ra.instructions ? ans('instructions', 'Instructions for the reviewer', ra.instructions, 'certain', 'reviewAccess.instructions', { control: 'text', action: 'type', label: info, where: 'Add instructions', ...long(ra.instructions) }) : ans('instructions', 'Instructions for the reviewer', null, 'ask-owner', 'reviewAccess.instructions', { note: 'Set reviewAccess.instructions (how to sign in and reach the main features).' })); // with reviewDemoAccount in .storebound/credentials.json the agent types it (never printed: not in chat, logs, the // plan or a commit); without it the owner types the username and password (or adds it there first) @@ -228,22 +249,30 @@ function signInDetails(config: ShipConfig, p: PlanIds): Part { control: 'text', where: 'Add instructions: Username, Password', note: `reviewDemoAccount: ${p.demoAccount === undefined ? 'not checked' : 'MISSING'}. The owner adds it to .storebound/credentials.json before the run (then the agent types it, never printed), or types the username and password into the card themselves. storebound never prints them.` })); } + // the dialog's required declaration box: an agreement, so the owner ticks it (agents never tick declarations) + if (restricted) out.push(ans('fullAccess', 'Do these sign in details give full access to every feature and content, including premium or paid content?', null, 'agreement', 'Play Add instructions dialog (required)', { control: 'checkbox', label: PLAY_FULL_ACCESS_BOX, where: 'Add instructions dialog, required box' })); + const add = 'Add instructions'; return { title: 'Play Console: Sign in details (App access)', start: start(config, p, 'app-content/testing-credentials', appContentReach('Sign in details')), steps: steps([ { text: `Open App content > Sign in details: ${start(config, p, 'app-content/testing-credentials', []).url}` }, { text: 'Pick whether any functionality is restricted (material radio: JS click on the inner [role=radio]).', answers: ['access'] }, + guest && { text: `Guest steps (no sign-in): press "${add}", type the Name "${GUEST_ENTRY_NAME}", leave Username and Password empty, and type reviewAccess.instructions into "${info}" (at most ${PLAY_ACCESS_INFO_MAX} characters).`, click: [add], answers: ['entryName', 'credentials', 'instructions'] }, ra?.signInRequired && { text: p.demoAccount - ? 'Restricted: press "Add instructions", type the instructions, then type the Username and Password from .storebound/credentials.json (reviewDemoAccount; click each field and type: never echo them in chat or logs).' - : 'Restricted: press "Add instructions", type the instructions; the Username and Password are the owner\'s (no reviewDemoAccount in .storebound/credentials.json: in the owner batch).', click: ['Add instructions'], answers: ['instructions', 'credentials'] }, + ? `Restricted: press "${add}", type the instructions, then type the Username and Password from .storebound/credentials.json (reviewDemoAccount; click each field and type: never echo them in chat or logs).` + : `Restricted: press "${add}", type the instructions; the Username and Password are the owner's (no reviewDemoAccount in .storebound/credentials.json: in the owner batch).`, click: [add], answers: ['instructions', 'credentials'] }, + restricted && { text: `Leave the required box "${PLAY_FULL_ACCESS_BOX}" unticked and stop: the owner ticks it (a declaration). Then press "Add" (closes the dialog; the entry is listed).`, click: ['Add'], answers: ['fullAccess'] }, { text: DECLARATION_SAVE }, { text: 'Verify: read the page text and pipe it to `storebound console verify play-sign-in-details --from-text -`.' }, ]), answers: out, - ...declarationButtons('Sign in details', [{ button: 'Add instructions', class: 'navigation', where: 'Sign in details: opens the instructions dialog' }]), - uiFacts: ['Verified 2026-10-12: the Sign in details card (App access) is /app-content/testing-credentials.', 'Credentials are never in the config, the plan or chat: the agent types them from .storebound/credentials.json (reviewDemoAccount) without printing them; without that entry the owner types them.', PLAY_MATERIAL_FACT], - verify: pageVerify('play-sign-in-details', ['The selected radio matches reviewAccess.signInRequired; with a sign-in, one set of instructions is listed.']), + ...declarationButtons('Sign in details', [{ button: add, class: 'navigation', where: 'Sign in details: opens the instructions dialog' }, + ...(restricted ? [{ button: 'Add', class: 'navigation', where: 'Add instructions dialog: lists the entry on the card (nothing is stored before Save)' }] : [])]), + uiFacts: ['Verified 2026-10-12: the Sign in details card (App access) is /app-content/testing-credentials.', 'Credentials are never in the config, the plan or chat: the agent types them from .storebound/credentials.json (reviewDemoAccount) without printing them; without that entry the owner types them.', + `The Add instructions dialog: Name, Username, Password, "${info}" (at most ${PLAY_ACCESS_INFO_MAX} characters) and the required box "${PLAY_FULL_ACCESS_BOX}" (the owner's).`, + 'A guest-play game declared "no restrictions" was rejected as a login wall (empty name field, Continue disabled, an "I have a code" link on the first screen): with guest steps the card says restricted with one entry without credentials.', PLAY_MATERIAL_FACT], + verify: pageVerify('play-sign-in-details', [guest ? `Restricted is selected; one entry "${GUEST_ENTRY_NAME}" with no username or password is listed.` : 'The selected radio matches reviewAccess.signInRequired; with a sign-in, one set of instructions is listed.']), guidance: [`Open: Policy and programs > App content > Sign in details (App access): ${start(config, p, 'app-content/testing-credentials', []).url}`], }; } diff --git a/cli/lib/profile.ts b/cli/lib/profile.ts index 154982c..5d6ae24 100644 --- a/cli/lib/profile.ts +++ b/cli/lib/profile.ts @@ -7,7 +7,9 @@ import { allSubscriptions } from './subscriptions'; export interface ProfileIssue { level: 'error' | 'warn'; msg: string } -export function profileIssues(config: ShipConfig, o: { demoAccount: boolean }): ProfileIssue[] { +/** firstRunGate: where the code shows a first-run gate (name entry, code / transfer entry, account transfer UI), from + * scan's SIGNALS.firstRunGate; check fills it for a Play app without reviewer instructions. */ +export function profileIssues(config: ShipConfig, o: { demoAccount: boolean; firstRunGate?: string[] }): ProfileIssue[] { const out: ProfileIssue[] = []; const err = (msg: string) => out.push({ level: 'error', msg }); const warn = (msg: string) => out.push({ level: 'warn', msg }); @@ -29,6 +31,15 @@ export function profileIssues(config: ShipConfig, o: { demoAccount: boolean }): if (!o.demoAccount) err('App Review needs a demo account (sign-in required) · add reviewDemoAccount to .storebound/credentials.json · review-access skill'); if (!config.reviewAccess.instructions) warn('reviewAccess.instructions: tell the reviewer how to sign in and where the main features are'); } else if (accounts && config.reviewAccess === undefined) warn('the app has accounts: set reviewAccess { signInRequired } so App Review and Play App access get a demo account when needed'); + // a guest-play app (no sign-in) rejected by Play as a login wall: an empty name field with Continue disabled and an + // "I have a code" link on the first screen read as a sign-in. Guest steps in reviewAccess.instructions turn Play's + // Sign in details into a "restricted" entry without credentials that tells the reviewer how to get in. + const gate = o.firstRunGate ?? []; + const iap = !!config.products?.length || allSubscriptions(config.subscriptions).length > 0; + if (config.play && !config.reviewAccess?.signInRequired && !config.reviewAccess?.instructions?.trim() && (gate.length || iap)) + warn(`Play may reject as a login wall; add guest steps: ${[gate.length && `first-run gate in the code (${gate.slice(0, 2).join(', ')})`, iap && 'in-app purchases'].filter(Boolean).join(' and ')} with no reviewAccess.instructions. Set reviewAccess.instructions to English steps for a reviewer without an account (e.g. "No account needed: tap Continue, the name is pre-filled; the code link is only for moving progress"); the play-sign-in-details plan then declares a guest entry (store-pitfalls: guest-play apps rejected as a login wall)`); + const steps = config.reviewAccess?.instructions?.trim() ?? ''; + if (config.play && steps.length > 500) warn(`reviewAccess.instructions: ${steps.length} characters; Play's Sign in details "Any other information" takes at most 500`); const subs = allSubscriptions(config.subscriptions); if (subs.length) { if (!config.app.termsUrl) err('subscriptions: set app.termsUrl (Terms of Use / EULA, linked in the app and the metadata; Apple 3.1.2)'); diff --git a/cli/lib/scan/rules.ts b/cli/lib/scan/rules.ts index 75df08a..861722a 100644 --- a/cli/lib/scan/rules.ts +++ b/cli/lib/scan/rules.ts @@ -132,6 +132,9 @@ export const SIGNALS = { // body / schema / type (not a quoted constant or label), an email validation, an email endpoint or an email sign-in // call. Accounts alone never mean email (a username with a device token has none). email: userLine(/type\s*[:=]\s*["'{]*email["'}]|inputMode=["']email["']|keyboardType=["']?email-address|autoComplete=["']email|textContentType=["']?(emailAddress|\.emailAddress)|UITextContentType\.emailAddress|\.keyboardType\(\.emailAddress\)|keyboardType\s*=\s*\.emailAddress|TextInputType\.emailAddress|TYPE_TEXT_VARIATION_EMAIL_ADDRESS|KeyboardType\.Email\b|(?`](Enter (your |a )?(player |nick ?|user ?)?name|Choose (a |your )?(player |nick ?|user ?)?name|Pick (a |your )?(player |nick ?)?name|What'?s your name|I have a code|Enter (a |your )?(transfer|recovery|restore) code|(Transfer|Recovery|Restore) code|Transfer (your )?(account|progress|save)|Restore (your )?progress)|\b(transferCode|recoveryCode|restoreCode|accountTransfer|transferAccount|TransferCode|RecoveryCode)\b/i), deleteAccount: codeLine(/deleteUser\b|\buser\.delete\(|currentUser\??\.delete\(|\bdeleteAccount\b|delete_account|DeleteAccount|delete-account|["'>]Delete (my |your )?account|\brevokeToken\b|auth\.admin\.deleteUser|Hesab(ı|ımı) sil/i), restore: /restoreCompletedTransactions|AppStore\.sync\(|restorePurchases|restoreTransactions|RestoreTransactions|getAvailablePurchases|\brestorePurchase\b|\.restore\(\)|Restore Purchases|syncPurchases|Purchases\.shared\.restore|store\.restore\b|queryPurchasesAsync/i, subscriptionCode: /subscriptionPeriod|Product\.SubscriptionInfo|\.autoRenewable\b|entitlements\.active|SubscriptionStoreView|ProductType\.Subscription|ProductType\.SUBS|BillingClient\.ProductType\.SUBS/, diff --git a/docs/pitfalls.md b/docs/pitfalls.md index 44a6d2c..638eba3 100644 --- a/docs/pitfalls.md +++ b/docs/pitfalls.md @@ -9,6 +9,7 @@ fails without the fix. | # | date | lesson | guard | |---|---|---|---| +| 73 | 2026-10 | A game with guest play (no sign-in; `reviewAccess.signInRequired: false`, Play Sign in details "No restrictions") was rejected by Play: "Play Console Requirements: provide valid login credentials". The first screen had an empty player-name field with Continue disabled until a name was typed and an "I Have a Code" link to a transfer / recovery code dialog; the reviewer read it as a login wall. | `profileIssues` (`cli/lib/profile.ts`, called by `check` with `SIGNALS.firstRunGate` hits from `cli/lib/scan/rules.ts`): warns "Play may reject as a login wall; add guest steps" for a Play app with a first-run gate or IAP and no `reviewAccess.instructions`, and when the steps exceed 500 characters. The `play-sign-in-details` plan (`cli/lib/console-play-plans.ts`): with guest steps "restricted" and one entry "Guest play (no account needed)" without credentials, the steps in "Any other information", the dialog's required full-access box as an owner step; Add and Save the agent's. `tests/guest-login-wall.test.ts`. | | 72 | 2026-10 | A game: after Play production 1.0.0 (3) was rejected (Broken Functionality), the fix 1.0.1 (5) went through upload and track updates, then edits.validate answered 400 "Changes cannot be sent for review automatically. Please set the query parameter changesNotSentForReview to true". Play refuses the automatic send after a rejection, with other pending changes or managed publishing. | `cli/play/client.ts` (edit flow) with `cli/play/review-pending.ts`: on exactly that 400 the same edit is validated and committed again with changesNotSentForReview=true (a dry run validates that way and says so), the Publishing overview deep link is printed (the owner's button), the commit is recorded in .storebound/ and `play status` shows it; `--not-sent-for-review` on play release / upload / rollout / listing / images (MCP `not_sent_for_review`). `tests/play-not-sent-for-review.test.ts`. Only edits.commit takes changesNotSentForReview: validate answers 400 "Unknown name ... Cannot bind query parameter" (the fake store does too). | | 71 | 2026-10 | Messages sent the owner to console pages as plain text paths ("App Store Connect > App Information > Age Rating", "Play Console > App content") without a link; the owner had to find the app and the page by hand. | `cli/lib/console-links.ts`: every message carries the absolute deep link (app list / developer chooser when ids are unknown), `url` in finding JSON, an Open button in the desktop; `tests/console-links.test.ts` fails on a console path in cli/ or templates/ without an https URL; agent rule 11 in AGENTS.md. | | 70 | 2026-10 | A game: the app's first in-app purchase (a starter pack) was not in the 1.0.0 review submission (its items held only the appStoreVersion); it stayed READY_TO_SUBMIT and was noticed after two days in WAITING_FOR_REVIEW. Apple reviews the first product of a type only with an app version; approved without it, it is not sellable. | `firstProductGaps` (`cli/lib/review-submit.ts`, read by `cli/apple/first-products.ts`): WARN in `apple status` (text and --json), `release status` and the desktop checks; blocker in `validate` / `release plan` and the `apple submit` plan; `iap-review` says "ready, not submitted yet". `tests/first-products.test.ts`. | diff --git a/skills/review-access/SKILL.md b/skills/review-access/SKILL.md index 305a89b..7bba606 100644 --- a/skills/review-access/SKILL.md +++ b/skills/review-access/SKILL.md @@ -40,3 +40,8 @@ creates the account and pastes it into that file. storebound prints it masked (` - A password change or an expired trial on the demo account means a rejection: tell the owner to keep it stable. - Region locks, IP allow-lists or SMS checks block reviewers; switch them off for the demo account. - If sign-in is optional (everything works signed out), set `signInRequired: false`; the demo fields are cleared. +- No sign-in but a first screen that can look like one (a name to type before Continue, an "I have a code" link, + in-app purchases): keep `signInRequired: false` and write guest steps into `reviewAccess.instructions` (English, at + most 500 characters). Play's Sign in details then gets one entry "Guest play (no account needed)" without + credentials (`storebound console plan play-sign-in-details`); a guest-play game declared "no restrictions" was + rejected as a login wall (store-pitfalls 73). Pre-fill the name so Continue is enabled at once. diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 423f16f..28a176b 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -852,3 +852,28 @@ that fails without the fix (see CONTRIBUTING.md). - **Guard:** `isReviewRefused` and the edit flow in `cli/play/client.ts` / `cli/play/review-pending.ts` (only that 400; no other request is repeated, the same edit id), `play status` (text and `reviewPending` in --json); `tests/play-not-sent-for-review.test.ts` (fake Play refuses validate / commit once). + +## 73. Guest-play apps rejected as a login wall +- **Symptom:** Play rejects a game that has no sign-in under "Play Console Requirements: provide valid login + credentials". The config said `reviewAccess: { signInRequired: false }` and Sign in details answered "All + functionality in my app is available without any access restrictions". +- **Cause:** the reviewer's screenshot was the first-run screen: an empty required player-name field, the Continue + button disabled until a name is typed, and an "I Have a Code" link that opens a transfer / recovery code dialog. + To a reviewer an empty field plus a disabled button plus a code entry looks like a sign-in, and "no restrictions" + gave them nothing to follow. +- **Do (console):** write English guest steps into `reviewAccess.instructions` (keep `signInRequired: false`; at most + 500 characters), e.g. "No account needed. Open the app, the player name is pre-filled: tap Continue. 'I Have a Code' + is optional (moves progress from another device); skip it." `storebound console plan play-sign-in-details` then + answers "All or some functionality in my app is restricted" with one entry named "Guest play (no account needed)", + Username and Password empty and the steps in "Any other information required to access your app". The agent presses + Add instructions, fills the dialog and stops: the dialog's required box "Sign in details in this declaration provide + full access to all the features and content within this app, including premium or paid content" is a declaration + the owner ticks (agents never tick declarations). Then the agent presses Add and Save; Send changes for review stays + the owner's. App Review gets the same steps in its notes. +- **Do (in the app):** pre-fill a suggested name so Continue is enabled at once, and make the code entry clearly + optional ("Moving from another device? I have a code"), never the first thing a new player must do. +- **Guard:** `storebound check` warns "Play may reject as a login wall; add guest steps" for a Play app with a + first-run gate in the code (name entry, code / transfer entry, account transfer UI: `SIGNALS.firstRunGate`) or + in-app purchases and no `reviewAccess.instructions`, and when the steps are longer than 500 characters + (`cli/lib/profile.ts`). The plan's guest path is in `cli/lib/console-play-plans.ts` (`signInDetails`); + `tests/guest-login-wall.test.ts`. diff --git a/tests/console-play-plans.test.ts b/tests/console-play-plans.test.ts index 9bf7eb0..aab8f04 100644 --- a/tests/console-play-plans.test.ts +++ b/tests/console-play-plans.test.ts @@ -185,11 +185,11 @@ describe('agent-only Play plans: shape, URLs, never-press lists, the owner batch // in .storebound/credentials.json: the agent types it, never printed (no value in the plan) expect(cred).toMatchObject({ status: 'certain', value: null, action: 'type', text: 'from .storebound/credentials.json (never printed)' }); expect(cred.note).toContain('reviewDemoAccount: present'); - expect(p.ownerBatch.map(o => o.kind)).toEqual(['final']); + expect(p.ownerBatch.map(o => o.kind)).toEqual(['agreement', 'final']); // kept out of .storebound: the owner types it (in the owner batch, before the run) const own = consolePlan('play-sign-in-details', { ...base, reviewAccess: { signInRequired: true, instructions: 'Sign in with the demo account.' } } as ShipConfig, { demoAccount: false }); expect(own.answers.find(a => a.id === 'credentials')).toMatchObject({ status: 'ask-owner', value: null }); - expect(own.ownerBatch.map(o => [o.when, o.kind])).toEqual([['before', 'credentials'], ['after', 'final']]); + expect(own.ownerBatch.map(o => [o.when, o.kind])).toEqual([['before', 'credentials'], ['after', 'agreement'], ['after', 'final']]); expect(JSON.stringify(own)).not.toMatch(/password"\s*:/i); expect(consolePlan('play-sign-in-details', base).answers.map(a => a.status)).toEqual(['ask-owner']); }); diff --git a/tests/guest-login-wall.test.ts b/tests/guest-login-wall.test.ts new file mode 100644 index 0000000..8390298 --- /dev/null +++ b/tests/guest-login-wall.test.ts @@ -0,0 +1,100 @@ +// A guest-play game (no sign-in) that Play rejected as a login wall ("provide valid login credentials"): the first +// screen had an empty name field, Continue disabled until a name was typed and an "I have a code" link. The guardrails: +// `storebound check` warns when such an app has no reviewer steps, and the play-sign-in-details plan declares one entry +// without credentials when reviewAccess.instructions holds the guest steps. Pure; every id is a placeholder. +import { describe, expect, test } from 'bun:test'; +import type { ShipConfig } from '../cli/lib/config'; +import { consolePlan } from '../cli/lib/console-plan'; +import { GUEST_ENTRY_NAME, PLAY_ACCESS_INFO_MAX, PLAY_FULL_ACCESS_BOX } from '../cli/lib/console-play-plans'; +import { verifyPlayDeclaration } from '../cli/lib/console-verify'; +import { profileIssues } from '../cli/lib/profile'; +import { SIGNALS } from '../cli/lib/scan/rules'; + +const base = { + app: { name: 'Example Game', profile: 'game', privacyPolicyUrl: 'https://example.com/privacy', supportUrl: 'https://example.com/support', website: 'https://example.com', contactEmail: 'support@example.com' }, + languages: [{ id: 'en', play: 'en-US', apple: 'en-US' }], + play: { packageName: 'com.example.game', developerId: '4444', appId: '9876' }, +} as ShipConfig; +const STEPS = 'No account needed. 1) Open the app. 2) The player name is pre-filled: tap Continue. 3) "I Have a Code" is optional (moves progress from another device); skip it. Every feature works as a guest.'; +const guest = { ...base, reviewAccess: { signInRequired: false, instructions: STEPS } } as ShipConfig; + +describe('check: guest-play apps that Play may read as a login wall', () => { + const msg = 'Play may reject as a login wall; add guest steps'; + const warns = (c: ShipConfig, firstRunGate?: string[]) => profileIssues(c, { demoAccount: false, firstRunGate }).filter(i => i.level === 'warn').map(i => i.msg); + + test('a first-run gate in the code or IAP without instructions warns', () => { + const noSteps = { ...base, reviewAccess: { signInRequired: false } } as ShipConfig; + expect(warns(noSteps, ['src/onboarding.tsx:12']).find(m => m.startsWith(msg))).toContain('src/onboarding.tsx:12'); + const iap = { ...noSteps, products: [{ id: 'com.example.game.coins', type: 'consumable', usd: 0.99, listings: { en: { title: 'Coins', description: 'Coins' } } }] } as ShipConfig; + expect(warns(iap).find(m => m.startsWith(msg))).toContain('in-app purchases'); + // no reviewAccess at all is the same: nothing tells the reviewer how to get in + expect(warns(base, ['src/a.ts:1']).some(m => m.startsWith(msg))).toBe(true); + }); + + test('quiet with guest steps, without a gate, for a sign-in app and without Play', () => { + expect(warns(guest, ['src/onboarding.tsx:12']).some(m => m.startsWith(msg))).toBe(false); + expect(warns({ ...base, reviewAccess: { signInRequired: false } } as ShipConfig, []).some(m => m.startsWith(msg))).toBe(false); + expect(warns({ ...base, reviewAccess: { signInRequired: true, instructions: 'Sign in with the demo account.' } } as ShipConfig, ['src/a.ts:1']).some(m => m.startsWith(msg))).toBe(false); + const { play: _, ...noPlay } = base; + expect(warns({ ...noPlay, apple: { bundleId: 'com.example.game' } } as ShipConfig, ['src/a.ts:1']).some(m => m.startsWith(msg))).toBe(false); + }); + + test('instructions longer than Play\'s 500 characters warn', () => { + expect(warns({ ...base, reviewAccess: { signInRequired: false, instructions: 'x'.repeat(PLAY_ACCESS_INFO_MAX + 1) } } as ShipConfig).join('\n')).toContain('at most 500'); + }); + + test('the first-run gate signal: name entry, code / transfer entry, account transfer UI', () => { + for (const t of [ + '', "label: 'Choose a player name',", 'I Have a Code', "title: 'Enter transfer code'", + 'const transferCode = useState("")', "'Transfer your progress'", 'Recovery code', "button('Restore progress')", + ]) expect({ t, hit: SIGNALS.firstRunGate.test(t) }).toEqual({ t, hit: true }); + for (const t of [ + "import { transferCode } from './transfer';", 'const playerName = save.name;', '// enter your name later', 'const code = 42;', "'Your name is shown on the leaderboard'", + ]) expect({ t, hit: SIGNALS.firstRunGate.test(t) }).toEqual({ t, hit: false }); + }); +}); + +describe('plan play-sign-in-details: guest steps', () => { + test('instructions with no sign-in: restricted, one entry without credentials, the box is the owner\'s', () => { + const p = consolePlan('play-sign-in-details', guest); + expect(p.answers.find(a => a.id === 'access')).toMatchObject({ value: true, status: 'certain', label: 'All or some functionality in my app is restricted' }); + expect(p.answers.find(a => a.id === 'entryName')).toMatchObject({ value: GUEST_ENTRY_NAME, status: 'certain', action: 'type' }); + expect(p.answers.find(a => a.id === 'credentials')).toMatchObject({ value: null, status: 'certain', text: 'leave both empty (no account)' }); + expect(p.answers.find(a => a.id === 'credentials')!.action).toBeUndefined(); + expect(p.answers.find(a => a.id === 'instructions')).toMatchObject({ value: STEPS, status: 'certain', label: 'Any other information required to access your app' }); + expect(p.answers.find(a => a.id === 'fullAccess')).toMatchObject({ status: 'agreement', value: null, control: 'checkbox', label: PLAY_FULL_ACCESS_BOX }); + expect(p.answers.find(a => a.id === 'fullAccess')!.action).toBeUndefined(); + // the agent presses Add instructions, Add and Save; the box and Send changes for review are the owner's + expect(p.buttons.allowed.map(b => b.button)).toEqual(expect.arrayContaining(['Add instructions', 'Add', 'Save'])); + expect(p.buttons.final).toBe('Send changes for review'); + expect(p.buttons.neverPress).toContain('Send changes for review'); + expect(p.ownerBatch.map(o => [o.when, o.kind])).toEqual([['after', 'agreement'], ['after', 'final']]); + expect(p.ownerBatch[0]!.text).toContain(PLAY_FULL_ACCESS_BOX); + expect(p.askOwner.join('\n')).toContain('the owner ticks it'); + const text = p.steps.map(s => s.text).join('\n'); + expect(text).toContain(`"${GUEST_ENTRY_NAME}"`); + expect(text).toContain('leave Username and Password empty'); + expect(text).toMatch(/unticked and stop/); + }); + + test('verify reads the entry name and the steps back, never the box', () => { + const page = `Sign in details\nAll or some functionality in my app is restricted\n${GUEST_ENTRY_NAME}\n${STEPS}`; + const r = verifyPlayDeclaration('play-sign-in-details', guest, page); + expect(r.rows.filter(x => x.status === 'match').map(x => x.item)).toEqual(['Is any functionality restricted (sign-in, membership, location)?', 'Name of the instructions entry', 'Instructions for the reviewer']); + expect(r.rows.some(x => /full access/.test(x.item))).toBe(false); + expect(r.mismatches).toBe(0); + }); + + test('steps over 500 characters are an owner check', () => { + const p = consolePlan('play-sign-in-details', { ...base, reviewAccess: { signInRequired: false, instructions: 'x'.repeat(PLAY_ACCESS_INFO_MAX + 1) } } as ShipConfig); + expect(p.answers.find(a => a.id === 'instructions')).toMatchObject({ status: 'owner-check' }); + }); + + test('no instructions: the "No restrictions" path stays', () => { + const p = consolePlan('play-sign-in-details', { ...base, reviewAccess: { signInRequired: false } } as ShipConfig); + expect(p.answers.map(a => [a.id, a.value])).toEqual([['access', false]]); + expect(p.answers[0]!.label).toBe('All functionality in my app is available without any access restrictions'); + expect(p.buttons.allowed.map(b => b.button)).not.toContain('Add'); + expect(p.ownerBatch.map(o => o.kind)).toEqual(['final']); + }); +});