@aglyn/aglyn 1.0.0-beta.269 → 1.0.0-beta.271

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 (121) hide show
  1. package/package.json +11 -11
  2. package/src/lib/app-utils/account-preferences.d.ts +248 -0
  3. package/src/lib/app-utils/account-preferences.js +434 -0
  4. package/src/lib/app-utils/account-preferences.js.map +1 -0
  5. package/src/lib/app-utils/activity-labels.js +2 -0
  6. package/src/lib/app-utils/activity-labels.js.map +1 -1
  7. package/src/lib/app-utils/analytics-events.d.ts +13 -0
  8. package/src/lib/app-utils/analytics-events.js +2 -0
  9. package/src/lib/app-utils/analytics-events.js.map +1 -1
  10. package/src/lib/app-utils/append-only-history.d.ts +94 -0
  11. package/src/lib/app-utils/append-only-history.js +73 -0
  12. package/src/lib/app-utils/append-only-history.js.map +1 -0
  13. package/src/lib/app-utils/bundle-intent.d.ts +186 -0
  14. package/src/lib/app-utils/bundle-intent.js +386 -0
  15. package/src/lib/app-utils/bundle-intent.js.map +1 -0
  16. package/src/lib/app-utils/client-build.d.ts +204 -0
  17. package/src/lib/app-utils/client-build.js +237 -0
  18. package/src/lib/app-utils/client-build.js.map +1 -0
  19. package/src/lib/app-utils/console-routes.d.ts +4 -0
  20. package/src/lib/app-utils/console-routes.js +2 -0
  21. package/src/lib/app-utils/console-routes.js.map +1 -1
  22. package/src/lib/app-utils/country-options.d.ts +27 -0
  23. package/src/lib/app-utils/country-options.js +96 -0
  24. package/src/lib/app-utils/country-options.js.map +1 -0
  25. package/src/lib/app-utils/docs-help-sections.generated.js +7 -5
  26. package/src/lib/app-utils/docs-help-sections.generated.js.map +1 -1
  27. package/src/lib/app-utils/docs-help.generated.d.ts +11 -5
  28. package/src/lib/app-utils/docs-help.generated.js +22 -2
  29. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  30. package/src/lib/app-utils/docs-index.generated.js +116 -39
  31. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  32. package/src/lib/app-utils/margin-utilization.js +6 -4
  33. package/src/lib/app-utils/margin-utilization.js.map +1 -1
  34. package/src/lib/app-utils/media-filter.d.ts +26 -0
  35. package/src/lib/app-utils/media-filter.js +32 -0
  36. package/src/lib/app-utils/media-filter.js.map +1 -1
  37. package/src/lib/app-utils/mobile-push.d.ts +8 -0
  38. package/src/lib/app-utils/mobile-push.js +4 -1
  39. package/src/lib/app-utils/mobile-push.js.map +1 -1
  40. package/src/lib/app-utils/native-app-association.d.ts +3 -2
  41. package/src/lib/app-utils/native-app-association.js +4 -2
  42. package/src/lib/app-utils/native-app-association.js.map +1 -1
  43. package/src/lib/app-utils/native-nav.d.ts +265 -0
  44. package/src/lib/app-utils/native-nav.js +741 -0
  45. package/src/lib/app-utils/native-nav.js.map +1 -0
  46. package/src/lib/app-utils/native-themes.d.ts +249 -0
  47. package/src/lib/app-utils/native-themes.js +732 -0
  48. package/src/lib/app-utils/native-themes.js.map +1 -0
  49. package/src/lib/app-utils/notifications.d.ts +1 -1
  50. package/src/lib/app-utils/notifications.js +10 -0
  51. package/src/lib/app-utils/notifications.js.map +1 -1
  52. package/src/lib/app-utils/onboarding-deep-link.d.ts +36 -2
  53. package/src/lib/app-utils/onboarding-deep-link.js +48 -5
  54. package/src/lib/app-utils/onboarding-deep-link.js.map +1 -1
  55. package/src/lib/app-utils/one-box-search.d.ts +108 -0
  56. package/src/lib/app-utils/one-box-search.js +167 -0
  57. package/src/lib/app-utils/one-box-search.js.map +1 -0
  58. package/src/lib/app-utils/operator-alerts.js +21 -0
  59. package/src/lib/app-utils/operator-alerts.js.map +1 -1
  60. package/src/lib/app-utils/org-permissions.js +10 -61
  61. package/src/lib/app-utils/org-permissions.js.map +1 -1
  62. package/src/lib/app-utils/org-role-grants.d.ts +95 -0
  63. package/src/lib/app-utils/org-role-grants.js +193 -0
  64. package/src/lib/app-utils/org-role-grants.js.map +1 -0
  65. package/src/lib/app-utils/owner-approval-record.d.ts +102 -0
  66. package/src/lib/app-utils/owner-approval-record.js +148 -0
  67. package/src/lib/app-utils/owner-approval-record.js.map +1 -0
  68. package/src/lib/app-utils/plan-entitlements.d.ts +224 -68
  69. package/src/lib/app-utils/plan-entitlements.js +278 -61
  70. package/src/lib/app-utils/plan-entitlements.js.map +1 -1
  71. package/src/lib/app-utils/release-flags.d.ts +1 -1
  72. package/src/lib/app-utils/release-flags.js +10 -0
  73. package/src/lib/app-utils/release-flags.js.map +1 -1
  74. package/src/lib/app-utils/server.d.ts +4 -0
  75. package/src/lib/app-utils/server.js +7 -0
  76. package/src/lib/app-utils/server.js.map +1 -1
  77. package/src/lib/app-utils/site-care-project.d.ts +227 -0
  78. package/src/lib/app-utils/site-care-project.js +423 -0
  79. package/src/lib/app-utils/site-care-project.js.map +1 -0
  80. package/src/lib/app-utils/site-care.d.ts +268 -0
  81. package/src/lib/app-utils/site-care.js +283 -0
  82. package/src/lib/app-utils/site-care.js.map +1 -0
  83. package/src/lib/app-utils/tenant-dns-targets.d.ts +62 -0
  84. package/src/lib/app-utils/tenant-dns-targets.js +80 -0
  85. package/src/lib/app-utils/tenant-dns-targets.js.map +1 -0
  86. package/src/lib/app-utils/upgrade-proposal.d.ts +29 -4
  87. package/src/lib/app-utils/upgrade-proposal.js +60 -9
  88. package/src/lib/app-utils/upgrade-proposal.js.map +1 -1
  89. package/src/lib/foundation/definitions/org-billing.types.d.ts +54 -10
  90. package/src/lib/foundation/definitions/org-billing.types.js.map +1 -1
  91. package/src/lib/foundation/definitions/organization.types.d.ts +3 -1
  92. package/src/lib/foundation/definitions/organization.types.js.map +1 -1
  93. package/src/lib/foundation/definitions/platform.types.d.ts +9 -0
  94. package/src/lib/foundation/definitions/platform.types.js.map +1 -1
  95. package/src/lib/foundation/index.d.ts +1 -1
  96. package/src/lib/foundation/index.js.map +1 -1
  97. package/src/lib/plugin-manager/feature-plugins.d.ts +86 -0
  98. package/src/lib/plugin-manager/feature-plugins.js +20 -0
  99. package/src/lib/plugin-manager/feature-plugins.js.map +1 -1
  100. package/src/lib/plugin-manager/first-party-plugins.generated.js +22 -1
  101. package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
  102. package/src/lib/plugin-manager/plugin-ai-capabilities.d.ts +19 -0
  103. package/src/lib/plugin-manager/plugin-ai-capabilities.js.map +1 -1
  104. package/src/lib/plugin-manager/plugin-domain-events.d.ts +4 -3
  105. package/src/lib/plugin-manager/plugin-domain-events.js.map +1 -1
  106. package/src/lib/plugin-manager/plugin-domain-search.d.ts +81 -0
  107. package/src/lib/plugin-manager/plugin-domain-search.js +63 -0
  108. package/src/lib/plugin-manager/plugin-domain-search.js.map +1 -0
  109. package/src/lib/plugin-manager/plugin-entitlements.d.ts +23 -5
  110. package/src/lib/plugin-manager/plugin-entitlements.js +10 -0
  111. package/src/lib/plugin-manager/plugin-entitlements.js.map +1 -1
  112. package/src/lib/plugin-manager/plugin-media-ingest.d.ts +25 -0
  113. package/src/lib/plugin-manager/plugin-media-ingest.js.map +1 -1
  114. package/src/lib/plugin-manager/plugin-search-sources.d.ts +85 -0
  115. package/src/lib/plugin-manager/plugin-search-sources.js +91 -0
  116. package/src/lib/plugin-manager/plugin-search-sources.js.map +1 -0
  117. package/src/lib/plugin-manager/plugin-services.js +6 -0
  118. package/src/lib/plugin-manager/plugin-services.js.map +1 -1
  119. package/src/lib/plugin-manager/purchase-gate-preclearance.d.ts +61 -0
  120. package/src/lib/plugin-manager/purchase-gate-preclearance.js +102 -0
  121. package/src/lib/plugin-manager/purchase-gate-preclearance.js.map +1 -0
@@ -14,12 +14,16 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
- import type { OrgPlan } from '../foundation/definitions/org-billing.types';
17
+ import type { AiTierId, OrgPlan } from '../foundation/definitions/org-billing.types';
18
+ import { type BundleAddons } from './bundle-intent';
18
19
  /**
19
20
  * The marketing → console onboarding deep link (AGL-1117).
20
21
  *
21
22
  * `app.aglyn.com/signup?plan=pro&interval=year`, optionally `&ai=1` for the
22
- * Aglyn AI add-on (AGL-2897).
23
+ * Aglyn AI add-on (AGL-2897), or `&ai=<tier>` (`&ai=boost`) for a named
24
+ * Aglyn AI tier (AGL-3965). The bundle checkout link (AGL-3955) adds
25
+ * `&addons=siteCare,ai:boost,extraHost:2` for any add-on the plan sells and
26
+ * `&coupon=<code>` for a promotion code to apply at checkout.
23
27
  *
24
28
  * This is a CONTRACT with a site we do not control and cannot deploy in
25
29
  * lockstep with. So it is parsed defensively and in one place: the pricing
@@ -63,6 +67,33 @@ export interface OnboardingPlanIntent {
63
67
  * the add-on is quoted with the deal, not bought through a checkout.
64
68
  */
65
69
  ai?: true;
70
+ /**
71
+ * The Aglyn AI tier the link named (AGL-3965): `?ai=boost`, or an
72
+ * `ai:boost` token in `addons=` (AGL-3955). Present only with `ai`; absent,
73
+ * `ai=1` means the tier with the plan's old price and band
74
+ * (`defaultAiTierForPlan`), which is what checkout sells.
75
+ */
76
+ aiTier?: AiTierId;
77
+ /**
78
+ * Every add-on the `addons=` param preselects (AGL-3955), parsed by the
79
+ * shared `parseBundleAddons`. Absent when it named none. `ai=1` stays the
80
+ * `ai` flag above; an `ai` token here sets that flag too, so a reader that
81
+ * wants the whole set reads both (`intentAddons`).
82
+ * Whether the plan sells each one is the reader's question
83
+ * (`fitBundleAddonsToPlan`), because the owner can pick another plan.
84
+ */
85
+ addons?: BundleAddons;
86
+ /**
87
+ * A promotion code to apply at checkout (AGL-3955). Applied only: codes are
88
+ * minted on the staff Coupons page. An invalid or expired one is dropped by
89
+ * the Billing page with a notice, never an error page.
90
+ */
91
+ coupon?: string;
92
+ /**
93
+ * Add-on tokens the link named that nothing understood, as written, so the
94
+ * page can say in one line that they were left off.
95
+ */
96
+ droppedAddons?: string[];
66
97
  }
67
98
  /**
68
99
  * Read a plan intent off the URL, or null when there isn't a usable one.
@@ -73,6 +104,8 @@ export interface OnboardingPlanIntent {
73
104
  * what they already have.
74
105
  */
75
106
  export declare function parseOnboardingPlanIntent(params: URLSearchParams | Record<string, string | string[] | undefined> | null | undefined): OnboardingPlanIntent | null;
107
+ /** The whole add-on set an intent asks for: `addons=` and the `ai` flag together. */
108
+ export declare function intentAddons(intent: Pick<OnboardingPlanIntent, 'ai' | 'addons'> | null | undefined): BundleAddons;
76
109
  /**
77
110
  * The intent as query params — the one place the wire form is written
78
111
  * (AGL-1535).
@@ -109,6 +142,7 @@ export declare function onboardingPlanQuery(intent: OnboardingPlanIntent): strin
109
142
  */
110
143
  export declare function onboardingSignupHref(signupUrl: string, plan: OrgPlan, interval: OnboardingInterval | null, options?: {
111
144
  ai?: boolean;
145
+ aiTier?: AiTierId;
112
146
  }): string;
113
147
  /**
114
148
  * Where a freshly provisioned workspace should land, given the plan the
@@ -14,7 +14,8 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */ import { _ as _extends } from "@swc/helpers/_/_extends";
17
- import { isCustomPricedPlan } from "./plan-entitlements.js";
17
+ import { bundleAddonsParam, hasBundleAddons, normalizeBundleCoupon, parseBundleAddons } from "./bundle-intent.js";
18
+ import { isAiTierId, isCustomPricedPlan } from "./plan-entitlements.js";
18
19
  const SELF_SERVE_PLANS = [
19
20
  'starter',
20
21
  'pro',
@@ -31,6 +32,7 @@ const SELF_SERVE_PLANS = [
31
32
  * Returning an intent for it would send someone to a billing page to buy
32
33
  * what they already have.
33
34
  */ export function parseOnboardingPlanIntent(params) {
35
+ var _parsedAddons_aiTier;
34
36
  if (!params) return null;
35
37
  const read = (key)=>{
36
38
  var _ref;
@@ -57,7 +59,20 @@ const SELF_SERVE_PLANS = [
57
59
  // empty, `0`, `false`, a typo — is "not asked for": a checkout that adds a
58
60
  // paid item off a malformed link is the expensive direction to be wrong in.
59
61
  const ai = read('ai');
60
- const aiStated = ai === '1' || ai === 'true' || ai === 'yes';
62
+ // The bundle (AGL-3955). Read case-preserving: a coupon code is typed the
63
+ // way Stripe stores it, and `read` lower-cases.
64
+ const rawParam = (key)=>{
65
+ var _ref;
66
+ const raw = params instanceof URLSearchParams ? params.get(key) : params[key];
67
+ return String((_ref = Array.isArray(raw) ? raw[0] : raw) != null ? _ref : '').trim();
68
+ };
69
+ const parsedAddons = parseBundleAddons(rawParam('addons'));
70
+ const addons = _extends({}, parsedAddons.addons);
71
+ const coupon = normalizeBundleCoupon(rawParam('coupon'));
72
+ // `?ai=<tier>` names the tier outright; otherwise an `ai:<tier>` token in
73
+ // `addons=` does (AGL-3965).
74
+ const aiTier = isAiTierId(ai) ? ai : (_parsedAddons_aiTier = parsedAddons.aiTier) != null ? _parsedAddons_aiTier : null;
75
+ const aiStated = ai === '1' || ai === 'true' || ai === 'yes' || aiTier !== null;
61
76
  return _extends({
62
77
  plan: plan,
63
78
  // Anything unrecognized falls to monthly, never to yearly: guessing the
@@ -69,8 +84,22 @@ const SELF_SERVE_PLANS = [
69
84
  // org rather than be told "monthly" with a straight face.
70
85
  intervalStated: known,
71
86
  contactSales: false
72
- }, aiStated ? {
87
+ }, aiStated || addons.aiAddon ? {
73
88
  ai: true
89
+ } : {}, aiTier ? {
90
+ aiTier
91
+ } : {}, hasBundleAddons(addons) ? {
92
+ addons
93
+ } : {}, coupon ? {
94
+ coupon
95
+ } : {}, parsedAddons.dropped.length ? {
96
+ droppedAddons: parsedAddons.dropped
97
+ } : {});
98
+ }
99
+ /** The whole add-on set an intent asks for: `addons=` and the `ai` flag together. */ export function intentAddons(intent) {
100
+ var _ref;
101
+ return _extends({}, (_ref = intent == null ? void 0 : intent.addons) != null ? _ref : {}, (intent == null ? void 0 : intent.ai) ? {
102
+ aiAddon: 1
74
103
  } : {});
75
104
  }
76
105
  /**
@@ -82,7 +111,19 @@ const SELF_SERVE_PLANS = [
82
111
  * `&interval=` unconditionally, which turned the parser's monthly DEFAULT into
83
112
  * a statement the next reader could not tell from the real thing.
84
113
  */ export function onboardingPlanQuery(intent) {
85
- return `plan=${encodeURIComponent(intent.plan)}` + (intent.intervalStated ? `&interval=${intent.interval}` : '') + (intent.ai ? '&ai=1' : '');
114
+ var _intent_addons, _intent_aiTier;
115
+ // `ai=1` stays the spelling when Aglyn AI is the only add-on, so the
116
+ // published pricing CTAs and every hop between them read unchanged. Any
117
+ // other add-on writes the whole set as `addons=` (AGL-3955), Aglyn AI with it.
118
+ const addons = _extends({}, (_intent_addons = intent.addons) != null ? _intent_addons : {});
119
+ if (intent.ai) addons.aiAddon = 1;
120
+ const others = Object.keys(addons).some((kind)=>{
121
+ var _addons_kind;
122
+ return kind !== 'aiAddon' && ((_addons_kind = addons[kind]) != null ? _addons_kind : 0) > 0;
123
+ });
124
+ const coupon = normalizeBundleCoupon(intent.coupon);
125
+ return `plan=${encodeURIComponent(intent.plan)}` + (intent.intervalStated ? `&interval=${intent.interval}` : '') + (others ? // readable when it is read aloud or typed off a QR's caption.
126
+ `&addons=${bundleAddonsParam(addons, intent.aiTier)}` : addons.aiAddon ? `&ai=${(_intent_aiTier = intent.aiTier) != null ? _intent_aiTier : '1'}` : '') + (coupon ? `&coupon=${encodeURIComponent(coupon)}` : '');
86
127
  }
87
128
  /**
88
129
  * The signup URL a marketing CTA should carry — the WRITER half of the
@@ -121,8 +162,10 @@ const SELF_SERVE_PLANS = [
121
162
  interval: stated ? interval : 'month',
122
163
  intervalStated: stated,
123
164
  contactSales
124
- }, options.ai && !contactSales ? {
165
+ }, (options.ai || options.aiTier) && !contactSales ? {
125
166
  ai: true
167
+ } : {}, options.aiTier && !contactSales ? {
168
+ aiTier: options.aiTier
126
169
  } : {}))}`;
127
170
  }
128
171
  /**
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/onboarding-deep-link.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { OrgPlan } from '../foundation/definitions/org-billing.types'\nimport { isCustomPricedPlan } from './plan-entitlements'\n\n/**\n * The marketing → console onboarding deep link (AGL-1117).\n *\n * `app.aglyn.com/signup?plan=pro&interval=year`, optionally `&ai=1` for the\n * Aglyn AI add-on (AGL-2897).\n *\n * This is a CONTRACT with a site we do not control and cannot deploy in\n * lockstep with. So it is parsed defensively and in one place: the pricing\n * page will eventually be edited by someone who has never read this file, and\n * a typo in a CTA must degrade to ordinary signup rather than break it or —\n * far worse — silently start someone on the wrong plan.\n */\nexport type OnboardingInterval = 'month' | 'year'\n\nexport interface OnboardingPlanIntent {\n plan: OrgPlan\n interval: OnboardingInterval\n /**\n * Whether the link ACTUALLY said an interval, as opposed to `interval`\n * being the safe default below (AGL-1535).\n *\n * Some CTAs carry a plan and deliberately no interval — the /pricing scale\n * strip quotes monthly and annual side by side and commits to neither. The\n * billing page honours that (it only lets a *stated* interval move the\n * toggle, so an annual org is not silently flipped to monthly on arrival),\n * but every hop between the CTA and that page used to re-serialize the\n * intent with `&interval=month` appended — which made the omission\n * unrecoverable one redirect after it was made. Carrying the distinction is\n * what keeps \"no interval stated\" a thing the last reader can still see.\n */\n intervalStated: boolean\n /**\n * Enterprise is quoted, not bought. The CTA still carries `plan=enterprise`\n * because that is what the visitor clicked, and the console has to route\n * them to contact-sales rather than to a checkout that cannot price it\n * (AGL-1110).\n */\n contactSales: boolean\n /**\n * The Aglyn AI add-on, asked for alongside the plan (AGL-2897):\n * `?plan=pro&interval=month&ai=1`.\n *\n * Present only when the link said so, and only ever `true` — the add-on\n * rides the plan checkout as a second subscription item, so a flag that\n * was never stated must not be told apart from one that was set to false\n * by every hop that re-serializes the intent. Enterprise never carries it:\n * the add-on is quoted with the deal, not bought through a checkout.\n */\n ai?: true\n}\n\nconst SELF_SERVE_PLANS: readonly OrgPlan[] = [\n 'starter',\n 'pro',\n 'business',\n 'scale',\n 'advanced',\n 'agency',\n]\n\n/**\n * Read a plan intent off the URL, or null when there isn't a usable one.\n *\n * Null for `free` on purpose: it is a real plan but not a purchase, so there\n * is nothing to carry into checkout and a new org already starts there.\n * Returning an intent for it would send someone to a billing page to buy\n * what they already have.\n */\nexport function parseOnboardingPlanIntent(\n params:\n | URLSearchParams\n | Record<string, string | string[] | undefined>\n | null\n | undefined,\n): OnboardingPlanIntent | null {\n if (!params) return null\n const read = (key: string): string => {\n const raw =\n params instanceof URLSearchParams ? params.get(key) : params[key]\n // Next gives `string[]` for a repeated param. Take the first rather than\n // joining, so `?plan=pro&plan=free` cannot become the string \"pro,free\"\n // and fall through to null — the first is what the CTA meant.\n return String((Array.isArray(raw) ? raw[0] : raw) ?? '')\n .trim()\n .toLowerCase()\n }\n const plan = read('plan')\n if (!plan) return null\n if (isCustomPricedPlan(plan as OrgPlan)) {\n return {\n plan: plan as OrgPlan,\n interval: 'month',\n intervalStated: false,\n contactSales: true,\n }\n }\n if (!SELF_SERVE_PLANS.includes(plan as OrgPlan)) return null\n const interval = read('interval')\n const known = interval === 'year' || interval === 'annual' || interval === 'month'\n // The add-on is opt-in by an explicit affirmative. Anything else — absent,\n // empty, `0`, `false`, a typo — is \"not asked for\": a checkout that adds a\n // paid item off a malformed link is the expensive direction to be wrong in.\n const ai = read('ai')\n const aiStated = ai === '1' || ai === 'true' || ai === 'yes'\n return {\n plan: plan as OrgPlan,\n // Anything unrecognized falls to monthly, never to yearly: guessing the\n // longer commitment from a malformed link is the expensive direction to\n // be wrong in.\n interval: interval === 'year' || interval === 'annual' ? 'year' : 'month',\n // A junk interval is not a stated one: `?interval=decade` means the link\n // is broken, and the reader should fall back to what it knows about the\n // org rather than be told \"monthly\" with a straight face.\n intervalStated: known,\n contactSales: false,\n ...(aiStated ? { ai: true } : {}),\n }\n}\n\n/**\n * The intent as query params — the one place the wire form is written\n * (AGL-1535).\n *\n * Every hop that re-serializes an intent (the signup landing, the auth-bounce\n * `continue`, the home jump) used to build this string by hand and append\n * `&interval=` unconditionally, which turned the parser's monthly DEFAULT into\n * a statement the next reader could not tell from the real thing.\n */\nexport function onboardingPlanQuery(intent: OnboardingPlanIntent): string {\n return (\n `plan=${encodeURIComponent(intent.plan)}` +\n (intent.intervalStated ? `&interval=${intent.interval}` : '') +\n (intent.ai ? '&ai=1' : '')\n )\n}\n\n/**\n * The signup URL a marketing CTA should carry — the WRITER half of the\n * contract above (AGL-1989).\n *\n * The contract had a parser and a re-serializer but nothing that produced a\n * link in the first place, so every published CTA was hand-typed. That is how\n * `/pricing` came to have six CTAs carrying `interval=` and three carrying\n * none: the \"Need more scale?\" strip quotes Scale, Advanced and Agency at both\n * cadences and its CHOOSE links say only `?plan=scale`, so a visitor who had\n * just picked Annual arrived pre-selected for a MONTHLY plan and silently lost\n * the annual discount they were shown. Nothing could have caught it, because\n * the string that should have been published existed nowhere a check could\n * read it.\n *\n * `interval: null` is a real and different thing from `'month'`: it means the\n * CTA does not commit to a cadence, which the billing page honours by leaving\n * an annual org on annual (`intervalStated`). Callers with a cadence to state\n * must state it.\n *\n * The marketing site is authored content, not code, so this does not publish\n * anything — it is what the authoring input quotes, so the person clicking the\n * link into the besigner pastes a string this contract's own parser agrees\n * with rather than one they typed.\n */\nexport function onboardingSignupHref(\n signupUrl: string,\n plan: OrgPlan,\n interval: OnboardingInterval | null,\n options: { ai?: boolean } = {},\n): string {\n const contactSales = isCustomPricedPlan(plan)\n // A custom-priced plan is quoted, not bought, so `parseOnboardingPlanIntent`\n // reports `intervalStated: false` for it HOWEVER the link is written. Writing\n // `?plan=enterprise&interval=year` anyway puts a claim on the wire that no\n // reader honours — and before AGL-1864 fixed the reader, that exact link\n // flipped an annual org to monthly on arrival. Drop it at the source, which\n // is the last place it can be dropped before it is published.\n const stated = interval != null && contactSales === false\n return `${signupUrl}?${onboardingPlanQuery({\n plan,\n interval: stated ? interval : 'month',\n intervalStated: stated,\n contactSales,\n // The add-on CTA (AGL-2897). Dropped for a custom-priced plan for the\n // same reason the interval is: the parser refuses it there, so writing\n // it would publish a claim no reader honors.\n ...(options.ai && !contactSales ? { ai: true } : {}),\n })}`\n}\n\n/**\n * Where a freshly provisioned workspace should land, given the plan the\n * visitor picked on the marketing site.\n *\n * Deliberately returns a path rather than pushing: signup owns the\n * navigation, and a pure function is the half worth testing.\n */\nexport function onboardingDestination(\n orgSlug: string,\n intent: OnboardingPlanIntent | null,\n): string {\n if (!orgSlug) return '/'\n if (!intent) return `/${orgSlug}`\n // Enterprise is a conversation, not a checkout (AGL-1110).\n if (intent.contactSales) return `/${orgSlug}/support?topic=enterprise`\n return `/${orgSlug}/billing?${onboardingPlanQuery(intent)}`\n}\n"],"names":["isCustomPricedPlan","SELF_SERVE_PLANS","parseOnboardingPlanIntent","params","read","key","Array","raw","URLSearchParams","get","String","isArray","trim","toLowerCase","plan","interval","intervalStated","contactSales","includes","known","ai","aiStated","onboardingPlanQuery","intent","encodeURIComponent","onboardingSignupHref","signupUrl","options","stated","onboardingDestination","orgSlug"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAGD,SAASA,kBAAkB,QAAQ,yBAAqB;AAqDxD,MAAMC,mBAAuC;IAC3C;IACA;IACA;IACA;IACA;IACA;CACD;AAED;;;;;;;CAOC,GACD,OAAO,SAASC,0BACdC,MAIa;IAEb,IAAI,CAACA,QAAQ,OAAO;IACpB,MAAMC,OAAO,CAACC;YAMGC;QALf,MAAMC,MACJJ,kBAAkBK,kBAAkBL,OAAOM,GAAG,CAACJ,OAAOF,MAAM,CAACE,IAAI;QACnE,yEAAyE;QACzE,wEAAwE;QACxE,8DAA8D;QAC9D,OAAOK,QAAQJ,OAAAA,MAAMK,OAAO,CAACJ,OAAOA,GAAG,CAAC,EAAE,GAAGA,eAA9BD,OAAsC,IAClDM,IAAI,GACJC,WAAW;IAChB;IACA,MAAMC,OAAOV,KAAK;IAClB,IAAI,CAACU,MAAM,OAAO;IAClB,IAAId,mBAAmBc,OAAkB;QACvC,OAAO;YACLA,MAAMA;YACNC,UAAU;YACVC,gBAAgB;YAChBC,cAAc;QAChB;IACF;IACA,IAAI,CAAChB,iBAAiBiB,QAAQ,CAACJ,OAAkB,OAAO;IACxD,MAAMC,WAAWX,KAAK;IACtB,MAAMe,QAAQJ,aAAa,UAAUA,aAAa,YAAYA,aAAa;IAC3E,2EAA2E;IAC3E,2EAA2E;IAC3E,4EAA4E;IAC5E,MAAMK,KAAKhB,KAAK;IAChB,MAAMiB,WAAWD,OAAO,OAAOA,OAAO,UAAUA,OAAO;IACvD,OAAO;QACLN,MAAMA;QACN,wEAAwE;QACxE,wEAAwE;QACxE,eAAe;QACfC,UAAUA,aAAa,UAAUA,aAAa,WAAW,SAAS;QAClE,yEAAyE;QACzE,wEAAwE;QACxE,0DAA0D;QAC1DC,gBAAgBG;QAChBF,cAAc;OACVI,WAAW;QAAED,IAAI;IAAK,IAAI,CAAC;AAEnC;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASE,oBAAoBC,MAA4B;IAC9D,OACE,CAAC,KAAK,EAAEC,mBAAmBD,OAAOT,IAAI,GAAG,GACxCS,CAAAA,OAAOP,cAAc,GAAG,CAAC,UAAU,EAAEO,OAAOR,QAAQ,EAAE,GAAG,EAAC,IAC1DQ,CAAAA,OAAOH,EAAE,GAAG,UAAU,EAAC;AAE5B;AAEA;;;;;;;;;;;;;;;;;;;;;;;CAuBC,GACD,OAAO,SAASK,qBACdC,SAAiB,EACjBZ,IAAa,EACbC,QAAmC,EACnCY,UAA4B,CAAC,CAAC;IAE9B,MAAMV,eAAejB,mBAAmBc;IACxC,6EAA6E;IAC7E,8EAA8E;IAC9E,2EAA2E;IAC3E,yEAAyE;IACzE,4EAA4E;IAC5E,8DAA8D;IAC9D,MAAMc,SAASb,YAAY,QAAQE,iBAAiB;IACpD,OAAO,GAAGS,UAAU,CAAC,EAAEJ,oBAAoB;QACzCR;QACAC,UAAUa,SAASb,WAAW;QAC9BC,gBAAgBY;QAChBX;OAIIU,QAAQP,EAAE,IAAI,CAACH,eAAe;QAAEG,IAAI;IAAK,IAAI,CAAC,KAChD;AACN;AAEA;;;;;;CAMC,GACD,OAAO,SAASS,sBACdC,OAAe,EACfP,MAAmC;IAEnC,IAAI,CAACO,SAAS,OAAO;IACrB,IAAI,CAACP,QAAQ,OAAO,CAAC,CAAC,EAAEO,SAAS;IACjC,2DAA2D;IAC3D,IAAIP,OAAON,YAAY,EAAE,OAAO,CAAC,CAAC,EAAEa,QAAQ,yBAAyB,CAAC;IACtE,OAAO,CAAC,CAAC,EAAEA,QAAQ,SAAS,EAAER,oBAAoBC,SAAS;AAC7D"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/onboarding-deep-link.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { AiTierId, OrgPlan } from '../foundation/definitions/org-billing.types'\nimport {\n bundleAddonsParam,\n hasBundleAddons,\n normalizeBundleCoupon,\n parseBundleAddons,\n type BundleAddons,\n} from './bundle-intent'\nimport { isAiTierId, isCustomPricedPlan } from './plan-entitlements'\n\n/**\n * The marketing → console onboarding deep link (AGL-1117).\n *\n * `app.aglyn.com/signup?plan=pro&interval=year`, optionally `&ai=1` for the\n * Aglyn AI add-on (AGL-2897), or `&ai=<tier>` (`&ai=boost`) for a named\n * Aglyn AI tier (AGL-3965). The bundle checkout link (AGL-3955) adds\n * `&addons=siteCare,ai:boost,extraHost:2` for any add-on the plan sells and\n * `&coupon=<code>` for a promotion code to apply at checkout.\n *\n * This is a CONTRACT with a site we do not control and cannot deploy in\n * lockstep with. So it is parsed defensively and in one place: the pricing\n * page will eventually be edited by someone who has never read this file, and\n * a typo in a CTA must degrade to ordinary signup rather than break it or —\n * far worse — silently start someone on the wrong plan.\n */\nexport type OnboardingInterval = 'month' | 'year'\n\nexport interface OnboardingPlanIntent {\n plan: OrgPlan\n interval: OnboardingInterval\n /**\n * Whether the link ACTUALLY said an interval, as opposed to `interval`\n * being the safe default below (AGL-1535).\n *\n * Some CTAs carry a plan and deliberately no interval — the /pricing scale\n * strip quotes monthly and annual side by side and commits to neither. The\n * billing page honours that (it only lets a *stated* interval move the\n * toggle, so an annual org is not silently flipped to monthly on arrival),\n * but every hop between the CTA and that page used to re-serialize the\n * intent with `&interval=month` appended — which made the omission\n * unrecoverable one redirect after it was made. Carrying the distinction is\n * what keeps \"no interval stated\" a thing the last reader can still see.\n */\n intervalStated: boolean\n /**\n * Enterprise is quoted, not bought. The CTA still carries `plan=enterprise`\n * because that is what the visitor clicked, and the console has to route\n * them to contact-sales rather than to a checkout that cannot price it\n * (AGL-1110).\n */\n contactSales: boolean\n /**\n * The Aglyn AI add-on, asked for alongside the plan (AGL-2897):\n * `?plan=pro&interval=month&ai=1`.\n *\n * Present only when the link said so, and only ever `true` — the add-on\n * rides the plan checkout as a second subscription item, so a flag that\n * was never stated must not be told apart from one that was set to false\n * by every hop that re-serializes the intent. Enterprise never carries it:\n * the add-on is quoted with the deal, not bought through a checkout.\n */\n ai?: true\n /**\n * The Aglyn AI tier the link named (AGL-3965): `?ai=boost`, or an\n * `ai:boost` token in `addons=` (AGL-3955). Present only with `ai`; absent,\n * `ai=1` means the tier with the plan's old price and band\n * (`defaultAiTierForPlan`), which is what checkout sells.\n */\n aiTier?: AiTierId\n /**\n * Every add-on the `addons=` param preselects (AGL-3955), parsed by the\n * shared `parseBundleAddons`. Absent when it named none. `ai=1` stays the\n * `ai` flag above; an `ai` token here sets that flag too, so a reader that\n * wants the whole set reads both (`intentAddons`).\n * Whether the plan sells each one is the reader's question\n * (`fitBundleAddonsToPlan`), because the owner can pick another plan.\n */\n addons?: BundleAddons\n /**\n * A promotion code to apply at checkout (AGL-3955). Applied only: codes are\n * minted on the staff Coupons page. An invalid or expired one is dropped by\n * the Billing page with a notice, never an error page.\n */\n coupon?: string\n /**\n * Add-on tokens the link named that nothing understood, as written, so the\n * page can say in one line that they were left off.\n */\n droppedAddons?: string[]\n}\n\nconst SELF_SERVE_PLANS: readonly OrgPlan[] = [\n 'starter',\n 'pro',\n 'business',\n 'scale',\n 'advanced',\n 'agency',\n]\n\n/**\n * Read a plan intent off the URL, or null when there isn't a usable one.\n *\n * Null for `free` on purpose: it is a real plan but not a purchase, so there\n * is nothing to carry into checkout and a new org already starts there.\n * Returning an intent for it would send someone to a billing page to buy\n * what they already have.\n */\nexport function parseOnboardingPlanIntent(\n params:\n | URLSearchParams\n | Record<string, string | string[] | undefined>\n | null\n | undefined,\n): OnboardingPlanIntent | null {\n if (!params) return null\n const read = (key: string): string => {\n const raw =\n params instanceof URLSearchParams ? params.get(key) : params[key]\n // Next gives `string[]` for a repeated param. Take the first rather than\n // joining, so `?plan=pro&plan=free` cannot become the string \"pro,free\"\n // and fall through to null — the first is what the CTA meant.\n return String((Array.isArray(raw) ? raw[0] : raw) ?? '')\n .trim()\n .toLowerCase()\n }\n const plan = read('plan')\n if (!plan) return null\n if (isCustomPricedPlan(plan as OrgPlan)) {\n return {\n plan: plan as OrgPlan,\n interval: 'month',\n intervalStated: false,\n contactSales: true,\n }\n }\n if (!SELF_SERVE_PLANS.includes(plan as OrgPlan)) return null\n const interval = read('interval')\n const known = interval === 'year' || interval === 'annual' || interval === 'month'\n // The add-on is opt-in by an explicit affirmative. Anything else — absent,\n // empty, `0`, `false`, a typo — is \"not asked for\": a checkout that adds a\n // paid item off a malformed link is the expensive direction to be wrong in.\n const ai = read('ai')\n // The bundle (AGL-3955). Read case-preserving: a coupon code is typed the\n // way Stripe stores it, and `read` lower-cases.\n const rawParam = (key: string): string => {\n const raw = params instanceof URLSearchParams ? params.get(key) : params[key]\n return String((Array.isArray(raw) ? raw[0] : raw) ?? '').trim()\n }\n const parsedAddons = parseBundleAddons(rawParam('addons'))\n const addons: BundleAddons = { ...parsedAddons.addons }\n const coupon = normalizeBundleCoupon(rawParam('coupon'))\n // `?ai=<tier>` names the tier outright; otherwise an `ai:<tier>` token in\n // `addons=` does (AGL-3965).\n const aiTier = isAiTierId(ai) ? ai : (parsedAddons.aiTier ?? null)\n const aiStated = ai === '1' || ai === 'true' || ai === 'yes' || aiTier !== null\n return {\n plan: plan as OrgPlan,\n // Anything unrecognized falls to monthly, never to yearly: guessing the\n // longer commitment from a malformed link is the expensive direction to\n // be wrong in.\n interval: interval === 'year' || interval === 'annual' ? 'year' : 'month',\n // A junk interval is not a stated one: `?interval=decade` means the link\n // is broken, and the reader should fall back to what it knows about the\n // org rather than be told \"monthly\" with a straight face.\n intervalStated: known,\n contactSales: false,\n ...(aiStated || addons.aiAddon ? { ai: true } : {}),\n ...(aiTier ? { aiTier } : {}),\n ...(hasBundleAddons(addons) ? { addons } : {}),\n ...(coupon ? { coupon } : {}),\n ...(parsedAddons.dropped.length ? { droppedAddons: parsedAddons.dropped } : {}),\n }\n}\n\n/** The whole add-on set an intent asks for: `addons=` and the `ai` flag together. */\nexport function intentAddons(intent: Pick<OnboardingPlanIntent, 'ai' | 'addons'> | null | undefined): BundleAddons {\n return { ...(intent?.addons ?? {}), ...(intent?.ai ? { aiAddon: 1 } : {}) }\n}\n\n/**\n * The intent as query params — the one place the wire form is written\n * (AGL-1535).\n *\n * Every hop that re-serializes an intent (the signup landing, the auth-bounce\n * `continue`, the home jump) used to build this string by hand and append\n * `&interval=` unconditionally, which turned the parser's monthly DEFAULT into\n * a statement the next reader could not tell from the real thing.\n */\nexport function onboardingPlanQuery(intent: OnboardingPlanIntent): string {\n // `ai=1` stays the spelling when Aglyn AI is the only add-on, so the\n // published pricing CTAs and every hop between them read unchanged. Any\n // other add-on writes the whole set as `addons=` (AGL-3955), Aglyn AI with it.\n const addons: BundleAddons = { ...(intent.addons ?? {}) }\n if (intent.ai) addons.aiAddon = 1\n const others = Object.keys(addons).some(\n (kind) => kind !== 'aiAddon' && (addons[kind as keyof BundleAddons] ?? 0) > 0,\n )\n const coupon = normalizeBundleCoupon(intent.coupon)\n return (\n `plan=${encodeURIComponent(intent.plan)}` +\n (intent.intervalStated ? `&interval=${intent.interval}` : '') +\n (others\n ? // Fixed tokens, letters, digits, `:` and `,` only, so the link stays\n // readable when it is read aloud or typed off a QR's caption.\n `&addons=${bundleAddonsParam(addons, intent.aiTier)}`\n : addons.aiAddon\n ? `&ai=${intent.aiTier ?? '1'}`\n : '') +\n (coupon ? `&coupon=${encodeURIComponent(coupon)}` : '')\n )\n}\n\n/**\n * The signup URL a marketing CTA should carry — the WRITER half of the\n * contract above (AGL-1989).\n *\n * The contract had a parser and a re-serializer but nothing that produced a\n * link in the first place, so every published CTA was hand-typed. That is how\n * `/pricing` came to have six CTAs carrying `interval=` and three carrying\n * none: the \"Need more scale?\" strip quotes Scale, Advanced and Agency at both\n * cadences and its CHOOSE links say only `?plan=scale`, so a visitor who had\n * just picked Annual arrived pre-selected for a MONTHLY plan and silently lost\n * the annual discount they were shown. Nothing could have caught it, because\n * the string that should have been published existed nowhere a check could\n * read it.\n *\n * `interval: null` is a real and different thing from `'month'`: it means the\n * CTA does not commit to a cadence, which the billing page honours by leaving\n * an annual org on annual (`intervalStated`). Callers with a cadence to state\n * must state it.\n *\n * The marketing site is authored content, not code, so this does not publish\n * anything — it is what the authoring input quotes, so the person clicking the\n * link into the besigner pastes a string this contract's own parser agrees\n * with rather than one they typed.\n */\nexport function onboardingSignupHref(\n signupUrl: string,\n plan: OrgPlan,\n interval: OnboardingInterval | null,\n options: { ai?: boolean; aiTier?: AiTierId } = {},\n): string {\n const contactSales = isCustomPricedPlan(plan)\n // A custom-priced plan is quoted, not bought, so `parseOnboardingPlanIntent`\n // reports `intervalStated: false` for it HOWEVER the link is written. Writing\n // `?plan=enterprise&interval=year` anyway puts a claim on the wire that no\n // reader honours — and before AGL-1864 fixed the reader, that exact link\n // flipped an annual org to monthly on arrival. Drop it at the source, which\n // is the last place it can be dropped before it is published.\n const stated = interval != null && contactSales === false\n return `${signupUrl}?${onboardingPlanQuery({\n plan,\n interval: stated ? interval : 'month',\n intervalStated: stated,\n contactSales,\n // The add-on CTA (AGL-2897). Dropped for a custom-priced plan for the\n // same reason the interval is: the parser refuses it there, so writing\n // it would publish a claim no reader honors.\n ...((options.ai || options.aiTier) && !contactSales ? { ai: true } : {}),\n ...(options.aiTier && !contactSales ? { aiTier: options.aiTier } : {}),\n })}`\n}\n\n/**\n * Where a freshly provisioned workspace should land, given the plan the\n * visitor picked on the marketing site.\n *\n * Deliberately returns a path rather than pushing: signup owns the\n * navigation, and a pure function is the half worth testing.\n */\nexport function onboardingDestination(\n orgSlug: string,\n intent: OnboardingPlanIntent | null,\n): string {\n if (!orgSlug) return '/'\n if (!intent) return `/${orgSlug}`\n // Enterprise is a conversation, not a checkout (AGL-1110).\n if (intent.contactSales) return `/${orgSlug}/support?topic=enterprise`\n return `/${orgSlug}/billing?${onboardingPlanQuery(intent)}`\n}\n"],"names":["bundleAddonsParam","hasBundleAddons","normalizeBundleCoupon","parseBundleAddons","isAiTierId","isCustomPricedPlan","SELF_SERVE_PLANS","parseOnboardingPlanIntent","params","parsedAddons","read","key","Array","raw","URLSearchParams","get","String","isArray","trim","toLowerCase","plan","interval","intervalStated","contactSales","includes","known","ai","rawParam","addons","coupon","aiTier","aiStated","aiAddon","dropped","length","droppedAddons","intentAddons","intent","onboardingPlanQuery","others","Object","keys","some","kind","encodeURIComponent","onboardingSignupHref","signupUrl","options","stated","onboardingDestination","orgSlug"],"mappings":"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"}
@@ -0,0 +1,108 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type NativeNavManifest } from './native-nav';
18
+ /** The console route the apps call. */
19
+ export declare const ONE_BOX_SEARCH_PATH = "/api/search";
20
+ /** Rows per group when the request names no `limit`. */
21
+ export declare const ONE_BOX_ROWS_DEFAULT = 5;
22
+ /** The most rows one group answers in one response. */
23
+ export declare const ONE_BOX_ROWS_MAX = 25;
24
+ /** The longest query the route reads; the rest is dropped. */
25
+ export declare const ONE_BOX_QUERY_MAX = 100;
26
+ /** The group of workspaces the person belongs to: the switcher's half. */
27
+ export declare const ONE_BOX_WORKSPACES_GROUP = "workspaces";
28
+ /**
29
+ * The group of the app's own screens and settings. The apps answer it
30
+ * themselves, with no read, from the assembled manifest
31
+ * (`searchNavScreens(ContractValues.nativeNavManifest, …)`), and show it in
32
+ * the one box beside the route's groups.
33
+ */
34
+ export declare const ONE_BOX_SCREENS_GROUP = "appScreens";
35
+ /** One result. */
36
+ export interface OneBoxRow {
37
+ /** The group it came from (`workspaces`, `sites`, `screens`, `orders`, `contacts`, …). */
38
+ group: string;
39
+ /** The workspace id, site id, nav row id or record id. */
40
+ id: string;
41
+ label: string;
42
+ /** One line under it: a slug, a buyer, the list a setting is in. */
43
+ detail?: string;
44
+ /** The workspace it belongs to; a workspace row's own id. */
45
+ orgId?: string;
46
+ /** The site it belongs to; a site row's own id. */
47
+ hostId?: string;
48
+ /**
49
+ * The console path it opens at. The apps resolve it through their deep-link
50
+ * grammar, as they resolve a notification's link, so a record opens on its
51
+ * native screen and a Besigner page in the in-app Besigner.
52
+ */
53
+ link?: string;
54
+ /** For a screens-and-settings row: the native screen and its params. */
55
+ screen?: string;
56
+ params?: Record<string, string>;
57
+ }
58
+ /** One kind of result. */
59
+ export interface OneBoxGroup {
60
+ group: string;
61
+ /** The heading above its rows. */
62
+ heading: string;
63
+ /** The kind in a sentence ("pages"). */
64
+ noun: string;
65
+ rows: OneBoxRow[];
66
+ /** How many matched in what was searched, of which `rows` is a page. */
67
+ matched: number;
68
+ /** How many records were matched against. */
69
+ searched: number;
70
+ /** The group filled its window, so it may hold matches that were not read. */
71
+ truncated: boolean;
72
+ /** The group could not be read. Never the same as matching nothing. */
73
+ failed: boolean;
74
+ /** Pass back as `cursor` with `group` for the next page, absent at the end. */
75
+ nextCursor?: number;
76
+ }
77
+ export interface OneBoxResponse {
78
+ /** The query as it was read. */
79
+ query: string;
80
+ orgId?: string;
81
+ hostId?: string;
82
+ /** Groups in the order they are shown. */
83
+ groups: OneBoxGroup[];
84
+ }
85
+ /** How many rows a request asked for, within bounds. */
86
+ export declare function oneBoxLimit(raw: unknown): number;
87
+ /** Where a page starts, within bounds. */
88
+ export declare function oneBoxCursor(raw: unknown): number;
89
+ /** The query as the route reads it: trimmed, capped, or null when too short to run. */
90
+ export declare function oneBoxQuery(raw: unknown): string | null;
91
+ /** A matched row before paging, with its score. */
92
+ export interface OneBoxScored {
93
+ row: OneBoxRow;
94
+ score: number;
95
+ }
96
+ /**
97
+ * One group's page: every match ranked the console's way (score, then the
98
+ * shorter name, then A to Z), cut at `cursor` for `limit` rows.
99
+ */
100
+ export declare function pageOneBoxGroup(meta: Pick<OneBoxGroup, 'group' | 'heading' | 'noun' | 'searched' | 'truncated' | 'failed'>, scored: readonly OneBoxScored[], cursor: number, limit: number): OneBoxGroup;
101
+ /**
102
+ * The screens-and-settings matches for where the person stands, from the
103
+ * assembled manifest (`ContractValues.nativeNavManifest` in the apps): the
104
+ * workspace's lists with no site, the site's lists (and the workspace
105
+ * settings) with one. `enabledPluginIds` is the effective set for that
106
+ * scope, so a screen of a plugin that is off is never offered.
107
+ */
108
+ export declare function searchNavScreens(manifest: NativeNavManifest, text: string, scope: 'account' | 'workspace' | 'site', enabledPluginIds: readonly string[], hostAdmin: boolean): OneBoxScored[];
@@ -0,0 +1,167 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ /**
18
+ * THE ONE BOX: SWITCHING AND SEARCH IN ONE PLACE (AGL-3943, Zach 2026-10-18).
19
+ *
20
+ * ⌘K in the native apps opens one box that finds workspaces, sites, pages,
21
+ * orders, contacts, emails, components, layouts, templates and every plugin's
22
+ * records together, plus the app's own screens and settings ("billing" finds
23
+ * Plan). The apps ask `GET /api/search` (`ONE_BOX_SEARCH_PATH`) with the
24
+ * person's ID token and get grouped rows back; this module is that payload,
25
+ * the paging rules, and the screens-and-settings group, which the apps
26
+ * answer themselves from the nav manifest, with no read at all.
27
+ *
28
+ * Request query: `q` (at least two characters), `orgId` and `hostId` (the
29
+ * scope the person stands in; both optional), `limit` (rows per group,
30
+ * default 5, at most 25), and `group` + `cursor` to page one group further
31
+ * ("Show more"). Who may see what is decided by the route, never by these.
32
+ *
33
+ * Imports only the plain modules beside it, so the native apps read the
34
+ * types and the screens-and-settings answers through the generated contracts.
35
+ */ import { compareScored, isSearchableQuery, scoreMatch } from "./name-match.js";
36
+ import { resolveNavList } from "./native-nav.js";
37
+ /** The console route the apps call. */ export const ONE_BOX_SEARCH_PATH = '/api/search';
38
+ /** Rows per group when the request names no `limit`. */ export const ONE_BOX_ROWS_DEFAULT = 5;
39
+ /** The most rows one group answers in one response. */ export const ONE_BOX_ROWS_MAX = 25;
40
+ /** The longest query the route reads; the rest is dropped. */ export const ONE_BOX_QUERY_MAX = 100;
41
+ /** The group of workspaces the person belongs to: the switcher's half. */ export const ONE_BOX_WORKSPACES_GROUP = 'workspaces';
42
+ /**
43
+ * The group of the app's own screens and settings. The apps answer it
44
+ * themselves, with no read, from the assembled manifest
45
+ * (`searchNavScreens(ContractValues.nativeNavManifest, …)`), and show it in
46
+ * the one box beside the route's groups.
47
+ */ export const ONE_BOX_SCREENS_GROUP = 'appScreens';
48
+ /** How many rows a request asked for, within bounds. */ export function oneBoxLimit(raw) {
49
+ const asked = typeof raw === 'string' ? Number(raw) : typeof raw === 'number' ? raw : NaN;
50
+ if (!Number.isFinite(asked) || asked < 1) return ONE_BOX_ROWS_DEFAULT;
51
+ return Math.min(ONE_BOX_ROWS_MAX, Math.floor(asked));
52
+ }
53
+ /** Where a page starts, within bounds. */ export function oneBoxCursor(raw) {
54
+ const asked = typeof raw === 'string' ? Number(raw) : typeof raw === 'number' ? raw : NaN;
55
+ return Number.isFinite(asked) && asked > 0 ? Math.floor(asked) : 0;
56
+ }
57
+ /** The query as the route reads it: trimmed, capped, or null when too short to run. */ export function oneBoxQuery(raw) {
58
+ const text = typeof raw === 'string' ? raw.trim().slice(0, ONE_BOX_QUERY_MAX) : '';
59
+ return isSearchableQuery(text) ? text : null;
60
+ }
61
+ /**
62
+ * One group's page: every match ranked the console's way (score, then the
63
+ * shorter name, then A to Z), cut at `cursor` for `limit` rows.
64
+ */ export function pageOneBoxGroup(meta, scored, cursor, limit) {
65
+ const ranked = [
66
+ ...scored
67
+ ].sort((a, b)=>compareScored({
68
+ score: a.score,
69
+ label: a.row.label
70
+ }, {
71
+ score: b.score,
72
+ label: b.row.label
73
+ }));
74
+ const end = cursor + limit;
75
+ return _extends({}, meta, {
76
+ rows: ranked.slice(cursor, end).map((entry)=>entry.row),
77
+ matched: ranked.length
78
+ }, end < ranked.length ? {
79
+ nextCursor: end
80
+ } : {});
81
+ }
82
+ const LIST_HEADINGS = {
83
+ workspace: 'Workspace',
84
+ site: 'Site',
85
+ workspaceSettings: 'Workspace settings',
86
+ siteSettings: 'Site settings'
87
+ };
88
+ /**
89
+ * The screens-and-settings matches for where the person stands, from the
90
+ * assembled manifest (`ContractValues.nativeNavManifest` in the apps): the
91
+ * workspace's lists with no site, the site's lists (and the workspace
92
+ * settings) with one. `enabledPluginIds` is the effective set for that
93
+ * scope, so a screen of a plugin that is off is never offered.
94
+ */ export function searchNavScreens(manifest, text, scope, enabledPluginIds, hostAdmin) {
95
+ if (!isSearchableQuery(text) || scope === 'account') return [];
96
+ const lists = scope === 'site' ? [
97
+ 'site',
98
+ 'siteSettings',
99
+ 'workspaceSettings'
100
+ ] : [
101
+ 'workspace',
102
+ 'workspaceSettings'
103
+ ];
104
+ const seen = new Set();
105
+ const out = [];
106
+ for (const listId of lists){
107
+ const list = resolveNavList(manifest, listId, enabledPluginIds, hostAdmin);
108
+ for (const group of list.groups){
109
+ for (const row of group.rows){
110
+ var _row_keywords, _group_label, _row_sections;
111
+ const where = group.label ? `${LIST_HEADINGS[listId]} · ${group.label}` : LIST_HEADINGS[listId];
112
+ const entries = [
113
+ {
114
+ id: `${listId}.${row.id}`,
115
+ label: row.label,
116
+ screen: row.screen,
117
+ params: row.params,
118
+ keywords: [
119
+ ...(_row_keywords = row.keywords) != null ? _row_keywords : [],
120
+ (_group_label = group.label) != null ? _group_label : ''
121
+ ]
122
+ },
123
+ ...((_row_sections = row.sections) != null ? _row_sections : []).filter((section)=>section.label !== row.label).map((section)=>{
124
+ var _group_label;
125
+ return {
126
+ id: `${listId}.${row.id}.${section.id}`,
127
+ label: section.label,
128
+ screen: section.screen,
129
+ params: section.params,
130
+ keywords: [
131
+ row.label,
132
+ (_group_label = group.label) != null ? _group_label : ''
133
+ ]
134
+ };
135
+ })
136
+ ];
137
+ for (const entry of entries){
138
+ var _entry_params;
139
+ // The same screen reached from two lists is one result.
140
+ const key = `${entry.screen}|${JSON.stringify((_entry_params = entry.params) != null ? _entry_params : {})}`;
141
+ if (seen.has(key) || entry.screen === 'shell.home') continue;
142
+ const score = scoreMatch({
143
+ name: entry.label,
144
+ extra: entry.keywords
145
+ }, text);
146
+ if (score === null) continue;
147
+ seen.add(key);
148
+ out.push({
149
+ score,
150
+ row: _extends({
151
+ group: ONE_BOX_SCREENS_GROUP,
152
+ id: entry.id,
153
+ label: entry.label,
154
+ detail: entry.label === row.label ? where : `${where} · ${row.label}`,
155
+ screen: entry.screen
156
+ }, entry.params ? {
157
+ params: entry.params
158
+ } : {})
159
+ });
160
+ }
161
+ }
162
+ }
163
+ }
164
+ return out;
165
+ }
166
+
167
+ //# sourceMappingURL=one-box-search.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/one-box-search.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * THE ONE BOX: SWITCHING AND SEARCH IN ONE PLACE (AGL-3943, Zach 2026-10-18).\n *\n * ⌘K in the native apps opens one box that finds workspaces, sites, pages,\n * orders, contacts, emails, components, layouts, templates and every plugin's\n * records together, plus the app's own screens and settings (\"billing\" finds\n * Plan). The apps ask `GET /api/search` (`ONE_BOX_SEARCH_PATH`) with the\n * person's ID token and get grouped rows back; this module is that payload,\n * the paging rules, and the screens-and-settings group, which the apps\n * answer themselves from the nav manifest, with no read at all.\n *\n * Request query: `q` (at least two characters), `orgId` and `hostId` (the\n * scope the person stands in; both optional), `limit` (rows per group,\n * default 5, at most 25), and `group` + `cursor` to page one group further\n * (\"Show more\"). Who may see what is decided by the route, never by these.\n *\n * Imports only the plain modules beside it, so the native apps read the\n * types and the screens-and-settings answers through the generated contracts.\n */\n\nimport { compareScored, isSearchableQuery, scoreMatch } from './name-match'\nimport { resolveNavList, type NativeNavListId, type NativeNavManifest } from './native-nav'\n\n/** The console route the apps call. */\nexport const ONE_BOX_SEARCH_PATH = '/api/search'\n\n/** Rows per group when the request names no `limit`. */\nexport const ONE_BOX_ROWS_DEFAULT = 5\n\n/** The most rows one group answers in one response. */\nexport const ONE_BOX_ROWS_MAX = 25\n\n/** The longest query the route reads; the rest is dropped. */\nexport const ONE_BOX_QUERY_MAX = 100\n\n/** The group of workspaces the person belongs to: the switcher's half. */\nexport const ONE_BOX_WORKSPACES_GROUP = 'workspaces'\n\n/**\n * The group of the app's own screens and settings. The apps answer it\n * themselves, with no read, from the assembled manifest\n * (`searchNavScreens(ContractValues.nativeNavManifest, …)`), and show it in\n * the one box beside the route's groups.\n */\nexport const ONE_BOX_SCREENS_GROUP = 'appScreens'\n\n/** One result. */\nexport interface OneBoxRow {\n /** The group it came from (`workspaces`, `sites`, `screens`, `orders`, `contacts`, …). */\n group: string\n /** The workspace id, site id, nav row id or record id. */\n id: string\n label: string\n /** One line under it: a slug, a buyer, the list a setting is in. */\n detail?: string\n /** The workspace it belongs to; a workspace row's own id. */\n orgId?: string\n /** The site it belongs to; a site row's own id. */\n hostId?: string\n /**\n * The console path it opens at. The apps resolve it through their deep-link\n * grammar, as they resolve a notification's link, so a record opens on its\n * native screen and a Besigner page in the in-app Besigner.\n */\n link?: string\n /** For a screens-and-settings row: the native screen and its params. */\n screen?: string\n params?: Record<string, string>\n}\n\n/** One kind of result. */\nexport interface OneBoxGroup {\n group: string\n /** The heading above its rows. */\n heading: string\n /** The kind in a sentence (\"pages\"). */\n noun: string\n rows: OneBoxRow[]\n /** How many matched in what was searched, of which `rows` is a page. */\n matched: number\n /** How many records were matched against. */\n searched: number\n /** The group filled its window, so it may hold matches that were not read. */\n truncated: boolean\n /** The group could not be read. Never the same as matching nothing. */\n failed: boolean\n /** Pass back as `cursor` with `group` for the next page, absent at the end. */\n nextCursor?: number\n}\n\nexport interface OneBoxResponse {\n /** The query as it was read. */\n query: string\n orgId?: string\n hostId?: string\n /** Groups in the order they are shown. */\n groups: OneBoxGroup[]\n}\n\n/** How many rows a request asked for, within bounds. */\nexport function oneBoxLimit(raw: unknown): number {\n const asked = typeof raw === 'string' ? Number(raw) : typeof raw === 'number' ? raw : NaN\n if (!Number.isFinite(asked) || asked < 1) return ONE_BOX_ROWS_DEFAULT\n return Math.min(ONE_BOX_ROWS_MAX, Math.floor(asked))\n}\n\n/** Where a page starts, within bounds. */\nexport function oneBoxCursor(raw: unknown): number {\n const asked = typeof raw === 'string' ? Number(raw) : typeof raw === 'number' ? raw : NaN\n return Number.isFinite(asked) && asked > 0 ? Math.floor(asked) : 0\n}\n\n/** The query as the route reads it: trimmed, capped, or null when too short to run. */\nexport function oneBoxQuery(raw: unknown): string | null {\n const text = typeof raw === 'string' ? raw.trim().slice(0, ONE_BOX_QUERY_MAX) : ''\n return isSearchableQuery(text) ? text : null\n}\n\n/** A matched row before paging, with its score. */\nexport interface OneBoxScored {\n row: OneBoxRow\n score: number\n}\n\n/**\n * One group's page: every match ranked the console's way (score, then the\n * shorter name, then A to Z), cut at `cursor` for `limit` rows.\n */\nexport function pageOneBoxGroup(\n meta: Pick<OneBoxGroup, 'group' | 'heading' | 'noun' | 'searched' | 'truncated' | 'failed'>,\n scored: readonly OneBoxScored[],\n cursor: number,\n limit: number,\n): OneBoxGroup {\n const ranked = [...scored].sort((a, b) =>\n compareScored({ score: a.score, label: a.row.label }, { score: b.score, label: b.row.label }),\n )\n const end = cursor + limit\n return {\n ...meta,\n rows: ranked.slice(cursor, end).map((entry) => entry.row),\n matched: ranked.length,\n ...(end < ranked.length ? { nextCursor: end } : {}),\n }\n}\n\nconst LIST_HEADINGS: Record<NativeNavListId, string> = {\n workspace: 'Workspace',\n site: 'Site',\n workspaceSettings: 'Workspace settings',\n siteSettings: 'Site settings',\n}\n\n/**\n * The screens-and-settings matches for where the person stands, from the\n * assembled manifest (`ContractValues.nativeNavManifest` in the apps): the\n * workspace's lists with no site, the site's lists (and the workspace\n * settings) with one. `enabledPluginIds` is the effective set for that\n * scope, so a screen of a plugin that is off is never offered.\n */\nexport function searchNavScreens(\n manifest: NativeNavManifest,\n text: string,\n scope: 'account' | 'workspace' | 'site',\n enabledPluginIds: readonly string[],\n hostAdmin: boolean,\n): OneBoxScored[] {\n if (!isSearchableQuery(text) || scope === 'account') return []\n const lists: NativeNavListId[] =\n scope === 'site' ? ['site', 'siteSettings', 'workspaceSettings'] : ['workspace', 'workspaceSettings']\n const seen = new Set<string>()\n const out: OneBoxScored[] = []\n for (const listId of lists) {\n const list = resolveNavList(manifest, listId, enabledPluginIds, hostAdmin)\n for (const group of list.groups) {\n for (const row of group.rows) {\n const where = group.label ? `${LIST_HEADINGS[listId]} · ${group.label}` : LIST_HEADINGS[listId]\n const entries = [\n {\n id: `${listId}.${row.id}`,\n label: row.label,\n screen: row.screen,\n params: row.params,\n keywords: [...(row.keywords ?? []), group.label ?? ''],\n },\n ...(row.sections ?? [])\n .filter((section) => section.label !== row.label)\n .map((section) => ({\n id: `${listId}.${row.id}.${section.id}`,\n label: section.label,\n screen: section.screen,\n params: section.params,\n keywords: [row.label, group.label ?? ''],\n })),\n ]\n for (const entry of entries) {\n // The same screen reached from two lists is one result.\n const key = `${entry.screen}|${JSON.stringify(entry.params ?? {})}`\n if (seen.has(key) || entry.screen === 'shell.home') continue\n const score = scoreMatch({ name: entry.label, extra: entry.keywords }, text)\n if (score === null) continue\n seen.add(key)\n out.push({\n score,\n row: {\n group: ONE_BOX_SCREENS_GROUP,\n id: entry.id,\n label: entry.label,\n detail: entry.label === row.label ? where : `${where} · ${row.label}`,\n screen: entry.screen,\n ...(entry.params ? { params: entry.params } : {}),\n },\n })\n }\n }\n }\n }\n return out\n}\n"],"names":["compareScored","isSearchableQuery","scoreMatch","resolveNavList","ONE_BOX_SEARCH_PATH","ONE_BOX_ROWS_DEFAULT","ONE_BOX_ROWS_MAX","ONE_BOX_QUERY_MAX","ONE_BOX_WORKSPACES_GROUP","ONE_BOX_SCREENS_GROUP","oneBoxLimit","raw","asked","Number","NaN","isFinite","Math","min","floor","oneBoxCursor","oneBoxQuery","text","trim","slice","pageOneBoxGroup","meta","scored","cursor","limit","ranked","sort","a","b","score","label","row","end","rows","map","entry","matched","length","nextCursor","LIST_HEADINGS","workspace","site","workspaceSettings","siteSettings","searchNavScreens","manifest","scope","enabledPluginIds","hostAdmin","lists","seen","Set","out","listId","list","group","groups","where","entries","id","screen","params","keywords","sections","filter","section","key","JSON","stringify","has","name","extra","add","push","detail"],"mappings":"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"}
@@ -172,6 +172,27 @@ const DAY = 24 * 60;
172
172
  dedupeWindowMinutes: 7 * DAY,
173
173
  defaultEnabled: true
174
174
  },
175
+ {
176
+ // The scheduled reconciliation of Site Care extra time (AGL-3959): a
177
+ // request's billed hours and the Stripe invoice item that carries them
178
+ // disagree, or one exists without the other. Should know, not must:
179
+ // nothing is lost yet, the next invoice has not gone, and the fix is on
180
+ // the request. The same check compares a flat-quoted project's
181
+ // invoices (AGL-3970), whose findings arrive with `month` "projects".
182
+ type: 'billing.siteCareReconciliation',
183
+ label: 'Site Care billing does not match Stripe',
184
+ description: 'A Site Care request’s billed hours, amount or line description disagree with its Stripe invoice item, a Stripe item has no request row behind it, a month’s total disagrees with its statement, or a project invoice’s status or amount disagrees with Stripe (or exists in Stripe with no project row). Open the workspace’s staff page and correct it from the request or the project — never by hand in Stripe.',
185
+ tier: 'should',
186
+ category: 'billing',
187
+ title: 'Site Care billing mismatch on {{orgName}} ({{month}})',
188
+ body: '{{count}} Site Care finding(s) on workspace {{orgName}} ({{orgId}}) for {{month}}: {{findings}} Fix it from the request on the staff organization page.',
189
+ link: '/admin/orgs/{{orgId}}#site-care',
190
+ delivery: 'immediate',
191
+ // One alert a day per workspace and month: the same finding re-checked
192
+ // tomorrow is the same news.
193
+ dedupeWindowMinutes: DAY,
194
+ defaultEnabled: true
195
+ },
175
196
  {
176
197
  type: 'billing.webhookSignatureRejected',
177
198
  label: 'Billing webhook failing its signature check',