@appaflytech/zeyrek-ai-mcp 0.0.50 → 0.0.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/knowledge/expressionPrelude.js +3 -0
- package/dist/knowledge/mobileStore.js +258 -1
- package/dist/knowledge/platform/mobile.js +1 -1
- package/dist/knowledge/platform/mobileRules.js +79 -0
- package/dist/knowledge/rules.js +199 -6
- package/dist/knowledge/storeImport.js +69 -0
- package/dist/resources/examples/flow-credit-spend.json +233 -0
- package/dist/resources/examples/flow-purchase-paywall.json +662 -0
- package/dist/resources/examples/store-import-mapping.json +25 -0
- package/dist/resources/guides/expressions.md +1 -0
- package/dist/resources/guides/mobile-publish.md +63 -0
- package/dist/resources/guides/mobile-steps.md +51 -0
- package/dist/resources/guides/workflow.md +30 -0
- package/dist/resources/schema-fingerprint.json +2 -2
- package/dist/resources/schemas/CreateWorkflowCommand.json +6 -1
- package/dist/resources/schemas/GlobalFunctionUpdateResultDto.json +4 -0
- package/dist/resources/schemas/PreviewWorkflowCommand.json +6 -1
- package/dist/resources/schemas/StepDto.json +6 -1
- package/dist/resources/schemas/UpdateWorkflowCommand.json +6 -1
- package/dist/server.js +4 -0
- package/dist/tools/flows.js +72 -6
- package/dist/tools/mobilePurchases.js +236 -0
- package/dist/tools/mobileStore.js +1 -1
- package/dist/tools/mobileStoreImport.js +173 -0
- package/dist/tools/platformInfo.js +3 -3
- package/package.json +1 -1
|
@@ -97,7 +97,7 @@ export const STORE_FRAME_LAYOUTS = {
|
|
|
97
97
|
tablet10: { deviceWidth: 0.8, bezel: 0.03, radius: 0.05, cutout: 'none' }
|
|
98
98
|
};
|
|
99
99
|
/** Beyan anahtarları (`MobileStoreSettings.*Declaration`); iOS appPrivacy, ötekiler Android */
|
|
100
|
-
export const DECLARATION_KEYS = ['appPrivacy', 'dataSafety', 'contentRating', 'targetAudience', 'appAccess'];
|
|
100
|
+
export const DECLARATION_KEYS = ['appPrivacy', 'dataSafety', 'contentRating', 'targetAudience', 'appAccess', 'legacyScheme'];
|
|
101
101
|
/**
|
|
102
102
|
* Yuva kuralının istek gitmeden denetlenen kısmı (`MobileStoreAssetValidator`): derleme görseli dil
|
|
103
103
|
* almaz, ekran görüntüsü cihaz sınıfı ister, öteki türler cihaz sınıfı almaz. Ölçü/biçim/alfa
|
|
@@ -467,3 +467,260 @@ export const mobileStorePolicyRules = [
|
|
|
467
467
|
}
|
|
468
468
|
}
|
|
469
469
|
];
|
|
470
|
+
/**
|
|
471
|
+
* Uygulama içi satın alma ürünü (kök ADR 0027, `plans/mobil-uygulama-ici-satin-alma` b-01) — backend
|
|
472
|
+
* `MobileStoreProductConstants` / `MobileStoreProductValidator` aynası; `test/mobile-purchases.test.ts` sabitleri backend
|
|
473
|
+
* kaynağına bağlar. Katalog Core DB'dedir (`mobile_store_products`, ortam kavramı yok): adım ürünü bu anahtarla anar,
|
|
474
|
+
* cihaz mağaza kimliğini manifestten çözer.
|
|
475
|
+
*/
|
|
476
|
+
export const PRODUCT_KEY_PATTERN = /^[a-z][a-z0-9_]{1,63}$/;
|
|
477
|
+
export const PRODUCT_NAME_MAX_LENGTH = 30;
|
|
478
|
+
export const PRODUCT_DESCRIPTION_MAX_LENGTH = 45;
|
|
479
|
+
export const PRODUCT_KINDS = ['subscription', 'non_consumable', 'consumable'];
|
|
480
|
+
/** `consumable` ürünün verdiği miktarın üst sınırı (backend `MobileStoreProductConstants.GrantsAmountMax`) */
|
|
481
|
+
export const GRANTS_AMOUNT_MAX = 1_000_000;
|
|
482
|
+
export const PRODUCT_PERIODS = ['week', 'month', 'quarter', 'half_year', 'year'];
|
|
483
|
+
export const PRODUCT_TRIALS = ['none', '3d', '1w', '1m'];
|
|
484
|
+
/** Kabuğun satın almayı taşıyan ilk sürümü (backend `MobileBuildOptions` `PurchasesCapability`; P-23 eski kabukta uyarır) */
|
|
485
|
+
export const PURCHASES_SHELL_VERSION = '0.9.0';
|
|
486
|
+
/** Tür → davranış tablosu (backend `MobileStoreProductConstants.Kinds`, ADR 0027 madde 3): süreli mi, hak mı bakiye mi veriyor */
|
|
487
|
+
export const PRODUCT_KIND_BEHAVIOR = {
|
|
488
|
+
subscription: { periodic: true, grantsEntitlement: true, grantsBalance: false },
|
|
489
|
+
non_consumable: { periodic: false, grantsEntitlement: true, grantsBalance: false },
|
|
490
|
+
consumable: { periodic: false, grantsEntitlement: false, grantsBalance: true }
|
|
491
|
+
};
|
|
492
|
+
/** Ürünün yazılabilir alanları (`MobileStoreProductModel`); yanıttaki mağaza durumu (`ios`/`android`) gövdeye girmez */
|
|
493
|
+
const PRODUCT_MODEL_FIELDS = ['key', 'kind', 'entitlement', 'balance', 'grantsAmount', 'priceTry', 'period', 'trial', 'texts', 'reviewAssetId'];
|
|
494
|
+
const kindBehavior = (kind) => PRODUCT_KINDS.includes(kind) ? PRODUCT_KIND_BEHAVIOR[kind] : undefined;
|
|
495
|
+
/** Mağazaya gitmiş ürün (backend `MobileStoreProduct.IsSent`): bir mağazada `storeId`'si var. Anahtar, tür ve hak kilitlenir */
|
|
496
|
+
export const isProductSent = (product) => (isObject(product.ios) && typeof product.ios.storeId === 'string' && product.ios.storeId !== '') ||
|
|
497
|
+
(isObject(product.android) && typeof product.android.storeId === 'string' && product.android.storeId !== '');
|
|
498
|
+
/**
|
|
499
|
+
* Backend `PUT products/{id}` tam modeli yazar: gönderilmeyen alan boşalır (`texts` boşalınca 422). Yama bu yüzden okunan
|
|
500
|
+
* ürünün üstüne birleştirilir; `texts` dil dil ve alan alan birleşir, `null` dili kaldırır. Süresiz türde (`non_consumable`)
|
|
501
|
+
* yamada verilmeyen `period`/`trial` boşalır — tür değişiminde eski süre taşınırsa backend 422 verirdi. Aynı gerekçeyle hak vermeyen
|
|
502
|
+
* türde `entitlement`, bakiye vermeyen türde `balance`/`grantsAmount` boşalır.
|
|
503
|
+
*/
|
|
504
|
+
export function mergeProduct(current, patch) {
|
|
505
|
+
const base = Object.fromEntries(PRODUCT_MODEL_FIELDS.map(field => [field, current?.[field] ?? null]));
|
|
506
|
+
const merged = { ...base };
|
|
507
|
+
for (const field of PRODUCT_MODEL_FIELDS) {
|
|
508
|
+
if (field !== 'texts' && patch[field] !== undefined)
|
|
509
|
+
merged[field] = patch[field];
|
|
510
|
+
}
|
|
511
|
+
const texts = isObject(base.texts) ? { ...base.texts } : {};
|
|
512
|
+
for (const [locale, text] of Object.entries(isObject(patch.texts) ? patch.texts : {})) {
|
|
513
|
+
if (text === null)
|
|
514
|
+
delete texts[locale];
|
|
515
|
+
else
|
|
516
|
+
texts[locale] = { ...(isObject(texts[locale]) ? texts[locale] : {}), ...(isObject(text) ? text : {}) };
|
|
517
|
+
}
|
|
518
|
+
merged.texts = texts;
|
|
519
|
+
const behavior = kindBehavior(merged.kind);
|
|
520
|
+
if (behavior?.periodic === false) {
|
|
521
|
+
if (patch.period === undefined)
|
|
522
|
+
merged.period = null;
|
|
523
|
+
if (patch.trial === undefined)
|
|
524
|
+
merged.trial = null;
|
|
525
|
+
}
|
|
526
|
+
if (behavior?.grantsEntitlement === false && patch.entitlement === undefined)
|
|
527
|
+
merged.entitlement = null;
|
|
528
|
+
if (behavior?.grantsBalance === false) {
|
|
529
|
+
if (patch.balance === undefined)
|
|
530
|
+
merged.balance = null;
|
|
531
|
+
if (patch.grantsAmount === undefined)
|
|
532
|
+
merged.grantsAmount = null;
|
|
533
|
+
}
|
|
534
|
+
for (const field of ['balance', 'grantsAmount'])
|
|
535
|
+
if (merged[field] === null)
|
|
536
|
+
delete merged[field];
|
|
537
|
+
return merged;
|
|
538
|
+
}
|
|
539
|
+
/** Birleşmiş gövdenin okunan üründen farklı alanları (`texts.en.name` gibi yaprak yolla) */
|
|
540
|
+
export function changedProductFields(current, next) {
|
|
541
|
+
return PRODUCT_MODEL_FIELDS.flatMap((field) => {
|
|
542
|
+
if (field !== 'texts')
|
|
543
|
+
return JSON.stringify(next[field] ?? null) === JSON.stringify(current[field] ?? null) ? [] : [field];
|
|
544
|
+
const before = isObject(current.texts) ? current.texts : {};
|
|
545
|
+
const after = isObject(next.texts) ? next.texts : {};
|
|
546
|
+
return [...new Set([...Object.keys(before), ...Object.keys(after)])].flatMap(locale => ['name', 'description']
|
|
547
|
+
.filter(key => (isObject(before[locale]) ? before[locale][key] : undefined) !== (isObject(after[locale]) ? after[locale][key] : undefined))
|
|
548
|
+
.map(key => `texts.${locale}.${key}`));
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* `MobileStoreProductValidator` aynası: backend'in 422 vereceği her alan, aynı gerekçeyle. `siblings` uygulamanın öteki
|
|
553
|
+
* ürünleridir (anahtar tekilliği; bir hak tek türden ürünle verilir — abonelik grubu ile tek seferlik ürün aynı hakkı
|
|
554
|
+
* paylaşamaz). Fiyat TRY, en çok iki ondalık; mağaza fiyat basamağına eşlemeyi gönderim yapar.
|
|
555
|
+
*/
|
|
556
|
+
export function productFormatErrors(product, siblings = []) {
|
|
557
|
+
const errors = [];
|
|
558
|
+
const add = (field, message) => errors.push({ field, message });
|
|
559
|
+
const key = typeof product.key === 'string' ? product.key : '';
|
|
560
|
+
if (!PRODUCT_KEY_PATTERN.test(key))
|
|
561
|
+
add('key', `${PRODUCT_KEY_PATTERN.source} (küçük harf, rakam, alt çizgi; adım bu anahtarla anar)`);
|
|
562
|
+
else if (siblings.some(sibling => sibling.key === key))
|
|
563
|
+
add('key', 'bu uygulamada aynı anahtarlı başka ürün var');
|
|
564
|
+
const behavior = kindBehavior(product.kind);
|
|
565
|
+
if (!behavior) {
|
|
566
|
+
add('kind', PRODUCT_KINDS.join(' | '));
|
|
567
|
+
return errors;
|
|
568
|
+
}
|
|
569
|
+
const entitlement = typeof product.entitlement === 'string' ? product.entitlement : '';
|
|
570
|
+
const other = behavior.grantsEntitlement ? siblings.find(sibling => sibling.entitlement === entitlement && sibling.kind !== product.kind) : undefined;
|
|
571
|
+
const present = (value) => value !== null && value !== undefined;
|
|
572
|
+
if (behavior.grantsEntitlement && !PRODUCT_KEY_PATTERN.test(entitlement))
|
|
573
|
+
add('entitlement', `hak adı ${PRODUCT_KEY_PATTERN.source} (ekranlar Entitlements.<hak> okur)`);
|
|
574
|
+
else if (other)
|
|
575
|
+
add('entitlement', `'${entitlement}' hakkını '${String(other.key)}' (${String(other.kind)}) veriyor — bir hak tek türden ürünle verilir (Apple abonelik grubu = hak)`);
|
|
576
|
+
if (!behavior.grantsEntitlement && present(product.entitlement))
|
|
577
|
+
add('entitlement', `${String(product.kind)} ürününde hak olmaz — boş bırakın (tüketilen ürün hak değil bakiye verir)`);
|
|
578
|
+
if (behavior.grantsBalance && !PRODUCT_KEY_PATTERN.test(typeof product.balance === 'string' ? product.balance : ''))
|
|
579
|
+
add('balance', `bakiye adı ${PRODUCT_KEY_PATTERN.source} (ekranlar Balances.<ad> okur, balance-spend adımı bu adla harcar)`);
|
|
580
|
+
if (!behavior.grantsBalance && present(product.balance))
|
|
581
|
+
add('balance', `${String(product.kind)} ürününde bakiye olmaz — yalnız consumable bakiye verir`);
|
|
582
|
+
const amount = product.grantsAmount;
|
|
583
|
+
if (behavior.grantsBalance && !(typeof amount === 'number' && Number.isInteger(amount) && amount >= 1 && amount <= GRANTS_AMOUNT_MAX))
|
|
584
|
+
add('grantsAmount', `her satın almada eklenen miktar, 1–${GRANTS_AMOUNT_MAX} arası tam sayı`);
|
|
585
|
+
if (!behavior.grantsBalance && present(amount))
|
|
586
|
+
add('grantsAmount', `${String(product.kind)} ürününde miktar olmaz — yalnız consumable bakiye verir`);
|
|
587
|
+
const price = product.priceTry;
|
|
588
|
+
if (typeof price !== 'number' || !Number.isFinite(price) || price <= 0 || Math.round(price * 100) / 100 !== price)
|
|
589
|
+
add('priceTry', 'sıfırdan büyük TRY fiyatı, en çok iki ondalık (949.99)');
|
|
590
|
+
if (behavior.periodic && !PRODUCT_PERIODS.includes(product.period))
|
|
591
|
+
add('period', `abonelikte zorunlu: ${PRODUCT_PERIODS.join(' | ')}`);
|
|
592
|
+
if (behavior.periodic && product.trial !== null && product.trial !== undefined && !PRODUCT_TRIALS.includes(product.trial))
|
|
593
|
+
add('trial', `${PRODUCT_TRIALS.join(' | ')} ya da boş`);
|
|
594
|
+
if (!behavior.periodic && product.period !== null && product.period !== undefined)
|
|
595
|
+
add('period', `${String(product.kind)} ürününde süre olmaz — boş bırakın`);
|
|
596
|
+
if (!behavior.periodic && product.trial !== null && product.trial !== undefined)
|
|
597
|
+
add('trial', `${String(product.kind)} ürününde deneme olmaz — boş bırakın`);
|
|
598
|
+
const texts = isObject(product.texts) ? product.texts : {};
|
|
599
|
+
if (Object.keys(texts).length === 0)
|
|
600
|
+
add('texts', "en az bir dilde ad ve açıklama: { tr: { name, description } } (mağaza metni; ar, en, ru, tr mağazanın diline eşlenir)");
|
|
601
|
+
for (const [locale, text] of Object.entries(texts)) {
|
|
602
|
+
const name = isObject(text) && typeof text.name === 'string' ? text.name : '';
|
|
603
|
+
const description = isObject(text) && typeof text.description === 'string' ? text.description : '';
|
|
604
|
+
if (!LOCALE_PATTERN.test(locale))
|
|
605
|
+
add(`texts.${locale}`, `dil kodu ${LOCALE_PATTERN.source}`);
|
|
606
|
+
if (!name.trim() || name.length > PRODUCT_NAME_MAX_LENGTH)
|
|
607
|
+
add(`texts.${locale}.name`, `1–${PRODUCT_NAME_MAX_LENGTH} karakter (şimdi ${name.length})`);
|
|
608
|
+
if (!description.trim() || description.length > PRODUCT_DESCRIPTION_MAX_LENGTH)
|
|
609
|
+
add(`texts.${locale}.description`, `1–${PRODUCT_DESCRIPTION_MAX_LENGTH} karakter (şimdi ${description.length})`);
|
|
610
|
+
}
|
|
611
|
+
return errors;
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Mağazaya gitmiş üründe değişemeyen alanlar (backend `MobileStoreProductLockedException`, 422): mağaza onları ürünün kimliği sayar.
|
|
615
|
+
* `grantsAmount` kilitli değildir (mağaza alanı değil; eski satışlar eski miktarla kalır).
|
|
616
|
+
*/
|
|
617
|
+
export function productLockedChanges(current, next) {
|
|
618
|
+
return isProductSent(current) ? ['key', 'kind', 'entitlement', 'balance'].filter(field => (current[field] ?? null) !== (next[field] ?? null)) : [];
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Satın alma hazırlık satırının ne istediği ve kimin yaptığı — anahtarlar backend altın listesi
|
|
622
|
+
* `TestData/mobile-store/purchase-readiness-rows.json` ile kilitli (`test/mobile-purchases.test.ts`). Gizli bilgi (Apple
|
|
623
|
+
* satın alma anahtarı, hesaplar) MCP'den yazılmaz; konsol satırları insanın işidir.
|
|
624
|
+
*/
|
|
625
|
+
export const PURCHASE_READINESS_HINTS = {
|
|
626
|
+
'ios.connection': "Organizasyonun Apple hesabı (App Store Connect API anahtarı) bağlı değil — Studio › Yayınla › Hesaplar (gizli bilgi, MCP yazmaz).",
|
|
627
|
+
'ios.paidAgreement': "App Store Connect › Business'ta ücretli uygulama sözleşmesi, banka ve vergi bilgisi — insanın işi. missing: son gönderim paid_agreement hatası aldı; sözleşme imzalanınca mobile_product_send { retrySendId }.",
|
|
628
|
+
'ios.iapKey': "App Store Server API satın alma anahtarı (.p8 + Key ID + Issuer ID) — Studio › Satın almalar › Mağaza hazırlığı'nda yüklenir (gizli bilgi, MCP yazmaz).",
|
|
629
|
+
'ios.notificationUrl': "App Store Server Notifications V2 adresi: copyValue'yu App Store Connect › Uygulama › App Information'a (production ve sandbox aynı adres) yapıştırın; ilk bildirim gelince ready olur. fix: kayıtlı adres farklı (detail).",
|
|
630
|
+
'android.connection': "Play hizmet hesabı bağlı değil — Studio › Yayınla › Hesaplar (gizli bilgi, MCP yazmaz).",
|
|
631
|
+
'android.merchant': "Play Console › Ödeme profili (merchant hesabı) — insanın işi.",
|
|
632
|
+
'android.billingBuild': `Google ürün açmadan önce faturalandırma izinli (BILLING) bir derlemenin Play'e yüklenmiş olmasını ister: ürün kaydedildikten sonra kabuk ≥ ${PURCHASES_SHELL_VERSION} ile mobile_build_start { platform: 'android', target: 'internal' }; ilk Android ürünü mağazada açılınca ready.`,
|
|
633
|
+
'android.permissions': "fix: son gönderim Play'den 403 aldı (play_permission) — Play Console › Kullanıcılar ve izinler'de hizmet hesabına uygulama içi ürünleri yönetme izni verin, sonra mobile_product_send { retrySendId }.",
|
|
634
|
+
'android.rtdnTopic': "Play Console › Para kazanma kurulumu › Gerçek zamanlı geliştirici bildirimleri: Pub/Sub konusu + kimlik doğrulamalı push aboneliği (uç copyValue, hizmet hesabı Zeyrek'e yüklenen Play hesabı) — insanın işi; ilk bildirim gelince ready."
|
|
635
|
+
};
|
|
636
|
+
/**
|
|
637
|
+
* FLOW-045'in katalog ayağı (araç katmanı — politika kuralı tanımı görür, kataloğu görmez): statik `purchase.value`
|
|
638
|
+
* uygulamanın ürün anahtarı değilse cihaz ve önizleme `invalid` döner. Backend akış doğrulayıcısı kataloğu okumaz
|
|
639
|
+
* (iş sahibi kararı b-04, 2026-10-23: Studio → Core bağımlılığı açılmaz; mağaza yayını ön kontrolü P-23
|
|
640
|
+
* `purchases_unknown_product` uyarır), ajanın yazımını gateway `block` ile durdurur — istek gitmez, hata katalogdaki
|
|
641
|
+
* anahtarları sayar. Şablonlu değer (`{{ }}`) ve boş değer burada susar (boşu politika kuralı `block` eder); desen dışı ya da
|
|
642
|
+
* tırnaklı değer de katalogda olamayacağı için durur — nasıl düzeltileceğini politika kuralının uyarısı söyler.
|
|
643
|
+
*/
|
|
644
|
+
export function purchaseCatalogFindings(steps, products) {
|
|
645
|
+
const keys = products.map(product => String(product.key));
|
|
646
|
+
return steps.flatMap((step) => {
|
|
647
|
+
const value = isObject(step.args) && typeof step.args.value === 'string' ? step.args.value.trim() : '';
|
|
648
|
+
if (!value || value.includes('{{') || keys.includes(value))
|
|
649
|
+
return [];
|
|
650
|
+
return [{
|
|
651
|
+
ruleId: 'FLOW-045',
|
|
652
|
+
severity: 'block',
|
|
653
|
+
message: `'${String(step.stepId ?? step.name ?? '?')}' purchase adımının ürünü '${value}' uygulamanın ürün kataloğunda yok — cihaz ve önizleme 'invalid' döner. ${keys.length > 0 ? `Katalog: ${keys.join(', ')}.` : 'Uygulamada hiç ürün yok.'} Ürünü önce mobile_product_upsert ile ekleyin ya da anahtarı katalogdakiyle değiştirin; seçimden gelen anahtar için '{{ … }}' şablonu yazın.`,
|
|
654
|
+
guide: 'ngapps://guides/mobile-steps'
|
|
655
|
+
}];
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
const stepText = (step, field) => isObject(step.args) && typeof step.args[field] === 'string' ? step.args[field].trim() : '';
|
|
659
|
+
/** Katalogdaki tüketilen ürünlerin bakiye adları (sırayla, tekrarsız) */
|
|
660
|
+
export const catalogBalances = (products) => [...new Set(products.filter(product => product.kind === 'consumable').map(product => String(product.balance ?? '')).filter(balance => balance !== ''))];
|
|
661
|
+
/** Katalogdaki hak adları (abonelik ve tek seferlik ürünlerin `entitlement`'ı) */
|
|
662
|
+
export const catalogEntitlements = (products) => [...new Set(products.filter(product => product.kind !== 'consumable').map(product => String(product.entitlement ?? '')).filter(entitlement => entitlement !== ''))];
|
|
663
|
+
/**
|
|
664
|
+
* FLOW-046'nın katalog ayağı (araç katmanı — politika kuralı tanımı görür, kataloğu görmez), FLOW-045'in `purchaseCatalogFindings`
|
|
665
|
+
* emsali: statik `balance-spend.value` uygulamanın tüketilen ürünlerinden birinin bakiye adı değilse sunucu `invalid` döner
|
|
666
|
+
* (katalogda olmayan bakiye adı 422). Şablonlu (`{{ }}`) ve boş değer burada susar (boşu politika kuralı `block` eder).
|
|
667
|
+
* Katalog `block` bulgusu üretir; çağıran yazımdan ÖNCE `applyPolicyMode` + `enforcePolicy` ile uygular.
|
|
668
|
+
*/
|
|
669
|
+
export function balanceCatalogFindings(steps, products) {
|
|
670
|
+
const balances = catalogBalances(products);
|
|
671
|
+
return steps.flatMap((step) => {
|
|
672
|
+
const value = stepText(step, 'value');
|
|
673
|
+
if (!value || value.includes('{{') || balances.includes(value))
|
|
674
|
+
return [];
|
|
675
|
+
return [{
|
|
676
|
+
ruleId: 'FLOW-046',
|
|
677
|
+
severity: 'block',
|
|
678
|
+
message: `'${String(step.stepId ?? step.name ?? '?')}' balance-spend adımının bakiyesi '${value}' uygulamanın tüketilen ürünlerinde yok — sunucu 'invalid' döner. ${balances.length > 0 ? `Katalogdaki bakiyeler: ${balances.join(', ')}.` : 'Uygulamada tüketilen ürün yok (abonelik ve tek seferlik ürün bakiye vermez).'} Önce mobile_product_upsert { kind: 'consumable', balance: '${value}', grantsAmount } ile ürünü ekleyin ya da adı katalogdakiyle değiştirin; seçimden gelen ad için '{{ … }}' şablonu yazın.`,
|
|
679
|
+
guide: 'ngapps://guides/mobile-steps'
|
|
680
|
+
}];
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Workflow `balance` adımı için aynı katalog denetimi (FLOW-046, workflow yüzeyi): girdi `rule.expression` nesne literalidir
|
|
685
|
+
* (`{ balance: 'kredi', amount: 10, idempotencyKey: … }`), `balanceOperation` işlemi söyler. Statik `balance` (spend/grant/read) tüketilen
|
|
686
|
+
* ürünün bakiye adı, statik `entitlement` (entitled) bir ürünün hakkı olmalı — olmayan ad sunucuda hata dalına düşer.
|
|
687
|
+
* `spend`/`grant` `idempotencyKey` vermiyorsa her adım çalışması yeni satır yazar (b-10, tekrar güvenliği varsayılan değildir): uyarı.
|
|
688
|
+
*/
|
|
689
|
+
export function workflowBalanceFindings(sites, products) {
|
|
690
|
+
const balances = catalogBalances(products);
|
|
691
|
+
const entitlements = catalogEntitlements(products);
|
|
692
|
+
const literal = (expression, key) => new RegExp(`(?:^|[{,\\s])${key}\\s*:\\s*(['"\`])([^'"\`]*)\\1`).exec(expression)?.[2];
|
|
693
|
+
return sites.flatMap(({ label, rule }) => {
|
|
694
|
+
const operation = String(rule.balanceOperation ?? '');
|
|
695
|
+
const expression = typeof rule.expression === 'string' ? rule.expression : '';
|
|
696
|
+
const findings = [];
|
|
697
|
+
const guide = 'ngapps://guides/workflow';
|
|
698
|
+
if (operation === 'entitled') {
|
|
699
|
+
const entitlement = literal(expression, 'entitlement');
|
|
700
|
+
if (entitlement && !entitlements.includes(entitlement)) {
|
|
701
|
+
findings.push({ ruleId: 'FLOW-046', severity: 'block', message: `'${label}' balance adımı (entitled) '${entitlement}' hakkını soruyor ama uygulamanın hiçbir ürünü bu hakkı vermiyor — cevap hep false olur. ${entitlements.length > 0 ? `Katalogdaki haklar: ${entitlements.join(', ')}.` : 'Uygulamada hak veren ürün yok.'} Hak adı mobile_product_list'teki ürünün entitlement alanıdır.`, guide });
|
|
702
|
+
}
|
|
703
|
+
return findings;
|
|
704
|
+
}
|
|
705
|
+
const balance = literal(expression, 'balance');
|
|
706
|
+
if (balance && !balances.includes(balance)) {
|
|
707
|
+
findings.push({ ruleId: 'FLOW-046', severity: 'block', message: `'${label}' balance adımı (${operation || '?'}) '${balance}' bakiyesini kullanıyor ama uygulamanın tüketilen ürünlerinde yok — sunucu geçersiz girdi ile hata dalına düşer. ${balances.length > 0 ? `Katalogdaki bakiyeler: ${balances.join(', ')}.` : 'Uygulamada tüketilen ürün yok.'} Önce mobile_product_upsert { kind: 'consumable', balance } ile ürünü ekleyin ya da adı katalogdakiyle değiştirin.`, guide });
|
|
708
|
+
}
|
|
709
|
+
if ((operation === 'spend' || operation === 'grant') && !/(?:^|[{,\s])idempotencyKey\s*:/.test(expression)) {
|
|
710
|
+
findings.push({ ruleId: 'FLOW-046', severity: 'warn', message: `'${label}' balance adımı (${operation}) idempotencyKey vermiyor: anahtar yoksa her adım çalışması YENİ satır yazar — yeniden koşabilen akış (yeniden deneme, döngü, aynı olayın iki kez gelmesi) bakiyeyi iki kez ${operation === 'spend' ? 'düşer' : 'artırır'}. Olayı tanımlayan bir değer verin: idempotencyKey: input.davetId.`, guide });
|
|
711
|
+
}
|
|
712
|
+
return findings;
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* MOB-036 (K9): tüketilen ürünü olan ama kullanıcı girişi (access.session.create uçlu microservice) olmayan uygulamada bakiye
|
|
717
|
+
* yalnız kuruluma yazılır; tüketilen ürün mağazada "geri yüklenemez" — telefon değişince ya da uygulama silinip kurulunca bakiye
|
|
718
|
+
* gider. `microservices` okunamadıysa `undefined`: bilmediğimizi uyarı diye söylemeyiz.
|
|
719
|
+
*/
|
|
720
|
+
export function balanceWithoutSignInWarnings(products, microservices) {
|
|
721
|
+
if (!microservices || catalogBalances(products).length === 0)
|
|
722
|
+
return [];
|
|
723
|
+
if (microservices.some(service => service.systemOperation === 'access.session.create'))
|
|
724
|
+
return [];
|
|
725
|
+
return [`MOB-036: Bu uygulamada tüketilen ürün (${catalogBalances(products).join(', ')}) var ama kullanıcı girişi yok — bakiye yalnız bu telefonun kurulumuna yazılır. Tüketilen ürün mağazada "Geri yükle" ile geri gelmez: kullanıcı telefon değiştirir ya da uygulamayı silip kurarsa satın aldığı kredi gider (K9). Girişli uygulamada bakiye hesaba taşınır: access.session.create uçlu bir giriş (platform_guide('access')) kurun ya da kullanıcıya bakiyenin telefona bağlı olduğunu açıkça söyleyin; satış yapmadan önce karar verin.`];
|
|
726
|
+
}
|
|
@@ -116,7 +116,7 @@ export const mobileProfile = Object.freeze({
|
|
|
116
116
|
},
|
|
117
117
|
style: { freeStyle: false, hoverStyle: false, resolver: 'native' },
|
|
118
118
|
steps: {
|
|
119
|
-
registered: new Set([...ENGINE_STEP_TYPES, 'alert', 'confirm', 'redirect', 'navigate', 'storage', 'setcookie', 'download', 'share', 'haptic', 'location', 'notification-permission', 'notification-schedule', 'notification-cancel', 'permission', 'open-settings', 'ad-show', 'back', 'biometric-auth', 'scan-code']),
|
|
119
|
+
registered: new Set([...ENGINE_STEP_TYPES, 'alert', 'confirm', 'redirect', 'navigate', 'storage', 'setcookie', 'download', 'share', 'haptic', 'location', 'notification-permission', 'notification-schedule', 'notification-cancel', 'permission', 'open-settings', 'ad-show', 'back', 'biometric-auth', 'scan-code', 'purchase', 'restore-purchases', 'balance-spend']),
|
|
120
120
|
aliases: STEP_ALIASES,
|
|
121
121
|
infoOnly: new Set(['download', 'setcookie'])
|
|
122
122
|
},
|
|
@@ -144,6 +144,24 @@ export const MOBILE_RULES = {
|
|
|
144
144
|
severity: 'warn',
|
|
145
145
|
sourceRef: 'docs/adr/0023-mobil-cevrimdisi-yerel-kopya-ve-uygulama-paketi.md K13, K8; zeyrek-frontend/packages/ng-apps-ui-native/src/shell/mobile-offline.ts (okuyucu; ayna mobileOffline.ts); ng-apps-flow tasks/microservice/microservice.ts (error.network yalnız veri önbelleği açıkken)',
|
|
146
146
|
summary: "properties.mobile.offline okunamıyor (nesne değil, boolean olmayan screens/data/banner, 1 | 7 | 30 dışı maxAgeDays, tanınmayan anahtar) — cihaz varsayılana düşer; akış ağ hatasını error.status 503 ile ayırıyor (bilgi: error.network)"
|
|
147
|
+
},
|
|
148
|
+
'MOB-034': {
|
|
149
|
+
id: 'MOB-034',
|
|
150
|
+
severity: 'warn',
|
|
151
|
+
sourceRef: 'docs/adr/0027-uygulama-ici-satin-alma.md madde 6 (telefon değişince Geri yükle); App Store Review Guidelines 3.1.1 (geri yüklenebilir satın almada geri yükleme yolu); @ng-apps/flow restore-purchases',
|
|
152
|
+
summary: 'Satın alma adımı olan ekranda hiçbir akışta restore-purchases yok — mağaza incelemesi geri yükleme yolunu satın alma ekranında arar'
|
|
153
|
+
},
|
|
154
|
+
'MOB-036': {
|
|
155
|
+
id: 'MOB-036',
|
|
156
|
+
severity: 'warn',
|
|
157
|
+
sourceRef: 'docs/adr/0027-uygulama-ici-satin-alma.md bakiye bölümü (K9: tüketilen ürün mağazada geri yüklenemez, bakiye telefon değişince yalnız hesapla taşınır); plans/mobil-uygulama-ici-satin-alma README K9; backend 04-bakiye-defteri.md (restore tüketileni geri yüklemez)',
|
|
158
|
+
summary: 'Tüketilen ürünü olan uygulamada kullanıcı girişi (access.session.create) yok — bakiye yalnız kuruluma yazılır, telefon değişince ya da uygulama silinip kurulunca kaybolur'
|
|
159
|
+
},
|
|
160
|
+
'MOB-042': {
|
|
161
|
+
id: 'MOB-042',
|
|
162
|
+
severity: 'warn',
|
|
163
|
+
sourceRef: 'App Store Review Guidelines 3.1.2 (abonelik bilgilendirmesi: süre, yenileme, iptal; kullanım koşulları ve gizlilik bağlantısı); Play abonelik politikası; Studio teklif ekranı şablonu (features/purchases/paywall-template.functions.ts: yenileme metni + gizlilik bağlantısı)',
|
|
164
|
+
summary: 'Abonelik satan sayfada otomatik yenileme metni ya da koşullar/gizlilik bağlantısı yok'
|
|
147
165
|
}
|
|
148
166
|
};
|
|
149
167
|
/**
|
|
@@ -260,3 +278,64 @@ export function mobileComponentNotes(slug) {
|
|
|
260
278
|
...(wave === undefined ? [] : [`Mobil bileşen dalgası: ${wave} (plans/mobil-uygulama-expo §11).`])
|
|
261
279
|
];
|
|
262
280
|
}
|
|
281
|
+
const recordsOf = (value) => (Array.isArray(value) ? value : []).filter((entry) => typeof entry === 'object' && entry !== null && !Array.isArray(entry));
|
|
282
|
+
const flowSteps = (flow) => [...recordsOf(flow.steps), ...recordsOf(flow.errorSteps)];
|
|
283
|
+
/**
|
|
284
|
+
* MOB-034 — yazılan akışta `purchase` var ve bu ekranın (sayfa, layout ya da widget) hiçbir akışında `restore-purchases`
|
|
285
|
+
* yok. Uygulama genelinde arayan uç yok (bir uygulamada yüzlerce sayfa var); denetim ekran düzeyindedir ve Apple
|
|
286
|
+
* incelemesinin baktığı yer de satın alma ekranıdır. Geri yükleme bilinçli olarak başka bir ekrandaysa (Ayarlar) uyarı yok
|
|
287
|
+
* sayılır — mesaj bunu söyler. `otherFlows` ekranın kayıtlı öteki akışlarıdır (yazılanın eski hâli hariç).
|
|
288
|
+
* Ekran yalnız tüketilen ürün satıyorsa uyarı susar: tüketilen ürün mağazada geri yüklenmez (K9). `products` okunamadıysa
|
|
289
|
+
* ürünün türünü bilmiyoruz, uyarı kalır.
|
|
290
|
+
*/
|
|
291
|
+
export function restorePathWarnings(written, otherFlows, products) {
|
|
292
|
+
const purchases = written.filter(step => step.type === 'purchase');
|
|
293
|
+
if (purchases.length === 0)
|
|
294
|
+
return [];
|
|
295
|
+
const consumables = (products ?? []).filter(product => product.kind === 'consumable').map(product => String(product.key));
|
|
296
|
+
const sold = [...purchases, ...otherFlows.flatMap(flowSteps).filter(step => step.type === 'purchase')];
|
|
297
|
+
const onlyConsumables = consumables.length > 0 && sold.every((step) => {
|
|
298
|
+
const value = typeof step.args?.value === 'string' ? String(step.args.value).trim() : '';
|
|
299
|
+
return value.includes('{{') ? consumables.length === products?.length : consumables.includes(value);
|
|
300
|
+
});
|
|
301
|
+
if (onlyConsumables)
|
|
302
|
+
return [];
|
|
303
|
+
if ([...written, ...otherFlows.flatMap(flowSteps)].some(step => step.type === 'restore-purchases'))
|
|
304
|
+
return [];
|
|
305
|
+
return [`${MOBILE_RULES['MOB-034'].id}: '${String(purchases[0]?.stepId ?? purchases[0]?.name ?? '?')}' satın alma adımının ekranında hiçbir akışta restore-purchases yok — telefon değiştiren ya da uygulamayı yeniden kuran kullanıcı hakkını yalnız Geri yükle ile alır ve App Store incelemesi satın alma ekranında bu yolu arar. Ekrana bir 'Satın almaları geri yükle' düğmesi ve ona restore-purchases adımlı bir akış ekleyin (always: Entitlements güncellenir; error dalında Steps.<ad>.error.status). Uyarı yalnız satın alma akışı yazılırken denetlenir: geri yükleme akışını önce yazın — sonra yazılırsa bu uyarı eskimiş kalır, yok sayın. Geri yükleme bilinçli olarak başka bir ekrandaysa (Ayarlar) da yok sayın.`];
|
|
306
|
+
}
|
|
307
|
+
/** Otomatik yenileme cümlesi, dört platform dilinde (tr, en, ru, ar) */
|
|
308
|
+
const RENEWAL_TEXT = /yenile[nm]|renew|продл|تجدي|يتجدد|تتجدد/i;
|
|
309
|
+
/** Çeviri anahtarıyla yazılmış metin (`{{ t('…') }}`, prop türü translation): gateway metni okuyamaz */
|
|
310
|
+
const TRANSLATED_TEXT = /\bt\(\s*\\?['"]|"type"\s*:\s*"translation"/;
|
|
311
|
+
/** Dış bağlantı: düğüm prop'u `href` dolu, metindeki bağlantı (markdown, html) */
|
|
312
|
+
const LINK_IN_PAGE = /"href"\s*:\s*(?:"[^"]+"|\{[^{}]*"(?:data|ref)"\s*:\s*"[^"]+")|\]\(https?:|href=\\?["']?https?:/;
|
|
313
|
+
/**
|
|
314
|
+
* MOB-042 — abonelik satan sayfa (statik anahtarı katalogda abonelik olan ya da şablonlu değerle satan ve katalogda
|
|
315
|
+
* abonelik bulunan `purchase` adımı) yenileme koşulunu ve koşullar/gizlilik bağlantısını göstermiyor. Kural metinseldir:
|
|
316
|
+
* sayfa ağacının JSON'unda yenileme sözcüğü ve dolu bir `href` (ya da metin içi bağlantı) aranır; ekranın akışlarından
|
|
317
|
+
* dışarı açan `redirect` (`https://…`) de bağlantı sayılır. Çeviri anahtarıyla yazılmış sayfada metin okunamaz, yenileme
|
|
318
|
+
* denetimi susar (bilmediğimizi uyarı diye söylemeyiz).
|
|
319
|
+
*/
|
|
320
|
+
export function subscriptionDisclosureWarnings(written, products, pageNodes, hostFlows) {
|
|
321
|
+
const purchases = written.filter(step => step.type === 'purchase');
|
|
322
|
+
const subscriptions = products.filter(product => product.kind === 'subscription').map(product => String(product.key));
|
|
323
|
+
const sells = purchases.some((step) => {
|
|
324
|
+
const value = typeof step.args?.value === 'string' ? String(step.args.value).trim() : '';
|
|
325
|
+
return value.includes('{{') ? subscriptions.length > 0 : subscriptions.includes(value);
|
|
326
|
+
});
|
|
327
|
+
if (!sells)
|
|
328
|
+
return [];
|
|
329
|
+
// Türkçe büyük harf (`YENİLENİR`) `i` bayrağıyla eşleşmez: metin önce Türkçe küçük harfe çevrilir.
|
|
330
|
+
const page = JSON.stringify(pageNodes ?? null);
|
|
331
|
+
const lowered = page.toLocaleLowerCase('tr');
|
|
332
|
+
const opensOutside = [...written, ...hostFlows.flatMap(flowSteps)]
|
|
333
|
+
.some(step => step.type === 'redirect' && /^https?:\/\//.test(String(step.args?.path ?? '').trim().replace(/^['"`]/, '')));
|
|
334
|
+
const missing = [
|
|
335
|
+
...(RENEWAL_TEXT.test(lowered) || RENEWAL_TEXT.test(page) || TRANSLATED_TEXT.test(page) ? [] : ['otomatik yenileme metni (dönem sonunda yenilenir, mağaza hesabından iptal edilir)']),
|
|
336
|
+
...(LINK_IN_PAGE.test(page) || opensOutside ? [] : ['kullanım koşulları / gizlilik bağlantısı (href taşıyan düğme ya da metin)'])
|
|
337
|
+
];
|
|
338
|
+
if (missing.length === 0)
|
|
339
|
+
return [];
|
|
340
|
+
return [`${MOBILE_RULES['MOB-042'].id}: bu sayfa abonelik satıyor ama ekranda ${missing.join(' ve ')} yok — App Store (3.1.2) ve Play abonelik politikası satın alma ekranında süreyi, fiyatı, otomatik yenilemeyi, iptal yolunu ve koşullar/gizlilik bağlantısını ister; eksik ekran incelemeden döner. Fiyat ve süre Products.<anahtar>.price / .period ile yazılır; gizlilik adresi mobile_store_get urls.privacyPolicy. Kalıp: platform_guide('mobile-steps') §Satın alma tarifi. Metin çeviri anahtarıyla yazıldıysa gateway okuyamaz — çeviride varsa yok sayın.`];
|
|
341
|
+
}
|