Skip to content

feat: share a budget's hours with its customer through a secret link - #100

Open
claude-for-timatic[bot] wants to merge 10 commits into
mainfrom
feat/50-budget-share-link
Open

claude-for-timatic[bot] wants to merge 10 commits into
mainfrom
feat/50-budget-share-link

Conversation

@claude-for-timatic

@claude-for-timatic claude-for-timatic Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

A budget can be shared with its customer through a revocable secret link. The public page lives entirely in the frontend (Timatic/timatic-frontend#14). The backend only makes the secret usable: the share token is a read-only credential for one budget on the existing endpoints, with prices stripped. This PR has no public endpoint of its own.

Closes #50

Contract

Managing the link (needs budgets.update):

  • POST budgets/{budget}/share-link returns 201 with type budget-share-links and attributes token and url. The url is {frontend_url}/shared-budgets/{budgetId}#{token}. Each call replaces the previous link, and the token is shown only here.
  • DELETE budgets/{budget}/share-link returns 204 and revokes the link.
  • The Budget resource gains isShared. Creating a link for a budget with showToCustomer=false answers 422.

Using the link: the SPA sends the header X-Share-Token: <token>. It works for GET only, for that one budget:

  • budgets/{id}, with include limited to customer and/or budgetType
  • budgets/{id}/periods
  • budget-time-spent-totals?filter[budgetId]={id}&periodUnit=…

These fields are absent when the share token is used:

Resource Absent fields
budgets totalPrice, contractId, changeId
periods totalPrice
included customers hourlyRate, externalId

Responses:

  • 401: the token is invalid or revoked, or its budget is hidden.
  • 403: any other route, method, include or budget id (including ids that don't exist), or an array-form query parameter.
  • 429: more than 60 requests a minute per budget, with Retry-After.
  • A bearer token sent along wins over the share token.

Decisions

  • No public endpoint in the backend. This follows the review on this PR. The SPA has no server, so it reads the existing endpoints with the token, and the frontend decides what to show.
  • Implementation: a share_token guard (Auth::viaRequest) returns a SharedBudgetViewer principal that can only budgets.read. The RestrictShareTokenToSharedBudget middleware is the real gate:
    • It allows only the routes, includes and budget listed above.
    • It is registered before SubstituteBindings, so a budget that doesn't exist gets 403 instead of 404 and doesn't reveal which ids exist.
    • The API group is now auth:api,share_token.
  • Prices are stripped in the existing resources when a share token is used. Period prices are removed after the shared period cache is read, so logged-in users keep their prices and the token never gets them.
  • The token: Str::random(48), stored as a sha256 hash. Creating a new link invalidates the old one.
  • The token sits in the URL fragment, so it never reaches server or analytics logs. The budget id is in the path because the SPA needs it.
  • Hiding a budget (show_to_customer=false) revokes its link for good. Showing it again does not bring the old link back. Archived budgets stay readable.
  • config/auth.php gets a shared_budget_viewers provider. It exists only so Larastan knows Auth::user() can be a viewer, and nothing queries it.

Review

The first design (a public shared-budgets/{token} endpoint with its own resources) was replaced after your review. A security review agent then attacked the token. Nothing reached beyond the allowlist: includes, sparse fieldsets, filter arrays, HEAD, other route groups, guard order and policies all hold. Fixed after that review:

  • hiding a budget now revokes its link
  • a bearer-wins test
  • 403 instead of 404 for budgets that don't exist
  • 403 instead of 500 for array query parameters
  • Retry-After on 429
  • the token moved into the URL fragment

Docs to update after #96 (budgets)

  • Share link:

    • It is a read-only credential for one budget on the existing endpoints.
    • It is sent as X-Share-Token and limited to 60 requests a minute.
    • Prices and internal ids are withheld.
    • Only its hash is stored, and creating a new link invalidates the old one.
    • Hiding the budget revokes it for good, and archived budgets stay readable.
    • It answers 403 and never 404.

    Tests: tests/Feature/Budgets/ShareLinkTest.php.

  • GLOSSARY.md: add Share link.

Verification

Full Pest suite green, PHPStan 0 errors, Pint applied.

Only the sha256 hash of the token is stored; the plain token and the
frontend url are returned once. Budgets hidden from the customer cannot
be shared.
The frontend sends the token in the X-Share-Token header. The new
share_token guard resolves it to a SharedBudgetViewer, which may only
GET its own budget (with customer and budgetType), its periods and its
time spent totals, at 60 requests a minute. Prices, contract and change
ids and the customer's hourly rate and external id are left out.
Showing the budget again does not revive the old link; a new one has to
be created.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Share a budget overview with a customer through a secret link

1 participant