diff --git a/.github/scripts/store-codegen-pull-request.sh b/.github/scripts/store-codegen-pull-request.sh new file mode 100644 index 0000000000..54f17478e9 --- /dev/null +++ b/.github/scripts/store-codegen-pull-request.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash + +set -euo pipefail + +: "${GH_TOKEN:?GH_TOKEN is required}" +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +: "${DRIFT_FILES_PATH:?DRIFT_FILES_PATH is required}" + +base_branch="${BASE_BRANCH:-dev}" +automation_branch="${AUTOMATION_BRANCH:-automation/store-codegen-drift}" +pr_title="chore: refresh store codegen snapshot" + +codegen_targets=( + "packages/store/scripts/api-schema/schema.json" + "packages/store/src/gateway/AUTO_GENERATED/" +) + +is_codegen_path() { + local candidate="$1" + + [[ "${candidate}" == "packages/store/scripts/api-schema/schema.json" ]] || + [[ "${candidate}" == packages/store/src/gateway/AUTO_GENERATED/* ]] +} + +collect_changed_paths() { + git diff -z --name-only HEAD + git ls-files -z --others --exclude-standard +} + +unexpected_paths=() +while IFS= read -r -d '' changed_path; do + if [[ -n "${changed_path}" ]] && ! is_codegen_path "${changed_path}"; then + unexpected_paths+=("${changed_path}") + fi +done < <(collect_changed_paths) + +if (( ${#unexpected_paths[@]} > 0 )); then + echo "Refusing to create an automation PR with changes outside the codegen allowlist:" >&2 + printf ' %s\n' "${unexpected_paths[@]}" >&2 + exit 1 +fi + +drift_paths=() +while IFS= read -r -d '' changed_path; do + if [[ -n "${changed_path}" ]] && is_codegen_path "${changed_path}"; then + drift_paths+=("${changed_path}") + fi +done < <(collect_changed_paths) + +if (( ${#drift_paths[@]} == 0 )); then + echo "No store codegen drift found." + exit 0 +fi + +git checkout -B "${automation_branch}" +git add -- "${codegen_targets[@]}" +git \ + -c user.name="github-actions[bot]" \ + -c user.email="41898282+github-actions[bot]@users.noreply.github.com" \ + commit -m "${pr_title}" + +gh auth setup-git + +remote_sha="$(git ls-remote --heads origin "refs/heads/${automation_branch}" | awk '{print $1}')" +if [[ -n "${remote_sha}" ]]; then + git push \ + --force-with-lease="refs/heads/${automation_branch}:${remote_sha}" \ + origin "HEAD:refs/heads/${automation_branch}" +else + git push origin "HEAD:refs/heads/${automation_branch}" +fi + +body_file="$(mktemp)" +trap 'rm -f "${body_file}"' EXIT +{ + echo "The Store Codegen Drift workflow detected an upstream schema change and regenerated the checked-in gateway client snapshot." + echo + echo "Changed generated files:" + sed 's/\(.*\)/- `\1`/' "${DRIFT_FILES_PATH}" + echo + echo "This PR is automation-created, but it is intentionally not auto-merged." +} > "${body_file}" + +pr_number="$( + gh pr list \ + --repo "${GITHUB_REPOSITORY}" \ + --base "${base_branch}" \ + --head "${automation_branch}" \ + --state open \ + --json number \ + --jq '.[0].number // empty' +)" + +if [[ -n "${pr_number}" ]]; then + gh pr edit "${pr_number}" \ + --repo "${GITHUB_REPOSITORY}" \ + --title "${pr_title}" \ + --body-file "${body_file}" +else + gh pr create \ + --repo "${GITHUB_REPOSITORY}" \ + --base "${base_branch}" \ + --head "${automation_branch}" \ + --title "${pr_title}" \ + --body-file "${body_file}" +fi diff --git a/.github/scripts/tests/store-codegen-pull-request.test.sh b/.github/scripts/tests/store-codegen-pull-request.test.sh new file mode 100644 index 0000000000..8241e2371f --- /dev/null +++ b/.github/scripts/tests/store-codegen-pull-request.test.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +script_path="$(cd "${script_dir}/.." && pwd)/store-codegen-pull-request.sh" +test_root="$(mktemp -d)" + +cleanup() { + rm -rf "${test_root}" +} +trap cleanup EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +assert_contains() { + local needle="$1" + local file="$2" + grep -Fq -- "${needle}" "${file}" || fail "Expected ${file} to contain: ${needle}" +} + +remote_path="${test_root}/remote.git" +repo_path="${test_root}/repo" +stub_bin="${test_root}/bin" +gh_log="${test_root}/gh.log" +drift_files="${test_root}/drift-files.txt" + +codegen_paths=( + "packages/store/scripts/api-schema/schema.json" + "packages/store/src/gateway/AUTO_GENERATED/.schema-hash" + "packages/store/src/gateway/AUTO_GENERATED/auth.ts" + "packages/store/src/gateway/AUTO_GENERATED/relay.ts" + "packages/store/src/gateway/AUTO_GENERATED/spaces.ts" + "packages/store/src/gateway/AUTO_GENERATED/transactions.ts" +) +new_codegen_path="packages/store/src/gateway/AUTO_GENERATED/added.ts" + +git init --bare "${remote_path}" >/dev/null +git init --initial-branch=dev "${repo_path}" >/dev/null +git -C "${repo_path}" config user.name "Test User" +git -C "${repo_path}" config user.email "test@example.com" + +for path in "${codegen_paths[@]}"; do + mkdir -p "${repo_path}/$(dirname "${path}")" + printf 'initial\n' > "${repo_path}/${path}" +done +printf 'baseline\n' > "${repo_path}/README.md" +git -C "${repo_path}" add . +git -C "${repo_path}" commit -m "initial" >/dev/null +git -C "${repo_path}" remote add origin "${remote_path}" +git -C "${repo_path}" push --set-upstream origin dev >/dev/null + +mkdir -p "${stub_bin}" +printf '%s\n' '#!/usr/bin/env bash' > "${stub_bin}/gh" +printf '%s\n' 'set -euo pipefail' >> "${stub_bin}/gh" +printf '%s\n' 'printf "%s\\n" "$*" >> "${GH_STUB_LOG}"' >> "${stub_bin}/gh" +printf '%s\n' 'if [[ "${1:-}" == "pr" && "${2:-}" == "list" ]]; then' >> "${stub_bin}/gh" +printf '%s\n' ' printf "%s" "${GH_STUB_PR_NUMBER:-}"' >> "${stub_bin}/gh" +printf '%s\n' 'elif [[ "${1:-}" == "pr" && "${2:-}" == "create" ]]; then' >> "${stub_bin}/gh" +printf '%s\n' ' printf "%s\\n" "https://github.com/DOS/Safe-Wallet/pull/123"' >> "${stub_bin}/gh" +printf '%s\n' 'elif [[ "${1:-}" == "pr" && "${2:-}" == "edit" ]]; then' >> "${stub_bin}/gh" +printf '%s\n' ' printf "%s\\n" "https://github.com/DOS/Safe-Wallet/pull/${3:-42}"' >> "${stub_bin}/gh" +printf '%s\n' 'fi' >> "${stub_bin}/gh" +chmod +x "${stub_bin}/gh" + +printf '%s\n' "${codegen_paths[@]}" > "${drift_files}" + +run_script() { + ( + cd "${repo_path}" + PATH="${stub_bin}:${PATH}" \ + GH_TOKEN="test-token" \ + GH_STUB_LOG="${gh_log}" \ + GH_STUB_PR_NUMBER="${GH_STUB_PR_NUMBER:-}" \ + GITHUB_REPOSITORY="DOS/Safe-Wallet" \ + BASE_BRANCH="dev" \ + AUTOMATION_BRANCH="automation/store-codegen-drift" \ + DRIFT_FILES_PATH="${drift_files}" \ + bash "${script_path}" + ) +} + +for path in "${codegen_paths[@]}"; do + printf 'first refresh\n' > "${repo_path}/${path}" +done + +run_script + +first_remote_sha="$(git --git-dir="${remote_path}" rev-parse refs/heads/automation/store-codegen-drift)" +[[ -n "${first_remote_sha}" ]] || fail "Automation branch was not created" +[[ "$(git --git-dir="${remote_path}" show "${first_remote_sha}:${codegen_paths[0]}")" == "first refresh" ]] || fail "Generated content was not pushed" +assert_contains "pr create" "${gh_log}" +if grep -Fq "pr edit" "${gh_log}"; then + fail "First run must create a PR, not edit one" +fi + +git -C "${repo_path}" checkout dev >/dev/null +for path in "${codegen_paths[@]}"; do + printf 'second refresh\n' > "${repo_path}/${path}" +done +: > "${gh_log}" +GH_STUB_PR_NUMBER=42 run_script + +second_remote_sha="$(git --git-dir="${remote_path}" rev-parse refs/heads/automation/store-codegen-drift)" +[[ "${second_remote_sha}" != "${first_remote_sha}" ]] || fail "Automation branch was not updated" +[[ "$(git --git-dir="${remote_path}" show "${second_remote_sha}:${codegen_paths[0]}")" == "second refresh" ]] || fail "Updated generated content was not pushed" +assert_contains "pr edit 42" "${gh_log}" + +git -C "${repo_path}" checkout dev >/dev/null +printf '%s\n' "${codegen_paths[@]}" "${new_codegen_path}" > "${drift_files}" +printf 'added by schema refresh\n' > "${repo_path}/${new_codegen_path}" +: > "${gh_log}" +GH_STUB_PR_NUMBER=42 run_script + +third_remote_sha="$(git --git-dir="${remote_path}" rev-parse refs/heads/automation/store-codegen-drift)" +[[ "${third_remote_sha}" != "${second_remote_sha}" ]] || fail "Automation branch was not updated for an untracked generated file" +[[ "$(git --git-dir="${remote_path}" show "${third_remote_sha}:${new_codegen_path}")" == "added by schema refresh" ]] || fail "Untracked generated file was not pushed" +assert_contains "pr edit 42" "${gh_log}" + +git -C "${repo_path}" checkout dev >/dev/null +printf '%s\n' "${codegen_paths[@]}" > "${drift_files}" +printf 'unexpected change\n' > "${repo_path}/README.md" +printf 'third refresh\n' > "${repo_path}/${codegen_paths[0]}" +: > "${gh_log}" +if GH_STUB_PR_NUMBER=42 run_script; then + fail "Script accepted a change outside the codegen allowlist" +fi + +final_remote_sha="$(git --git-dir="${remote_path}" rev-parse refs/heads/automation/store-codegen-drift)" +[[ "${final_remote_sha}" == "${third_remote_sha}" ]] || fail "Rejected run changed the remote branch" +[[ "$(git -C "${repo_path}" config user.name)" == "Test User" ]] || fail "Script changed the local git user.name" +[[ "$(git -C "${repo_path}" config user.email)" == "test@example.com" ]] || fail "Script changed the local git user.email" + +echo "PASS: store codegen PR automation" diff --git a/.github/scripts/tests/store-generated-endpoints.test.sh b/.github/scripts/tests/store-generated-endpoints.test.sh new file mode 100644 index 0000000000..f2db3a0f6b --- /dev/null +++ b/.github/scripts/tests/store-generated-endpoints.test.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +set -euo pipefail + +generated_spaces="packages/store/src/gateway/AUTO_GENERATED/spaces.ts" + +grep -Fq 'entitlementsGetEntitlementsV1: build.query<' "${generated_spaces}" +grep -Fq 'export type EntitlementsResponse = {' "${generated_spaces}" +grep -Fq 'useEntitlementsGetEntitlementsV1Query' "${generated_spaces}" +grep -Fq 'url: `/v1/spaces/${queryArg.spaceId}/entitlements`' "${generated_spaces}" + +echo "PASS: required Store endpoints are generated" diff --git a/.github/workflows/store-codegen-drift.yml b/.github/workflows/store-codegen-drift.yml index 574f4a4dcd..3299b9acb9 100644 --- a/.github/workflows/store-codegen-drift.yml +++ b/.github/workflows/store-codegen-drift.yml @@ -6,41 +6,60 @@ on: workflow_dispatch: concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-dev cancel-in-progress: true jobs: check-drift: permissions: contents: read - issues: write runs-on: ubuntu-latest steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6.0.0 + with: + persist-credentials: false + ref: dev - uses: ./.github/actions/yarn with: after-install: 'false' + - name: Test drift PR automation + run: bash .github/scripts/tests/store-codegen-pull-request.test.sh - run: yarn workspace @safe-global/store build:dev - - name: Check for drift + - name: Test generated endpoint routing + run: bash .github/scripts/tests/store-generated-endpoints.test.sh + - name: Detect drift id: drift - run: git diff --exit-code -- packages/store/scripts/api-schema/schema.json packages/store/src/gateway/AUTO_GENERATED/ + shell: bash + run: | + codegen_paths=( + packages/store/scripts/api-schema/schema.json + packages/store/src/gateway/AUTO_GENERATED/ + ) - - name: Capture changed files - if: failure() && steps.drift.outcome == 'failure' - run: git diff --name-only -- packages/store/scripts/api-schema/schema.json packages/store/src/gateway/AUTO_GENERATED/ > "${RUNNER_TEMP}/drift-files.txt" + : > "${RUNNER_TEMP}/drift-files.txt" + while IFS= read -r -d '' changed_path; do + if [[ -n "${changed_path}" ]]; then + printf '%s\n' "${changed_path}" >> "${RUNNER_TEMP}/drift-files.txt" + fi + done < <( + git diff -z --name-only HEAD -- "${codegen_paths[@]}" + git ls-files -z --others --exclude-standard -- "${codegen_paths[@]}" + ) + + if [[ ! -s "${RUNNER_TEMP}/drift-files.txt" ]]; then + echo "changed=false" >> "${GITHUB_OUTPUT}" + else + echo "changed=true" >> "${GITHUB_OUTPUT}" + fi - name: Report drift - if: failure() && steps.drift.outcome == 'failure' + if: steps.drift.outputs.changed == 'true' run: | { echo "## Store codegen drift detected" echo echo 'The generated Store API client is out of sync with the staging CGW schema.' - echo - echo '**Fix:**' - echo '```bash' - echo 'yarn workspace @safe-global/store build:dev' - echo '```' + echo 'The workflow will create or update a review PR with the regenerated snapshot.' echo echo '**Changed files:**' echo '```text' @@ -48,73 +67,12 @@ jobs: echo '```' } >> "${GITHUB_STEP_SUMMARY}" - - name: Open or update drift issue - if: failure() && steps.drift.outcome == 'failure' && vars.ENABLE_DRIFT_ISSUES == 'true' - continue-on-error: true - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - const fs = require('fs') - const path = require('path') - const marker = '' - const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}` - const driftPath = path.join(process.env.RUNNER_TEMP, 'drift-files.txt') - const raw = fs.existsSync(driftPath) ? fs.readFileSync(driftPath, 'utf8') : '' - const files = raw.trim() || '(no files listed)' - const body = [ - marker, - 'The scheduled `Store Codegen Drift` workflow detected that `packages/store/src/gateway/AUTO_GENERATED/` is out of sync with the staging CGW schema.', - '', - `**Failing run:** ${runUrl}`, - '', - '**Fix:**', - '```bash', - 'yarn workspace @safe-global/store build:dev', - '# commit the resulting changes', - '```', - '', - '**Changed files:**', - '```', - files, - '```', - '', - 'This issue will auto-close the next time the workflow succeeds.', - ].join('\n') - const q = `repo:${context.repo.owner}/${context.repo.repo} is:issue is:open in:body "${marker}" author:app/github-actions` - const { data } = await github.rest.search.issuesAndPullRequests({ q }) - if (data.items.length > 0) { - await github.rest.issues.createComment({ - ...context.repo, - issue_number: data.items[0].number, - body: `New drift detected in run ${runUrl}.\n\nChanged files:\n\`\`\`\n${files}\n\`\`\``, - }) - } else { - await github.rest.issues.create({ - ...context.repo, - title: 'CI: store codegen drift detected', - body, - }) - } - - - name: Close drift issue on success - if: success() - continue-on-error: true - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - const marker = '' - const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}` - const q = `repo:${context.repo.owner}/${context.repo.repo} is:issue is:open in:body "${marker}" author:app/github-actions` - const { data } = await github.rest.search.issuesAndPullRequests({ q }) - for (const issue of data.items) { - await github.rest.issues.createComment({ - ...context.repo, - issue_number: issue.number, - body: `Drift resolved in run ${runUrl}. Auto-closing.`, - }) - await github.rest.issues.update({ - ...context.repo, - issue_number: issue.number, - state: 'closed', - }) - } + - name: Create or update drift PR + if: steps.drift.outputs.changed == 'true' + env: + GH_TOKEN: ${{ secrets.GH_PAT }} + GITHUB_REPOSITORY: ${{ github.repository }} + BASE_BRANCH: dev + AUTOMATION_BRANCH: automation/store-codegen-drift + DRIFT_FILES_PATH: ${{ runner.temp }}/drift-files.txt + run: bash .github/scripts/store-codegen-pull-request.sh diff --git a/apps/mobile/src/services/analytics/constants.ts b/apps/mobile/src/services/analytics/constants.ts index ba6203a352..25d9e21d79 100644 --- a/apps/mobile/src/services/analytics/constants.ts +++ b/apps/mobile/src/services/analytics/constants.ts @@ -30,10 +30,12 @@ export const ANALYTICS_LABELS = { SWAP_OWNER: 'owner_swap', CHANGE_THRESHOLD: 'owner_threshold_change', DELETE_GUARD: 'guard_remove', + DELETE_MODULE_GUARD: 'module_guard_remove', DISABLE_MODULE: 'module_remove', ENABLE_MODULE: 'module_enable', SET_FALLBACK_HANDLER: 'fallback_handler_set', SET_GUARD: 'guard_set', + SET_MODULE_GUARD: 'module_guard_set', CHANGE_MASTER_COPY: 'safe_update', } as const satisfies Record, diff --git a/apps/mobile/src/store/middleware/analytics/strategies/__tests__/TransactionConfirmationStrategy.test.ts b/apps/mobile/src/store/middleware/analytics/strategies/__tests__/TransactionConfirmationStrategy.test.ts index 29d3bab7af..32020c77fa 100644 --- a/apps/mobile/src/store/middleware/analytics/strategies/__tests__/TransactionConfirmationStrategy.test.ts +++ b/apps/mobile/src/store/middleware/analytics/strategies/__tests__/TransactionConfirmationStrategy.test.ts @@ -228,6 +228,44 @@ describe('TransactionConfirmationStrategy', () => { expect(mockTrackEvent).toHaveBeenCalledWith(mockEventData) }) + it('should handle module guard settings change transactions', () => { + const mockTransaction = { + txInfo: { + type: 'SettingsChange', + dataDecoded: { + method: 'setModuleGuard', + parameters: [], + }, + settingsInfo: { + type: 'SET_MODULE_GUARD', + moduleGuard: { value: '0x000000000000000000000000000000000000c0de' }, + }, + }, + id: 'module_guard_settings_tx', + timestamp: Date.now(), + txStatus: 'SUCCESS', + } + + const action: ActionWithPayload = { + type: 'settings/module-guard/fulfilled', + payload: mockTransaction, + } + + const mockEventData = { + eventName: EventType.TX_CONFIRMED, + eventCategory: 'transactions', + eventAction: 'Confirm transaction', + eventLabel: ANALYTICS_LABELS.SETTINGS_TYPES.SET_MODULE_GUARD, + } + + mockCreateTxConfirmEvent.mockReturnValue(mockEventData) + + strategy.execute(mockStore, action) + + expect(mockCreateTxConfirmEvent).toHaveBeenCalledWith(ANALYTICS_LABELS.SETTINGS_TYPES.SET_MODULE_GUARD) + expect(mockTrackEvent).toHaveBeenCalledWith(mockEventData) + }) + it('should handle rejection transactions', () => { const mockTransaction = { txInfo: { diff --git a/apps/web/src/components/transactions/TxDetails/TxData/SettingsChange/index.test.tsx b/apps/web/src/components/transactions/TxDetails/TxData/SettingsChange/index.test.tsx new file mode 100644 index 0000000000..e4041de929 --- /dev/null +++ b/apps/web/src/components/transactions/TxDetails/TxData/SettingsChange/index.test.tsx @@ -0,0 +1,35 @@ +import { SettingsInfoType } from '@safe-global/store/gateway/types' +import { render } from '@/tests/test-utils' +import SettingsChangeTxInfo from '.' + +jest.mock('@/components/common/EthHashInfo', () => ({ + __esModule: true, + default: ({ address }: { address: string }) => {address}, +})) + +jest.mock('@/hooks/useHasUntrustedFallbackHandler', () => ({ + useHasUntrustedFallbackHandler: () => false, +})) + +describe('SettingsChangeTxInfo', () => { + it('renders a module guard address when one is set', () => { + const moduleGuard = '0x000000000000000000000000000000000000c0de' + const { getByText } = render( + , + ) + + expect(getByText('Set module guard:')).toBeInTheDocument() + expect(getByText(moduleGuard)).toBeInTheDocument() + }) + + it('renders module guard removal', () => { + const { getByText } = render() + + expect(getByText('Delete module guard')).toBeInTheDocument() + }) +}) diff --git a/apps/web/src/components/transactions/TxDetails/TxData/SettingsChange/index.tsx b/apps/web/src/components/transactions/TxDetails/TxData/SettingsChange/index.tsx index dd65c5d178..bee12f2d68 100644 --- a/apps/web/src/components/transactions/TxDetails/TxData/SettingsChange/index.tsx +++ b/apps/web/src/components/transactions/TxDetails/TxData/SettingsChange/index.tsx @@ -139,6 +139,21 @@ const SettingsChangeTxInfo = ({ case SettingsInfoType.DELETE_GUARD: { return } + case SettingsInfoType.SET_MODULE_GUARD: { + return ( + + + + ) + } + case SettingsInfoType.DELETE_MODULE_GUARD: { + return + } default: return <> } diff --git a/apps/web/src/services/analytics/__tests__/tx-tracking.test.ts b/apps/web/src/services/analytics/__tests__/tx-tracking.test.ts index dbf4793294..82573e6f7b 100644 --- a/apps/web/src/services/analytics/__tests__/tx-tracking.test.ts +++ b/apps/web/src/services/analytics/__tests__/tx-tracking.test.ts @@ -125,6 +125,32 @@ describe('getTransactionTrackingType', () => { expect(txType).toEqual(TX_TYPES.guard_remove) }) + it('should return module_guard_set for set module guard settings changes', async () => { + const details = { + txInfo: { + type: TransactionInfoType.SETTINGS_CHANGE, + settingsInfo: { + type: SettingsInfoType.SET_MODULE_GUARD, + }, + }, + } as unknown as TransactionDetails + const txType = getTransactionTrackingType(details) + expect(txType).toEqual(TX_TYPES.module_guard_set) + }) + + it('should return module_guard_remove for delete module guard settings changes', async () => { + const details = { + txInfo: { + type: TransactionInfoType.SETTINGS_CHANGE, + settingsInfo: { + type: SettingsInfoType.DELETE_MODULE_GUARD, + }, + }, + } as unknown as TransactionDetails + const txType = getTransactionTrackingType(details) + expect(txType).toEqual(TX_TYPES.module_guard_remove) + }) + it('should return rejection for rejection transactions', async () => { const details = { txInfo: { diff --git a/apps/web/src/services/analytics/events/transactions.ts b/apps/web/src/services/analytics/events/transactions.ts index bdffe2453b..3fbec93f49 100644 --- a/apps/web/src/services/analytics/events/transactions.ts +++ b/apps/web/src/services/analytics/events/transactions.ts @@ -10,6 +10,8 @@ export enum TX_TYPES { // Module txs guard_remove = 'guard_remove', module_remove = 'module_remove', + module_guard_set = 'module_guard_set', + module_guard_remove = 'module_guard_remove', // Transfers transfer_token = 'transfer_token', diff --git a/apps/web/src/services/analytics/tx-tracking.ts b/apps/web/src/services/analytics/tx-tracking.ts index f13a77d3c5..07d95b29c4 100644 --- a/apps/web/src/services/analytics/tx-tracking.ts +++ b/apps/web/src/services/analytics/tx-tracking.ts @@ -81,6 +81,12 @@ export const getTransactionTrackingType = ( case SettingsInfoType.DELETE_GUARD: { return TX_TYPES.guard_remove } + case SettingsInfoType.SET_MODULE_GUARD: { + return TX_TYPES.module_guard_set + } + case SettingsInfoType.DELETE_MODULE_GUARD: { + return TX_TYPES.module_guard_remove + } } } diff --git a/packages/store/scripts/api-schema/schema.json b/packages/store/scripts/api-schema/schema.json index 40aad2cc43..c87e902641 100644 --- a/packages/store/scripts/api-schema/schema.json +++ b/packages/store/scripts/api-schema/schema.json @@ -183,6 +183,15 @@ "schema": { "type": "boolean" } + }, + { + "name": "elevate", + "required": false, + "in": "query", + "description": "When true, requests step-up authentication: the provider re-challenges a second factor and the resulting session is elevated for sensitive actions.", + "schema": { + "type": "boolean" + } } ], "responses": { @@ -2890,6 +2899,9 @@ "401": { "description": "Authentication required or user unauthorized to modify this space" }, + "402": { + "description": "The space is at its plan's Safe seat limit. The body carries `{ code: \"QUOTA_EXCEEDED\", feature, quota, used, resetsAt }`" + }, "403": { "description": "Access forbidden - user lacks permission to add Safes to this space" }, @@ -3734,6 +3746,52 @@ ] } }, + "/v1/spaces/{spaceId}/entitlements": { + "get": { + "description": "The single source of truth for what a workspace can do: the active plan and per-feature entitlements, with quotas and usage for metered ones. Free-tier workspaces get the same contract shape.", + "operationId": "entitlementsGetEntitlementsV1", + "parameters": [ + { + "name": "spaceId", + "required": true, + "in": "path", + "description": "Space UUID", + "schema": { + "example": "123e4567-e89b-12d3-a456-426614174000", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EntitlementsResponse" + } + } + } + }, + "400": { + "description": "Invalid space identifier" + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Access forbidden - user is not a member of this space" + }, + "404": { + "description": "Space not found" + } + }, + "summary": "Get space entitlements", + "tags": [ + "entitlements" + ] + } + }, "/v1/chains/{chainId}/owners/{ownerAddress}/safes": { "get": { "description": "Retrieves a list of Safe addresses that are owned by the specified address on a specific chain.", @@ -6709,7 +6767,11 @@ "type": "number", "nullable": true }, - "validUntil": { + "currentPeriodStart": { + "type": "number", + "nullable": true + }, + "currentPeriodEnd": { "type": "number", "nullable": true }, @@ -10666,6 +10728,188 @@ "answeredByUserId" ] }, + "FeatureKey": { + "type": "string", + "enum": [ + "safe_seats" + ], + "description": "Feature key from the entitlements catalog." + }, + "BinaryEntitlement": { + "type": "object", + "properties": { + "feature": { + "description": "Feature key from the entitlements catalog.", + "allOf": [ + { + "$ref": "#/components/schemas/FeatureKey" + } + ] + }, + "enabled": { + "type": "boolean", + "description": "Whether the plan grants the feature at all. A metered feature reports its quota and usage even when this is false." + }, + "type": { + "type": "string", + "enum": [ + "binary" + ] + } + }, + "required": [ + "feature", + "enabled", + "type" + ] + }, + "ValueEntitlement": { + "type": "object", + "properties": { + "feature": { + "description": "Feature key from the entitlements catalog.", + "allOf": [ + { + "$ref": "#/components/schemas/FeatureKey" + } + ] + }, + "enabled": { + "type": "boolean", + "description": "Whether the plan grants the feature at all. A metered feature reports its quota and usage even when this is false." + }, + "type": { + "type": "string", + "enum": [ + "value" + ] + }, + "value": { + "type": "string", + "nullable": true + } + }, + "required": [ + "feature", + "enabled", + "type", + "value" + ] + }, + "MeteredEntitlement": { + "type": "object", + "properties": { + "feature": { + "description": "Feature key from the entitlements catalog.", + "allOf": [ + { + "$ref": "#/components/schemas/FeatureKey" + } + ] + }, + "enabled": { + "type": "boolean", + "description": "Whether the plan grants the feature at all. A metered feature reports its quota and usage even when this is false." + }, + "type": { + "type": "string", + "enum": [ + "metered" + ] + }, + "quota": { + "type": "number", + "nullable": true, + "description": "The plan's quota, never inflated to match usage; null means unlimited." + }, + "used": { + "type": "number", + "description": "May legally exceed `quota`." + }, + "resetsAt": { + "format": "date-time", + "type": "string", + "nullable": true, + "description": "Null for stock-type features (seats) that have no reset window." + } + }, + "required": [ + "feature", + "enabled", + "type", + "quota", + "used", + "resetsAt" + ] + }, + "EntitlementsPlan": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Plan identifier in the billing service" + }, + "name": { + "type": "string", + "nullable": true + }, + "cycleEndsAt": { + "format": "date-time", + "type": "string", + "nullable": true, + "description": "End of the current billing cycle" + } + }, + "required": [ + "id", + "name", + "cycleEndsAt" + ] + }, + "EntitlementsResponse": { + "type": "object", + "properties": { + "plan": { + "nullable": true, + "description": "Null when the workspace has no active subscription.", + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/EntitlementsPlan" + } + ] + }, + "entitlements": { + "type": "array", + "description": "One entry per catalog feature. Which fields an entry carries is decided by `type`: a metered one always carries quota, usage and its reset window, and the others never do.", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/BinaryEntitlement" + }, + { + "$ref": "#/components/schemas/ValueEntitlement" + }, + { + "$ref": "#/components/schemas/MeteredEntitlement" + } + ], + "discriminator": { + "propertyName": "type", + "mapping": { + "binary": "#/components/schemas/BinaryEntitlement", + "value": "#/components/schemas/ValueEntitlement", + "metered": "#/components/schemas/MeteredEntitlement" + } + } + } + } + }, + "required": [ + "plan", + "entitlements" + ] + }, "SafeList": { "type": "object", "properties": { @@ -10695,7 +10939,7 @@ "gasLimit": { "type": "string", "nullable": true, - "description": "Accepted for backward compatibility and validation; not forwarded to the relay provider (Gelato)." + "description": "Accepted for backward compatibility and validation; not forwarded to the relay provider." }, "safeTxHash": { "type": "string", @@ -12396,6 +12640,20 @@ "type" ] }, + "DeleteModuleGuard": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "DELETE_MODULE_GUARD" + ] + } + }, + "required": [ + "type" + ] + }, "DisableModule": { "type": "object", "properties": { @@ -12490,6 +12748,24 @@ "guard" ] }, + "SetModuleGuard": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "SET_MODULE_GUARD" + ] + }, + "moduleGuard": { + "$ref": "#/components/schemas/AddressInfo" + } + }, + "required": [ + "type", + "moduleGuard" + ] + }, "SettingsChange": { "type": "object", "properties": { @@ -12500,11 +12776,13 @@ "CHANGE_MASTER_COPY", "CHANGE_THRESHOLD", "DELETE_GUARD", + "DELETE_MODULE_GUARD", "DISABLE_MODULE", "ENABLE_MODULE", "REMOVE_OWNER", "SET_FALLBACK_HANDLER", "SET_GUARD", + "SET_MODULE_GUARD", "SWAP_OWNER" ] } @@ -12565,6 +12843,9 @@ { "$ref": "#/components/schemas/DeleteGuard" }, + { + "$ref": "#/components/schemas/DeleteModuleGuard" + }, { "$ref": "#/components/schemas/DisableModule" }, @@ -12580,6 +12861,9 @@ { "$ref": "#/components/schemas/SetGuard" }, + { + "$ref": "#/components/schemas/SetModuleGuard" + }, { "$ref": "#/components/schemas/SwapOwner" } diff --git a/packages/store/scripts/openapi-config.ts b/packages/store/scripts/openapi-config.ts index 85ab0f7b5d..dc27c4ce73 100644 --- a/packages/store/scripts/openapi-config.ts +++ b/packages/store/scripts/openapi-config.ts @@ -71,7 +71,9 @@ const config: ConfigFile = { filterEndpoints: [/^users/], }, '../src/gateway/AUTO_GENERATED/spaces.ts': { - filterEndpoints: [/^(spaces|members|spaceSafes|spaceAudit|spaceCounterfactualSafes|addressBook|userAddressBook)/], + filterEndpoints: [ + /^(spaces|members|spaceSafes|spaceAudit|spaceCounterfactualSafes|addressBook|userAddressBook|entitlements)/, + ], }, '../src/gateway/AUTO_GENERATED/counterfactual-safes.ts': { filterEndpoints: [/^counterfactualSafes/], diff --git a/packages/store/src/gateway/AUTO_GENERATED/.schema-hash b/packages/store/src/gateway/AUTO_GENERATED/.schema-hash index 31fdd2bef0..3de9ddc227 100644 --- a/packages/store/src/gateway/AUTO_GENERATED/.schema-hash +++ b/packages/store/src/gateway/AUTO_GENERATED/.schema-hash @@ -1 +1 @@ -fd472742cf02d28f896f3e389d0fab8a78ebca07f9aeb6b22afc479be27519fe +e9984df7d8acc32efa5370bd62918903c1a9aedda334828d00ef55ff0e922556 diff --git a/packages/store/src/gateway/AUTO_GENERATED/auth.ts b/packages/store/src/gateway/AUTO_GENERATED/auth.ts index cca6b00582..ea6e5d68df 100644 --- a/packages/store/src/gateway/AUTO_GENERATED/auth.ts +++ b/packages/store/src/gateway/AUTO_GENERATED/auth.ts @@ -33,6 +33,7 @@ const injectedRtkApi = api redirect_url: queryArg.redirectUrl, connection: queryArg.connection, enroll: queryArg.enroll, + elevate: queryArg.elevate, }, }), providesTags: ['auth'], @@ -83,6 +84,8 @@ export type OidcAuthAuthorizeV1ApiArg = { connection?: string /** When true, requests hosted enrollment of a new authenticator: the provider challenges an existing factor, then walks the user through enrolling the new one. */ enroll?: boolean + /** When true, requests step-up authentication: the provider re-challenges a second factor and the resulting session is elevated for sensitive actions. */ + elevate?: boolean } export type OidcAuthCallbackV1ApiResponse = unknown export type OidcAuthCallbackV1ApiArg = { diff --git a/packages/store/src/gateway/AUTO_GENERATED/relay.ts b/packages/store/src/gateway/AUTO_GENERATED/relay.ts index 63aa8e2657..31f3cb79ac 100644 --- a/packages/store/src/gateway/AUTO_GENERATED/relay.ts +++ b/packages/store/src/gateway/AUTO_GENERATED/relay.ts @@ -65,7 +65,7 @@ export type RelayDto = { version: string to: string data: string - /** Accepted for backward compatibility and validation; not forwarded to the relay provider (Gelato). */ + /** Accepted for backward compatibility and validation; not forwarded to the relay provider. */ gasLimit?: string | null /** Safe transaction hash for relay-fee eligibility check */ safeTxHash?: string diff --git a/packages/store/src/gateway/AUTO_GENERATED/spaces.ts b/packages/store/src/gateway/AUTO_GENERATED/spaces.ts index 48c073964e..b9cb1d8fa2 100644 --- a/packages/store/src/gateway/AUTO_GENERATED/spaces.ts +++ b/packages/store/src/gateway/AUTO_GENERATED/spaces.ts @@ -1,5 +1,5 @@ import { cgwClient as api } from '../cgwClient' -export const addTagTypes = ['spaces'] as const +export const addTagTypes = ['spaces', 'entitlements'] as const const injectedRtkApi = api .enhanceEndpoints({ addTagTypes, @@ -199,6 +199,13 @@ const injectedRtkApi = api query: (queryArg) => ({ url: `/v1/spaces/${queryArg.spaceId}/counterfactual-safes` }), providesTags: ['spaces'], }), + entitlementsGetEntitlementsV1: build.query< + EntitlementsGetEntitlementsV1ApiResponse, + EntitlementsGetEntitlementsV1ApiArg + >({ + query: (queryArg) => ({ url: `/v1/spaces/${queryArg.spaceId}/entitlements` }), + providesTags: ['entitlements'], + }), }), overrideExisting: false, }) @@ -387,6 +394,11 @@ export type SpaceCounterfactualSafesGetV1ApiArg = { /** Space UUID */ spaceId: string } +export type EntitlementsGetEntitlementsV1ApiResponse = /** status 200 */ EntitlementsResponse +export type EntitlementsGetEntitlementsV1ApiArg = { + /** Space UUID */ + spaceId: string +} export type SpaceAddressBookItemDto = { name: string address: string @@ -612,6 +624,58 @@ export type GetCounterfactualSafesResponse = { [key: string]: GetCounterfactualSafeItem[] } } +export type EntitlementsPlan = { + /** Plan identifier in the billing service */ + id: string + name: string | null + /** End of the current billing cycle */ + cycleEndsAt: string | null +} +export type FeatureKey = 'safe_seats' +export type BinaryEntitlement = { + /** Feature key from the entitlements catalog. */ + feature: FeatureKey + /** Whether the plan grants the feature at all. A metered feature reports its quota and usage even when this is false. */ + enabled: boolean + type: 'binary' +} +export type ValueEntitlement = { + /** Feature key from the entitlements catalog. */ + feature: FeatureKey + /** Whether the plan grants the feature at all. A metered feature reports its quota and usage even when this is false. */ + enabled: boolean + type: 'value' + value: string | null +} +export type MeteredEntitlement = { + /** Feature key from the entitlements catalog. */ + feature: FeatureKey + /** Whether the plan grants the feature at all. A metered feature reports its quota and usage even when this is false. */ + enabled: boolean + type: 'metered' + /** The plan's quota, never inflated to match usage; null means unlimited. */ + quota: number | null + /** May legally exceed `quota`. */ + used: number + /** Null for stock-type features (seats) that have no reset window. */ + resetsAt: string | null +} +export type EntitlementsResponse = { + /** Null when the workspace has no active subscription. */ + plan: EntitlementsPlan | null + /** One entry per catalog feature. Which fields an entry carries is decided by `type`: a metered one always carries quota, usage and its reset window, and the others never do. */ + entitlements: ( + | ({ + type: 'binary' + } & BinaryEntitlement) + | ({ + type: 'value' + } & ValueEntitlement) + | ({ + type: 'metered' + } & MeteredEntitlement) + )[] +} export const { useAddressBooksGetAddressBookItemsV1Query, useLazyAddressBooksGetAddressBookItemsV1Query, @@ -651,4 +715,6 @@ export const { useMembersRemoveUserV1Mutation, useSpaceCounterfactualSafesGetV1Query, useLazySpaceCounterfactualSafesGetV1Query, + useEntitlementsGetEntitlementsV1Query, + useLazyEntitlementsGetEntitlementsV1Query, } = injectedRtkApi diff --git a/packages/store/src/gateway/AUTO_GENERATED/transactions.ts b/packages/store/src/gateway/AUTO_GENERATED/transactions.ts index 50d83d6eaa..95702cf601 100644 --- a/packages/store/src/gateway/AUTO_GENERATED/transactions.ts +++ b/packages/store/src/gateway/AUTO_GENERATED/transactions.ts @@ -370,6 +370,9 @@ export type ChangeThreshold = { export type DeleteGuard = { type: 'DELETE_GUARD' } +export type DeleteModuleGuard = { + type: 'DELETE_MODULE_GUARD' +} export type DisableModule = { type: 'DISABLE_MODULE' module: AddressInfo @@ -391,6 +394,10 @@ export type SetGuard = { type: 'SET_GUARD' guard: AddressInfo } +export type SetModuleGuard = { + type: 'SET_MODULE_GUARD' + moduleGuard: AddressInfo +} export type SwapOwner = { type: 'SWAP_OWNER' oldOwner: AddressInfo @@ -405,11 +412,13 @@ export type SettingsChangeTransaction = { | ChangeMasterCopy | ChangeThreshold | DeleteGuard + | DeleteModuleGuard | DisableModule | EnableModule | RemoveOwner | SetFallbackHandler | SetGuard + | SetModuleGuard | SwapOwner } export type Erc20Transfer = { diff --git a/packages/store/src/gateway/types.ts b/packages/store/src/gateway/types.ts index 406a58ca7c..e054db00bc 100644 --- a/packages/store/src/gateway/types.ts +++ b/packages/store/src/gateway/types.ts @@ -74,6 +74,8 @@ export enum SettingsInfoType { DISABLE_MODULE = 'DISABLE_MODULE', SET_GUARD = 'SET_GUARD', DELETE_GUARD = 'DELETE_GUARD', + SET_MODULE_GUARD = 'SET_MODULE_GUARD', + DELETE_MODULE_GUARD = 'DELETE_MODULE_GUARD', } export enum TransactionInfoType {