refactor(api)!: unify revoke/cancel on PUT /{resource}/{id}/status (#452) (#453)

* refactor(api)!: unify revoke/cancel on PUT /{resource}/{id}/status (#452)

Retire the misleading DELETE /shares/{token} and PATCH /store/orders/{orderId}
shapes in favor of the existing status-subresource convention already used by
background-jobs and download-tasks.

- Shares: PUT /api/shares/{token}/status {status:'revoked'} -> 200 + the updated
  creator ShareView. revokeShare now resolves the share before the UPDATE (the
  record is unresolvable once revoked) and builds the view via a composeShareView
  helper shared with viewShare; concurrently-revoked tokens now return 404.
  Removed the now-dead getCreatorByToken repo port/adapter method.
- Store: PUT /api/store/orders/{orderId}/status {status:'canceled'} -> 200. Only
  the local route shape changed; the upstream cloud SDK $patch call is untouched.
- Frontend: deleteShare -> revokeShare and cancelCloudOrder now use .status.$put
  via the Hono RPC client; updated the shares route component.
- Regenerated the Go OpenAPI client.

Note: revoking a share whose matter is trashed-but-not-purged now returns 404
(was 204), a consequence of reusing viewShare's resolution path.

Agent-Profile: https://agent-kanban.dev/agents/f759c704c282d88a
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(spec): rename share delete scenarios to revoke status-subresource

Align spec/shares.feature scenario tags (@shares/revoke,
@shares/revoke-non-creator) with the renamed [spec:] breadcrumbs so
lint:spec traceability passes.

Agent-Profile: https://agent-kanban.dev/agents/f759c704c282d88a
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(shares): keep revoke working for a trashed-but-not-purged matter

revokeShare switched to resolveByToken, which returned matter_trashed for a
soft-deleted (not purged) matter and was short-circuited to 404. Because
trashing a matter does not cascade to its shares, the share stayed active and
still appeared in the owner's list — so the owner could no longer revoke it
(privacy footgun: restoring the file re-exposed a share they believed revoked).

ShareResolution now carries the share/matter/recipient records on the
matter_trashed variant (and splits not_found/revoked into single-literal members
so control-flow narrowing works). Viewer-facing callers still branch on status,
so trashed -> 410 for viewers is unchanged. revokeShare treats matter_trashed as
revocable (ownership check, revokeByToken, revoked creator view), while not_found
and already-revoked still map to 404.

Adds unit coverage (trashed-matter revoke succeeds; non-creator still 403) and a
backend integration test (share a landing matter, trash it, PUT status revoked ->
200 + status:'revoked', DB flips).

Agent-Profile: https://agent-kanban.dev/agents/f759c704c282d88a
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jasper Van
2026-06-18 02:11:08 -04:00
committed by GitHub
parent 2ae603bbab
commit 78e2550e67
16 changed files with 598 additions and 376 deletions
+314 -243
View File
@@ -1071,18 +1071,33 @@ func (e VerifySharePassword200JSONResponseBodyOk) Valid() bool {
}
}
// Defines values for RevokeShareJSONBodyStatus.
const (
RevokeShareJSONBodyStatusRevoked RevokeShareJSONBodyStatus = "revoked"
)
// Valid indicates whether the value is a known member of the RevokeShareJSONBodyStatus enum.
func (e RevokeShareJSONBodyStatus) Valid() bool {
switch e {
case RevokeShareJSONBodyStatusRevoked:
return true
default:
return false
}
}
// Defines values for ListAnnouncementsParamsScope.
const (
ListAnnouncementsParamsScopeActive ListAnnouncementsParamsScope = "active"
ListAnnouncementsParamsScopeAll ListAnnouncementsParamsScope = "all"
Active ListAnnouncementsParamsScope = "active"
All ListAnnouncementsParamsScope = "all"
)
// Valid indicates whether the value is a known member of the ListAnnouncementsParamsScope enum.
func (e ListAnnouncementsParamsScope) Valid() bool {
switch e {
case ListAnnouncementsParamsScopeActive:
case Active:
return true
case ListAnnouncementsParamsScopeAll:
case All:
return true
default:
return false
@@ -3479,6 +3494,14 @@ type VerifySharePasswordJSONBody struct {
// VerifySharePassword200JSONResponseBodyOk defines parameters for VerifySharePassword.
type VerifySharePassword200JSONResponseBodyOk bool
// RevokeShareJSONBody defines parameters for RevokeShare.
type RevokeShareJSONBody struct {
Status RevokeShareJSONBodyStatus `json:"status"`
}
// RevokeShareJSONBodyStatus defines parameters for RevokeShare.
type RevokeShareJSONBodyStatus string
// ListAnnouncementsParams defines parameters for ListAnnouncements.
type ListAnnouncementsParams struct {
Page *int `form:"page,omitempty" json:"page,omitempty"`
@@ -4002,6 +4025,9 @@ type SaveShareJSONRequestBody SaveShareJSONBody
// VerifySharePasswordJSONRequestBody defines body for VerifySharePassword for application/json ContentType.
type VerifySharePasswordJSONRequestBody VerifySharePasswordJSONBody
// RevokeShareJSONRequestBody defines body for RevokeShare for application/json ContentType.
type RevokeShareJSONRequestBody RevokeShareJSONBody
// CreateAnnouncementJSONRequestBody defines body for CreateAnnouncement for application/json ContentType.
type CreateAnnouncementJSONRequestBody CreateAnnouncementJSONBody
@@ -5017,9 +5043,6 @@ type ClientInterface interface {
CreateShare(ctx context.Context, body CreateShareJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
// RevokeShare request
RevokeShare(ctx context.Context, token string, reqEditors ...RequestEditorFn) (*http.Response, error)
// GetShare request
GetShare(ctx context.Context, token string, reqEditors ...RequestEditorFn) (*http.Response, error)
@@ -5036,6 +5059,11 @@ type ClientInterface interface {
VerifySharePassword(ctx context.Context, token string, body VerifySharePasswordJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
// RevokeShareWithBody request with any body
RevokeShareWithBody(ctx context.Context, token string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
RevokeShare(ctx context.Context, token string, body RevokeShareJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
// ListAnnouncements request
ListAnnouncements(ctx context.Context, params *ListAnnouncementsParams, reqEditors ...RequestEditorFn) (*http.Response, error)
@@ -5208,14 +5236,14 @@ type ClientInterface interface {
// ListOrders request
ListOrders(ctx context.Context, params *ListOrdersParams, reqEditors ...RequestEditorFn) (*http.Response, error)
// ContinueOrderPayment request
ContinueOrderPayment(ctx context.Context, orderId string, reqEditors ...RequestEditorFn) (*http.Response, error)
// CancelOrderWithBody request with any body
CancelOrderWithBody(ctx context.Context, orderId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
CancelOrder(ctx context.Context, orderId string, body CancelOrderJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
// ContinueOrderPayment request
ContinueOrderPayment(ctx context.Context, orderId string, reqEditors ...RequestEditorFn) (*http.Response, error)
// ListStorePackages request
ListStorePackages(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error)
@@ -7788,18 +7816,6 @@ func (c *Client) CreateShare(ctx context.Context, body CreateShareJSONRequestBod
return c.Client.Do(req)
}
func (c *Client) RevokeShare(ctx context.Context, token string, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewRevokeShareRequest(c.Server, token)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
return c.Client.Do(req)
}
func (c *Client) GetShare(ctx context.Context, token string, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewGetShareRequest(c.Server, token)
if err != nil {
@@ -7872,6 +7888,30 @@ func (c *Client) VerifySharePassword(ctx context.Context, token string, body Ver
return c.Client.Do(req)
}
func (c *Client) RevokeShareWithBody(ctx context.Context, token string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewRevokeShareRequestWithBody(c.Server, token, contentType, body)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
return c.Client.Do(req)
}
func (c *Client) RevokeShare(ctx context.Context, token string, body RevokeShareJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewRevokeShareRequest(c.Server, token, body)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
return c.Client.Do(req)
}
func (c *Client) ListAnnouncements(ctx context.Context, params *ListAnnouncementsParams, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewListAnnouncementsRequest(c.Server, params)
if err != nil {
@@ -8616,6 +8656,18 @@ func (c *Client) ListOrders(ctx context.Context, params *ListOrdersParams, reqEd
return c.Client.Do(req)
}
func (c *Client) ContinueOrderPayment(ctx context.Context, orderId string, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewContinueOrderPaymentRequest(c.Server, orderId)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
return c.Client.Do(req)
}
func (c *Client) CancelOrderWithBody(ctx context.Context, orderId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewCancelOrderRequestWithBody(c.Server, orderId, contentType, body)
if err != nil {
@@ -8640,18 +8692,6 @@ func (c *Client) CancelOrder(ctx context.Context, orderId string, body CancelOrd
return c.Client.Do(req)
}
func (c *Client) ContinueOrderPayment(ctx context.Context, orderId string, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewContinueOrderPaymentRequest(c.Server, orderId)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
return c.Client.Do(req)
}
func (c *Client) ListStorePackages(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewListStorePackagesRequest(c.Server)
if err != nil {
@@ -14702,40 +14742,6 @@ func NewCreateShareRequestWithBody(server string, contentType string, body io.Re
return req, nil
}
// NewRevokeShareRequest generates requests for RevokeShare
func NewRevokeShareRequest(server string, token string) (*http.Request, error) {
var err error
var pathParam0 string
pathParam0, err = runtime.StyleParamWithOptions("simple", false, "token", token, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
if err != nil {
return nil, err
}
serverURL, err := url.Parse(server)
if err != nil {
return nil, err
}
operationPath := fmt.Sprintf("/api/shares/%s", pathParam0)
if operationPath[0] == '/' {
operationPath = "." + operationPath
}
queryURL, err := serverURL.Parse(operationPath)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil)
if err != nil {
return nil, err
}
return req, nil
}
// NewGetShareRequest generates requests for GetShare
func NewGetShareRequest(server string, token string) (*http.Request, error) {
var err error
@@ -14949,6 +14955,53 @@ func NewVerifySharePasswordRequestWithBody(server string, token string, contentT
return req, nil
}
// NewRevokeShareRequest calls the generic RevokeShare builder with application/json body
func NewRevokeShareRequest(server string, token string, body RevokeShareJSONRequestBody) (*http.Request, error) {
var bodyReader io.Reader
buf, err := json.Marshal(body)
if err != nil {
return nil, err
}
bodyReader = bytes.NewReader(buf)
return NewRevokeShareRequestWithBody(server, token, "application/json", bodyReader)
}
// NewRevokeShareRequestWithBody generates requests for RevokeShare with any type of body
func NewRevokeShareRequestWithBody(server string, token string, contentType string, body io.Reader) (*http.Request, error) {
var err error
var pathParam0 string
pathParam0, err = runtime.StyleParamWithOptions("simple", false, "token", token, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
if err != nil {
return nil, err
}
serverURL, err := url.Parse(server)
if err != nil {
return nil, err
}
operationPath := fmt.Sprintf("/api/shares/%s/status", pathParam0)
if operationPath[0] == '/' {
operationPath = "." + operationPath
}
queryURL, err := serverURL.Parse(operationPath)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodPut, queryURL.String(), body)
if err != nil {
return nil, err
}
req.Header.Add("Content-Type", contentType)
return req, nil
}
// NewListAnnouncementsRequest generates requests for ListAnnouncements
func NewListAnnouncementsRequest(server string, params *ListAnnouncementsParams) (*http.Request, error) {
var err error
@@ -16840,53 +16893,6 @@ func NewListOrdersRequest(server string, params *ListOrdersParams) (*http.Reques
return req, nil
}
// NewCancelOrderRequest calls the generic CancelOrder builder with application/json body
func NewCancelOrderRequest(server string, orderId string, body CancelOrderJSONRequestBody) (*http.Request, error) {
var bodyReader io.Reader
buf, err := json.Marshal(body)
if err != nil {
return nil, err
}
bodyReader = bytes.NewReader(buf)
return NewCancelOrderRequestWithBody(server, orderId, "application/json", bodyReader)
}
// NewCancelOrderRequestWithBody generates requests for CancelOrder with any type of body
func NewCancelOrderRequestWithBody(server string, orderId string, contentType string, body io.Reader) (*http.Request, error) {
var err error
var pathParam0 string
pathParam0, err = runtime.StyleParamWithOptions("simple", false, "orderId", orderId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
if err != nil {
return nil, err
}
serverURL, err := url.Parse(server)
if err != nil {
return nil, err
}
operationPath := fmt.Sprintf("/api/store/orders/%s", pathParam0)
if operationPath[0] == '/' {
operationPath = "." + operationPath
}
queryURL, err := serverURL.Parse(operationPath)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodPatch, queryURL.String(), body)
if err != nil {
return nil, err
}
req.Header.Add("Content-Type", contentType)
return req, nil
}
// NewContinueOrderPaymentRequest generates requests for ContinueOrderPayment
func NewContinueOrderPaymentRequest(server string, orderId string) (*http.Request, error) {
var err error
@@ -16921,6 +16927,53 @@ func NewContinueOrderPaymentRequest(server string, orderId string) (*http.Reques
return req, nil
}
// NewCancelOrderRequest calls the generic CancelOrder builder with application/json body
func NewCancelOrderRequest(server string, orderId string, body CancelOrderJSONRequestBody) (*http.Request, error) {
var bodyReader io.Reader
buf, err := json.Marshal(body)
if err != nil {
return nil, err
}
bodyReader = bytes.NewReader(buf)
return NewCancelOrderRequestWithBody(server, orderId, "application/json", bodyReader)
}
// NewCancelOrderRequestWithBody generates requests for CancelOrder with any type of body
func NewCancelOrderRequestWithBody(server string, orderId string, contentType string, body io.Reader) (*http.Request, error) {
var err error
var pathParam0 string
pathParam0, err = runtime.StyleParamWithOptions("simple", false, "orderId", orderId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
if err != nil {
return nil, err
}
serverURL, err := url.Parse(server)
if err != nil {
return nil, err
}
operationPath := fmt.Sprintf("/api/store/orders/%s/status", pathParam0)
if operationPath[0] == '/' {
operationPath = "." + operationPath
}
queryURL, err := serverURL.Parse(operationPath)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodPut, queryURL.String(), body)
if err != nil {
return nil, err
}
req.Header.Add("Content-Type", contentType)
return req, nil
}
// NewListStorePackagesRequest generates requests for ListStorePackages
func NewListStorePackagesRequest(server string) (*http.Request, error) {
var err error
@@ -18460,9 +18513,6 @@ type ClientWithResponsesInterface interface {
CreateShareWithResponse(ctx context.Context, body CreateShareJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateShareResponse, error)
// RevokeShareWithResponse request
RevokeShareWithResponse(ctx context.Context, token string, reqEditors ...RequestEditorFn) (*RevokeShareResponse, error)
// GetShareWithResponse request
GetShareWithResponse(ctx context.Context, token string, reqEditors ...RequestEditorFn) (*GetShareResponse, error)
@@ -18479,6 +18529,11 @@ type ClientWithResponsesInterface interface {
VerifySharePasswordWithResponse(ctx context.Context, token string, body VerifySharePasswordJSONRequestBody, reqEditors ...RequestEditorFn) (*VerifySharePasswordResponse, error)
// RevokeShareWithBodyWithResponse request with any body
RevokeShareWithBodyWithResponse(ctx context.Context, token string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*RevokeShareResponse, error)
RevokeShareWithResponse(ctx context.Context, token string, body RevokeShareJSONRequestBody, reqEditors ...RequestEditorFn) (*RevokeShareResponse, error)
// ListAnnouncementsWithResponse request
ListAnnouncementsWithResponse(ctx context.Context, params *ListAnnouncementsParams, reqEditors ...RequestEditorFn) (*ListAnnouncementsResponse, error)
@@ -18651,14 +18706,14 @@ type ClientWithResponsesInterface interface {
// ListOrdersWithResponse request
ListOrdersWithResponse(ctx context.Context, params *ListOrdersParams, reqEditors ...RequestEditorFn) (*ListOrdersResponse, error)
// ContinueOrderPaymentWithResponse request
ContinueOrderPaymentWithResponse(ctx context.Context, orderId string, reqEditors ...RequestEditorFn) (*ContinueOrderPaymentResponse, error)
// CancelOrderWithBodyWithResponse request with any body
CancelOrderWithBodyWithResponse(ctx context.Context, orderId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CancelOrderResponse, error)
CancelOrderWithResponse(ctx context.Context, orderId string, body CancelOrderJSONRequestBody, reqEditors ...RequestEditorFn) (*CancelOrderResponse, error)
// ContinueOrderPaymentWithResponse request
ContinueOrderPaymentWithResponse(ctx context.Context, orderId string, reqEditors ...RequestEditorFn) (*ContinueOrderPaymentResponse, error)
// ListStorePackagesWithResponse request
ListStorePackagesWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListStorePackagesResponse, error)
@@ -24835,37 +24890,6 @@ func (r CreateShareResponse) ContentType() string {
return ""
}
type RevokeShareResponse struct {
Body []byte
HTTPResponse *http.Response
JSON403 *Error
JSON404 *Error
}
// Status returns HTTPResponse.Status
func (r RevokeShareResponse) Status() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Status
}
return http.StatusText(0)
}
// StatusCode returns HTTPResponse.StatusCode
func (r RevokeShareResponse) StatusCode() int {
if r.HTTPResponse != nil {
return r.HTTPResponse.StatusCode
}
return 0
}
// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers
func (r RevokeShareResponse) ContentType() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Header.Get("Content-Type")
}
return ""
}
type GetShareResponse struct {
Body []byte
HTTPResponse *http.Response
@@ -25002,6 +25026,38 @@ func (r VerifySharePasswordResponse) ContentType() string {
return ""
}
type RevokeShareResponse struct {
Body []byte
HTTPResponse *http.Response
JSON200 *ShareView
JSON403 *Error
JSON404 *Error
}
// Status returns HTTPResponse.Status
func (r RevokeShareResponse) Status() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Status
}
return http.StatusText(0)
}
// StatusCode returns HTTPResponse.StatusCode
func (r RevokeShareResponse) StatusCode() int {
if r.HTTPResponse != nil {
return r.HTTPResponse.StatusCode
}
return 0
}
// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers
func (r RevokeShareResponse) ContentType() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Header.Get("Content-Type")
}
return ""
}
type ListAnnouncementsResponse struct {
Body []byte
HTTPResponse *http.Response
@@ -26507,40 +26563,6 @@ func (r ListOrdersResponse) ContentType() string {
return ""
}
type CancelOrderResponse struct {
Body []byte
HTTPResponse *http.Response
JSON200 *CloudStoreValue
JSON400 *Error
JSON403 *Error
JSON404 *Error
JSON502 *Error
}
// Status returns HTTPResponse.Status
func (r CancelOrderResponse) Status() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Status
}
return http.StatusText(0)
}
// StatusCode returns HTTPResponse.StatusCode
func (r CancelOrderResponse) StatusCode() int {
if r.HTTPResponse != nil {
return r.HTTPResponse.StatusCode
}
return 0
}
// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers
func (r CancelOrderResponse) ContentType() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Header.Get("Content-Type")
}
return ""
}
type ContinueOrderPaymentResponse struct {
Body []byte
HTTPResponse *http.Response
@@ -26575,6 +26597,40 @@ func (r ContinueOrderPaymentResponse) ContentType() string {
return ""
}
type CancelOrderResponse struct {
Body []byte
HTTPResponse *http.Response
JSON200 *CloudStoreValue
JSON400 *Error
JSON403 *Error
JSON404 *Error
JSON502 *Error
}
// Status returns HTTPResponse.Status
func (r CancelOrderResponse) Status() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Status
}
return http.StatusText(0)
}
// StatusCode returns HTTPResponse.StatusCode
func (r CancelOrderResponse) StatusCode() int {
if r.HTTPResponse != nil {
return r.HTTPResponse.StatusCode
}
return 0
}
// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers
func (r CancelOrderResponse) ContentType() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Header.Get("Content-Type")
}
return ""
}
type ListStorePackagesResponse struct {
Body []byte
HTTPResponse *http.Response
@@ -29158,15 +29214,6 @@ func (c *ClientWithResponses) CreateShareWithResponse(ctx context.Context, body
return ParseCreateShareResponse(rsp)
}
// RevokeShareWithResponse request returning *RevokeShareResponse
func (c *ClientWithResponses) RevokeShareWithResponse(ctx context.Context, token string, reqEditors ...RequestEditorFn) (*RevokeShareResponse, error) {
rsp, err := c.RevokeShare(ctx, token, reqEditors...)
if err != nil {
return nil, err
}
return ParseRevokeShareResponse(rsp)
}
// GetShareWithResponse request returning *GetShareResponse
func (c *ClientWithResponses) GetShareWithResponse(ctx context.Context, token string, reqEditors ...RequestEditorFn) (*GetShareResponse, error) {
rsp, err := c.GetShare(ctx, token, reqEditors...)
@@ -29219,6 +29266,23 @@ func (c *ClientWithResponses) VerifySharePasswordWithResponse(ctx context.Contex
return ParseVerifySharePasswordResponse(rsp)
}
// RevokeShareWithBodyWithResponse request with arbitrary body returning *RevokeShareResponse
func (c *ClientWithResponses) RevokeShareWithBodyWithResponse(ctx context.Context, token string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*RevokeShareResponse, error) {
rsp, err := c.RevokeShareWithBody(ctx, token, contentType, body, reqEditors...)
if err != nil {
return nil, err
}
return ParseRevokeShareResponse(rsp)
}
func (c *ClientWithResponses) RevokeShareWithResponse(ctx context.Context, token string, body RevokeShareJSONRequestBody, reqEditors ...RequestEditorFn) (*RevokeShareResponse, error) {
rsp, err := c.RevokeShare(ctx, token, body, reqEditors...)
if err != nil {
return nil, err
}
return ParseRevokeShareResponse(rsp)
}
// ListAnnouncementsWithResponse request returning *ListAnnouncementsResponse
func (c *ClientWithResponses) ListAnnouncementsWithResponse(ctx context.Context, params *ListAnnouncementsParams, reqEditors ...RequestEditorFn) (*ListAnnouncementsResponse, error) {
rsp, err := c.ListAnnouncements(ctx, params, reqEditors...)
@@ -29763,6 +29827,15 @@ func (c *ClientWithResponses) ListOrdersWithResponse(ctx context.Context, params
return ParseListOrdersResponse(rsp)
}
// ContinueOrderPaymentWithResponse request returning *ContinueOrderPaymentResponse
func (c *ClientWithResponses) ContinueOrderPaymentWithResponse(ctx context.Context, orderId string, reqEditors ...RequestEditorFn) (*ContinueOrderPaymentResponse, error) {
rsp, err := c.ContinueOrderPayment(ctx, orderId, reqEditors...)
if err != nil {
return nil, err
}
return ParseContinueOrderPaymentResponse(rsp)
}
// CancelOrderWithBodyWithResponse request with arbitrary body returning *CancelOrderResponse
func (c *ClientWithResponses) CancelOrderWithBodyWithResponse(ctx context.Context, orderId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CancelOrderResponse, error) {
rsp, err := c.CancelOrderWithBody(ctx, orderId, contentType, body, reqEditors...)
@@ -29780,15 +29853,6 @@ func (c *ClientWithResponses) CancelOrderWithResponse(ctx context.Context, order
return ParseCancelOrderResponse(rsp)
}
// ContinueOrderPaymentWithResponse request returning *ContinueOrderPaymentResponse
func (c *ClientWithResponses) ContinueOrderPaymentWithResponse(ctx context.Context, orderId string, reqEditors ...RequestEditorFn) (*ContinueOrderPaymentResponse, error) {
rsp, err := c.ContinueOrderPayment(ctx, orderId, reqEditors...)
if err != nil {
return nil, err
}
return ParseContinueOrderPaymentResponse(rsp)
}
// ListStorePackagesWithResponse request returning *ListStorePackagesResponse
func (c *ClientWithResponses) ListStorePackagesWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListStorePackagesResponse, error) {
rsp, err := c.ListStorePackages(ctx, reqEditors...)
@@ -39150,39 +39214,6 @@ func ParseCreateShareResponse(rsp *http.Response) (*CreateShareResponse, error)
return response, nil
}
// ParseRevokeShareResponse parses an HTTP response from a RevokeShareWithResponse call
func ParseRevokeShareResponse(rsp *http.Response) (*RevokeShareResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
defer func() { _ = rsp.Body.Close() }()
if err != nil {
return nil, err
}
response := &RevokeShareResponse{
Body: bodyBytes,
HTTPResponse: rsp,
}
switch {
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403:
var dest Error
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON403 = &dest
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404:
var dest Error
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON404 = &dest
}
return response, nil
}
// ParseGetShareResponse parses an HTTP response from a GetShareWithResponse call
func ParseGetShareResponse(rsp *http.Response) (*GetShareResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
@@ -39387,6 +39418,46 @@ func ParseVerifySharePasswordResponse(rsp *http.Response) (*VerifySharePasswordR
return response, nil
}
// ParseRevokeShareResponse parses an HTTP response from a RevokeShareWithResponse call
func ParseRevokeShareResponse(rsp *http.Response) (*RevokeShareResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
defer func() { _ = rsp.Body.Close() }()
if err != nil {
return nil, err
}
response := &RevokeShareResponse{
Body: bodyBytes,
HTTPResponse: rsp,
}
switch {
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
var dest ShareView
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON200 = &dest
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403:
var dest Error
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON403 = &dest
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404:
var dest Error
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON404 = &dest
}
return response, nil
}
// ParseListAnnouncementsResponse parses an HTTP response from a ListAnnouncementsWithResponse call
func ParseListAnnouncementsResponse(rsp *http.Response) (*ListAnnouncementsResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
@@ -41015,15 +41086,15 @@ func ParseListOrdersResponse(rsp *http.Response) (*ListOrdersResponse, error) {
return response, nil
}
// ParseCancelOrderResponse parses an HTTP response from a CancelOrderWithResponse call
func ParseCancelOrderResponse(rsp *http.Response) (*CancelOrderResponse, error) {
// ParseContinueOrderPaymentResponse parses an HTTP response from a ContinueOrderPaymentWithResponse call
func ParseContinueOrderPaymentResponse(rsp *http.Response) (*ContinueOrderPaymentResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
defer func() { _ = rsp.Body.Close() }()
if err != nil {
return nil, err
}
response := &CancelOrderResponse{
response := &ContinueOrderPaymentResponse{
Body: bodyBytes,
HTTPResponse: rsp,
}
@@ -41069,15 +41140,15 @@ func ParseCancelOrderResponse(rsp *http.Response) (*CancelOrderResponse, error)
return response, nil
}
// ParseContinueOrderPaymentResponse parses an HTTP response from a ContinueOrderPaymentWithResponse call
func ParseContinueOrderPaymentResponse(rsp *http.Response) (*ContinueOrderPaymentResponse, error) {
// ParseCancelOrderResponse parses an HTTP response from a CancelOrderWithResponse call
func ParseCancelOrderResponse(rsp *http.Response) (*CancelOrderResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
defer func() { _ = rsp.Body.Close() }()
if err != nil {
return nil, err
}
response := &ContinueOrderPaymentResponse{
response := &CancelOrderResponse{
Body: bodyBytes,
HTTPResponse: rsp,
}
+3 -6
View File
@@ -88,10 +88,12 @@ export function createShareRepo(db: Database): ShareRepo {
const row = rows[0]
if (!row) return { status: 'not_found' }
if (row.share.status === 'revoked') return { status: 'revoked' }
if (row.matter.status === 'trashed') return { status: 'matter_trashed' }
const recipients = await db.select().from(shareRecipients).where(eq(shareRecipients.shareId, row.share.id))
if (row.matter.status === 'trashed')
return { status: 'matter_trashed', share: row.share, matter: row.matter, recipients }
return { status: 'ok', share: row.share, matter: row.matter, recipients }
},
@@ -170,11 +172,6 @@ export function createShareRepo(db: Database): ShareRepo {
])
},
async getCreatorByToken(token: string): Promise<string | null> {
const rows = await db.select({ creatorId: shares.creatorId }).from(shares).where(eq(shares.token, token))
return rows[0]?.creatorId ?? null
},
async revokeByToken(token: string, creatorId: string): Promise<boolean> {
const result = await db
.update(shares)
+65 -13
View File
@@ -418,7 +418,7 @@ describe('GET /api/shares', () => {
const body2 = (await res2.json()) as Record<string, unknown>
// Revoke the second share
await app.request(`/api/shares/${body2.token}`, { method: 'DELETE', headers })
await revokeRequest(app, body2.token as string, headers)
const resActive = await app.request('/api/shares?status=active', { headers })
const activeBody = (await resActive.json()) as { items: unknown[]; total: number }
@@ -859,22 +859,33 @@ describe('POST /api/shares/:token/objects', () => {
})
})
// ─── DELETE /api/shares/:token ────────────────────────────────────────────────
// ─── PUT /api/shares/:token/status ───────────────────────────────────────────
describe('DELETE /api/shares/:token (auth guard)', () => {
const revokeRequest = (app: Awaited<ReturnType<typeof createTestApp>>['app'], token: string, headers: HeadersInit) =>
app.request(`/api/shares/${token}/status`, {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'revoked' }),
})
describe('PUT /api/shares/:token/status (auth guard)', () => {
it('returns 401 without auth', async () => {
const { app } = await createTestApp()
const res = await app.request('/api/shares/some-token', { method: 'DELETE' })
const res = await app.request('/api/shares/some-token/status', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'revoked' }),
})
expect(res.status).toBe(401)
})
})
describe('DELETE /api/shares/:token', () => {
describe('PUT /api/shares/:token/status', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('creator can delete their share and share status becomes revoked in DB [spec: shares/delete]', async () => {
it('creator can revoke their share, gets the revoked view, and DB status flips [spec: shares/revoke]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
@@ -884,14 +895,18 @@ describe('DELETE /api/shares/:token', () => {
const createRes = await createShare(app, headers, { matterId: 'del1', kind: 'landing' })
const token = ((await createRes.json()) as Record<string, unknown>).token as string
const res = await app.request(`/api/shares/${token}`, { method: 'DELETE', headers })
expect(res.status).toBe(204)
const res = await revokeRequest(app, token, headers)
expect(res.status).toBe(200)
const body = (await res.json()) as { token: string; status: string; id?: string }
expect(body.token).toBe(token)
expect(body.status).toBe('revoked')
expect(body.id).toBeTruthy()
const rows = await db.select({ status: shares.status }).from(shares).where(eq(shares.token, token))
expect(rows[0]?.status).toBe('revoked')
})
it('returns 403 when non-creator tries to delete a share [spec: shares/delete-non-creator]', async () => {
it('returns 403 when non-creator tries to revoke a share [spec: shares/revoke-non-creator]', async () => {
const { app, db } = await createTestApp()
await insertStorage(db)
@@ -903,7 +918,7 @@ describe('DELETE /api/shares/:token', () => {
const token = ((await createRes.json()) as Record<string, unknown>).token as string
const headersB = await authedHeaders(app, `del-other-${nanoid()}@example.com`)
const res = await app.request(`/api/shares/${token}`, { method: 'DELETE', headers: headersB })
const res = await revokeRequest(app, token, headersB)
expect(res.status).toBe(403)
})
@@ -911,9 +926,46 @@ describe('DELETE /api/shares/:token', () => {
const { app } = await createTestApp()
const headers = await authedHeaders(app)
const res = await app.request('/api/shares/does-not-exist', { method: 'DELETE', headers })
const res = await revokeRequest(app, 'does-not-exist', headers)
expect(res.status).toBe(404)
})
it('returns 404 when revoking an already-revoked share', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'del3', name: 'twice.txt' })
const createRes = await createShare(app, headers, { matterId: 'del3', kind: 'landing' })
const token = ((await createRes.json()) as Record<string, unknown>).token as string
expect((await revokeRequest(app, token, headers)).status).toBe(200)
expect((await revokeRequest(app, token, headers)).status).toBe(404)
})
it('creator can still revoke a share whose matter was trashed (not purged) [spec: shares/revoke-trashed-matter]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'del4', name: 'trashed-then-revoked.txt' })
const createRes = await createShare(app, headers, { matterId: 'del4', kind: 'landing' })
const token = ((await createRes.json()) as Record<string, unknown>).token as string
// Soft-delete the matter without purging it — the share row stays active.
await db.run(sql`UPDATE matters SET status = 'trashed' WHERE id = 'del4'`)
const res = await revokeRequest(app, token, headers)
expect(res.status).toBe(200)
const body = (await res.json()) as { token: string; status: string }
expect(body.token).toBe(token)
expect(body.status).toBe('revoked')
const rows = await db.select({ status: shares.status }).from(shares).where(eq(shares.token, token))
expect(rows[0]?.status).toBe('revoked')
})
})
describe('GET /api/shares?box=received', () => {
@@ -980,8 +1032,8 @@ describe('GET /api/shares?box=received', () => {
recipients: [{ recipientUserId: recipientId }],
})
const token = ((await created.json()) as Record<string, unknown>).token as string
const revoke = await app.request(`/api/shares/${token}`, { method: 'DELETE', headers: creatorHeaders })
expect(revoke.status).toBe(204)
const revoke = await revokeRequest(app, token, creatorHeaders)
expect(revoke.status).toBe(200)
const res = await app.request('/api/shares?box=received', { headers: recipientHeaders })
const body = (await res.json()) as { total: number }
+8 -5
View File
@@ -341,11 +341,14 @@ const revokeShareRoute = createRoute({
operationId: 'revokeShare',
summary: 'Revoke a share',
tags: ['Shares'],
method: 'delete',
path: '/{token}',
request: { params: z.object({ token: z.string() }) },
method: 'put',
path: '/{token}/status',
request: {
params: z.object({ token: z.string() }),
...jsonBody(z.object({ status: z.literal('revoked') })),
},
responses: {
204: { description: 'Revoked' },
200: jsonContent(shareViewSchema, 'Revoked share'),
403: errorResponse('Forbidden'),
404: errorResponse('Not found'),
},
@@ -405,7 +408,7 @@ export const authedShares = authedApp
userId: c.get('userId')!,
orgId: c.get('orgId')!,
})
if (out.ok) return c.body(null, 204)
if (out.ok) return c.json(toShareViewDTO(out.dto), 200)
throw out.error
})
.openapi(saveShareRoute, async (c) => {
@@ -288,7 +288,7 @@ describe('Share lifecycle audit events', () => {
expect(meta.hasPassword).toBe(false)
})
it('records share_revoke when a share is deleted', async () => {
it('records share_revoke when a share is revoked', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
@@ -303,11 +303,12 @@ describe('Share lifecycle audit events', () => {
})
const { token } = (await createRes.json()) as { token: string }
const revokeRes = await app.request(`/api/shares/${token}`, {
method: 'DELETE',
headers,
const revokeRes = await app.request(`/api/shares/${token}/status`, {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'revoked' }),
})
expect(revokeRes.status).toBe(204)
expect(revokeRes.status).toBe(200)
const evt = await getLatestActivity(db, 'share_revoke')
expect(evt).toBeDefined()
+8 -8
View File
@@ -1026,8 +1026,8 @@ describe('Quota Store API', () => {
status: 200,
json: async () => cloudOrder({ id: 'order-cloud-1', target: { orgId, customerId: orgId } }),
} as Response)
const canceled = await app.request('/api/store/orders/order-cloud-1', {
method: 'PATCH',
const canceled = await app.request('/api/store/orders/order-cloud-1/status', {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'canceled' }),
})
@@ -1072,8 +1072,8 @@ describe('Quota Store API', () => {
method: 'POST',
headers,
})
const canceled = await app.request('/api/store/orders/order-other-org', {
method: 'PATCH',
const canceled = await app.request('/api/store/orders/order-other-org/status', {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'canceled' }),
})
@@ -2406,8 +2406,8 @@ describe('Quota Store API — storefront proxy error branches', () => {
status: 500,
json: async () => ({ error: 'cloud_boom' }),
} as Response)
const cancel = await app.request('/api/store/orders/order-err', {
method: 'PATCH',
const cancel = await app.request('/api/store/orders/order-err/status', {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'canceled' }),
})
@@ -2423,8 +2423,8 @@ describe('Quota Store API — storefront proxy error branches', () => {
const orders = await app.request('/api/store/orders', { headers })
const payment = await app.request('/api/store/orders/order-1/payments', { method: 'POST', headers })
const cancel = await app.request('/api/store/orders/order-1', {
method: 'PATCH',
const cancel = await app.request('/api/store/orders/order-1/status', {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'canceled' }),
})
+2 -2
View File
@@ -188,8 +188,8 @@ const cancelOrderRoute = createRoute({
operationId: 'cancelOrder',
summary: 'Cancel an order',
tags: ['Store'],
method: 'patch',
path: '/orders/{orderId}',
method: 'put',
path: '/orders/{orderId}/status',
middleware: [requireTeamRole('owner')] as const,
request: { params: z.object({ orderId: z.string() }), ...jsonBody(z.object({ status: z.literal('canceled') })) },
responses: {
+5 -2
View File
@@ -49,7 +49,11 @@ export interface ShareListItem {
export type ShareResolution =
| { status: 'ok'; share: ShareRecord; matter: Matter; recipients: ShareRecipientRecord[] }
| { status: 'not_found' | 'revoked' | 'matter_trashed' }
// matter_trashed carries the records so the creator-only revoke path can still
// act on the share; viewer-facing callers branch on `status` and ignore them.
| { status: 'matter_trashed'; share: ShareRecord; matter: Matter; recipients: ShareRecipientRecord[] }
| { status: 'not_found' }
| { status: 'revoked' }
// Thrown by createShare on invalid share-shape combinations. Carries a stable
// code the http layer maps to a 400/404.
@@ -68,7 +72,6 @@ export interface ShareRepo {
decrementDownloads(shareId: string): Promise<void>
listRecipientUserIds(shareId: string): Promise<string[]>
cascadeDeleteByMatter(matterId: string): Promise<void>
getCreatorByToken(token: string): Promise<string | null>
revokeByToken(token: string, creatorId: string): Promise<boolean>
listForApi(
creatorId: string,
+10 -1
View File
@@ -178,7 +178,16 @@ describe('resolveDirectShareDownload', () => {
})
it('returns matter_trashed when the share resolves to a trashed matter', async () => {
const { deps } = makeDeps({ share: { resolveByToken: async () => ({ status: 'matter_trashed' }) } })
const { deps } = makeDeps({
share: {
resolveByToken: async () => ({
status: 'matter_trashed',
share: sampleShare,
matter: sampleMatter,
recipients: [],
}),
},
})
const out = await resolveDirectShareDownload(deps, { token: 'ds_token1', cloudBaseUrl: CLOUD_BASE_URL })
expectError(out, 410, undefined, 'File no longer available')
})
+66 -17
View File
@@ -122,6 +122,10 @@ const okResolution = (
over: { share?: ShareRecord; matter?: Matter; recipients?: ShareRecipientRecord[] } = {},
): ShareResolution => ({ status: 'ok', share: landingShare, matter: fileMatter, recipients: [], ...over })
const trashedResolution = (
over: { share?: ShareRecord; matter?: Matter; recipients?: ShareRecipientRecord[] } = {},
): ShareResolution => ({ status: 'matter_trashed', share: landingShare, matter: fileMatter, recipients: [], ...over })
function makeShareRepo(over: Partial<ShareRepo> = {}): ShareRepo {
return {
resolveByToken: async () => okResolution(),
@@ -129,7 +133,6 @@ function makeShareRepo(over: Partial<ShareRepo> = {}): ShareRepo {
hasDownloadsAvailable: async () => true,
incrementDownloadsAtomic: async () => ({ ok: true, downloads: 3 }),
decrementDownloads: async () => {},
getCreatorByToken: async () => 'creator-1',
revokeByToken: async () => true,
listForApi: async () => ({ items: [], total: 0 }),
listReceivedForApi: async () => ({ items: [], total: 0 }),
@@ -216,7 +219,7 @@ beforeEach(() => {
describe('viewShare', () => {
it('returns matter_trashed when the matter is trashed', async () => {
const { deps } = makeDeps({ share: { resolveByToken: async () => ({ status: 'matter_trashed' }) } })
const { deps } = makeDeps({ share: { resolveByToken: async () => trashedResolution() } })
expectError(
await viewShare(deps, { token: 't', viewerId: null, viewCookie: undefined, accessCookie: undefined }),
410,
@@ -419,7 +422,7 @@ describe('listShareObjects', () => {
const baseParams = { token: 'sk_token1', viewerId: null, accessCookie: 'ok', relativePath: '', page: 1, pageSize: 50 }
it('returns matter_trashed / not_found from resolution', async () => {
const trashed = makeDeps({ share: { resolveByToken: async () => ({ status: 'matter_trashed' }) } })
const trashed = makeDeps({ share: { resolveByToken: async () => trashedResolution() } })
expectError(await listShareObjects(trashed.deps, baseParams), 410, undefined, 'File no longer available')
const revoked = makeDeps({ share: { resolveByToken: async () => ({ status: 'revoked' }) } })
@@ -585,7 +588,7 @@ describe('downloadShareObject', () => {
})
it('returns matter_trashed / not_found from resolution', async () => {
const trashed = makeDeps({ share: { resolveByToken: async () => ({ status: 'matter_trashed' }) } })
const trashed = makeDeps({ share: { resolveByToken: async () => trashedResolution() } })
expectError(await downloadShareObject(trashed.deps, baseParams), 410, undefined, 'File no longer available')
const revoked = makeDeps({ share: { resolveByToken: async () => ({ status: 'revoked' }) } })
expectError(await downloadShareObject(revoked.deps, baseParams), 404, undefined, 'File not found or not accessible')
@@ -871,29 +874,75 @@ describe('createShare', () => {
// ─── revokeShare ─────────────────────────────────────────────────────────────
describe('revokeShare', () => {
it('returns not_found when the token has no creator', async () => {
const { deps, record } = makeDeps({ share: { getCreatorByToken: async () => null } })
expectError(await revokeShare(deps, { token: 't', userId: 'u1', orgId: 'o-1' }), 404, undefined, 'Not found')
it('returns not_found when the token does not resolve', async () => {
const { deps, record } = makeDeps({ share: { resolveByToken: async () => ({ status: 'not_found' }) } })
expectError(await revokeShare(deps, { token: 't', userId: 'creator-1', orgId: 'o-1' }), 404, undefined, 'Not found')
expect(record).not.toHaveBeenCalled()
})
it('returns not_found when the share is already revoked', async () => {
const { deps, record } = makeDeps({ share: { resolveByToken: async () => ({ status: 'revoked' }) } })
expectError(await revokeShare(deps, { token: 't', userId: 'creator-1', orgId: 'o-1' }), 404, undefined, 'Not found')
expect(record).not.toHaveBeenCalled()
})
it('returns forbidden when the requester is not the creator', async () => {
const { deps } = makeDeps({ share: { getCreatorByToken: async () => 'someone-else' } })
expectError(await revokeShare(deps, { token: 't', userId: 'u1', orgId: 'o-1' }), 403, undefined, 'Forbidden')
const { deps } = makeDeps()
expectError(
await revokeShare(deps, { token: 't', userId: 'someone-else', orgId: 'o-1' }),
403,
undefined,
'Forbidden',
)
})
it('returns not_found when the scoped revoke loses the race', async () => {
const { deps } = makeDeps({ share: { getCreatorByToken: async () => 'u1', revokeByToken: async () => false } })
expectError(await revokeShare(deps, { token: 't', userId: 'u1', orgId: 'o-1' }), 404, undefined, 'Not found')
const { deps } = makeDeps({ share: { revokeByToken: async () => false } })
expectError(
await revokeShare(deps, { token: 'sk_token1', userId: 'creator-1', orgId: 'o-1' }),
404,
undefined,
'Not found',
)
})
it('revokes and records activity on success', async () => {
it('still revokes a share whose matter is trashed (trashing does not cascade)', async () => {
const revokeByToken = vi.fn(async () => true)
const { deps, record } = makeDeps({ share: { getCreatorByToken: async () => 'u1', revokeByToken } })
expect(await revokeShare(deps, { token: 'tok', userId: 'u1', orgId: 'o-1' })).toEqual({ ok: true })
expect(revokeByToken).toHaveBeenCalledWith('tok', 'u1')
const { deps, record } = makeDeps({ share: { resolveByToken: async () => trashedResolution(), revokeByToken } })
const out = await revokeShare(deps, { token: 'sk_token1', userId: 'creator-1', orgId: 'o-1' })
expect(out.ok).toBe(true)
if (!out.ok) throw new Error('expected ok')
expect(out.dto).toMatchObject({ token: 'sk_token1', status: 'revoked', id: 's-1', creatorId: 'creator-1' })
expect(revokeByToken).toHaveBeenCalledWith('sk_token1', 'creator-1')
expect(record).toHaveBeenCalledWith(expect.objectContaining({ action: 'share_revoke', targetName: 'sk_token1' }))
})
it('returns forbidden for a non-creator even when the matter is trashed', async () => {
const { deps } = makeDeps({ share: { resolveByToken: async () => trashedResolution() } })
expectError(
await revokeShare(deps, { token: 'sk_token1', userId: 'someone-else', orgId: 'o-1' }),
403,
undefined,
'Forbidden',
)
})
it('revokes, records activity, and returns the revoked creator view', async () => {
const revokeByToken = vi.fn(async () => true)
const { deps, record } = makeDeps({ share: { revokeByToken } })
const out = await revokeShare(deps, { token: 'sk_token1', userId: 'creator-1', orgId: 'o-1' })
expect(out.ok).toBe(true)
if (!out.ok) throw new Error('expected ok')
expect(out.dto).toMatchObject({
token: 'sk_token1',
status: 'revoked',
id: 's-1',
creatorId: 'creator-1',
recipients: [],
})
expect(revokeByToken).toHaveBeenCalledWith('sk_token1', 'creator-1')
expect(record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'share_revoke', targetName: 'tok', orgId: 'o-1', userId: 'u1' }),
expect.objectContaining({ action: 'share_revoke', targetName: 'sk_token1', orgId: 'o-1', userId: 'creator-1' }),
)
})
})
@@ -910,7 +959,7 @@ describe('saveShare', () => {
}
it('returns matter_trashed when the share target was trashed', async () => {
const { deps } = makeDeps({ share: { resolveByToken: async () => ({ status: 'matter_trashed' }) } })
const { deps } = makeDeps({ share: { resolveByToken: async () => trashedResolution() } })
expectError(await saveShare(deps, baseParams), 410, undefined, 'Share target has been deleted')
})
+65 -41
View File
@@ -104,26 +104,18 @@ export type ViewShareOutcome =
| { ok: true; dto: ShareViewerDto | ShareCreatorDto; setViewCookie: boolean }
| { ok: false; error: AppError }
export async function viewShare(deps: ShareDeps, params: ViewShareParams): Promise<ViewShareOutcome> {
const { token, viewerId, viewCookie, accessCookie, now = new Date() } = params
const resolved = await deps.share.resolveByToken(token)
if (resolved.status !== 'ok') {
if (resolved.status === 'matter_trashed') return { ok: false, error: expiredError('File no longer available') }
return { ok: false, error: notFound('Share not found or revoked') }
}
// Assemble the share view DTO from resolved share data. The creator (matched by
// viewerId) gets the richer ShareCreatorDto; everyone else gets the viewer DTO.
// Shared by viewShare and revokeShare so both expose an identically-shaped view.
async function composeShareView(
deps: ShareDeps,
resolved: { share: ShareRecord; matter: Matter; recipients: ShareRecipientRecord[] },
opts: { viewerId: string | null; accessCookie: string | undefined; now: Date },
): Promise<ShareViewerDto | ShareCreatorDto> {
const { share, matter, recipients } = resolved
const { viewerId, accessCookie, now } = opts
const isCreator = !!viewerId && viewerId === share.creatorId
// Direct shares are not publicly viewable; only the creator sees metadata.
if (share.kind !== 'landing' && !isCreator) return { ok: false, error: notFound('Share not found or revoked') }
// View-dedup increment: non-creators whose view cookie isn't yet 'seen'. The
// handler sets the cookie when setViewCookie is true.
const setViewCookie = !isCreator && viewCookie !== 'seen'
if (setViewCookie) await deps.share.incrementViews(share.id)
const accessibleByUser = viewerId ? isAccessibleByUser(recipients, viewerId) : false
const requiresPassword = !isCreator && !!(share.passwordHash && !accessibleByUser && accessCookie !== 'ok')
const expired = !!(share.expiresAt && share.expiresAt < now)
@@ -146,25 +138,45 @@ export async function viewShare(deps: ShareDeps, params: ViewShareParams): Promi
accessibleByUser,
downloads: share.downloads,
views: share.views,
rootRef: encodeChildRef(token, matter.id),
rootRef: encodeChildRef(share.token, matter.id),
}
if (isCreator) {
return {
ok: true,
setViewCookie,
dto: {
...base,
id: share.id,
matterId: share.matterId,
orgId: share.orgId,
creatorId: share.creatorId,
createdAt: share.createdAt,
recipients,
},
...base,
id: share.id,
matterId: share.matterId,
orgId: share.orgId,
creatorId: share.creatorId,
createdAt: share.createdAt,
recipients,
}
}
return { ok: true, setViewCookie, dto: base }
return base
}
export async function viewShare(deps: ShareDeps, params: ViewShareParams): Promise<ViewShareOutcome> {
const { token, viewerId, viewCookie, accessCookie, now = new Date() } = params
const resolved = await deps.share.resolveByToken(token)
if (resolved.status !== 'ok') {
if (resolved.status === 'matter_trashed') return { ok: false, error: expiredError('File no longer available') }
return { ok: false, error: notFound('Share not found or revoked') }
}
const { share, matter, recipients } = resolved
const isCreator = !!viewerId && viewerId === share.creatorId
// Direct shares are not publicly viewable; only the creator sees metadata.
if (share.kind !== 'landing' && !isCreator) return { ok: false, error: notFound('Share not found or revoked') }
// View-dedup increment: non-creators whose view cookie isn't yet 'seen'. The
// handler sets the cookie when setViewCookie is true.
const setViewCookie = !isCreator && viewCookie !== 'seen'
if (setViewCookie) await deps.share.incrementViews(share.id)
const dto = await composeShareView(deps, { share, matter, recipients }, { viewerId, accessCookie, now })
return { ok: true, setViewCookie, dto }
}
// ─── POST /:token/sessions — verify password → access-cookie decision ────────
@@ -486,22 +498,28 @@ export async function createShare(
}
}
// ─── DELETE /:token — revoke (ownership-scoped) ──────────────────────────────
// ─── PUT /:token/status — revoke (ownership-scoped) ──────────────────────────
export type RevokeShareParams = { token: string; userId: string; orgId: string }
export type RevokeShareParams = { token: string; userId: string; orgId: string; now?: Date }
export type RevokeShareOutcome = { ok: true } | { ok: false; error: AppError }
export type RevokeShareOutcome = { ok: true; dto: ShareViewerDto | ShareCreatorDto } | { ok: false; error: AppError }
export async function revokeShare(deps: ShareDeps, params: RevokeShareParams): Promise<RevokeShareOutcome> {
const { token, userId, orgId } = params
const { token, userId, orgId, now = new Date() } = params
const creatorId = await deps.share.getCreatorByToken(token)
if (creatorId === null) return { ok: false, error: notFound() }
if (creatorId !== userId) return { ok: false, error: forbidden() }
// Resolve before revoking: once the status flips to 'revoked', resolveByToken
// no longer returns the record, so we capture the share here to build the
// creator view. Unknown or already-revoked tokens are "not found" to the
// revoker. A trashed matter still carries the records: the owner must be able
// to revoke a share whose target was soft-deleted (trashing does not cascade
// to shares), so this path stays revocable.
const resolved = await deps.share.resolveByToken(token)
if (resolved.status === 'not_found' || resolved.status === 'revoked') return { ok: false, error: notFound() }
if (resolved.share.creatorId !== userId) return { ok: false, error: forbidden() }
// Race-safe: revokeByToken scopes the UPDATE to (token, creatorId). A
// concurrent revoke or ownership change between the check above and this call
// returns false — translate to not_found at the boundary.
// Race-safe: revokeByToken scopes the UPDATE to (token, creatorId). An
// ownership change between the resolve above and this call returns false —
// translate to not_found at the boundary.
const revoked = await deps.share.revokeByToken(token, userId)
if (!revoked) return { ok: false, error: notFound() }
@@ -513,7 +531,13 @@ export async function revokeShare(deps: ShareDeps, params: RevokeShareParams): P
targetName: token,
})
return { ok: true }
// Return the creator view reflecting the post-revoke state.
const dto = await composeShareView(
deps,
{ share: { ...resolved.share, status: 'revoked' }, matter: resolved.matter, recipients: resolved.recipients },
{ viewerId: userId, accessCookie: undefined, now },
)
return { ok: true, dto }
}
// ─── POST /:token/objects — save-to-drive (gates + copy) ─────────────────────
+12 -6
View File
@@ -171,18 +171,24 @@ Feature: Shares
When they save a share there
Then the API responds 403
@shares/delete @api
@shares/revoke @api
Scenario: A creator revokes their share
Given a creator's share
When they delete it
Then its status becomes revoked
When they set its status to revoked
Then the revoked share view is returned and its status becomes revoked
@shares/delete-non-creator @api
Scenario: Non-creators cannot delete a share
@shares/revoke-non-creator @api
Scenario: Non-creators cannot revoke a share
Given a share owned by someone else
When a non-creator deletes it
When a non-creator sets its status to revoked
Then the API responds 403
@shares/revoke-trashed-matter @api
Scenario: A creator can revoke a share whose file was trashed
Given a creator's share whose matter is trashed but not purged
When they set its status to revoked
Then the revoked share view is returned and its status becomes revoked
@shares/received-list @api
Scenario: Users see shares addressed to them
Given shares addressed by id and by email
+11 -9
View File
@@ -1,7 +1,7 @@
// Integration-project tests for src/lib/api.ts share wrapper functions.
// These run in the integration vitest project so codecov picks them up for patch coverage.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiError, deleteShare, getShare, listShares } from './api'
import { ApiError, getShare, listShares, revokeShare } from './api'
function makeResponse(body: unknown, ok = true, status = 200): Response {
return {
@@ -73,20 +73,22 @@ describe('shares API wrappers (integration)', () => {
})
})
describe('deleteShare', () => {
it('calls DELETE /api/shares/:id and resolves on 204', async () => {
vi.mocked(fetch).mockResolvedValueOnce({ ok: true, status: 204 } as Response)
describe('revokeShare', () => {
it('puts status: revoked to /api/shares/:id/status and resolves with the share', async () => {
const payload = { token: 'share-1', status: 'revoked', kind: 'landing' }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
await expect(deleteShare('share-1')).resolves.toBeUndefined()
await expect(revokeShare('share-1')).resolves.toEqual(payload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/shares/share-1')
expect(init.method).toBe('DELETE')
expect(url).toContain('/api/shares/share-1/status')
expect(init.method).toBe('PUT')
expect(JSON.parse(init.body as string)).toEqual({ status: 'revoked' })
})
it('throws ApiError on non-ok response', async () => {
vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 403, statusText: 'Forbidden' } as Response)
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Forbidden' }, false, 403))
await expect(deleteShare('share-1')).rejects.toBeInstanceOf(ApiError)
await expect(revokeShare('share-1')).rejects.toBeInstanceOf(ApiError)
})
})
})
+18 -11
View File
@@ -35,7 +35,6 @@ import {
deleteIhostImage,
deleteInviteCode,
deleteObject,
deleteShare,
deleteStorage,
deleteTeamLogo,
disconnectCloud,
@@ -112,6 +111,7 @@ import {
revokeIhostApiKey,
revokeOrgEntitlement,
revokeRemoteDownloadApiKey,
revokeShare,
revokeSiteInvitation,
revokeUserEntitlement,
revokeWebDavAppPassword,
@@ -425,8 +425,8 @@ describe('api', () => {
expect(JSON.parse(calls[2][1].body as string)).toEqual({ code: 'GIFT-123' })
expect(calls[3][0]).toBe('/api/store/orders/order-1/payments')
expect(calls[3][1].method).toBe('POST')
expect(calls[4][0]).toBe('/api/store/orders/order-1')
expect(calls[4][1].method).toBe('PATCH')
expect(calls[4][0]).toBe('/api/store/orders/order-1/status')
expect(calls[4][1].method).toBe('PUT')
expect(JSON.parse(calls[4][1].body as string)).toEqual({ status: 'canceled' })
})
@@ -2357,20 +2357,27 @@ describe('api', () => {
})
})
describe('deleteShare', () => {
it('calls DELETE /api/shares/:token and resolves on 204', async () => {
vi.mocked(fetch).mockResolvedValueOnce({ ok: true, status: 204 } as Response)
describe('revokeShare', () => {
it('puts status: revoked to /api/shares/:token/status and resolves with the revoked share', async () => {
const payload = { token: 'tok123', status: 'revoked', kind: 'landing' }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
await expect(deleteShare('tok123')).resolves.toBeUndefined()
const result = await revokeShare('tok123')
expect(result).toEqual(payload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/shares/tok123')
expect(init.method).toBe('DELETE')
expect(url).toContain('/api/shares/tok123/status')
expect(init.method).toBe('PUT')
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
expect(body).toEqual({ status: 'revoked' })
})
it('throws ApiError on non-ok response', async () => {
vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 403, statusText: 'Forbidden' } as Response)
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Forbidden' }, false, 403))
await expect(deleteShare('tok123')).rejects.toThrow('Forbidden')
await expect(revokeShare('tok123')).rejects.toThrow('Forbidden')
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Forbidden' }, false, 403))
await expect(revokeShare('tok123')).rejects.toBeInstanceOf(ApiError)
})
})
+3 -5
View File
@@ -611,7 +611,7 @@ export function continueCloudOrderPayment(orderId: string) {
export function cancelCloudOrder(orderId: string) {
return unwrap<CloudOrder>(
cloudStoreApi.orders[':orderId'].$patch({ param: { orderId }, json: { status: 'canceled' } }),
cloudStoreApi.orders[':orderId'].status.$put({ param: { orderId }, json: { status: 'canceled' } }),
)
}
@@ -885,10 +885,8 @@ export function getShare(token: string) {
return unwrap<ShareView>(publicSharesApi[':token'].$get({ param: { token } }))
}
export function deleteShare(token: string) {
return authedSharesApi[':token'].$delete({ param: { token } }).then((res) => {
if (!res.ok) throw new ApiError(res.status, toErrorBody(res.status, { error: res.statusText }))
})
export function revokeShare(token: string) {
return unwrap<ShareView>(authedSharesApi[':token'].status.$put({ param: { token }, json: { status: 'revoked' } }))
}
export interface CreateShareResult {
+2 -2
View File
@@ -18,7 +18,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useClipboard } from '@/hooks/use-clipboard'
import { deleteShare, listReceivedShares, listShares, type ShareListItem } from '@/lib/api'
import { listReceivedShares, listShares, revokeShare, type ShareListItem } from '@/lib/api'
export const Route = createFileRoute('/_authenticated/shares/')({
validateSearch: (search: Record<string, unknown>) => ({
@@ -65,7 +65,7 @@ function SharesPage() {
})
const revokeMutation = useMutation({
mutationFn: (token: string) => deleteShare(token),
mutationFn: (token: string) => revokeShare(token),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['shares'] })
toast.success(t('shares.revokeSuccess'))