@salesforce/afv-skills 1.14.0 → 1.16.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.
- package/package.json +1 -1
- package/skills/activating-datacloud/SKILL.md +0 -1
- package/skills/analyzing-omnistudio-dependencies/SKILL.md +0 -1
- package/skills/applying-slds/SKILL.md +322 -0
- package/skills/applying-slds/checklists.md +83 -0
- package/skills/applying-slds/examples.md +283 -0
- package/skills/applying-slds/guidance/README.md +83 -0
- package/skills/applying-slds/guidance/blueprints-index.md +213 -0
- package/skills/applying-slds/guidance/icons-guidance.md +186 -0
- package/skills/applying-slds/guidance/overviews/borders.md +236 -0
- package/skills/applying-slds/guidance/overviews/color.md +266 -0
- package/skills/applying-slds/guidance/overviews/display-density.md +366 -0
- package/skills/applying-slds/guidance/overviews/icons.md +240 -0
- package/skills/applying-slds/guidance/overviews/illustrations.md +235 -0
- package/skills/applying-slds/guidance/overviews/shadows.md +176 -0
- package/skills/applying-slds/guidance/overviews/spacing.md +216 -0
- package/skills/applying-slds/guidance/overviews/typography.md +323 -0
- package/skills/applying-slds/guidance/overviews/utilities.md +542 -0
- package/skills/applying-slds/guidance/slds-development-guide.md +288 -0
- package/skills/applying-slds/guidance/styling-hooks/borders.md +202 -0
- package/skills/applying-slds/guidance/styling-hooks/color/expressive-palette-hooks.md +153 -0
- package/skills/applying-slds/guidance/styling-hooks/color/index.md +171 -0
- package/skills/applying-slds/guidance/styling-hooks/color/semantic/accent-hooks.md +204 -0
- package/skills/applying-slds/guidance/styling-hooks/color/semantic/feedback-hooks.md +768 -0
- package/skills/applying-slds/guidance/styling-hooks/color/semantic/surface-hooks.md +337 -0
- package/skills/applying-slds/guidance/styling-hooks/color/system-hooks.md +132 -0
- package/skills/applying-slds/guidance/styling-hooks/index.md +327 -0
- package/skills/applying-slds/guidance/styling-hooks/shadows.md +238 -0
- package/skills/applying-slds/guidance/styling-hooks/spacing.md +254 -0
- package/skills/applying-slds/guidance/styling-hooks/typography.md +448 -0
- package/skills/applying-slds/guidance/utilities/alignment.md +119 -0
- package/skills/applying-slds/guidance/utilities/borders.md +131 -0
- package/skills/applying-slds/guidance/utilities/box.md +125 -0
- package/skills/applying-slds/guidance/utilities/color.md +165 -0
- package/skills/applying-slds/guidance/utilities/dark-mode.md +111 -0
- package/skills/applying-slds/guidance/utilities/description-list.md +168 -0
- package/skills/applying-slds/guidance/utilities/floats.md +117 -0
- package/skills/applying-slds/guidance/utilities/grid.md +264 -0
- package/skills/applying-slds/guidance/utilities/horizontal-list.md +110 -0
- package/skills/applying-slds/guidance/utilities/hyphenation.md +84 -0
- package/skills/applying-slds/guidance/utilities/index.md +205 -0
- package/skills/applying-slds/guidance/utilities/interactions.md +89 -0
- package/skills/applying-slds/guidance/utilities/layout.md +109 -0
- package/skills/applying-slds/guidance/utilities/line-clamp.md +131 -0
- package/skills/applying-slds/guidance/utilities/margin.md +155 -0
- package/skills/applying-slds/guidance/utilities/media-object.md +161 -0
- package/skills/applying-slds/guidance/utilities/name-value-list.md +152 -0
- package/skills/applying-slds/guidance/utilities/padding.md +155 -0
- package/skills/applying-slds/guidance/utilities/position.md +177 -0
- package/skills/applying-slds/guidance/utilities/print.md +114 -0
- package/skills/applying-slds/guidance/utilities/scrollable.md +126 -0
- package/skills/applying-slds/guidance/utilities/sizing.md +190 -0
- package/skills/applying-slds/guidance/utilities/themes.md +121 -0
- package/skills/applying-slds/guidance/utilities/truncate.md +127 -0
- package/skills/applying-slds/guidance/utilities/typography.md +166 -0
- package/skills/applying-slds/guidance/utilities/vertical-list.md +166 -0
- package/skills/applying-slds/guidance/utilities/visibility.md +228 -0
- package/skills/applying-slds/metadata/README.md +84 -0
- package/skills/applying-slds/metadata/blueprints/components/accordion.yaml +304 -0
- package/skills/applying-slds/metadata/blueprints/components/activity-timeline.yaml +92 -0
- package/skills/applying-slds/metadata/blueprints/components/alert.yaml +103 -0
- package/skills/applying-slds/metadata/blueprints/components/app-launcher.yaml +94 -0
- package/skills/applying-slds/metadata/blueprints/components/avatar-group.yaml +81 -0
- package/skills/applying-slds/metadata/blueprints/components/avatar.yaml +97 -0
- package/skills/applying-slds/metadata/blueprints/components/badges.yaml +102 -0
- package/skills/applying-slds/metadata/blueprints/components/brand-band.yaml +198 -0
- package/skills/applying-slds/metadata/blueprints/components/breadcrumbs.yaml +95 -0
- package/skills/applying-slds/metadata/blueprints/components/builder-header.yaml +192 -0
- package/skills/applying-slds/metadata/blueprints/components/button-groups.yaml +82 -0
- package/skills/applying-slds/metadata/blueprints/components/button-icons.yaml +295 -0
- package/skills/applying-slds/metadata/blueprints/components/buttons.yaml +230 -0
- package/skills/applying-slds/metadata/blueprints/components/cards.yaml +124 -0
- package/skills/applying-slds/metadata/blueprints/components/carousel.yaml +140 -0
- package/skills/applying-slds/metadata/blueprints/components/chat.yaml +179 -0
- package/skills/applying-slds/metadata/blueprints/components/checkbox-button-group.yaml +192 -0
- package/skills/applying-slds/metadata/blueprints/components/checkbox-button.yaml +204 -0
- package/skills/applying-slds/metadata/blueprints/components/checkbox-toggle.yaml +177 -0
- package/skills/applying-slds/metadata/blueprints/components/checkbox.yaml +108 -0
- package/skills/applying-slds/metadata/blueprints/components/color-picker.yaml +172 -0
- package/skills/applying-slds/metadata/blueprints/components/combobox.yaml +136 -0
- package/skills/applying-slds/metadata/blueprints/components/counter.yaml +147 -0
- package/skills/applying-slds/metadata/blueprints/components/data-tables.yaml +157 -0
- package/skills/applying-slds/metadata/blueprints/components/datepickers.yaml +130 -0
- package/skills/applying-slds/metadata/blueprints/components/datetime-picker.yaml +155 -0
- package/skills/applying-slds/metadata/blueprints/components/docked-composer.yaml +201 -0
- package/skills/applying-slds/metadata/blueprints/components/docked-form-footer.yaml +161 -0
- package/skills/applying-slds/metadata/blueprints/components/docked-utility-bar.yaml +175 -0
- package/skills/applying-slds/metadata/blueprints/components/drop-zone.yaml +115 -0
- package/skills/applying-slds/metadata/blueprints/components/dueling-picklist.yaml +196 -0
- package/skills/applying-slds/metadata/blueprints/components/dynamic-icons.yaml +128 -0
- package/skills/applying-slds/metadata/blueprints/components/dynamic-menu.yaml +141 -0
- package/skills/applying-slds/metadata/blueprints/components/expandable-section.yaml +115 -0
- package/skills/applying-slds/metadata/blueprints/components/expression.yaml +143 -0
- package/skills/applying-slds/metadata/blueprints/components/feeds.yaml +125 -0
- package/skills/applying-slds/metadata/blueprints/components/file-selector.yaml +154 -0
- package/skills/applying-slds/metadata/blueprints/components/files.yaml +119 -0
- package/skills/applying-slds/metadata/blueprints/components/form-element.yaml +145 -0
- package/skills/applying-slds/metadata/blueprints/components/global-header.yaml +120 -0
- package/skills/applying-slds/metadata/blueprints/components/global-navigation.yaml +100 -0
- package/skills/applying-slds/metadata/blueprints/components/icons.yaml +138 -0
- package/skills/applying-slds/metadata/blueprints/components/illustration.yaml +205 -0
- package/skills/applying-slds/metadata/blueprints/components/input.yaml +151 -0
- package/skills/applying-slds/metadata/blueprints/components/list-builder.yaml +127 -0
- package/skills/applying-slds/metadata/blueprints/components/lookups.yaml +132 -0
- package/skills/applying-slds/metadata/blueprints/components/map.yaml +118 -0
- package/skills/applying-slds/metadata/blueprints/components/menus.yaml +134 -0
- package/skills/applying-slds/metadata/blueprints/components/modals.yaml +152 -0
- package/skills/applying-slds/metadata/blueprints/components/notifications.yaml +88 -0
- package/skills/applying-slds/metadata/blueprints/components/page-headers.yaml +135 -0
- package/skills/applying-slds/metadata/blueprints/components/panels.yaml +149 -0
- package/skills/applying-slds/metadata/blueprints/components/path.yaml +154 -0
- package/skills/applying-slds/metadata/blueprints/components/picklist.yaml +125 -0
- package/skills/applying-slds/metadata/blueprints/components/pills.yaml +154 -0
- package/skills/applying-slds/metadata/blueprints/components/popovers.yaml +120 -0
- package/skills/applying-slds/metadata/blueprints/components/progress-bar.yaml +110 -0
- package/skills/applying-slds/metadata/blueprints/components/progress-indicator.yaml +133 -0
- package/skills/applying-slds/metadata/blueprints/components/progress-ring.yaml +102 -0
- package/skills/applying-slds/metadata/blueprints/components/prompt.yaml +126 -0
- package/skills/applying-slds/metadata/blueprints/components/publishers.yaml +178 -0
- package/skills/applying-slds/metadata/blueprints/components/radio-button-group.yaml +172 -0
- package/skills/applying-slds/metadata/blueprints/components/radio-group.yaml +112 -0
- package/skills/applying-slds/metadata/blueprints/components/rich-text-editor.yaml +135 -0
- package/skills/applying-slds/metadata/blueprints/components/scoped-notifications.yaml +188 -0
- package/skills/applying-slds/metadata/blueprints/components/scoped-tabs.yaml +97 -0
- package/skills/applying-slds/metadata/blueprints/components/select.yaml +127 -0
- package/skills/applying-slds/metadata/blueprints/components/setup-assistant.yaml +152 -0
- package/skills/applying-slds/metadata/blueprints/components/slider.yaml +111 -0
- package/skills/applying-slds/metadata/blueprints/components/spinners.yaml +135 -0
- package/skills/applying-slds/metadata/blueprints/components/split-view.yaml +112 -0
- package/skills/applying-slds/metadata/blueprints/components/summary-detail.yaml +103 -0
- package/skills/applying-slds/metadata/blueprints/components/tabs.yaml +138 -0
- package/skills/applying-slds/metadata/blueprints/components/textarea.yaml +116 -0
- package/skills/applying-slds/metadata/blueprints/components/tiles.yaml +108 -0
- package/skills/applying-slds/metadata/blueprints/components/timepicker.yaml +111 -0
- package/skills/applying-slds/metadata/blueprints/components/toast.yaml +154 -0
- package/skills/applying-slds/metadata/blueprints/components/tooltips.yaml +107 -0
- package/skills/applying-slds/metadata/blueprints/components/tree-grid.yaml +116 -0
- package/skills/applying-slds/metadata/blueprints/components/trees.yaml +116 -0
- package/skills/applying-slds/metadata/blueprints/components/trial-bar.yaml +112 -0
- package/skills/applying-slds/metadata/blueprints/components/vertical-navigation.yaml +130 -0
- package/skills/applying-slds/metadata/blueprints/components/vertical-tabs.yaml +140 -0
- package/skills/applying-slds/metadata/blueprints/components/visual-picker.yaml +150 -0
- package/skills/applying-slds/metadata/blueprints/components/welcome-mat.yaml +136 -0
- package/skills/applying-slds/metadata/hooks-index.json +6272 -0
- package/skills/applying-slds/metadata/icon-metadata.json +38466 -0
- package/skills/applying-slds/metadata/utilities-index.json +21912 -0
- package/skills/applying-slds/references/component-selection.md +112 -0
- package/skills/applying-slds/references/icons-decision-guide.md +124 -0
- package/skills/applying-slds/references/styling-decision-guide.md +228 -0
- package/skills/applying-slds/references/utilities-quick-ref.md +125 -0
- package/skills/applying-slds/scripts/search-blueprints.cjs +117 -0
- package/skills/applying-slds/scripts/search-hooks.cjs +139 -0
- package/skills/applying-slds/scripts/search-icons.cjs +174 -0
- package/skills/applying-slds/scripts/search-utilities.cjs +161 -0
- package/skills/building-mobile-apps/SKILL.md +0 -1
- package/skills/building-omnistudio-callable-apex/SKILL.md +0 -1
- package/skills/building-omnistudio-datamapper/SKILL.md +0 -1
- package/skills/building-omnistudio-flexcard/SKILL.md +0 -1
- package/skills/building-omnistudio-integration-procedure/SKILL.md +0 -1
- package/skills/building-omnistudio-omniscript/SKILL.md +0 -1
- package/skills/building-sf-integrations/SKILL.md +0 -1
- package/skills/configuring-connected-apps/SKILL.md +0 -1
- package/skills/connecting-datacloud/SKILL.md +0 -1
- package/skills/creating-b2b-commerce-store/SKILL.md +0 -1
- package/skills/debugging-apex-logs/SKILL.md +0 -1
- package/skills/deploying-metadata/SKILL.md +0 -1
- package/skills/deploying-omnistudio-datapacks/SKILL.md +0 -1
- package/skills/developing-agentforce/SKILL.md +0 -1
- package/skills/fetching-salesforce-docs/SKILL.md +0 -1
- package/skills/generating-custom-lightning-type/SKILL.md +17 -39
- package/skills/generating-custom-lightning-type/assets/primitive-types-and-constraints.md +41 -0
- package/skills/generating-custom-lightning-type/references/widget-rendition.md +124 -0
- package/skills/generating-lwc-components/SKILL.md +0 -1
- package/skills/generating-mermaid-diagrams/SKILL.md +0 -1
- package/skills/generating-visual-diagrams/SKILL.md +0 -1
- package/skills/handling-sf-data/SKILL.md +0 -1
- package/skills/harmonizing-datacloud/SKILL.md +0 -1
- package/skills/integrating-b2b-commerce-open-code-components/SKILL.md +0 -1
- package/skills/investigating-agentforce-architecture/README.md +156 -0
- package/skills/investigating-agentforce-architecture/SKILL.md +230 -0
- package/skills/investigating-agentforce-architecture/assets/cli/describe_sobject.yaml +16 -0
- package/skills/investigating-agentforce-architecture/assets/cli/describe_tooling_sobject.yaml +17 -0
- package/skills/investigating-agentforce-architecture/assets/cli/list_metadata_genaiprompttemplate.yaml +17 -0
- package/skills/investigating-agentforce-architecture/assets/cli/org_display.yaml +15 -0
- package/skills/investigating-agentforce-architecture/assets/cli/retrieve_genai_plugin.yaml +18 -0
- package/skills/investigating-agentforce-architecture/assets/cli/show_access_token.yaml +27 -0
- package/skills/investigating-agentforce-architecture/assets/mermaid/action_tree.mmd +20 -0
- package/skills/investigating-agentforce-architecture/assets/mermaid/data_flow.mmd +19 -0
- package/skills/investigating-agentforce-architecture/assets/mermaid/dependency_graph.mmd +19 -0
- package/skills/investigating-agentforce-architecture/assets/mermaid/invocation_sequence.mmd +20 -0
- package/skills/investigating-agentforce-architecture/assets/mermaid/planner_state.mmd +18 -0
- package/skills/investigating-agentforce-architecture/assets/soql/apex_class_bodies_by_ids.soql +3 -0
- package/skills/investigating-agentforce-architecture/assets/soql/apex_class_bodies_by_names.soql +3 -0
- package/skills/investigating-agentforce-architecture/assets/soql/bot_definition_details.soql +3 -0
- package/skills/investigating-agentforce-architecture/assets/soql/bot_version_lookup.soql +4 -0
- package/skills/investigating-agentforce-architecture/assets/soql/flow_definition_by_ids.soql +3 -0
- package/skills/investigating-agentforce-architecture/assets/soql/flow_definition_ids_by_names.soql +3 -0
- package/skills/investigating-agentforce-architecture/assets/soql/flow_definition_view_by_durable_ids.soql +4 -0
- package/skills/investigating-agentforce-architecture/assets/soql/flow_metadata_by_id.soql +3 -0
- package/skills/investigating-agentforce-architecture/assets/soql/functions_by_plugins.soql +5 -0
- package/skills/investigating-agentforce-architecture/assets/soql/planner_attrs_by_parent_ids.soql +3 -0
- package/skills/investigating-agentforce-architecture/assets/soql/planner_bundle_functions.soql +3 -0
- package/skills/investigating-agentforce-architecture/assets/soql/planner_definition_by_agent_chain.soql +3 -0
- package/skills/investigating-agentforce-architecture/assets/soql/plugin_functions_by_plugin_ids.soql +3 -0
- package/skills/investigating-agentforce-architecture/assets/soql/plugin_instructions_by_plugin_ids.soql +3 -0
- package/skills/investigating-agentforce-architecture/assets/soql/plugins_by_planner.soql +4 -0
- package/skills/investigating-agentforce-architecture/references/architecture_sections.md +243 -0
- package/skills/investigating-agentforce-architecture/references/contract.json +244 -0
- package/skills/investigating-agentforce-architecture/references/soql_fields.md +512 -0
- package/skills/investigating-agentforce-architecture/scripts/_shared/__init__.py +1 -0
- package/skills/investigating-agentforce-architecture/scripts/_shared/fs_guard.py +329 -0
- package/skills/investigating-agentforce-architecture/scripts/_shared/paths.py +110 -0
- package/skills/investigating-agentforce-architecture/scripts/_shared/runtime.py +59 -0
- package/skills/investigating-agentforce-architecture/scripts/_shared/sql.py +10 -0
- package/skills/investigating-agentforce-architecture/scripts/cache_check.py +234 -0
- package/skills/investigating-agentforce-architecture/scripts/config.py +131 -0
- package/skills/investigating-agentforce-architecture/scripts/fetch_soql.py +689 -0
- package/skills/investigating-agentforce-architecture/scripts/finalize.py +295 -0
- package/skills/investigating-agentforce-architecture/scripts/main.py +2835 -0
- package/skills/investigating-agentforce-architecture/scripts/metadata_listing.py +265 -0
- package/skills/investigating-agentforce-architecture/scripts/parallel_retrieve.py +69 -0
- package/skills/investigating-agentforce-architecture/scripts/parse_bundle.py +215 -0
- package/skills/investigating-agentforce-architecture/scripts/parse_wave.py +845 -0
- package/skills/investigating-agentforce-architecture/scripts/probe_channels.py +302 -0
- package/skills/investigating-agentforce-architecture/scripts/render_architecture.py +1043 -0
- package/skills/investigating-agentforce-architecture/scripts/resolve_bot.py +255 -0
- package/skills/investigating-agentforce-architecture/scripts/resolve_invocation_target.py +130 -0
- package/skills/investigating-agentforce-architecture/scripts/rest_client.py +763 -0
- package/skills/investigating-agentforce-architecture/scripts/retrieve_planner.py +13 -0
- package/skills/investigating-agentforce-architecture/scripts/sf_cli.py +242 -0
- package/skills/investigating-agentforce-architecture/scripts/soql_loader.py +253 -0
- package/skills/investigating-agentforce-architecture/scripts/summarize_tree.py +143 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/__init__.py +0 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/_bootstrap.py +23 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/fixtures/__init__.py +0 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/fixtures/genai_payloads.py +400 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_cache_check.py +307 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_cache_check_main.py +283 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_config.py +115 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_end_to_end_fixture.py +651 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_finalize.py +278 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_flow_children_inflation.py +582 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_fs_guard.py +113 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_iterative_wave_b.py +478 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_main_pipeline.py +3359 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_parallel_retrieve.py +131 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_parse_bundle.py +400 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_parse_wave.py +644 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_parse_wave_classifiers.py +224 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_parse_wave_helpers.py +380 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_parse_wave_main.py +397 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_per_branch_visited.py +244 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_probe_channels.py +359 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_probe_cli_recipes.py +185 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_render_architecture.py +810 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_resolve_bot.py +203 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_resolve_creds.py +157 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_resolve_invocation_target.py +145 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_rest_client.py +1253 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_runtime_override.py +100 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_sf_cli.py +261 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_signature_stamping.py +466 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_soql_loader.py +501 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_summarize_tree.py +241 -0
- package/skills/investigating-agentforce-architecture/scripts/tests/test_write_emit_ctx.py +480 -0
- package/skills/investigating-agentforce-architecture/tools/emit_env.py +157 -0
- package/skills/investigating-agentforce-architecture/tools/emit_result.py +262 -0
- package/skills/investigating-agentforce-architecture/tools/sanitize.py +33 -0
- package/skills/investigating-agentforce-architecture/tools/write_emit_ctx.py +332 -0
- package/skills/investigating-agentforce-d360/README.md +123 -0
- package/skills/investigating-agentforce-d360/SKILL.md +163 -0
- package/skills/investigating-agentforce-d360/assets/dc/app_generation.sql +51 -0
- package/skills/investigating-agentforce-d360/assets/dc/content_category.sql +44 -0
- package/skills/investigating-agentforce-d360/assets/dc/content_quality.sql +41 -0
- package/skills/investigating-agentforce-d360/assets/dc/discover_sessions.sql +36 -0
- package/skills/investigating-agentforce-d360/assets/dc/feedback.sql +47 -0
- package/skills/investigating-agentforce-d360/assets/dc/feedback_details.sql +38 -0
- package/skills/investigating-agentforce-d360/assets/dc/gateway_records.sql +45 -0
- package/skills/investigating-agentforce-d360/assets/dc/gateway_request_llm.sql +50 -0
- package/skills/investigating-agentforce-d360/assets/dc/gateway_request_metadata.sql +44 -0
- package/skills/investigating-agentforce-d360/assets/dc/gateway_request_tags.sql +42 -0
- package/skills/investigating-agentforce-d360/assets/dc/gateway_requests.sql +89 -0
- package/skills/investigating-agentforce-d360/assets/dc/gateway_responses.sql +43 -0
- package/skills/investigating-agentforce-d360/assets/dc/generations.sql +52 -0
- package/skills/investigating-agentforce-d360/assets/dc/interactions.sql +53 -0
- package/skills/investigating-agentforce-d360/assets/dc/messages.sql +53 -0
- package/skills/investigating-agentforce-d360/assets/dc/messaging_session.sql +37 -0
- package/skills/investigating-agentforce-d360/assets/dc/moment_interactions.sql +34 -0
- package/skills/investigating-agentforce-d360/assets/dc/moments.sql +39 -0
- package/skills/investigating-agentforce-d360/assets/dc/participants.sql +48 -0
- package/skills/investigating-agentforce-d360/assets/dc/sessions.sql +78 -0
- package/skills/investigating-agentforce-d360/assets/dc/steps.sql +64 -0
- package/skills/investigating-agentforce-d360/assets/dc/tag_associations.sql +46 -0
- package/skills/investigating-agentforce-d360/assets/dc/tag_definition_associations.sql +37 -0
- package/skills/investigating-agentforce-d360/assets/dc/tag_definitions.sql +50 -0
- package/skills/investigating-agentforce-d360/assets/dc/tags.sql +37 -0
- package/skills/investigating-agentforce-d360/assets/dc/telemetry_spans.sql +55 -0
- package/skills/investigating-agentforce-d360/references/artifacts.md +50 -0
- package/skills/investigating-agentforce-d360/references/dc_dmo_fields.md +823 -0
- package/skills/investigating-agentforce-d360/references/dc_pipeline_contract.md +608 -0
- package/skills/investigating-agentforce-d360/scripts/_shared/__init__.py +2 -0
- package/skills/investigating-agentforce-d360/scripts/_shared/cli_override.py +98 -0
- package/skills/investigating-agentforce-d360/scripts/_shared/fs_guard.py +334 -0
- package/skills/investigating-agentforce-d360/scripts/_shared/paths.py +155 -0
- package/skills/investigating-agentforce-d360/scripts/_shared/runtime.py +59 -0
- package/skills/investigating-agentforce-d360/scripts/_shared/sql.py +14 -0
- package/skills/investigating-agentforce-d360/scripts/assemble_dc.py +1624 -0
- package/skills/investigating-agentforce-d360/scripts/config.py +45 -0
- package/skills/investigating-agentforce-d360/scripts/dc.py +188 -0
- package/skills/investigating-agentforce-d360/scripts/discover_sessions.py +556 -0
- package/skills/investigating-agentforce-d360/scripts/fetch_dc.py +1045 -0
- package/skills/investigating-agentforce-d360/scripts/render_dc.py +1750 -0
- package/skills/investigating-agentforce-d360/scripts/resolve_session.py +264 -0
- package/skills/investigating-agentforce-d360/scripts/storage.py +92 -0
- package/skills/investigating-agentforce-d360/scripts/tests/__init__.py +0 -0
- package/skills/investigating-agentforce-d360/scripts/tests/_bootstrap.py +15 -0
- package/skills/investigating-agentforce-d360/scripts/tests/fixtures/__init__.py +0 -0
- package/skills/investigating-agentforce-d360/scripts/tests/fixtures/synthetic_session.py +424 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_assemble_dc_bootstrap_and_mode.py +115 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_assemble_dc_gateway_direct.py +220 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_assemble_dc_gateway_direct_integration.py +158 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_assemble_dc_helpers.py +287 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_assemble_dc_integration.py +247 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_dc_and_resolve_session.py +433 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_discover_sessions.py +458 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_discover_sessions_grep_ci.py +193 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_fetch_dc_helpers.py +266 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_fetch_dc_identity.py +528 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_fetch_dc_main.py +251 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_fetch_dc_waterfall.py +229 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_fetch_dc_waterfall_full.py +283 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_identity_coherence.py +327 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_render_dc_branches.py +256 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_render_dc_gateway_direct.py +130 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_render_dc_helpers.py +291 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_render_dc_integration.py +220 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_render_dc_planner_llm_calls.py +284 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_render_dc_show_prompts_gating.py +215 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_resolve_from_disk.py +100 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_resolve_session_main.py +149 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_runtime_override.py +104 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_session_shape.py +95 -0
- package/skills/investigating-agentforce-d360/scripts/tests/test_session_shape_dropped_by_stdm.py +85 -0
- package/skills/managing-managed-event-subscription/SKILL.md +152 -0
- package/skills/managing-managed-event-subscription/assets/managed-event-subscription-template.xml +20 -0
- package/skills/managing-managed-event-subscription/references/delete-guide.md +57 -0
- package/skills/managing-managed-event-subscription/references/topic-name-formats.md +26 -0
- package/skills/managing-managed-event-subscription/references/update-constraints.md +30 -0
- package/skills/modeling-omnistudio-epc-catalog/SKILL.md +0 -1
- package/skills/observing-agentforce/SKILL.md +0 -1
- package/skills/orchestrating-datacloud/SKILL.md +0 -1
- package/skills/preparing-datacloud/SKILL.md +0 -1
- package/skills/querying-soql/SKILL.md +0 -1
- package/skills/retrieving-datacloud/SKILL.md +0 -1
- package/skills/running-apex-tests/SKILL.md +0 -1
- package/skills/running-code-analyzer/SKILL.md +0 -1
- package/skills/segmenting-datacloud/SKILL.md +0 -1
- package/skills/testing-agentforce/SKILL.md +0 -1
- package/skills/uplifting-components-to-slds2/SKILL.md +3 -2
- package/skills/uplifting-components-to-slds2/references/color-hooks-decision-guide.md +30 -9
- package/skills/uplifting-components-to-slds2/references/examples.md +24 -6
- package/skills/validating-slds/SKILL.md +262 -0
- package/skills/validating-slds/references/quality-checks.md +308 -0
- package/skills/validating-slds/references/report-format.md +302 -0
- package/skills/validating-slds/scripts/analyze-quality.cjs +521 -0
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
"""Tooling + Data REST client scaffolding with security-critical primitives.
|
|
2
|
+
|
|
3
|
+
This module ships the HTTP primitives used by every REST-path caller in the
|
|
4
|
+
skill. Two security invariants are enforced here and must not be bypassed:
|
|
5
|
+
|
|
6
|
+
the Authorization header is STRIPPED from any cross-host redirect.
|
|
7
|
+
Python's default HTTPRedirectHandler blindly forwards all request headers
|
|
8
|
+
(including Authorization) to the redirect target. A compromised or
|
|
9
|
+
attacker-controlled edge that returns a 302 to an arbitrary host would
|
|
10
|
+
otherwise receive the bearer token. We subclass HTTPRedirectHandler to
|
|
11
|
+
strip Authorization whenever the redirect target hostname differs from
|
|
12
|
+
the original. Callers MUST use `build_opener()` — never `urllib.request.
|
|
13
|
+
urlopen` directly, which wires the default redirect handler.
|
|
14
|
+
|
|
15
|
+
access tokens MUST NEVER appear in exception strings, tracebacks,
|
|
16
|
+
or logged output. `redact_error(exc)` returns a safe string representation
|
|
17
|
+
with `Authorization: Bearer ...` scrubbed. Every `except` that surfaces
|
|
18
|
+
HTTP / subprocess error text runs the text through `redact_error` first.
|
|
19
|
+
Never call `log.exception()` on an object that carries request headers.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import email.utils
|
|
24
|
+
import functools
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import re
|
|
28
|
+
import time
|
|
29
|
+
import urllib.error
|
|
30
|
+
import urllib.parse
|
|
31
|
+
import urllib.request
|
|
32
|
+
from typing import Any, Callable, Tuple
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# stdlib logger for transient-HTTP backoff breadcrumbs. The skill has
|
|
36
|
+
# no global logging config — we emit at DEBUG and leave handler/level wiring
|
|
37
|
+
# to callers. Absent any config, Python's "last-resort" handler suppresses
|
|
38
|
+
# DEBUG records, so this is a no-op in production until someone enables it.
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RestClientError(RuntimeError):
|
|
43
|
+
"""REST request failed — always constructed with a redacted message."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# -----------------------------------------------------------------------------
|
|
47
|
+
# error redaction
|
|
48
|
+
# -----------------------------------------------------------------------------
|
|
49
|
+
# Three patterns cover the bulk of token-leakage surfaces:
|
|
50
|
+
# 1. `Authorization: Bearer <token>` in any string context (header echo,
|
|
51
|
+
# exception repr, stringified HTTP error).
|
|
52
|
+
# 2. `accessToken=<token>` / `access_token=<token>` in URL-encoded bodies
|
|
53
|
+
# or query strings (`sf org display` errors sometimes echo these).
|
|
54
|
+
# 3. `"accessToken":"<token>"` or `"access_token":"<token>"` in JSON
|
|
55
|
+
# payload echoes.
|
|
56
|
+
#
|
|
57
|
+
# Regexes are intentionally permissive on the token character class
|
|
58
|
+
# (anything non-whitespace, non-quote, non-ampersand) — we'd rather
|
|
59
|
+
# over-redact than miss a token with an unexpected encoding.
|
|
60
|
+
|
|
61
|
+
_AUTH_HEADER_RE = re.compile(
|
|
62
|
+
r"(Authorization\s*:\s*Bearer\s+)\S+",
|
|
63
|
+
flags=re.IGNORECASE,
|
|
64
|
+
)
|
|
65
|
+
# Matches access[_]?Token=<value> in url-encoded form; stops at & or whitespace.
|
|
66
|
+
_ACCESS_TOKEN_QS_RE = re.compile(
|
|
67
|
+
r"(access[_]?token\s*=\s*)[^&\s\"']+",
|
|
68
|
+
flags=re.IGNORECASE,
|
|
69
|
+
)
|
|
70
|
+
# Matches "access[_]?Token":"<value>" in JSON; stops at closing quote.
|
|
71
|
+
_ACCESS_TOKEN_JSON_RE = re.compile(
|
|
72
|
+
r"(\"access[_]?token\"\s*:\s*\")[^\"]*",
|
|
73
|
+
flags=re.IGNORECASE,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def redact_text(text: str) -> str:
|
|
78
|
+
"""Scrub bearer tokens and accessToken values from arbitrary text.
|
|
79
|
+
|
|
80
|
+
Pure function; no side effects. Used by `redact_error` and available
|
|
81
|
+
for use on any raw response body / stderr string before it reaches
|
|
82
|
+
a log or exception message.
|
|
83
|
+
|
|
84
|
+
renamed from `_redact_text` to a public symbol so
|
|
85
|
+
cross-module callers (sf_cli._redact_subprocess_stderr) import a stable
|
|
86
|
+
name instead of reaching into a module-private. The `_redact_text`
|
|
87
|
+
alias below is retained for backwards compatibility with any caller
|
|
88
|
+
that still imports the underscore-prefixed form; it will be removed
|
|
89
|
+
in a future batch once all callers migrate.
|
|
90
|
+
"""
|
|
91
|
+
if not text:
|
|
92
|
+
return text
|
|
93
|
+
text = _AUTH_HEADER_RE.sub(r"\1<redacted>", text)
|
|
94
|
+
text = _ACCESS_TOKEN_QS_RE.sub(r"\1<redacted>", text)
|
|
95
|
+
text = _ACCESS_TOKEN_JSON_RE.sub(r'\1<redacted>', text)
|
|
96
|
+
return text
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# deprecated alias. Retained so existing tests and any lingering
|
|
100
|
+
# `from rest_client import _redact_text` imports keep working. New code
|
|
101
|
+
# MUST use `redact_text` (public). Planned removal in a follow-up batch
|
|
102
|
+
# once the codebase is clean.
|
|
103
|
+
_redact_text = redact_text
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def redact_error(exc: BaseException) -> str:
|
|
107
|
+
"""Return a safe string representation of `exc`.
|
|
108
|
+
|
|
109
|
+
guaranteed to:
|
|
110
|
+
* include the exception type name (for triage)
|
|
111
|
+
* include the exception message WITH bearer tokens scrubbed
|
|
112
|
+
* NEVER include raw header collections, even if the exception carries them
|
|
113
|
+
|
|
114
|
+
For HTTPError specifically, we do NOT call `exc.read()` — the caller is
|
|
115
|
+
responsible for reading the body once (reading twice is usually a no-op
|
|
116
|
+
but we avoid the extra I/O and any token embedded in the body is scrubbed
|
|
117
|
+
at the caller via `redact_text` when it surfaces in the error message).
|
|
118
|
+
"""
|
|
119
|
+
exc_type = type(exc).__name__
|
|
120
|
+
try:
|
|
121
|
+
raw = str(exc)
|
|
122
|
+
except Exception:
|
|
123
|
+
raw = "<unreprable>"
|
|
124
|
+
return f"{exc_type}: {redact_text(raw)}"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# -----------------------------------------------------------------------------
|
|
128
|
+
# cross-host redirect strips Authorization
|
|
129
|
+
# -----------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class StripAuthOnCrossHostRedirect(urllib.request.HTTPRedirectHandler):
|
|
133
|
+
"""Strip Authorization header on any cross-host redirect.
|
|
134
|
+
|
|
135
|
+
Python's default HTTPRedirectHandler preserves request headers across
|
|
136
|
+
301/302/303/307/308. When `instanceUrl` is the trusted origin and a
|
|
137
|
+
redirect points at ANY other hostname, we treat the Authorization
|
|
138
|
+
header as tainted and drop it before the follow-up request goes out.
|
|
139
|
+
|
|
140
|
+
Hostname comparison is case-insensitive (DNS is case-insensitive).
|
|
141
|
+
We compare `urlparse(...).hostname` — which strips port and userinfo
|
|
142
|
+
— because a port change on the same host is NOT a credential-leak
|
|
143
|
+
vector, but treating it as cross-host would break legitimate
|
|
144
|
+
`:443` → bare-host redirects.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
148
|
+
orig_host = urllib.parse.urlparse(req.full_url).hostname
|
|
149
|
+
new_host = urllib.parse.urlparse(newurl).hostname
|
|
150
|
+
# Normalize for case-insensitive comparison. `None == None` is safe
|
|
151
|
+
# (both malformed URLs treated as "same host" — urllib would fail
|
|
152
|
+
# the follow-up anyway).
|
|
153
|
+
same_host = (orig_host or "").lower() == (new_host or "").lower()
|
|
154
|
+
|
|
155
|
+
if same_host:
|
|
156
|
+
# Default behavior: preserve Authorization (same-host redirect is
|
|
157
|
+
# standard practice; stripping would break legitimate OAuth flows).
|
|
158
|
+
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
159
|
+
|
|
160
|
+
# cross-host redirect — build a NEW Request without any
|
|
161
|
+
# Authorization header. We cannot mutate `req` in place because
|
|
162
|
+
# urllib's default handler reuses it; callers that retain a reference
|
|
163
|
+
# would observe the mutation.
|
|
164
|
+
new_req = super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
165
|
+
if new_req is None:
|
|
166
|
+
return None
|
|
167
|
+
# Authorization may have been set via add_header (lowercased internal
|
|
168
|
+
# storage as "Authorization") or via unredirected_hdrs. Remove both.
|
|
169
|
+
# Note: `Request.headers` stores header names in the form produced by
|
|
170
|
+
# `.capitalize()` (so "Authorization" lands as "Authorization" since
|
|
171
|
+
# .capitalize() leaves single-word strings unchanged at the first
|
|
172
|
+
# letter — but we defensively pop both casings).
|
|
173
|
+
for hdr_name in list(new_req.headers.keys()):
|
|
174
|
+
if hdr_name.lower() == "authorization":
|
|
175
|
+
new_req.headers.pop(hdr_name, None)
|
|
176
|
+
for hdr_name in list(new_req.unredirected_hdrs.keys()):
|
|
177
|
+
if hdr_name.lower() == "authorization":
|
|
178
|
+
new_req.unredirected_hdrs.pop(hdr_name, None)
|
|
179
|
+
return new_req
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def build_opener() -> urllib.request.OpenerDirector:
|
|
183
|
+
"""Return an OpenerDirector wired with StripAuthOnCrossHostRedirect.
|
|
184
|
+
|
|
185
|
+
every REST call in this module must go through an opener built
|
|
186
|
+
here. Direct use of `urllib.request.urlopen(...)` bypasses the redirect
|
|
187
|
+
handler and reintroduces the cross-host-token-leak vulnerability.
|
|
188
|
+
"""
|
|
189
|
+
return urllib.request.build_opener(StripAuthOnCrossHostRedirect())
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# -----------------------------------------------------------------------------
|
|
193
|
+
# 401 token refresh decorator + tooling/data query helpers
|
|
194
|
+
# -----------------------------------------------------------------------------
|
|
195
|
+
# Motivation: `sf org display` returns an access token with a short TTL
|
|
196
|
+
# (typically 15min–2h). A pipeline run with many Flow / Tooling fetches can
|
|
197
|
+
# exceed TTL. Without a refresh path the pipeline fails mid-run with a raw
|
|
198
|
+
# 401 and the user has to re-run from scratch.
|
|
199
|
+
#
|
|
200
|
+
# Design: `retry_on_401` is a decorator-factory that takes a `refresh_fn`
|
|
201
|
+
# closure. On 401 (HTTP status OR body contains INVALID_SESSION_ID) it
|
|
202
|
+
# invokes `refresh_fn()`, retries the wrapped call ONCE, and surfaces the
|
|
203
|
+
# original error if the retry also 401s. The refresh closure is passed
|
|
204
|
+
# per-call rather than stored globally so the client stays stateless and
|
|
205
|
+
# testable — each caller wires its own `lambda: run_sf("org_display", ...)`.
|
|
206
|
+
#
|
|
207
|
+
# Redaction: any error text that surfaces from this decorator runs through
|
|
208
|
+
# `redact_error` / `redact_text` . The 401 body is read once and
|
|
209
|
+
# scrubbed before being used for INVALID_SESSION_ID detection — we never
|
|
210
|
+
# log or re-raise raw bytes from the wire.
|
|
211
|
+
|
|
212
|
+
_INVALID_SESSION_MARKER = "INVALID_SESSION_ID"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
_ERROR_ENVELOPE_KEYS = ("errors", "error", "errorDetails", "messages")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _body_indicates_invalid_session(body: Any) -> bool:
|
|
219
|
+
"""detect `INVALID_SESSION_ID` in a parsed or raw response body.
|
|
220
|
+
|
|
221
|
+
Some SF endpoints (notably a few Tooling paths) return HTTP 200 with an
|
|
222
|
+
error body rather than 401. The documented SF error shape is a list of
|
|
223
|
+
`{"errorCode": "INVALID_SESSION_ID", "message": "..."}` dicts at the
|
|
224
|
+
body root.
|
|
225
|
+
|
|
226
|
+
BUGFIX (2026-05-03): the prior implementation recursed into ALL
|
|
227
|
+
`dict.values()` and fell back to substring-matching the stringified
|
|
228
|
+
form. Tooling Query responses for ApexClass include a `records` list
|
|
229
|
+
whose `Body` field is the raw Apex source — which can legitimately
|
|
230
|
+
contain the literal string `INVALID_SESSION_ID` (as an errorCode
|
|
231
|
+
constant referenced by catch/rethrow code, e.g. `XCSF_FlowFaultMessage`
|
|
232
|
+
and `SkillRulesMatchAction` in the real-org fixture). That tripped the
|
|
233
|
+
value-walk and the substring fallback, misclassifying a 200 OK success
|
|
234
|
+
response as an auth failure and forcing a spurious
|
|
235
|
+
`INVALID_SESSION_ID after refresh` envelope into Wave B's `_unresolved`.
|
|
236
|
+
|
|
237
|
+
The tightened rules:
|
|
238
|
+
1. Top-level list/tuple: the SF error envelope — recurse (one level
|
|
239
|
+
is enough in practice, but recursion is safe because list items
|
|
240
|
+
are error dicts, not data rows with Apex bodies).
|
|
241
|
+
2. Dict: a positive match requires `errorCode == INVALID_SESSION_ID`
|
|
242
|
+
at THIS level, OR recursion into a well-known error-envelope key
|
|
243
|
+
(`errors`, `error`, `errorDetails`, `messages`). Data keys like
|
|
244
|
+
`records`, `Body`, `attributes` are NEVER walked — that was the
|
|
245
|
+
bug. Typos / shape variants still surface through the
|
|
246
|
+
error-envelope keys, which is the real world observed-shape set.
|
|
247
|
+
3. Top-level raw str/bytes: an unparsed error body. A substring
|
|
248
|
+
match here is still safe because this helper is only called on
|
|
249
|
+
the TOP-LEVEL parsed response (never on a field inside a success
|
|
250
|
+
dict), so a raw-string body means SF literally returned text
|
|
251
|
+
rather than JSON — in which case substring match is the best
|
|
252
|
+
we can do.
|
|
253
|
+
4. Anything else (int, float, None, other types): not an error
|
|
254
|
+
shape. Return False.
|
|
255
|
+
"""
|
|
256
|
+
if body is None:
|
|
257
|
+
return False
|
|
258
|
+
if isinstance(body, (list, tuple)):
|
|
259
|
+
return any(_body_indicates_invalid_session(item) for item in body)
|
|
260
|
+
if isinstance(body, dict):
|
|
261
|
+
code = body.get("errorCode") or body.get("error_code")
|
|
262
|
+
if isinstance(code, str) and _INVALID_SESSION_MARKER in code:
|
|
263
|
+
return True
|
|
264
|
+
# Only recurse into known error-envelope keys. Walking arbitrary
|
|
265
|
+
# values misclassifies legitimate success payloads whose data
|
|
266
|
+
# fields (e.g. ApexClass.Body) happen to contain the string
|
|
267
|
+
# `INVALID_SESSION_ID`.
|
|
268
|
+
for key in _ERROR_ENVELOPE_KEYS:
|
|
269
|
+
if key in body and _body_indicates_invalid_session(body[key]):
|
|
270
|
+
return True
|
|
271
|
+
return False
|
|
272
|
+
if isinstance(body, (str, bytes)):
|
|
273
|
+
text = body.decode("utf-8", errors="replace") if isinstance(body, bytes) else body
|
|
274
|
+
return _INVALID_SESSION_MARKER in text
|
|
275
|
+
return False
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class _InvalidSessionSignal(Exception):
|
|
279
|
+
"""Internal-only: tunnel INVALID_SESSION_ID-in-200-body through retry_on_401.
|
|
280
|
+
|
|
281
|
+
Never escapes this module — the decorator catches it, refreshes, retries,
|
|
282
|
+
and on a second failure surfaces a RestClientError (redacted).
|
|
283
|
+
"""
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _is_invalid_session_403(exc: urllib.error.HTTPError) -> bool:
|
|
287
|
+
"""detect HTTP 403 + `INVALID_SESSION_ID` in body.
|
|
288
|
+
|
|
289
|
+
Some SF endpoints respond to an expired session with `403 Forbidden`
|
|
290
|
+
(body: `{"errorCode": "INVALID_SESSION_ID"}`) instead of 401. We treat
|
|
291
|
+
that shape as auth-refresh-triggering just like 401. Any OTHER 403
|
|
292
|
+
(permission denied, IP restriction, etc.) propagates unchanged — we
|
|
293
|
+
don't burn a refresh on a non-auth 403.
|
|
294
|
+
|
|
295
|
+
Reading `.read()` on an HTTPError is a one-shot — callers that surface
|
|
296
|
+
this exception must not re-read the body. For our retry path this is
|
|
297
|
+
fine: the decorator consumes the body once to decide, then discards
|
|
298
|
+
the exception after retry.
|
|
299
|
+
"""
|
|
300
|
+
if exc.code != 403:
|
|
301
|
+
return False
|
|
302
|
+
try:
|
|
303
|
+
body = exc.read()
|
|
304
|
+
except Exception:
|
|
305
|
+
return False
|
|
306
|
+
if not body:
|
|
307
|
+
return False
|
|
308
|
+
try:
|
|
309
|
+
text = body.decode("utf-8", errors="replace") if isinstance(body, bytes) else str(body)
|
|
310
|
+
except Exception:
|
|
311
|
+
return False
|
|
312
|
+
return _INVALID_SESSION_MARKER in text
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def retry_on_401(
|
|
316
|
+
refresh_fn: Callable[[], Tuple[str, str]],
|
|
317
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
318
|
+
"""Decorator: retry a REST call ONCE after refreshing the session token.
|
|
319
|
+
|
|
320
|
+
detects three auth-failure shapes:
|
|
321
|
+
1. `urllib.error.HTTPError` with `code == 401`
|
|
322
|
+
2. `urllib.error.HTTPError` with `code == 403` AND body contains
|
|
323
|
+
`INVALID_SESSION_ID` (some SF endpoints return 403 for
|
|
324
|
+
expired sessions; non-auth 403 is NOT treated as refreshable).
|
|
325
|
+
3. HTTP 200 with response body containing `INVALID_SESSION_ID` —
|
|
326
|
+
callers signal this by raising `_InvalidSessionSignal`; the
|
|
327
|
+
helpers below do the body inspection before returning.
|
|
328
|
+
|
|
329
|
+
(REMEDIATE): the retry contract is documented and enforced here:
|
|
330
|
+
`refresh_fn()` returns `(instance_url, access_token)` and the caller
|
|
331
|
+
MUST thread those fresh credentials into the NEXT invocation of the
|
|
332
|
+
wrapped callable. See `tooling_query` / `data_query`, which implement
|
|
333
|
+
this via the `creds_provider` closure pattern (Option A). The previous
|
|
334
|
+
design captured credentials in a closure at call time — refresh
|
|
335
|
+
returned fresh creds, the wrapped closure kept using stale ones, and
|
|
336
|
+
the retry 401'd again. That's the defect. `retry_on_401` itself
|
|
337
|
+
is still credential-agnostic; the invariant lives in the caller.
|
|
338
|
+
|
|
339
|
+
If the retry ALSO fails (401 / 403+INVALID_SESSION_ID / INVALID_SESSION_ID),
|
|
340
|
+
the ORIGINAL error is re-raised (wrapped as `RestClientError` with a
|
|
341
|
+
redacted message) — not the retry's error. Rationale: the user sees
|
|
342
|
+
the first-attempt context (what call failed) rather than a downstream
|
|
343
|
+
artefact of the retry path.
|
|
344
|
+
|
|
345
|
+
Non-auth errors (500, 403 without INVALID_SESSION_ID, network timeout,
|
|
346
|
+
etc.) propagate unchanged — refresh is NOT attempted.
|
|
347
|
+
"""
|
|
348
|
+
|
|
349
|
+
def _is_auth_http_error(exc: urllib.error.HTTPError) -> bool:
|
|
350
|
+
# 401 is the classic shape; 403+INVALID_SESSION_ID is a
|
|
351
|
+
# variant observed on some SF endpoints. All other 4xx/5xx
|
|
352
|
+
# propagate without refresh.
|
|
353
|
+
if exc.code == 401:
|
|
354
|
+
return True
|
|
355
|
+
if _is_invalid_session_403(exc):
|
|
356
|
+
return True
|
|
357
|
+
return False
|
|
358
|
+
|
|
359
|
+
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
360
|
+
@functools.wraps(fn)
|
|
361
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
362
|
+
try:
|
|
363
|
+
return fn(*args, **kwargs)
|
|
364
|
+
except urllib.error.HTTPError as first_exc:
|
|
365
|
+
if not _is_auth_http_error(first_exc):
|
|
366
|
+
raise
|
|
367
|
+
# auth-failure path — refresh and retry once.
|
|
368
|
+
try:
|
|
369
|
+
refresh_fn()
|
|
370
|
+
except Exception as refresh_exc:
|
|
371
|
+
# Refresh itself failed — surface the REFRESH error so
|
|
372
|
+
# the user knows they need to re-auth, but keep token
|
|
373
|
+
# redaction on the message.
|
|
374
|
+
raise RestClientError(
|
|
375
|
+
f"token refresh failed after auth error: {redact_error(refresh_exc)}"
|
|
376
|
+
) from None
|
|
377
|
+
try:
|
|
378
|
+
return fn(*args, **kwargs)
|
|
379
|
+
except urllib.error.HTTPError as retry_exc:
|
|
380
|
+
if _is_auth_http_error(retry_exc):
|
|
381
|
+
raise RestClientError(
|
|
382
|
+
f"auth error after refresh (original): {redact_error(first_exc)}"
|
|
383
|
+
) from None
|
|
384
|
+
raise
|
|
385
|
+
except _InvalidSessionSignal:
|
|
386
|
+
raise RestClientError(
|
|
387
|
+
f"INVALID_SESSION_ID after refresh (original): "
|
|
388
|
+
f"{redact_error(first_exc)}"
|
|
389
|
+
) from None
|
|
390
|
+
except _InvalidSessionSignal as first_sig:
|
|
391
|
+
# body-path INVALID_SESSION_ID — refresh and retry once.
|
|
392
|
+
try:
|
|
393
|
+
refresh_fn()
|
|
394
|
+
except Exception as refresh_exc:
|
|
395
|
+
raise RestClientError(
|
|
396
|
+
f"token refresh failed after INVALID_SESSION_ID: "
|
|
397
|
+
f"{redact_error(refresh_exc)}"
|
|
398
|
+
) from None
|
|
399
|
+
try:
|
|
400
|
+
return fn(*args, **kwargs)
|
|
401
|
+
except urllib.error.HTTPError as retry_exc:
|
|
402
|
+
if _is_auth_http_error(retry_exc):
|
|
403
|
+
raise RestClientError(
|
|
404
|
+
f"auth error after refresh (original INVALID_SESSION_ID): "
|
|
405
|
+
f"{redact_error(first_sig)}"
|
|
406
|
+
) from None
|
|
407
|
+
raise
|
|
408
|
+
except _InvalidSessionSignal:
|
|
409
|
+
raise RestClientError(
|
|
410
|
+
f"INVALID_SESSION_ID after refresh: {redact_error(first_sig)}"
|
|
411
|
+
) from None
|
|
412
|
+
|
|
413
|
+
return wrapper
|
|
414
|
+
|
|
415
|
+
return decorator
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# -----------------------------------------------------------------------------
|
|
419
|
+
# transient HTTP (429/503) exponential backoff
|
|
420
|
+
# -----------------------------------------------------------------------------
|
|
421
|
+
# Motivation: SF REST endpoints occasionally return 429 (rate-limited) or
|
|
422
|
+
# 503 (service unavailable) during API-limit windows, planned maintenance,
|
|
423
|
+
# or transient edge hiccups. Without a retry the pipeline fails mid-run on
|
|
424
|
+
# what is almost always a recoverable condition. Three attempts with
|
|
425
|
+
# exponential backoff (1s → 2s → 4s, roughly — `base_delay * 2**attempt`)
|
|
426
|
+
# resolve the vast majority of transient blips without meaningfully
|
|
427
|
+
# extending happy-path latency.
|
|
428
|
+
#
|
|
429
|
+
# Scope (what this decorator does NOT do):
|
|
430
|
+
# * Not a 401 refresher — that's `retry_on_401`. The layering is:
|
|
431
|
+
# retry_on_401( retry_on_transient_http()( _query_once ) )
|
|
432
|
+
# so 429s retry first (inner), and a 401 surfacing through that layer
|
|
433
|
+
# triggers the outer refresh. The two concerns stay independent.
|
|
434
|
+
# * No jitter. Bounded to 3 attempts + short delays; adding jitter here
|
|
435
|
+
# would only matter if many callers burst-retried against the same
|
|
436
|
+
# endpoint, which is not the shape of this skill's traffic.
|
|
437
|
+
# * No retry on 5xx generally. Only 503 is retried because 500/502/504
|
|
438
|
+
# often indicate deterministic server-side failures where retrying
|
|
439
|
+
# won't help and we'd prefer to surface fast.
|
|
440
|
+
#
|
|
441
|
+
# Retry-After handling:
|
|
442
|
+
# * If the header is present and parses as a non-negative number of
|
|
443
|
+
# seconds OR an HTTP-date, we honor it — bounded below by base_delay *
|
|
444
|
+
# 2**attempt so a "Retry-After: 0" doesn't disarm the backoff.
|
|
445
|
+
# * Negative / malformed values fall back to the exponential schedule.
|
|
446
|
+
#
|
|
447
|
+
# Redaction: any HTTPError surfaced from the final failure goes through
|
|
448
|
+
# `redact_error` at the caller's message site (see `_query_once`'s wrapper
|
|
449
|
+
# chain). We do NOT stringify headers here — no `str(exc.headers)` or
|
|
450
|
+
# `resp.read()` debug dumps on retry. URL logging strips the query string.
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _parse_retry_after(raw: str | None) -> float | None:
|
|
454
|
+
"""Parse a Retry-After header value to seconds.
|
|
455
|
+
|
|
456
|
+
Per RFC 9110 the value is either `delta-seconds` (non-negative int) or
|
|
457
|
+
an HTTP-date. We accept floats too — some clients emit decimal seconds
|
|
458
|
+
and the spec-strict int parse would silently lose them.
|
|
459
|
+
|
|
460
|
+
Returns None for missing / malformed input; caller falls back to the
|
|
461
|
+
exponential schedule.
|
|
462
|
+
"""
|
|
463
|
+
if not raw:
|
|
464
|
+
return None
|
|
465
|
+
raw = raw.strip()
|
|
466
|
+
try:
|
|
467
|
+
secs = float(raw)
|
|
468
|
+
if secs < 0:
|
|
469
|
+
return None
|
|
470
|
+
return secs
|
|
471
|
+
except ValueError:
|
|
472
|
+
pass
|
|
473
|
+
# HTTP-date form. `parsedate_to_datetime` raises on malformed input on
|
|
474
|
+
# 3.10+; wrap defensively.
|
|
475
|
+
try:
|
|
476
|
+
target = email.utils.parsedate_to_datetime(raw)
|
|
477
|
+
except (TypeError, ValueError):
|
|
478
|
+
return None
|
|
479
|
+
if target is None:
|
|
480
|
+
return None
|
|
481
|
+
# Header expresses an absolute time; convert to a delta from now.
|
|
482
|
+
now = time.time()
|
|
483
|
+
delta = target.timestamp() - now
|
|
484
|
+
if delta < 0:
|
|
485
|
+
return 0.0
|
|
486
|
+
return delta
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def retry_on_transient_http(
|
|
490
|
+
max_retries: int = 3,
|
|
491
|
+
base_delay: float = 1.0,
|
|
492
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
493
|
+
"""Decorator: retry on HTTP 429/503 up to `max_retries` times.
|
|
494
|
+
|
|
495
|
+
on `urllib.error.HTTPError` with code in {429, 503}, sleep and
|
|
496
|
+
retry. Any other exception (including 401 — let `retry_on_401`
|
|
497
|
+
handle that, and any 5xx besides 503) propagates immediately.
|
|
498
|
+
|
|
499
|
+
Retry semantics:
|
|
500
|
+
* max_retries: number of retries AFTER the first attempt (default 3).
|
|
501
|
+
* Total attempts = 1 + max_retries (default 4: 1 original + 3 retries).
|
|
502
|
+
* Final attempt's exception propagates; no further retry.
|
|
503
|
+
|
|
504
|
+
Delay per attempt = max(Retry-After-seconds, base_delay * 2**attempt).
|
|
505
|
+
If Retry-After is absent or malformed, falls back to the exponential
|
|
506
|
+
schedule. `attempt` is 0-indexed for the first retry (so the first
|
|
507
|
+
sleep is base_delay * 1 = base_delay).
|
|
508
|
+
|
|
509
|
+
Logs a DEBUG breadcrumb per retry via the module-level `logger`. No
|
|
510
|
+
header values are logged — only the HTTP code + computed delay +
|
|
511
|
+
attempt counter.
|
|
512
|
+
"""
|
|
513
|
+
|
|
514
|
+
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
515
|
+
@functools.wraps(fn)
|
|
516
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
517
|
+
# Total attempts = 1 original + max_retries retries. `attempt`
|
|
518
|
+
# below is 0-indexed over the retry slots, which matches the
|
|
519
|
+
# exponential formula base_delay * 2**attempt starting at
|
|
520
|
+
# base_delay for attempt=0.
|
|
521
|
+
for attempt in range(max_retries):
|
|
522
|
+
try:
|
|
523
|
+
return fn(*args, **kwargs)
|
|
524
|
+
except urllib.error.HTTPError as exc:
|
|
525
|
+
if exc.code not in (429, 503):
|
|
526
|
+
# Any other HTTP status (including 401) — out of
|
|
527
|
+
# scope for this decorator. Propagate so outer
|
|
528
|
+
# layers (retry_on_401) can handle it.
|
|
529
|
+
raise
|
|
530
|
+
retry_after_raw = None
|
|
531
|
+
try:
|
|
532
|
+
# `exc.headers` is a Message-like; .get tolerates
|
|
533
|
+
# the case where headers are absent on some
|
|
534
|
+
# synthesized HTTPErrors.
|
|
535
|
+
retry_after_raw = exc.headers.get("Retry-After") if exc.headers else None
|
|
536
|
+
except Exception:
|
|
537
|
+
retry_after_raw = None
|
|
538
|
+
hinted = _parse_retry_after(retry_after_raw)
|
|
539
|
+
expo = base_delay * (2 ** attempt)
|
|
540
|
+
delay = max(hinted, expo) if hinted is not None else expo
|
|
541
|
+
logger.debug(
|
|
542
|
+
"retry_on_transient_http: HTTP %d, sleeping %.2fs (attempt %d/%d)",
|
|
543
|
+
exc.code, delay, attempt + 1, max_retries,
|
|
544
|
+
)
|
|
545
|
+
time.sleep(delay)
|
|
546
|
+
continue
|
|
547
|
+
# Final attempt — let any exception propagate directly. If
|
|
548
|
+
# this attempt raises 429/503 the caller gets the HTTPError
|
|
549
|
+
# unchanged (redaction happens at the wrapping call site).
|
|
550
|
+
return fn(*args, **kwargs)
|
|
551
|
+
|
|
552
|
+
return wrapper
|
|
553
|
+
|
|
554
|
+
return decorator
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _query_once(
|
|
558
|
+
instance_url: str,
|
|
559
|
+
token: str,
|
|
560
|
+
path: str,
|
|
561
|
+
soql: str,
|
|
562
|
+
) -> dict:
|
|
563
|
+
"""Single SOQL GET — reads body, checks for INVALID_SESSION_ID.
|
|
564
|
+
|
|
565
|
+
the Salesforce REST Query + Tooling Query
|
|
566
|
+
endpoints are GET-only with the SOQL passed as a urlencoded `q=`
|
|
567
|
+
querystring. The prior POST-with-JSON-body shape returned HTTP 405
|
|
568
|
+
("Method Not Allowed") on every real-org run — confirmed by curl
|
|
569
|
+
against a real org:
|
|
570
|
+
|
|
571
|
+
GET /services/data/v60.0/query/?q=<soql> -> 200 OK
|
|
572
|
+
POST /services/data/v60.0/query/ -> 405 Method Not Allowed
|
|
573
|
+
|
|
574
|
+
We urlencode the SOQL (via `urllib.parse.urlencode({"q": soql})`)
|
|
575
|
+
rather than string-concatenating so spaces, single quotes, commas,
|
|
576
|
+
and parentheses in the query — all legal inside SOQL string
|
|
577
|
+
literals — travel through the wire correctly. The JSON body and
|
|
578
|
+
`Content-Type: application/json` header are dropped (GET carries
|
|
579
|
+
no body; Content-Type with a bodyless GET is technically legal but
|
|
580
|
+
meaningless and some edge proxies get irritated by it).
|
|
581
|
+
|
|
582
|
+
uses `build_opener()` so cross-host redirects strip Authorization.
|
|
583
|
+
any exception is surfaced with `redact_error`; raw body is run
|
|
584
|
+
through `redact_text` before it reaches an exception message.
|
|
585
|
+
signals `_InvalidSessionSignal` on 200-body-error so
|
|
586
|
+
`retry_on_401` can refresh + retry.
|
|
587
|
+
sets `Accept-Encoding: identity` to opt out of gzip. The
|
|
588
|
+
responses here are small JSON payloads — gzip would add handling
|
|
589
|
+
complexity (Content-Encoding detection + decompression) for zero
|
|
590
|
+
measurable benefit. Explicit `identity` keeps the body-inspection
|
|
591
|
+
path (INVALID_SESSION_ID detection) straightforward and testable.
|
|
592
|
+
"""
|
|
593
|
+
# urlencode handles all SOQL-legal characters — spaces,
|
|
594
|
+
# single quotes inside string literals, commas, parentheses. Hand-
|
|
595
|
+
# rolled concatenation would have to escape each class separately.
|
|
596
|
+
qs = urllib.parse.urlencode({"q": soql})
|
|
597
|
+
url = f"{instance_url.rstrip('/')}{path}?{qs}"
|
|
598
|
+
req = urllib.request.Request(url, method="GET")
|
|
599
|
+
req.add_header("Authorization", f"Bearer {token}")
|
|
600
|
+
req.add_header("Accept", "application/json")
|
|
601
|
+
# force identity encoding — no gzip.
|
|
602
|
+
req.add_header("Accept-Encoding", "identity")
|
|
603
|
+
|
|
604
|
+
opener = build_opener()
|
|
605
|
+
try:
|
|
606
|
+
with opener.open(req) as resp:
|
|
607
|
+
raw = resp.read()
|
|
608
|
+
except urllib.error.HTTPError as exc:
|
|
609
|
+
# Bug D.1 fix: HTTPError.read() is one-shot. The default
|
|
610
|
+
# `redact_error()` path stringifies just `HTTP Error 4xx: <reason>`
|
|
611
|
+
# which loses the Salesforce error body — and that body is what
|
|
612
|
+
# tells the operator WHY (e.g. INVALID_FIELD, MALFORMED_QUERY,
|
|
613
|
+
# name-too-long, or a specific bad identifier). Pull the body
|
|
614
|
+
# ONCE here, scrub it via redact_text, attach it to the exception
|
|
615
|
+
# as `_response_body_preview`, and re-raise. Downstream callers
|
|
616
|
+
# that surface `redact_error(exc)` keep their existing string
|
|
617
|
+
# output; callers that want the body read it from the attribute.
|
|
618
|
+
try:
|
|
619
|
+
body = exc.read()
|
|
620
|
+
except Exception:
|
|
621
|
+
body = b""
|
|
622
|
+
try:
|
|
623
|
+
body_text = body.decode("utf-8", errors="replace")
|
|
624
|
+
except Exception:
|
|
625
|
+
body_text = ""
|
|
626
|
+
# Cap at 500 chars — enough to carry a Salesforce error array
|
|
627
|
+
# element with the offending name in it; small enough that
|
|
628
|
+
# downstream contexts don't bloat. redact_text strips bearer
|
|
629
|
+
# tokens defensively (body shouldn't contain one but cheaper to
|
|
630
|
+
# always scrub than reason about it).
|
|
631
|
+
exc._response_body_preview = redact_text(body_text)[:500] # type: ignore[attr-defined]
|
|
632
|
+
raise
|
|
633
|
+
|
|
634
|
+
try:
|
|
635
|
+
parsed = json.loads(raw.decode("utf-8"))
|
|
636
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as e:
|
|
637
|
+
raise RestClientError(
|
|
638
|
+
f"malformed query response: {redact_error(e)}"
|
|
639
|
+
) from None
|
|
640
|
+
|
|
641
|
+
# HTTP 200 + INVALID_SESSION_ID in body → tunnel up to the
|
|
642
|
+
# decorator so it can refresh + retry.
|
|
643
|
+
if _body_indicates_invalid_session(parsed):
|
|
644
|
+
raise _InvalidSessionSignal(
|
|
645
|
+
redact_text(json.dumps(parsed)[:500])
|
|
646
|
+
)
|
|
647
|
+
return parsed
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
# (REMEDIATE): credentials flow through a provider closure, not
|
|
651
|
+
# through function arguments that are captured once at decoration time.
|
|
652
|
+
#
|
|
653
|
+
# The previous design had a fatal defect:
|
|
654
|
+
#
|
|
655
|
+
# def tooling_query(instance_url, token, soql, *, on_401_refresh):
|
|
656
|
+
# @retry_on_401(on_401_refresh)
|
|
657
|
+
# def _call():
|
|
658
|
+
# return _query_once(instance_url, token, ...) # stale closure
|
|
659
|
+
# return _call()
|
|
660
|
+
#
|
|
661
|
+
# `refresh_fn()` returned `(new_url, new_token)` but those values were
|
|
662
|
+
# thrown away — the decorator only called `refresh_fn()` for its side
|
|
663
|
+
# effects, then re-invoked the same closure holding the ORIGINAL stale
|
|
664
|
+
# `instance_url` / `token`. Retry 401'd again. Feature was dead.
|
|
665
|
+
#
|
|
666
|
+
# Option A (taken): `tooling_query` / `data_query` accept a
|
|
667
|
+
# `creds_provider: Callable[[], Tuple[str, str]]` that is called EACH
|
|
668
|
+
# attempt. `refresh_fn` is responsible for mutating whatever state the
|
|
669
|
+
# provider reads from — typically a simple closure over a list. This
|
|
670
|
+
# keeps `retry_on_401` credential-agnostic and makes the contract
|
|
671
|
+
# ("refresh updates the source that `creds_provider` reads from")
|
|
672
|
+
# explicit at the caller site instead of implicit in the helper.
|
|
673
|
+
#
|
|
674
|
+
# Test: `RetryOn401CredentialRefreshIntegrationTests` in
|
|
675
|
+
# test_rest_client.py verifies that a real refresh carries fresh
|
|
676
|
+
# credentials into the retry call; the prior design fails that test.
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def tooling_query(
|
|
680
|
+
creds_provider: Callable[[], Tuple[str, str]],
|
|
681
|
+
soql: str,
|
|
682
|
+
*,
|
|
683
|
+
api_version: str,
|
|
684
|
+
on_401_refresh: Callable[[], Tuple[str, str]],
|
|
685
|
+
) -> dict:
|
|
686
|
+
"""GET a SOQL query against the Tooling API.
|
|
687
|
+
|
|
688
|
+
`creds_provider()` is invoked on EACH attempt. `on_401_refresh`
|
|
689
|
+
is responsible for updating whatever state the provider reads from
|
|
690
|
+
before it returns — otherwise the retry would hit the same stale
|
|
691
|
+
credentials and 401 again.
|
|
692
|
+
|
|
693
|
+
`api_version` is a REQUIRED keyword-only arg.
|
|
694
|
+
The prior hardcoded `v60.0` was the source of — real orgs
|
|
695
|
+
run on v66 and expose fields v60 does not (confirmed empirically:
|
|
696
|
+
`BotDefinition.Description` exists on v66, `INVALID_FIELD` on v60).
|
|
697
|
+
Callers thread the version through from `main._derive_org_ids`, which
|
|
698
|
+
reads it once from `sf org display --json`. Making the param REQUIRED
|
|
699
|
+
(no default) surfaces a missed call-site as a TypeError at call time
|
|
700
|
+
rather than a silent regression back to a stale pinned version.
|
|
701
|
+
Shape is already enforced by `fs_guard.validate_api_version`
|
|
702
|
+
(`^v[0-9]+\\.[0-9]+$`) at the caller.
|
|
703
|
+
|
|
704
|
+
Stateless: both closures are passed per-call. Each caller owns the
|
|
705
|
+
storage the refresh writes to (typically a small `list[tuple[str, str]]`
|
|
706
|
+
cell).
|
|
707
|
+
"""
|
|
708
|
+
path = f"/services/data/{api_version}/tooling/query/"
|
|
709
|
+
|
|
710
|
+
# retry_on_transient_http is the INNER layer — 429/503 retries
|
|
711
|
+
# happen first, and any 401 bubbling through triggers retry_on_401's
|
|
712
|
+
# refresh path at the outer layer. Ordering matters: if transient
|
|
713
|
+
# retry were outer, a 429 during a refresh retry would be mis-handled.
|
|
714
|
+
@retry_on_401(on_401_refresh)
|
|
715
|
+
@retry_on_transient_http()
|
|
716
|
+
def _call() -> dict:
|
|
717
|
+
# re-read creds on every attempt so refresh actually lands.
|
|
718
|
+
instance_url, token = creds_provider()
|
|
719
|
+
return _query_once(instance_url, token, path, soql)
|
|
720
|
+
|
|
721
|
+
return _call()
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def data_query(
|
|
725
|
+
creds_provider: Callable[[], Tuple[str, str]],
|
|
726
|
+
soql: str,
|
|
727
|
+
*,
|
|
728
|
+
api_version: str,
|
|
729
|
+
on_401_refresh: Callable[[], Tuple[str, str]],
|
|
730
|
+
) -> dict:
|
|
731
|
+
"""GET a SOQL query against the Data API (non-Tooling).
|
|
732
|
+
|
|
733
|
+
identical retry wiring to `tooling_query`, different URL path.
|
|
734
|
+
`creds_provider` is invoked on every attempt so a refresh actually
|
|
735
|
+
propagates fresh credentials into the retry.
|
|
736
|
+
|
|
737
|
+
`api_version` is a REQUIRED keyword-only arg;
|
|
738
|
+
see `tooling_query` for the rationale.
|
|
739
|
+
"""
|
|
740
|
+
path = f"/services/data/{api_version}/query/"
|
|
741
|
+
|
|
742
|
+
# see `tooling_query` for the decorator-ordering rationale.
|
|
743
|
+
@retry_on_401(on_401_refresh)
|
|
744
|
+
@retry_on_transient_http()
|
|
745
|
+
def _call() -> dict:
|
|
746
|
+
# re-read creds on every attempt so refresh actually lands.
|
|
747
|
+
instance_url, token = creds_provider()
|
|
748
|
+
return _query_once(instance_url, token, path, soql)
|
|
749
|
+
|
|
750
|
+
return _call()
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def static_creds(instance_url: str, token: str) -> Callable[[], Tuple[str, str]]:
|
|
754
|
+
"""Build a zero-state creds_provider that always returns the same pair.
|
|
755
|
+
|
|
756
|
+
Convenience for callers that don't wire a refresh path — tests,
|
|
757
|
+
single-shot scripts where a 401 is terminal. If the call 401s, the
|
|
758
|
+
retry sees the same creds and fails again; this is correct behavior
|
|
759
|
+
for an unrefreshable context (no point claiming otherwise).
|
|
760
|
+
"""
|
|
761
|
+
def _provider() -> Tuple[str, str]:
|
|
762
|
+
return instance_url, token
|
|
763
|
+
return _provider
|