feat(agent): support workspace-scoped resource access

This commit is contained in:
saltbo
2026-08-03 19:13:50 -04:00
parent b93db898a4
commit 0be5c1b7e9
25 changed files with 472 additions and 95 deletions
+116 -38
View File
@@ -3117,6 +3117,7 @@ const (
GetOAuthConsentContext200JSONResponseBodyScopesOauthGrantsRead GetOAuthConsentContext200JSONResponseBodyScopes = "oauth-grants:read"
GetOAuthConsentContext200JSONResponseBodyScopesObjectsCreate GetOAuthConsentContext200JSONResponseBodyScopes = "objects:create"
GetOAuthConsentContext200JSONResponseBodyScopesObjectsDelete GetOAuthConsentContext200JSONResponseBodyScopes = "objects:delete"
GetOAuthConsentContext200JSONResponseBodyScopesObjectsPurge GetOAuthConsentContext200JSONResponseBodyScopes = "objects:purge"
GetOAuthConsentContext200JSONResponseBodyScopesObjectsRead GetOAuthConsentContext200JSONResponseBodyScopes = "objects:read"
GetOAuthConsentContext200JSONResponseBodyScopesObjectsUpdate GetOAuthConsentContext200JSONResponseBodyScopes = "objects:update"
GetOAuthConsentContext200JSONResponseBodyScopesQuotaPurchase GetOAuthConsentContext200JSONResponseBodyScopes = "quota:purchase"
@@ -3251,6 +3252,8 @@ func (e GetOAuthConsentContext200JSONResponseBodyScopes) Valid() bool {
return true
case GetOAuthConsentContext200JSONResponseBodyScopesObjectsDelete:
return true
case GetOAuthConsentContext200JSONResponseBodyScopesObjectsPurge:
return true
case GetOAuthConsentContext200JSONResponseBodyScopesObjectsRead:
return true
case GetOAuthConsentContext200JSONResponseBodyScopesObjectsUpdate:
@@ -3381,6 +3384,7 @@ const (
ListOAuthGrants200JSONResponseBodyItemsScopesOauthGrantsRead ListOAuthGrants200JSONResponseBodyItemsScopes = "oauth-grants:read"
ListOAuthGrants200JSONResponseBodyItemsScopesObjectsCreate ListOAuthGrants200JSONResponseBodyItemsScopes = "objects:create"
ListOAuthGrants200JSONResponseBodyItemsScopesObjectsDelete ListOAuthGrants200JSONResponseBodyItemsScopes = "objects:delete"
ListOAuthGrants200JSONResponseBodyItemsScopesObjectsPurge ListOAuthGrants200JSONResponseBodyItemsScopes = "objects:purge"
ListOAuthGrants200JSONResponseBodyItemsScopesObjectsRead ListOAuthGrants200JSONResponseBodyItemsScopes = "objects:read"
ListOAuthGrants200JSONResponseBodyItemsScopesObjectsUpdate ListOAuthGrants200JSONResponseBodyItemsScopes = "objects:update"
ListOAuthGrants200JSONResponseBodyItemsScopesQuotaPurchase ListOAuthGrants200JSONResponseBodyItemsScopes = "quota:purchase"
@@ -3515,6 +3519,8 @@ func (e ListOAuthGrants200JSONResponseBodyItemsScopes) Valid() bool {
return true
case ListOAuthGrants200JSONResponseBodyItemsScopesObjectsDelete:
return true
case ListOAuthGrants200JSONResponseBodyItemsScopesObjectsPurge:
return true
case ListOAuthGrants200JSONResponseBodyItemsScopesObjectsRead:
return true
case ListOAuthGrants200JSONResponseBodyItemsScopesObjectsUpdate:
@@ -5462,6 +5468,13 @@ type AuthorizationDetailsCatalog struct {
Metadata map[string]string `json:"metadata"`
} `json:"display"`
} `json:"items"`
Pagination struct {
HasMore bool `json:"hasMore"`
Limit int `json:"limit"`
NextOffset *int `json:"nextOffset"`
Offset int `json:"offset"`
Total int `json:"total"`
} `json:"pagination"`
}
// AuthorizationDetailsCatalogItemsAuthorizationDetailType defines model for AuthorizationDetailsCatalog.Items.AuthorizationDetail.Type.
@@ -6250,6 +6263,8 @@ type Matter struct {
Name string `json:"name"`
Object string `json:"object"`
OrgId string `json:"orgId"`
// Parent Slash-delimited parent folder path relative to the workspace root; empty for root objects.
Parent string `json:"parent"`
Size *int `json:"size"`
Status string `json:"status"`
@@ -6281,21 +6296,25 @@ type NotificationPage struct {
// ObjectListItem defines model for ObjectListItem.
type ObjectListItem struct {
Alias string `json:"alias"`
CreatedAt string `json:"createdAt"`
Dirtype *int `json:"dirtype"`
Alias string `json:"alias"`
CreatedAt string `json:"createdAt"`
Dirtype *int `json:"dirtype"`
// HasChildren Whether this folder contains at least one child folder.
HasChildren bool `json:"hasChildren"`
Id string `json:"id"`
Name string `json:"name"`
Object string `json:"object"`
OrgId string `json:"orgId"`
Parent string `json:"parent"`
Size *int `json:"size"`
Status string `json:"status"`
StorageId string `json:"storageId"`
TrashedAt *int `json:"trashedAt"`
Type string `json:"type"`
UpdatedAt string `json:"updatedAt"`
// Parent Slash-delimited parent folder path relative to the workspace root; empty for root objects.
Parent string `json:"parent"`
Size *int `json:"size"`
Status string `json:"status"`
StorageId string `json:"storageId"`
TrashedAt *int `json:"trashedAt"`
Type string `json:"type"`
UpdatedAt string `json:"updatedAt"`
}
// ObjectPage defines model for ObjectPage.
@@ -7355,6 +7374,12 @@ type LinkSocialAccountJSONBody_Provider struct {
union json.RawMessage
}
// ListAuthorizationDetailsCatalogParams defines parameters for ListAuthorizationDetailsCatalog.
type ListAuthorizationDetailsCatalogParams struct {
Limit *int `form:"limit,omitempty" json:"limit,omitempty"`
Offset *int `form:"offset,omitempty" json:"offset,omitempty"`
}
// GetApiAuthOauth2AuthorizeParams defines parameters for GetApiAuthOauth2Authorize.
type GetApiAuthOauth2AuthorizeParams struct {
// ResponseType OAuth 2.1 response type (e.g., 'code')
@@ -8473,11 +8498,15 @@ type ListOAuthGrants200JSONResponseBodyItemsStatus string
type ListObjectsParams struct {
PageSize *int `form:"pageSize,omitempty" json:"pageSize,omitempty"`
PageToken *string `form:"pageToken,omitempty" json:"pageToken,omitempty"`
Parent *string `form:"parent,omitempty" json:"parent,omitempty"`
Path *string `form:"path,omitempty" json:"path,omitempty"`
Type *string `form:"type,omitempty" json:"type,omitempty"`
Search *string `form:"search,omitempty" json:"search,omitempty"`
OrgId *string `form:"orgId,omitempty" json:"orgId,omitempty"`
// Parent Slash-delimited parent folder path relative to the workspace root; empty for the root.
Parent *string `form:"parent,omitempty" json:"parent,omitempty"`
// Path Alias for parent: the slash-delimited parent folder path relative to the workspace root.
Path *string `form:"path,omitempty" json:"path,omitempty"`
Type *string `form:"type,omitempty" json:"type,omitempty"`
Search *string `form:"search,omitempty" json:"search,omitempty"`
OrgId *string `form:"orgId,omitempty" json:"orgId,omitempty"`
}
// CreateObjectJSONBody defines parameters for CreateObject.
@@ -8485,8 +8514,10 @@ type CreateObjectJSONBody struct {
Dirtype *int `json:"dirtype,omitempty"`
Name string `json:"name"`
OnConflict *CreateObjectJSONBodyOnConflict `json:"onConflict,omitempty"`
Parent *string `json:"parent,omitempty"`
Size *int `json:"size,omitempty"`
// Parent Slash-delimited parent folder path relative to the workspace root; use an empty string for the root.
Parent *string `json:"parent,omitempty"`
Size *int `json:"size,omitempty"`
// StorageId Only site administrators may set this field; omit it to let ZPan automatically select an available storage.
StorageId *string `json:"storageId,omitempty"`
@@ -11455,7 +11486,7 @@ type ClientInterface interface {
ListUserSessions(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error)
// ListAuthorizationDetailsCatalog request
ListAuthorizationDetailsCatalog(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error)
ListAuthorizationDetailsCatalog(ctx context.Context, params *ListAuthorizationDetailsCatalogParams, reqEditors ...RequestEditorFn) (*http.Response, error)
// GetApiAuthOauth2Authorize request
GetApiAuthOauth2Authorize(ctx context.Context, params *GetApiAuthOauth2AuthorizeParams, reqEditors ...RequestEditorFn) (*http.Response, error)
@@ -13144,8 +13175,8 @@ func (c *Client) ListUserSessions(ctx context.Context, reqEditors ...RequestEdit
return c.Client.Do(req)
}
func (c *Client) ListAuthorizationDetailsCatalog(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewListAuthorizationDetailsCatalogRequest(c.Server)
func (c *Client) ListAuthorizationDetailsCatalog(ctx context.Context, params *ListAuthorizationDetailsCatalogParams, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewListAuthorizationDetailsCatalogRequest(c.Server, params)
if err != nil {
return nil, err
}
@@ -18772,7 +18803,7 @@ func NewListUserSessionsRequest(server string) (*http.Request, error) {
}
// NewListAuthorizationDetailsCatalogRequest generates requests for ListAuthorizationDetailsCatalog
func NewListAuthorizationDetailsCatalogRequest(server string) (*http.Request, error) {
func NewListAuthorizationDetailsCatalogRequest(server string, params *ListAuthorizationDetailsCatalogParams) (*http.Request, error) {
var err error
serverURL, err := url.Parse(server)
@@ -18790,6 +18821,45 @@ func NewListAuthorizationDetailsCatalogRequest(server string) (*http.Request, er
return nil, err
}
if params != nil {
// queryValues collects non-styled parameters (passthrough, JSON)
// that are safe to round-trip through url.Values.Encode().
queryValues := queryURL.Query()
// rawQueryFragments collects pre-encoded query fragments from
// styled parameters, preserving literal commas as delimiters
// per the OpenAPI spec (e.g. "color=blue,black,brown").
var rawQueryFragments []string
if params.Limit != nil {
if queryFrag, err := runtime.StyleParamWithOptions("form", true, "limit", *params.Limit, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", Format: ""}); err != nil {
return nil, err
} else {
for _, qp := range strings.Split(queryFrag, "&") {
rawQueryFragments = append(rawQueryFragments, qp)
}
}
}
if params.Offset != nil {
if queryFrag, err := runtime.StyleParamWithOptions("form", true, "offset", *params.Offset, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", Format: ""}); err != nil {
return nil, err
} else {
for _, qp := range strings.Split(queryFrag, "&") {
rawQueryFragments = append(rawQueryFragments, qp)
}
}
}
if encoded := queryValues.Encode(); encoded != "" {
rawQueryFragments = append(rawQueryFragments, encoded)
}
queryURL.RawQuery = strings.Join(rawQueryFragments, "&")
}
req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil)
if err != nil {
return nil, err
@@ -28907,7 +28977,7 @@ type ClientWithResponsesInterface interface {
ListUserSessionsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListUserSessionsResponse, error)
// ListAuthorizationDetailsCatalogWithResponse request
ListAuthorizationDetailsCatalogWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListAuthorizationDetailsCatalogResponse, error)
ListAuthorizationDetailsCatalogWithResponse(ctx context.Context, params *ListAuthorizationDetailsCatalogParams, reqEditors ...RequestEditorFn) (*ListAuthorizationDetailsCatalogResponse, error)
// GetApiAuthOauth2AuthorizeWithResponse request
GetApiAuthOauth2AuthorizeWithResponse(ctx context.Context, params *GetApiAuthOauth2AuthorizeParams, reqEditors ...RequestEditorFn) (*GetApiAuthOauth2AuthorizeResponse, error)
@@ -37203,6 +37273,8 @@ type CreateObjectResponse struct {
Name string `json:"name"`
Object string `json:"object"`
OrgId string `json:"orgId"`
// Parent Slash-delimited parent folder path relative to the workspace root; empty for root objects.
Parent string `json:"parent"`
Size *int `json:"size"`
Status string `json:"status"`
@@ -37333,13 +37405,15 @@ type GetObjectResponse struct {
Name string `json:"name"`
Object string `json:"object"`
OrgId string `json:"orgId"`
Parent string `json:"parent"`
Size *int `json:"size"`
Status string `json:"status"`
StorageId string `json:"storageId"`
TrashedAt *int `json:"trashedAt"`
Type string `json:"type"`
UpdatedAt string `json:"updatedAt"`
// Parent Slash-delimited parent folder path relative to the workspace root; empty for root objects.
Parent string `json:"parent"`
Size *int `json:"size"`
Status string `json:"status"`
StorageId string `json:"storageId"`
TrashedAt *int `json:"trashedAt"`
Type string `json:"type"`
UpdatedAt string `json:"updatedAt"`
}
JSON400 *Error
JSON402 *Error
@@ -41570,8 +41644,8 @@ func (c *ClientWithResponses) ListUserSessionsWithResponse(ctx context.Context,
}
// ListAuthorizationDetailsCatalogWithResponse request returning *ListAuthorizationDetailsCatalogResponse
func (c *ClientWithResponses) ListAuthorizationDetailsCatalogWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListAuthorizationDetailsCatalogResponse, error) {
rsp, err := c.ListAuthorizationDetailsCatalog(ctx, reqEditors...)
func (c *ClientWithResponses) ListAuthorizationDetailsCatalogWithResponse(ctx context.Context, params *ListAuthorizationDetailsCatalogParams, reqEditors ...RequestEditorFn) (*ListAuthorizationDetailsCatalogResponse, error) {
rsp, err := c.ListAuthorizationDetailsCatalog(ctx, params, reqEditors...)
if err != nil {
return nil, err
}
@@ -55366,6 +55440,8 @@ func ParseCreateObjectResponse(rsp *http.Response) (*CreateObjectResponse, error
Name string `json:"name"`
Object string `json:"object"`
OrgId string `json:"orgId"`
// Parent Slash-delimited parent folder path relative to the workspace root; empty for root objects.
Parent string `json:"parent"`
Size *int `json:"size"`
Status string `json:"status"`
@@ -55530,13 +55606,15 @@ func ParseGetObjectResponse(rsp *http.Response) (*GetObjectResponse, error) {
Name string `json:"name"`
Object string `json:"object"`
OrgId string `json:"orgId"`
Parent string `json:"parent"`
Size *int `json:"size"`
Status string `json:"status"`
StorageId string `json:"storageId"`
TrashedAt *int `json:"trashedAt"`
Type string `json:"type"`
UpdatedAt string `json:"updatedAt"`
// Parent Slash-delimited parent folder path relative to the workspace root; empty for root objects.
Parent string `json:"parent"`
Size *int `json:"size"`
Status string `json:"status"`
StorageId string `json:"storageId"`
TrashedAt *int `json:"trashedAt"`
Type string `json:"type"`
UpdatedAt string `json:"updatedAt"`
}
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
@@ -55,6 +55,22 @@ This document records end-to-end regressions for Agent-driven ZPan file manageme
| AFM-029 | Production resource metadata | Immediately after ZPan production deployment, Realmroot's existing resource registration still reported that no authorization-detail catalog was advertised. | Deployment validation refreshes the public resource contract before asking the Agent to discover contexts, then reauthorizes the existing provider account for the newly advertised catalog scope. | Resolved through the public Realmroot management/resource workflow; production then exposed all four real workspaces |
| AFM-030 | Realmroot generic OAuth connection | The Restish plugin waited for `access connect` by polling an optional authorization-detail catalog, so an ordinary OAuth resource without a catalog failed with 400. | Connection completion is observed through generic Agent resource discovery; context-aware and ordinary OAuth resources use the same protocol path. | Fixed in Realmroot, covered by unit/plugin/E2E tests, CI green, and deployed |
| AFM-031 | Non-interactive approval handoff | A context-isolated Agent's pending access request was created, but redirected plugin stderr remained quiet until the response hook completed, so the Agent interrupted and retried before seeing the approval URL. | Non-interactive runtimes use the plugin's protected approval handoff file while the original command remains in the foreground; interactive terminals continue to receive the URL directly. | Fixed in Realmroot plugin fallback and skill guidance; two live non-TTY requests surfaced one approval URL each without a retry |
| AFM-032 | ZPan RFC 9396 consent | Realmroot correctly expanded one account connection to include all previously authorized and newly requested workspaces, but ZPan PAR and consent context rejected more than one fixed workspace detail despite the documented multi-workspace subject grant. | Authorization-code requests accept one or more unique workspace details; consent locks every fixed workspace, while token exchange remains exactly one workspace. | Fixed in ZPan; focused Node tests, 5,397-test full suite, 81 Workers tests, build, and live three-workspace consent passed |
| AFM-033 | Realmroot generic interaction plugin | A completed connection request was reconstructed as a partial plugin-owned struct, losing `resourceServerId`, `resources`, timestamps, and reason while inventing `credentialOffer: null`. | The plugin reads only generic interaction control fields and returns the complete server-owned representation unchanged unless it consumes a real credential offer into a safe receipt. | Fixed in the generic profile handler and covered by Go tests plus live completed-request readback |
| AFM-034 | Realmroot generated commands | The OpenAPI contract and skill drifted into nonexistent top-level commands and redundant names such as `resource-server servers`. A global Restish override temporarily hid the problem, while the server's unsupported `x-cli-config.command_layout` extension was silently ignored by Restish 2.3.0. | Keep the generated workflow surface to `whoami`, `request-capabilities`, `connect`, and `access`. Discover and read Resource Servers and provider-owned Resources with generic `restish get` against server-returned links; the plugin handles only local identity, interaction, and credential custody. | Fixed in the server OpenAPI contract, skill, plugin, and tests; fresh isolated Restish configuration exercises the four workflow commands plus generic reads without a local command-layout override |
| AFM-035 | Realmroot target credential selection | A stale target-profile issuer caused the auth hook to skip a valid short-lived credential, Restish sent its placeholder authorization value, and the response hook then deleted the credential after the resulting 401. | Target authentication fails before network I/O with an explicit missing-credential or issuer-mismatch error; a 401 clears cached state only when the request actually carried both DPoP authorization and proof. | Fixed in the generic Realmroot plugin with focused Go tests; the local profile now uses the stable local issuer and direct ZPan invocation passed |
| AFM-036 | Realmroot dynamic client repair | An existing provider connection bypassed dynamic-client validation during target credential issuance, so changed callback or JWKS metadata survived until token exchange failed with an opaque `invalid_grant`. | Validate the complete security-relevant RFC 7592 registration metadata before issuing with the current client generation, repair drift through the provider registration endpoint, and require reauthorization only when repair rotates the generation. | Fixed in Realmroot with connector and external-resource use-case coverage; the clean local credential flow passed after automatic repair |
| AFM-037 | ZPan trash purge authorization | Soft-delete worked through OAuth, but permanent purge declared only bearer/cookie security and excluded `objects:purge` from the OAuth resource scope catalog, leaving an Agent unable to complete cleanup through the same authorization model. | Every file-management permission is an OAuth scope independent of authentication mechanism; purge advertises OAuth, bearer, and cookie alternatives and is requestable through normal Resource access. | Fixed in ZPan OpenAPI, authorization metadata, consent labels, translations, and tests; isolated cleanroom obtained an exact short-lived purge credential, but the named fixtures were already absent so no unsafe purge was attempted |
| AFM-038 | Installed Realmroot skill artifact | The independent Agent initially read a previously installed Skill release whose discovery example still used removed `/agent/api-resources`, while the implementation-under-test Skill source already documented `/api/resource-servers`. | Acceptance must load the Skill artifact from the same version under test; releases must publish server, plugin, and Skill contract changes together. | Cleanroom rerun pinned to the updated Skill source; release packaging remains a deployment responsibility |
| AFM-039 | Durable grant reuse | Resource discovery aggregated the scopes of active durable grants, but access creation reused only an exactly equal scope set. An Agent requesting one operation's narrower scope therefore saw `authorizedScopes` and was still sent through redundant controller approval. | A persistent or time-bounded grant authorizes every scope subset for the same Agent, connection, Resource Server, and provider-owned Resource. Each access request still produces an exact, short-lived credential containing only the requested subset; one-token grants remain exact and single-use. | Fixed in Realmroot access creation and issuance; a live two-scope subset of a broader workspace grant returned `ready` without interaction and the credential receipt contained exactly those two scopes |
| AFM-040 | Acceptance environment profiles | Local and staging acceptance initially relied on profile names as though they were part of the public API contracts. They are actually machine-local deployment aliases. | Realmroot and ZPan publish only the external user's `default` profile. Acceptance config creates any `local` or `staging` aliases locally and reuses the contract's generic credential binding without exposing those environments to external users. | Corrected in both OpenAPI contracts and covered by focused assertions that the public templates contain only `default` |
| AFM-041 | Restish scope-catalog refresh | Restish preserves an existing profile's explicit `satisfies` values when `api sync` loads a Resource Server contract containing a newly added scope. Operation readiness can then reject the new scope before the Realmroot auth hook runs. | Realmroot's plugin cannot repair a decision made before plugin invocation. Fresh API connections receive the complete server-owned scope catalog; evolving existing profiles requires upstream Restish support to merge server-owned credential metadata safely. | Confirmed host boundary and added to Realmroot issue #138; final cleanroom uses a fresh isolated API configuration, while no Resource-Server-specific rule is added to the plugin or Realmroot Skill |
| AFM-042 | OAuth/OIDC discovery | ZPan exposed only path-appended OpenID metadata, while Realmroot correctly requested the RFC discovery path for an issuer containing `/api/auth`; local Vite returned SPA HTML at that path. | ZPan serves both RFC OAuth and OpenID discovery paths at the application root, and the local frontend entry proxies `/.well-known` to the API. | Fixed in ZPan with OpenAPI/discovery tests; live Realmroot discovery passed |
| AFM-043 | Local dynamic registration | Dynamic registration rejected Realmroot's loopback HTTP `jwks_uri`, even though the complete local flow intentionally uses `localhost`. | HTTPS remains mandatory except for literal loopback hosts; both registration and later assertion verification apply the same exception. | Fixed in the Better Auth npm patch and ZPan registration management; focused registration tests passed |
| AFM-044 | Evolving authorization metadata | Realmroot retained an older provider metadata snapshot, and ZPan advertised its authorization-detail catalog extension only from OAuth metadata, not OpenID metadata. Workspace discovery therefore fell back to opaque identifiers instead of provider labels. | Agent connection requests refresh standard provider metadata; OAuth and OpenID discovery advertise the same catalog extension. | Fixed generically in Realmroot and ZPan; live discovery returned `Admin's Space` and `Agent Cleanroom 20260803` with roles and types |
| AFM-045 | Reverse-proxy URL binding | JWT assertion and DPoP validation compared public proofs bound to `localhost:5185` with the local proxy's rewritten backend URL `localhost:8222`. | Authentication validates canonical published endpoints, and the local proxy preserves the external Host for URL-bound proofs. | Fixed in ZPan OAuth verification and Vite proxy configuration; target credential issuance and secured file operations passed |
| AFM-046 | Object parent semantics | Generated `create-object`, `update-object`, and `list-objects` help exposed an unexplained `parent` string, so an Agent reasonably supplied a folder ID even though ZPan stores a slash-delimited folder path. | OpenAPI explicitly defines parent/path as workspace-relative slash-delimited folder paths and explains root handling; `hasChildren` explicitly means child folders. | Fixed in shared schemas and object OpenAPI; the corrected path-based move/list round passed |
| AFM-047 | Restish credential templates | Realmroot and ZPan emitted an unsupported sibling-level credential `params` object in `x-cli-config`, causing fresh profile validation to reject otherwise valid server templates. | Provider selection lives only in the supported `auth.params` contract. | Fixed in both server-owned OpenAPI templates and assertions |
## Regression rounds
@@ -153,16 +169,16 @@ Future rounds must record the exact scenario, workspace count, connection state,
- Switched to the Admin grant and confirmed the second workspace's folder ID was unavailable there.
- Result: passed entirely through CLI; nested file management, share lifecycle, deletion, and cross-workspace isolation behaved correctly.
### Cleanroom Round 3 — new least-privilege one-time grant
### Cleanroom Round 3 — historical exact-match behavior
- Environment: local; Agent Regression Space.
- Requested a new exact grant for only `objects:read`; the existing broader persistent grant was not incorrectly reused.
- Requested only `objects:read`; the then-current exact-match implementation did not reuse an existing broader persistent grant and therefore opened controller approval.
- The CLI printed one controller URL and waited. Browser use was limited to the controller reviewing the fixed workspace, selecting the default one-token lifetime, and approving.
- The original CLI command resumed with `status: approved` and a grant ID.
- The first target-token issuance exited zero with no output, and object listing succeeded in the selected workspace.
- A create-folder attempt failed with `PERMISSION_DENIED`, proving ZPan enforced the token's scope independently of authentication method.
- A second target-token issuance failed with an actionable inactive/consumed-grant error.
- Result: passed; foreground approval, one-token lifetime, least privilege, and scope enforcement all behaved correctly without Agent-side diagnostics.
- Result: the one-token and scope-enforcement mechanics passed, but the redundant approval was later classified as AFM-039 and removed. Current behavior reuses durable authority while still issuing only the requested `objects:read` scope.
### Cleanroom Round 4 — fresh Agent identity and full workspace authorization
@@ -174,6 +190,16 @@ Future rounds must record the exact scenario, workspace count, connection state,
- The object and quota both reported the selected workspace identifier.
- Result: passed; a clean Agent runtime reached full workspace-scoped file management using only CLI plus controller approvals.
### Final Cleanroom Round 5 — independent Skill-only acceptance
- Environment: fresh isolated Restish config, freshly installed plugin build, fresh plugin state, and a context-isolated Agent instructed to use only the Realmroot Skill and CLI. The parent test harness supplied local contract URLs because the production contract TLS endpoint was unavailable; the Agent did not configure APIs, credentials, profiles, or scopes.
- Discovery selected ZPan and two workspaces only from server-owned Resource Server and Resource representations. The account connection was already `connected`, so no connection interaction occurred.
- Exactly three controller decisions occurred: Agent identity, persistent authority for Agent Regression Space, and persistent authority for Agent Cleanroom 20260803.
- Both workspaces passed quota read, folder creation, search, rename, soft delete, trash readback, permanent purge, and empty post-purge trash verification.
- Switching B → A → B → A produced new short-lived Resource-bound receipts without a workspace header. Each workspace returned zero matches for the other workspace's live fixture while continuing to expose its own.
- A request for exactly `objects:read objects:update` reused the broader persistent A authority without interaction and returned a receipt containing exactly those two scopes.
- Result: passed. The Agent used no source, database, logs, browser, raw token, grant, connection ID, authorization-detail payload, or manual Restish configuration; all fixtures were purged.
### Paid Round 1 — quota exhaustion, x402 settlement, upload continuation
- Environment: local ZPan, Realmroot, ZPan Cloud, Agent Wallet Sandbox, MinIO, and a public HTTPS tunnel for the local ZPan callback.
@@ -1,3 +1,30 @@
diff --git a/dist/client-assertion-C-Cg0dCs.mjs b/dist/client-assertion-C-Cg0dCs.mjs
index 9fb95331b3ef55daa5dd7cf9718ec803f3d6e852..160bfb1dab65809b6c3b50dd75eec9c065740a38 100644
--- a/dist/client-assertion-C-Cg0dCs.mjs
+++ b/dist/client-assertion-C-Cg0dCs.mjs
@@ -1,5 +1,5 @@
import { o as getClient } from "./resource-challenge-2qgK1B-a.mjs";
-import { isPublicRoutableHost } from "@better-auth/core/utils/host";
+import { isLoopbackHost, isPublicRoutableHost } from "@better-auth/core/utils/host";
import { APIError } from "better-call";
import { CLIENT_ASSERTION_TYPE, PRIVATE_KEY_JWT_SIGNING_ALGORITHMS } from "@better-auth/core/oauth2";
import { base64Url } from "@better-auth/utils/base64";
@@ -56,11 +56,13 @@ function isPrivateHostname(hostname) {
}
function validateJwksUri(ctx, jwksUri, clientIdUrlOrigin) {
const parsed = new URL(jwksUri);
- if (parsed.protocol !== "https:") throw new APIError("BAD_REQUEST", {
+ const loopback = isLoopbackHost(parsed.hostname);
+ const localDevelopmentServer = isLoopbackHost(new URL(ctx.context.baseURL).hostname);
+ if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && loopback && localDevelopmentServer)) throw new APIError("BAD_REQUEST", {
error_description: "jwks_uri must use HTTPS",
error: "invalid_client"
});
- if (isPrivateHostname(parsed.hostname)) throw new APIError("BAD_REQUEST", {
+ if (isPrivateHostname(parsed.hostname) && !(loopback && localDevelopmentServer)) throw new APIError("BAD_REQUEST", {
error_description: "jwks_uri must not point to a private or reserved address",
error: "invalid_client"
});
diff --git a/dist/index.d.mts b/dist/index.d.mts
index 74c48338addc218100218739af9f51bbf59db2f2..2d9c07f812efc04019f5512bb0feaf6d006fa495 100644
--- a/dist/index.d.mts
@@ -16,7 +43,7 @@ index 74c48338addc218100218739af9f51bbf59db2f2..2d9c07f812efc04019f5512bb0feaf6d
\ No newline at end of file
+export { type ActiveAccessTokenPayload, type AuthMethod, type AuthServerMetadata, type AuthorizationDetail, type AuthorizePrompt, type BearerMethodsSupported, type ClientDiscovery, type ClientRegistrationRequest, type Confirmation, DEFAULT_OAUTH_SCOPES, type GrantType, type InitialAccessTokenAuthorization, type OAuthAuthenticatedClient, type OAuthAuthorizationQuery, type OAuthClaimExtensionInput, type OAuthClient, type OAuthClientAuthenticationInput, type OAuthClientAuthenticationRequest, type OAuthClientAuthenticationResult, type OAuthClientAuthenticationStrategy, type OAuthClientResource, type OAuthConsent, type OAuthEndpointErrorResult, type OAuthEndpointRedirectContext, type OAuthErrorCode, type OAuthExtensionGrantHandler, type OAuthExtensionGrantHandlerInput, type OAuthFieldErrorCode, type OAuthFieldErrorCodeMap, type OAuthMetadataExtensionInput, type OAuthOpaqueAccessToken, type OAuthOptions, type OAuthProviderApi, type OAuthProviderExtension, type OAuthRedirectOnError, type OAuthRefreshToken, type OAuthResource, type OAuthResourceInput, type OAuthTokenIssueParams, type OAuthTokenResponse, type OAuthUserInfoExtensionInput, type OIDCMetadata, type Prompt, type ResourceServerMetadata, ResourceUriSchema, type SchemaClient, type Scope, type StoreTokenType, type StoredAuthorizationQuery, type TokenEndpointAuthMethod, type TokenType, type VerificationValue, authServerMetadata, checkOAuthClient, consumeClientAssertion, extendOAuthProvider, getIssuer, getOAuthProviderApi, getOAuthProviderState, metadataResponse, oauthAuthorizationServerMetadata, oauthProvider, oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata, oauthToSchema, oidcServerMetadata, raiseResourceServerChallenge };
diff --git a/dist/index.mjs b/dist/index.mjs
index 3896e50f99997176d2f99c934d52701cf076d11b..e26efc84ae50f385d3bb7de89d5b5b059d9f21e8 100644
index 3896e50f99997176d2f99c934d52701cf076d11b..c3668c63eaf7dadd4f901bef218b90e59192cd03 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -166,6 +166,44 @@ const STANDARD_CLAIM_NAMES = Object.keys(STANDARD_CLAIMS);
@@ -285,7 +312,23 @@ index 3896e50f99997176d2f99c934d52701cf076d11b..e26efc84ae50f385d3bb7de89d5b5b05
if (clientWithDefaults.type) {
if (isPublic && !(clientWithDefaults.type === "native" || clientWithDefaults.type === "user-agent-based")) throw new APIError("BAD_REQUEST", {
error: "invalid_client_metadata",
@@ -5379,6 +5451,10 @@ const schema = {
@@ -3473,11 +3545,13 @@ async function checkOAuthClient(client, opts, settings) {
});
if (clientWithDefaults.jwks_uri) try {
const uri = new URL(clientWithDefaults.jwks_uri);
- if (uri.protocol !== "https:") throw new APIError("BAD_REQUEST", {
+ const loopback = isLoopbackHost(uri.hostname);
+ const localDevelopmentServer = settings?.ctx ? isLoopbackHost(new URL(settings.ctx.context.baseURL).hostname) : false;
+ if (uri.protocol !== "https:" && !(uri.protocol === "http:" && loopback && localDevelopmentServer)) throw new APIError("BAD_REQUEST", {
error: "invalid_client_metadata",
error_description: "jwks_uri must use HTTPS"
});
- if (isPrivateHostname(uri.hostname)) throw new APIError("BAD_REQUEST", {
+ if (isPrivateHostname(uri.hostname) && !(loopback && localDevelopmentServer)) throw new APIError("BAD_REQUEST", {
error: "invalid_client_metadata",
error_description: "jwks_uri must not point to a private or reserved address"
});
@@ -5379,6 +5453,10 @@ const schema = {
type: "string[]",
required: false
},
@@ -296,7 +339,7 @@ index 3896e50f99997176d2f99c934d52701cf076d11b..e26efc84ae50f385d3bb7de89d5b5b05
expiresAt: { type: "date" },
createdAt: { type: "date" },
revoked: {
@@ -5474,6 +5550,10 @@ const schema = {
@@ -5474,6 +5552,10 @@ const schema = {
type: "string[]",
required: false
},
@@ -307,7 +350,7 @@ index 3896e50f99997176d2f99c934d52701cf076d11b..e26efc84ae50f385d3bb7de89d5b5b05
refreshId: {
type: "string",
required: false,
@@ -5532,6 +5612,10 @@ const schema = {
@@ -5532,6 +5614,10 @@ const schema = {
type: "string[]",
required: false
},
@@ -318,7 +361,7 @@ index 3896e50f99997176d2f99c934d52701cf076d11b..e26efc84ae50f385d3bb7de89d5b5b05
scopes: {
type: "string[]",
required: true
@@ -5949,6 +6033,7 @@ const oauthProvider = (options) => {
@@ -5949,6 +6035,7 @@ const oauthProvider = (options) => {
accept: z.boolean().meta({ description: "Accept or deny user consent for a set of scopes" }),
scope: z.string().optional().meta({ description: "List of accept of accepted space-separated scopes. If none is provided, then all originally requested scopes are accepted." }),
claims: claimsRequestParameterSchema.optional().meta({ description: "Accepted OIDC claims request object. If none is provided, then all originally requested claims are accepted." }),
@@ -326,7 +369,7 @@ index 3896e50f99997176d2f99c934d52701cf076d11b..e26efc84ae50f385d3bb7de89d5b5b05
oauth_query: z.string().optional().meta({ description: "The redirected page's query parameters" })
}),
use: [sessionMiddleware],
@@ -6916,6 +7001,14 @@ async function authorizeEndpoint(ctx, opts, settings) {
@@ -6916,6 +7003,14 @@ async function authorizeEndpoint(ctx, opts, settings) {
if (client.disabled) return handleRedirect(ctx, getErrorURL(ctx, "client_disabled", "client is disabled"));
if (!clientAllowsGrant(client, "authorization_code")) return handleRedirect(ctx, getErrorURL(ctx, "unauthorized_client", "client is not authorized to use the authorization_code grant"));
if (!findRegisteredRedirectUri(client.redirectUris, query.redirect_uri) || !query.redirect_uri) return handleRedirect(ctx, getErrorURL(ctx, "invalid_redirect", "invalid redirect uri"));
@@ -341,7 +384,7 @@ index 3896e50f99997176d2f99c934d52701cf076d11b..e26efc84ae50f385d3bb7de89d5b5b05
let requestedScopes = query.scope?.split(" ").filter((s) => s);
if (requestedScopes) {
const validScopes = new Set(client.scopes ?? opts.scopes);
@@ -7042,6 +7135,18 @@ async function authorizeEndpoint(ctx, opts, settings) {
@@ -7042,6 +7137,18 @@ async function authorizeEndpoint(ctx, opts, settings) {
if (promptNone) return redirectWithPromptNoneError(ctx, opts, query, "consent_required", "End-User consent is required");
return redirectWithPromptCode(ctx, opts, "consent", { sessionId: session.session.id });
}
+3 -3
View File
@@ -30,7 +30,7 @@ overrides:
patchedDependencies:
'@better-auth/oauth-provider@1.7.0-rc.2':
hash: 7090dafc18d45b6b4e1cca723d9786306d6ffed78adc9ad3a614065062ebfb6d
hash: 4af6cc2bd9af458b82bc39f183871b53ed9d9dce47021f662447da91ea17d5e9
path: patches/@better-auth__oauth-provider@1.7.0-rc.2.patch
importers:
@@ -51,7 +51,7 @@ importers:
version: 1.7.0-rc.2(@better-auth/core@1.7.0-rc.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(better-auth@1.7.0-rc.2(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.10.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260606.1)(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(kysely@0.28.17))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@4.1.4))(better-call@1.3.7(zod@4.4.3))
'@better-auth/oauth-provider':
specifier: 1.7.0-rc.2
version: 1.7.0-rc.2(patch_hash=7090dafc18d45b6b4e1cca723d9786306d6ffed78adc9ad3a614065062ebfb6d)(@better-auth/core@1.7.0-rc.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.0-rc.2(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.10.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260606.1)(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(kysely@0.28.17))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@4.1.4))(better-call@1.3.7(zod@4.4.3))
version: 1.7.0-rc.2(patch_hash=4af6cc2bd9af458b82bc39f183871b53ed9d9dce47021f662447da91ea17d5e9)(@better-auth/core@1.7.0-rc.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.0-rc.2(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.10.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260606.1)(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(kysely@0.28.17))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@4.1.4))(better-call@1.3.7(zod@4.4.3))
'@better-captcha/react':
specifier: ^0.7.0
version: 0.7.0(react@19.2.5)(typescript@5.9.3)
@@ -6318,7 +6318,7 @@ snapshots:
'@better-auth/core': 1.7.0-rc.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
'@better-auth/utils': 0.4.2
'@better-auth/oauth-provider@1.7.0-rc.2(patch_hash=7090dafc18d45b6b4e1cca723d9786306d6ffed78adc9ad3a614065062ebfb6d)(@better-auth/core@1.7.0-rc.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.0-rc.2(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.10.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260606.1)(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(kysely@0.28.17))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@4.1.4))(better-call@1.3.7(zod@4.4.3))':
'@better-auth/oauth-provider@1.7.0-rc.2(patch_hash=4af6cc2bd9af458b82bc39f183871b53ed9d9dce47021f662447da91ea17d5e9)(@better-auth/core@1.7.0-rc.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.0-rc.2(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.10.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260606.1)(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(kysely@0.28.17))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@4.1.4))(better-call@1.3.7(zod@4.4.3))':
dependencies:
'@better-auth/core': 1.7.0-rc.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
'@better-auth/utils': 0.4.2
+15 -3
View File
@@ -165,15 +165,28 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
const response = await c.get('auth').handler(c.req.raw)
if (c.req.method === 'HEAD' || !response.ok) return response
const metadata = (await response.json()) as Record<string, unknown>
const authOrigin = new URL((await c.get('auth').$context).baseURL).origin
return c.json({
...metadata,
authorization_details_catalog_endpoint: `${new URL(c.req.url).origin}/api/auth/oauth2/authorization-details/catalog`,
authorization_details_catalog_endpoint: `${authOrigin}/api/auth/oauth2/authorization-details/catalog`,
authorization_details_catalog_scope: AuthorizationScope.WORKSPACES_DISCOVER,
authorization_details_catalog_version: 1,
})
})
app.on(['GET', 'HEAD'], '/.well-known/openid-configuration/api/auth', async (c) => {
return c.get('auth').handler(c.req.raw)
const url = new URL(c.req.url)
url.pathname = '/api/auth/.well-known/openid-configuration'
const response = await c.get('auth').handler(new Request(url, c.req.raw))
if (c.req.method === 'HEAD' || !response.ok) return response
const metadata = (await response.json()) as Record<string, unknown>
const authOrigin = new URL((await c.get('auth').$context).baseURL).origin
return c.json({
...metadata,
authorization_details_catalog_endpoint: `${authOrigin}/api/auth/oauth2/authorization-details/catalog`,
authorization_details_catalog_scope: AuthorizationScope.WORKSPACES_DISCOVER,
authorization_details_catalog_version: 1,
})
})
app.on(['GET', 'HEAD'], '/.well-known/oauth-protected-resource/api', async (c) => {
@@ -307,7 +320,6 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
scopes: OAUTH_RESOURCE_SCOPES.join(' '),
},
},
params: { provider: 'realmroot-target' },
},
},
},
+58
View File
@@ -839,6 +839,7 @@ describe('OAuth consent guards', () => {
registration_endpoint: 'http://localhost:3000/api/auth/oauth2/register',
authorization_details_catalog_endpoint: 'http://localhost:3000/api/auth/oauth2/authorization-details/catalog',
authorization_details_catalog_scope: AuthorizationScope.WORKSPACES_DISCOVER,
authorization_details_catalog_version: 1,
grant_types_supported: expect.arrayContaining([
'urn:ietf:params:oauth:grant-type:jwt-bearer',
'urn:ietf:params:oauth:grant-type:token-exchange',
@@ -901,6 +902,63 @@ describe('OAuth consent guards', () => {
)
})
it('allows loopback HTTP JWKS metadata for local dynamic registration', async () => {
const ctx = await createTestApp()
const res = await ctx.app.request('/api/auth/oauth2/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_name: 'Local External Resource Broker',
redirect_uris: ['http://localhost:4179/api/account-connections/oauth/callback'],
grant_types: [
'authorization_code',
'refresh_token',
'urn:ietf:params:oauth:grant-type:jwt-bearer',
'urn:ietf:params:oauth:grant-type:token-exchange',
],
response_types: ['code'],
token_endpoint_auth_method: 'client_secret_basic',
scope: 'openid offline_access',
jwks_uri: 'http://localhost:4179/api/auth/jwks',
authorization_details_types: [WORKSPACE_AUTHORIZATION_DETAIL_TYPE],
}),
})
expect(res.status, await res.clone().text()).toBe(201)
})
it('rejects loopback HTTP JWKS metadata on a non-loopback server', async () => {
const ctx = await createTestApp()
const origin = 'https://drive.example.com'
const auth = await createAuth(ctx.platform, 'test-secret', origin, [origin])
const app = createApp(ctx.platform, auth)
const res = await app.request(`${origin}/api/auth/oauth2/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_name: 'Unsafe Loopback Broker',
redirect_uris: ['https://broker.example.com/api/account-connections/oauth/callback'],
grant_types: [
'authorization_code',
'refresh_token',
'urn:ietf:params:oauth:grant-type:jwt-bearer',
'urn:ietf:params:oauth:grant-type:token-exchange',
],
response_types: ['code'],
token_endpoint_auth_method: 'client_secret_basic',
scope: 'openid offline_access',
jwks_uri: 'http://localhost:4179/api/auth/jwks',
authorization_details_types: [WORKSPACE_AUTHORIZATION_DETAIL_TYPE],
}),
})
expect(res.status).toBe(400)
await expect(res.json()).resolves.toMatchObject({
error: 'invalid_client_metadata',
error_description: 'jwks_uri must use HTTPS',
})
})
it('reads, replaces, and deletes a dynamic client through its RFC 7592 configuration endpoint', async () => {
const ctx = await createTestApp()
const registration = await ctx.app.request('/api/auth/oauth2/register', {
@@ -281,7 +281,7 @@ async function updateClient(
if (metadata.token_endpoint_auth_method !== current.tokenEndpointAuthMethod) {
return invalidClientMetadata('token_endpoint_auth_method cannot be changed without rotating client credentials')
}
const validationError = validateMetadata(metadata)
const validationError = validateMetadata(metadata, url)
if (validationError) return invalidClientMetadata(validationError)
for (const resourceId of metadata.resources ?? []) {
if (!(await isOAuthResourceAvailable(db, resourceId))) {
@@ -334,7 +334,7 @@ async function updateClient(
return oauthJson(200, await clientInformation(db, updated, url, registrationToken))
}
function validateMetadata(metadata: z.infer<typeof updateSchema>): string | null {
function validateMetadata(metadata: z.infer<typeof updateSchema>, serverUrl: URL): string | null {
if (metadata.grant_types.some((grant) => !SUPPORTED_GRANTS.has(grant)))
return 'grant_types contains an unsupported grant type'
if (metadata.grant_types.includes('authorization_code')) {
@@ -351,10 +351,25 @@ function validateMetadata(metadata: z.infer<typeof updateSchema>): string | null
return 'authorization_details_types contains an unsupported type'
}
if (metadata.jwks && metadata.jwks_uri) return 'jwks and jwks_uri are mutually exclusive'
if (metadata.jwks_uri && new URL(metadata.jwks_uri).protocol !== 'https:') return 'jwks_uri must use HTTPS'
if (metadata.jwks_uri && !isSecureOrLocalDevelopmentUrl(metadata.jwks_uri, serverUrl)) {
return 'jwks_uri must use HTTPS'
}
return null
}
function isSecureOrLocalDevelopmentUrl(value: string, serverUrl: URL): boolean {
const url = new URL(value)
if (url.protocol === 'https:') return true
if (url.protocol !== 'http:') return false
return isLoopbackHostname(serverUrl.hostname) && isLoopbackHostname(url.hostname)
}
function isLoopbackHostname(hostname: string): boolean {
return (
hostname === 'localhost' || hostname.endsWith('.localhost') || hostname === '[::1]' || hostname.startsWith('127.')
)
}
async function clientInformation(
db: Database,
client: ManagedOAuthClient,
+14 -1
View File
@@ -58,13 +58,26 @@ describe('OAuth pushed authorization requests', () => {
[{ response_type: 'token' }, 'Only the authorization code response type is supported'],
[{ code_challenge: 'short' }, 'A valid S256 PKCE challenge is required'],
[{ authorization_details: 'not-json' }, 'Invalid workspace authorization details'],
[{ authorization_details: '[]' }, 'Exactly one workspace authorization request is required'],
[{ authorization_details: '[]' }, 'At least one workspace authorization request is required'],
])('rejects invalid pushed request parameters %#', async (overrides, message) => {
await expect(submit(pushedRequest(overrides))).rejects.toMatchObject({
body: expect.objectContaining({ error_description: message }),
})
})
it('accepts multiple fixed workspace authorization details', async () => {
const response = await submit(
pushedRequest({
authorization_details: JSON.stringify([
{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE, identifier: 'workspace-1' },
{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE, identifier: 'workspace-2' },
]),
}),
)
expect(response.status).toBe(201)
})
it('rejects clients without the authorization code grant', async () => {
getOAuthProviderApi.mockReturnValue({
getClient: vi.fn(async () => ({
+2 -2
View File
@@ -144,8 +144,8 @@ async function validatePushedAuthorizationRequest(
} catch {
throw oauthError('invalid_authorization_details', 'Invalid workspace authorization details')
}
if (details.length !== 1) {
throw oauthError('invalid_authorization_details', 'Exactly one workspace authorization request is required')
if (details.length === 0) {
throw oauthError('invalid_authorization_details', 'At least one workspace authorization request is required')
}
}
+1 -1
View File
@@ -264,7 +264,7 @@ async function verifyAgentAssertion(
? createRemoteJWKSet(new URL(client.jwksUri))
: null
if (!jwks) throw oauthError('invalid_client', 'Registered client has no JWKS')
const endpoint = ctx.request?.url ?? `${ctx.context.baseURL}${ctx.path ?? '/oauth2/token'}`
const endpoint = `${ctx.context.baseURL}${ctx.path ?? '/oauth2/token'}`
let verified: Awaited<ReturnType<typeof jwtVerify>>
try {
verified = await jwtVerify(assertion, jwks, { audience: endpoint, maxTokenAge: '5m' })
@@ -31,6 +31,15 @@ describe('OAuth authorization details catalog', () => {
display: { label: 'Build Team', metadata: { type: 'organization', role: 'editor' } },
},
],
pagination: { limit: 50, offset: 0, total: 2, hasMore: false, nextOffset: null },
})
const firstPage = await app.request('/api/auth/oauth2/authorization-details/catalog?limit=1&offset=0', {
headers: { Authorization: `Bearer ${token}` },
})
await expect(firstPage.json()).resolves.toMatchObject({
items: [{ authorizationDetail: { identifier: 'personal-1' } }],
pagination: { limit: 1, offset: 0, total: 2, hasMore: true, nextOffset: 1 },
})
await db.delete(authSchema.member).where(eq(authSchema.member.organizationId, 'team-1'))
+21 -3
View File
@@ -18,7 +18,17 @@ const catalogEntrySchema = z.object({
}),
})
const catalogSchema = z.object({ items: z.array(catalogEntrySchema) }).openapi('AuthorizationDetailsCatalog')
const paginationSchema = z.object({
limit: z.number().int().positive(),
offset: z.number().int().nonnegative(),
total: z.number().int().nonnegative(),
hasMore: z.boolean(),
nextOffset: z.number().int().nonnegative().nullable(),
})
const catalogSchema = z
.object({ items: z.array(catalogEntrySchema), pagination: paginationSchema })
.openapi('AuthorizationDetailsCatalog')
const catalogRoute = {
operationId: 'listAuthorizationDetailsCatalog',
@@ -29,6 +39,12 @@ const catalogRoute = {
method: 'get' as const,
path: '/',
security: [{ oauth2: [AuthorizationScope.WORKSPACES_DISCOVER] }],
request: {
query: z.object({
limit: z.coerce.number().int().min(1).max(100).default(50),
offset: z.coerce.number().int().min(0).default(0),
}),
},
responses: {
200: jsonContent(catalogSchema, 'Available workspace authorization details'),
401: errorResponse('Invalid or expired account access token'),
@@ -43,9 +59,11 @@ export const oauthAuthorizationDetails = new OpenAPIHono<Env>().openapi(catalogR
const auth = c.get('auth')
const authContext = await auth.$context
const items = await listOAuthAuthorizationDetailsCatalog(c.get('deps'), {
const query = c.req.valid('query')
const catalog = await listOAuthAuthorizationDetailsCatalog(c.get('deps'), {
db: c.get('platform').db,
token,
...query,
verifyJwtToken: async () =>
(
await jwtVerify(token, createLocalJWKSet(await auth.api.getJwks()), {
@@ -55,5 +73,5 @@ export const oauthAuthorizationDetails = new OpenAPIHono<Env>().openapi(catalogR
).payload,
})
c.header('Cache-Control', 'no-store')
return c.json({ items }, 200)
return c.json(catalog, 200)
})
+14 -4
View File
@@ -52,7 +52,9 @@ const matterSchema = z
type: z.string(),
size: z.number().int().nullable(),
dirtype: z.number().int().nullable(),
parent: z.string(),
parent: z
.string()
.describe('Slash-delimited parent folder path relative to the workspace root; empty for root objects.'),
object: z.string(),
storageId: z.string(),
status: z.string(),
@@ -93,7 +95,9 @@ export async function createCapacityRequestHash(orgId: string, input: unknown):
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('')
}
const objectListItemSchema = matterSchema.extend({ hasChildren: z.boolean() }).openapi('ObjectListItem')
const objectListItemSchema = matterSchema
.extend({ hasChildren: z.boolean().describe('Whether this folder contains at least one child folder.') })
.openapi('ObjectListItem')
type ObjectListItemDTO = z.infer<typeof objectListItemSchema>
function toObjectListItemDTO(item: MatterListItem): ObjectListItemDTO {
@@ -142,8 +146,14 @@ const objectWithDownloadSchema = matterSchema.extend({ downloadUrl: z.string().o
// overrides the shared pageSize cap of 100 with a higher ceiling — the rest of the
// API keeps the 100 default. Live objects only — the recycle bin is GET /trash/objects.
const listObjectsQuerySchema = cursorPageQuerySchema.extend({
parent: z.string().optional(),
path: z.string().optional(),
parent: z
.string()
.describe('Slash-delimited parent folder path relative to the workspace root; empty for the root.')
.optional(),
path: z
.string()
.describe('Alias for parent: the slash-delimited parent folder path relative to the workspace root.')
.optional(),
type: z.string().optional(),
search: z.string().optional(),
orgId: z.string().optional(),
+1 -1
View File
@@ -104,7 +104,7 @@ const restoreObjectRoute = authRoute(
)
const purgeObjectRoute = authRoute(
{ scopes: [AuthorizationScope.OBJECTS_PURGE], oauth: false, minTeamRole: 'editor' },
{ scopes: [AuthorizationScope.OBJECTS_PURGE], minTeamRole: 'editor' },
{
operationId: 'purgeTrashObject',
summary: 'Permanently delete trashed object',
+32 -12
View File
@@ -170,14 +170,15 @@ describe('global OpenAPI document', () => {
>
}
'x-cli-config'?: {
profiles?: {
default?: {
profiles?: Record<
string,
{
credentials?: Record<
string,
{ auth?: { params?: Record<string, unknown> }; params?: Record<string, unknown> }
>
}
}
>
}
}
@@ -209,11 +210,11 @@ describe('global OpenAPI document', () => {
scopes: expect.stringContaining(AuthorizationScope.OBJECTS_CREATE),
},
},
params: { provider: 'realmroot-target' },
})
expect(doc['x-cli-config']?.profiles?.default?.credentials?.oauth2.auth?.params?.scopes).not.toContain(
expect(doc['x-cli-config']?.profiles?.default?.credentials?.oauth2.auth?.params?.scopes).toContain(
AuthorizationScope.OBJECTS_PURGE,
)
expect(Object.keys(doc['x-cli-config']?.profiles ?? {})).toEqual(['default'])
})
it('publishes scopes through authorization-server metadata without a duplicate catalog endpoint', async () => {
@@ -270,16 +271,27 @@ describe('global OpenAPI document', () => {
expect(protectedHead.status).toBe(200)
})
it('serves HEAD for OAuth discovery and OpenID metadata endpoints', async () => {
it('serves RFC discovery paths for OAuth and OpenID metadata', async () => {
const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
const [authServer, openidConfig] = await Promise.all([
const [authServer, openidConfig, openidHead] = await Promise.all([
app.request('/.well-known/oauth-authorization-server/api/auth', { method: 'HEAD' }),
app.request('/.well-known/openid-configuration/api/auth'),
app.request('/.well-known/openid-configuration/api/auth', { method: 'HEAD' }),
])
expect(authServer.status).toBe(200)
expect(openidConfig.status).toBe(404)
expect(openidConfig.status).toBe(200)
expect(await openidConfig.json()).toMatchObject({
issuer: 'http://localhost:3000/api/auth',
authorization_endpoint: 'http://localhost:3000/api/auth/oauth2/authorize',
token_endpoint: 'http://localhost:3000/api/auth/oauth2/token',
authorization_details_catalog_endpoint: 'http://localhost:3000/api/auth/oauth2/authorization-details/catalog',
authorization_details_catalog_scope: AuthorizationScope.WORKSPACES_DISCOVER,
authorization_details_catalog_version: 1,
})
expect(openidHead.status).toBe(200)
expect(await authServer.text()).toBe('')
expect(await openidHead.text()).toBe('')
})
it('documents the workspace-scoped API-key event-stream authorization contract', async () => {
@@ -471,7 +483,7 @@ describe('global OpenAPI document', () => {
})
})
it('keeps purge scope separate from its non-OAuth credential policy', async () => {
it('authorizes permanent purge with its OAuth scope independently from the credential type', async () => {
const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
const res = await app.request('/api/openapi.json')
const doc = (await res.json()) as {
@@ -479,10 +491,13 @@ describe('global OpenAPI document', () => {
}
const operation = doc.paths['/api/trash/objects/{id}']?.delete
expect(operation?.security).toEqual([{ bearerAuth: [] }, { cookieAuth: [] }])
expect(operation?.security).toEqual([
{ oauth2: [AuthorizationScope.OBJECTS_PURGE] },
{ bearerAuth: [] },
{ cookieAuth: [] },
])
expect(operation?.['x-zpan-authorization-constraints']).toEqual({
requiredScopes: [AuthorizationScope.OBJECTS_PURGE],
oauth: false,
minTeamRole: 'editor',
})
})
@@ -563,7 +578,12 @@ describe('global OpenAPI document', () => {
name: expect.any(Object),
type: expect.any(Object),
size: expect.any(Object),
parent: expect.any(Object),
parent: {
description:
'Slash-delimited parent folder path relative to the workspace root; use an empty string for the root.',
default: '',
type: 'string',
},
onConflict: expect.any(Object),
storageId: {
description:
+14 -1
View File
@@ -12,6 +12,8 @@ export async function listOAuthAuthorizationDetailsCatalog(
input: {
db: Database
token: string
limit: number
offset: number
verifyJwtToken: () => Promise<JWTPayload>
},
) {
@@ -22,7 +24,7 @@ export async function listOAuthAuthorizationDetailsCatalog(
if (!account.scopes.includes(AuthorizationScope.WORKSPACES_DISCOVER)) throw forbidden('Forbidden')
const workspaces = await deps.org.listUserWorkspaceCatalog(account.userId)
return workspaces.map((workspace) => ({
const items = workspaces.slice(input.offset, input.offset + input.limit).map((workspace) => ({
authorizationDetail: {
type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE as typeof WORKSPACE_AUTHORIZATION_DETAIL_TYPE,
identifier: workspace.id,
@@ -32,6 +34,17 @@ export async function listOAuthAuthorizationDetailsCatalog(
metadata: { type: workspace.type, role: workspace.role },
},
}))
const nextOffset = input.offset + input.limit < workspaces.length ? input.offset + input.limit : null
return {
items,
pagination: {
limit: input.limit,
offset: input.offset,
total: workspaces.length,
hasMore: nextOffset !== null,
nextOffset,
},
}
}
async function resolveJwtAccountToken(deps: CatalogDeps, db: Database, verifyJwtToken: () => Promise<JWTPayload>) {
+44 -1
View File
@@ -139,6 +139,49 @@ describe('OAuth consent usecase', () => {
})
})
it('honors multiple workspace identifiers fixed by the client', async () => {
const orgRepo = org({
listUserOrgs: vi.fn(async () => [
{ id: 'org-1', name: 'Personal' },
{ id: 'org-2', name: 'Team' },
]),
})
await expect(
getOAuthConsentContext(deps(orgRepo), {
db,
userId: 'user-1',
oauthQuery: oauthQuery({
authorization_details: JSON.stringify([
{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE, identifier: 'org-1' },
{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE, identifier: 'org-2' },
]),
}),
}),
).resolves.toMatchObject({
workspaces: [
{ id: 'org-1', name: 'Personal' },
{ id: 'org-2', name: 'Team' },
],
requestedWorkspaceIds: ['org-1', 'org-2'],
})
})
it('rejects a fixed workspace set the user cannot fully access', async () => {
await expect(
getOAuthConsentContext(deps(org()), {
db,
userId: 'user-1',
oauthQuery: oauthQuery({
authorization_details: JSON.stringify([
{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE, identifier: 'org-1' },
{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE, identifier: 'org-2' },
]),
}),
}),
).rejects.toMatchObject({ httpStatus: 403 })
})
it('rejects requests that are not the managed authorization-code client flow', async () => {
await expect(
getOAuthConsentContext(deps(org()), {
@@ -162,7 +205,7 @@ describe('OAuth consent usecase', () => {
getOAuthConsentContext(deps(org()), {
db,
userId: 'user-1',
oauthQuery: oauthQuery({ scope: 'objects:purge' }),
oauthQuery: oauthQuery({ scope: 'objects:unknown' }),
}),
).rejects.toMatchObject({ httpStatus: 400 })
})
+14 -7
View File
@@ -51,20 +51,27 @@ export async function getOAuthConsentContext(
} catch {
throw badRequest('Invalid OAuth authorization details')
}
if (authorizationDetails.length !== 1) throw badRequest('Exactly one workspace authorization request is required')
const requestedWorkspaceId = authorizationDetails[0].identifier
if (authorizationDetails.length === 0) throw badRequest('At least one workspace authorization request is required')
const requestedWorkspaceIds = authorizationDetails.flatMap((detail) => (detail.identifier ? [detail.identifier] : []))
const requestedWorkspaceIdSet = new Set(requestedWorkspaceIds)
const availableWorkspaces = await deps.org.listUserOrgs(input.userId)
const workspaces = requestedWorkspaceId
? availableWorkspaces.filter((workspace) => workspace.id === requestedWorkspaceId)
: availableWorkspaces
if (workspaces.length === 0) throw forbidden('Workspace access is required for OAuth')
const workspaces =
requestedWorkspaceIds.length > 0
? availableWorkspaces.filter((workspace) => requestedWorkspaceIdSet.has(workspace.id))
: availableWorkspaces
if (
workspaces.length === 0 ||
(requestedWorkspaceIds.length > 0 && workspaces.length !== requestedWorkspaceIdSet.size)
) {
throw forbidden('Workspace access is required for OAuth')
}
return {
clientId,
clientName: client.clientName,
clientOrigin: new URL(redirectUri).origin,
workspaces: workspaces.map((workspace) => ({ id: workspace.id, name: workspace.name })),
requestedWorkspaceIds: requestedWorkspaceId ? [requestedWorkspaceId] : [],
requestedWorkspaceIds,
scopes,
standardScopes,
redirectUri,
+1 -1
View File
@@ -22,7 +22,7 @@ describe('authorization scope registry', () => {
it('keeps permanent object purge out of agent-grantable scopes', () => {
expect(CANONICAL_AUTHORIZATION_SCOPES).toContain(AuthorizationScope.OBJECTS_PURGE)
expect(OAUTH_RESOURCE_SCOPES).not.toContain(AuthorizationScope.OBJECTS_PURGE)
expect(OAUTH_RESOURCE_SCOPES).toContain(AuthorizationScope.OBJECTS_PURGE)
expect(scopePermissions([AuthorizationScope.OBJECTS_DELETE])).toEqual({ objects: ['delete'] })
})
+2 -1
View File
@@ -11,7 +11,7 @@ export const WORKSPACE_AUTHORIZATION_DETAIL_TYPE = 'https://zpan.space/authoriza
export const OAUTH_STANDARD_SCOPES = ['openid', 'profile', 'email', 'offline_access'] as const
export const OAUTH_ACCOUNT_SCOPES = [AuthorizationScope.WORKSPACES_DISCOVER] as const
export const OAUTH_RESOURCE_SCOPES = CANONICAL_AUTHORIZATION_SCOPES.filter(
(scope) => scope !== AuthorizationScope.OBJECTS_PURGE && !(OAUTH_ACCOUNT_SCOPES as readonly string[]).includes(scope),
(scope) => !(OAUTH_ACCOUNT_SCOPES as readonly string[]).includes(scope),
)
export const OAUTH_GRANT_SCOPES = [...OAUTH_ACCOUNT_SCOPES, ...OAUTH_RESOURCE_SCOPES] as const
export const OAUTH_SCOPES = [...OAUTH_STANDARD_SCOPES, ...OAUTH_GRANT_SCOPES] as const
@@ -20,6 +20,7 @@ const EXPLICIT_SCOPE_DESCRIPTIONS: Partial<Record<AuthorizationScope, string>> =
[AuthorizationScope.OBJECTS_CREATE]: 'Create folders and upload objects',
[AuthorizationScope.OBJECTS_UPDATE]: 'Rename, move, and copy objects',
[AuthorizationScope.OBJECTS_DELETE]: 'Soft-delete objects',
[AuthorizationScope.OBJECTS_PURGE]: 'Permanently delete trashed objects',
[AuthorizationScope.SHARES_READ]: 'List and inspect shares',
[AuthorizationScope.SHARES_CREATE]: 'Create public shares',
[AuthorizationScope.SHARES_DELETE]: 'Revoke shares',
+6 -2
View File
@@ -279,11 +279,15 @@ export const signUpSchema = z.object({
export const conflictStrategySchema = z.enum(['fail', 'rename', 'replace'])
export type ConflictStrategy = z.infer<typeof conflictStrategySchema>
const matterParentPathSchema = z
.string()
.describe('Slash-delimited parent folder path relative to the workspace root; use an empty string for the root.')
export const createMatterSchema = z.object({
name: z.string().min(1),
type: z.string().min(1).optional(),
size: z.number().int().min(0).optional(),
parent: z.string().default(''),
parent: matterParentPathSchema.default(''),
dirtype: z.number().int().default(0),
onConflict: conflictStrategySchema.optional(),
storageId: z
@@ -300,7 +304,7 @@ export type CreateMatterInput = z.infer<typeof createMatterSchema>
export const updateMatterSchema = z.object({
action: z.literal('update').optional().default('update'),
name: z.string().min(1).optional(),
parent: z.string().optional(),
parent: matterParentPathSchema.optional(),
onConflict: conflictStrategySchema.optional(),
})
+1
View File
@@ -13,6 +13,7 @@ const explicitOAuthResourceScopeLabels: Partial<Record<OAuthGrantScope, string>>
[AuthorizationScope.OBJECTS_CREATE]: 'settings.oauthApps.scope.objectsCreate',
[AuthorizationScope.OBJECTS_UPDATE]: 'settings.oauthApps.scope.objectsUpdate',
[AuthorizationScope.OBJECTS_DELETE]: 'settings.oauthApps.scope.objectsDelete',
[AuthorizationScope.OBJECTS_PURGE]: 'settings.oauthApps.scope.objectsPurge',
[AuthorizationScope.SHARES_READ]: 'settings.oauthApps.scope.sharesRead',
[AuthorizationScope.SHARES_CREATE]: 'settings.oauthApps.scope.sharesCreate',
[AuthorizationScope.SHARES_DELETE]: 'settings.oauthApps.scope.sharesDelete',
+1
View File
@@ -1213,6 +1213,7 @@
"settings.oauthApps.scope.objectsCreate": "Files: create objects",
"settings.oauthApps.scope.objectsUpdate": "Files: update objects",
"settings.oauthApps.scope.objectsDelete": "Files: delete objects",
"settings.oauthApps.scope.objectsPurge": "Files: permanently delete trashed objects",
"settings.oauthApps.scope.sharesRead": "Shares: read shares",
"settings.oauthApps.scope.sharesCreate": "Shares: create shares",
"settings.oauthApps.scope.sharesDelete": "Shares: revoke shares",
+1
View File
@@ -1213,6 +1213,7 @@
"settings.oauthApps.scope.objectsCreate": "文件:创建对象",
"settings.oauthApps.scope.objectsUpdate": "文件:更新对象",
"settings.oauthApps.scope.objectsDelete": "文件:删除对象",
"settings.oauthApps.scope.objectsPurge": "文件:永久删除回收站对象",
"settings.oauthApps.scope.sharesRead": "分享:读取分享",
"settings.oauthApps.scope.sharesCreate": "分享:创建分享",
"settings.oauthApps.scope.sharesDelete": "分享:撤销分享",
+5 -1
View File
@@ -68,7 +68,11 @@ export default defineConfig(({ mode }) => ({
proxy: {
'/api': {
target: `http://localhost:${apiPort}`,
changeOrigin: true,
changeOrigin: false,
},
'/.well-known': {
target: `http://localhost:${apiPort}`,
changeOrigin: false,
},
},
}