review access: guest-play apps rejected by Play as a login wall (empty name field, disabled Continue, a code link on the first screen): check warns for a Play app with a first-run gate or IAP and no reviewAccess.instructions; with guest steps the play-sign-in-details plan declares one entry without credentials, the dialog's required full-access box as the owner's step; store-pitfalls 73
This commit is contained in:
parent
d6070cc185
commit
824758179b
11 changed files with 202 additions and 15 deletions
10
cli/check.ts
10
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<void> {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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}`],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)');
|
||||
|
|
|
|||
|
|
@ -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|(?<![\w.$-])["']?email["']?\s*\??:\s*(?![\s"'`]|$)|z\.string\(\)(\.\w+\([^)]*\))*\.email\(|\bisEmail\(|\[\^\\s@\]\+@|\[a-zA-Z0-9._%+-\]\+@|\/(api\/)?(auth\/)?(email|verify-email|forgot-password|reset-password|magic-link)\b['"`]|signInWithEmailAndPassword|createUserWithEmailAndPassword|sendSignInLinkToEmail|createUser\(withEmail:|signIn\(withEmail:|sendSignInLink\(toEmail:|signInWithOtp\(\s*\{\s*email|ASAuthorization\.Scope\.email|requestedScopes[^\n]{0,40}\.email\b/),
|
||||
// a first-run gate a reviewer can read as a login wall (Play "provide valid login credentials" for a guest-play game):
|
||||
// a name to type before Continue, a transfer / recovery code entry, an account or progress transfer screen
|
||||
firstRunGate: codeLine(/["'>`](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/,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue