kasy-cli 1.32.0 → 1.34.0

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.
Files changed (60) hide show
  1. package/bin/kasy.js +42 -0
  2. package/lib/commands/apple-web.js +222 -0
  3. package/lib/commands/configure.js +3 -91
  4. package/lib/commands/doctor.js +20 -0
  5. package/lib/commands/facebook.js +189 -0
  6. package/lib/commands/new.js +50 -2
  7. package/lib/scaffold/CHANGELOG.json +18 -0
  8. package/lib/scaffold/backends/firebase/setup-from-scratch.js +164 -0
  9. package/lib/scaffold/backends/supabase/deploy.js +92 -0
  10. package/lib/scaffold/backends/supabase/patch/lib/features/authentication/api/authentication_api.dart +10 -0
  11. package/lib/scaffold/shared/generator-utils.js +18 -6
  12. package/lib/utils/apple-web.js +147 -0
  13. package/lib/utils/facebook.js +162 -0
  14. package/lib/utils/i18n/messages-en.js +62 -0
  15. package/lib/utils/i18n/messages-es.js +62 -0
  16. package/lib/utils/i18n/messages-pt.js +62 -0
  17. package/package.json +2 -2
  18. package/templates/firebase/AGENTS.md +87 -0
  19. package/templates/firebase/CLAUDE.md +16 -0
  20. package/templates/firebase/DESIGN_SYSTEM.md +234 -0
  21. package/templates/firebase/docs/auth-setup.en.md +2 -2
  22. package/templates/firebase/docs/auth-setup.es.md +2 -2
  23. package/templates/firebase/docs/auth-setup.pt.md +2 -2
  24. package/templates/firebase/lib/components/components.dart +1 -0
  25. package/templates/firebase/lib/components/kasy_app_bar.dart +4 -1
  26. package/templates/firebase/lib/components/kasy_screen.dart +114 -0
  27. package/templates/firebase/lib/components/kasy_toast.dart +39 -70
  28. package/templates/firebase/lib/core/chrome/chrome_visibility.dart +22 -0
  29. package/templates/firebase/lib/core/config/features.dart +5 -0
  30. package/templates/firebase/lib/core/rating/widgets/review_popup.dart +46 -124
  31. package/templates/firebase/lib/core/widgets/update_bottom_sheet.dart +29 -126
  32. package/templates/firebase/lib/features/ai_chat/ai_chat_page.dart +11 -7
  33. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_composer.dart +21 -0
  34. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_list.dart +1 -1
  35. package/templates/firebase/lib/features/authentication/api/authentication_api.dart +61 -0
  36. package/templates/firebase/lib/features/authentication/ui/signin_page.dart +21 -15
  37. package/templates/firebase/lib/features/authentication/ui/signup_page.dart +20 -14
  38. package/templates/firebase/lib/features/feedbacks/ui/widgets/feature_card.dart +1 -2
  39. package/templates/firebase/lib/features/home/home_components_page.dart +7 -1
  40. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +32 -0
  41. package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +165 -209
  42. package/templates/firebase/lib/features/notifications/ui/components/notification_settings_sheet.dart +2 -2
  43. package/templates/firebase/lib/features/notifications/ui/components/notification_tile.dart +21 -8
  44. package/templates/firebase/lib/features/notifications/ui/components/push_notification_switcher.dart +2 -2
  45. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +3 -6
  46. package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +77 -126
  47. package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_illustration_scaffold.dart +3 -4
  48. package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +2 -4
  49. package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +2 -1
  50. package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +17 -8
  51. package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +2 -2
  52. package/templates/firebase/lib/i18n/en.i18n.json +5 -4
  53. package/templates/firebase/lib/i18n/es.i18n.json +5 -4
  54. package/templates/firebase/lib/i18n/pt.i18n.json +5 -4
  55. package/templates/firebase/lib/router.dart +2 -0
  56. package/templates/firebase/pubspec.yaml +1 -1
  57. package/templates/firebase/tool/design_check.dart +152 -0
  58. package/templates/firebase/assets/images/review.png +0 -0
  59. package/templates/firebase/assets/images/update.png +0 -0
  60. package/templates/firebase/lib/features/notifications/ui/components/notifications_header.dart +0 -32
@@ -1,4 +1,22 @@
1
1
  {
2
+ "1.34.0": {
3
+ "modules": {
4
+ "core": {
5
+ "pt": "Login com Facebook automatizado + Facebook na web (Firebase): novo comando `kasy facebook` guia os passos na Meta (abre o link), grava App ID + Client Token no iOS/Android e habilita o provedor no Firebase (Identity Toolkit) ou Supabase. No Firebase, o Facebook passa a funcionar na WEB (signInWithPopup) e o botão só aparece quando configurado (flag withFacebookWebSignin). Apple/Facebook na web no Supabase ficam como native-only (roadmap). Corrigido: o `kasy apple-web` agora liga o Apple web só no Firebase (não cria mais botão morto no Supabase). Credenciais ficam salvas e o `kasy new` aplica sozinho.",
6
+ "en": "Facebook Login automated + Facebook on web (Firebase): new `kasy facebook` command guides the Meta steps (opens the link), writes App ID + Client Token into iOS/Android and enables the provider on Firebase (Identity Toolkit) or Supabase. On Firebase, Facebook now works on the WEB (signInWithPopup) and the button only shows once configured (withFacebookWebSignin flag). Apple/Facebook on web for Supabase stay native-only (roadmap). Fixed: `kasy apple-web` now enables Apple web on Firebase only (no more dead button on Supabase). Credentials are cached and `kasy new` applies them automatically.",
7
+ "es": "Inicio de sesión con Facebook automatizado + Facebook en la web (Firebase): nuevo comando `kasy facebook` guía los pasos en Meta (abre el enlace), escribe App ID + Client Token en iOS/Android y habilita el proveedor en Firebase (Identity Toolkit) o Supabase. En Firebase, Facebook ahora funciona en la WEB (signInWithPopup) y el botón solo aparece cuando está configurado (flag withFacebookWebSignin). Apple/Facebook en la web para Supabase quedan como native-only (roadmap). Corregido: `kasy apple-web` ahora activa Apple web solo en Firebase (sin botón muerto en Supabase). Las credenciales se guardan y `kasy new` las aplica automáticamente."
8
+ }
9
+ }
10
+ },
11
+ "1.33.0": {
12
+ "modules": {
13
+ "core": {
14
+ "pt": "Login com Apple na WEB agora é automatizável (backend Firebase): novo comando `kasy apple-web` grava o codeFlowConfig (Service ID + Team ID + Key ID + .p8) no provedor Apple do Firebase, que re-assina o secret sozinho (não expira), reaproveitando suas credenciais salvas. Projetos Firebase novos já nascem com Apple web se você já configurou antes. O botão Apple na web só aparece quando funciona de verdade (sem botão morto); `kasy doctor` mostra se falta configurar. No Supabase, Apple na web é roadmap.",
15
+ "en": "Apple Sign-In on the WEB is now automatable (Firebase backend): new `kasy apple-web` command writes the codeFlowConfig (Service ID + Team ID + Key ID + .p8) into the Firebase Apple provider, which re-signs the secret itself (never expires), reusing your saved credentials. New Firebase projects ship web Apple ready if you configured it before. The web Apple button only shows when it actually works (no dead button); `kasy doctor` reports if it's pending. On Supabase, Apple on web is roadmap.",
16
+ "es": "El inicio de sesión con Apple en la WEB ahora es automatizable (backend Firebase): nuevo comando `kasy apple-web` escribe el codeFlowConfig (Service ID + Team ID + Key ID + .p8) en el proveedor Apple de Firebase, que vuelve a firmar el secret solo (no expira), reutilizando tus credenciales guardadas. Los proyectos Firebase nuevos vienen con Apple web listo si ya lo configuraste antes. El botón de Apple en la web solo aparece cuando funciona de verdad (sin botón muerto); `kasy doctor` indica si falta configurar. En Supabase, Apple en la web es roadmap."
17
+ }
18
+ }
19
+ },
2
20
  "1.32.0": {
3
21
  "modules": {
4
22
  "core": {
@@ -764,6 +764,168 @@ async function authorizeLocalhostForProject(projectId) {
764
764
  return ensureLocalhostAuthorizedDomains(projectId, token);
765
765
  }
766
766
 
767
+ /**
768
+ * Configure Apple Sign-In on Firebase for the WEB (and the OAuth code flow) by
769
+ * writing the Apple provider's codeFlowConfig (Service ID + Team ID + Key ID +
770
+ * `.p8`) via the Identity Toolkit Admin v2 API. Once stored, Firebase re-signs the
771
+ * short-lived client secret itself, so it never expires.
772
+ *
773
+ * Existing bundleIds (used by the native iOS flow) are preserved and the project's
774
+ * own bundleId is merged in, so configuring web never breaks native.
775
+ *
776
+ * @param {object} opts
777
+ * @param {string} opts.projectId
778
+ * @param {string} opts.serviceId - Apple Service ID (becomes the provider clientId)
779
+ * @param {string} opts.teamId
780
+ * @param {string} opts.keyId
781
+ * @param {string} opts.privateKey - PEM contents of the .p8
782
+ * @param {string} [opts.bundleId] - app bundle id to keep allowed for native sign-in
783
+ * @returns {{ ok: boolean, error?: string }}
784
+ */
785
+ async function configureFirebaseAppleWeb({ projectId, serviceId, teamId, keyId, privateKey, bundleId }) {
786
+ if (!serviceId || !teamId || !keyId || !privateKey) {
787
+ return { ok: false, error: 'serviceId, teamId, keyId and privateKey are required' };
788
+ }
789
+ let token;
790
+ try {
791
+ token = await getAccessToken();
792
+ } catch (_) {
793
+ return { ok: false, error: 'Could not get access token' };
794
+ }
795
+
796
+ const headers = {
797
+ Authorization: `Bearer ${token}`,
798
+ 'Content-Type': 'application/json',
799
+ 'X-Goog-User-Project': projectId,
800
+ };
801
+ const base = `https://identitytoolkit.googleapis.com/admin/v2/projects/${projectId}/defaultSupportedIdpConfigs`;
802
+
803
+ // Read the current Apple config (if any) so we preserve the native bundleIds.
804
+ let existing = null;
805
+ try {
806
+ const getRes = await fetch(`${base}/apple.com`, { headers });
807
+ if (getRes.ok) existing = await getRes.json();
808
+ } catch (_) {
809
+ // No existing config (or transient) — we'll create it below.
810
+ }
811
+
812
+ const bundleIds = Array.from(
813
+ new Set([...(existing?.appleSignInConfig?.bundleIds || []), bundleId].filter(Boolean)),
814
+ );
815
+
816
+ const body = {
817
+ name: `projects/${projectId}/defaultSupportedIdpConfigs/apple.com`,
818
+ enabled: true,
819
+ clientId: serviceId,
820
+ appleSignInConfig: {
821
+ codeFlowConfig: { teamId, keyId, privateKey },
822
+ ...(bundleIds.length ? { bundleIds } : {}),
823
+ },
824
+ };
825
+
826
+ try {
827
+ if (existing) {
828
+ const res = await fetch(`${base}/apple.com?updateMask=enabled,clientId,appleSignInConfig`, {
829
+ method: 'PATCH',
830
+ headers,
831
+ body: JSON.stringify(body),
832
+ });
833
+ if (!res.ok) {
834
+ const text = await res.text();
835
+ return { ok: false, error: `PATCH failed (${res.status}): ${text.slice(0, 200)}` };
836
+ }
837
+ } else {
838
+ const res = await fetch(`${base}?idpId=apple.com`, {
839
+ method: 'POST',
840
+ headers,
841
+ body: JSON.stringify(body),
842
+ });
843
+ if (!res.ok) {
844
+ const text = await res.text();
845
+ // Raced with another writer — fall back to PATCH.
846
+ if (res.status === 409 || text.includes('ALREADY_EXISTS')) {
847
+ const patchRes = await fetch(`${base}/apple.com?updateMask=enabled,clientId,appleSignInConfig`, {
848
+ method: 'PATCH',
849
+ headers,
850
+ body: JSON.stringify(body),
851
+ });
852
+ if (!patchRes.ok) {
853
+ const t2 = await patchRes.text();
854
+ return { ok: false, error: `PATCH failed (${patchRes.status}): ${t2.slice(0, 200)}` };
855
+ }
856
+ } else {
857
+ return { ok: false, error: `POST failed (${res.status}): ${text.slice(0, 200)}` };
858
+ }
859
+ }
860
+ }
861
+ } catch (err) {
862
+ return { ok: false, error: `Network error: ${err.message}` };
863
+ }
864
+
865
+ return { ok: true };
866
+ }
867
+
868
+ /**
869
+ * Enable the Facebook provider on Firebase via the Identity Toolkit Admin v2 API.
870
+ * Needs the Meta App ID (clientId) + App Secret (clientSecret). The native App ID /
871
+ * Client Token live in Info.plist / strings.xml and are written separately.
872
+ *
873
+ * @param {object} opts - { projectId, appId, appSecret }
874
+ * @returns {{ ok: boolean, error?: string }}
875
+ */
876
+ async function configureFirebaseFacebook({ projectId, appId, appSecret }) {
877
+ if (!appId || !appSecret) {
878
+ return { ok: false, error: 'appId and appSecret are required' };
879
+ }
880
+ let token;
881
+ try {
882
+ token = await getAccessToken();
883
+ } catch (_) {
884
+ return { ok: false, error: 'Could not get access token' };
885
+ }
886
+
887
+ const headers = {
888
+ Authorization: `Bearer ${token}`,
889
+ 'Content-Type': 'application/json',
890
+ 'X-Goog-User-Project': projectId,
891
+ };
892
+ const base = `https://identitytoolkit.googleapis.com/admin/v2/projects/${projectId}/defaultSupportedIdpConfigs`;
893
+ const body = {
894
+ name: `projects/${projectId}/defaultSupportedIdpConfigs/facebook.com`,
895
+ enabled: true,
896
+ clientId: appId,
897
+ clientSecret: appSecret,
898
+ };
899
+
900
+ try {
901
+ let res = await fetch(`${base}?idpId=facebook.com`, {
902
+ method: 'POST',
903
+ headers,
904
+ body: JSON.stringify(body),
905
+ });
906
+ if (!res.ok) {
907
+ const text = await res.text();
908
+ if (res.status === 409 || text.includes('ALREADY_EXISTS')) {
909
+ res = await fetch(`${base}/facebook.com?updateMask=enabled,clientId,clientSecret`, {
910
+ method: 'PATCH',
911
+ headers,
912
+ body: JSON.stringify(body),
913
+ });
914
+ if (!res.ok) {
915
+ const t2 = await res.text();
916
+ return { ok: false, error: `PATCH failed (${res.status}): ${t2.slice(0, 200)}` };
917
+ }
918
+ } else {
919
+ return { ok: false, error: `POST failed (${res.status}): ${text.slice(0, 200)}` };
920
+ }
921
+ }
922
+ } catch (err) {
923
+ return { ok: false, error: `Network error: ${err.message}` };
924
+ }
925
+
926
+ return { ok: true };
927
+ }
928
+
767
929
  /**
768
930
  * Initialize Firebase Auth (Identity Platform) for a project. Brand-new projects
769
931
  * have no auth config, so any Admin v2 operation (PATCH /config, or the Firebase
@@ -1299,6 +1461,8 @@ module.exports = {
1299
1461
  ensureFirebaseAuthInitialized,
1300
1462
  ensureLocalhostAuthorizedDomains,
1301
1463
  authorizeLocalhostForProject,
1464
+ configureFirebaseAppleWeb,
1465
+ configureFirebaseFacebook,
1302
1466
  listBillingAccounts,
1303
1467
  listGcpOrganizations,
1304
1468
  checkGcloudAuth,
@@ -15,6 +15,7 @@ const path = require('node:path');
15
15
  const os = require('node:os');
16
16
  const fs = require('fs-extra');
17
17
  const { augmentedEnv } = require('../../../utils/env-tools');
18
+ const { signAppleClientSecret } = require('../../../utils/apple-web');
18
19
 
19
20
  const execAsync = promisify(exec);
20
21
 
@@ -407,6 +408,95 @@ async function enableAppleSignIn(projectRef, bundleId) {
407
408
  return { ok: false, error: result.data.message || JSON.stringify(result.data) };
408
409
  }
409
410
 
411
+ /**
412
+ * GET the current Supabase auth config (read-only). Used to merge values we must
413
+ * not clobber (e.g. the native bundle id already in external_apple_client_id).
414
+ */
415
+ async function getSupabaseAuthConfig(projectRef, token) {
416
+ try {
417
+ const res = await fetch(`https://api.supabase.com/v1/projects/${projectRef}/config/auth`, {
418
+ headers: { Authorization: `Bearer ${token}` },
419
+ });
420
+ if (!res.ok) return null;
421
+ return await res.json();
422
+ } catch {
423
+ return null;
424
+ }
425
+ }
426
+
427
+ /**
428
+ * Enable Apple Sign-In on the WEB for Supabase.
429
+ *
430
+ * Unlike Firebase (which stores the .p8 and re-signs), Supabase stores a static,
431
+ * pre-signed client secret JWT that expires every ~6 months. We sign it here with
432
+ * the developer's `.p8` and write it as external_apple_secret. The Service ID is
433
+ * added to external_apple_client_id (the audience list) alongside the native bundle
434
+ * id, so both native iOS and the web OAuth flow validate.
435
+ *
436
+ * @param {string} projectRef
437
+ * @param {object} opts - { serviceId, teamId, keyId, privateKey, bundleId? }
438
+ * @returns {{ ok: boolean, error?: string, expiresAt?: number }}
439
+ */
440
+ async function enableAppleWebSignIn(projectRef, { serviceId, teamId, keyId, privateKey, bundleId } = {}) {
441
+ if (!serviceId || !teamId || !keyId || !privateKey) {
442
+ return { ok: false, error: 'serviceId, teamId, keyId and privateKey are required' };
443
+ }
444
+ const token = await getSupabaseAccessToken();
445
+ if (!token) return { ok: false, error: 'Could not retrieve Supabase access token' };
446
+
447
+ let secret;
448
+ let expiresAt;
449
+ try {
450
+ ({ token: secret, expiresAt } = signAppleClientSecret({ serviceId, teamId, keyId, privateKey }));
451
+ } catch (err) {
452
+ return { ok: false, error: err.message };
453
+ }
454
+
455
+ // Merge the Service ID into the existing audience list without dropping the
456
+ // native bundle id the CLI set at creation.
457
+ const current = await getSupabaseAuthConfig(projectRef, token);
458
+ const existingIds = String(current?.external_apple_client_id || '')
459
+ .split(',')
460
+ .map((s) => s.trim())
461
+ .filter(Boolean);
462
+ const clientIds = Array.from(new Set([...existingIds, bundleId, serviceId].filter(Boolean))).join(',');
463
+
464
+ const result = await patchAuthConfig(projectRef, token, {
465
+ external_apple_enabled: true,
466
+ external_apple_client_id: clientIds,
467
+ external_apple_secret: secret,
468
+ });
469
+ if (!result.ok) return { ok: false, error: result.error };
470
+ if (result.data.external_apple_enabled === true) return { ok: true, expiresAt };
471
+ return { ok: false, error: result.data.message || JSON.stringify(result.data) };
472
+ }
473
+
474
+ /**
475
+ * Enable the Facebook provider on Supabase via the Management API.
476
+ * Needs the Meta App ID (client_id) + App Secret (secret). The native App ID /
477
+ * Client Token live in Info.plist / strings.xml and are written separately.
478
+ *
479
+ * @param {string} projectRef
480
+ * @param {object} opts - { appId, appSecret }
481
+ * @returns {{ ok: boolean, error?: string }}
482
+ */
483
+ async function enableFacebookSignIn(projectRef, { appId, appSecret } = {}) {
484
+ if (!appId || !appSecret) {
485
+ return { ok: false, error: 'appId and appSecret are required' };
486
+ }
487
+ const token = await getSupabaseAccessToken();
488
+ if (!token) return { ok: false, error: 'Could not retrieve Supabase access token' };
489
+
490
+ const result = await patchAuthConfig(projectRef, token, {
491
+ external_facebook_enabled: true,
492
+ external_facebook_client_id: appId,
493
+ external_facebook_secret: appSecret,
494
+ });
495
+ if (!result.ok) return { ok: false, error: result.error };
496
+ if (result.data.external_facebook_enabled === true) return { ok: true };
497
+ return { ok: false, error: result.data.message || JSON.stringify(result.data) };
498
+ }
499
+
410
500
  /**
411
501
  * Configure auth settings via Supabase Management API:
412
502
  * - Enable anonymous sign-in
@@ -717,6 +807,8 @@ module.exports = {
717
807
  enableAnonymousSignIn,
718
808
  enableGoogleSignIn,
719
809
  enableAppleSignIn,
810
+ enableAppleWebSignIn,
811
+ enableFacebookSignIn,
720
812
  checkLoggedIn,
721
813
  getOrgsList,
722
814
  getProjectsByOrg,
@@ -192,6 +192,11 @@ Note: wait a minute after enabling anonymous sign-in before trying again. It tak
192
192
 
193
193
  @override
194
194
  Future<Credentials> signinWithFacebook() async {
195
+ // Facebook on web for Supabase is not wired yet (roadmap); the button is hidden
196
+ // on web, so this is a defensive guard.
197
+ if (kIsWeb) {
198
+ throw ApiError(code: 501, message: 'Facebook sign-in on web is not supported on Supabase.');
199
+ }
195
200
  final loginResult = await FacebookAuth.instance.login(
196
201
  permissions: ['email', 'public_profile'],
197
202
  );
@@ -439,6 +444,11 @@ Note: wait a minute after enabling anonymous sign-in before trying again. It tak
439
444
 
440
445
  @override
441
446
  Future<Credentials> signupFromAnonymousWithFacebook() async {
447
+ // Facebook on web for Supabase is not wired yet (roadmap); the button is hidden
448
+ // on web, so this is a defensive guard.
449
+ if (kIsWeb) {
450
+ throw ApiError(code: 501, message: 'Facebook sign-in on web is not supported on Supabase.');
451
+ }
442
452
  final loginResult = await FacebookAuth.instance.login(
443
453
  permissions: ['email', 'public_profile'],
444
454
  );
@@ -340,6 +340,7 @@ async function writeRouter(projectDir, modules, packageName, defaultPaywall = 'b
340
340
  lines.push(`import 'package:flutter_riverpod/flutter_riverpod.dart';`);
341
341
  lines.push(`import 'package:go_router/go_router.dart';`);
342
342
  lines.push(`import 'package:${pkg}/core/bottom_menu/bottom_menu.dart';`);
343
+ lines.push(`import 'package:${pkg}/core/chrome/chrome_visibility.dart';`);
343
344
  if (withAnalytics) {
344
345
  lines.push(`import 'package:${pkg}/core/data/api/analytics_api.dart';`);
345
346
  }
@@ -417,6 +418,7 @@ async function writeRouter(projectDir, modules, packageName, defaultPaywall = 'b
417
418
  if (withAnalytics) {
418
419
  lines.push(` AnalyticsObserver(analyticsApi: MixpanelAnalyticsApi.instance()),`);
419
420
  }
421
+ lines.push(` KasyChromeVisibilityObserver(),`);
420
422
  lines.push(` ...?observers,`);
421
423
  lines.push(` ],`);
422
424
  lines.push(` routes: [`);
@@ -624,10 +626,15 @@ async function writeFeaturesConfig(projectDir, modules, answers = {}, language =
624
626
  const withStripe = modules.includes('stripe');
625
627
  const withLocalReminders = modules.includes('local_reminders');
626
628
  const withWeb = modules.includes('web');
627
- // Apple sign-in on web only works on Firebase (signInWithPopup). Supabase/API
628
- // throw on web (no Service ID + token exchange), so they must ship this false
629
- // and hide the Apple button on web. Native always shows it.
630
- const withAppleWebSignin = backend === 'firebase';
629
+ // Apple sign-in on web needs a Service ID + signed secret that don't exist until
630
+ // the developer configures it (`kasy apple-web`). Until then, showing the button
631
+ // means a dead button on web, so it ships false on every backend and the command
632
+ // flips it to true once web Apple actually works. Native always shows it.
633
+ const withAppleWebSignin = false;
634
+ // Facebook sign-in on web works on the Firebase backend (signInWithPopup) after
635
+ // `kasy facebook`; on Supabase the web flow isn't wired yet (roadmap). Ships false
636
+ // on every backend (the command flips it to true on Firebase). Native always shows.
637
+ const withFacebookWebSignin = false;
631
638
 
632
639
  const f = getStrings(language).features;
633
640
  const content = `${f.comment1}
@@ -644,9 +651,14 @@ const bool withStripePromoCodes = true;
644
651
  // When true, the Stripe Customer Portal lets subscribers switch plans (upgrade / downgrade).
645
652
  const bool withStripePlanSwitching = true;
646
653
  const bool withLocalReminders = ${withLocalReminders};
647
- // Apple sign-in on web: Firebase supports it (signInWithPopup); Supabase/API throw
648
- // on web, so they ship false. Native always shows the Apple button.
654
+ // Apple sign-in on web: ships false until configured with \`kasy apple-web\` (needs a
655
+ // paid Apple Service ID + signed secret). The command flips this to true once web
656
+ // Apple actually works, so the button never appears dead. Native always shows it.
649
657
  const bool withAppleWebSignin = ${withAppleWebSignin};
658
+ // Facebook sign-in on web: ships false until configured with \`kasy facebook\` on the
659
+ // Firebase backend (signInWithPopup). On Supabase the web flow is roadmap, so it stays
660
+ // false there. Native (iOS/Android) always shows the Facebook button.
661
+ const bool withFacebookWebSignin = ${withFacebookWebSignin};
650
662
  ${f.comment3}
651
663
  ${f.comment4}
652
664
  ${f.comment5}
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Apple "Sign in with Apple" WEB support helpers.
3
+ *
4
+ * The native iOS/macOS flow needs no secret — the CLI already enables it on both
5
+ * backends at project creation. The WEB (and Android, which we hide) flow uses
6
+ * Apple's OAuth, which requires a Service ID + a client secret that is a short-lived
7
+ * JWT signed with the developer's `.p8` private key.
8
+ *
9
+ * Apple offers NO API to create the Service ID or the `.p8` key — those stay manual
10
+ * on developer.apple.com (done once per Apple account). What we CAN automate is
11
+ * taking the four inputs the developer already created and pushing them to the
12
+ * backend:
13
+ * - Firebase: store Service ID + Team ID + Key ID + `.p8` in the Apple provider
14
+ * (Firebase re-signs the JWT itself, so it never expires).
15
+ * - Supabase: sign the JWT here and store it as external_apple_secret (Supabase
16
+ * cannot re-sign, so this expires every 6 months and must be regenerated).
17
+ *
18
+ * This module: (1) signs the Apple client-secret JWT with node:crypto (no extra
19
+ * dependency), and (2) caches the four inputs in ~/.kasy/apple-web.json so future
20
+ * projects (and the 6-month Supabase renewal) configure web Apple without re-asking.
21
+ */
22
+
23
+ const os = require('node:os');
24
+ const path = require('node:path');
25
+ const crypto = require('node:crypto');
26
+ const fs = require('fs-extra');
27
+
28
+ // Apple caps the client-secret JWT lifetime at 6 months. We sign for 180 days to
29
+ // stay safely under the limit. Firebase ignores this (it re-signs); Supabase stores
30
+ // the JWT verbatim, so this is the value behind its "expires every 6 months" notice.
31
+ const APPLE_SECRET_MAX_SECONDS = 180 * 24 * 60 * 60;
32
+
33
+ const CONFIG_DIR = path.join(os.homedir(), '.kasy');
34
+ const APPLE_WEB_CONFIG_PATH = path.join(CONFIG_DIR, 'apple-web.json');
35
+
36
+ /** base64url-encode a string or Buffer (no padding, URL-safe alphabet). */
37
+ function base64url(input) {
38
+ return Buffer.from(input)
39
+ .toString('base64')
40
+ .replace(/=+$/g, '')
41
+ .replace(/\+/g, '-')
42
+ .replace(/\//g, '_');
43
+ }
44
+
45
+ /**
46
+ * Normalize a `.p8` private key that may arrive with literal "\n" sequences
47
+ * (common when pasted from a one-line env var) into real newlines so
48
+ * crypto.createPrivateKey can parse the PEM.
49
+ */
50
+ function normalizePrivateKey(privateKey) {
51
+ const key = String(privateKey || '').trim();
52
+ if (key.includes('-----BEGIN') && !key.includes('\n') && key.includes('\\n')) {
53
+ return key.replace(/\\n/g, '\n');
54
+ }
55
+ return key;
56
+ }
57
+
58
+ /**
59
+ * Sign the Apple "Sign in with Apple" client secret (an ES256 JWT).
60
+ *
61
+ * @param {object} opts
62
+ * @param {string} opts.serviceId - Apple Service ID (the OAuth client_id), e.g. com.acme.app.signin
63
+ * @param {string} opts.teamId - Apple Developer Team ID
64
+ * @param {string} opts.keyId - Key ID of the .p8
65
+ * @param {string} opts.privateKey - PEM contents of the .p8 (PKCS#8 EC P-256)
66
+ * @param {number} [opts.expiresInSeconds] - lifetime; clamped to Apple's 6-month max
67
+ * @returns {{ token: string, issuedAt: number, expiresAt: number }}
68
+ */
69
+ function signAppleClientSecret({ serviceId, teamId, keyId, privateKey, expiresInSeconds = APPLE_SECRET_MAX_SECONDS } = {}) {
70
+ if (!serviceId) throw new Error('serviceId (Apple Service ID) is required');
71
+ if (!teamId) throw new Error('teamId (Apple Team ID) is required');
72
+ if (!keyId) throw new Error('keyId is required');
73
+ if (!privateKey) throw new Error('privateKey (.p8 contents) is required');
74
+
75
+ const issuedAt = Math.floor(Date.now() / 1000);
76
+ const expiresAt = issuedAt + Math.min(expiresInSeconds, APPLE_SECRET_MAX_SECONDS);
77
+
78
+ const header = { alg: 'ES256', kid: keyId };
79
+ const payload = {
80
+ iss: teamId,
81
+ iat: issuedAt,
82
+ exp: expiresAt,
83
+ aud: 'https://appleid.apple.com',
84
+ sub: serviceId,
85
+ };
86
+
87
+ const signingInput = `${base64url(JSON.stringify(header))}.${base64url(JSON.stringify(payload))}`;
88
+
89
+ let keyObject;
90
+ try {
91
+ keyObject = crypto.createPrivateKey(normalizePrivateKey(privateKey));
92
+ } catch (err) {
93
+ throw new Error(`Invalid Apple .p8 private key: ${err.message}`);
94
+ }
95
+
96
+ // ES256 JWTs need the raw r||s signature (IEEE P1363), not DER.
97
+ const signature = crypto.sign('sha256', Buffer.from(signingInput), {
98
+ key: keyObject,
99
+ dsaEncoding: 'ieee-p1363',
100
+ });
101
+
102
+ return { token: `${signingInput}.${base64url(signature)}`, issuedAt, expiresAt };
103
+ }
104
+
105
+ /**
106
+ * Load cached Apple web credentials from ~/.kasy/apple-web.json.
107
+ * Returns null if absent or incomplete.
108
+ */
109
+ async function loadAppleWebCreds() {
110
+ try {
111
+ if (!(await fs.pathExists(APPLE_WEB_CONFIG_PATH))) return null;
112
+ const data = await fs.readJson(APPLE_WEB_CONFIG_PATH);
113
+ if (!data || !data.serviceId || !data.teamId || !data.keyId || !data.privateKey) return null;
114
+ return data;
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Persist Apple web credentials to ~/.kasy/apple-web.json with 0600 perms so the
122
+ * 6-month Supabase renewal and future projects can reuse them without re-asking.
123
+ */
124
+ async function saveAppleWebCreds({ serviceId, teamId, keyId, privateKey }) {
125
+ await fs.ensureDir(CONFIG_DIR);
126
+ await fs.writeJson(
127
+ APPLE_WEB_CONFIG_PATH,
128
+ { serviceId, teamId, keyId, privateKey: normalizePrivateKey(privateKey) },
129
+ { spaces: 2 },
130
+ );
131
+ // Best effort: restrict to the owner (no-op / unsupported on some Windows setups).
132
+ try {
133
+ await fs.chmod(APPLE_WEB_CONFIG_PATH, 0o600);
134
+ } catch {
135
+ /* ignore */
136
+ }
137
+ }
138
+
139
+ module.exports = {
140
+ APPLE_SECRET_MAX_SECONDS,
141
+ APPLE_WEB_CONFIG_PATH,
142
+ base64url,
143
+ normalizePrivateKey,
144
+ signAppleClientSecret,
145
+ loadAppleWebCreds,
146
+ saveAppleWebCreds,
147
+ };